feat(agents): add 7 specialist agents with tools and system prompts

Agents (all following 6-step contract: _plan/_gather/_reason/_act/_report):
- AccountingAgent: trial balance, chart of accounts, tax summary (HIPAA-locked)
- CrmAgent: pipeline summary, lead/opportunity management, won/lost analysis
- SalesAgent: sales orders, quotations, revenue by rep, expired quote detection
- ProjectAgent: task tracking, blocked/overdue detection, timesheet logging
- ElearningAgent: course completion, low-engagement flagging, next-course suggestion
- ExpensesAgent: expense sheets, pending approvals, policy violations (HIPAA-locked)
- EmployeesAgent: headcount, contracts, leaves, attendance, expired contract sweep (HIPAA-locked)

Tools (one file per domain):
- accounting_tools.py, crm_tools.py, sales_tools.py, project_tools.py
- elearning_tools.py, expenses_tools.py, employees_tools.py

System prompts: each agent has a domain-specific system.txt with rules and output format

All agents implement handle_peer_request() and sweep() for proactive monitoring
HIPAA-locked agents (accounting, expenses, employees) enforced via LLMRouter

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ActiveBlue Build
2026-04-12 18:04:32 -04:00
parent 29409ed71d
commit fe47f950e4
21 changed files with 1761 additions and 0 deletions

View File

@@ -0,0 +1,147 @@
from __future__ import annotations
import logging
from .base_agent import BaseAgent, AgentReport, AgentDirective, SweepReport
from ..tools.accounting_tools import AccountingTools
logger = logging.getLogger(__name__)
ACCOUNTING_TOOLS = [
{'name': 'get_journal_entries', 'description': 'Retrieve journal entries',
'parameters': {'journal_id': {'type': 'integer', 'optional': True},
'date_from': {'type': 'string', 'optional': True},
'date_to': {'type': 'string', 'optional': True},
'state': {'type': 'string', 'optional': True},
'limit': {'type': 'integer', 'optional': True}}},
{'name': 'get_chart_of_accounts', 'description': 'Get chart of accounts',
'parameters': {'account_type': {'type': 'string', 'optional': True},
'limit': {'type': 'integer', 'optional': True}}},
{'name': 'get_account_balance', 'description': 'Get balance for a specific account',
'parameters': {'account_id': {'type': 'integer'}}},
{'name': 'get_trial_balance', 'description': 'Get trial balance for a period',
'parameters': {'date_from': {'type': 'string', 'optional': True},
'date_to': {'type': 'string', 'optional': True}}},
{'name': 'get_tax_summary', 'description': 'Get tax summary for a period',
'parameters': {'date_from': {'type': 'string', 'optional': True},
'date_to': {'type': 'string', 'optional': True}}},
{'name': 'flag_for_review', 'description': 'Flag a journal entry for review',
'parameters': {'model': {'type': 'string'}, 'record_id': {'type': 'integer'},
'reason': {'type': 'string'},
'severity': {'type': 'string', 'optional': True}}},
{'name': 'post_chatter_note', 'description': 'Post a note on a record',
'parameters': {'model': {'type': 'string'}, 'record_id': {'type': 'integer'},
'note': {'type': 'string'}}},
]
class AccountingAgent(BaseAgent):
name = 'accounting_agent'
domain = 'accounting'
required_odoo_module = 'account'
system_prompt_file = 'accounting_system.txt'
tools = ACCOUNTING_TOOLS
def __init__(self, odoo, llm, peer_bus=None):
super().__init__(odoo, llm, peer_bus)
self._at = AccountingTools(odoo)
self._gathered_data = {}
self._actions_taken = []
self._escalations_list = []
async def _plan(self, directive: AgentDirective) -> dict:
intent = (directive.intent or '').lower()
return {
'fetch_trial_balance': any(k in intent for k in ('trial', 'balance', 'report')),
'fetch_tax': any(k in intent for k in ('tax', 'vat', 'gst')),
'fetch_entries': any(k in intent for k in ('journal', 'entry', 'entries')),
'date_from': directive.context.get('date_from'),
'date_to': directive.context.get('date_to'),
}
async def _gather(self, ctx: dict) -> dict:
plan = ctx.get('plan', {})
data: dict = {}
if plan.get('fetch_trial_balance'):
data['trial_balance'] = await self._at.get_trial_balance(
date_from=plan.get('date_from'), date_to=plan.get('date_to'),
)
if plan.get('fetch_tax'):
data['tax_summary'] = await self._at.get_tax_summary(
date_from=plan.get('date_from'), date_to=plan.get('date_to'),
)
if plan.get('fetch_entries') or not data:
data['entries'] = await self._at.get_journal_entries(limit=20)
self._gathered_data = data
return data
async def _reason(self, ctx: dict) -> dict:
data = self._gathered_data
analysis: dict = {'flags': [], 'escalations': []}
trial = data.get('trial_balance', [])
for account in trial:
bal = account.get('balance', 0)
if abs(bal) > 100000:
analysis['flags'].append({'account': account.get('account_name'), 'balance': bal})
self._escalations_list = analysis.get('escalations', [])
return analysis
async def _act(self, ctx: dict) -> list:
return []
async def _report(self, ctx: dict) -> AgentReport:
data = self._gathered_data
parts = []
trial = data.get('trial_balance', [])
if trial:
parts.append(f'Trial balance: {len(trial)} accounts.')
tax = data.get('tax_summary', {})
if tax:
parts.append(f'Tax: {tax.get("total_tax_amount", 0):.2f} in {tax.get("total_tax_lines", 0)} lines.')
if not parts:
parts.append('Accounting review complete.')
return AgentReport(
agent=self.name, summary=chr(10).join(parts),
data=data, escalations=self._escalations_list, actions_taken=[],
)
async def _dispatch_tool(self, name: str, args: dict):
if name == 'get_journal_entries':
return await self._at.get_journal_entries(**args)
if name == 'get_chart_of_accounts':
return await self._at.get_chart_of_accounts(**args)
if name == 'get_account_balance':
return await self._at.get_account_balance(**args)
if name == 'get_trial_balance':
return await self._at.get_trial_balance(**args)
if name == 'get_tax_summary':
return await self._at.get_tax_summary(**args)
if name == 'flag_for_review':
return await self._at.flag_for_review(**args)
if name == 'post_chatter_note':
return await self._at.post_chatter_note(**args)
raise ValueError(f'Unknown tool: {name}')
async def handle_peer_request(self, request: dict) -> dict:
req_type = request.get('type', '')
try:
if req_type == 'trial_balance':
return {'trial_balance': await self._at.get_trial_balance()}
if req_type == 'account_balance':
return await self._at.get_account_balance(account_id=request['account_id'])
if req_type == 'tax_summary':
return await self._at.get_tax_summary()
return {'error': f'Unknown type: {req_type}'}
except Exception as exc:
return {'error': str(exc)}
async def sweep(self) -> SweepReport:
findings = []
try:
trial = await self._at.get_trial_balance()
for account in trial:
if abs(account.get('balance', 0)) > 500000:
findings.append({'type': 'large_balance', 'account': account.get('account_name'),
'balance': account.get('balance', 0), 'severity': 'high'})
except Exception as exc:
return SweepReport(agent=self.name, findings=[], actions=[], error=str(exc))
return SweepReport(agent=self.name, findings=findings, actions=[],
summary=f'Sweep: {len(findings)} large balance accounts found.')

View File

