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:
147
agent_service/agents/accounting_agent.py
Normal file
147
agent_service/agents/accounting_agent.py
Normal 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.')
|
||||
139
agent_service/agents/crm_agent.py
Normal file
139
agent_service/agents/crm_agent.py
Normal 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.')
|
||||
144
agent_service/agents/elearning_agent.py
Normal file
144
agent_service/agents/elearning_agent.py
Normal 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.')
|
||||
158
agent_service/agents/employees_agent.py
Normal file
158
agent_service/agents/employees_agent.py
Normal 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.')
|
||||
140
agent_service/agents/expenses_agent.py
Normal file
140
agent_service/agents/expenses_agent.py
Normal 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.')
|
||||
146
agent_service/agents/project_agent.py
Normal file
146
agent_service/agents/project_agent.py
Normal 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.')
|
||||
147
agent_service/agents/sales_agent.py
Normal file
147
agent_service/agents/sales_agent.py
Normal 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.')
|
||||
19
agent_service/prompts/accounting_system.txt
Normal file
19
agent_service/prompts/accounting_system.txt
Normal 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
|
||||
16
agent_service/prompts/crm_system.txt
Normal file
16
agent_service/prompts/crm_system.txt
Normal 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
|
||||
16
agent_service/prompts/elearning_system.txt
Normal file
16
agent_service/prompts/elearning_system.txt
Normal 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
|
||||
20
agent_service/prompts/employees_system.txt
Normal file
20
agent_service/prompts/employees_system.txt
Normal 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
|
||||
19
agent_service/prompts/expenses_system.txt
Normal file
19
agent_service/prompts/expenses_system.txt
Normal 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
|
||||
16
agent_service/prompts/project_system.txt
Normal file
16
agent_service/prompts/project_system.txt
Normal 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
|
||||
16
agent_service/prompts/sales_system.txt
Normal file
16
agent_service/prompts/sales_system.txt
Normal 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
|
||||
86
agent_service/tools/accounting_tools.py
Normal file
86
agent_service/tools/accounting_tools.py
Normal 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
|
||||
104
agent_service/tools/crm_tools.py
Normal file
104
agent_service/tools/crm_tools.py
Normal 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
|
||||
87
agent_service/tools/elearning_tools.py
Normal file
87
agent_service/tools/elearning_tools.py
Normal 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
|
||||
91
agent_service/tools/employees_tools.py
Normal file
91
agent_service/tools/employees_tools.py
Normal 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
|
||||
86
agent_service/tools/expenses_tools.py
Normal file
86
agent_service/tools/expenses_tools.py
Normal 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
|
||||
83
agent_service/tools/project_tools.py
Normal file
83
agent_service/tools/project_tools.py
Normal 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
|
||||
81
agent_service/tools/sales_tools.py
Normal file
81
agent_service/tools/sales_tools.py
Normal 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
|
||||
Reference in New Issue
Block a user