From fe47f950e414c63bb33f292f4a0677702f459239 Mon Sep 17 00:00:00 2001 From: ActiveBlue Build Date: Sun, 12 Apr 2026 18:04:32 -0400 Subject: [PATCH] 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 --- agent_service/agents/accounting_agent.py | 147 ++++++++++++++++++ agent_service/agents/crm_agent.py | 139 +++++++++++++++++ agent_service/agents/elearning_agent.py | 144 ++++++++++++++++++ agent_service/agents/employees_agent.py | 158 ++++++++++++++++++++ agent_service/agents/expenses_agent.py | 140 +++++++++++++++++ agent_service/agents/project_agent.py | 146 ++++++++++++++++++ agent_service/agents/sales_agent.py | 147 ++++++++++++++++++ agent_service/prompts/accounting_system.txt | 19 +++ agent_service/prompts/crm_system.txt | 16 ++ agent_service/prompts/elearning_system.txt | 16 ++ agent_service/prompts/employees_system.txt | 20 +++ agent_service/prompts/expenses_system.txt | 19 +++ agent_service/prompts/project_system.txt | 16 ++ agent_service/prompts/sales_system.txt | 16 ++ agent_service/tools/accounting_tools.py | 86 +++++++++++ agent_service/tools/crm_tools.py | 104 +++++++++++++ agent_service/tools/elearning_tools.py | 87 +++++++++++ agent_service/tools/employees_tools.py | 91 +++++++++++ agent_service/tools/expenses_tools.py | 86 +++++++++++ agent_service/tools/project_tools.py | 83 ++++++++++ agent_service/tools/sales_tools.py | 81 ++++++++++ 21 files changed, 1761 insertions(+) create mode 100644 agent_service/agents/accounting_agent.py create mode 100644 agent_service/agents/crm_agent.py create mode 100644 agent_service/agents/elearning_agent.py create mode 100644 agent_service/agents/employees_agent.py create mode 100644 agent_service/agents/expenses_agent.py create mode 100644 agent_service/agents/project_agent.py create mode 100644 agent_service/agents/sales_agent.py create mode 100644 agent_service/prompts/accounting_system.txt create mode 100644 agent_service/prompts/crm_system.txt create mode 100644 agent_service/prompts/elearning_system.txt create mode 100644 agent_service/prompts/employees_system.txt create mode 100644 agent_service/prompts/expenses_system.txt create mode 100644 agent_service/prompts/project_system.txt create mode 100644 agent_service/prompts/sales_system.txt create mode 100644 agent_service/tools/accounting_tools.py create mode 100644 agent_service/tools/crm_tools.py create mode 100644 agent_service/tools/elearning_tools.py create mode 100644 agent_service/tools/employees_tools.py create mode 100644 agent_service/tools/expenses_tools.py create mode 100644 agent_service/tools/project_tools.py create mode 100644 agent_service/tools/sales_tools.py diff --git a/agent_service/agents/accounting_agent.py b/agent_service/agents/accounting_agent.py new file mode 100644 index 0000000..ef087c9 --- /dev/null +++ b/agent_service/agents/accounting_agent.py @@ -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.') diff --git a/agent_service/agents/crm_agent.py b/agent_service/agents/crm_agent.py new file mode 100644 index 0000000..4f54116 --- /dev/null +++ b/agent_service/agents/crm_agent.py @@ -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.') diff --git a/agent_service/agents/elearning_agent.py b/agent_service/agents/elearning_agent.py new file mode 100644 index 0000000..29ef06c --- /dev/null +++ b/agent_service/agents/elearning_agent.py @@ -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.') diff --git a/agent_service/agents/employees_agent.py b/agent_service/agents/employees_agent.py new file mode 100644 index 0000000..617b394 --- /dev/null +++ b/agent_service/agents/employees_agent.py @@ -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.') diff --git a/agent_service/agents/expenses_agent.py b/agent_service/agents/expenses_agent.py new file mode 100644 index 0000000..c4fb995 --- /dev/null +++ b/agent_service/agents/expenses_agent.py @@ -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.') diff --git a/agent_service/agents/project_agent.py b/agent_service/agents/project_agent.py new file mode 100644 index 0000000..b6a7661 --- /dev/null +++ b/agent_service/agents/project_agent.py @@ -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.') diff --git a/agent_service/agents/sales_agent.py b/agent_service/agents/sales_agent.py new file mode 100644 index 0000000..35561f9 --- /dev/null +++ b/agent_service/agents/sales_agent.py @@ -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.') diff --git a/agent_service/prompts/accounting_system.txt b/agent_service/prompts/accounting_system.txt new file mode 100644 index 0000000..eda11bb --- /dev/null +++ b/agent_service/prompts/accounting_system.txt @@ -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 diff --git a/agent_service/prompts/crm_system.txt b/agent_service/prompts/crm_system.txt new file mode 100644 index 0000000..f4aaad8 --- /dev/null +++ b/agent_service/prompts/crm_system.txt @@ -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 diff --git a/agent_service/prompts/elearning_system.txt b/agent_service/prompts/elearning_system.txt new file mode 100644 index 0000000..c11eecb --- /dev/null +++ b/agent_service/prompts/elearning_system.txt @@ -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 diff --git a/agent_service/prompts/employees_system.txt b/agent_service/prompts/employees_system.txt new file mode 100644 index 0000000..804be81 --- /dev/null +++ b/agent_service/prompts/employees_system.txt @@ -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 diff --git a/agent_service/prompts/expenses_system.txt b/agent_service/prompts/expenses_system.txt new file mode 100644 index 0000000..e60b4f0 --- /dev/null +++ b/agent_service/prompts/expenses_system.txt @@ -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 diff --git a/agent_service/prompts/project_system.txt b/agent_service/prompts/project_system.txt new file mode 100644 index 0000000..4ad463a --- /dev/null +++ b/agent_service/prompts/project_system.txt @@ -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 diff --git a/agent_service/prompts/sales_system.txt b/agent_service/prompts/sales_system.txt new file mode 100644 index 0000000..695f620 --- /dev/null +++ b/agent_service/prompts/sales_system.txt @@ -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 diff --git a/agent_service/tools/accounting_tools.py b/agent_service/tools/accounting_tools.py new file mode 100644 index 0000000..a09e13f --- /dev/null +++ b/agent_service/tools/accounting_tools.py @@ -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 diff --git a/agent_service/tools/crm_tools.py b/agent_service/tools/crm_tools.py new file mode 100644 index 0000000..48eacf7 --- /dev/null +++ b/agent_service/tools/crm_tools.py @@ -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 diff --git a/agent_service/tools/elearning_tools.py b/agent_service/tools/elearning_tools.py new file mode 100644 index 0000000..a7f38d9 --- /dev/null +++ b/agent_service/tools/elearning_tools.py @@ -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 diff --git a/agent_service/tools/employees_tools.py b/agent_service/tools/employees_tools.py new file mode 100644 index 0000000..114933f --- /dev/null +++ b/agent_service/tools/employees_tools.py @@ -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 diff --git a/agent_service/tools/expenses_tools.py b/agent_service/tools/expenses_tools.py new file mode 100644 index 0000000..3b27ff0 --- /dev/null +++ b/agent_service/tools/expenses_tools.py @@ -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 diff --git a/agent_service/tools/project_tools.py b/agent_service/tools/project_tools.py new file mode 100644 index 0000000..9bd0582 --- /dev/null +++ b/agent_service/tools/project_tools.py @@ -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 diff --git a/agent_service/tools/sales_tools.py b/agent_service/tools/sales_tools.py new file mode 100644 index 0000000..8e99429 --- /dev/null +++ b/agent_service/tools/sales_tools.py @@ -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