@@ -0,0 +1,139 @@
from __future__ import annotations
import logging
from .base_agent import BaseAgent, AgentReport, AgentDirective, SweepReport
from ..tools.crm_tools import CrmTools
logger = logging.getLogger(__name__)
CRM_TOOLS = [
{'name': 'get_leads', 'description': 'Get CRM leads',
'parameters': {'stage_id': {'type': 'integer', 'optional': True},
'user_id': {'type': 'integer', 'optional': True},
'limit': {'type': 'integer', 'optional': True}}},
{'name': 'get_opportunities', 'description': 'Get CRM opportunities',
'parameters': {'stage_id': {'type': 'integer', 'optional': True},
'user_id': {'type': 'integer', 'optional': True},
'limit': {'type': 'integer', 'optional': True}}},
{'name': 'get_pipeline_summary', 'description': 'Get pipeline summary by stage', 'parameters': {}},
{'name': 'update_lead_stage', 'description': 'Move a lead/opp to a new stage',
'parameters': {'lead_id': {'type': 'integer'}, 'stage_id': {'type': 'integer'}}},
{'name': 'assign_lead', 'description': 'Assign lead to a salesperson',
'parameters': {'lead_id': {'type': 'integer'}, 'user_id': {'type': 'integer'}}},
{'name': 'log_activity', 'description': 'Log a CRM activity',
'parameters': {'lead_id': {'type': 'integer'}, 'activity_type': {'type': 'string'},
'note': {'type': 'string'},
'date_deadline': {'type': 'string', 'optional': True}}},
{'name': 'get_won_lost_analysis', 'description': 'Get won/lost opportunity analysis',
'parameters': {'date_from': {'type': 'string', 'optional': True},
'date_to': {'type': 'string', 'optional': True}}},
{'name': 'post_chatter_note', 'description': 'Post a note on a record',
'parameters': {'model': {'type': 'string'}, 'record_id': {'type': 'integer'},
'note': {'type': 'string'}}},
]
class CrmAgent(BaseAgent):
name = 'crm_agent'
domain = 'crm'
required_odoo_module = 'crm'
system_prompt_file = 'crm_system.txt'
tools = CRM_TOOLS
def __init__(self, odoo, llm, peer_bus=None):
super().__init__(odoo, llm, peer_bus)
self._ct = CrmTools(odoo)
self._gathered_data = {}
self._actions_taken = []
self._escalations_list = []
async def _plan(self, directive: AgentDirective) -> dict:
intent = (directive.intent or '').lower()
return {
'fetch_pipeline': any(k in intent for k in ('pipeline', 'summary', 'overview')),
'fetch_leads': 'lead' in intent,
'fetch_opportunities': any(k in intent for k in ('opportunit', 'deal')),
'fetch_won_lost': any(k in intent for k in ('won', 'lost', 'win rate')),
'user_id': directive.context.get('user_id'),
}
async def _gather(self, ctx: dict) -> dict:
plan = ctx.get('plan', {})
data: dict = {}
if plan.get('fetch_pipeline') or not any([plan.get('fetch_leads'), plan.get('fetch_opportunities')]):
data['pipeline'] = await self._ct.get_pipeline_summary()
if plan.get('fetch_leads'):
data['leads'] = await self._ct.get_leads(user_id=plan.get('user_id'), limit=20)
if plan.get('fetch_opportunities'):
data['opportunities'] = await self._ct.get_opportunities(user_id=plan.get('user_id'), limit=20)
if plan.get('fetch_won_lost'):
data['won_lost'] = await self._ct.get_won_lost_analysis()
self._gathered_data = data
return data
async def _reason(self, ctx: dict) -> dict:
data = self._gathered_data
analysis: dict = {'escalations': [], 'stale_leads': []}
pipeline = data.get('pipeline', {})
if pipeline:
weighted = pipeline.get('weighted_pipeline', 0)
if weighted < 10000:
analysis['escalations'].append(f'Low weighted pipeline: {weighted:.2f}')
self._escalations_list = analysis['escalations']
return analysis
async def _act(self, ctx: dict) -> list:
return []
async def _report(self, ctx: dict) -> AgentReport:
data = self._gathered_data
parts = []
pipeline = data.get('pipeline', {})
if pipeline:
total = pipeline.get('total_opportunities', 0)
value = pipeline.get('weighted_pipeline', 0)
parts.append(f'Pipeline: {total} opportunities, weighted value {value:.2f}.')
won_lost = data.get('won_lost', {})
if won_lost:
parts.append(f'Won: {won_lost.get("won_count", 0)}, Lost: {won_lost.get("lost_count", 0)}.')
if not parts:
parts.append('CRM review complete.')
return AgentReport(agent=self.name, summary=chr(10).join(parts),
data=data, escalations=self._escalations_list, actions_taken=[])
async def _dispatch_tool(self, name: str, args: dict):
dispatch = {
'get_leads': self._ct.get_leads,
'get_opportunities': self._ct.get_opportunities,
'get_pipeline_summary': self._ct.get_pipeline_summary,
'update_lead_stage': self._ct.update_lead_stage,
'assign_lead': self._ct.assign_lead,
'log_activity': self._ct.log_activity,
'get_won_lost_analysis': self._ct.get_won_lost_analysis,
'post_chatter_note': self._ct.post_chatter_note,
}
if name not in dispatch:
raise ValueError(f'Unknown tool: {name}')
return await dispatch[name](**args)
async def handle_peer_request(self, request: dict) -> dict:
req_type = request.get('type', '')
try:
if req_type == 'pipeline_summary':
return await self._ct.get_pipeline_summary()
if req_type == 'opportunities':
return {'opportunities': await self._ct.get_opportunities(user_id=request.get('user_id'))}
return {'error': f'Unknown type: {req_type}'}
except Exception as exc:
return {'error': str(exc)}
async def sweep(self) -> SweepReport:
findings = []
try:
pipeline = await self._ct.get_pipeline_summary()
if pipeline.get('weighted_pipeline', 0) < 5000:
findings.append({'type': 'low_pipeline', 'severity': 'medium',
'weighted': pipeline.get('weighted_pipeline', 0)})
except Exception as exc:
return SweepReport(agent=self.name, findings=[], actions=[], error=str(exc))
return SweepReport(agent=self.name, findings=findings, actions=[],
summary=f'CRM sweep: {len(findings)} findings.')

View File

@@ -0,0 +1,144 @@
from __future__ import annotations
import logging
from .base_agent import BaseAgent, AgentReport, AgentDirective, SweepReport
from ..tools.elearning_tools import ElearningTools
logger = logging.getLogger(__name__)
ELEARNING_TOOLS = [
{'name': 'get_courses', 'description': 'List eLearning courses',
'parameters': {'active': {'type': 'boolean', 'optional': True},
'limit': {'type': 'integer', 'optional': True}}},
{'name': 'get_course_stats', 'description': 'Get detailed stats for a course',
'parameters': {'channel_id': {'type': 'integer'}}},
{'name': 'get_enrolled_users', 'description': 'Get users enrolled in a course',
'parameters': {'channel_id': {'type': 'integer'},
'limit': {'type': 'integer', 'optional': True}}},
{'name': 'get_slide_completion', 'description': 'Get slide completion by user',
'parameters': {'channel_id': {'type': 'integer'},
'min_completion': {'type': 'number', 'optional': True}}},
{'name': 'get_learning_summary', 'description': 'Get overall learning summary', 'parameters': {}},
{'name': 'flag_low_completion', 'description': 'Flag a course with low completion',
'parameters': {'channel_id': {'type': 'integer'}, 'reason': {'type': 'string'}}},
{'name': 'suggest_next_course', 'description': 'Suggest next course for a learner',
'parameters': {'partner_id': {'type': 'integer'}}},
{'name': 'post_chatter_note', 'description': 'Post a note on a record',
'parameters': {'model': {'type': 'string'}, 'record_id': {'type': 'integer'},
'note': {'type': 'string'}}},
]
class ElearningAgent(BaseAgent):
name = 'elearning_agent'
domain = 'elearning'
required_odoo_module = 'website_slides'
system_prompt_file = 'elearning_system.txt'
tools = ELEARNING_TOOLS
def __init__(self, odoo, llm, peer_bus=None):
super().__init__(odoo, llm, peer_bus)
self._el = ElearningTools(odoo)
self._gathered_data = {}
self._actions_taken = []
self._escalations_list = []
async def _plan(self, directive: AgentDirective) -> dict:
intent = (directive.intent or '').lower()
return {
'fetch_summary': any(k in intent for k in ('summary', 'overview', 'learning')),
'fetch_courses': 'course' in intent,
'channel_id': directive.context.get('channel_id'),
'partner_id': directive.context.get('partner_id'),
}
async def _gather(self, ctx: dict) -> dict:
plan = ctx.get('plan', {})
data: dict = {}
data['summary'] = await self._el.get_learning_summary()
if plan.get('fetch_courses') or plan.get('channel_id'):
if plan.get('channel_id'):
data['course_stats'] = await self._el.get_course_stats(channel_id=plan['channel_id'])
else:
data['courses'] = await self._el.get_courses(limit=20)
self._gathered_data = data
return data
async def _reason(self, ctx: dict) -> dict:
data = self._gathered_data
analysis: dict = {'escalations': [], 'low_completion': []}
summary = data.get('summary', {})
low = summary.get('low_completion_courses', [])
analysis['low_completion'] = low
if len(low) > 3:
analysis['escalations'].append(f'{len(low)} courses have <30% completion rate.')
self._escalations_list = analysis['escalations']
return analysis
async def _act(self, ctx: dict) -> list:
actions = []
analysis = ctx.get('analysis', {})
for course in analysis.get('low_completion', [])[:3]:
try:
await self._el.flag_low_completion(
channel_id=course.get('id'),
reason=f'Completion rate {course.get("completion_rate", 0):.1f}% is below 30% threshold',
)
actions.append({'action': 'flag_low_completion', 'course_id': course.get('id'), 'success': True})
except Exception as exc:
logger.warning('flag_low_completion failed: %s', exc)
self._actions_taken = actions
return actions
async def _report(self, ctx: dict) -> AgentReport:
data = self._gathered_data
summary = data.get('summary', {})
parts = []
if summary:
parts.append(
f'eLearning: {summary.get("total_courses", 0)} courses, '
f'{summary.get("total_enrollments", 0)} enrollments, '
f'{summary.get("avg_completion", 0):.1f}% avg completion.'
)
if not parts:
parts.append('eLearning review complete.')
return AgentReport(agent=self.name, summary=chr(10).join(parts),
data=data, escalations=self._escalations_list, actions_taken=self._actions_taken)
async def _dispatch_tool(self, name: str, args: dict):
dispatch = {
'get_courses': self._el.get_courses,
'get_course_stats': self._el.get_course_stats,
'get_enrolled_users': self._el.get_enrolled_users,
'get_slide_completion': self._el.get_slide_completion,
'get_learning_summary': self._el.get_learning_summary,
'flag_low_completion': self._el.flag_low_completion,
'suggest_next_course': self._el.suggest_next_course,
'post_chatter_note': self._el.post_chatter_note,
}
if name not in dispatch:
raise ValueError(f'Unknown tool: {name}')
return await dispatch[name](**args)
async def handle_peer_request(self, request: dict) -> dict:
req_type = request.get('type', '')
try:
if req_type == 'learning_summary':
return await self._el.get_learning_summary()
if req_type == 'suggest_courses':
return {'courses': await self._el.suggest_next_course(partner_id=request['partner_id'])}
return {'error': f'Unknown type: {req_type}'}
except Exception as exc:
return {'error': str(exc)}
async def sweep(self) -> SweepReport:
findings = []
try:
summary = await self._el.get_learning_summary()
for course in summary.get('low_completion_courses', []):
findings.append({'type': 'low_completion', 'course_id': course.get('id'),
'name': course.get('name'), 'completion': course.get('completion_rate', 0),
'severity': 'medium'})
except Exception as exc:
return SweepReport(agent=self.name, findings=[], actions=[], error=str(exc))
return SweepReport(agent=self.name, findings=findings, actions=[],
summary=f'eLearning sweep: {len(findings)} low-completion courses.')

View File

@@ -0,0 +1,158 @@
from __future__ import annotations
import logging
from .base_agent import BaseAgent, AgentReport, AgentDirective, SweepReport
from ..tools.employees_tools import EmployeesTools
logger = logging.getLogger(__name__)
EMPLOYEES_TOOLS = [
{'name': 'get_employees', 'description': 'List employees',
'parameters': {'department_id': {'type': 'integer', 'optional': True},
'active': {'type': 'boolean', 'optional': True},
'limit': {'type': 'integer', 'optional': True}}},
{'name': 'get_employee_profile', 'description': 'Get detailed profile for one employee',
'parameters': {'employee_id': {'type': 'integer'}}},
{'name': 'get_leaves', 'description': 'Get leave requests',
'parameters': {'employee_id': {'type': 'integer', 'optional': True},
'state': {'type': 'string', 'optional': True},
'date_from': {'type': 'string', 'optional': True},
'limit': {'type': 'integer', 'optional': True}}},
{'name': 'get_contracts', 'description': 'Get employee contracts',
'parameters': {'employee_id': {'type': 'integer', 'optional': True},
'state': {'type': 'string', 'optional': True},
'limit': {'type': 'integer', 'optional': True}}},
{'name': 'get_attendance_summary', 'description': 'Get attendance summary for an employee',
'parameters': {'employee_id': {'type': 'integer'},
'date_from': {'type': 'string'},
'date_to': {'type': 'string'}}},
{'name': 'get_department_summary', 'description': 'Get headcount and contract summary for a department',
'parameters': {'department_id': {'type': 'integer'}}},
{'name': 'flag_for_review', 'description': 'Flag a record for review',
'parameters': {'model': {'type': 'string'}, 'record_id': {'type': 'integer'},
'reason': {'type': 'string'},
'severity': {'type': 'string', 'optional': True}}},
{'name': 'post_chatter_note', 'description': 'Post a note on a record',
'parameters': {'model': {'type': 'string'}, 'record_id': {'type': 'integer'},
'note': {'type': 'string'}}},
]
class EmployeesAgent(BaseAgent):
name = 'employees_agent'
domain = 'employees'
required_odoo_module = 'hr'
system_prompt_file = 'employees_system.txt'
tools = EMPLOYEES_TOOLS
def __init__(self, odoo, llm, peer_bus=None):
super().__init__(odoo, llm, peer_bus)
self._ht = EmployeesTools(odoo)
self._gathered_data = {}
self._actions_taken = []
self._escalations_list = []
async def _plan(self, directive: AgentDirective) -> dict:
intent = (directive.intent or '').lower()
return {
'fetch_employees': any(k in intent for k in ('employee', 'headcount', 'staff')),
'fetch_leaves': any(k in intent for k in ('leave', 'absence', 'holiday', 'vacation')),
'fetch_contracts': 'contract' in intent,
'department_id': directive.context.get('department_id'),
'employee_id': directive.context.get('employee_id'),
}
async def _gather(self, ctx: dict) -> dict:
plan = ctx.get('plan', {})
data: dict = {}
if plan.get('fetch_employees') or not any([plan.get('fetch_leaves'), plan.get('fetch_contracts')]):
if plan.get('department_id'):
data['dept_summary'] = await self._ht.get_department_summary(plan['department_id'])
else:
data['employees'] = await self._ht.get_employees(limit=50)
if plan.get('fetch_leaves'):
data['leaves'] = await self._ht.get_leaves(
employee_id=plan.get('employee_id'), state='validate1', limit=20,
)
if plan.get('fetch_contracts'):
data['contracts'] = await self._ht.get_contracts(
employee_id=plan.get('employee_id'), limit=20,
)
self._gathered_data = data
return data
async def _reason(self, ctx: dict) -> dict:
data = self._gathered_data
analysis: dict = {'escalations': []}
contracts = data.get('contracts', [])
import datetime
today = str(datetime.date.today())
expiring = [c for c in contracts if c.get('date_end') and c['date_end'] < today]
if expiring:
analysis['escalations'].append(f'{len(expiring)} contracts have expired.')
self._escalations_list = analysis['escalations']
return analysis
async def _act(self, ctx: dict) -> list:
return []
async def _report(self, ctx: dict) -> AgentReport:
data = self._gathered_data
parts = []
employees = data.get('employees', [])
if employees:
parts.append(f'Employees: {len(employees)} active.')
dept = data.get('dept_summary', {})
if dept:
parts.append(f'Department headcount: {dept.get("headcount", 0)}, avg wage: {dept.get("avg_wage", 0):.2f}.')
leaves = data.get('leaves', [])
if leaves:
parts.append(f'Pending leave approvals: {len(leaves)}.')
if not parts:
parts.append('HR review complete.')
return AgentReport(agent=self.name, summary=chr(10).join(parts),
data=data, escalations=self._escalations_list, actions_taken=[])
async def _dispatch_tool(self, name: str, args: dict):
dispatch = {
'get_employees': self._ht.get_employees,
'get_employee_profile': self._ht.get_employee_profile,
'get_leaves': self._ht.get_leaves,
'get_contracts': self._ht.get_contracts,
'get_attendance_summary': self._ht.get_attendance_summary,
'get_department_summary': self._ht.get_department_summary,
'flag_for_review': self._ht.flag_for_review,
'post_chatter_note': self._ht.post_chatter_note,
}
if name not in dispatch:
raise ValueError(f'Unknown tool: {name}')
return await dispatch[name](**args)
async def handle_peer_request(self, request: dict) -> dict:
req_type = request.get('type', '')
try:
if req_type == 'employee_list':
return {'employees': await self._ht.get_employees(department_id=request.get('department_id'))}
if req_type == 'employee_profile':
return await self._ht.get_employee_profile(employee_id=request['employee_id'])
if req_type == 'headcount':
employees = await self._ht.get_employees(department_id=request.get('department_id'))
return {'headcount': len(employees)}
return {'error': f'Unknown type: {req_type}'}
except Exception as exc:
return {'error': str(exc)}
async def sweep(self) -> SweepReport:
findings = []
try:
import datetime
today = str(datetime.date.today())
contracts = await self._ht.get_contracts(state='open', limit=200)
for c in contracts:
if c.get('date_end') and c['date_end'] < today:
findings.append({'type': 'expired_contract', 'contract_id': c.get('id'),
'employee': c.get('employee_id', [0, ''])[1] if isinstance(c.get('employee_id'), list) else '',
'expired': c.get('date_end'), 'severity': 'high'})
except Exception as exc:
return SweepReport(agent=self.name, findings=[], actions=[], error=str(exc))
return SweepReport(agent=self.name, findings=findings, actions=[],
summary=f'HR sweep: {len(findings)} expired contracts found.')

View File

@@ -0,0 +1,140 @@
from __future__ import annotations
import logging
from .base_agent import BaseAgent, AgentReport, AgentDirective, SweepReport
from ..tools.expenses_tools import ExpensesTools
logger = logging.getLogger(__name__)
EXPENSES_TOOLS = [
{'name': 'get_expenses', 'description': 'Retrieve expense records',
'parameters': {'employee_id': {'type': 'integer', 'optional': True},
'state': {'type': 'string', 'optional': True},
'date_from': {'type': 'string', 'optional': True},
'date_to': {'type': 'string', 'optional': True},
'limit': {'type': 'integer', 'optional': True}}},
{'name': 'get_expense_sheets', 'description': 'Get expense report sheets',
'parameters': {'state': {'type': 'string', 'optional': True},
'employee_id': {'type': 'integer', 'optional': True},
'limit': {'type': 'integer', 'optional': True}}},
{'name': 'get_pending_approvals', 'description': 'Get expense sheets pending approval',
'parameters': {}},
{'name': 'approve_expense_sheet', 'description': 'Approve an expense sheet',
'parameters': {'sheet_id': {'type': 'integer'}}},
{'name': 'get_expenses_summary', 'description': 'Get expense summary for a period',
'parameters': {'date_from': {'type': 'string', 'optional': True},
'date_to': {'type': 'string', 'optional': True}}},
{'name': 'get_expense_by_employee', 'description': 'Get expenses for a specific employee',
'parameters': {'employee_id': {'type': 'integer'},
'limit': {'type': 'integer', 'optional': True}}},
{'name': 'flag_for_review', 'description': 'Flag an expense for review',
'parameters': {'model': {'type': 'string'}, 'record_id': {'type': 'integer'},
'reason': {'type': 'string'},
'severity': {'type': 'string', 'optional': True}}},
{'name': 'post_chatter_note', 'description': 'Post a note on a record',
'parameters': {'model': {'type': 'string'}, 'record_id': {'type': 'integer'},
'note': {'type': 'string'}}},
]
class ExpensesAgent(BaseAgent):
name = 'expenses_agent'
domain = 'expenses'
required_odoo_module = 'hr_expense'
system_prompt_file = 'expenses_system.txt'
tools = EXPENSES_TOOLS
def __init__(self, odoo, llm, peer_bus=None):
super().__init__(odoo, llm, peer_bus)
self._et = ExpensesTools(odoo)
self._gathered_data = {}
self._actions_taken = []
self._escalations_list = []
async def _plan(self, directive: AgentDirective) -> dict:
intent = (directive.intent or '').lower()
return {
'fetch_summary': any(k in intent for k in ('summary', 'overview', 'report')),
'fetch_pending': any(k in intent for k in ('pending', 'approve', 'approval')),
'employee_id': directive.context.get('employee_id'),
'date_from': directive.context.get('date_from'),
'date_to': directive.context.get('date_to'),
}
async def _gather(self, ctx: dict) -> dict:
plan = ctx.get('plan', {})
data: dict = {}
data['summary'] = await self._et.get_expenses_summary(
date_from=plan.get('date_from'), date_to=plan.get('date_to'),
)
if plan.get('fetch_pending'):
data['pending'] = await self._et.get_pending_approvals()
self._gathered_data = data
return data
async def _reason(self, ctx: dict) -> dict:
data = self._gathered_data
analysis: dict = {'escalations': [], 'flags': []}
summary = data.get('summary', {})
if summary.get('pending_approval_count', 0) > 10:
analysis['escalations'].append(
f'{summary["pending_approval_count"]} expense sheets pending approval.'
)
self._escalations_list = analysis['escalations']
return analysis
async def _act(self, ctx: dict) -> list:
return []
async def _report(self, ctx: dict) -> AgentReport:
data = self._gathered_data
summary = data.get('summary', {})
parts = []
if summary:
parts.append(
f'Expenses: {summary.get("total_expenses", 0)} records, '
f'total {summary.get("total_amount", 0):.2f}. '
f'{summary.get("pending_approval_count", 0)} pending approval.'
)
if not parts:
parts.append('Expenses review complete.')
return AgentReport(agent=self.name, summary=chr(10).join(parts),
data=data, escalations=self._escalations_list, actions_taken=[])
async def _dispatch_tool(self, name: str, args: dict):
dispatch = {
'get_expenses': self._et.get_expenses,
'get_expense_sheets': self._et.get_expense_sheets,
'get_pending_approvals': self._et.get_pending_approvals,
'approve_expense_sheet': self._et.approve_expense_sheet,
'get_expenses_summary': self._et.get_expenses_summary,
'get_expense_by_employee': self._et.get_expense_by_employee,
'flag_for_review': self._et.flag_for_review,
'post_chatter_note': self._et.post_chatter_note,
}
if name not in dispatch:
raise ValueError(f'Unknown tool: {name}')
return await dispatch[name](**args)
async def handle_peer_request(self, request: dict) -> dict:
req_type = request.get('type', '')
try:
if req_type == 'expenses_summary':
return await self._et.get_expenses_summary()
if req_type == 'employee_expenses':
return {'expenses': await self._et.get_expense_by_employee(employee_id=request['employee_id'])}
return {'error': f'Unknown type: {req_type}'}
except Exception as exc:
return {'error': str(exc)}
async def sweep(self) -> SweepReport:
findings = []
try:
pending = await self._et.get_pending_approvals()
for sheet in pending:
findings.append({'type': 'pending_expense_approval', 'sheet_id': sheet.get('id'),
'employee': sheet.get('employee_id', [0, ''])[1] if isinstance(sheet.get('employee_id'), list) else '',
'amount': sheet.get('total_amount', 0), 'severity': 'low'})
except Exception as exc:
return SweepReport(agent=self.name, findings=[], actions=[], error=str(exc))
return SweepReport(agent=self.name, findings=findings, actions=[],
summary=f'Expenses sweep: {len(findings)} pending approvals.')

View File

@@ -0,0 +1,146 @@
from __future__ import annotations
import logging
from .base_agent import BaseAgent, AgentReport, AgentDirective, SweepReport
from ..tools.project_tools import ProjectTools
logger = logging.getLogger(__name__)
PROJECT_TOOLS = [
{'name': 'get_projects', 'description': 'List projects',
'parameters': {'active': {'type': 'boolean', 'optional': True},
'limit': {'type': 'integer', 'optional': True}}},
{'name': 'get_tasks', 'description': 'Get tasks with filters',
'parameters': {'project_id': {'type': 'integer', 'optional': True},
'stage_id': {'type': 'integer', 'optional': True},
'user_id': {'type': 'integer', 'optional': True},
'limit': {'type': 'integer', 'optional': True}}},
{'name': 'get_project_summary', 'description': 'Get summary for a specific project',
'parameters': {'project_id': {'type': 'integer'}}},
{'name': 'update_task_stage', 'description': 'Move task to a new stage',
'parameters': {'task_id': {'type': 'integer'}, 'stage_id': {'type': 'integer'}}},
{'name': 'assign_task', 'description': 'Assign task to a user',
'parameters': {'task_id': {'type': 'integer'}, 'user_id': {'type': 'integer'}}},
{'name': 'create_task', 'description': 'Create a new task in a project',
'parameters': {'project_id': {'type': 'integer'}, 'name': {'type': 'string'},
'description': {'type': 'string', 'optional': True},
'user_id': {'type': 'integer', 'optional': True},
'date_deadline': {'type': 'string', 'optional': True}}},
{'name': 'log_timesheet', 'description': 'Log timesheet hours on a task',
'parameters': {'task_id': {'type': 'integer'}, 'employee_id': {'type': 'integer'},
'hours': {'type': 'number'}, 'description': {'type': 'string', 'optional': True},
'date': {'type': 'string', 'optional': True}}},
{'name': 'post_chatter_note', 'description': 'Post a note on a record',
'parameters': {'model': {'type': 'string'}, 'record_id': {'type': 'integer'},
'note': {'type': 'string'}}},
]
class ProjectAgent(BaseAgent):
name = 'project_agent'
domain = 'project'
required_odoo_module = 'project'
system_prompt_file = 'project_system.txt'
tools = PROJECT_TOOLS
def __init__(self, odoo, llm, peer_bus=None):
super().__init__(odoo, llm, peer_bus)
self._pt = ProjectTools(odoo)
self._gathered_data = {}
self._actions_taken = []
self._escalations_list = []
async def _plan(self, directive: AgentDirective) -> dict:
intent = (directive.intent or '').lower()
return {
'fetch_projects': any(k in intent for k in ('project', 'overview')),
'fetch_tasks': 'task' in intent,
'project_id': directive.context.get('project_id'),
'user_id': directive.context.get('user_id'),
}
async def _gather(self, ctx: dict) -> dict:
plan = ctx.get('plan', {})
data: dict = {}
if plan.get('fetch_projects') or not plan.get('fetch_tasks'):
data['projects'] = await self._pt.get_projects(limit=20)
if plan.get('fetch_tasks') or plan.get('project_id'):
data['tasks'] = await self._pt.get_tasks(
project_id=plan.get('project_id'), user_id=plan.get('user_id'), limit=50,
)
self._gathered_data = data
return data
async def _reason(self, ctx: dict) -> dict:
data = self._gathered_data
analysis: dict = {'escalations': [], 'blocked_tasks': []}
tasks = data.get('tasks', [])
blocked = [t for t in tasks if t.get('kanban_state') == 'blocked']
analysis['blocked_tasks'] = blocked
if len(blocked) > 5:
analysis['escalations'].append(f'{len(blocked)} tasks are blocked.')
self._escalations_list = analysis['escalations']
return analysis
async def _act(self, ctx: dict) -> list:
return []
async def _report(self, ctx: dict) -> AgentReport:
data = self._gathered_data
analysis = ctx.get('analysis', {})
parts = []
projects = data.get('projects', [])
if projects:
parts.append(f'Projects: {len(projects)} active.')
tasks = data.get('tasks', [])
if tasks:
blocked = len(analysis.get('blocked_tasks', []))
parts.append(f'Tasks: {len(tasks)} total, {blocked} blocked.')
if not parts:
parts.append('Project review complete.')
return AgentReport(agent=self.name, summary=chr(10).join(parts),
data=data, escalations=self._escalations_list, actions_taken=[])
async def _dispatch_tool(self, name: str, args: dict):
dispatch = {
'get_projects': self._pt.get_projects,
'get_tasks': self._pt.get_tasks,
'get_project_summary': self._pt.get_project_summary,
'update_task_stage': self._pt.update_task_stage,
'assign_task': self._pt.assign_task,
'create_task': self._pt.create_task,
'log_timesheet': self._pt.log_timesheet,
'post_chatter_note': self._pt.post_chatter_note,
}
if name not in dispatch:
raise ValueError(f'Unknown tool: {name}')
return await dispatch[name](**args)
async def handle_peer_request(self, request: dict) -> dict:
req_type = request.get('type', '')
try:
if req_type == 'project_list':
return {'projects': await self._pt.get_projects()}
if req_type == 'task_count':
tasks = await self._pt.get_tasks(project_id=request.get('project_id'))
return {'count': len(tasks)}
return {'error': f'Unknown type: {req_type}'}
except Exception as exc:
return {'error': str(exc)}
async def sweep(self) -> SweepReport:
findings = []
try:
tasks = await self._pt.get_tasks(limit=200)
import datetime
today = str(datetime.date.today())
for t in tasks:
if t.get('kanban_state') == 'blocked':
findings.append({'type': 'blocked_task', 'task_id': t.get('id'),
'name': t.get('name', ''), 'severity': 'medium'})
elif t.get('date_deadline') and t['date_deadline'] < today:
findings.append({'type': 'overdue_task', 'task_id': t.get('id'),
'name': t.get('name', ''), 'severity': 'low'})
except Exception as exc:
return SweepReport(agent=self.name, findings=[], actions=[], error=str(exc))
return SweepReport(agent=self.name, findings=findings, actions=[],
summary=f'Project sweep: {len(findings)} issues found.')

View File

@@ -0,0 +1,147 @@
from __future__ import annotations
import logging
from .base_agent import BaseAgent, AgentReport, AgentDirective, SweepReport
from ..tools.sales_tools import SalesTools
logger = logging.getLogger(__name__)
SALES_TOOLS = [
{'name': 'get_sales_orders', 'description': 'Retrieve confirmed sales orders',
'parameters': {'state': {'type': 'string', 'optional': True},
'partner_id': {'type': 'integer', 'optional': True},
'date_from': {'type': 'string', 'optional': True},
'date_to': {'type': 'string', 'optional': True},
'limit': {'type': 'integer', 'optional': True}}},
{'name': 'get_quotations', 'description': 'Get open quotations',
'parameters': {'partner_id': {'type': 'integer', 'optional': True},
'limit': {'type': 'integer', 'optional': True}}},
{'name': 'get_sales_summary', 'description': 'Get sales summary and rep breakdown',
'parameters': {'date_from': {'type': 'string', 'optional': True},
'date_to': {'type': 'string', 'optional': True}}},
{'name': 'get_customer_orders', 'description': 'Get all orders for a specific customer',
'parameters': {'partner_id': {'type': 'integer'},
'limit': {'type': 'integer', 'optional': True}}},
{'name': 'confirm_quotation', 'description': 'Confirm a draft quotation to sales order',
'parameters': {'order_id': {'type': 'integer'}}},
{'name': 'update_order_note', 'description': 'Update the internal note on an order',
'parameters': {'order_id': {'type': 'integer'}, 'note': {'type': 'string'}}},
{'name': 'flag_for_review', 'description': 'Flag a sales order for review',
'parameters': {'model': {'type': 'string'}, 'record_id': {'type': 'integer'},
'reason': {'type': 'string'},
'severity': {'type': 'string', 'optional': True}}},
{'name': 'post_chatter_note', 'description': 'Post a note on a record',
'parameters': {'model': {'type': 'string'}, 'record_id': {'type': 'integer'},
'note': {'type': 'string'}}},
]
class SalesAgent(BaseAgent):
name = 'sales_agent'
domain = 'sales'
required_odoo_module = 'sale'
system_prompt_file = 'sales_system.txt'
tools = SALES_TOOLS
def __init__(self, odoo, llm, peer_bus=None):
super().__init__(odoo, llm, peer_bus)
self._st = SalesTools(odoo)
self._gathered_data = {}
self._actions_taken = []
self._escalations_list = []
async def _plan(self, directive: AgentDirective) -> dict:
intent = (directive.intent or '').lower()
return {
'fetch_summary': any(k in intent for k in ('summary', 'overview', 'report', 'revenue')),
'fetch_quotations': any(k in intent for k in ('quotation', 'quote', 'draft')),
'fetch_orders': any(k in intent for k in ('order', 'sale')),
'partner_id': directive.context.get('partner_id'),
'date_from': directive.context.get('date_from'),
'date_to': directive.context.get('date_to'),
}
async def _gather(self, ctx: dict) -> dict:
plan = ctx.get('plan', {})
data: dict = {}
if plan.get('fetch_summary') or not any([plan.get('fetch_quotations'), plan.get('fetch_orders')]):
data['summary'] = await self._st.get_sales_summary(
date_from=plan.get('date_from'), date_to=plan.get('date_to'),
)
if plan.get('fetch_quotations'):
data['quotations'] = await self._st.get_quotations(
partner_id=plan.get('partner_id'), limit=20,
)
if plan.get('fetch_orders'):
data['orders'] = await self._st.get_sales_orders(
partner_id=plan.get('partner_id'), limit=20,
)
self._gathered_data = data
return data
async def _reason(self, ctx: dict) -> dict:
data = self._gathered_data
analysis: dict = {'escalations': []}
summary = data.get('summary', {})
if summary and summary.get('total_revenue', 0) == 0:
analysis['escalations'].append('No confirmed sales orders in the period.')
self._escalations_list = analysis['escalations']
return analysis
async def _act(self, ctx: dict) -> list:
return []
async def _report(self, ctx: dict) -> AgentReport:
data = self._gathered_data
parts = []
summary = data.get('summary', {})
if summary:
parts.append(
f'Sales: {summary.get("order_count", 0)} orders, '
f'total revenue {summary.get("total_revenue", 0):.2f}.'
)
if not parts:
parts.append('Sales review complete.')
return AgentReport(agent=self.name, summary=chr(10).join(parts),
data=data, escalations=self._escalations_list, actions_taken=[])
async def _dispatch_tool(self, name: str, args: dict):
dispatch = {
'get_sales_orders': self._st.get_sales_orders,
'get_quotations': self._st.get_quotations,
'get_sales_summary': self._st.get_sales_summary,
'get_customer_orders': self._st.get_customer_orders,
'confirm_quotation': self._st.confirm_quotation,
'update_order_note': self._st.update_order_note,
'flag_for_review': self._st.flag_for_review,
'post_chatter_note': self._st.post_chatter_note,
}
if name not in dispatch:
raise ValueError(f'Unknown tool: {name}')
return await dispatch[name](**args)
async def handle_peer_request(self, request: dict) -> dict:
req_type = request.get('type', '')
try:
if req_type == 'sales_summary':
return await self._st.get_sales_summary()
if req_type == 'customer_orders':
return {'orders': await self._st.get_customer_orders(partner_id=request['partner_id'])}
return {'error': f'Unknown type: {req_type}'}
except Exception as exc:
return {'error': str(exc)}
async def sweep(self) -> SweepReport:
findings = []
try:
quotations = await self._st.get_quotations(limit=50)
import datetime
today = str(datetime.date.today())
expired = [q for q in quotations if q.get('validity_date') and q['validity_date'] < today]
for q in expired:
findings.append({'type': 'expired_quotation', 'order_id': q.get('id'),
'partner': q.get('partner_id', [0, 'Unknown'])[1] if isinstance(q.get('partner_id'), list) else '',
'severity': 'low'})
except Exception as exc:
return SweepReport(agent=self.name, findings=[], actions=[], error=str(exc))
return SweepReport(agent=self.name, findings=findings, actions=[],
summary=f'Sales sweep: {len(findings)} expired quotations found.')

View File

@@ -0,0 +1,19 @@
You are the Accounting Agent for ActiveBlue AI, specialising in journal entries, chart of accounts, trial balance, and tax reporting in Odoo 18.
## Role
Analyse accounting data, identify imbalances, monitor account balances, and generate financial reports for management.
## Rules
- NEVER post, confirm, or reverse journal entries — read-only analysis only
- Flag any account with unusual balances (negative assets, positive liabilities) for human review
- Tax data is HIPAA-sensitive: do not share with non-accounting agents
- Always specify the date range when reporting figures
## HIPAA
HIPAA-locked: Ollama only. No cloud LLM.
## Output Format
1. Trial balance summary
2. Notable account balances
3. Tax position
4. Recommendations

View File

@@ -0,0 +1,16 @@
You are the CRM Agent for ActiveBlue AI, specialising in lead management, opportunity pipeline, and sales activity in Odoo 18.
## Role
Monitor the sales pipeline, identify stale opportunities, track conversion rates, and surface actionable insights for the sales team.
## Rules
- NEVER update customer contact details (email, phone, address)
- NEVER close, win, or lose an opportunity automatically — only move stages with explicit user intent
- Escalate any opportunity >90 days without activity
- Do not expose individual contact PII to other agents via PeerBus
## Output Format
1. Pipeline summary by stage
2. Stale / at-risk opportunities
3. Won/Lost analysis
4. Recommended actions

View File

@@ -0,0 +1,16 @@
You are the eLearning Agent for ActiveBlue AI, specialising in course management, learner engagement, and completion tracking in Odoo 18.
## Role
Monitor course completion rates, identify disengaged learners, suggest next courses, and flag underperforming content.
## Rules
- NEVER modify course content or slide order
- NEVER enroll or unenroll users from courses without explicit user instruction
- Low completion (<30%) should be flagged, not auto-corrected
- Learner progress data must not be shared with non-HR agents in identifiable form
## Output Format
1. Course completion overview
2. Courses with low engagement
3. Learner progress highlights
4. Recommendations for content improvement

View File

@@ -0,0 +1,20 @@
You are the Employees Agent for ActiveBlue AI, specialising in HR data, contracts, leave management, and attendance in Odoo 18.
## Role
Monitor employee headcount, flag expired contracts, review pending leave approvals, and provide department summaries.
## Rules
- NEVER access or report salary data to non-HR agents
- NEVER modify employee personal data (address, bank details, national ID)
- Expired contracts must be escalated to HR manager — do not auto-renew
- All employee data is HIPAA-sensitive and processed locally only
## HIPAA
HIPAA-locked: Ollama only. No cloud LLM. Salary fields must never appear in logs.
## Output Format
1. Headcount summary
2. Contract status (active, expiring, expired)
3. Pending leave approvals
4. Attendance anomalies
5. Escalations

View File

@@ -0,0 +1,19 @@
You are the Expenses Agent for ActiveBlue AI, specialising in expense reports, reimbursements, and policy compliance in Odoo 18.
## Role
Monitor pending expense approvals, flag policy violations, and provide expense summaries by employee or department.
## Rules
- NEVER auto-approve expense sheets above $500 without human confirmation
- Flag expenses with no receipt attachment as policy violations
- Employee expense data is HIPAA-sensitive: do not share individual amounts with non-HR agents
- Always verify expense state before attempting approval
## HIPAA
HIPAA-locked: Ollama only. No cloud LLM.
## Output Format
1. Expense summary for the period
2. Pending approvals
3. Policy violations flagged
4. Recommendations

View File

@@ -0,0 +1,16 @@
You are the Project Agent for ActiveBlue AI, specialising in project tasks, timesheets, and delivery tracking in Odoo 18.
## Role
Monitor project health, identify blocked or overdue tasks, track timesheet hours, and surface risks to project delivery.
## Rules
- NEVER delete tasks or projects
- Creating tasks requires explicit user instruction with project_id and task name
- Logging timesheets requires the employee's explicit approval
- Escalate projects where >20% of tasks are blocked
## Output Format
1. Project health overview
2. Blocked / overdue tasks
3. Timesheet summary
4. Risk escalations

View File

@@ -0,0 +1,16 @@
You are the Sales Agent for ActiveBlue AI, specialising in sales orders, quotations, and revenue analysis in Odoo 18.
## Role
Track sales performance, identify expiring quotations, monitor revenue by sales rep, and surface upsell / follow-up opportunities.
## Rules
- NEVER confirm a quotation unless explicitly instructed by the user
- NEVER modify pricing, discounts, or product lines on orders
- Flag orders with unusual discounts (>30%) for manager review
- Expired quotations should be flagged, not auto-archived
## Output Format
1. Revenue summary for the period
2. Open quotations status
3. Top performers / underperformers
4. Recommended follow-ups

View File

@@ -0,0 +1,86 @@
from __future__ import annotations
import logging
from datetime import date, timedelta
from ..tools.odoo_client import OdooClient
logger = logging.getLogger(__name__)
class AccountingTools:
def __init__(self, odoo: OdooClient):
self._o = odoo
async def get_journal_entries(self, journal_id: int = None, date_from: str = None,
date_to: str = None, state: str = 'posted', limit: int = 50) -> list:
domain = [('move_type', '=', 'entry'), ('state', '=', state)]
if journal_id:
domain.append(('journal_id', '=', journal_id))
if date_from:
domain.append(('date', '>=', date_from))
if date_to:
domain.append(('date', '<=', date_to))
fields = ['name', 'date', 'journal_id', 'ref', 'state', 'amount_total', 'line_ids']
return await self._o.search_read('account.move', domain, fields, limit=limit)
async def get_chart_of_accounts(self, account_type: str = None, limit: int = 100) -> list:
domain = [('deprecated', '=', False)]
if account_type:
domain.append(('account_type', '=', account_type))
fields = ['code', 'name', 'account_type', 'balance', 'currency_id']
return await self._o.search_read('account.account', domain, fields, limit=limit)
async def get_account_balance(self, account_id: int) -> dict:
records = await self._o.search_read(
'account.account', [('id', '=', account_id)],
['code', 'name', 'balance', 'account_type'], limit=1,
)
return records[0] if records else {}
async def get_trial_balance(self, date_from: str = None, date_to: str = None) -> list:
today = date.today()
df = date_from or today.replace(day=1).isoformat()
dt = date_to or today.isoformat()
domain = [
('move_id.state', '=', 'posted'),
('date', '>=', df),
('date', '<=', dt),
]
fields = ['account_id', 'debit', 'credit', 'balance']
lines = await self._o.search_read('account.move.line', domain, fields, limit=500)
by_account: dict = {}
for line in lines:
aid = line['account_id'][0] if isinstance(line['account_id'], list) else line['account_id']
aname = line['account_id'][1] if isinstance(line['account_id'], list) else str(aid)
if aid not in by_account:
by_account[aid] = {'account_id': aid, 'account_name': aname, 'debit': 0.0, 'credit': 0.0}
by_account[aid]['debit'] += line.get('debit', 0.0)
by_account[aid]['credit'] += line.get('credit', 0.0)
result = list(by_account.values())
for r in result:
r['balance'] = r['debit'] - r['credit']
return result
async def get_tax_summary(self, date_from: str = None, date_to: str = None) -> dict:
today = date.today()
df = date_from or today.replace(day=1).isoformat()
dt = date_to or today.isoformat()
domain = [
('move_id.state', '=', 'posted'),
('date', '>=', df),
('date', '<=', dt),
('tax_ids', '!=', False),
]
fields = ['tax_ids', 'debit', 'credit', 'balance']
lines = await self._o.search_read('account.move.line', domain, fields, limit=500)
total_tax = sum(abs(line.get('balance', 0)) for line in lines)
return {'period_from': df, 'period_to': dt, 'total_tax_lines': len(lines), 'total_tax_amount': total_tax}
async def flag_for_review(self, model: str, record_id: int, reason: str, severity: str = 'medium') -> bool:
note = f'[AI FLAG - {severity.upper()}] {reason}'
await self._o.call(model, 'message_post', [[record_id]], {'body': note, 'message_type': 'comment'})
logger.info('Flagged %s:%s (%s) for review', model, record_id, severity)
return True
async def post_chatter_note(self, model: str, record_id: int, note: str) -> bool:
await self._o.call(model, 'message_post', [[record_id]], {'body': note, 'message_type': 'comment'})
return True

View File

@@ -0,0 +1,104 @@
from __future__ import annotations
import logging
from ..tools.odoo_client import OdooClient
logger = logging.getLogger(__name__)
class CrmTools:
def __init__(self, odoo: OdooClient):
self._o = odoo
async def get_leads(self, stage_id: int = None, user_id: int = None,
limit: int = 50, active: bool = True) -> list:
domain = [('type', '=', 'lead'), ('active', '=', active)]
if stage_id:
domain.append(('stage_id', '=', stage_id))
if user_id:
domain.append(('user_id', '=', user_id))
fields = ['name', 'partner_id', 'stage_id', 'user_id', 'expected_revenue',
'probability', 'date_deadline', 'priority']
return await self._o.search_read('crm.lead', domain, fields, limit=limit)
async def get_opportunities(self, stage_id: int = None, user_id: int = None,
limit: int = 50, active: bool = True) -> list:
domain = [('type', '=', 'opportunity'), ('active', '=', active)]
if stage_id:
domain.append(('stage_id', '=', stage_id))
if user_id:
domain.append(('user_id', '=', user_id))
fields = ['name', 'partner_id', 'stage_id', 'user_id', 'expected_revenue',
'probability', 'date_deadline', 'priority', 'date_closed']
return await self._o.search_read('crm.lead', domain, fields, limit=limit)
async def get_pipeline_summary(self) -> dict:
stages = await self._o.search_read('crm.stage', [], ['name', 'sequence'], limit=20)
opportunities = await self._o.search_read(
'crm.lead',
[('type', '=', 'opportunity'), ('active', '=', True)],
['stage_id', 'expected_revenue', 'probability'],
limit=500,
)
by_stage: dict = {}
for opp in opportunities:
sid = opp['stage_id'][0] if isinstance(opp['stage_id'], list) else opp['stage_id']
sname = opp['stage_id'][1] if isinstance(opp['stage_id'], list) else str(sid)
if sid not in by_stage:
by_stage[sid] = {'stage': sname, 'count': 0, 'total_revenue': 0.0, 'weighted': 0.0}
by_stage[sid]['count'] += 1
rev = opp.get('expected_revenue', 0)
prob = opp.get('probability', 0) / 100
by_stage[sid]['total_revenue'] += rev
by_stage[sid]['weighted'] += rev * prob
return {
'stages': list(by_stage.values()),
'total_opportunities': len(opportunities),
'total_pipeline': sum(o.get('expected_revenue', 0) for o in opportunities),
'weighted_pipeline': sum(
o.get('expected_revenue', 0) * o.get('probability', 0) / 100 for o in opportunities
),
}
async def update_lead_stage(self, lead_id: int, stage_id: int) -> bool:
result = await self._o.write('crm.lead', [lead_id], {'stage_id': stage_id})
return result.success
async def assign_lead(self, lead_id: int, user_id: int) -> bool:
result = await self._o.write('crm.lead', [lead_id], {'user_id': user_id})
return result.success
async def log_activity(self, lead_id: int, activity_type: str, note: str,
date_deadline: str = None) -> bool:
type_records = await self._o.search_read(
'mail.activity.type', [('name', 'ilike', activity_type)], ['id'], limit=1,
)
type_id = type_records[0]['id'] if type_records else False
vals = {'note': note, 'res_model': 'crm.lead', 'res_id': lead_id}
if type_id:
vals['activity_type_id'] = type_id
if date_deadline:
vals['date_deadline'] = date_deadline
await self._o.call('mail.activity', 'create', [vals])
return True
async def get_won_lost_analysis(self, date_from: str = None, date_to: str = None) -> dict:
domain_won = [('type', '=', 'opportunity'), ('probability', '=', 100)]
domain_lost = [('type', '=', 'opportunity'), ('active', '=', False)]
if date_from:
domain_won.append(('date_closed', '>=', date_from))
domain_lost.append(('date_closed', '>=', date_from))
if date_to:
domain_won.append(('date_closed', '<=', date_to))
domain_lost.append(('date_closed', '<=', date_to))
won = await self._o.search_read('crm.lead', domain_won, ['expected_revenue'], limit=500)
lost = await self._o.search_read('crm.lead', domain_lost, ['expected_revenue'], limit=500)
return {
'won_count': len(won),
'won_revenue': sum(o.get('expected_revenue', 0) for o in won),
'lost_count': len(lost),
'lost_revenue': sum(o.get('expected_revenue', 0) for o in lost),
}
async def post_chatter_note(self, model: str, record_id: int, note: str) -> bool:
await self._o.call(model, 'message_post', [[record_id]], {'body': note, 'message_type': 'comment'})
return True

View File

@@ -0,0 +1,87 @@
from __future__ import annotations
import logging
from ..tools.odoo_client import OdooClient
logger = logging.getLogger(__name__)
class ElearningTools:
def __init__(self, odoo: OdooClient):
self._o = odoo
async def get_courses(self, active: bool = True, limit: int = 50) -> list:
domain = [('active', '=', active)]
fields = ['name', 'description_short', 'website_published', 'total_slides',
'total_time', 'members_count', 'completion_rate', 'tag_ids']
return await self._o.search_read('slide.channel', domain, fields, limit=limit)
async def get_course_stats(self, channel_id: int) -> dict:
channels = await self._o.search_read(
'slide.channel', [('id', '=', channel_id)],
['name', 'total_slides', 'members_count', 'completion_rate', 'total_time'],
limit=1,
)
if not channels:
return {}
ch = channels[0]
slides = await self._o.search_read(
'slide.slide', [('channel_id', '=', channel_id), ('active', '=', True)],
['name', 'slide_type', 'completion_rate', 'likes', 'dislikes', 'view_count'],
limit=200,
)
return {
'channel': ch,
'slide_count': len(slides),
'avg_slide_completion': sum(s.get('completion_rate', 0) for s in slides) / max(len(slides), 1),
'total_views': sum(s.get('view_count', 0) for s in slides),
}
async def get_enrolled_users(self, channel_id: int, limit: int = 100) -> list:
domain = [('channel_id', '=', channel_id)]
fields = ['partner_id', 'completion', 'last_activity_date', 'channel_completion']
return await self._o.search_read('slide.channel.partner', domain, fields, limit=limit)
async def get_slide_completion(self, channel_id: int, min_completion: float = 0.0) -> list:
partners = await self._o.search_read(
'slide.channel.partner',
[('channel_id', '=', channel_id), ('channel_completion', '>=', min_completion)],
['partner_id', 'channel_completion', 'last_activity_date'],
limit=200,
)
return partners
async def get_learning_summary(self) -> dict:
channels = await self._o.search_read(
'slide.channel', [('active', '=', True), ('website_published', '=', True)],
['name', 'members_count', 'completion_rate'],
limit=50,
)
low_completion = [c for c in channels if c.get('completion_rate', 100) < 30]
return {
'total_courses': len(channels),
'total_enrollments': sum(c.get('members_count', 0) for c in channels),
'avg_completion': sum(c.get('completion_rate', 0) for c in channels) / max(len(channels), 1),
'low_completion_courses': low_completion,
}
async def flag_low_completion(self, channel_id: int, reason: str) -> bool:
msg = f'[AI FLAG] {reason}'
await self._o.call('slide.channel', 'message_post', [[channel_id]], {'body': msg, 'message_type': 'comment'})
return True
async def suggest_next_course(self, partner_id: int) -> list:
completed = await self._o.search_read(
'slide.channel.partner',
[('partner_id', '=', partner_id), ('channel_completion', '>=', 90)],
['channel_id'],
limit=50,
)
completed_ids = [c['channel_id'][0] if isinstance(c['channel_id'], list) else c['channel_id'] for c in completed]
domain = [('active', '=', True), ('website_published', '=', True)]
if completed_ids:
domain.append(('id', 'not in', completed_ids))
return await self._o.search_read('slide.channel', domain, ['name', 'total_slides', 'completion_rate'], limit=5)
async def post_chatter_note(self, model: str, record_id: int, note: str) -> bool:
await self._o.call(model, 'message_post', [[record_id]], {'body': note, 'message_type': 'comment'})
return True

View File

@@ -0,0 +1,91 @@
from __future__ import annotations
import logging
from ..tools.odoo_client import OdooClient
logger = logging.getLogger(__name__)
class EmployeesTools:
def __init__(self, odoo: OdooClient):
self._o = odoo
async def get_employees(self, department_id: int = None, active: bool = True,
limit: int = 100) -> list:
domain = [('active', '=', active)]
if department_id:
domain.append(('department_id', '=', department_id))
fields = ['name', 'department_id', 'job_id', 'job_title', 'work_email',
'coach_id', 'parent_id', 'employee_type']
return await self._o.search_read('hr.employee', domain, fields, limit=limit)
async def get_employee_profile(self, employee_id: int) -> dict:
employees = await self._o.search_read(
'hr.employee', [('id', '=', employee_id)],
['name', 'department_id', 'job_id', 'job_title', 'work_email',
'coach_id', 'parent_id', 'employee_type', 'study_field', 'study_school'],
limit=1,
)
return employees[0] if employees else {}
async def get_leaves(self, employee_id: int = None, state: str = None,
date_from: str = None, limit: int = 50) -> list:
domain = []
if employee_id:
domain.append(('employee_id', '=', employee_id))
if state:
domain.append(('state', '=', state))
if date_from:
domain.append(('date_from', '>=', date_from))
fields = ['name', 'employee_id', 'holiday_status_id', 'date_from',
'date_to', 'number_of_days', 'state']
return await self._o.search_read('hr.leave', domain, fields, limit=limit)
async def get_contracts(self, employee_id: int = None, state: str = 'open',
limit: int = 50) -> list:
domain = [('state', '=', state)]
if employee_id:
domain.append(('employee_id', '=', employee_id))
fields = ['name', 'employee_id', 'wage', 'date_start', 'date_end',
'state', 'structure_type_id']
return await self._o.search_read('hr.contract', domain, fields, limit=limit)
async def get_attendance_summary(self, employee_id: int, date_from: str,
date_to: str) -> dict:
domain = [
('employee_id', '=', employee_id),
('check_in', '>=', date_from),
('check_in', '<=', date_to),
]
records = await self._o.search_read('hr.attendance', domain, ['worked_hours', 'check_in'], limit=200)
total_hours = sum(r.get('worked_hours', 0) for r in records)
days_present = len(set(r['check_in'][:10] for r in records if r.get('check_in')))
return {
'employee_id': employee_id,
'period_from': date_from,
'period_to': date_to,
'total_hours': round(total_hours, 2),
'days_present': days_present,
'attendance_records': len(records),
}
async def get_department_summary(self, department_id: int) -> dict:
employees = await self.get_employees(department_id=department_id)
active_contracts = await self.get_contracts(state='open')
dept_contracts = [c for c in active_contracts
if any(e['id'] == (c['employee_id'][0] if isinstance(c['employee_id'], list) else c['employee_id'])
for e in employees)]
return {
'department_id': department_id,
'headcount': len(employees),
'active_contracts': len(dept_contracts),
'avg_wage': sum(c.get('wage', 0) for c in dept_contracts) / max(len(dept_contracts), 1),
}
async def flag_for_review(self, model: str, record_id: int, reason: str, severity: str = 'medium') -> bool:
msg = f'[AI FLAG - {severity.upper()}] {reason}'
await self._o.call(model, 'message_post', [[record_id]], {'body': msg, 'message_type': 'comment'})
return True
async def post_chatter_note(self, model: str, record_id: int, note: str) -> bool:
await self._o.call(model, 'message_post', [[record_id]], {'body': note, 'message_type': 'comment'})
return True

View File

@@ -0,0 +1,86 @@
from __future__ import annotations
import logging
from ..tools.odoo_client import OdooClient
logger = logging.getLogger(__name__)
class ExpensesTools:
def __init__(self, odoo: OdooClient):
self._o = odoo
async def get_expenses(self, employee_id: int = None, state: str = None,
date_from: str = None, date_to: str = None, limit: int = 50) -> list:
domain = []
if employee_id:
domain.append(('employee_id', '=', employee_id))
if state:
domain.append(('state', '=', state))
if date_from:
domain.append(('date', '>=', date_from))
if date_to:
domain.append(('date', '<=', date_to))
fields = ['name', 'employee_id', 'product_id', 'total_amount', 'date',
'state', 'sheet_id', 'description']
return await self._o.search_read('hr.expense', domain, fields, limit=limit)
async def get_expense_sheets(self, state: str = None, employee_id: int = None,
limit: int = 50) -> list:
domain = []
if state:
domain.append(('state', '=', state))
if employee_id:
domain.append(('employee_id', '=', employee_id))
fields = ['name', 'employee_id', 'state', 'total_amount', 'date',
'accounting_date', 'journal_id']
return await self._o.search_read('hr.expense.sheet', domain, fields, limit=limit)
async def get_pending_approvals(self) -> list:
return await self._o.search_read(
'hr.expense.sheet',
[('state', '=', 'submit')],
['name', 'employee_id', 'total_amount', 'date'],
limit=100,
)
async def approve_expense_sheet(self, sheet_id: int) -> bool:
try:
await self._o.call('hr.expense.sheet', 'approve_expense_sheets', [[sheet_id]])
logger.info('Approved expense sheet %s', sheet_id)
return True
except Exception as exc:
logger.warning('approve_expense_sheet failed %s: %s', sheet_id, exc)
return False
async def get_expenses_summary(self, date_from: str = None, date_to: str = None) -> dict:
domain = [('state', 'not in', ['refused'])]
if date_from:
domain.append(('date', '>=', date_from))
if date_to:
domain.append(('date', '<=', date_to))
expenses = await self._o.search_read('hr.expense', domain, ['total_amount', 'employee_id', 'product_id'], limit=1000)
total = sum(e.get('total_amount', 0) for e in expenses)
pending_sheets = await self.get_pending_approvals()
return {
'total_expenses': len(expenses),
'total_amount': total,
'pending_approval_count': len(pending_sheets),
'pending_amount': sum(s.get('total_amount', 0) for s in pending_sheets),
}
async def get_expense_by_employee(self, employee_id: int, limit: int = 20) -> list:
return await self._o.search_read(
'hr.expense',
[('employee_id', '=', employee_id)],
['name', 'total_amount', 'date', 'state', 'product_id'],
limit=limit,
)
async def flag_for_review(self, model: str, record_id: int, reason: str, severity: str = 'medium') -> bool:
msg = f'[AI FLAG - {severity.upper()}] {reason}'
await self._o.call(model, 'message_post', [[record_id]], {'body': msg, 'message_type': 'comment'})
return True
async def post_chatter_note(self, model: str, record_id: int, note: str) -> bool:
await self._o.call(model, 'message_post', [[record_id]], {'body': note, 'message_type': 'comment'})
return True

View File

@@ -0,0 +1,83 @@
from __future__ import annotations
import logging
from ..tools.odoo_client import OdooClient
logger = logging.getLogger(__name__)
class ProjectTools:
def __init__(self, odoo: OdooClient):
self._o = odoo
async def get_projects(self, active: bool = True, limit: int = 50) -> list:
domain = [('active', '=', active)]
fields = ['name', 'partner_id', 'user_id', 'date_start', 'date',
'task_count', 'description', 'last_update_status']
return await self._o.search_read('project.project', domain, fields, limit=limit)
async def get_tasks(self, project_id: int = None, stage_id: int = None,
user_id: int = None, limit: int = 100) -> list:
domain = [('active', '=', True)]
if project_id:
domain.append(('project_id', '=', project_id))
if stage_id:
domain.append(('stage_id', '=', stage_id))
if user_id:
domain.append(('user_ids', 'in', [user_id]))
fields = ['name', 'project_id', 'stage_id', 'user_ids', 'date_deadline',
'priority', 'kanban_state', 'description', 'tag_ids']
return await self._o.search_read('project.task', domain, fields, limit=limit)
async def get_project_summary(self, project_id: int) -> dict:
tasks = await self._o.search_read(
'project.task', [('project_id', '=', project_id), ('active', '=', True)],
['stage_id', 'kanban_state', 'date_deadline', 'user_ids'],
limit=500,
)
total = len(tasks)
blocked = [t for t in tasks if t.get('kanban_state') == 'blocked']
overdue = [t for t in tasks if t.get('date_deadline') and t['date_deadline'] < str(__import__('datetime').date.today())]
return {
'project_id': project_id,
'total_tasks': total,
'blocked_tasks': len(blocked),
'overdue_tasks': len(overdue),
}
async def update_task_stage(self, task_id: int, stage_id: int) -> bool:
result = await self._o.write('project.task', [task_id], {'stage_id': stage_id})
return result.success
async def assign_task(self, task_id: int, user_id: int) -> bool:
result = await self._o.write('project.task', [task_id], {'user_ids': [(4, user_id)]})
return result.success
async def create_task(self, project_id: int, name: str, description: str = '',
user_id: int = None, date_deadline: str = None) -> int:
vals = {'project_id': project_id, 'name': name}
if description:
vals['description'] = description
if user_id:
vals['user_ids'] = [(4, user_id)]
if date_deadline:
vals['date_deadline'] = date_deadline
record_id = await self._o.call('project.task', 'create', [vals])
logger.info('Created task %s in project %s', record_id, project_id)
return record_id
async def log_timesheet(self, task_id: int, employee_id: int, hours: float,
description: str = '', date: str = None) -> int:
import datetime
vals = {
'task_id': task_id,
'employee_id': employee_id,
'unit_amount': hours,
'name': description or 'AI-logged timesheet',
'date': date or str(datetime.date.today()),
}
record_id = await self._o.call('account.analytic.line', 'create', [vals])
return record_id
async def post_chatter_note(self, model: str, record_id: int, note: str) -> bool:
await self._o.call(model, 'message_post', [[record_id]], {'body': note, 'message_type': 'comment'})
return True

View File

@@ -0,0 +1,81 @@
from __future__ import annotations
import logging
from ..tools.odoo_client import OdooClient
logger = logging.getLogger(__name__)
class SalesTools:
def __init__(self, odoo: OdooClient):
self._o = odoo
async def get_sales_orders(self, state: str = 'sale', partner_id: int = None,
date_from: str = None, date_to: str = None, limit: int = 50) -> list:
domain = [('state', '=', state)]
if partner_id:
domain.append(('partner_id', '=', partner_id))
if date_from:
domain.append(('date_order', '>=', date_from))
if date_to:
domain.append(('date_order', '<=', date_to))
fields = ['name', 'partner_id', 'state', 'date_order', 'amount_total',
'amount_untaxed', 'user_id', 'invoice_status']
return await self._o.search_read('sale.order', domain, fields, limit=limit)
async def get_quotations(self, partner_id: int = None, limit: int = 50) -> list:
domain = [('state', 'in', ['draft', 'sent'])]
if partner_id:
domain.append(('partner_id', '=', partner_id))
fields = ['name', 'partner_id', 'state', 'date_order', 'validity_date',
'amount_total', 'user_id']
return await self._o.search_read('sale.order', domain, fields, limit=limit)
async def get_sales_summary(self, date_from: str = None, date_to: str = None) -> dict:
domain = [('state', '=', 'sale')]
if date_from:
domain.append(('date_order', '>=', date_from))
if date_to:
domain.append(('date_order', '<=', date_to))
orders = await self._o.search_read('sale.order', domain, ['amount_total', 'user_id'], limit=1000)
total = sum(o.get('amount_total', 0) for o in orders)
by_rep: dict = {}
for o in orders:
uid = o['user_id'][0] if isinstance(o['user_id'], list) else 0
uname = o['user_id'][1] if isinstance(o['user_id'], list) else 'Unknown'
by_rep.setdefault(uid, {'name': uname, 'count': 0, 'total': 0.0})
by_rep[uid]['count'] += 1
by_rep[uid]['total'] += o.get('amount_total', 0)
return {
'order_count': len(orders),
'total_revenue': total,
'by_sales_rep': sorted(by_rep.values(), key=lambda x: x['total'], reverse=True)[:10],
}
async def get_customer_orders(self, partner_id: int, limit: int = 20) -> list:
return await self._o.search_read(
'sale.order', [('partner_id', '=', partner_id)],
['name', 'state', 'date_order', 'amount_total', 'invoice_status'],
limit=limit,
)
async def confirm_quotation(self, order_id: int) -> bool:
try:
await self._o.call('sale.order', 'action_confirm', [[order_id]])
logger.info('Confirmed quotation %s', order_id)
return True
except Exception as exc:
logger.warning('confirm_quotation failed %s: %s', order_id, exc)
return False
async def update_order_note(self, order_id: int, note: str) -> bool:
result = await self._o.write('sale.order', [order_id], {'note': note})
return result.success
async def flag_for_review(self, model: str, record_id: int, reason: str, severity: str = 'medium') -> bool:
msg = f'[AI FLAG - {severity.upper()}] {reason}'
await self._o.call(model, 'message_post', [[record_id]], {'body': msg, 'message_type': 'comment'})
return True
async def post_chatter_note(self, model: str, record_id: int, note: str) -> bool:
await self._o.call(model, 'message_post', [[record_id]], {'body': note, 'message_type': 'comment'})
return True