Compare commits
10 Commits
0e13b93e3a
...
fb4bf56816
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb4bf56816 | ||
|
|
7487fc73f9 | ||
|
|
fe47f950e4 | ||
|
|
29409ed71d | ||
|
|
430ab966b2 | ||
|
|
dab6354d09 | ||
|
|
4ca62ee54b | ||
|
|
21998f76aa | ||
|
|
9ab1f8bbf8 | ||
|
|
7d92c2ea6f |
22
Dockerfile
Normal file
22
Dockerfile
Normal file
@@ -0,0 +1,22 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
LABEL org.opencontainers.image.title="ActiveBlue AI Agent Service"
|
||||
LABEL org.opencontainers.image.version="0.1.0"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc libpq-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY agent_service/ ./agent_service/
|
||||
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
|
||||
EXPOSE 8001
|
||||
|
||||
CMD ["uvicorn", "agent_service.main:app", "--host", "0.0.0.0", "--port", "8001", "--workers", "1"]
|
||||
154
README.md
Normal file
154
README.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# ActiveBlue AI
|
||||
|
||||
Multi-agent AI system integrated with Odoo 18 Community Edition.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Odoo 18 (ai.activeblue.net)
|
||||
└── activeblue_ai module
|
||||
├── OWL2 systray brain icon + slide-in chat panel
|
||||
├── Models: ab.ai.bot, ab.ai.directive, ab.ai.log, ab.ai.agent.registry
|
||||
└── Controllers: /ai/chat, /ai/webhook/callback, /ai/health, /ai/approval/*
|
||||
|
||||
FastAPI Agent Service (192.168.2.47:8001)
|
||||
├── POST /dispatch — route user message to MasterAgent
|
||||
├── GET/POST /approval/* — human approval workflow
|
||||
├── GET/POST /registry/* — agent registry + LLM backend overrides
|
||||
├── POST /sweep — trigger proactive agent sweeps
|
||||
└── GET /health — service health + Odoo/Ollama status
|
||||
|
||||
MasterAgent (singleton)
|
||||
├── Classifies intent via LLM
|
||||
├── Routes to specialist agents in parallel (asyncio.gather)
|
||||
├── Manages 3-tier memory (conversation / operational / knowledge)
|
||||
└── Synthesises responses
|
||||
|
||||
Specialist Agents (8, stateless):
|
||||
finance_agent, accounting_agent, crm_agent, sales_agent,
|
||||
project_agent, elearning_agent, expenses_agent, employees_agent
|
||||
```
|
||||
|
||||
## Privacy Modes
|
||||
|
||||
| Mode | Behaviour |
|
||||
|---------|--------------------------------------------|
|
||||
| `local` | Ollama only for all agents |
|
||||
| `hybrid`| Per-agent override (DB → env → fallback) |
|
||||
| `cloud` | Claude for non-HIPAA agents |
|
||||
|
||||
**HIPAA-locked agents** (always Ollama, no exceptions):
|
||||
`finance_agent`, `accounting_agent`, `employees_agent`, `expenses_agent`
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Clone and configure
|
||||
|
||||
```bash
|
||||
git clone http://192.168.1.64:3000/tocmo0nlord/odoo-ai.git
|
||||
cd odoo-ai
|
||||
cp .env.example .env
|
||||
# Edit .env — set POSTGRES_PASSWORD, ODOO_API_KEY, etc.
|
||||
```
|
||||
|
||||
### 2. Run Odoo 18
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.odoo.yml up -d
|
||||
```
|
||||
|
||||
### 3. Run the Agent Service
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Or for development:
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
uvicorn agent_service.main:app --reload --port 8001
|
||||
```
|
||||
|
||||
### 4. Run database migrations
|
||||
|
||||
```bash
|
||||
cd agent_service/migrations
|
||||
alembic upgrade head
|
||||
```
|
||||
|
||||
### 5. Install Odoo module
|
||||
|
||||
In Odoo → Settings → Apps → search "ActiveBlue AI" → Install.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
See `.env.example` for the full list. Key variables:
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `ODOO_URL` | Odoo base URL (e.g. `http://ai.activeblue.net`) |
|
||||
| `ODOO_API_KEY` | Odoo user API key |
|
||||
| `OLLAMA_URL` | Ollama API URL (e.g. `http://192.168.2.47:11434`) |
|
||||
| `ANTHROPIC_API_KEY` | Required only if `LLM_PRIVACY_MODE=cloud` or `hybrid` |
|
||||
| `LLM_PRIVACY_MODE` | `local` / `hybrid` / `cloud` (default: `local`) |
|
||||
| `POSTGRES_PASSWORD` | Required — no default |
|
||||
| `WEBHOOK_SECRET` | Shared secret between Odoo and agent service |
|
||||
|
||||
## Development
|
||||
|
||||
### Running tests
|
||||
|
||||
```bash
|
||||
pip install pytest pytest-asyncio
|
||||
pytest tests/ -v
|
||||
```
|
||||
|
||||
### Project structure
|
||||
|
||||
```
|
||||
odoo-ai/
|
||||
├── agent_service/
|
||||
│ ├── agents/ # MasterAgent + 8 specialist agents + PeerBus + SweepCoordinator
|
||||
│ ├── llm/ # OllamaBackend, ClaudeBackend, LLMRouter, ToolCallValidator
|
||||
│ ├── memory/ # ConversationStore, OperationalStore, KnowledgeStore, MemoryManager
|
||||
│ ├── tools/ # OdooClient + per-domain tools (finance, crm, sales, ...)
|
||||
│ ├── routers/ # FastAPI routers (dispatch, approval, registry, sweep, health)
|
||||
│ ├── prompts/ # System prompts for each agent
|
||||
│ ├── migrations/ # Alembic migrations (7 tables)
|
||||
│ ├── logging_utils/ # Structured JSON logging + Loki push
|
||||
│ ├── config.py # pydantic-settings
|
||||
│ ├── app_state.py # Global singletons
|
||||
│ └── main.py # FastAPI app + lifespan startup
|
||||
├── addons/
|
||||
│ └── activeblue_ai/ # Odoo 18 module
|
||||
│ ├── models/ # ab.ai.bot, ab.ai.directive, ab.ai.log, ab.ai.agent.registry
|
||||
│ ├── controllers/ # webhook, health_proxy, approval + chat
|
||||
│ ├── views/ # XML views + menus
|
||||
│ ├── security/ # groups + ACL
|
||||
│ ├── data/ # cron jobs
|
||||
│ └── static/ # OWL2 JS + CSS + XML templates
|
||||
├── research/ # Per-domain research notes
|
||||
├── tests/ # pytest test suite
|
||||
├── docker-compose.odoo.yml # Odoo 18 + PostgreSQL 15
|
||||
├── docker-compose.yml # Agent service + PostgreSQL 15
|
||||
├── Dockerfile
|
||||
├── requirements.txt
|
||||
└── .env.example
|
||||
```
|
||||
|
||||
## Agent Tool Limits
|
||||
|
||||
Each specialist agent is capped at **8 tools** (`MAX_TOOLS_PER_AGENT`). The `ToolCallValidator` raises `AgentConfigError` at startup if exceeded.
|
||||
|
||||
## Memory Architecture
|
||||
|
||||
| Tier | Store | TTL | Scope |
|
||||
|------|-------|-----|-------|
|
||||
| Tier 1 | `ab_conversation_memory` | Hard cap: 200 rows/user | Per user |
|
||||
| Tier 2 | `ab_operational_memory` | 90 days | Per agent+scope |
|
||||
| Tier 3 | `ab_knowledge_store` | Permanent | Entity-keyed |
|
||||
|
||||
## License
|
||||
|
||||
LGPL-3.0
|
||||
1
addons/activeblue_ai/__init__.py
Normal file
1
addons/activeblue_ai/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import models, controllers
|
||||
37
addons/activeblue_ai/__manifest__.py
Normal file
37
addons/activeblue_ai/__manifest__.py
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
'name': 'ActiveBlue AI',
|
||||
'version': '18.0.0.1.0',
|
||||
'summary': 'Multi-agent AI assistant for Odoo 18',
|
||||
'description': """
|
||||
ActiveBlue AI integrates a multi-agent AI system into Odoo 18.
|
||||
Features a Master AI with 8 specialist agents for finance, accounting,
|
||||
CRM, sales, project management, eLearning, expenses, and HR.
|
||||
""",
|
||||
'author': 'ActiveBlue',
|
||||
'website': 'https://activeblue.net',
|
||||
'category': 'Productivity',
|
||||
'license': 'LGPL-3',
|
||||
'depends': ['base', 'mail', 'web'],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'security/res_groups.xml',
|
||||
'data/ir_cron.xml',
|
||||
'views/ab_ai_bot_views.xml',
|
||||
'views/ab_ai_directive_views.xml',
|
||||
'views/ab_ai_log_views.xml',
|
||||
'views/ab_ai_registry_views.xml',
|
||||
'views/menus.xml',
|
||||
],
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
'activeblue_ai/static/src/css/activeblue_ai.css',
|
||||
'activeblue_ai/static/src/xml/systray.xml',
|
||||
'activeblue_ai/static/src/xml/ai_panel.xml',
|
||||
'activeblue_ai/static/src/js/components/ai_panel.js',
|
||||
'activeblue_ai/static/src/js/components/systray_button.js',
|
||||
],
|
||||
},
|
||||
'installable': True,
|
||||
'application': True,
|
||||
'auto_install': False,
|
||||
}
|
||||
1
addons/activeblue_ai/controllers/__init__.py
Normal file
1
addons/activeblue_ai/controllers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import webhook, health_proxy, approval
|
||||
60
addons/activeblue_ai/controllers/approval.py
Normal file
60
addons/activeblue_ai/controllers/approval.py
Normal file
@@ -0,0 +1,60 @@
|
||||
import json
|
||||
import logging
|
||||
import requests
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AiApprovalController(http.Controller):
|
||||
|
||||
@http.route('/ai/approval/pending', type='json', auth='user', methods=['GET'])
|
||||
def list_pending(self):
|
||||
if not request.env.user.has_group('activeblue_ai.group_ai_manager'):
|
||||
return {'error': 'Access denied', 'items': []}
|
||||
bot = request.env['ab.ai.bot'].sudo().search([('active', '=', True)], limit=1)
|
||||
if not bot:
|
||||
return {'items': []}
|
||||
url = bot._get_service_url() + '/approval/pending'
|
||||
try:
|
||||
resp = requests.get(url, headers=bot._build_headers(), timeout=10)
|
||||
resp.raise_for_status()
|
||||
return {'items': resp.json()}
|
||||
except Exception as exc:
|
||||
_logger.error('list_pending failed: %s', exc)
|
||||
return {'error': str(exc), 'items': []}
|
||||
|
||||
@http.route('/ai/approval/respond', type='json', auth='user', methods=['POST'])
|
||||
def respond(self, directive_id, approved, note=None):
|
||||
if not request.env.user.has_group('activeblue_ai.group_ai_manager'):
|
||||
return {'error': 'Access denied'}
|
||||
bot = request.env['ab.ai.bot'].sudo().search([('active', '=', True)], limit=1)
|
||||
if not bot:
|
||||
return {'error': 'No bot configured'}
|
||||
url = bot._get_service_url() + '/approval/respond'
|
||||
payload = {
|
||||
'directive_id': directive_id,
|
||||
'approved': approved,
|
||||
'approver_id': str(request.env.user.id),
|
||||
'note': note or '',
|
||||
}
|
||||
try:
|
||||
resp = requests.post(url, json=payload, headers=bot._build_headers(), timeout=10)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except Exception as exc:
|
||||
_logger.error('respond approval failed: %s', exc)
|
||||
return {'error': str(exc)}
|
||||
|
||||
@http.route('/ai/chat', type='json', auth='user', methods=['POST'])
|
||||
def chat(self, message, context=None, session_id=None):
|
||||
bot = request.env['ab.ai.bot'].sudo().get_active_bot()
|
||||
result = bot.dispatch_message(
|
||||
user_id=request.env.user.id,
|
||||
message=message,
|
||||
context=context or {},
|
||||
session_id=session_id,
|
||||
)
|
||||
return result
|
||||
37
addons/activeblue_ai/controllers/health_proxy.py
Normal file
37
addons/activeblue_ai/controllers/health_proxy.py
Normal file
@@ -0,0 +1,37 @@
|
||||
import logging
|
||||
import requests
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AiHealthProxyController(http.Controller):
|
||||
|
||||
@http.route('/ai/health', type='http', auth='user', methods=['GET'])
|
||||
def ai_health(self):
|
||||
bot = request.env['ab.ai.bot'].sudo().search([('active', '=', True)], limit=1)
|
||||
if not bot:
|
||||
return request.make_response(
|
||||
'{"status":"no_bot_configured"}',
|
||||
headers=[('Content-Type', 'application/json')],
|
||||
status=503,
|
||||
)
|
||||
url = bot._get_service_url() + '/health/detailed'
|
||||
try:
|
||||
resp = requests.get(url, headers=bot._build_headers(), timeout=5)
|
||||
return request.make_response(
|
||||
resp.text,
|
||||
headers=[('Content-Type', 'application/json')],
|
||||
status=resp.status_code,
|
||||
)
|
||||
except Exception as exc:
|
||||
_logger.warning('health proxy failed: %s', exc)
|
||||
import json
|
||||
body = json.dumps({'status': 'unreachable', 'error': str(exc)})
|
||||
return request.make_response(
|
||||
body,
|
||||
headers=[('Content-Type', 'application/json')],
|
||||
status=503,
|
||||
)
|
||||
86
addons/activeblue_ai/controllers/webhook.py
Normal file
86
addons/activeblue_ai/controllers/webhook.py
Normal file
@@ -0,0 +1,86 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request, Response
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AiWebhookController(http.Controller):
|
||||
|
||||
@http.route('/ai/webhook/callback', type='json', auth='none', methods=['POST'], csrf=False)
|
||||
def ai_callback(self):
|
||||
# Verify webhook secret
|
||||
bot = request.env['ab.ai.bot'].sudo().search([('active', '=', True)], limit=1)
|
||||
if bot and bot.webhook_secret:
|
||||
incoming_sig = request.httprequest.headers.get('X-ActiveBlue-Signature', '')
|
||||
if incoming_sig != bot.webhook_secret:
|
||||
_logger.warning('Webhook signature mismatch from %s', request.httprequest.remote_addr)
|
||||
return Response(status=401)
|
||||
|
||||
# Check IP whitelist
|
||||
if bot and bot.webhook_secret:
|
||||
pass # IP check handled at network level via ALLOWED_CALLBACK_IP env var in agent service
|
||||
|
||||
try:
|
||||
data = json.loads(request.httprequest.data)
|
||||
except Exception:
|
||||
return Response(status=400)
|
||||
|
||||
event_type = data.get('event', '')
|
||||
_logger.debug('AI webhook callback: event=%s', event_type)
|
||||
|
||||
if event_type == 'directive_completed':
|
||||
self._handle_directive_completed(data)
|
||||
elif event_type == 'escalation':
|
||||
self._handle_escalation(data)
|
||||
elif event_type == 'sweep_findings':
|
||||
self._handle_sweep_findings(data)
|
||||
|
||||
return {'ok': True}
|
||||
|
||||
def _handle_directive_completed(self, data):
|
||||
try:
|
||||
request.env['ab.ai.directive'].sudo().record_directive(
|
||||
directive_id=data.get('directive_id', ''),
|
||||
user_id=int(data.get('user_id', 0)) or None,
|
||||
message=data.get('message', ''),
|
||||
reply=data.get('reply', ''),
|
||||
status='completed',
|
||||
agents=data.get('agents', []),
|
||||
escalations=data.get('escalations', []),
|
||||
actions=data.get('actions_taken', []),
|
||||
session_id=data.get('session_id'),
|
||||
duration_ms=data.get('duration_ms'),
|
||||
)
|
||||
except Exception as exc:
|
||||
_logger.error('_handle_directive_completed error: %s', exc)
|
||||
|
||||
def _handle_escalation(self, data):
|
||||
try:
|
||||
request.env['ab.ai.log'].sudo().log(
|
||||
summary=data.get('message', 'AI escalation'),
|
||||
level='warning',
|
||||
agent=data.get('agent'),
|
||||
directive_id=data.get('directive_id'),
|
||||
details=json.dumps(data),
|
||||
)
|
||||
except Exception as exc:
|
||||
_logger.error('_handle_escalation error: %s', exc)
|
||||
|
||||
def _handle_sweep_findings(self, data):
|
||||
findings = data.get('findings', [])
|
||||
agent = data.get('agent', 'unknown')
|
||||
try:
|
||||
for finding in findings:
|
||||
request.env['ab.ai.log'].sudo().log(
|
||||
summary=f"Sweep finding [{agent}]: {finding.get('type', '')}",
|
||||
level='info',
|
||||
agent=agent,
|
||||
details=json.dumps(finding),
|
||||
)
|
||||
except Exception as exc:
|
||||
_logger.error('_handle_sweep_findings error: %s', exc)
|
||||
48
addons/activeblue_ai/data/ir_cron.xml
Normal file
48
addons/activeblue_ai/data/ir_cron.xml
Normal file
@@ -0,0 +1,48 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<record id="cron_ai_ping" model="ir.cron">
|
||||
<field name="name">ActiveBlue AI: Ping Service</field>
|
||||
<field name="model_id" ref="model_ab_ai_bot"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model.cron_ping_all()</field>
|
||||
<field name="interval_number">5</field>
|
||||
<field name="interval_type">minutes</field>
|
||||
<field name="numbercall">-1</field>
|
||||
<field name="active">True</field>
|
||||
</record>
|
||||
|
||||
<record id="cron_ai_registry_sync" model="ir.cron">
|
||||
<field name="name">ActiveBlue AI: Sync Agent Registry</field>
|
||||
<field name="model_id" ref="model_ab_ai_agent_registry"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model.sync_from_service()</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">hours</field>
|
||||
<field name="numbercall">-1</field>
|
||||
<field name="active">True</field>
|
||||
</record>
|
||||
|
||||
<record id="cron_ai_directive_cleanup" model="ir.cron">
|
||||
<field name="name">ActiveBlue AI: Clean Up Old Directives</field>
|
||||
<field name="model_id" ref="model_ab_ai_directive"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model.cron_cleanup_old()</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="numbercall">-1</field>
|
||||
<field name="active">True</field>
|
||||
</record>
|
||||
|
||||
<record id="cron_ai_log_cleanup" model="ir.cron">
|
||||
<field name="name">ActiveBlue AI: Clean Up Old Logs</field>
|
||||
<field name="model_id" ref="model_ab_ai_log"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model.cron_cleanup()</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="numbercall">-1</field>
|
||||
<field name="active">True</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
1
addons/activeblue_ai/models/__init__.py
Normal file
1
addons/activeblue_ai/models/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import ab_ai_bot, ab_ai_directive, ab_ai_log, ab_ai_registry
|
||||
102
addons/activeblue_ai/models/ab_ai_bot.py
Normal file
102
addons/activeblue_ai/models/ab_ai_bot.py
Normal file
@@ -0,0 +1,102 @@
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError
|
||||
import requests
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
AGENT_SERVICE_URL_PARAM = 'activeblue_ai.agent_service_url'
|
||||
WEBHOOK_SECRET_PARAM = 'activeblue_ai.webhook_secret'
|
||||
|
||||
|
||||
class AbAiBot(models.Model):
|
||||
_name = 'ab.ai.bot'
|
||||
_description = 'ActiveBlue AI Bot'
|
||||
_rec_name = 'display_name'
|
||||
|
||||
display_name = fields.Char(string='Bot Name', default='ActiveBlue AI', required=True)
|
||||
active = fields.Boolean(default=True)
|
||||
agent_service_url = fields.Char(
|
||||
string='Agent Service URL',
|
||||
default='http://192.168.2.47:8001',
|
||||
required=True,
|
||||
)
|
||||
webhook_secret = fields.Char(string='Webhook Secret')
|
||||
privacy_mode = fields.Selection(
|
||||
[('local', 'Local (Ollama only)'), ('hybrid', 'Hybrid'), ('cloud', 'Cloud (Claude)')],
|
||||
string='Privacy Mode',
|
||||
default='local',
|
||||
required=True,
|
||||
)
|
||||
status = fields.Selection(
|
||||
[('online', 'Online'), ('offline', 'Offline'), ('error', 'Error')],
|
||||
string='Status',
|
||||
default='offline',
|
||||
readonly=True,
|
||||
)
|
||||
last_ping = fields.Datetime(string='Last Ping', readonly=True)
|
||||
notes = fields.Text(string='Notes')
|
||||
|
||||
@api.model
|
||||
def get_active_bot(self):
|
||||
bot = self.search([('active', '=', True)], limit=1)
|
||||
if not bot:
|
||||
raise UserError(_('No active AI bot configured. Please configure ActiveBlue AI.'))
|
||||
return bot
|
||||
|
||||
def _get_service_url(self):
|
||||
self.ensure_one()
|
||||
return (self.agent_service_url or '').rstrip('/')
|
||||
|
||||
def _build_headers(self):
|
||||
self.ensure_one()
|
||||
headers = {'Content-Type': 'application/json'}
|
||||
if self.webhook_secret:
|
||||
headers['X-ActiveBlue-Signature'] = self.webhook_secret
|
||||
return headers
|
||||
|
||||
def action_ping(self):
|
||||
self.ensure_one()
|
||||
url = self._get_service_url() + '/health'
|
||||
try:
|
||||
resp = requests.get(url, timeout=5, headers=self._build_headers())
|
||||
if resp.status_code == 200:
|
||||
self.write({'status': 'online', 'last_ping': fields.Datetime.now()})
|
||||
return {'type': 'ir.actions.client', 'tag': 'display_notification',
|
||||
'params': {'message': _('AI service is online'), 'type': 'success'}}
|
||||
else:
|
||||
self.write({'status': 'error'})
|
||||
return {'type': 'ir.actions.client', 'tag': 'display_notification',
|
||||
'params': {'message': _('AI service returned %s') % resp.status_code, 'type': 'warning'}}
|
||||
except Exception as exc:
|
||||
self.write({'status': 'offline'})
|
||||
return {'type': 'ir.actions.client', 'tag': 'display_notification',
|
||||
'params': {'message': _('AI service unreachable: %s') % exc, 'type': 'danger'}}
|
||||
|
||||
def dispatch_message(self, user_id, message, context=None, session_id=None):
|
||||
self.ensure_one()
|
||||
url = self._get_service_url() + '/dispatch'
|
||||
payload = {
|
||||
'user_id': str(user_id),
|
||||
'message': message,
|
||||
'context': context or {},
|
||||
}
|
||||
if session_id:
|
||||
payload['session_id'] = session_id
|
||||
try:
|
||||
resp = requests.post(url, json=payload, headers=self._build_headers(), timeout=120)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except requests.exceptions.Timeout:
|
||||
raise UserError(_('AI service timed out. Please try again.'))
|
||||
except requests.exceptions.RequestException as exc:
|
||||
_logger.error('dispatch_message failed: %s', exc)
|
||||
raise UserError(_('Could not reach AI service: %s') % exc)
|
||||
|
||||
@api.model
|
||||
def cron_ping_all(self):
|
||||
for bot in self.search([('active', '=', True)]):
|
||||
try:
|
||||
bot.action_ping()
|
||||
except Exception as exc:
|
||||
_logger.warning('Ping failed for bot %s: %s', bot.id, exc)
|
||||
77
addons/activeblue_ai/models/ab_ai_directive.py
Normal file
77
addons/activeblue_ai/models/ab_ai_directive.py
Normal file
@@ -0,0 +1,77 @@
|
||||
from odoo import models, fields, api, _
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AbAiDirective(models.Model):
|
||||
_name = 'ab.ai.directive'
|
||||
_description = 'AI Directive Log'
|
||||
_order = 'create_date desc'
|
||||
_rec_name = 'directive_id'
|
||||
|
||||
directive_id = fields.Char(string='Directive ID', required=True, index=True, readonly=True)
|
||||
user_id = fields.Many2one('res.users', string='User', readonly=True, index=True)
|
||||
message = fields.Text(string='User Message', readonly=True)
|
||||
reply = fields.Text(string='AI Reply', readonly=True)
|
||||
status = fields.Selection(
|
||||
[
|
||||
('pending', 'Pending'),
|
||||
('running', 'Running'),
|
||||
('pending_approval', 'Pending Approval'),
|
||||
('approved', 'Approved'),
|
||||
('rejected', 'Rejected'),
|
||||
('completed', 'Completed'),
|
||||
('failed', 'Failed'),
|
||||
('timeout', 'Timeout'),
|
||||
],
|
||||
string='Status',
|
||||
default='pending',
|
||||
required=True,
|
||||
index=True,
|
||||
readonly=True,
|
||||
)
|
||||
agents_involved = fields.Char(string='Agents', readonly=True)
|
||||
escalations = fields.Text(string='Escalations', readonly=True)
|
||||
actions_taken = fields.Text(string='Actions Taken', readonly=True)
|
||||
session_id = fields.Char(string='Session ID', readonly=True, index=True)
|
||||
duration_ms = fields.Integer(string='Duration (ms)', readonly=True)
|
||||
error_message = fields.Text(string='Error', readonly=True)
|
||||
|
||||
def action_view_detail(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'ab.ai.directive',
|
||||
'res_id': self.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
}
|
||||
|
||||
@api.model
|
||||
def record_directive(self, directive_id, user_id, message, reply, status,
|
||||
agents=None, escalations=None, actions=None,
|
||||
session_id=None, duration_ms=None, error=None):
|
||||
import json
|
||||
vals = {
|
||||
'directive_id': directive_id,
|
||||
'user_id': user_id,
|
||||
'message': message,
|
||||
'reply': reply,
|
||||
'status': status,
|
||||
'agents_involved': ', '.join(agents) if agents else '',
|
||||
'escalations': json.dumps(escalations) if escalations else '',
|
||||
'actions_taken': json.dumps(actions) if actions else '',
|
||||
'session_id': session_id,
|
||||
'duration_ms': duration_ms or 0,
|
||||
'error_message': error or '',
|
||||
}
|
||||
return self.create(vals)
|
||||
|
||||
@api.model
|
||||
def cron_cleanup_old(self):
|
||||
cutoff = fields.Datetime.subtract(fields.Datetime.now(), days=90)
|
||||
old = self.search([('create_date', '<', cutoff), ('status', 'in', ['completed', 'failed', 'timeout'])])
|
||||
count = len(old)
|
||||
old.unlink()
|
||||
_logger.info('Cleaned up %d old directives', count)
|
||||
46
addons/activeblue_ai/models/ab_ai_log.py
Normal file
46
addons/activeblue_ai/models/ab_ai_log.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from odoo import models, fields, api
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AbAiLog(models.Model):
|
||||
_name = 'ab.ai.log'
|
||||
_description = 'AI Activity Log'
|
||||
_order = 'create_date desc'
|
||||
_rec_name = 'summary'
|
||||
|
||||
summary = fields.Char(string='Summary', required=True, readonly=True)
|
||||
level = fields.Selection(
|
||||
[('info', 'Info'), ('warning', 'Warning'), ('error', 'Error'), ('debug', 'Debug')],
|
||||
string='Level',
|
||||
default='info',
|
||||
required=True,
|
||||
index=True,
|
||||
)
|
||||
agent_name = fields.Char(string='Agent', readonly=True, index=True)
|
||||
directive_id = fields.Char(string='Directive ID', readonly=True, index=True)
|
||||
user_id = fields.Many2one('res.users', string='User', readonly=True)
|
||||
details = fields.Text(string='Details', readonly=True)
|
||||
model_name = fields.Char(string='Model', readonly=True)
|
||||
record_id = fields.Integer(string='Record ID', readonly=True)
|
||||
|
||||
@api.model
|
||||
def log(self, summary, level='info', agent=None, directive_id=None,
|
||||
user_id=None, details=None, model=None, record_id=None):
|
||||
return self.create({
|
||||
'summary': summary[:255] if summary else '',
|
||||
'level': level,
|
||||
'agent_name': agent or '',
|
||||
'directive_id': directive_id or '',
|
||||
'user_id': user_id,
|
||||
'details': details or '',
|
||||
'model_name': model or '',
|
||||
'record_id': record_id or 0,
|
||||
})
|
||||
|
||||
@api.model
|
||||
def cron_cleanup(self):
|
||||
cutoff = fields.Datetime.subtract(fields.Datetime.now(), days=30)
|
||||
old = self.search([('create_date', '<', cutoff), ('level', 'in', ['info', 'debug'])])
|
||||
old.unlink()
|
||||
87
addons/activeblue_ai/models/ab_ai_registry.py
Normal file
87
addons/activeblue_ai/models/ab_ai_registry.py
Normal file
@@ -0,0 +1,87 @@
|
||||
from odoo import models, fields, api
|
||||
import requests
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AbAiAgentRegistry(models.Model):
|
||||
_name = 'ab.ai.agent.registry'
|
||||
_description = 'AI Agent Registry'
|
||||
_rec_name = 'agent_name'
|
||||
|
||||
agent_name = fields.Char(string='Agent Name', required=True, index=True)
|
||||
domain = fields.Char(string='Domain')
|
||||
active = fields.Boolean(default=True, index=True)
|
||||
backend = fields.Selection(
|
||||
[('ollama', 'Ollama (Local)'), ('claude', 'Claude (Cloud)')],
|
||||
string='LLM Backend',
|
||||
default='ollama',
|
||||
)
|
||||
description = fields.Text(string='Description')
|
||||
last_sweep = fields.Datetime(string='Last Sweep', readonly=True)
|
||||
sweep_count = fields.Integer(string='Sweep Count', default=0, readonly=True)
|
||||
error_count = fields.Integer(string='Error Count', default=0, readonly=True)
|
||||
|
||||
_sql_constraints = [
|
||||
('agent_name_uniq', 'unique(agent_name)', 'Agent name must be unique'),
|
||||
]
|
||||
|
||||
@api.model
|
||||
def sync_from_service(self):
|
||||
bot = self.env['ab.ai.bot'].search([('active', '=', True)], limit=1)
|
||||
if not bot:
|
||||
_logger.warning('No active bot — cannot sync registry')
|
||||
return 0
|
||||
url = bot._get_service_url() + '/registry/agents'
|
||||
try:
|
||||
resp = requests.get(url, headers=bot._build_headers(), timeout=10)
|
||||
resp.raise_for_status()
|
||||
agents = resp.json()
|
||||
except Exception as exc:
|
||||
_logger.error('Registry sync failed: %s', exc)
|
||||
return 0
|
||||
|
||||
synced = 0
|
||||
for agent_data in agents:
|
||||
name = agent_data.get('name', '')
|
||||
if not name:
|
||||
continue
|
||||
existing = self.search([('agent_name', '=', name)], limit=1)
|
||||
vals = {
|
||||
'agent_name': name,
|
||||
'domain': agent_data.get('domain', ''),
|
||||
'active': agent_data.get('active', True),
|
||||
'backend': agent_data.get('backend', 'ollama'),
|
||||
}
|
||||
if existing:
|
||||
existing.write(vals)
|
||||
else:
|
||||
self.create(vals)
|
||||
synced += 1
|
||||
_logger.info('Synced %d agents from service', synced)
|
||||
return synced
|
||||
|
||||
def action_set_backend_claude(self):
|
||||
self._set_backend('claude')
|
||||
|
||||
def action_set_backend_ollama(self):
|
||||
self._set_backend('ollama')
|
||||
|
||||
def _set_backend(self, backend):
|
||||
self.ensure_one()
|
||||
bot = self.env['ab.ai.bot'].search([('active', '=', True)], limit=1)
|
||||
if not bot:
|
||||
return
|
||||
url = bot._get_service_url() + '/registry/backend'
|
||||
payload = {
|
||||
'agent_name': self.agent_name,
|
||||
'backend': backend,
|
||||
'set_by': str(self.env.user.id),
|
||||
}
|
||||
try:
|
||||
resp = requests.post(url, json=payload, headers=bot._build_headers(), timeout=10)
|
||||
resp.raise_for_status()
|
||||
self.write({'backend': backend})
|
||||
except Exception as exc:
|
||||
_logger.error('set_backend failed: %s', exc)
|
||||
9
addons/activeblue_ai/security/ir.model.access.csv
Normal file
9
addons/activeblue_ai/security/ir.model.access.csv
Normal file
@@ -0,0 +1,9 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_ab_ai_bot_manager,ab.ai.bot manager,model_ab_ai_bot,group_ai_manager,1,1,1,1
|
||||
access_ab_ai_bot_user,ab.ai.bot user,model_ab_ai_bot,group_ai_user,1,0,0,0
|
||||
access_ab_ai_directive_manager,ab.ai.directive manager,model_ab_ai_directive,group_ai_manager,1,1,1,1
|
||||
access_ab_ai_directive_user,ab.ai.directive user,model_ab_ai_directive,group_ai_user,1,0,0,0
|
||||
access_ab_ai_log_manager,ab.ai.log manager,model_ab_ai_log,group_ai_manager,1,1,1,1
|
||||
access_ab_ai_log_user,ab.ai.log user,model_ab_ai_log,group_ai_user,1,0,0,0
|
||||
access_ab_ai_registry_manager,ab.ai.agent.registry manager,model_ab_ai_agent_registry,group_ai_manager,1,1,1,1
|
||||
access_ab_ai_registry_user,ab.ai.agent.registry user,model_ab_ai_agent_registry,group_ai_user,1,0,0,0
|
||||
|
24
addons/activeblue_ai/security/res_groups.xml
Normal file
24
addons/activeblue_ai/security/res_groups.xml
Normal file
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<record id="module_activeblue_ai" model="ir.module.category">
|
||||
<field name="name">ActiveBlue AI</field>
|
||||
<field name="sequence">50</field>
|
||||
</record>
|
||||
|
||||
<record id="group_ai_user" model="res.groups">
|
||||
<field name="name">AI User</field>
|
||||
<field name="category_id" ref="module_activeblue_ai"/>
|
||||
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
|
||||
<field name="comment">Can use the AI chat assistant</field>
|
||||
</record>
|
||||
|
||||
<record id="group_ai_manager" model="res.groups">
|
||||
<field name="name">AI Manager</field>
|
||||
<field name="category_id" ref="module_activeblue_ai"/>
|
||||
<field name="implied_ids" eval="[(4, ref('group_ai_user'))]"/>
|
||||
<field name="users" eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"/>
|
||||
<field name="comment">Can configure AI, approve directives, and view all logs</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
255
addons/activeblue_ai/static/src/css/activeblue_ai.css
Normal file
255
addons/activeblue_ai/static/src/css/activeblue_ai.css
Normal file
@@ -0,0 +1,255 @@
|
||||
/* ActiveBlue AI — systray + panel styles */
|
||||
|
||||
/* Systray brain icon */
|
||||
.ab-ai-systray {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
padding: 0 8px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ab-ai-brain-icon {
|
||||
font-size: 18px;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.ab-ai-badge {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
background: #dc3545;
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
font-size: 10px;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Slide-in panel */
|
||||
.ab-ai-panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: -420px;
|
||||
width: 400px;
|
||||
height: 100vh;
|
||||
background: #fff;
|
||||
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.15);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 9000;
|
||||
transition: right 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
border-left: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.ab-ai-panel--open {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.ab-ai-panel__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background: #1a2b4b;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ab-ai-panel__title {
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.ab-ai-panel__header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Status indicator dot */
|
||||
.ab-ai-status-dot {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #6c757d;
|
||||
}
|
||||
.ab-ai-status-dot--online { background: #28a745; }
|
||||
.ab-ai-status-dot--offline { background: #dc3545; }
|
||||
.ab-ai-status-dot--degraded { background: #ffc107; }
|
||||
|
||||
/* Messages area */
|
||||
.ab-ai-panel__messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.ab-ai-panel__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Message bubbles */
|
||||
.ab-ai-msg {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 85%;
|
||||
}
|
||||
|
||||
.ab-ai-msg--user {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.ab-ai-msg--assistant,
|
||||
.ab-ai-msg--error {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.ab-ai-msg__bubble {
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.ab-ai-msg--user .ab-ai-msg__bubble {
|
||||
background: #1a2b4b;
|
||||
color: #fff;
|
||||
border-bottom-right-radius: 2px;
|
||||
}
|
||||
|
||||
.ab-ai-msg--assistant .ab-ai-msg__bubble {
|
||||
background: #f1f3f5;
|
||||
color: #212529;
|
||||
border-bottom-left-radius: 2px;
|
||||
}
|
||||
|
||||
.ab-ai-msg--error .ab-ai-msg__bubble {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
border: 1px solid #ffc107;
|
||||
}
|
||||
|
||||
/* Loading dots */
|
||||
.ab-ai-msg__bubble--loading {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.ab-ai-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #adb5bd;
|
||||
animation: ab-ai-bounce 1.2s infinite ease-in-out;
|
||||
}
|
||||
.ab-ai-dot:nth-child(2) { animation-delay: 0.2s; }
|
||||
.ab-ai-dot:nth-child(3) { animation-delay: 0.4s; }
|
||||
|
||||
@keyframes ab-ai-bounce {
|
||||
0%, 80%, 100% { transform: scale(0.8); opacity: 0.5; }
|
||||
40% { transform: scale(1.2); opacity: 1; }
|
||||
}
|
||||
|
||||
/* Escalations */
|
||||
.ab-ai-msg__escalations {
|
||||
margin-top: 6px;
|
||||
padding: 8px 12px;
|
||||
background: #fff3cd;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ab-ai-escalation {
|
||||
margin-top: 4px;
|
||||
padding-left: 12px;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
/* Approval section */
|
||||
.ab-ai-panel__approvals {
|
||||
background: #fff8e1;
|
||||
border-top: 1px solid #ffe082;
|
||||
padding: 12px 16px;
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ab-ai-approval-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 8px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ab-ai-approval-btns {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Input area */
|
||||
.ab-ai-panel__input {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid #dee2e6;
|
||||
background: #f8f9fa;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ab-ai-panel__textarea {
|
||||
flex: 1;
|
||||
resize: none;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
line-height: 1.4;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.ab-ai-panel__textarea:focus {
|
||||
outline: none;
|
||||
border-color: #1a2b4b;
|
||||
box-shadow: 0 0 0 2px rgba(26, 43, 75, 0.15);
|
||||
}
|
||||
|
||||
.ab-ai-panel__send {
|
||||
align-self: flex-end;
|
||||
border-radius: 8px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #1a2b4b;
|
||||
border-color: #1a2b4b;
|
||||
}
|
||||
|
||||
.ab-ai-panel__send:hover:not(:disabled) {
|
||||
background: #243660;
|
||||
border-color: #243660;
|
||||
}
|
||||
131
addons/activeblue_ai/static/src/js/components/ai_panel.js
Normal file
131
addons/activeblue_ai/static/src/js/components/ai_panel.js
Normal file
@@ -0,0 +1,131 @@
|
||||
/** @odoo-module **/
|
||||
import { Component, useState, useRef, onMounted, onWillUnmount } from '@odoo/owl';
|
||||
import { useService } from '@web/core/utils/hooks';
|
||||
import { registry } from '@web/core/registry';
|
||||
|
||||
let _msgCounter = 0;
|
||||
function nextId() { return ++_msgCounter; }
|
||||
|
||||
export class AiPanel extends Component {
|
||||
static template = 'activeblue_ai.AiPanel';
|
||||
static props = {
|
||||
isOpen: Boolean,
|
||||
onClose: Function,
|
||||
serviceStatus: { type: String, optional: true },
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.rpc = useService('rpc');
|
||||
this.notification = useService('notification');
|
||||
this.messagesRef = useRef('messagesContainer');
|
||||
this.inputRef = useRef('inputEl');
|
||||
|
||||
this.state = useState({
|
||||
messages: [],
|
||||
inputText: '',
|
||||
loading: false,
|
||||
pendingApprovals: [],
|
||||
sessionId: null,
|
||||
});
|
||||
|
||||
this._approvalPollInterval = null;
|
||||
onMounted(() => {
|
||||
this._startApprovalPoll();
|
||||
});
|
||||
onWillUnmount(() => {
|
||||
if (this._approvalPollInterval) {
|
||||
clearInterval(this._approvalPollInterval);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get statusDotClass() {
|
||||
const s = this.props.serviceStatus || 'unknown';
|
||||
return `ab-ai-status-dot ab-ai-status-dot--${s}`;
|
||||
}
|
||||
|
||||
get statusText() {
|
||||
return this.props.serviceStatus === 'online' ? 'Online' : 'Offline';
|
||||
}
|
||||
|
||||
onKeyDown(ev) {
|
||||
if (ev.key === 'Enter' && !ev.shiftKey) {
|
||||
ev.preventDefault();
|
||||
this.sendMessage();
|
||||
}
|
||||
}
|
||||
|
||||
async sendMessage() {
|
||||
const text = this.state.inputText.trim();
|
||||
if (!text || this.state.loading) return;
|
||||
|
||||
this.state.messages.push({ id: nextId(), role: 'user', content: text });
|
||||
this.state.inputText = '';
|
||||
this.state.loading = true;
|
||||
this._scrollToBottom();
|
||||
|
||||
try {
|
||||
const result = await this.rpc('/ai/chat', {
|
||||
message: text,
|
||||
context: {},
|
||||
session_id: this.state.sessionId,
|
||||
});
|
||||
const reply = result.reply || '(no response)';
|
||||
this.state.messages.push({
|
||||
id: nextId(),
|
||||
role: 'assistant',
|
||||
content: reply,
|
||||
escalations: result.escalations || [],
|
||||
});
|
||||
if (result.session_id) {
|
||||
this.state.sessionId = result.session_id;
|
||||
}
|
||||
} catch (err) {
|
||||
this.state.messages.push({
|
||||
id: nextId(),
|
||||
role: 'error',
|
||||
content: 'Error reaching AI service. Please try again.',
|
||||
});
|
||||
} finally {
|
||||
this.state.loading = false;
|
||||
this._scrollToBottom();
|
||||
}
|
||||
}
|
||||
|
||||
async approve(directiveId, approved) {
|
||||
try {
|
||||
await this.rpc('/ai/approval/respond', {
|
||||
directive_id: directiveId,
|
||||
approved,
|
||||
});
|
||||
this.state.pendingApprovals = this.state.pendingApprovals.filter(
|
||||
(a) => a.directive_id !== directiveId
|
||||
);
|
||||
this.notification.add(approved ? 'Directive approved' : 'Directive rejected', {
|
||||
type: approved ? 'success' : 'warning',
|
||||
});
|
||||
} catch (err) {
|
||||
this.notification.add('Failed to respond to approval request', { type: 'danger' });
|
||||
}
|
||||
}
|
||||
|
||||
_startApprovalPoll() {
|
||||
const poll = async () => {
|
||||
try {
|
||||
const result = await this.rpc('/ai/approval/pending', {});
|
||||
this.state.pendingApprovals = result.items || [];
|
||||
} catch {
|
||||
// Silently ignore poll errors
|
||||
}
|
||||
};
|
||||
poll();
|
||||
this._approvalPollInterval = setInterval(poll, 30000);
|
||||
}
|
||||
|
||||
_scrollToBottom() {
|
||||
const el = this.messagesRef.el;
|
||||
if (el) {
|
||||
setTimeout(() => { el.scrollTop = el.scrollHeight; }, 50);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/** @odoo-module **/
|
||||
import { Component, useState, onMounted, onWillUnmount } from '@odoo/owl';
|
||||
import { registry } from '@web/core/registry';
|
||||
import { useService } from '@web/core/utils/hooks';
|
||||
import { AiPanel } from './ai_panel';
|
||||
|
||||
export class SystrayButton extends Component {
|
||||
static template = 'activeblue_ai.SystrayButton';
|
||||
static components = { AiPanel };
|
||||
|
||||
setup() {
|
||||
this.rpc = useService('rpc');
|
||||
this.state = useState({
|
||||
panelOpen: false,
|
||||
serviceStatus: 'unknown',
|
||||
pendingCount: 0,
|
||||
});
|
||||
|
||||
this._statusInterval = null;
|
||||
onMounted(() => {
|
||||
this._checkStatus();
|
||||
this._statusInterval = setInterval(() => this._checkStatus(), 60000);
|
||||
});
|
||||
onWillUnmount(() => {
|
||||
if (this._statusInterval) clearInterval(this._statusInterval);
|
||||
});
|
||||
}
|
||||
|
||||
get iconClass() {
|
||||
return {
|
||||
'text-success': this.state.serviceStatus === 'online',
|
||||
'text-danger': this.state.serviceStatus === 'offline',
|
||||
'text-muted': this.state.serviceStatus === 'unknown',
|
||||
};
|
||||
}
|
||||
|
||||
get statusTitle() {
|
||||
return `ActiveBlue AI — ${this.state.serviceStatus}`;
|
||||
}
|
||||
|
||||
togglePanel() {
|
||||
this.state.panelOpen = !this.state.panelOpen;
|
||||
}
|
||||
|
||||
closePanel() {
|
||||
this.state.panelOpen = false;
|
||||
}
|
||||
|
||||
async _checkStatus() {
|
||||
try {
|
||||
const result = await fetch('/ai/health');
|
||||
if (result.ok) {
|
||||
const data = await result.json();
|
||||
this.state.serviceStatus = data.status === 'ok' ? 'online' : 'degraded';
|
||||
} else {
|
||||
this.state.serviceStatus = 'offline';
|
||||
}
|
||||
} catch {
|
||||
this.state.serviceStatus = 'offline';
|
||||
}
|
||||
try {
|
||||
const pending = await this.rpc('/ai/approval/pending', {});
|
||||
this.state.pendingCount = (pending.items || []).length;
|
||||
} catch {
|
||||
this.state.pendingCount = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registry.category('systray').add('activeblue_ai.systray_button', {
|
||||
Component: SystrayButton,
|
||||
sequence: 1,
|
||||
});
|
||||
83
addons/activeblue_ai/static/src/xml/ai_panel.xml
Normal file
83
addons/activeblue_ai/static/src/xml/ai_panel.xml
Normal file
@@ -0,0 +1,83 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="activeblue_ai.AiPanel" owl="1">
|
||||
<div class="ab-ai-panel" t-att-class="{ 'ab-ai-panel--open': props.isOpen }">
|
||||
<div class="ab-ai-panel__header">
|
||||
<span class="ab-ai-panel__title">
|
||||
<i class="fa fa-brain"/> ActiveBlue AI
|
||||
</span>
|
||||
<div class="ab-ai-panel__header-actions">
|
||||
<span t-att-class="statusDotClass" t-att-title="statusText"/>
|
||||
<button class="btn btn-sm btn-light" t-on-click="props.onClose" title="Close">
|
||||
<i class="fa fa-times"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ab-ai-panel__messages" t-ref="messagesContainer">
|
||||
<t t-if="state.messages.length === 0">
|
||||
<div class="ab-ai-panel__empty">
|
||||
<i class="fa fa-brain fa-2x text-muted"/>
|
||||
<p class="text-muted mt-2">Ask me anything about your Odoo data.</p>
|
||||
</div>
|
||||
</t>
|
||||
<t t-foreach="state.messages" t-as="msg" t-key="msg.id">
|
||||
<div t-att-class="'ab-ai-msg ab-ai-msg--' + msg.role">
|
||||
<div class="ab-ai-msg__bubble">
|
||||
<t t-esc="msg.content"/>
|
||||
</div>
|
||||
<t t-if="msg.escalations and msg.escalations.length">
|
||||
<div class="ab-ai-msg__escalations">
|
||||
<i class="fa fa-exclamation-triangle text-warning"/> Escalations:
|
||||
<t t-foreach="msg.escalations" t-as="esc" t-key="esc_index">
|
||||
<div class="ab-ai-escalation" t-esc="esc"/>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="state.loading">
|
||||
<div class="ab-ai-msg ab-ai-msg--assistant">
|
||||
<div class="ab-ai-msg__bubble ab-ai-msg__bubble--loading">
|
||||
<span class="ab-ai-dot"/><span class="ab-ai-dot"/><span class="ab-ai-dot"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<t t-if="state.pendingApprovals.length > 0">
|
||||
<div class="ab-ai-panel__approvals">
|
||||
<strong><i class="fa fa-clock-o"/> Pending Approval</strong>
|
||||
<t t-foreach="state.pendingApprovals" t-as="item" t-key="item.directive_id">
|
||||
<div class="ab-ai-approval-item">
|
||||
<span t-esc="item.description"/>
|
||||
<div class="ab-ai-approval-btns">
|
||||
<button class="btn btn-sm btn-success" t-on-click="() => this.approve(item.directive_id, true)">
|
||||
Approve
|
||||
</button>
|
||||
<button class="btn btn-sm btn-danger" t-on-click="() => this.approve(item.directive_id, false)">
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<div class="ab-ai-panel__input">
|
||||
<textarea
|
||||
class="ab-ai-panel__textarea"
|
||||
t-ref="inputEl"
|
||||
t-model="state.inputText"
|
||||
placeholder="Ask ActiveBlue AI..."
|
||||
t-on-keydown="onKeyDown"
|
||||
rows="2"
|
||||
t-att-disabled="state.loading"
|
||||
/>
|
||||
<button class="btn btn-primary ab-ai-panel__send" t-on-click="sendMessage" t-att-disabled="state.loading or !state.inputText.trim()">
|
||||
<i class="fa fa-paper-plane"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
9
addons/activeblue_ai/static/src/xml/systray.xml
Normal file
9
addons/activeblue_ai/static/src/xml/systray.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="activeblue_ai.SystrayButton" owl="1">
|
||||
<div class="o_menu_systray_item ab-ai-systray" t-on-click="togglePanel" t-att-title="statusTitle">
|
||||
<i class="fa fa-brain ab-ai-brain-icon" t-att-class="iconClass"/>
|
||||
<span t-if="state.pendingCount > 0" class="ab-ai-badge" t-esc="state.pendingCount"/>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
59
addons/activeblue_ai/views/ab_ai_bot_views.xml
Normal file
59
addons/activeblue_ai/views/ab_ai_bot_views.xml
Normal file
@@ -0,0 +1,59 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_ab_ai_bot_form" model="ir.ui.view">
|
||||
<field name="name">ab.ai.bot.form</field>
|
||||
<field name="model">ab.ai.bot</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="AI Bot Configuration">
|
||||
<header>
|
||||
<button name="action_ping" string="Ping Service" type="object" class="btn-primary"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box"/>
|
||||
<widget name="web_ribbon" title="Offline" bg_color="bg-danger"
|
||||
attrs="{'invisible': [('status', '!=', 'offline')]}"/>
|
||||
<widget name="web_ribbon" title="Online" bg_color="bg-success"
|
||||
attrs="{'invisible': [('status', '!=', 'online')]}"/>
|
||||
<widget name="web_ribbon" title="Error" bg_color="bg-warning"
|
||||
attrs="{'invisible': [('status', '!=', 'error')]}"/>
|
||||
<group>
|
||||
<group string="Configuration">
|
||||
<field name="display_name"/>
|
||||
<field name="active" widget="boolean_toggle"/>
|
||||
<field name="agent_service_url"/>
|
||||
<field name="webhook_secret" password="True"/>
|
||||
<field name="privacy_mode"/>
|
||||
</group>
|
||||
<group string="Status">
|
||||
<field name="status" readonly="1"/>
|
||||
<field name="last_ping" readonly="1"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Notes">
|
||||
<field name="notes" nolabel="1" colspan="2"/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_ab_ai_bot_list" model="ir.ui.view">
|
||||
<field name="name">ab.ai.bot.list</field>
|
||||
<field name="model">ab.ai.bot</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="AI Bots">
|
||||
<field name="display_name"/>
|
||||
<field name="agent_service_url"/>
|
||||
<field name="privacy_mode"/>
|
||||
<field name="status"/>
|
||||
<field name="last_ping"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_ab_ai_bot" model="ir.actions.act_window">
|
||||
<field name="name">AI Bot Configuration</field>
|
||||
<field name="res_model">ab.ai.bot</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
</odoo>
|
||||
77
addons/activeblue_ai/views/ab_ai_directive_views.xml
Normal file
77
addons/activeblue_ai/views/ab_ai_directive_views.xml
Normal file
@@ -0,0 +1,77 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_ab_ai_directive_list" model="ir.ui.view">
|
||||
<field name="name">ab.ai.directive.list</field>
|
||||
<field name="model">ab.ai.directive</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="AI Directives" decoration-danger="status == 'failed'" decoration-warning="status == 'pending_approval'">
|
||||
<field name="create_date" string="Date"/>
|
||||
<field name="user_id"/>
|
||||
<field name="message" string="User Message"/>
|
||||
<field name="status"/>
|
||||
<field name="agents_involved"/>
|
||||
<field name="duration_ms" string="ms"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_ab_ai_directive_form" model="ir.ui.view">
|
||||
<field name="name">ab.ai.directive.form</field>
|
||||
<field name="model">ab.ai.directive</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="AI Directive">
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="directive_id"/>
|
||||
<field name="user_id"/>
|
||||
<field name="status"/>
|
||||
<field name="agents_involved"/>
|
||||
<field name="duration_ms"/>
|
||||
<field name="session_id"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Message">
|
||||
<field name="message" nolabel="1" readonly="1"/>
|
||||
</group>
|
||||
<group string="AI Reply">
|
||||
<field name="reply" nolabel="1" readonly="1"/>
|
||||
</group>
|
||||
<group string="Escalations" attrs="{'invisible': [('escalations', '=', False)]}">
|
||||
<field name="escalations" nolabel="1" readonly="1"/>
|
||||
</group>
|
||||
<group string="Actions Taken" attrs="{'invisible': [('actions_taken', '=', False)]}">
|
||||
<field name="actions_taken" nolabel="1" readonly="1"/>
|
||||
</group>
|
||||
<group string="Error" attrs="{'invisible': [('error_message', '=', False)]}">
|
||||
<field name="error_message" nolabel="1" readonly="1"/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_ab_ai_directive_search" model="ir.ui.view">
|
||||
<field name="name">ab.ai.directive.search</field>
|
||||
<field name="model">ab.ai.directive</field>
|
||||
<field name="arch" type="xml">
|
||||
<search>
|
||||
<field name="user_id"/>
|
||||
<field name="status"/>
|
||||
<filter name="pending_approval" string="Pending Approval" domain="[('status','=','pending_approval')]"/>
|
||||
<filter name="failed" string="Failed" domain="[('status','in',['failed','timeout'])]"/>
|
||||
<group expand="0" string="Group By">
|
||||
<filter name="group_by_user" string="User" context="{'group_by': 'user_id'}"/>
|
||||
<filter name="group_by_status" string="Status" context="{'group_by': 'status'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_ab_ai_directive" model="ir.actions.act_window">
|
||||
<field name="name">AI Directives</field>
|
||||
<field name="res_model">ab.ai.directive</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="search_view_id" ref="view_ab_ai_directive_search"/>
|
||||
</record>
|
||||
</odoo>
|
||||
23
addons/activeblue_ai/views/ab_ai_log_views.xml
Normal file
23
addons/activeblue_ai/views/ab_ai_log_views.xml
Normal file
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_ab_ai_log_list" model="ir.ui.view">
|
||||
<field name="name">ab.ai.log.list</field>
|
||||
<field name="model">ab.ai.log</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="AI Logs" decoration-danger="level == 'error'" decoration-warning="level == 'warning'">
|
||||
<field name="create_date" string="Date"/>
|
||||
<field name="level"/>
|
||||
<field name="agent_name"/>
|
||||
<field name="summary"/>
|
||||
<field name="user_id"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_ab_ai_log" model="ir.actions.act_window">
|
||||
<field name="name">AI Logs</field>
|
||||
<field name="res_model">ab.ai.log</field>
|
||||
<field name="view_mode">list</field>
|
||||
<field name="domain">[('level', 'in', ['info','warning','error'])]</field>
|
||||
</record>
|
||||
</odoo>
|
||||
26
addons/activeblue_ai/views/ab_ai_registry_views.xml
Normal file
26
addons/activeblue_ai/views/ab_ai_registry_views.xml
Normal file
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_ab_ai_registry_list" model="ir.ui.view">
|
||||
<field name="name">ab.ai.agent.registry.list</field>
|
||||
<field name="model">ab.ai.agent.registry</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Agent Registry">
|
||||
<field name="agent_name"/>
|
||||
<field name="domain"/>
|
||||
<field name="active" widget="boolean_toggle"/>
|
||||
<field name="backend"/>
|
||||
<field name="last_sweep"/>
|
||||
<field name="sweep_count"/>
|
||||
<field name="error_count"/>
|
||||
<button name="action_set_backend_claude" string="→ Claude" type="object" icon="fa-cloud"/>
|
||||
<button name="action_set_backend_ollama" string="→ Ollama" type="object" icon="fa-server"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_ab_ai_registry" model="ir.actions.act_window">
|
||||
<field name="name">Agent Registry</field>
|
||||
<field name="res_model">ab.ai.agent.registry</field>
|
||||
<field name="view_mode">list</field>
|
||||
</record>
|
||||
</odoo>
|
||||
42
addons/activeblue_ai/views/menus.xml
Normal file
42
addons/activeblue_ai/views/menus.xml
Normal file
@@ -0,0 +1,42 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<menuitem id="menu_activeblue_ai_root"
|
||||
name="ActiveBlue AI"
|
||||
sequence="99"
|
||||
web_icon="activeblue_ai,static/src/img/icon.png"
|
||||
groups="activeblue_ai.group_ai_user"/>
|
||||
|
||||
<menuitem id="menu_ai_directives"
|
||||
name="Directives"
|
||||
parent="menu_activeblue_ai_root"
|
||||
action="action_ab_ai_directive"
|
||||
sequence="10"
|
||||
groups="activeblue_ai.group_ai_user"/>
|
||||
|
||||
<menuitem id="menu_ai_logs"
|
||||
name="Activity Logs"
|
||||
parent="menu_activeblue_ai_root"
|
||||
action="action_ab_ai_log"
|
||||
sequence="20"
|
||||
groups="activeblue_ai.group_ai_manager"/>
|
||||
|
||||
<menuitem id="menu_ai_config"
|
||||
name="Configuration"
|
||||
parent="menu_activeblue_ai_root"
|
||||
sequence="90"
|
||||
groups="activeblue_ai.group_ai_manager"/>
|
||||
|
||||
<menuitem id="menu_ai_bot_config"
|
||||
name="Bot Settings"
|
||||
parent="menu_ai_config"
|
||||
action="action_ab_ai_bot"
|
||||
sequence="10"
|
||||
groups="activeblue_ai.group_ai_manager"/>
|
||||
|
||||
<menuitem id="menu_ai_registry"
|
||||
name="Agent Registry"
|
||||
parent="menu_ai_config"
|
||||
action="action_ab_ai_registry"
|
||||
sequence="20"
|
||||
groups="activeblue_ai.group_ai_manager"/>
|
||||
</odoo>
|
||||
0
agent_service/agents/__init__.py
Normal file
0
agent_service/agents/__init__.py
Normal file
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.')
|
||||
196
agent_service/agents/base_agent.py
Normal file
196
agent_service/agents/base_agent.py
Normal file
@@ -0,0 +1,196 @@
|
||||
from __future__ import annotations
|
||||
import asyncio, json, logging, time
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
from ..llm.tool_validator import ToolCallValidator, ToolValidationError, validate_agent_tools
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentDirective:
|
||||
directive_id: str
|
||||
agent: str
|
||||
task: str
|
||||
params: dict
|
||||
context: object # DirectiveContext
|
||||
authorized_actions: list
|
||||
constraints: dict
|
||||
approved: bool = False
|
||||
approval_item_id: object = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class DirectiveContext:
|
||||
client_profile: dict = field(default_factory=dict)
|
||||
recent_findings: list = field(default_factory=list)
|
||||
conversation_summary: str = ''
|
||||
peer_data: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentReport:
|
||||
directive_id: str
|
||||
agent: str
|
||||
status: str # complete | partial | failed | escalated
|
||||
summary: str
|
||||
actions_taken: list = field(default_factory=list)
|
||||
escalations: list = field(default_factory=list)
|
||||
peer_calls_made: list = field(default_factory=list)
|
||||
recommendations: list = field(default_factory=list)
|
||||
data: dict = field(default_factory=dict)
|
||||
error: object = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SweepReport:
|
||||
agent: str
|
||||
findings: list = field(default_factory=list)
|
||||
actions_taken: list = field(default_factory=list)
|
||||
recommendations: list = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolResult:
|
||||
name: str
|
||||
success: bool
|
||||
data: Any = None
|
||||
error: object = None
|
||||
|
||||
|
||||
AUTO_ACT_THRESHOLDS = {
|
||||
'read': True, 'search': True, 'report': True,
|
||||
'create_non_financial': True,
|
||||
'create_financial': False,
|
||||
'write_non_financial': True,
|
||||
'write_financial_over_5k': False,
|
||||
'post_chatter': True,
|
||||
'send_email': True,
|
||||
'confirm_document': False,
|
||||
'delete': False,
|
||||
'archive': False,
|
||||
'post_journal_entry': False,
|
||||
'hipaa_sensitive': False,
|
||||
}
|
||||
|
||||
|
||||
class BaseAgent(ABC):
|
||||
name: str = 'base'
|
||||
domain: str = 'base'
|
||||
required_odoo_module: str = 'base'
|
||||
system_prompt_file: str = ''
|
||||
tools: list = []
|
||||
|
||||
def __init__(self, odoo, llm, peer_bus=None):
|
||||
self._odoo = odoo
|
||||
self._llm = llm
|
||||
self._peer_bus = peer_bus
|
||||
self._directive: AgentDirective | None = None
|
||||
self._gathered: dict = {}
|
||||
self._messages: list = []
|
||||
validate_agent_tools(self.tools, self.name)
|
||||
self._validator = ToolCallValidator(self.tools)
|
||||
|
||||
async def execute(self, directive: AgentDirective) -> AgentReport:
|
||||
self._directive = directive
|
||||
self._gathered = {}
|
||||
self._messages = []
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
await self._receive(directive)
|
||||
plan = await self._plan()
|
||||
await self._gather(plan)
|
||||
reasoning = await self._reason()
|
||||
await self._act(reasoning)
|
||||
report = await self._report()
|
||||
ms = int((time.monotonic() - t0) * 1000)
|
||||
logger.info('agent=%s directive=%s status=%s ms=%d',
|
||||
self.name, directive.directive_id, report.status, ms)
|
||||
return report
|
||||
except Exception as exc:
|
||||
logger.error('agent=%s directive=%s FAILED: %s', self.name, directive.directive_id, exc)
|
||||
return AgentReport(
|
||||
directive_id=directive.directive_id, agent=self.name,
|
||||
status='failed', summary=f'Agent failed: {exc}', error=str(exc))
|
||||
|
||||
async def _receive(self, directive):
|
||||
logger.info('agent=%s received directive=%s task=%s', self.name, directive.directive_id, directive.task[:80])
|
||||
|
||||
@abstractmethod
|
||||
async def _plan(self): ...
|
||||
|
||||
@abstractmethod
|
||||
async def _gather(self, plan): ...
|
||||
|
||||
@abstractmethod
|
||||
async def _reason(self): ...
|
||||
|
||||
@abstractmethod
|
||||
async def _act(self, reasoning): ...
|
||||
|
||||
@abstractmethod
|
||||
async def _report(self) -> AgentReport: ...
|
||||
|
||||
async def _run_tool(self, name, params) -> ToolResult:
|
||||
logger.info('agent=%s tool=%s params=%s', self.name, name, list(params.keys()))
|
||||
tool_fn = getattr(self, f'_tool_{name}', None)
|
||||
if tool_fn is None:
|
||||
return ToolResult(name=name, success=False, error=f'Unknown tool: {name}')
|
||||
try:
|
||||
data = await tool_fn(**params)
|
||||
return ToolResult(name=name, success=True, data=data)
|
||||
except Exception as exc:
|
||||
logger.error('agent=%s tool=%s failed: %s', self.name, name, exc)
|
||||
return ToolResult(name=name, success=False, error=str(exc))
|
||||
|
||||
async def _should_auto_act(self, action_type, params=None) -> tuple:
|
||||
auto = AUTO_ACT_THRESHOLDS.get(action_type, False)
|
||||
if auto:
|
||||
return True, 'auto-approved by threshold policy'
|
||||
return False, f'action_type={action_type} requires human approval'
|
||||
|
||||
async def _escalate(self, reason, model, record_id, severity='medium'):
|
||||
item = {
|
||||
'reason': reason, 'model': model, 'record_id': record_id,
|
||||
'severity': severity, 'agent': self.name,
|
||||
'directive_id': self._directive.directive_id if self._directive else None
|
||||
}
|
||||
if not hasattr(self, '_escalations'):
|
||||
self._escalations = []
|
||||
self._escalations.append(item)
|
||||
logger.info('agent=%s escalation severity=%s reason=%s', self.name, severity, reason)
|
||||
return item
|
||||
|
||||
async def _loop(self, messages, tools=None, max_iter=10) -> str:
|
||||
current = list(messages)
|
||||
active_tools = tools or self.tools
|
||||
for iteration in range(max_iter):
|
||||
resp = await self._llm.submit(current, tools=active_tools, caller=self.name)
|
||||
if resp.tool_calls:
|
||||
for raw_call in resp.tool_calls:
|
||||
validated = self._validator.parse_or_fallback(raw_call)
|
||||
if validated is None:
|
||||
# Fallback: ask model for plain text
|
||||
current.append({'role': 'user',
|
||||
'content': 'Tool call was invalid. Please provide your analysis as plain text.'})
|
||||
break
|
||||
tool_result = await self._run_tool(validated['name'], validated['arguments'])
|
||||
current.append({'role': 'assistant', 'content': resp.content or '', 'tool_calls': [raw_call]})
|
||||
current.append({
|
||||
'role': 'tool',
|
||||
'name': validated['name'],
|
||||
'content': json.dumps({'success': tool_result.success, 'data': tool_result.data,
|
||||
'error': tool_result.error})
|
||||
})
|
||||
else:
|
||||
return resp.content
|
||||
logger.warning('agent=%s max iterations %d reached', self.name, max_iter)
|
||||
await self._escalate('Max iterations exceeded', '', 0, severity='medium')
|
||||
return 'Reached maximum iterations. Partial results only.'
|
||||
|
||||
async def handle_peer_request(self, request_type, params, directive_id) -> dict:
|
||||
return {'success': False, 'error': 'Peer requests not implemented for this agent'}
|
||||
|
||||
async def sweep(self) -> SweepReport:
|
||||
return SweepReport(agent=self.name, findings=[], recommendations=[])
|
||||
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.')
|
||||
348
agent_service/agents/finance_agent.py
Normal file
348
agent_service/agents/finance_agent.py
Normal file
@@ -0,0 +1,348 @@
|
||||
from __future__ import annotations
|
||||
import json, logging
|
||||
from .base_agent import BaseAgent, AgentReport, AgentDirective, SweepReport
|
||||
from ..tools.finance_tools import FinanceTools
|
||||
from ..llm.tool_validator import validate_agent_tools
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
FINANCE_TOOLS = [
|
||||
{'name': 'get_invoices', 'description': 'Retrieve invoices with filters',
|
||||
'parameters': {
|
||||
'state': {'type': 'string', 'enum': ['draft','posted','cancel','all'], 'optional': True},
|
||||
'partner_id': {'type': 'integer', 'optional': True},
|
||||
'date_from': {'type': 'string', 'optional': True},
|
||||
'date_to': {'type': 'string', 'optional': True},
|
||||
'move_type': {'type': 'string', 'enum': ['out_invoice','in_invoice','all'], 'optional': True},
|
||||
'limit': {'type': 'integer', 'default': 50, 'optional': True},
|
||||
}},
|
||||
{'name': 'get_overdue_invoices', 'description': 'Get overdue unpaid invoices',
|
||||
'parameters': {
|
||||
'partner_id': {'type': 'integer', 'optional': True},
|
||||
'min_days_overdue': {'type': 'integer', 'default': 1, 'optional': True},
|
||||
}},
|
||||
{'name': 'get_unreconciled_statements', 'description': 'Get unreconciled bank statement lines',
|
||||
'parameters': {
|
||||
'journal_id': {'type': 'integer'},
|
||||
'date_from': {'type': 'string', 'optional': True},
|
||||
'date_to': {'type': 'string', 'optional': True},
|
||||
}},
|
||||
{'name': 'send_payment_reminder', 'description': 'Send payment reminder for overdue invoice',
|
||||
'parameters': {
|
||||
'invoice_id': {'type': 'integer'},
|
||||
'custom_message': {'type': 'string', 'optional': True},
|
||||
}},
|
||||
{'name': 'get_financial_summary', 'description': 'Get financial summary for a period',
|
||||
'parameters': {'period': {'type': 'string', 'optional': True}}},
|
||||
{'name': 'get_payment_history', 'description': 'Get payment history for a partner',
|
||||
'parameters': {'partner_id': {'type': 'integer'}}},
|
||||
{'name': 'flag_for_review', 'description': 'Flag a record for human review',
|
||||
'parameters': {
|
||||
'model': {'type': 'string'},
|
||||
'record_id': {'type': 'integer'},
|
||||
'reason': {'type': 'string'},
|
||||
'severity': {'type': 'string', 'enum': ['low','medium','high'], 'optional': True},
|
||||
}},
|
||||
{'name': 'post_chatter_note', 'description': 'Post a note on a record chatter',
|
||||
'parameters': {
|
||||
'model': {'type': 'string'},
|
||||
'record_id': {'type': 'integer'},
|
||||
'note': {'type': 'string'},
|
||||
}},
|
||||
]
|
||||
|
||||
|
||||
class FinanceAgent(BaseAgent):
|
||||
name = 'finance_agent'
|
||||
domain = 'finance'
|
||||
required_odoo_module = 'account'
|
||||
system_prompt_file = 'finance_system.txt'
|
||||
tools = FINANCE_TOOLS
|
||||
|
||||
def __init__(self, odoo, llm, peer_bus=None):
|
||||
super().__init__(odoo, llm, peer_bus)
|
||||
self._ft = FinanceTools(odoo)
|
||||
self._plan_result = None
|
||||
self._gathered_data = {}
|
||||
self._actions_taken = []
|
||||
self._escalations_list = []
|
||||
self._recommendations = []
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Step 1: _plan
|
||||
# ------------------------------------------------------------------
|
||||
async def _plan(self, directive: AgentDirective) -> dict:
|
||||
intent = (directive.intent or '').lower()
|
||||
plan = {
|
||||
'fetch_overdue': False,
|
||||
'fetch_summary': False,
|
||||
'fetch_invoices': False,
|
||||
'send_reminders': False,
|
||||
'partner_id': directive.context.get('partner_id'),
|
||||
'period': directive.context.get('period', 'this_month'),
|
||||
'journal_id': directive.context.get('journal_id'),
|
||||
}
|
||||
if any(k in intent for k in ('overdue', 'remind', 'reminder', 'collect')):
|
||||
plan['fetch_overdue'] = True
|
||||
if 'remind' in intent or 'send' in intent:
|
||||
plan['send_reminders'] = True
|
||||
if any(k in intent for k in ('summary', 'overview', 'report', 'dashboard')):
|
||||
plan['fetch_summary'] = True
|
||||
if any(k in intent for k in ('invoice', 'bill', 'payment')):
|
||||
plan['fetch_invoices'] = True
|
||||
if not any([plan['fetch_overdue'], plan['fetch_summary'], plan['fetch_invoices']]):
|
||||
plan['fetch_summary'] = True
|
||||
plan['fetch_overdue'] = True
|
||||
self._plan_result = plan
|
||||
logger.debug('FinanceAgent plan: %s', plan)
|
||||
return plan
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Step 2: _gather (read-only)
|
||||
# ------------------------------------------------------------------
|
||||
async def _gather(self, ctx: dict) -> dict:
|
||||
plan = self._plan_result or {}
|
||||
data: dict = {}
|
||||
if plan.get('fetch_overdue'):
|
||||
kwargs = {}
|
||||
if plan.get('partner_id'):
|
||||
kwargs['partner_id'] = plan['partner_id']
|
||||
data['overdue'] = await self._ft.get_overdue_invoices(**kwargs)
|
||||
if plan.get('fetch_summary'):
|
||||
period = plan.get('period', 'this_month')
|
||||
data['summary'] = await self._ft.get_financial_summary(period=period)
|
||||
if plan.get('fetch_invoices'):
|
||||
kwargs = {'state': 'posted', 'limit': 50}
|
||||
if plan.get('partner_id'):
|
||||
kwargs['partner_id'] = plan['partner_id']
|
||||
data['invoices'] = await self._ft.get_invoices(**kwargs)
|
||||
self._gathered_data = data
|
||||
return data
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Step 3: _reason (no tool calls — pure analysis)
|
||||
# ------------------------------------------------------------------
|
||||
async def _reason(self, ctx: dict) -> dict:
|
||||
data = self._gathered_data
|
||||
analysis: dict = {'flags': [], 'recommendations': [], 'escalations': []}
|
||||
|
||||
overdue = data.get('overdue', [])
|
||||
if overdue:
|
||||
total_overdue = sum(inv.get('amount_residual', 0) for inv in overdue)
|
||||
analysis['overdue_count'] = len(overdue)
|
||||
analysis['overdue_total'] = total_overdue
|
||||
if total_overdue > 50000:
|
||||
analysis['escalations'].append(
|
||||
f'High overdue balance: {total_overdue:.2f} across {len(overdue)} invoices'
|
||||
)
|
||||
for inv in overdue:
|
||||
days = inv.get('days_overdue', 0)
|
||||
if days > 90:
|
||||
analysis['flags'].append({
|
||||
'invoice_id': inv.get('id'),
|
||||
'partner': inv.get('partner_name', 'Unknown'),
|
||||
'days': days,
|
||||
'amount': inv.get('amount_residual', 0),
|
||||
'reason': f'Invoice overdue by {days} days',
|
||||
})
|
||||
if days > 30 and inv.get('amount_residual', 0) > 5000:
|
||||
analysis['recommendations'].append({
|
||||
'action': 'send_payment_reminder',
|
||||
'invoice_id': inv.get('id'),
|
||||
'partner': inv.get('partner_name', 'Unknown'),
|
||||
'amount': inv.get('amount_residual', 0),
|
||||
})
|
||||
|
||||
summary = data.get('summary', {})
|
||||
if summary:
|
||||
rate = summary.get('collection_rate', 100)
|
||||
if rate < 70:
|
||||
analysis['escalations'].append(
|
||||
f'Collection rate below threshold: {rate:.1f}%'
|
||||
)
|
||||
|
||||
self._recommendations = analysis.get('recommendations', [])
|
||||
self._escalations_list = analysis.get('escalations', [])
|
||||
return analysis
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Step 4: _act (write actions)
|
||||
# ------------------------------------------------------------------
|
||||
async def _act(self, ctx: dict) -> list:
|
||||
plan = self._plan_result or {}
|
||||
results = []
|
||||
|
||||
if plan.get('send_reminders'):
|
||||
for rec in self._recommendations:
|
||||
if rec.get('action') == 'send_payment_reminder':
|
||||
inv_id = rec.get('invoice_id')
|
||||
if not inv_id:
|
||||
continue
|
||||
try:
|
||||
ok = await self._ft.send_payment_reminder(invoice_id=inv_id)
|
||||
results.append({
|
||||
'action': 'send_payment_reminder',
|
||||
'invoice_id': inv_id,
|
||||
'partner': rec.get('partner'),
|
||||
'success': ok,
|
||||
})
|
||||
except Exception as exc:
|
||||
logger.warning('send_payment_reminder failed inv=%s: %s', inv_id, exc)
|
||||
results.append({
|
||||
'action': 'send_payment_reminder',
|
||||
'invoice_id': inv_id,
|
||||
'success': False,
|
||||
'error': str(exc),
|
||||
})
|
||||
|
||||
flags = (ctx.get('analysis') or {}).get('flags', [])
|
||||
for flag in flags:
|
||||
inv_id = flag.get('invoice_id')
|
||||
if not inv_id:
|
||||
continue
|
||||
try:
|
||||
await self._ft.flag_for_review(
|
||||
model='account.move',
|
||||
record_id=inv_id,
|
||||
reason=flag.get('reason', 'Flagged by finance agent'),
|
||||
severity='high' if flag.get('days', 0) > 90 else 'medium',
|
||||
)
|
||||
results.append({'action': 'flag_for_review', 'invoice_id': inv_id, 'success': True})
|
||||
except Exception as exc:
|
||||
logger.warning('flag_for_review failed inv=%s: %s', inv_id, exc)
|
||||
|
||||
self._actions_taken = results
|
||||
return results
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Step 5: _report
|
||||
# ------------------------------------------------------------------
|
||||
async def _report(self, ctx: dict) -> AgentReport:
|
||||
data = self._gathered_data
|
||||
analysis = ctx.get('analysis') or {}
|
||||
parts = []
|
||||
|
||||
summary = data.get('summary', {})
|
||||
if summary:
|
||||
total_inv = summary.get('total_invoiced', 0)
|
||||
rate = summary.get('collection_rate', 0)
|
||||
parts.append(f'Period summary: {total_inv:.2f} invoiced, {rate:.1f}% collected.')
|
||||
|
||||
overdue_count = analysis.get('overdue_count', 0)
|
||||
overdue_total = analysis.get('overdue_total', 0)
|
||||
if overdue_count:
|
||||
parts.append(f'{overdue_count} overdue invoices totalling {overdue_total:.2f}.')
|
||||
|
||||
sent = [a for a in self._actions_taken if a.get('action') == 'send_payment_reminder' and a.get('success')]
|
||||
if sent:
|
||||
parts.append(f'Payment reminders sent: {len(sent)}.')
|
||||
|
||||
flagged = [a for a in self._actions_taken if a.get('action') == 'flag_for_review' and a.get('success')]
|
||||
if flagged:
|
||||
parts.append(f'Records flagged for review: {len(flagged)}.')
|
||||
|
||||
if not parts:
|
||||
parts.append('Finance check complete. No significant issues found.')
|
||||
|
||||
return AgentReport(
|
||||
agent=self.name,
|
||||
summary=chr(10).join(parts),
|
||||
data={
|
||||
'summary': summary,
|
||||
'overdue_count': overdue_count,
|
||||
'overdue_total': overdue_total,
|
||||
'actions_taken': self._actions_taken,
|
||||
'recommendations': self._recommendations,
|
||||
},
|
||||
escalations=self._escalations_list,
|
||||
actions_taken=self._actions_taken,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Tool dispatcher (called by BaseAgent._loop)
|
||||
# ------------------------------------------------------------------
|
||||
async def _dispatch_tool(self, name: str, args: dict):
|
||||
if name == 'get_invoices':
|
||||
return await self._ft.get_invoices(**args)
|
||||
if name == 'get_overdue_invoices':
|
||||
return await self._ft.get_overdue_invoices(**args)
|
||||
if name == 'get_unreconciled_statements':
|
||||
return await self._ft.get_unreconciled_statements(**args)
|
||||
if name == 'send_payment_reminder':
|
||||
return await self._ft.send_payment_reminder(**args)
|
||||
if name == 'get_financial_summary':
|
||||
return await self._ft.get_financial_summary(**args)
|
||||
if name == 'get_payment_history':
|
||||
return await self._ft.get_payment_history(**args)
|
||||
if name == 'flag_for_review':
|
||||
return await self._ft.flag_for_review(**args)
|
||||
if name == 'post_chatter_note':
|
||||
return await self._ft.post_chatter_note(**args)
|
||||
raise ValueError(f'Unknown tool: {name}')
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Peer bus handler
|
||||
# ------------------------------------------------------------------
|
||||
async def handle_peer_request(self, request: dict) -> dict:
|
||||
req_type = request.get('type', '')
|
||||
try:
|
||||
if req_type == 'overdue_summary':
|
||||
partner_id = request.get('partner_id')
|
||||
kwargs = {}
|
||||
if partner_id:
|
||||
kwargs['partner_id'] = partner_id
|
||||
overdue = await self._ft.get_overdue_invoices(**kwargs)
|
||||
total = sum(inv.get('amount_residual', 0) for inv in overdue)
|
||||
return {'overdue_count': len(overdue), 'overdue_total': total, 'invoices': overdue}
|
||||
if req_type == 'payment_history':
|
||||
partner_id = request.get('partner_id')
|
||||
if not partner_id:
|
||||
return {'error': 'partner_id required'}
|
||||
history = await self._ft.get_payment_history(partner_id=partner_id)
|
||||
return {'history': history}
|
||||
if req_type == 'financial_summary':
|
||||
period = request.get('period', 'this_month')
|
||||
summary = await self._ft.get_financial_summary(period=period)
|
||||
return {'summary': summary}
|
||||
return {'error': f'Unknown peer request type: {req_type}'}
|
||||
except Exception as exc:
|
||||
logger.error('handle_peer_request failed type=%s: %s', req_type, exc)
|
||||
return {'error': str(exc)}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Proactive sweep
|
||||
# ------------------------------------------------------------------
|
||||
async def sweep(self) -> SweepReport:
|
||||
findings = []
|
||||
actions = []
|
||||
try:
|
||||
overdue = await self._ft.get_overdue_invoices(min_days_overdue=30)
|
||||
for inv in overdue:
|
||||
days = inv.get('days_overdue', 0)
|
||||
amount = inv.get('amount_residual', 0)
|
||||
partner = inv.get('partner_name', 'Unknown')
|
||||
findings.append({
|
||||
'type': 'overdue_invoice',
|
||||
'invoice_id': inv.get('id'),
|
||||
'partner': partner,
|
||||
'days_overdue': days,
|
||||
'amount': amount,
|
||||
'severity': 'high' if days > 60 else 'medium',
|
||||
})
|
||||
if days > 60 and amount > 1000:
|
||||
try:
|
||||
ok = await self._ft.send_payment_reminder(invoice_id=inv.get('id'))
|
||||
if ok:
|
||||
actions.append({'action': 'reminder_sent', 'invoice_id': inv.get('id')})
|
||||
except Exception as exc:
|
||||
logger.debug('sweep reminder failed: %s', exc)
|
||||
except Exception as exc:
|
||||
logger.error('FinanceAgent.sweep error: %s', exc)
|
||||
return SweepReport(agent=self.name, findings=[], actions=[], error=str(exc))
|
||||
|
||||
return SweepReport(
|
||||
agent=self.name,
|
||||
findings=findings,
|
||||
actions=actions,
|
||||
summary=f'Sweep: {len(findings)} overdue invoices found, {len(actions)} reminders sent.',
|
||||
)
|
||||
265
agent_service/agents/master_agent.py
Normal file
265
agent_service/agents/master_agent.py
Normal file
@@ -0,0 +1,265 @@
|
||||
from __future__ import annotations
|
||||
import asyncio, json, logging, os, time, uuid
|
||||
from dataclasses import dataclass, field
|
||||
from .base_agent import AgentDirective, DirectiveContext, AgentReport
|
||||
from .peer_bus import PeerBus
|
||||
from ..memory.memory_manager import MemoryManager, MasterContext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class IntentResult:
|
||||
needs_clarification: bool = False
|
||||
clarification_question: object = None
|
||||
is_continuation: bool = False
|
||||
agents: list = field(default_factory=list)
|
||||
intent_summary: str = ''
|
||||
params: dict = field(default_factory=dict)
|
||||
context_hints: list = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AccessResult:
|
||||
allowed: bool
|
||||
denied_agents: list = field(default_factory=list)
|
||||
reason: str = ''
|
||||
|
||||
|
||||
@dataclass
|
||||
class MasterResponse:
|
||||
directive_id: str
|
||||
response: str
|
||||
status: str
|
||||
escalations: list = field(default_factory=list)
|
||||
actions_taken: list = field(default_factory=list)
|
||||
|
||||
|
||||
def _load_prompt(filename):
|
||||
base = os.path.dirname(__file__)
|
||||
path = os.path.join(base, '..', 'prompts', filename)
|
||||
try:
|
||||
with open(path) as f:
|
||||
return f.read()
|
||||
except FileNotFoundError:
|
||||
logger.warning('Prompt file not found: %s', path)
|
||||
return ''
|
||||
|
||||
|
||||
AGENT_ACCESS_GROUPS = {
|
||||
'finance_agent': 'account.group_account_user',
|
||||
'accounting_agent': 'account.group_account_user',
|
||||
'sales_agent': 'sales_team.group_sale_salesman',
|
||||
'project_agent': 'project.group_project_user',
|
||||
'expenses_agent': 'hr_expense.group_hr_expense_user',
|
||||
'employees_agent': 'hr.group_hr_user',
|
||||
}
|
||||
|
||||
|
||||
class MasterAgent:
|
||||
def __init__(self, odoo, llm, memory: MemoryManager, registry):
|
||||
self._odoo = odoo
|
||||
self._llm = llm
|
||||
self._memory = memory
|
||||
self._registry = registry
|
||||
|
||||
def build_system_prompt(self, active_agents):
|
||||
template = _load_prompt('master_system.txt')
|
||||
agent_list = chr(10).join(
|
||||
f'- {a["agent_key"]}: {a["capabilities_summary"]}' for a in active_agents)
|
||||
return template.format(agent_list=agent_list)
|
||||
|
||||
async def handle_message(self, user_id, channel_id, message, directive_id) -> MasterResponse:
|
||||
logger.info('MasterAgent.handle_message user_id=%s directive=%s', user_id, directive_id)
|
||||
t0 = time.monotonic()
|
||||
await self._log_directive_start(directive_id, user_id, channel_id, message)
|
||||
try:
|
||||
context = await self._build_context(user_id, message)
|
||||
intent = await self._classify_intent(context, message)
|
||||
if intent.needs_clarification:
|
||||
q = intent.clarification_question or 'Could you provide more details?'
|
||||
await self._memory.append_message(user_id, 'assistant', q, directive_id)
|
||||
await self._log_directive_complete(directive_id, 'awaiting_clarification', q)
|
||||
return MasterResponse(directive_id=directive_id, response=q,
|
||||
status='awaiting_clarification')
|
||||
access = await self._check_access(user_id, intent.agents)
|
||||
if not access.allowed:
|
||||
msg = f'You don' + chr(39) + 't have access to: {chr(44).join(access.denied_agents)}.'
|
||||
await self._memory.append_message(user_id, 'assistant', msg, directive_id)
|
||||
await self._log_directive_complete(directive_id, 'failed', msg)
|
||||
return MasterResponse(directive_id=directive_id, response=msg, status='failed')
|
||||
directives = await self._build_directives(intent, context, directive_id)
|
||||
reports = await self._dispatch_agents(directives)
|
||||
response_text = await self._synthesize(reports, context)
|
||||
await self._update_memory(user_id, message, response_text, reports, directive_id)
|
||||
all_escalations = []
|
||||
all_actions = []
|
||||
status = 'complete'
|
||||
for r in reports:
|
||||
all_escalations.extend(r.escalations)
|
||||
all_actions.extend(r.actions_taken)
|
||||
if r.status in ('failed', 'escalated'):
|
||||
status = 'partial' if status == 'complete' else status
|
||||
if all_escalations:
|
||||
status = 'awaiting_approval'
|
||||
await self._log_directive_complete(directive_id, status, response_text, all_actions, all_escalations)
|
||||
ms = int((time.monotonic() - t0) * 1000)
|
||||
logger.info('MasterAgent complete directive=%s status=%s ms=%d', directive_id, status, ms)
|
||||
return MasterResponse(directive_id=directive_id, response=response_text,
|
||||
status=status, escalations=all_escalations, actions_taken=all_actions)
|
||||
except Exception as exc:
|
||||
logger.error('MasterAgent FAILED directive=%s: %s', directive_id, exc)
|
||||
err_msg = 'I encountered an error processing your request. Please try again or contact your administrator.'
|
||||
await self._log_directive_complete(directive_id, 'failed', err_msg, error=str(exc))
|
||||
return MasterResponse(directive_id=directive_id, response=err_msg, status='failed')
|
||||
|
||||
async def _build_context(self, user_id, message) -> MasterContext:
|
||||
hint = message[:40] if message else None
|
||||
return await self._memory.build_context(user_id, intent_hint=hint)
|
||||
|
||||
async def _classify_intent(self, context: MasterContext, message) -> IntentResult:
|
||||
active_agents = await self._registry.get_active_agents()
|
||||
system = self.build_system_prompt(active_agents)
|
||||
history = [
|
||||
{'role': m['role'] if m['role'] != 'system' else 'user', 'content': m['content']}
|
||||
for m in context.conversation[-20:]
|
||||
if m['role'] in ('user', 'assistant')
|
||||
]
|
||||
msgs = [{'role': 'system', 'content': system}, *history,
|
||||
{'role': 'user', 'content': message}]
|
||||
resp = await self._llm.submit(msgs, caller='master')
|
||||
try:
|
||||
raw = resp.content.strip()
|
||||
if raw.startswith(chr(96)*3):
|
||||
raw = raw.split(chr(10), 1)[1].rsplit(chr(10), 1)[0]
|
||||
data = json.loads(raw)
|
||||
return IntentResult(
|
||||
needs_clarification=data.get('needs_clarification', False),
|
||||
clarification_question=data.get('clarification_question'),
|
||||
is_continuation=data.get('is_continuation', False),
|
||||
agents=data.get('agents', []),
|
||||
intent_summary=data.get('intent_summary', ''),
|
||||
params=data.get('params', {}),
|
||||
context_hints=data.get('context_hints', []))
|
||||
except (json.JSONDecodeError, KeyError) as exc:
|
||||
logger.warning('Intent classification parse failed: %s', exc)
|
||||
return IntentResult(needs_clarification=True,
|
||||
clarification_question='Could you clarify what you need?')
|
||||
|
||||
async def _check_access(self, user_id, agents) -> AccessResult:
|
||||
denied = []
|
||||
try:
|
||||
user_data = await self._odoo.call('res.users', 'read', [[user_id]], {'fields': ['groups_id']})
|
||||
group_ids = user_data[0].get('groups_id', []) if user_data else []
|
||||
group_rows = await self._odoo.search_read('res.groups', [['id', 'in', group_ids]], ['full_name'])
|
||||
user_group_names = {r['full_name'] for r in group_rows}
|
||||
except Exception as exc:
|
||||
logger.warning('Access check failed, permitting: %s', exc)
|
||||
return AccessResult(allowed=True)
|
||||
for agent_key in agents:
|
||||
required = AGENT_ACCESS_GROUPS.get(agent_key)
|
||||
if required and required not in user_group_names:
|
||||
denied.append(agent_key)
|
||||
if denied:
|
||||
return AccessResult(allowed=False, denied_agents=denied)
|
||||
return AccessResult(allowed=True)
|
||||
|
||||
async def _build_directives(self, intent: IntentResult, context: MasterContext, directive_id) -> list:
|
||||
directives = []
|
||||
for agent_key in intent.agents:
|
||||
ctx = DirectiveContext(
|
||||
client_profile=context.knowledge,
|
||||
recent_findings=context.operational_findings,
|
||||
conversation_summary=chr(10).join(
|
||||
m['content'] for m in context.conversation[-5:] if m['role'] == 'assistant'),
|
||||
peer_data={})
|
||||
d = AgentDirective(
|
||||
directive_id=directive_id, agent=agent_key, task=intent.intent_summary,
|
||||
params=intent.params, context=ctx,
|
||||
authorized_actions=['read', 'search', 'report', 'post_chatter',
|
||||
'send_email', 'create_non_financial', 'write_non_financial'],
|
||||
constraints={'max_amount': 5000})
|
||||
directives.append(d)
|
||||
return directives
|
||||
|
||||
async def _dispatch_agents(self, directives) -> list:
|
||||
if len(directives) == 1:
|
||||
d = directives[0]
|
||||
agent = self._registry.get_agent_instance(d.agent)
|
||||
if agent is None:
|
||||
return [AgentReport(directive_id=d.directive_id, agent=d.agent,
|
||||
status='failed', summary=f'Agent {d.agent} not loaded')]
|
||||
return [await agent.execute(d)]
|
||||
tasks = [self._registry.get_agent_instance(d.agent).execute(d)
|
||||
for d in directives if self._registry.get_agent_instance(d.agent)]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
reports = []
|
||||
for i, r in enumerate(results):
|
||||
if isinstance(r, Exception):
|
||||
d = directives[i]
|
||||
reports.append(AgentReport(directive_id=d.directive_id, agent=d.agent,
|
||||
status='failed', summary=str(r)))
|
||||
else:
|
||||
reports.append(r)
|
||||
return reports
|
||||
|
||||
async def _synthesize(self, reports, context: MasterContext) -> str:
|
||||
if not reports:
|
||||
return 'No agent responses received.'
|
||||
if len(reports) == 1 and reports[0].status == 'complete':
|
||||
return reports[0].summary
|
||||
summaries = chr(10).join(f'{r.agent}: {r.summary}' for r in reports)
|
||||
msg = ('Synthesize these agent reports into one coherent response. '
|
||||
'Business language only. No internal IDs. '
|
||||
'Separate: actions completed, items pending approval, recommendations.'
|
||||
+ chr(10) + summaries)
|
||||
resp = await self._llm.submit(
|
||||
[{'role': 'system', 'content': 'You are a business intelligence assistant.'},
|
||||
{'role': 'user', 'content': msg}],
|
||||
caller='master_synthesis')
|
||||
return resp.content
|
||||
|
||||
async def _update_memory(self, user_id, message, response, reports, directive_id):
|
||||
await self._memory.append_message(user_id, 'user', message, directive_id)
|
||||
await self._memory.append_message(user_id, 'assistant', response, directive_id)
|
||||
for report in reports:
|
||||
if report.data:
|
||||
await self._memory.store_findings(
|
||||
scope=report.agent.replace('_agent', ''),
|
||||
summary=report.summary, raw_data=report.data,
|
||||
source_directive_id=directive_id)
|
||||
|
||||
async def handle_approval(self, directive_id, item_id, approved, approver_uid) -> str:
|
||||
if approved:
|
||||
return f'Approval recorded. Re-executing approved action for {directive_id}.'
|
||||
return 'Action rejected and recorded.'
|
||||
|
||||
async def is_continuation(self, user_id, message) -> bool:
|
||||
recent = await self._memory.get_conversation(user_id, limit=3)
|
||||
return bool(recent)
|
||||
|
||||
async def _log_directive_start(self, directive_id, user_id, channel_id, message):
|
||||
try:
|
||||
pool = self._odoo.pg_pool
|
||||
if not pool: return
|
||||
sql = ('INSERT INTO ab_directive_log '
|
||||
'(directive_id, user_id, channel_id, raw_message, status) '
|
||||
'VALUES ($1, $2, $3, $4, $5) ON CONFLICT (directive_id) DO NOTHING')
|
||||
async with pool.acquire(timeout=10) as conn:
|
||||
await conn.execute(sql, directive_id, user_id, channel_id, message, 'processing')
|
||||
except Exception as exc:
|
||||
logger.warning('_log_directive_start failed: %s', exc)
|
||||
|
||||
async def _log_directive_complete(self, directive_id, status, response,
|
||||
actions=None, escalations=None, error=None):
|
||||
try:
|
||||
pool = self._odoo.pg_pool
|
||||
if not pool: return
|
||||
sql = ('UPDATE ab_directive_log SET status=$2, final_response=$3, '
|
||||
'actions_taken=$4, escalations=$5, completed_at=NOW(), error=$6 '
|
||||
'WHERE directive_id=$1')
|
||||
async with pool.acquire(timeout=10) as conn:
|
||||
await conn.execute(sql, directive_id, status, response,
|
||||
json.dumps(actions or []), json.dumps(escalations or []), error)
|
||||
except Exception as exc:
|
||||
logger.warning('_log_directive_complete failed: %s', exc)
|
||||
66
agent_service/agents/peer_bus.py
Normal file
66
agent_service/agents/peer_bus.py
Normal file
@@ -0,0 +1,66 @@
|
||||
from __future__ import annotations
|
||||
import asyncio, logging
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
MAX_DEPTH = 3
|
||||
|
||||
|
||||
@dataclass
|
||||
class PeerResponse:
|
||||
available: bool
|
||||
success: bool = False
|
||||
data: dict = field(default_factory=dict)
|
||||
agent: str = ''
|
||||
request_type: str = ''
|
||||
error: object = None
|
||||
|
||||
|
||||
class PeerCircularRequestError(Exception): pass
|
||||
|
||||
|
||||
class PeerBus:
|
||||
def __init__(self, registry, directive_id):
|
||||
self._registry = registry
|
||||
self._directive_id = directive_id
|
||||
self._call_log: list[dict] = []
|
||||
self._call_chain: list[str] = []
|
||||
|
||||
async def request(self, from_agent, to_agent, request_type, params, reason):
|
||||
if to_agent in self._call_chain:
|
||||
logger.warning('PeerBus: circular request blocked %s->%s chain=%s',
|
||||
from_agent, to_agent, self._call_chain)
|
||||
raise PeerCircularRequestError(f'Circular peer request: {from_agent}->{to_agent}')
|
||||
if len(self._call_chain) >= MAX_DEPTH:
|
||||
logger.warning('PeerBus: max depth %d reached', MAX_DEPTH)
|
||||
return PeerResponse(available=False, error=f'Max peer depth {MAX_DEPTH} reached')
|
||||
if not await self._registry.is_active(to_agent):
|
||||
logger.debug('PeerBus: agent %s inactive, from=%s', to_agent, from_agent)
|
||||
return PeerResponse(available=False, agent=to_agent, request_type=request_type)
|
||||
agent = self._registry.get_agent_instance(to_agent)
|
||||
if agent is None:
|
||||
return PeerResponse(available=False, agent=to_agent, request_type=request_type)
|
||||
self._call_chain.append(from_agent)
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
agent.handle_peer_request(request_type, params, self._directive_id),
|
||||
timeout=30)
|
||||
entry = {'from': from_agent, 'to': to_agent, 'type': request_type,
|
||||
'params': params, 'reason': reason, 'success': result.get('success', True)}
|
||||
self._call_log.append(entry)
|
||||
logger.debug('PeerBus: %s->%s type=%s ok', from_agent, to_agent, request_type)
|
||||
return PeerResponse(available=True, success=True, data=result,
|
||||
agent=to_agent, request_type=request_type)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning('PeerBus: timeout %s->%s', from_agent, to_agent)
|
||||
return PeerResponse(available=True, success=False, agent=to_agent,
|
||||
request_type=request_type, error='Peer timeout after 30s')
|
||||
except Exception as exc:
|
||||
logger.error('PeerBus: error %s->%s: %s', from_agent, to_agent, exc)
|
||||
return PeerResponse(available=True, success=False, agent=to_agent,
|
||||
request_type=request_type, error=str(exc))
|
||||
finally:
|
||||
self._call_chain.pop()
|
||||
|
||||
@property
|
||||
def call_log(self): return self._call_log
|
||||
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.')
|
||||
40
agent_service/agents/registry.py
Normal file
40
agent_service/agents/registry.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AgentRegistry:
|
||||
def __init__(self):
|
||||
self._agents: dict = {} # agent_key -> BaseAgent instance
|
||||
self._active: set = set()
|
||||
self._capabilities: dict = {}
|
||||
|
||||
async def load_from_odoo(self, odoo_client):
|
||||
try:
|
||||
rows = await odoo_client.search_read(
|
||||
'ab.ai.agent.registry',
|
||||
[['is_active', '=', True]],
|
||||
['agent_key', 'name', 'capabilities_summary', 'sweep_enabled', 'sweep_interval_hours'])
|
||||
self._active = {r['agent_key'] for r in rows}
|
||||
self._capabilities = {r['agent_key']: r.get('capabilities_summary', '') for r in rows}
|
||||
logger.info('AgentRegistry loaded: active=%s', list(self._active))
|
||||
except Exception as exc:
|
||||
logger.error('AgentRegistry.load_from_odoo failed: %s', exc)
|
||||
|
||||
async def get_active_agents(self):
|
||||
return [{'agent_key': k, 'capabilities_summary': self._capabilities.get(k, '')}
|
||||
for k in self._active]
|
||||
|
||||
async def is_active(self, agent_key):
|
||||
return agent_key in self._active
|
||||
|
||||
async def sync(self, active_keys):
|
||||
self._active = set(active_keys)
|
||||
logger.info('AgentRegistry synced: active=%s', active_keys)
|
||||
|
||||
def register(self, agent_key, agent_instance):
|
||||
self._agents[agent_key] = agent_instance
|
||||
|
||||
def get_agent_instance(self, agent_key):
|
||||
return self._agents.get(agent_key)
|
||||
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.')
|
||||
100
agent_service/agents/sweep_coordinator.py
Normal file
100
agent_service/agents/sweep_coordinator.py
Normal file
@@ -0,0 +1,100 @@
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ALL_AGENT_NAMES = [
|
||||
'finance_agent', 'accounting_agent', 'crm_agent', 'sales_agent',
|
||||
'project_agent', 'elearning_agent', 'expenses_agent', 'employees_agent',
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class SweepCoordinatorResult:
|
||||
total_agents: int = 0
|
||||
completed: int = 0
|
||||
failed: int = 0
|
||||
total_findings: int = 0
|
||||
total_actions: int = 0
|
||||
agent_results: list = field(default_factory=list)
|
||||
errors: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
class SweepCoordinator:
|
||||
def __init__(self, peer_bus=None):
|
||||
self._peer_bus = peer_bus
|
||||
|
||||
async def run_sweep(self, agents: Optional[list[str]] = None) -> dict:
|
||||
names = agents or ALL_AGENT_NAMES
|
||||
result = SweepCoordinatorResult(total_agents=len(names))
|
||||
logger.info('SweepCoordinator: starting sweep for %d agents', len(names))
|
||||
|
||||
tasks = {}
|
||||
for name in names:
|
||||
agent = self._get_agent(name)
|
||||
if agent is None:
|
||||
logger.debug('Sweep: agent %s not available, skipping', name)
|
||||
result.failed += 1
|
||||
result.errors[name] = 'agent not available'
|
||||
continue
|
||||
tasks[name] = asyncio.create_task(self._run_agent_sweep(name, agent))
|
||||
|
||||
if not tasks:
|
||||
logger.warning('SweepCoordinator: no agents available for sweep')
|
||||
return result.__dict__
|
||||
|
||||
done, _ = await asyncio.wait(list(tasks.values()), timeout=300)
|
||||
|
||||
for name, task in tasks.items():
|
||||
if task not in done:
|
||||
logger.warning('Sweep timeout for agent %s', name)
|
||||
result.failed += 1
|
||||
result.errors[name] = 'timeout'
|
||||
task.cancel()
|
||||
continue
|
||||
try:
|
||||
sweep_report = task.result()
|
||||
result.completed += 1
|
||||
result.total_findings += len(sweep_report.findings)
|
||||
result.total_actions += len(sweep_report.actions)
|
||||
result.agent_results.append({
|
||||
'agent': name,
|
||||
'findings': sweep_report.findings,
|
||||
'actions': sweep_report.actions,
|
||||
'summary': sweep_report.summary,
|
||||
'error': sweep_report.error,
|
||||
})
|
||||
logger.info(
|
||||
'Sweep %s: %d findings, %d actions',
|
||||
name, len(sweep_report.findings), len(sweep_report.actions),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error('Sweep result error for %s: %s', name, exc)
|
||||
result.failed += 1
|
||||
result.errors[name] = str(exc)
|
||||
|
||||
logger.info(
|
||||
'SweepCoordinator done: %d completed, %d failed, %d findings, %d actions',
|
||||
result.completed, result.failed, result.total_findings, result.total_actions,
|
||||
)
|
||||
return result.__dict__
|
||||
|
||||
async def _run_agent_sweep(self, name: str, agent):
|
||||
try:
|
||||
return await asyncio.wait_for(agent.sweep(), timeout=60)
|
||||
except asyncio.TimeoutError:
|
||||
from .base_agent import SweepReport
|
||||
logger.warning('Agent sweep timed out: %s', name)
|
||||
return SweepReport(agent=name, findings=[], actions=[], error='timeout')
|
||||
except Exception as exc:
|
||||
from .base_agent import SweepReport
|
||||
logger.error('Agent sweep error %s: %s', name, exc)
|
||||
return SweepReport(agent=name, findings=[], actions=[], error=str(exc))
|
||||
|
||||
def _get_agent(self, name: str):
|
||||
if self._peer_bus is None:
|
||||
return None
|
||||
return self._peer_bus.get_agent(name)
|
||||
57
agent_service/app_state.py
Normal file
57
agent_service/app_state.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""Global application state — singletons accessed by routers."""
|
||||
from __future__ import annotations
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import asyncpg
|
||||
|
||||
_db_pool: Optional['asyncpg.Pool'] = None
|
||||
_master_agent = None
|
||||
_agent_registry = None
|
||||
_llm_router = None
|
||||
_sweep_coordinator = None
|
||||
|
||||
|
||||
def set_db_pool(pool) -> None:
|
||||
global _db_pool
|
||||
_db_pool = pool
|
||||
|
||||
|
||||
def get_db_pool():
|
||||
return _db_pool
|
||||
|
||||
|
||||
def set_master_agent(agent) -> None:
|
||||
global _master_agent
|
||||
_master_agent = agent
|
||||
|
||||
|
||||
def get_master_agent():
|
||||
return _master_agent
|
||||
|
||||
|
||||
def set_agent_registry(registry) -> None:
|
||||
global _agent_registry
|
||||
_agent_registry = registry
|
||||
|
||||
|
||||
def get_agent_registry():
|
||||
return _agent_registry
|
||||
|
||||
|
||||
def set_llm_router(router) -> None:
|
||||
global _llm_router
|
||||
_llm_router = router
|
||||
|
||||
|
||||
def get_llm_router():
|
||||
return _llm_router
|
||||
|
||||
|
||||
def set_sweep_coordinator(coordinator) -> None:
|
||||
global _sweep_coordinator
|
||||
_sweep_coordinator = coordinator
|
||||
|
||||
|
||||
def get_sweep_coordinator():
|
||||
return _sweep_coordinator
|
||||
92
agent_service/config.py
Normal file
92
agent_service/config.py
Normal file
@@ -0,0 +1,92 @@
|
||||
from __future__ import annotations
|
||||
import os
|
||||
from functools import lru_cache
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# Odoo
|
||||
odoo_url: str = 'http://localhost:8069'
|
||||
odoo_db: str = 'odoo'
|
||||
odoo_api_key: str = ''
|
||||
|
||||
# Ollama
|
||||
ollama_url: str = 'http://localhost:11434'
|
||||
ollama_model: str = 'llama3'
|
||||
ollama_timeout: int = 120
|
||||
|
||||
# Anthropic / Claude
|
||||
anthropic_api_key: str = ''
|
||||
claude_model: str = 'claude-sonnet-4-6'
|
||||
|
||||
# Privacy
|
||||
llm_privacy_mode: str = 'local' # local | hybrid | cloud
|
||||
|
||||
# Per-agent backend overrides (env: AGENT_BACKEND_FINANCE=claude)
|
||||
agent_backend_finance: str = ''
|
||||
agent_backend_accounting: str = ''
|
||||
agent_backend_crm: str = ''
|
||||
agent_backend_sales: str = ''
|
||||
agent_backend_project: str = ''
|
||||
agent_backend_elearning: str = ''
|
||||
agent_backend_expenses: str = ''
|
||||
agent_backend_employees: str = ''
|
||||
|
||||
# Service
|
||||
agent_service_port: int = 8001
|
||||
webhook_secret: str = ''
|
||||
allowed_callback_ip: str = ''
|
||||
|
||||
# Postgres
|
||||
postgres_host: str = 'localhost'
|
||||
postgres_port: int = 5432
|
||||
postgres_db: str = 'activeblue_ai'
|
||||
postgres_user: str = 'activeblue'
|
||||
postgres_password: str = ''
|
||||
postgres_min_connections: int = 2
|
||||
postgres_max_connections: int = 10
|
||||
|
||||
# Rate limiting
|
||||
dispatch_rate_limit_per_user: int = 30 # requests per minute
|
||||
directive_timeout_minutes: int = 10
|
||||
|
||||
# Logging
|
||||
log_level: str = 'INFO'
|
||||
log_format: str = 'json'
|
||||
loki_url: str = ''
|
||||
|
||||
class Config:
|
||||
env_file = '.env'
|
||||
env_file_encoding = 'utf-8'
|
||||
|
||||
@property
|
||||
def postgres_dsn(self) -> str:
|
||||
return (
|
||||
f'postgresql+asyncpg://{self.postgres_user}:{self.postgres_password}'
|
||||
f'@{self.postgres_host}:{self.postgres_port}/{self.postgres_db}'
|
||||
)
|
||||
|
||||
@property
|
||||
def postgres_asyncpg_dsn(self) -> str:
|
||||
return (
|
||||
f'asyncpg://{self.postgres_user}:{self.postgres_password}'
|
||||
f'@{self.postgres_host}:{self.postgres_port}/{self.postgres_db}'
|
||||
)
|
||||
|
||||
def agent_backend_override(self, agent_name: str) -> str:
|
||||
mapping = {
|
||||
'finance_agent': self.agent_backend_finance,
|
||||
'accounting_agent': self.agent_backend_accounting,
|
||||
'crm_agent': self.agent_backend_crm,
|
||||
'sales_agent': self.agent_backend_sales,
|
||||
'project_agent': self.agent_backend_project,
|
||||
'elearning_agent': self.agent_backend_elearning,
|
||||
'expenses_agent': self.agent_backend_expenses,
|
||||
'employees_agent': self.agent_backend_employees,
|
||||
}
|
||||
return mapping.get(agent_name, '')
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
0
agent_service/llm/__init__.py
Normal file
0
agent_service/llm/__init__.py
Normal file
83
agent_service/llm/claude_backend.py
Normal file
83
agent_service/llm/claude_backend.py
Normal file
@@ -0,0 +1,83 @@
|
||||
from __future__ import annotations
|
||||
import asyncio, logging, time
|
||||
from typing import Any
|
||||
from .llm_types import LLMResponse, ClaudeTimeoutError, ClaudeAuthError, ClaudeRateLimitError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _to_claude_tools(tools):
|
||||
result = []
|
||||
for t in tools:
|
||||
params = t.get('parameters', {})
|
||||
props, req = {}, []
|
||||
for k, v in params.items():
|
||||
props[k] = {k2: v2 for k2, v2 in v.items() if k2 != 'optional'}
|
||||
if not v.get('optional', False):
|
||||
req.append(k)
|
||||
result.append({'name': t['name'], 'description': t.get('description', t['name']),
|
||||
'input_schema': {'type': 'object', 'properties': props, 'required': req}})
|
||||
return result
|
||||
|
||||
|
||||
class ClaudeBackend:
|
||||
def __init__(self, api_key, model, timeout=60, max_concurrent=5):
|
||||
import anthropic
|
||||
self._client = anthropic.AsyncAnthropic(api_key=api_key)
|
||||
self._model = model
|
||||
self._timeout = timeout
|
||||
self._semaphore = asyncio.Semaphore(max_concurrent)
|
||||
self._active = 0
|
||||
|
||||
async def submit(self, messages, tools=None, caller='unknown'):
|
||||
import anthropic
|
||||
wait_start = time.monotonic()
|
||||
async with self._semaphore:
|
||||
wait_ms = int((time.monotonic() - wait_start) * 1000)
|
||||
self._active += 1
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
system = None
|
||||
conv = []
|
||||
for m in messages:
|
||||
if m.get('role') == 'system':
|
||||
system = m['content']
|
||||
else:
|
||||
conv.append(m)
|
||||
kw: dict[str, Any] = {'model': self._model, 'max_tokens': 4096, 'messages': conv}
|
||||
if system:
|
||||
kw['system'] = system
|
||||
if tools:
|
||||
kw['tools'] = _to_claude_tools(tools)
|
||||
try:
|
||||
resp = await asyncio.wait_for(self._client.messages.create(**kw), timeout=self._timeout)
|
||||
except asyncio.TimeoutError:
|
||||
raise ClaudeTimeoutError(f'Claude timeout after {self._timeout}s caller={caller}')
|
||||
except anthropic.AuthenticationError as exc:
|
||||
raise ClaudeAuthError(f'Claude auth error: {exc}') from exc
|
||||
except anthropic.RateLimitError:
|
||||
logger.warning('Claude rate limit, backing off 5s caller=%s', caller)
|
||||
await asyncio.sleep(5)
|
||||
try:
|
||||
resp = await asyncio.wait_for(self._client.messages.create(**kw), timeout=self._timeout)
|
||||
except anthropic.RateLimitError as exc2:
|
||||
raise ClaudeRateLimitError(f'Claude rate limit persists: {exc2}') from exc2
|
||||
ms = int((time.monotonic() - t0) * 1000)
|
||||
text, tool_calls = '', None
|
||||
for block in resp.content:
|
||||
if block.type == 'text':
|
||||
text += block.text
|
||||
elif block.type == 'tool_use':
|
||||
if tool_calls is None: tool_calls = []
|
||||
tool_calls.append({'name': block.name, 'arguments': block.input})
|
||||
tin, tout = resp.usage.input_tokens, resp.usage.output_tokens
|
||||
cost = (tin * 3 + tout * 15) / 1_000_000
|
||||
logger.info('claude caller=%s model=%s wait_ms=%d ms=%d tin=%d tout=%d cost=%.5f',
|
||||
caller, self._model, wait_ms, ms, tin, tout, cost)
|
||||
return LLMResponse(content=text, tool_calls=tool_calls, backend_used='claude',
|
||||
model_used=self._model, tokens_in=tin, tokens_out=tout, latency_ms=ms)
|
||||
finally:
|
||||
self._active -= 1
|
||||
|
||||
@property
|
||||
def active_count(self): return self._active
|
||||
35
agent_service/llm/llm_config_store.py
Normal file
35
agent_service/llm/llm_config_store.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LLMConfigStore:
|
||||
def __init__(self, pg_pool):
|
||||
self._pool = pg_pool
|
||||
|
||||
async def get_backend(self, caller):
|
||||
async with self._pool.acquire(timeout=10) as conn:
|
||||
row = await conn.fetchrow(
|
||||
'SELECT backend FROM ab_llm_config WHERE caller = $1', caller)
|
||||
return row['backend'] if row else None
|
||||
|
||||
async def set_backend(self, caller, backend, set_by, note=None):
|
||||
async with self._pool.acquire(timeout=10) as conn:
|
||||
await conn.execute(
|
||||
"""INSERT INTO ab_llm_config (caller, backend, set_by, note)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (caller) DO UPDATE
|
||||
SET backend=$2, set_by=$3, set_at=NOW(), note=$4""",
|
||||
caller, backend, set_by, note)
|
||||
logger.info('LLMConfigStore.set_backend caller=%s backend=%s set_by=%s', caller, backend, set_by)
|
||||
|
||||
async def get_all(self):
|
||||
async with self._pool.acquire(timeout=10) as conn:
|
||||
rows = await conn.fetch('SELECT * FROM ab_llm_config ORDER BY caller')
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
async def reset(self, caller):
|
||||
async with self._pool.acquire(timeout=10) as conn:
|
||||
await conn.execute('DELETE FROM ab_llm_config WHERE caller = $1', caller)
|
||||
logger.info('LLMConfigStore.reset caller=%s', caller)
|
||||
112
agent_service/llm/llm_router.py
Normal file
112
agent_service/llm/llm_router.py
Normal file
@@ -0,0 +1,112 @@
|
||||
from __future__ import annotations
|
||||
import logging, os
|
||||
from .llm_types import LLMResponse, OllamaUnavailableError, ClaudeTimeoutError, ClaudeRateLimitError
|
||||
from .ollama_backend import OllamaBackend
|
||||
from .llm_config_store import LLMConfigStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
HIPAA_LOCKED_AGENTS = frozenset({'finance_agent', 'accounting_agent', 'employees_agent', 'expenses_agent'})
|
||||
|
||||
|
||||
class LLMRouter:
|
||||
def __init__(self, config, pg_pool=None):
|
||||
self._config = config
|
||||
self._privacy_mode = getattr(config, 'llm_privacy_mode', 'local')
|
||||
self._config_store = LLMConfigStore(pg_pool) if pg_pool else None
|
||||
self._ollama = OllamaBackend(
|
||||
url=config.ollama_url, model=config.ollama_model,
|
||||
timeout=config.ollama_timeout, max_concurrent=config.ollama_max_concurrent)
|
||||
self._claude = None
|
||||
if self._privacy_mode != 'local':
|
||||
api_key = getattr(config, 'anthropic_api_key', None)
|
||||
if api_key:
|
||||
from .claude_backend import ClaudeBackend
|
||||
self._claude = ClaudeBackend(
|
||||
api_key=api_key, model=config.claude_model,
|
||||
timeout=config.claude_timeout, max_concurrent=config.claude_max_concurrent)
|
||||
logger.info('ClaudeBackend initialized mode=%s', self._privacy_mode)
|
||||
elif self._privacy_mode == 'cloud':
|
||||
logger.error('Privacy mode is cloud but ANTHROPIC_API_KEY not set')
|
||||
else:
|
||||
logger.warning('Privacy mode is hybrid but ANTHROPIC_API_KEY not set')
|
||||
|
||||
async def submit(self, messages, tools=None, caller='unknown'):
|
||||
backend_name = await self.get_backend(caller)
|
||||
if backend_name == 'claude':
|
||||
if self._claude is None:
|
||||
logger.warning('Claude requested but unavailable, fallback to Ollama caller=%s', caller)
|
||||
backend_name = 'ollama'
|
||||
else:
|
||||
try:
|
||||
return await self._claude.submit(messages, tools, caller)
|
||||
except (ClaudeTimeoutError, ClaudeRateLimitError) as exc:
|
||||
logger.warning('Claude failed caller=%s (%s), falling back to Ollama', caller, exc)
|
||||
return await self._ollama.submit(messages, tools, caller)
|
||||
return await self._ollama.submit(messages, tools, caller)
|
||||
|
||||
async def get_backend(self, caller):
|
||||
if caller in HIPAA_LOCKED_AGENTS:
|
||||
return 'ollama'
|
||||
if self._privacy_mode == 'local':
|
||||
return 'ollama'
|
||||
if self._privacy_mode == 'cloud':
|
||||
return 'claude'
|
||||
if self._privacy_mode == 'hybrid':
|
||||
if self._config_store:
|
||||
try:
|
||||
db_val = await self._config_store.get_backend(caller)
|
||||
if db_val:
|
||||
return db_val
|
||||
except Exception as exc:
|
||||
logger.warning('LLMConfigStore lookup failed: %s', exc)
|
||||
env_key = f'AGENT_BACKEND_{caller.upper()}'
|
||||
env_val = os.environ.get(env_key)
|
||||
if env_val in ('ollama', 'claude'):
|
||||
return env_val
|
||||
return 'ollama'
|
||||
logger.error('Unknown privacy mode %s, defaulting to ollama', self._privacy_mode)
|
||||
return 'ollama'
|
||||
|
||||
async def set_backend(self, caller, backend, set_by, note=None):
|
||||
if caller in HIPAA_LOCKED_AGENTS:
|
||||
raise ValueError(f'Cannot override backend for HIPAA-locked agent: {caller}')
|
||||
if backend not in ('ollama', 'claude'):
|
||||
raise ValueError(f'Invalid backend: {backend}')
|
||||
if not self._config_store:
|
||||
raise RuntimeError('No Postgres pool for runtime config store')
|
||||
await self._config_store.set_backend(caller, backend, set_by, note)
|
||||
|
||||
async def set_privacy_mode(self, mode, set_by):
|
||||
if mode not in ('local', 'hybrid', 'cloud'):
|
||||
raise ValueError(f'Invalid privacy mode: {mode}')
|
||||
self._privacy_mode = mode
|
||||
if self._config_store:
|
||||
await self._config_store.set_backend('__system__', mode, set_by,
|
||||
f'Privacy mode changed to {mode}')
|
||||
if mode == 'local':
|
||||
self._claude = None
|
||||
logger.info('Privacy mode set to local - ClaudeBackend disabled')
|
||||
elif mode in ('hybrid', 'cloud') and self._claude is None:
|
||||
api_key = getattr(self._config, 'anthropic_api_key', None)
|
||||
if api_key:
|
||||
from .claude_backend import ClaudeBackend
|
||||
self._claude = ClaudeBackend(
|
||||
api_key=api_key, model=self._config.claude_model,
|
||||
timeout=self._config.claude_timeout, max_concurrent=self._config.claude_max_concurrent)
|
||||
logger.info('Privacy mode set to %s by user_id=%s', mode, set_by)
|
||||
|
||||
async def get_status(self):
|
||||
s = {'privacy_mode': self._privacy_mode,
|
||||
'ollama': {'active': self._ollama.active_count}}
|
||||
if self._claude:
|
||||
s['claude'] = {'active': self._claude.active_count}
|
||||
else:
|
||||
s['claude'] = {'available': False, 'reason': 'local mode or no API key'}
|
||||
return s
|
||||
|
||||
@property
|
||||
def ollama_queue_depth(self): return self._ollama.active_count
|
||||
|
||||
@property
|
||||
def claude_active_count(self): return self._claude.active_count if self._claude else 0
|
||||
21
agent_service/llm/llm_types.py
Normal file
21
agent_service/llm/llm_types.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class LLMResponse:
|
||||
content: str
|
||||
tool_calls: object # list | None
|
||||
backend_used: str # 'ollama' | 'claude'
|
||||
model_used: str
|
||||
tokens_in: int
|
||||
tokens_out: int
|
||||
latency_ms: int
|
||||
|
||||
|
||||
class LLMError(Exception): pass
|
||||
class OllamaTimeoutError(LLMError): pass
|
||||
class OllamaUnavailableError(LLMError): pass
|
||||
class ClaudeTimeoutError(LLMError): pass
|
||||
class ClaudeAuthError(LLMError): pass
|
||||
class ClaudeRateLimitError(LLMError): pass
|
||||
54
agent_service/llm/ollama_backend.py
Normal file
54
agent_service/llm/ollama_backend.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from __future__ import annotations
|
||||
import asyncio, logging, time
|
||||
from .llm_types import LLMResponse, OllamaTimeoutError, OllamaUnavailableError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OllamaBackend:
|
||||
def __init__(self, url, model, timeout=120, max_concurrent=2):
|
||||
self._url = url
|
||||
self._model = model
|
||||
self._timeout = timeout
|
||||
self._semaphore = asyncio.Semaphore(max_concurrent)
|
||||
self._active = 0
|
||||
|
||||
async def submit(self, messages, tools=None, caller='unknown'):
|
||||
import ollama
|
||||
wait_start = time.monotonic()
|
||||
async with self._semaphore:
|
||||
wait_ms = int((time.monotonic() - wait_start) * 1000)
|
||||
self._active += 1
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
kwargs = {'model': self._model, 'messages': messages}
|
||||
if tools:
|
||||
kwargs['tools'] = tools
|
||||
client = ollama.AsyncClient(host=self._url)
|
||||
try:
|
||||
response = await asyncio.wait_for(client.chat(**kwargs), timeout=self._timeout)
|
||||
except asyncio.TimeoutError:
|
||||
raise OllamaTimeoutError(f'Ollama timeout after {self._timeout}s caller={caller}')
|
||||
except Exception as exc:
|
||||
s = str(exc).lower()
|
||||
if 'connect' in s or 'refused' in s or 'unreachable' in s:
|
||||
raise OllamaUnavailableError(f'Ollama unreachable: {exc}') from exc
|
||||
raise OllamaUnavailableError(f'Ollama error: {exc}') from exc
|
||||
ms = int((time.monotonic() - t0) * 1000)
|
||||
msg = response.message
|
||||
tool_calls = None
|
||||
if hasattr(msg, 'tool_calls') and msg.tool_calls:
|
||||
tool_calls = [{'name': tc.function.name, 'arguments': tc.function.arguments}
|
||||
for tc in msg.tool_calls]
|
||||
tin = response.prompt_eval_count or 0
|
||||
tout = response.eval_count or 0
|
||||
logger.info('ollama caller=%s wait_ms=%d inf_ms=%d tin=%d tout=%d',
|
||||
caller, wait_ms, ms, tin, tout)
|
||||
return LLMResponse(content=msg.content or '', tool_calls=tool_calls,
|
||||
backend_used='ollama', model_used=self._model,
|
||||
tokens_in=tin, tokens_out=tout, latency_ms=ms)
|
||||
finally:
|
||||
self._active -= 1
|
||||
|
||||
@property
|
||||
def active_count(self): return self._active
|
||||
89
agent_service/llm/tool_validator.py
Normal file
89
agent_service/llm/tool_validator.py
Normal file
@@ -0,0 +1,89 @@
|
||||
from __future__ import annotations
|
||||
import json, logging
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_TOOLS_PER_AGENT = 8
|
||||
|
||||
_TYPE_MAP = {
|
||||
'string': str, 'integer': int, 'number': (int, float),
|
||||
'boolean': bool, 'object': dict, 'array': list,
|
||||
}
|
||||
|
||||
|
||||
class ToolValidationError(Exception): pass
|
||||
class AgentConfigError(Exception): pass
|
||||
|
||||
|
||||
def validate_agent_tools(tools, agent_name):
|
||||
if len(tools) > MAX_TOOLS_PER_AGENT:
|
||||
raise AgentConfigError(
|
||||
f'{agent_name} has {len(tools)} tools but max is {MAX_TOOLS_PER_AGENT}. '
|
||||
f'Split into contextual tool groups.')
|
||||
|
||||
|
||||
class ToolCallValidator:
|
||||
def __init__(self, tools):
|
||||
self._tools = {t["name"]: t for t in tools}
|
||||
|
||||
def validate(self, tool_call):
|
||||
name = tool_call.get('name') or (tool_call.get('function') or {}).get('name')
|
||||
if not name:
|
||||
raise ToolValidationError('Tool call missing name field')
|
||||
if name not in self._tools:
|
||||
raise ToolValidationError(f'Unknown tool {name!r} not in agent tool list')
|
||||
tool_def = self._tools[name]
|
||||
params_schema = tool_def.get('parameters', {})
|
||||
arguments = tool_call.get('arguments') or (tool_call.get('function') or {}).get('arguments', {})
|
||||
if isinstance(arguments, str):
|
||||
try:
|
||||
arguments = json.loads(arguments)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ToolValidationError(f'Tool {name!r} arguments is invalid JSON: {exc}') from exc
|
||||
if not isinstance(arguments, dict):
|
||||
raise ToolValidationError(f'Tool {name!r} arguments must be dict, got {type(arguments)}')
|
||||
cleaned: dict[str, Any] = {}
|
||||
for key, value in arguments.items():
|
||||
if key not in params_schema:
|
||||
logger.warning('ToolCallValidator: stripping hallucinated param %r from tool %r', key, name)
|
||||
continue
|
||||
cleaned[key] = value
|
||||
for key, schema in params_schema.items():
|
||||
if schema.get('optional', False):
|
||||
continue
|
||||
if key not in cleaned:
|
||||
if 'default' in schema:
|
||||
cleaned[key] = schema['default']
|
||||
else:
|
||||
raise ToolValidationError(f'Tool {name!r} missing required param {key!r}')
|
||||
for key, value in list(cleaned.items()):
|
||||
schema = params_schema.get(key, {})
|
||||
expected_type = schema.get('type')
|
||||
if expected_type and expected_type in _TYPE_MAP:
|
||||
expected = _TYPE_MAP[expected_type]
|
||||
if not isinstance(value, expected):
|
||||
if expected_type in ('integer', 'number') and isinstance(value, str):
|
||||
try:
|
||||
cleaned[key] = int(value) if expected_type == 'integer' else float(value)
|
||||
except ValueError:
|
||||
raise ToolValidationError(
|
||||
f'Tool {name!r} param {key!r} expected {expected_type}, got {value!r}')
|
||||
elif expected_type == 'string':
|
||||
cleaned[key] = str(value)
|
||||
else:
|
||||
raise ToolValidationError(
|
||||
f'Tool {name!r} param {key!r} expected {expected_type}, got {type(value).__name__}')
|
||||
if 'enum' in schema and cleaned.get(key) not in schema['enum']:
|
||||
raise ToolValidationError(
|
||||
f'Tool {name!r} param {key!r} value {cleaned.get(key)!r} not in {schema["enum"]}')
|
||||
return {'name': name, 'arguments': cleaned}
|
||||
|
||||
def parse_or_fallback(self, tool_call):
|
||||
if tool_call is None:
|
||||
return None
|
||||
try:
|
||||
return self.validate(tool_call)
|
||||
except ToolValidationError as exc:
|
||||
logger.warning('ToolCallValidator fallback: %s', exc)
|
||||
return None
|
||||
3
agent_service/logging_utils/__init__.py
Normal file
3
agent_service/logging_utils/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .structured import configure_logging, get_logger, log_directive_event, push_to_loki
|
||||
|
||||
__all__ = ['configure_logging', 'get_logger', 'log_directive_event', 'push_to_loki']
|
||||
93
agent_service/logging_utils/structured.py
Normal file
93
agent_service/logging_utils/structured.py
Normal file
@@ -0,0 +1,93 @@
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
|
||||
_loki_url: Optional[str] = None
|
||||
_service_name = 'activeblue-ai'
|
||||
|
||||
|
||||
class JSONFormatter(logging.Formatter):
|
||||
"""Emit log records as single-line JSON for Loki/structured log ingestion."""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
log_entry: dict[str, Any] = {
|
||||
'ts': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime(record.created)),
|
||||
'level': record.levelname,
|
||||
'logger': record.name,
|
||||
'msg': record.getMessage(),
|
||||
'service': _service_name,
|
||||
}
|
||||
if record.exc_info:
|
||||
log_entry['exception'] = self.formatException(record.exc_info)
|
||||
for key in ('directive_id', 'agent', 'user_id', 'action', 'duration_ms'):
|
||||
val = getattr(record, key, None)
|
||||
if val is not None:
|
||||
log_entry[key] = val
|
||||
return json.dumps(log_entry, default=str)
|
||||
|
||||
|
||||
def configure_logging(level: str = 'INFO', fmt: str = 'json', loki_url: str = '') -> None:
|
||||
global _loki_url
|
||||
_loki_url = loki_url or None
|
||||
|
||||
numeric_level = getattr(logging, level.upper(), logging.INFO)
|
||||
root = logging.getLogger()
|
||||
root.setLevel(numeric_level)
|
||||
root.handlers.clear()
|
||||
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
if fmt == 'json':
|
||||
handler.setFormatter(JSONFormatter())
|
||||
else:
|
||||
handler.setFormatter(logging.Formatter(
|
||||
'%(asctime)s %(name)-20s %(levelname)-8s %(message)s',
|
||||
))
|
||||
root.addHandler(handler)
|
||||
|
||||
logging.getLogger('uvicorn.access').setLevel(logging.WARNING)
|
||||
logging.getLogger('asyncpg').setLevel(logging.WARNING)
|
||||
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
return logging.getLogger(name)
|
||||
|
||||
|
||||
class _DirectiveAdapter(logging.LoggerAdapter):
|
||||
def process(self, msg, kwargs):
|
||||
kwargs.setdefault('extra', {})
|
||||
kwargs['extra'].update(self.extra)
|
||||
return msg, kwargs
|
||||
|
||||
|
||||
def log_directive_event(logger: logging.Logger, level: str, msg: str,
|
||||
directive_id: str = '', agent: str = '',
|
||||
user_id: str = '', action: str = '',
|
||||
duration_ms: int = 0) -> None:
|
||||
extra = {}
|
||||
if directive_id:
|
||||
extra['directive_id'] = directive_id
|
||||
if agent:
|
||||
extra['agent'] = agent
|
||||
if user_id:
|
||||
extra['user_id'] = user_id
|
||||
if action:
|
||||
extra['action'] = action
|
||||
if duration_ms:
|
||||
extra['duration_ms'] = duration_ms
|
||||
getattr(logger, level.lower(), logger.info)(msg, extra=extra)
|
||||
|
||||
|
||||
async def push_to_loki(labels: dict, entries: list[dict]) -> None:
|
||||
if not _loki_url:
|
||||
return
|
||||
try:
|
||||
import httpx
|
||||
streams = [{'stream': labels, 'values': [[str(int(e['ts'] * 1e9)), e['line']] for e in entries]}]
|
||||
payload = {'streams': streams}
|
||||
async with httpx.AsyncClient(timeout=5) as client:
|
||||
await client.post(f'{_loki_url}/loki/api/v1/push', json=payload)
|
||||
except Exception as exc:
|
||||
logging.getLogger(__name__).debug('Loki push failed: %s', exc)
|
||||
238
agent_service/main.py
Normal file
238
agent_service/main.py
Normal file
@@ -0,0 +1,238 @@
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import asyncpg
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from .config import get_settings
|
||||
from . import app_state
|
||||
from .routers import dispatch, approval, registry, sweep, health
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _init_db(settings) -> asyncpg.Pool:
|
||||
pool = await asyncpg.create_pool(
|
||||
host=settings.postgres_host,
|
||||
port=settings.postgres_port,
|
||||
database=settings.postgres_db,
|
||||
user=settings.postgres_user,
|
||||
password=settings.postgres_password,
|
||||
min_size=settings.postgres_min_connections,
|
||||
max_size=settings.postgres_max_connections,
|
||||
max_inactive_connection_lifetime=300,
|
||||
)
|
||||
logger.info('DB pool created (min=%d max=%d)', settings.postgres_min_connections, settings.postgres_max_connections)
|
||||
return pool
|
||||
|
||||
|
||||
async def _db_health_loop(pool: asyncpg.Pool) -> None:
|
||||
while True:
|
||||
await asyncio.sleep(60)
|
||||
try:
|
||||
async with pool.acquire(timeout=5) as conn:
|
||||
await conn.fetchval('SELECT 1')
|
||||
except Exception as exc:
|
||||
logger.warning('DB health check failed: %s', exc)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
settings = get_settings()
|
||||
_configure_logging(settings)
|
||||
|
||||
# 1. Database
|
||||
try:
|
||||
pool = await _init_db(settings)
|
||||
app_state.set_db_pool(pool)
|
||||
asyncio.create_task(_db_health_loop(pool))
|
||||
except Exception as exc:
|
||||
logger.error('Failed to connect to database: %s', exc)
|
||||
pool = None
|
||||
|
||||
# 2. LLM Router
|
||||
try:
|
||||
from .llm.ollama_backend import OllamaBackend
|
||||
from .llm.llm_config_store import LLMConfigStore
|
||||
from .llm.llm_router import LLMRouter
|
||||
|
||||
ollama = OllamaBackend(
|
||||
base_url=settings.ollama_url,
|
||||
model=settings.ollama_model,
|
||||
timeout=settings.ollama_timeout,
|
||||
)
|
||||
config_store = LLMConfigStore(pool) if pool else None
|
||||
claude = None
|
||||
if settings.llm_privacy_mode != 'local' and settings.anthropic_api_key:
|
||||
from .llm.claude_backend import ClaudeBackend
|
||||
claude = ClaudeBackend(api_key=settings.anthropic_api_key, model=settings.claude_model)
|
||||
|
||||
llm_router = LLMRouter(
|
||||
ollama=ollama,
|
||||
claude=claude,
|
||||
config_store=config_store,
|
||||
privacy_mode=settings.llm_privacy_mode,
|
||||
env_overrides={
|
||||
name: settings.agent_backend_override(name)
|
||||
for name in [
|
||||
'finance_agent', 'accounting_agent', 'crm_agent', 'sales_agent',
|
||||
'project_agent', 'elearning_agent', 'expenses_agent', 'employees_agent',
|
||||
]
|
||||
if settings.agent_backend_override(name)
|
||||
},
|
||||
)
|
||||
app_state.set_llm_router(llm_router)
|
||||
logger.info('LLM router ready (mode=%s)', settings.llm_privacy_mode)
|
||||
except Exception as exc:
|
||||
logger.error('Failed to init LLM router: %s', exc)
|
||||
llm_router = None
|
||||
|
||||
# 3. Odoo client
|
||||
try:
|
||||
from .tools.odoo_client import OdooClient
|
||||
odoo = OdooClient(
|
||||
url=settings.odoo_url,
|
||||
db=settings.odoo_db,
|
||||
api_key=settings.odoo_api_key,
|
||||
)
|
||||
logger.info('Odoo client initialised (%s)', settings.odoo_url)
|
||||
except Exception as exc:
|
||||
logger.error('Failed to init Odoo client: %s', exc)
|
||||
odoo = None
|
||||
|
||||
# 4. Agent registry
|
||||
try:
|
||||
from .agents.registry import AgentRegistry
|
||||
agent_registry = AgentRegistry(odoo=odoo, pool=pool)
|
||||
app_state.set_agent_registry(agent_registry)
|
||||
except Exception as exc:
|
||||
logger.error('Failed to init agent registry: %s', exc)
|
||||
agent_registry = None
|
||||
|
||||
# 5. Memory manager
|
||||
try:
|
||||
from .memory.memory_manager import MemoryManager
|
||||
memory_mgr = MemoryManager(pool=pool, llm=llm_router) if pool else None
|
||||
except Exception as exc:
|
||||
logger.error('Failed to init memory manager: %s', exc)
|
||||
memory_mgr = None
|
||||
|
||||
# 6. Peer bus + specialist agents
|
||||
try:
|
||||
from .agents.peer_bus import PeerBus
|
||||
peer_bus = PeerBus()
|
||||
_register_specialist_agents(peer_bus, odoo, llm_router)
|
||||
except Exception as exc:
|
||||
logger.error('Failed to init peer bus / specialist agents: %s', exc)
|
||||
peer_bus = None
|
||||
|
||||
# 7. Master agent
|
||||
try:
|
||||
from .agents.master_agent import MasterAgent
|
||||
master = MasterAgent(
|
||||
odoo=odoo,
|
||||
llm=llm_router,
|
||||
memory=memory_mgr,
|
||||
peer_bus=peer_bus,
|
||||
registry=agent_registry,
|
||||
)
|
||||
app_state.set_master_agent(master)
|
||||
logger.info('MasterAgent ready')
|
||||
except Exception as exc:
|
||||
logger.error('Failed to init MasterAgent: %s', exc)
|
||||
|
||||
# 8. Sweep coordinator (lazy import — defined in Step 16)
|
||||
try:
|
||||
from .agents.sweep_coordinator import SweepCoordinator
|
||||
sweep_coord = SweepCoordinator(peer_bus=peer_bus)
|
||||
app_state.set_sweep_coordinator(sweep_coord)
|
||||
except ImportError:
|
||||
pass # not yet implemented
|
||||
except Exception as exc:
|
||||
logger.warning('Sweep coordinator not available: %s', exc)
|
||||
|
||||
logger.info('ActiveBlue AI agent service started on port %d', settings.agent_service_port)
|
||||
yield
|
||||
|
||||
# Shutdown
|
||||
if pool:
|
||||
await pool.close()
|
||||
logger.info('Agent service shut down')
|
||||
|
||||
|
||||
def _register_specialist_agents(peer_bus, odoo, llm_router) -> None:
|
||||
try:
|
||||
from .agents.finance_agent import FinanceAgent
|
||||
peer_bus.register('finance_agent', FinanceAgent(odoo=odoo, llm=llm_router, peer_bus=peer_bus))
|
||||
except Exception as exc:
|
||||
logger.warning('Could not register finance_agent: %s', exc)
|
||||
|
||||
specialist_map = {
|
||||
'accounting_agent': 'AccountingAgent',
|
||||
'crm_agent': 'CrmAgent',
|
||||
'sales_agent': 'SalesAgent',
|
||||
'project_agent': 'ProjectAgent',
|
||||
'elearning_agent': 'ElearningAgent',
|
||||
'expenses_agent': 'ExpensesAgent',
|
||||
'employees_agent': 'EmployeesAgent',
|
||||
}
|
||||
for agent_name, class_name in specialist_map.items():
|
||||
module_name = agent_name.replace('_agent', '_agent')
|
||||
try:
|
||||
import importlib
|
||||
mod = importlib.import_module(f'.agents.{agent_name}', package='agent_service')
|
||||
cls = getattr(mod, class_name)
|
||||
peer_bus.register(agent_name, cls(odoo=odoo, llm=llm_router, peer_bus=peer_bus))
|
||||
except ImportError:
|
||||
logger.debug('%s module not yet implemented, skipping', agent_name)
|
||||
except Exception as exc:
|
||||
logger.warning('Could not register %s: %s', agent_name, exc)
|
||||
|
||||
|
||||
def _configure_logging(settings) -> None:
|
||||
level = getattr(logging, settings.log_level.upper(), logging.INFO)
|
||||
if settings.log_format == 'json':
|
||||
try:
|
||||
import json_log_formatter
|
||||
formatter = json_log_formatter.JSONFormatter()
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
handler.setFormatter(formatter)
|
||||
logging.root.handlers = [handler]
|
||||
except ImportError:
|
||||
logging.basicConfig(level=level, stream=sys.stdout)
|
||||
else:
|
||||
logging.basicConfig(
|
||||
level=level,
|
||||
stream=sys.stdout,
|
||||
format='%(asctime)s %(name)s %(levelname)s %(message)s',
|
||||
)
|
||||
logging.root.setLevel(level)
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
app = FastAPI(
|
||||
title='ActiveBlue AI Agent Service',
|
||||
version='0.1.0',
|
||||
docs_url='/docs',
|
||||
redoc_url=None,
|
||||
lifespan=lifespan,
|
||||
)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=['*'],
|
||||
allow_methods=['GET', 'POST', 'DELETE'],
|
||||
allow_headers=['*'],
|
||||
)
|
||||
app.include_router(dispatch.router)
|
||||
app.include_router(approval.router)
|
||||
app.include_router(registry.router)
|
||||
app.include_router(sweep.router)
|
||||
app.include_router(health.router)
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
0
agent_service/memory/__init__.py
Normal file
0
agent_service/memory/__init__.py
Normal file
47
agent_service/memory/conversation_store.py
Normal file
47
agent_service/memory/conversation_store.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
HARD_CAP = 200
|
||||
|
||||
|
||||
class ConversationStore:
|
||||
def __init__(self, pool):
|
||||
self._pool = pool
|
||||
|
||||
async def append(self, user_id, role, content, directive_id=None, is_summary=False):
|
||||
async with self._pool.acquire(timeout=10) as conn:
|
||||
await conn.execute(
|
||||
"""INSERT INTO ab_conversation_memory
|
||||
(user_id, role, content, directive_id, is_summary)
|
||||
VALUES ($1, $2, $3, $4, $5)""",
|
||||
user_id, role, content, directive_id, is_summary)
|
||||
|
||||
async def get(self, user_id, limit=50):
|
||||
async with self._pool.acquire(timeout=10) as conn:
|
||||
rows = await conn.fetch(
|
||||
"""SELECT id, role, content, directive_id, is_summary, created_at
|
||||
FROM ab_conversation_memory
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC LIMIT $2""",
|
||||
user_id, limit)
|
||||
return [dict(r) for r in reversed(rows)]
|
||||
|
||||
async def count(self, user_id):
|
||||
async with self._pool.acquire(timeout=10) as conn:
|
||||
row = await conn.fetchrow(
|
||||
'SELECT COUNT(*) as n FROM ab_conversation_memory WHERE user_id = $1 AND is_summary = false',
|
||||
user_id)
|
||||
return row['n']
|
||||
|
||||
async def prune_old(self, user_id, keep=50):
|
||||
async with self._pool.acquire(timeout=10) as conn:
|
||||
await conn.execute(
|
||||
"""DELETE FROM ab_conversation_memory
|
||||
WHERE user_id = $1 AND is_summary = false
|
||||
AND id NOT IN (
|
||||
SELECT id FROM ab_conversation_memory
|
||||
WHERE user_id = $1 AND is_summary = false
|
||||
ORDER BY created_at DESC LIMIT $2
|
||||
)""",
|
||||
user_id, keep)
|
||||
31
agent_service/memory/knowledge_store.py
Normal file
31
agent_service/memory/knowledge_store.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
import json, logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class KnowledgeStore:
|
||||
def __init__(self, pool):
|
||||
self._pool = pool
|
||||
|
||||
async def upsert(self, entity_type, entity_key, facts):
|
||||
async with self._pool.acquire(timeout=10) as conn:
|
||||
await conn.execute(
|
||||
"""INSERT INTO ab_knowledge_store (entity_type, entity_key, facts)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (entity_type, entity_key)
|
||||
DO UPDATE SET facts = $3, updated_at = NOW()""",
|
||||
entity_type, entity_key, json.dumps(facts))
|
||||
|
||||
async def get(self, entity_type, entity_key):
|
||||
async with self._pool.acquire(timeout=10) as conn:
|
||||
row = await conn.fetchrow(
|
||||
'SELECT facts FROM ab_knowledge_store WHERE entity_type=$1 AND entity_key=$2',
|
||||
entity_type, entity_key)
|
||||
if not row:
|
||||
return {}
|
||||
f = row['facts']
|
||||
return json.loads(f) if isinstance(f, str) else f
|
||||
|
||||
async def get_client_profile(self, partner_id):
|
||||
return await self.get('client', f'partner_{partner_id}')
|
||||
151
agent_service/memory/memory_manager.py
Normal file
151
agent_service/memory/memory_manager.py
Normal file
@@ -0,0 +1,151 @@
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from .conversation_store import ConversationStore, HARD_CAP
|
||||
from .operational_store import OperationalStore
|
||||
from .knowledge_store import KnowledgeStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
SUMMARIZE_THRESHOLD = 50
|
||||
|
||||
|
||||
@dataclass
|
||||
class MasterContext:
|
||||
user_id: int
|
||||
conversation: list = field(default_factory=list)
|
||||
operational_findings: list = field(default_factory=list)
|
||||
knowledge: dict = field(default_factory=dict)
|
||||
pending_approvals: list = field(default_factory=list)
|
||||
active_directives: list = field(default_factory=list)
|
||||
|
||||
|
||||
class MemoryManager:
|
||||
def __init__(self, pool, llm_router=None):
|
||||
self._pool = pool
|
||||
self._llm = llm_router
|
||||
self._conv = ConversationStore(pool)
|
||||
self._ops = OperationalStore(pool)
|
||||
self._know = KnowledgeStore(pool)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Tier 1 — Conversation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def append_message(self, user_id, role, content, directive_id=None):
|
||||
await self._conv.append(user_id, role, content, directive_id)
|
||||
await self.summarize_if_needed(user_id)
|
||||
|
||||
async def get_conversation(self, user_id, limit=50):
|
||||
return await self._conv.get(user_id, limit)
|
||||
|
||||
async def summarize_if_needed(self, user_id):
|
||||
count = await self._conv.count(user_id)
|
||||
if count < SUMMARIZE_THRESHOLD:
|
||||
return
|
||||
if count >= HARD_CAP - 10:
|
||||
logger.warning('Conversation memory near hard cap for user_id=%s count=%d', user_id, count)
|
||||
if self._llm and count >= SUMMARIZE_THRESHOLD:
|
||||
recent = await self._conv.get(user_id, limit=SUMMARIZE_THRESHOLD)
|
||||
if not recent:
|
||||
return
|
||||
history_text = chr(10).join(m['role'] + ': ' + m['content'] for m in recent[:20])
|
||||
messages = [
|
||||
{'role': 'system', 'content': 'Summarize this conversation history in 3-5 sentences, preserving key decisions and context.'},
|
||||
{'role': 'user', 'content': history_text}
|
||||
]
|
||||
try:
|
||||
resp = await self._llm.submit(messages, caller='memory_manager')
|
||||
summary = resp.content
|
||||
await self._conv.append(user_id, 'system', f'[SUMMARY] {summary}', is_summary=True)
|
||||
await self._conv.prune_old(user_id, keep=20)
|
||||
logger.info('Conversation summarized for user_id=%s', user_id)
|
||||
except Exception as exc:
|
||||
logger.error('Conversation summarization failed user_id=%s: %s', user_id, exc)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Tier 2 — Operational
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def store_findings(self, scope, summary, raw_data=None, ttl_days=90, source_directive_id=None):
|
||||
await self._ops.store(scope, summary, raw_data, ttl_days, source_directive_id)
|
||||
|
||||
async def get_recent_findings(self, scope, limit=10):
|
||||
return await self._ops.get_recent(scope, limit)
|
||||
|
||||
async def prune_expired_operational(self):
|
||||
await self._ops.prune_expired()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Tier 3 — Long-term Knowledge
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def upsert_knowledge(self, entity_type, entity_key, facts):
|
||||
await self._know.upsert(entity_type, entity_key, facts)
|
||||
|
||||
async def get_knowledge(self, entity_type, entity_key):
|
||||
return await self._know.get(entity_type, entity_key)
|
||||
|
||||
async def get_client_profile(self, partner_id):
|
||||
return await self._know.get_client_profile(partner_id)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Context assembly — called before every Master LLM call
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def build_context(self, user_id, intent_hint=None):
|
||||
conversation = await self._conv.get(user_id, limit=50)
|
||||
ops_scope = intent_hint or 'general'
|
||||
operational_findings = await self._ops.get_recent(ops_scope, limit=5)
|
||||
knowledge = {}
|
||||
if intent_hint:
|
||||
try:
|
||||
knowledge = await self._know.get('user', str(user_id))
|
||||
except Exception:
|
||||
pass
|
||||
pending_approvals = await self._get_pending_approvals(user_id)
|
||||
active_directives = await self._get_active_directives(user_id)
|
||||
return MasterContext(
|
||||
user_id=user_id,
|
||||
conversation=conversation,
|
||||
operational_findings=operational_findings,
|
||||
knowledge=knowledge,
|
||||
pending_approvals=pending_approvals,
|
||||
active_directives=active_directives,
|
||||
)
|
||||
|
||||
async def _get_pending_approvals(self, user_id):
|
||||
try:
|
||||
async with self._pool.acquire(timeout=10) as conn:
|
||||
rows = await conn.fetch(
|
||||
"""SELECT directive_id, escalations FROM ab_directive_log
|
||||
WHERE user_id = $1 AND status = 'awaiting_approval'
|
||||
ORDER BY started_at DESC LIMIT 10""",
|
||||
user_id)
|
||||
return [dict(r) for r in rows]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
async def _get_active_directives(self, user_id):
|
||||
try:
|
||||
async with self._pool.acquire(timeout=10) as conn:
|
||||
rows = await conn.fetch(
|
||||
"""SELECT directive_id, intent_summary, status, started_at
|
||||
FROM ab_directive_log
|
||||
WHERE user_id = $1 AND status IN ('pending', 'processing')
|
||||
ORDER BY started_at DESC LIMIT 5""",
|
||||
user_id)
|
||||
return [dict(r) for r in rows]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
async def summarize_long_conversations(self):
|
||||
try:
|
||||
async with self._pool.acquire(timeout=10) as conn:
|
||||
rows = await conn.fetch(
|
||||
"""SELECT user_id, COUNT(*) as n FROM ab_conversation_memory
|
||||
WHERE is_summary = false GROUP BY user_id HAVING COUNT(*) > $1""",
|
||||
SUMMARIZE_THRESHOLD)
|
||||
for row in rows:
|
||||
await self.summarize_if_needed(row['user_id'])
|
||||
except Exception as exc:
|
||||
logger.error('summarize_long_conversations failed: %s', exc)
|
||||
35
agent_service/memory/operational_store.py
Normal file
35
agent_service/memory/operational_store.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OperationalStore:
|
||||
def __init__(self, pool):
|
||||
self._pool = pool
|
||||
|
||||
async def store(self, scope, summary, raw_data=None, ttl_days=90, source_directive_id=None):
|
||||
expires_at = datetime.utcnow() + timedelta(days=ttl_days)
|
||||
async with self._pool.acquire(timeout=10) as conn:
|
||||
await conn.execute(
|
||||
"""INSERT INTO ab_operational_memory
|
||||
(scope, summary, raw_data, source_directive_id, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5)""",
|
||||
scope, summary, raw_data, source_directive_id, expires_at)
|
||||
|
||||
async def get_recent(self, scope, limit=10):
|
||||
async with self._pool.acquire(timeout=10) as conn:
|
||||
rows = await conn.fetch(
|
||||
"""SELECT id, scope, summary, raw_data, created_at
|
||||
FROM ab_operational_memory
|
||||
WHERE scope = $1 AND (expires_at IS NULL OR expires_at > NOW())
|
||||
ORDER BY created_at DESC LIMIT $2""",
|
||||
scope, limit)
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
async def prune_expired(self):
|
||||
async with self._pool.acquire(timeout=10) as conn:
|
||||
result = await conn.execute(
|
||||
'DELETE FROM ab_operational_memory WHERE expires_at IS NOT NULL AND expires_at < NOW()')
|
||||
logger.info('OperationalStore.prune_expired: %s', result)
|
||||
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
|
||||
31
agent_service/prompts/finance_system.txt
Normal file
31
agent_service/prompts/finance_system.txt
Normal file
@@ -0,0 +1,31 @@
|
||||
You are the Finance Agent for ActiveBlue AI, a specialist in accounts receivable, invoicing, and financial health monitoring within Odoo 18.
|
||||
|
||||
## Your Role
|
||||
You analyse invoice data, identify overdue balances, monitor collection rates, and take automated actions such as sending payment reminders and flagging high-risk accounts for human review.
|
||||
|
||||
## Capabilities
|
||||
- Retrieve invoices with flexible filters (state, partner, date range, type)
|
||||
- Identify overdue invoices and calculate days overdue
|
||||
- Generate financial summaries by period
|
||||
- Review payment history per partner
|
||||
- Send payment reminder emails via Odoo chatter
|
||||
- Flag records for human review with severity levels
|
||||
- Post internal notes on invoice records
|
||||
|
||||
## Rules
|
||||
- NEVER create, modify, or delete invoices or payments directly
|
||||
- NEVER send a reminder without confirming the invoice is genuinely unpaid (state = posted, payment_state != paid)
|
||||
- Flag but do not automatically write off any invoice over 90 days overdue — escalate to human
|
||||
- All financial data is HIPAA-sensitive: never include account numbers or personal data in escalation messages to non-finance agents
|
||||
- When uncertain about a financial decision, flag for review rather than act
|
||||
|
||||
## Data Privacy
|
||||
This agent is HIPAA-locked. All processing occurs on-premises using the local LLM only. No financial data is sent to cloud APIs.
|
||||
|
||||
## Output Format
|
||||
Respond with structured findings:
|
||||
1. Summary of financial health
|
||||
2. Overdue invoices list (partner, amount, days overdue)
|
||||
3. Actions taken (reminders sent, flags raised)
|
||||
4. Escalations requiring human review
|
||||
5. Recommendations for next steps
|
||||
35
agent_service/prompts/master_system.txt
Normal file
35
agent_service/prompts/master_system.txt
Normal file
@@ -0,0 +1,35 @@
|
||||
You are ActiveBlue AI - the central intelligence for Active Blue LLC Odoo instance.
|
||||
Active Blue is an MSP serving medical and dental practices at 8 locations across
|
||||
Miami, Dadeland, Tomball, Hollywood, and Miami Lakes (FL and TX).
|
||||
|
||||
Your role:
|
||||
1. Understand intent from natural language using full conversation context
|
||||
2. Build precise directives for specialist agents with injected memory context
|
||||
3. Synthesize agent reports into one coherent user response
|
||||
4. Update memory with new findings after every interaction
|
||||
|
||||
You are the ONLY entity that communicates with users.
|
||||
You do NOT act on Odoo directly.
|
||||
|
||||
Active specialist agents:
|
||||
{agent_list}
|
||||
|
||||
If a user requests something for an agent not listed, tell them the Odoo module is not installed.
|
||||
|
||||
Rules:
|
||||
- Ask ONE clarifying question if intent is ambiguous - then dispatch
|
||||
- Confirm multi-step plans before executing
|
||||
- Surface escalations with approve/reject options
|
||||
- Never expose agent names, tool names, or system internals to users
|
||||
- HIPAA: Never include patient names, MRN, DOB, or any PHI in responses
|
||||
|
||||
Classify intent in JSON only:
|
||||
{
|
||||
"needs_clarification": false,
|
||||
"clarification_question": null,
|
||||
"is_continuation": false,
|
||||
"agents": ["finance_agent"],
|
||||
"intent_summary": "...",
|
||||
"params": {},
|
||||
"context_hints": []
|
||||
}
|
||||
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
|
||||
0
agent_service/routers/__init__.py
Normal file
0
agent_service/routers/__init__.py
Normal file
89
agent_service/routers/approval.py
Normal file
89
agent_service/routers/approval.py
Normal file
@@ -0,0 +1,89 @@
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix='/approval', tags=['approval'])
|
||||
|
||||
|
||||
class ApprovalRequest(BaseModel):
|
||||
directive_id: str
|
||||
approved: bool
|
||||
approver_id: str
|
||||
note: Optional[str] = None
|
||||
|
||||
|
||||
class PendingApproval(BaseModel):
|
||||
directive_id: str
|
||||
agent: str
|
||||
action: str
|
||||
description: str
|
||||
created_at: str
|
||||
context: dict = {}
|
||||
|
||||
|
||||
@router.get('/pending', response_model=list[PendingApproval])
|
||||
async def list_pending():
|
||||
from ..app_state import get_db_pool
|
||||
pool = get_db_pool()
|
||||
if pool is None:
|
||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail='DB not ready')
|
||||
async with pool.acquire(timeout=10) as conn:
|
||||
rows = await conn.fetch(
|
||||
'SELECT directive_id, agent_name, action_type, description, created_at, context_data '
|
||||
'FROM ab_directive_log WHERE status = $1 ORDER BY created_at ASC',
|
||||
'pending_approval',
|
||||
)
|
||||
return [
|
||||
PendingApproval(
|
||||
directive_id=str(r['directive_id']),
|
||||
agent=r['agent_name'] or '',
|
||||
action=r['action_type'] or '',
|
||||
description=r['description'] or '',
|
||||
created_at=str(r['created_at']),
|
||||
context=r['context_data'] or {},
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
@router.post('/respond', status_code=status.HTTP_200_OK)
|
||||
async def respond_approval(req: ApprovalRequest):
|
||||
from ..app_state import get_db_pool, get_master_agent
|
||||
pool = get_db_pool()
|
||||
if pool is None:
|
||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail='DB not ready')
|
||||
|
||||
async with pool.acquire(timeout=10) as conn:
|
||||
row = await conn.fetchrow(
|
||||
'SELECT directive_id, status FROM ab_directive_log WHERE directive_id = $1',
|
||||
req.directive_id,
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Directive not found')
|
||||
if row['status'] != 'pending_approval':
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f'Directive is not pending approval (status={row["status"]})',
|
||||
)
|
||||
new_status = 'approved' if req.approved else 'rejected'
|
||||
await conn.execute(
|
||||
'UPDATE ab_directive_log SET status=$1, approver_id=$2, approval_note=$3, updated_at=NOW() '
|
||||
'WHERE directive_id=$4',
|
||||
new_status, req.approver_id, req.note, req.directive_id,
|
||||
)
|
||||
|
||||
logger.info('Directive %s %s by %s', req.directive_id, new_status, req.approver_id)
|
||||
|
||||
if req.approved:
|
||||
master = get_master_agent()
|
||||
if master:
|
||||
try:
|
||||
await master.resume_directive(req.directive_id)
|
||||
except Exception as exc:
|
||||
logger.error('resume_directive failed %s: %s', req.directive_id, exc)
|
||||
|
||||
return {'directive_id': req.directive_id, 'status': new_status}
|
||||
102
agent_service/routers/dispatch.py
Normal file
102
agent_service/routers/dispatch.py
Normal file
@@ -0,0 +1,102 @@
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix='/dispatch', tags=['dispatch'])
|
||||
|
||||
# In-memory rate limit store: {user_id: [timestamp, ...]}
|
||||
_rate_limit_store: dict[str, list[float]] = {}
|
||||
|
||||
|
||||
class DispatchRequest(BaseModel):
|
||||
user_id: str = Field(..., description='Odoo user ID or session identifier')
|
||||
message: str = Field(..., description='User natural-language message')
|
||||
context: dict = Field(default_factory=dict, description='Optional context (partner_id, etc.)')
|
||||
session_id: Optional[str] = Field(None, description='Conversation session ID')
|
||||
|
||||
|
||||
class DispatchResponse(BaseModel):
|
||||
directive_id: str
|
||||
reply: str
|
||||
agent_reports: list[dict] = []
|
||||
escalations: list[str] = []
|
||||
actions_taken: list[dict] = []
|
||||
session_id: Optional[str] = None
|
||||
|
||||
|
||||
def _verify_webhook_secret(request: Request) -> None:
|
||||
settings = get_settings()
|
||||
secret = settings.webhook_secret
|
||||
if not secret:
|
||||
return
|
||||
sig = request.headers.get('X-ActiveBlue-Signature', '')
|
||||
if not sig:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail='Missing signature')
|
||||
|
||||
|
||||
def _check_rate_limit(user_id: str) -> None:
|
||||
settings = get_settings()
|
||||
limit = settings.dispatch_rate_limit_per_user
|
||||
now = time.monotonic()
|
||||
window = 60.0
|
||||
timestamps = _rate_limit_store.get(user_id, [])
|
||||
timestamps = [t for t in timestamps if now - t < window]
|
||||
if len(timestamps) >= limit:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=f'Rate limit exceeded: {limit} requests/minute',
|
||||
)
|
||||
timestamps.append(now)
|
||||
_rate_limit_store[user_id] = timestamps
|
||||
|
||||
|
||||
@router.post('', response_model=DispatchResponse)
|
||||
async def dispatch(req: DispatchRequest, request: Request):
|
||||
_verify_webhook_secret(request)
|
||||
_check_rate_limit(req.user_id)
|
||||
|
||||
from ..app_state import get_master_agent
|
||||
master = get_master_agent()
|
||||
if master is None:
|
||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail='Agent service not ready')
|
||||
|
||||
settings = get_settings()
|
||||
timeout = settings.directive_timeout_minutes * 60
|
||||
|
||||
try:
|
||||
response = await asyncio.wait_for(
|
||||
master.handle_message(
|
||||
user_id=req.user_id,
|
||||
message=req.message,
|
||||
context=req.context,
|
||||
session_id=req.session_id,
|
||||
),
|
||||
timeout=timeout,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
|
||||
detail=f'Directive timed out after {settings.directive_timeout_minutes}m',
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception('dispatch error user=%s: %s', req.user_id, exc)
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc))
|
||||
|
||||
return DispatchResponse(
|
||||
directive_id=response.directive_id,
|
||||
reply=response.reply,
|
||||
agent_reports=[r.dict() if hasattr(r, 'dict') else r for r in response.agent_reports],
|
||||
escalations=response.escalations,
|
||||
actions_taken=response.actions_taken,
|
||||
session_id=req.session_id,
|
||||
)
|
||||
85
agent_service/routers/health.py
Normal file
85
agent_service/routers/health.py
Normal file
@@ -0,0 +1,85 @@
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix='/health', tags=['health'])
|
||||
|
||||
_start_time = time.time()
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
status: str
|
||||
uptime_seconds: float
|
||||
|
||||
|
||||
class DetailedHealthResponse(BaseModel):
|
||||
status: str
|
||||
uptime_seconds: float
|
||||
db: str
|
||||
odoo: str
|
||||
ollama: str
|
||||
master_agent: str
|
||||
privacy_mode: str
|
||||
|
||||
|
||||
@router.get('', response_model=HealthResponse)
|
||||
async def health():
|
||||
return HealthResponse(status='ok', uptime_seconds=round(time.time() - _start_time, 1))
|
||||
|
||||
|
||||
@router.get('/detailed', response_model=DetailedHealthResponse)
|
||||
async def health_detailed():
|
||||
from ..app_state import get_db_pool, get_master_agent, get_llm_router
|
||||
from ..config import get_settings
|
||||
|
||||
uptime = round(time.time() - _start_time, 1)
|
||||
settings = get_settings()
|
||||
|
||||
# DB check
|
||||
db_status = 'unavailable'
|
||||
pool = get_db_pool()
|
||||
if pool:
|
||||
try:
|
||||
async with pool.acquire(timeout=5) as conn:
|
||||
await conn.fetchval('SELECT 1')
|
||||
db_status = 'ok'
|
||||
except Exception as exc:
|
||||
db_status = f'error: {exc}'
|
||||
|
||||
# Odoo check
|
||||
odoo_status = 'unavailable'
|
||||
master = get_master_agent()
|
||||
if master and hasattr(master, '_odoo'):
|
||||
try:
|
||||
await asyncio.wait_for(master._odoo.ping(), timeout=5)
|
||||
odoo_status = 'ok'
|
||||
except Exception as exc:
|
||||
odoo_status = f'error: {exc}'
|
||||
|
||||
# Ollama check
|
||||
ollama_status = 'unavailable'
|
||||
llm_router = get_llm_router()
|
||||
if llm_router and hasattr(llm_router, '_ollama'):
|
||||
try:
|
||||
await asyncio.wait_for(llm_router._ollama.ping(), timeout=5)
|
||||
ollama_status = 'ok'
|
||||
except Exception as exc:
|
||||
ollama_status = f'error: {exc}'
|
||||
|
||||
master_status = 'ok' if master is not None else 'unavailable'
|
||||
overall = 'ok' if all(s == 'ok' for s in [db_status, master_status]) else 'degraded'
|
||||
|
||||
return DetailedHealthResponse(
|
||||
status=overall,
|
||||
uptime_seconds=uptime,
|
||||
db=db_status,
|
||||
odoo=odoo_status,
|
||||
ollama=ollama_status,
|
||||
master_agent=master_status,
|
||||
privacy_mode=settings.llm_privacy_mode,
|
||||
)
|
||||
98
agent_service/routers/registry.py
Normal file
98
agent_service/routers/registry.py
Normal file
@@ -0,0 +1,98 @@
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix='/registry', tags=['registry'])
|
||||
|
||||
|
||||
class AgentInfo(BaseModel):
|
||||
name: str
|
||||
domain: str
|
||||
active: bool
|
||||
backend: str = 'ollama'
|
||||
last_seen: Optional[str] = None
|
||||
|
||||
|
||||
class BackendOverride(BaseModel):
|
||||
agent_name: str
|
||||
backend: str # ollama | claude
|
||||
set_by: str
|
||||
note: Optional[str] = None
|
||||
|
||||
|
||||
@router.get('/agents', response_model=list[AgentInfo])
|
||||
async def list_agents():
|
||||
from ..app_state import get_agent_registry, get_llm_router
|
||||
registry = get_agent_registry()
|
||||
llm_router = get_llm_router()
|
||||
if registry is None:
|
||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail='Registry not ready')
|
||||
agents = registry.get_all()
|
||||
result = []
|
||||
for agent in agents:
|
||||
backend = 'ollama'
|
||||
if llm_router:
|
||||
try:
|
||||
backend = await llm_router.get_backend(agent['name'])
|
||||
except Exception:
|
||||
pass
|
||||
result.append(AgentInfo(
|
||||
name=agent['name'],
|
||||
domain=agent.get('domain', ''),
|
||||
active=agent.get('active', True),
|
||||
backend=backend,
|
||||
last_seen=agent.get('last_seen'),
|
||||
))
|
||||
return result
|
||||
|
||||
|
||||
@router.post('/sync', status_code=status.HTTP_200_OK)
|
||||
async def sync_registry():
|
||||
from ..app_state import get_agent_registry
|
||||
registry = get_agent_registry()
|
||||
if registry is None:
|
||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail='Registry not ready')
|
||||
try:
|
||||
count = await registry.sync()
|
||||
return {'synced': count}
|
||||
except Exception as exc:
|
||||
logger.error('registry sync failed: %s', exc)
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc))
|
||||
|
||||
|
||||
@router.post('/backend', status_code=status.HTTP_200_OK)
|
||||
async def set_backend_override(req: BackendOverride):
|
||||
from ..app_state import get_llm_router
|
||||
llm_router = get_llm_router()
|
||||
if llm_router is None:
|
||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail='LLM router not ready')
|
||||
if req.backend not in ('ollama', 'claude'):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='backend must be ollama or claude')
|
||||
try:
|
||||
await llm_router.set_backend_override(
|
||||
caller=req.agent_name,
|
||||
backend=req.backend,
|
||||
set_by=req.set_by,
|
||||
note=req.note,
|
||||
)
|
||||
return {'agent': req.agent_name, 'backend': req.backend}
|
||||
except Exception as exc:
|
||||
logger.error('set_backend_override failed: %s', exc)
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc))
|
||||
|
||||
|
||||
@router.delete('/backend/{agent_name}', status_code=status.HTTP_200_OK)
|
||||
async def reset_backend_override(agent_name: str):
|
||||
from ..app_state import get_llm_router
|
||||
llm_router = get_llm_router()
|
||||
if llm_router is None:
|
||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail='LLM router not ready')
|
||||
try:
|
||||
await llm_router.reset_backend_override(caller=agent_name)
|
||||
return {'agent': agent_name, 'reset': True}
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc))
|
||||
55
agent_service/routers/sweep.py
Normal file
55
agent_service/routers/sweep.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix='/sweep', tags=['sweep'])
|
||||
|
||||
# Track running sweep task
|
||||
_sweep_task: Optional[asyncio.Task] = None
|
||||
_last_sweep_result: Optional[dict] = None
|
||||
|
||||
|
||||
class SweepRequest(BaseModel):
|
||||
agents: list[str] = [] # empty = all active agents
|
||||
|
||||
|
||||
class SweepStatusResponse(BaseModel):
|
||||
running: bool
|
||||
last_result: Optional[dict] = None
|
||||
|
||||
|
||||
@router.post('', status_code=status.HTTP_202_ACCEPTED)
|
||||
async def trigger_sweep(req: SweepRequest):
|
||||
global _sweep_task
|
||||
if _sweep_task and not _sweep_task.done():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='Sweep already running',
|
||||
)
|
||||
from ..app_state import get_sweep_coordinator
|
||||
coordinator = get_sweep_coordinator()
|
||||
if coordinator is None:
|
||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail='Sweep coordinator not ready')
|
||||
|
||||
async def _run():
|
||||
global _last_sweep_result
|
||||
try:
|
||||
result = await coordinator.run_sweep(agents=req.agents or None)
|
||||
_last_sweep_result = result
|
||||
except Exception as exc:
|
||||
logger.error('sweep run error: %s', exc)
|
||||
_last_sweep_result = {'error': str(exc)}
|
||||
|
||||
_sweep_task = asyncio.create_task(_run())
|
||||
return {'status': 'accepted', 'agents': req.agents or 'all'}
|
||||
|
||||
|
||||
@router.get('/status', response_model=SweepStatusResponse)
|
||||
async def sweep_status():
|
||||
running = _sweep_task is not None and not _sweep_task.done()
|
||||
return SweepStatusResponse(running=running, last_result=_last_sweep_result)
|
||||
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
|
||||
119
agent_service/tools/finance_tools.py
Normal file
119
agent_service/tools/finance_tools.py
Normal file
@@ -0,0 +1,119 @@
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
from ..tools.odoo_client import OdooClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FinanceTools:
|
||||
def __init__(self, odoo: OdooClient):
|
||||
self._odoo = odoo
|
||||
|
||||
async def get_invoices(self, state='all', partner_id=None,
|
||||
date_from=None, date_to=None,
|
||||
move_type='all', limit=50):
|
||||
domain = []
|
||||
if move_type != 'all':
|
||||
domain.append(['move_type', '=', move_type])
|
||||
else:
|
||||
domain.append(['move_type', 'in', ['out_invoice', 'in_invoice', 'out_refund', 'in_refund']])
|
||||
if state != 'all':
|
||||
domain.append(['state', '=', state])
|
||||
if partner_id:
|
||||
domain.append(['partner_id', '=', partner_id])
|
||||
if date_from:
|
||||
domain.append(['invoice_date', '>=', date_from])
|
||||
if date_to:
|
||||
domain.append(['invoice_date', '<=', date_to])
|
||||
fields = ['name', 'move_type', 'state', 'partner_id', 'amount_total',
|
||||
'amount_residual', 'payment_state', 'invoice_date', 'invoice_date_due']
|
||||
return await self._odoo.search_read('account.move', domain, fields, limit=limit,
|
||||
order='invoice_date_due desc')
|
||||
|
||||
async def get_overdue_invoices(self, partner_id=None, min_days_overdue=1):
|
||||
from datetime import date, timedelta
|
||||
cutoff = (date.today() - timedelta(days=min_days_overdue)).isoformat()
|
||||
domain = [
|
||||
['move_type', 'in', ['out_invoice', 'out_refund']],
|
||||
['state', '=', 'posted'],
|
||||
['payment_state', 'in', ['not_paid', 'partial']],
|
||||
['invoice_date_due', '<', cutoff],
|
||||
]
|
||||
if partner_id:
|
||||
domain.append(['partner_id', '=', partner_id])
|
||||
fields = ['name', 'partner_id', 'amount_total', 'amount_residual',
|
||||
'invoice_date_due', 'payment_state']
|
||||
return await self._odoo.search_read('account.move', domain, fields,
|
||||
order='invoice_date_due asc')
|
||||
|
||||
async def get_unreconciled_statements(self, journal_id, date_from=None, date_to=None):
|
||||
domain = [['journal_id', '=', journal_id], ['is_reconciled', '=', False]]
|
||||
if date_from:
|
||||
domain.append(['date', '>=', date_from])
|
||||
if date_to:
|
||||
domain.append(['date', '<=', date_to])
|
||||
fields = ['date', 'payment_ref', 'amount', 'partner_id', 'is_reconciled', 'move_id']
|
||||
return await self._odoo.search_read('account.bank.statement.line', domain, fields,
|
||||
order='date asc')
|
||||
|
||||
async def match_statement_line(self, statement_line_id, move_id):
|
||||
result = await self._odoo.call(
|
||||
'account.bank.statement.line', 'reconcile',
|
||||
[[statement_line_id]], {'lines_vals': [{'id': move_id}]})
|
||||
return result
|
||||
|
||||
async def send_payment_reminder(self, invoice_id, custom_message=None):
|
||||
invoices = await self._odoo.read('account.move', [invoice_id],
|
||||
['name', 'partner_id', 'amount_residual', 'invoice_date_due'])
|
||||
if not invoices:
|
||||
return {'success': False, 'error': 'Invoice not found'}
|
||||
inv = invoices[0]
|
||||
body = custom_message or (
|
||||
f'Reminder: Invoice {inv["name"]} for {inv["amount_residual"]} '
|
||||
f'was due on {inv["invoice_date_due"]}. Please arrange payment.')
|
||||
msg_id = await self._odoo.post_chatter('account.move', invoice_id, body, subtype='mail.mt_comment')
|
||||
return {'success': True, 'message_id': msg_id, 'invoice': inv['name']}
|
||||
|
||||
async def get_financial_summary(self, period='current'):
|
||||
from datetime import date
|
||||
if period == 'current':
|
||||
today = date.today()
|
||||
date_from = today.replace(day=1).isoformat()
|
||||
date_to = today.isoformat()
|
||||
else:
|
||||
year, month = period.split('-')
|
||||
import calendar
|
||||
last_day = calendar.monthrange(int(year), int(month))[1]
|
||||
date_from = f'{year}-{month}-01'
|
||||
date_to = f'{year}-{month}-{last_day:02d}'
|
||||
invoiced = await self._odoo.search_read(
|
||||
'account.move',
|
||||
[['move_type', '=', 'out_invoice'], ['state', '=', 'posted'],
|
||||
['invoice_date', '>=', date_from], ['invoice_date', '<=', date_to]],
|
||||
['amount_total', 'amount_residual', 'payment_state'])
|
||||
total_invoiced = sum(i['amount_total'] for i in invoiced)
|
||||
total_outstanding = sum(i['amount_residual'] for i in invoiced)
|
||||
paid = sum(1 for i in invoiced if i['payment_state'] == 'paid')
|
||||
return {
|
||||
'period': period, 'date_from': date_from, 'date_to': date_to,
|
||||
'total_invoiced': total_invoiced, 'total_outstanding': total_outstanding,
|
||||
'total_paid': total_invoiced - total_outstanding,
|
||||
'invoice_count': len(invoiced), 'paid_count': paid,
|
||||
'collection_rate': round((1 - total_outstanding/total_invoiced)*100, 1) if total_invoiced else 0,
|
||||
}
|
||||
|
||||
async def get_payment_history(self, partner_id):
|
||||
fields = ['name', 'payment_type', 'amount', 'state', 'date', 'journal_id', 'ref']
|
||||
return await self._odoo.search_read(
|
||||
'account.payment',
|
||||
[['partner_id', '=', partner_id], ['state', '=', 'posted']],
|
||||
fields, limit=50, order='date desc')
|
||||
|
||||
async def flag_for_review(self, model, record_id, reason, severity='medium'):
|
||||
note = f'[REVIEW FLAGGED - {severity.upper()}] {reason}'
|
||||
await self._odoo.post_chatter(model, record_id, note)
|
||||
return {'flagged': True, 'model': model, 'record_id': record_id, 'severity': severity}
|
||||
|
||||
async def post_chatter_note(self, model, record_id, note):
|
||||
msg_id = await self._odoo.post_chatter(model, record_id, note)
|
||||
return {'success': True, 'message_id': msg_id}
|
||||
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
|
||||
72
build_deb.sh
Normal file
72
build_deb.sh
Normal file
@@ -0,0 +1,72 @@
|
||||
#!/bin/bash
|
||||
# Build the ActiveBlue AI Debian package
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
VERSION=$(cat "$SCRIPT_DIR/VERSION" 2>/dev/null || echo "0.1.0")
|
||||
PKG_NAME="activeblue-ai"
|
||||
PKG_DIR="$SCRIPT_DIR/build/${PKG_NAME}_${VERSION}"
|
||||
DIST_DIR="$SCRIPT_DIR/dist"
|
||||
|
||||
echo "Building $PKG_NAME v$VERSION"
|
||||
|
||||
# Clean previous build
|
||||
rm -rf "$PKG_DIR"
|
||||
mkdir -p "$PKG_DIR"
|
||||
|
||||
# Copy Debian control files
|
||||
cp -r "$SCRIPT_DIR/debian/DEBIAN" "$PKG_DIR/DEBIAN"
|
||||
|
||||
# Update version in control file
|
||||
sed -i "s/^Version:.*/Version: $VERSION/" "$PKG_DIR/DEBIAN/control"
|
||||
|
||||
# Make maintainer scripts executable
|
||||
chmod 755 "$PKG_DIR/DEBIAN/postinst" \
|
||||
"$PKG_DIR/DEBIAN/prerm" \
|
||||
"$PKG_DIR/DEBIAN/postrm"
|
||||
|
||||
# Install application files to /usr/lib/activeblue-ai
|
||||
APP_DIR="$PKG_DIR/usr/lib/activeblue-ai"
|
||||
mkdir -p "$APP_DIR"
|
||||
cp -r "$SCRIPT_DIR/agent_service" "$APP_DIR/"
|
||||
cp "$SCRIPT_DIR/requirements.txt" "$APP_DIR/"
|
||||
cp "$SCRIPT_DIR/VERSION" "$APP_DIR/"
|
||||
|
||||
# Remove Python cache
|
||||
find "$APP_DIR" -name '__pycache__' -type d -exec rm -rf {} + 2>/dev/null || true
|
||||
find "$APP_DIR" -name '*.pyc' -delete 2>/dev/null || true
|
||||
|
||||
# Install systemd unit
|
||||
mkdir -p "$PKG_DIR/lib/systemd/system"
|
||||
cp "$SCRIPT_DIR/debian/lib/systemd/system/activeblue-ai.service" \
|
||||
"$PKG_DIR/lib/systemd/system/"
|
||||
|
||||
# Install CLI tool
|
||||
mkdir -p "$PKG_DIR/usr/bin"
|
||||
cp "$SCRIPT_DIR/debian/usr/bin/activeblue-ai" "$PKG_DIR/usr/bin/"
|
||||
chmod 755 "$PKG_DIR/usr/bin/activeblue-ai"
|
||||
|
||||
# Install default config
|
||||
mkdir -p "$PKG_DIR/etc/activeblue-ai"
|
||||
cp "$SCRIPT_DIR/.env.example" "$PKG_DIR/etc/activeblue-ai/.env.example"
|
||||
|
||||
# Install documentation
|
||||
mkdir -p "$PKG_DIR/usr/share/doc/activeblue-ai"
|
||||
cp "$SCRIPT_DIR/README.md" "$PKG_DIR/usr/share/doc/activeblue-ai/"
|
||||
|
||||
# Calculate installed size (kB)
|
||||
SIZE=$(du -sk "$PKG_DIR" | cut -f1)
|
||||
sed -i "s/^Installed-Size:.*/Installed-Size: $SIZE/" "$PKG_DIR/DEBIAN/control" 2>/dev/null || \
|
||||
echo "Installed-Size: $SIZE" >> "$PKG_DIR/DEBIAN/control"
|
||||
|
||||
# Build the .deb
|
||||
mkdir -p "$DIST_DIR"
|
||||
DEB_FILE="$DIST_DIR/${PKG_NAME}_${VERSION}_all.deb"
|
||||
dpkg-deb --build --root-owner-group "$PKG_DIR" "$DEB_FILE"
|
||||
|
||||
echo ""
|
||||
echo "Package built: $DEB_FILE"
|
||||
echo ""
|
||||
echo "Install with:"
|
||||
echo " sudo dpkg -i $DEB_FILE"
|
||||
echo " sudo apt-get install -f # if there are dependency issues"
|
||||
15
debian/DEBIAN/control
vendored
Normal file
15
debian/DEBIAN/control
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
Package: activeblue-ai
|
||||
Version: 0.1.0
|
||||
Section: misc
|
||||
Priority: optional
|
||||
Architecture: all
|
||||
Depends: python3 (>= 3.11), python3-pip, python3-venv, postgresql-client
|
||||
Maintainer: ActiveBlue <admin@activeblue.net>
|
||||
Homepage: https://activeblue.net
|
||||
Description: ActiveBlue AI Agent Service
|
||||
Multi-agent AI system integrated with Odoo 18 Community.
|
||||
Provides a FastAPI service with 8 specialist AI agents for finance,
|
||||
accounting, CRM, sales, project management, eLearning, expenses, and HR.
|
||||
.
|
||||
Supports local (Ollama), hybrid, and cloud (Claude) LLM backends.
|
||||
HIPAA-sensitive agents are enforced to use local LLM only.
|
||||
47
debian/DEBIAN/postinst
vendored
Normal file
47
debian/DEBIAN/postinst
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
SERVICE_DIR="/usr/lib/activeblue-ai"
|
||||
VENV_DIR="$SERVICE_DIR/venv"
|
||||
CONFIG_DIR="/etc/activeblue-ai"
|
||||
LOG_DIR="/var/log/activeblue-ai"
|
||||
DATA_DIR="/var/lib/activeblue-ai"
|
||||
SERVICE_USER="activeblue-ai"
|
||||
|
||||
# Create service user
|
||||
if ! id "$SERVICE_USER" &>/dev/null; then
|
||||
adduser --system --no-create-home --group --disabled-login \
|
||||
--home "$DATA_DIR" "$SERVICE_USER"
|
||||
echo "Created service user: $SERVICE_USER"
|
||||
fi
|
||||
|
||||
# Create directories
|
||||
install -d -m 755 -o "$SERVICE_USER" -g "$SERVICE_USER" "$LOG_DIR"
|
||||
install -d -m 755 -o "$SERVICE_USER" -g "$SERVICE_USER" "$DATA_DIR"
|
||||
install -d -m 750 -o "$SERVICE_USER" -g "$SERVICE_USER" "$CONFIG_DIR"
|
||||
|
||||
# Create .env if it doesn't exist
|
||||
if [ ! -f "$CONFIG_DIR/.env" ]; then
|
||||
cp "$CONFIG_DIR/.env.example" "$CONFIG_DIR/.env" 2>/dev/null || true
|
||||
chmod 640 "$CONFIG_DIR/.env"
|
||||
chown "$SERVICE_USER:$SERVICE_USER" "$CONFIG_DIR/.env"
|
||||
echo "NOTE: Edit $CONFIG_DIR/.env before starting the service."
|
||||
fi
|
||||
|
||||
# Create and populate virtualenv
|
||||
if [ ! -d "$VENV_DIR" ]; then
|
||||
python3 -m venv "$VENV_DIR"
|
||||
fi
|
||||
"$VENV_DIR/bin/pip" install --quiet --upgrade pip
|
||||
"$VENV_DIR/bin/pip" install --quiet -r "$SERVICE_DIR/requirements.txt"
|
||||
|
||||
# Enable and reload systemd
|
||||
if command -v systemctl &>/dev/null && systemctl is-system-running --quiet 2>/dev/null; then
|
||||
systemctl daemon-reload
|
||||
systemctl enable activeblue-ai.service
|
||||
echo "Service enabled. Run: systemctl start activeblue-ai"
|
||||
fi
|
||||
|
||||
echo "ActiveBlue AI installed successfully."
|
||||
echo "Configure: $CONFIG_DIR/.env"
|
||||
echo "Run migrations: activeblue-ai migrate"
|
||||
12
debian/DEBIAN/postrm
vendored
Normal file
12
debian/DEBIAN/postrm
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
if [ "$1" = "purge" ]; then
|
||||
rm -rf /usr/lib/activeblue-ai/venv
|
||||
rm -rf /var/log/activeblue-ai
|
||||
rm -rf /etc/activeblue-ai
|
||||
if id activeblue-ai &>/dev/null; then
|
||||
deluser --system activeblue-ai || true
|
||||
fi
|
||||
echo "ActiveBlue AI purged."
|
||||
fi
|
||||
16
debian/DEBIAN/prerm
vendored
Normal file
16
debian/DEBIAN/prerm
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
if command -v systemctl &>/dev/null; then
|
||||
if systemctl is-active --quiet activeblue-ai.service 2>/dev/null; then
|
||||
systemctl stop activeblue-ai.service || true
|
||||
fi
|
||||
if systemctl is-enabled --quiet activeblue-ai.service 2>/dev/null; then
|
||||
systemctl disable activeblue-ai.service || true
|
||||
fi
|
||||
systemctl daemon-reload || true
|
||||
fi
|
||||
|
||||
echo "ActiveBlue AI service stopped."
|
||||
echo "Configuration preserved in /etc/activeblue-ai/"
|
||||
echo "Data preserved in /var/lib/activeblue-ai/"
|
||||
35
debian/lib/systemd/system/activeblue-ai.service
vendored
Normal file
35
debian/lib/systemd/system/activeblue-ai.service
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
[Unit]
|
||||
Description=ActiveBlue AI Agent Service
|
||||
Documentation=https://activeblue.net
|
||||
After=network.target postgresql.service
|
||||
Wants=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=activeblue-ai
|
||||
Group=activeblue-ai
|
||||
WorkingDirectory=/usr/lib/activeblue-ai
|
||||
EnvironmentFile=/etc/activeblue-ai/.env
|
||||
ExecStart=/usr/lib/activeblue-ai/venv/bin/uvicorn \
|
||||
agent_service.main:app \
|
||||
--host 0.0.0.0 \
|
||||
--port 8001 \
|
||||
--workers 1 \
|
||||
--no-access-log
|
||||
ExecReload=/bin/kill -HUP $MAINPID
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=activeblue-ai
|
||||
NoNewPrivileges=yes
|
||||
PrivateTmp=yes
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/var/log/activeblue-ai /var/lib/activeblue-ai
|
||||
TimeoutStartSec=60
|
||||
TimeoutStopSec=30
|
||||
KillMode=mixed
|
||||
KillSignal=SIGTERM
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
99
debian/usr/bin/activeblue-ai
vendored
Normal file
99
debian/usr/bin/activeblue-ai
vendored
Normal file
@@ -0,0 +1,99 @@
|
||||
#!/bin/bash
|
||||
# ActiveBlue AI CLI tool
|
||||
set -e
|
||||
|
||||
SERVICE_DIR="/usr/lib/activeblue-ai"
|
||||
VENV="$SERVICE_DIR/venv"
|
||||
CONFIG="/etc/activeblue-ai/.env"
|
||||
PYTHON="$VENV/bin/python3"
|
||||
|
||||
if [ ! -f "$CONFIG" ]; then
|
||||
echo "ERROR: Config not found at $CONFIG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export $(grep -v '^#' "$CONFIG" | grep -v '^$' | xargs 2>/dev/null) 2>/dev/null || true
|
||||
|
||||
cmd="${1:-help}"
|
||||
shift || true
|
||||
|
||||
case "$cmd" in
|
||||
start)
|
||||
echo "Starting ActiveBlue AI service..."
|
||||
systemctl start activeblue-ai.service
|
||||
;;
|
||||
stop)
|
||||
echo "Stopping ActiveBlue AI service..."
|
||||
systemctl stop activeblue-ai.service
|
||||
;;
|
||||
restart)
|
||||
echo "Restarting ActiveBlue AI service..."
|
||||
systemctl restart activeblue-ai.service
|
||||
;;
|
||||
status)
|
||||
systemctl status activeblue-ai.service
|
||||
;;
|
||||
logs)
|
||||
journalctl -u activeblue-ai.service -f "${@}"
|
||||
;;
|
||||
migrate)
|
||||
echo "Running Alembic migrations..."
|
||||
cd "$SERVICE_DIR"
|
||||
"$VENV/bin/alembic" -c agent_service/migrations/alembic.ini upgrade head
|
||||
;;
|
||||
health)
|
||||
PORT="${AGENT_SERVICE_PORT:-8001}"
|
||||
curl -sf "http://localhost:${PORT}/health/detailed" | python3 -m json.tool
|
||||
;;
|
||||
sweep)
|
||||
PORT="${AGENT_SERVICE_PORT:-8001}"
|
||||
AGENTS="${1:-}"
|
||||
if [ -n "$AGENTS" ]; then
|
||||
curl -sf -X POST "http://localhost:${PORT}/sweep" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"agents\": [\"$AGENTS\"]}" | python3 -m json.tool
|
||||
else
|
||||
curl -sf -X POST "http://localhost:${PORT}/sweep" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"agents": []}' | python3 -m json.tool
|
||||
fi
|
||||
;;
|
||||
privacy)
|
||||
MODE="${1:-}"
|
||||
if [ -z "$MODE" ]; then
|
||||
echo "Current privacy mode: ${LLM_PRIVACY_MODE:-local}"
|
||||
else
|
||||
sed -i "s/^LLM_PRIVACY_MODE=.*/LLM_PRIVACY_MODE=$MODE/" "$CONFIG"
|
||||
echo "Privacy mode set to: $MODE"
|
||||
echo "Restart the service for changes to take effect: activeblue-ai restart"
|
||||
fi
|
||||
;;
|
||||
version)
|
||||
cat "$SERVICE_DIR/VERSION" 2>/dev/null || echo "0.1.0"
|
||||
;;
|
||||
help|--help|-h)
|
||||
cat <<'HELP'
|
||||
ActiveBlue AI — CLI tool
|
||||
|
||||
Usage: activeblue-ai <command> [options]
|
||||
|
||||
Commands:
|
||||
start Start the agent service
|
||||
stop Stop the agent service
|
||||
restart Restart the agent service
|
||||
status Show systemd service status
|
||||
logs [flags] Follow service logs (passes flags to journalctl)
|
||||
migrate Run Alembic database migrations
|
||||
health Show detailed health status
|
||||
sweep [agent] Trigger a proactive sweep (all agents or named agent)
|
||||
privacy [mode] Get or set LLM privacy mode (local|hybrid|cloud)
|
||||
version Show installed version
|
||||
help Show this help
|
||||
HELP
|
||||
;;
|
||||
*)
|
||||
echo "Unknown command: $cmd" >&2
|
||||
echo "Run 'activeblue-ai help' for usage." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
59
docker-compose.yml
Normal file
59
docker-compose.yml
Normal file
@@ -0,0 +1,59 @@
|
||||
version: '3.9'
|
||||
|
||||
services:
|
||||
agent-service:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: activeblue-agent
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
ports:
|
||||
- '192.168.2.47:8001:8001'
|
||||
depends_on:
|
||||
agent-db:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- activeblue-net
|
||||
healthcheck:
|
||||
test: ['CMD', 'curl', '-f', 'http://localhost:8001/health']
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: '50m'
|
||||
max-file: '5'
|
||||
|
||||
agent-db:
|
||||
image: postgres:15-alpine
|
||||
container_name: activeblue-agent-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-activeblue_ai}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-activeblue}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
volumes:
|
||||
- agent-db-data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- activeblue-net
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER:-activeblue} -d ${POSTGRES_DB:-activeblue_ai}']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: '20m'
|
||||
max-file: '3'
|
||||
|
||||
volumes:
|
||||
agent-db-data:
|
||||
|
||||
networks:
|
||||
activeblue-net:
|
||||
name: activeblue-net
|
||||
external: false
|
||||
96
publish_repo.sh
Normal file
96
publish_repo.sh
Normal file
@@ -0,0 +1,96 @@
|
||||
#!/bin/bash
|
||||
# Publish ActiveBlue AI to a local APT repository
|
||||
# Run this on the repository host after build_deb.sh
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
VERSION=$(cat "$SCRIPT_DIR/VERSION" 2>/dev/null || echo "0.1.0")
|
||||
PKG_NAME="activeblue-ai"
|
||||
DEB_FILE="$SCRIPT_DIR/dist/${PKG_NAME}_${VERSION}_all.deb"
|
||||
|
||||
# APT repo configuration
|
||||
REPO_ROOT="${REPO_ROOT:-/srv/apt-repo}"
|
||||
REPO_DIST="${REPO_DIST:-stable}"
|
||||
REPO_COMP="${REPO_COMP:-main}"
|
||||
REPO_ARCH="${REPO_ARCH:-all}"
|
||||
GPG_KEY="${GPG_KEY:-}"
|
||||
|
||||
if [ ! -f "$DEB_FILE" ]; then
|
||||
echo "ERROR: .deb not found at $DEB_FILE" >&2
|
||||
echo "Run ./build_deb.sh first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v dpkg-scanpackages &>/dev/null; then
|
||||
echo "Installing dpkg-dev..."
|
||||
sudo apt-get install -y dpkg-dev apt-utils
|
||||
fi
|
||||
|
||||
# Create repo structure
|
||||
POOL_DIR="$REPO_ROOT/pool/$REPO_DIST/$REPO_COMP"
|
||||
DISTS_DIR="$REPO_ROOT/dists/$REPO_DIST/$REPO_COMP/binary-$REPO_ARCH"
|
||||
mkdir -p "$POOL_DIR" "$DISTS_DIR"
|
||||
|
||||
# Copy .deb to pool
|
||||
cp -v "$DEB_FILE" "$POOL_DIR/"
|
||||
|
||||
# Generate Packages index
|
||||
cd "$REPO_ROOT"
|
||||
dpkg-scanpackages "pool/$REPO_DIST/$REPO_COMP" /dev/null > "dists/$REPO_DIST/$REPO_COMP/binary-$REPO_ARCH/Packages"
|
||||
gzip -9 -c "dists/$REPO_DIST/$REPO_COMP/binary-$REPO_ARCH/Packages" > \
|
||||
"dists/$REPO_DIST/$REPO_COMP/binary-$REPO_ARCH/Packages.gz"
|
||||
|
||||
# Generate Release file
|
||||
SUITE="$REPO_DIST"
|
||||
DATE=$(date -uR)
|
||||
cat > "dists/$REPO_DIST/Release" <<RELEASE
|
||||
Origin: ActiveBlue
|
||||
Label: ActiveBlue AI
|
||||
Suite: $SUITE
|
||||
Version: $VERSION
|
||||
Codename: $REPO_DIST
|
||||
Architectures: $REPO_ARCH
|
||||
Components: $REPO_COMP
|
||||
Description: ActiveBlue AI APT repository
|
||||
Date: $DATE
|
||||
RELEASE
|
||||
|
||||
# Add checksums
|
||||
{
|
||||
echo "MD5Sum:"
|
||||
find "dists/$REPO_DIST/$REPO_COMP" -type f | while read f; do
|
||||
rel="${f#dists/$REPO_DIST/}"
|
||||
md5=$(md5sum "$f" | cut -d' ' -f1)
|
||||
size=$(stat -c%s "$f")
|
||||
echo " $md5 $size $rel"
|
||||
done
|
||||
echo "SHA256:"
|
||||
find "dists/$REPO_DIST/$REPO_COMP" -type f | while read f; do
|
||||
rel="${f#dists/$REPO_DIST/}"
|
||||
sha=$(sha256sum "$f" | cut -d' ' -f1)
|
||||
size=$(stat -c%s "$f")
|
||||
echo " $sha $size $rel"
|
||||
done
|
||||
} >> "dists/$REPO_DIST/Release"
|
||||
|
||||
# Sign with GPG if key provided
|
||||
if [ -n "$GPG_KEY" ]; then
|
||||
gpg --default-key "$GPG_KEY" --armor --detach-sign \
|
||||
-o "dists/$REPO_DIST/Release.gpg" "dists/$REPO_DIST/Release"
|
||||
gpg --default-key "$GPG_KEY" --clearsign \
|
||||
-o "dists/$REPO_DIST/InRelease" "dists/$REPO_DIST/Release"
|
||||
echo "Release signed with GPG key: $GPG_KEY"
|
||||
else
|
||||
echo "WARNING: No GPG_KEY set — repository is unsigned."
|
||||
echo "Set GPG_KEY=<key-id> to sign the release."
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Repository published to: $REPO_ROOT"
|
||||
echo ""
|
||||
echo "To use this repository, add to /etc/apt/sources.list.d/activeblue.list:"
|
||||
echo " deb [trusted=yes] http://<your-server>/apt-repo $REPO_DIST $REPO_COMP"
|
||||
echo ""
|
||||
echo "Then:"
|
||||
echo " sudo apt-get update"
|
||||
echo " sudo apt-get install activeblue-ai"
|
||||
6
pytest.ini
Normal file
6
pytest.ini
Normal file
@@ -0,0 +1,6 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
testpaths = tests
|
||||
python_files = test_*.py
|
||||
python_classes = Test
|
||||
python_functions = test_
|
||||
11
requirements.txt
Normal file
11
requirements.txt
Normal file
@@ -0,0 +1,11 @@
|
||||
fastapi==0.115.0
|
||||
uvicorn[standard]==0.30.6
|
||||
pydantic==2.9.2
|
||||
pydantic-settings==2.5.2
|
||||
asyncpg==0.29.0
|
||||
anthropic==0.34.2
|
||||
httpx==0.27.2
|
||||
alembic==1.13.3
|
||||
sqlalchemy[asyncio]==2.0.35
|
||||
json-log-formatter==0.5.2
|
||||
python-dotenv==1.0.1
|
||||
37
research/finance_research.md
Normal file
37
research/finance_research.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Finance Agent Research
|
||||
|
||||
## Odoo 18 Finance Domain — Key Models
|
||||
|
||||
### account.move (Invoices & Bills)
|
||||
- `move_type`: `out_invoice` (customer invoice), `in_invoice` (vendor bill), `out_refund`, `in_refund`
|
||||
- `state`: `draft`, `posted`, `cancel`
|
||||
- `payment_state`: `not_paid`, `in_payment`, `paid`, `partial`, `reversed`
|
||||
- `invoice_date_due`: payment due date
|
||||
- `amount_residual`: outstanding balance
|
||||
- `partner_id`: customer/vendor
|
||||
|
||||
### account.payment
|
||||
- Payments linked to invoices via `account.move` reconciliation
|
||||
- `payment_type`: `inbound` (receipts), `outbound` (payments)
|
||||
- `state`: `draft`, `posted`, `cancel`
|
||||
|
||||
### account.bank.statement.line
|
||||
- Bank statement lines for reconciliation
|
||||
- `is_reconciled`: bool — whether matched to a payment/invoice
|
||||
|
||||
### Key Search Domains
|
||||
- Overdue: `[('move_type','=','out_invoice'),('state','=','posted'),('payment_state','!=','paid'),('invoice_date_due','<', today)]`
|
||||
- Unreconciled bank lines: `[('is_reconciled','=',False),('journal_id','=',journal_id)]`
|
||||
|
||||
## HIPAA Constraints
|
||||
- Finance agent is HIPAA-locked — Ollama only, never cloud LLM
|
||||
- Do not expose partner financial data to non-finance agents via PeerBus
|
||||
- Aggregate/anonymise data in cross-agent responses
|
||||
|
||||
## Automation Thresholds (from spec)
|
||||
- Auto-send reminder: overdue > 30 days AND amount_residual > 1,000
|
||||
- Flag for review: overdue > 90 days (regardless of amount)
|
||||
- Escalate to human: collection_rate < 70% OR overdue_total > 50,000
|
||||
|
||||
## Tool Limits
|
||||
MAX_TOOLS_PER_AGENT = 8 (finance agent is at limit)
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
47
tests/conftest.py
Normal file
47
tests/conftest.py
Normal file
@@ -0,0 +1,47 @@
|
||||
import asyncio
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def event_loop():
|
||||
loop = asyncio.new_event_loop()
|
||||
yield loop
|
||||
loop.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_odoo():
|
||||
odoo = MagicMock()
|
||||
odoo.search_read = AsyncMock(return_value=[])
|
||||
odoo.write = AsyncMock()
|
||||
odoo.call = AsyncMock(return_value=True)
|
||||
odoo.ping = AsyncMock(return_value=True)
|
||||
return odoo
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_pool():
|
||||
pool = MagicMock()
|
||||
conn = AsyncMock()
|
||||
conn.fetchval = AsyncMock(return_value=1)
|
||||
conn.fetch = AsyncMock(return_value=[])
|
||||
conn.fetchrow = AsyncMock(return_value=None)
|
||||
conn.execute = AsyncMock()
|
||||
pool.acquire = MagicMock(return_value=conn)
|
||||
conn.__aenter__ = AsyncMock(return_value=conn)
|
||||
conn.__aexit__ = AsyncMock(return_value=False)
|
||||
return pool
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_llm():
|
||||
from agent_service.llm.llm_types import LLMResponse
|
||||
llm = MagicMock()
|
||||
llm.get_backend = AsyncMock(return_value='ollama')
|
||||
llm.complete = AsyncMock(return_value=LLMResponse(
|
||||
content='{"intent": "finance_query", "agents": ["finance_agent"], "confidence": 0.9}',
|
||||
tool_calls=[], backend_used='ollama', model_used='llama3',
|
||||
tokens_in=10, tokens_out=20, latency_ms=100,
|
||||
))
|
||||
return llm
|
||||
60
tests/test_dispatch_router.py
Normal file
60
tests/test_dispatch_router.py
Normal file
@@ -0,0 +1,60 @@
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app_with_mock_master():
|
||||
mock_master = MagicMock()
|
||||
mock_response = MagicMock()
|
||||
mock_response.directive_id = 'test-directive-1'
|
||||
mock_response.reply = 'You have 3 overdue invoices totalling $15,000.'
|
||||
mock_response.agent_reports = []
|
||||
mock_response.escalations = []
|
||||
mock_response.actions_taken = []
|
||||
mock_master.handle_message = AsyncMock(return_value=mock_response)
|
||||
|
||||
with patch('agent_service.app_state.get_master_agent', return_value=mock_master):
|
||||
from agent_service.main import create_app
|
||||
app = create_app()
|
||||
yield app
|
||||
|
||||
|
||||
def test_dispatch_returns_reply(app_with_mock_master):
|
||||
client = TestClient(app_with_mock_master, raise_server_exceptions=False)
|
||||
resp = client.post('/dispatch', json={
|
||||
'user_id': '42',
|
||||
'message': 'What are my overdue invoices?',
|
||||
'context': {},
|
||||
})
|
||||
assert resp.status_code in (200, 503)
|
||||
|
||||
|
||||
def test_dispatch_rate_limit():
|
||||
mock_master = MagicMock()
|
||||
mock_response = MagicMock()
|
||||
mock_response.directive_id = 'x'
|
||||
mock_response.reply = 'ok'
|
||||
mock_response.agent_reports = []
|
||||
mock_response.escalations = []
|
||||
mock_response.actions_taken = []
|
||||
mock_master.handle_message = AsyncMock(return_value=mock_response)
|
||||
|
||||
with patch('agent_service.app_state.get_master_agent', return_value=mock_master), \
|
||||
patch('agent_service.routers.dispatch._rate_limit_store', {}):
|
||||
from agent_service.main import create_app
|
||||
app = create_app()
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
for _ in range(31):
|
||||
client.post('/dispatch', json={'user_id': 'ratelimit_user', 'message': 'test', 'context': {}})
|
||||
|
||||
|
||||
def test_health_endpoint():
|
||||
with patch('agent_service.app_state.get_master_agent', return_value=MagicMock()):
|
||||
from agent_service.main import create_app
|
||||
app = create_app()
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
resp = client.get('/health')
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert 'status' in data
|
||||
103
tests/test_e2e_dispatch.py
Normal file
103
tests/test_e2e_dispatch.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""
|
||||
End-to-end integration test: simulates a full dispatch cycle
|
||||
from HTTP request through MasterAgent to FinanceAgent response.
|
||||
|
||||
Uses in-process mocks for Odoo and LLM — no external services needed.
|
||||
"""
|
||||
import asyncio
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from agent_service.llm.llm_types import LLMResponse
|
||||
from agent_service.agents.peer_bus import PeerBus
|
||||
from agent_service.agents.finance_agent import FinanceAgent
|
||||
from agent_service.agents.master_agent import MasterAgent
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def llm_router():
|
||||
router = MagicMock()
|
||||
# First call: classify intent
|
||||
# Second+ calls: agent execution
|
||||
router.get_backend = AsyncMock(return_value='ollama')
|
||||
classify_response = LLMResponse(
|
||||
content='{"intent": "overdue_invoices", "agents": ["finance_agent"], "confidence": 0.95, "context": {}}',
|
||||
tool_calls=[], backend_used='ollama', model_used='llama3',
|
||||
tokens_in=50, tokens_out=80, latency_ms=200,
|
||||
)
|
||||
agent_response = LLMResponse(
|
||||
content='You have 2 overdue invoices totalling $125,000. Invoice #2 (BigCo, $120k) is 95 days overdue and has been flagged for review.',
|
||||
tool_calls=[], backend_used='ollama', model_used='llama3',
|
||||
tokens_in=200, tokens_out=150, latency_ms=800,
|
||||
)
|
||||
router.complete = AsyncMock(side_effect=[classify_response, agent_response])
|
||||
return router
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_finance_tools():
|
||||
ft = MagicMock()
|
||||
ft.get_overdue_invoices = AsyncMock(return_value=[
|
||||
{'id': 1, 'partner_name': 'ACME', 'amount_residual': 5000.0, 'days_overdue': 45},
|
||||
{'id': 2, 'partner_name': 'BigCo', 'amount_residual': 120000.0, 'days_overdue': 95},
|
||||
])
|
||||
ft.get_financial_summary = AsyncMock(return_value={'total_invoiced': 200000.0, 'collection_rate': 75.0})
|
||||
ft.get_invoices = AsyncMock(return_value=[])
|
||||
ft.flag_for_review = AsyncMock(return_value=True)
|
||||
ft.send_payment_reminder = AsyncMock(return_value=True)
|
||||
ft.post_chatter_note = AsyncMock(return_value=True)
|
||||
ft.get_payment_history = AsyncMock(return_value=[])
|
||||
return ft
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_odoo():
|
||||
odoo = MagicMock()
|
||||
odoo.search_read = AsyncMock(return_value=[])
|
||||
odoo.write = AsyncMock()
|
||||
odoo.call = AsyncMock(return_value=True)
|
||||
odoo.ping = AsyncMock(return_value=True)
|
||||
return odoo
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_overdue_invoice_query(mock_odoo, llm_router, mock_finance_tools):
|
||||
peer_bus = PeerBus()
|
||||
finance_agent = FinanceAgent(odoo=mock_odoo, llm=llm_router, peer_bus=peer_bus)
|
||||
finance_agent._ft = mock_finance_tools
|
||||
peer_bus.register('finance_agent', finance_agent)
|
||||
|
||||
master = MasterAgent(
|
||||
odoo=mock_odoo,
|
||||
llm=llm_router,
|
||||
memory=None,
|
||||
peer_bus=peer_bus,
|
||||
registry=None,
|
||||
)
|
||||
|
||||
result = await asyncio.wait_for(
|
||||
master.handle_message(
|
||||
user_id='test_user_1',
|
||||
message='What are my overdue invoices?',
|
||||
context={},
|
||||
),
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result.reply
|
||||
assert len(result.reply) > 10
|
||||
mock_finance_tools.get_overdue_invoices.assert_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_peer_bus_communication(mock_odoo, llm_router, mock_finance_tools):
|
||||
peer_bus = PeerBus()
|
||||
finance_agent = FinanceAgent(odoo=mock_odoo, llm=llm_router, peer_bus=peer_bus)
|
||||
finance_agent._ft = mock_finance_tools
|
||||
peer_bus.register('finance_agent', finance_agent)
|
||||
|
||||
from agent_service.agents.peer_bus import PeerResponse
|
||||
resp = await peer_bus.call('finance_agent', {'type': 'overdue_summary'})
|
||||
assert isinstance(resp, PeerResponse)
|
||||
assert resp.available is True
|
||||
assert 'overdue_count' in resp.data
|
||||
134
tests/test_finance_agent.py
Normal file
134
tests/test_finance_agent.py
Normal file
@@ -0,0 +1,134 @@
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from agent_service.agents.finance_agent import FinanceAgent, FINANCE_TOOLS
|
||||
from agent_service.agents.base_agent import AgentDirective, AgentReport, SweepReport
|
||||
from agent_service.llm.llm_types import LLMResponse
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_ft():
|
||||
ft = MagicMock()
|
||||
ft.get_overdue_invoices = AsyncMock(return_value=[
|
||||
{'id': 1, 'partner_name': 'ACME Corp', 'amount_residual': 5000.0, 'days_overdue': 45},
|
||||
{'id': 2, 'partner_name': 'BigCo', 'amount_residual': 120000.0, 'days_overdue': 95},
|
||||
])
|
||||
ft.get_financial_summary = AsyncMock(return_value={
|
||||
'total_invoiced': 200000.0, 'collection_rate': 75.0,
|
||||
})
|
||||
ft.get_invoices = AsyncMock(return_value=[])
|
||||
ft.send_payment_reminder = AsyncMock(return_value=True)
|
||||
ft.flag_for_review = AsyncMock(return_value=True)
|
||||
ft.post_chatter_note = AsyncMock(return_value=True)
|
||||
ft.get_payment_history = AsyncMock(return_value=[])
|
||||
return ft
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def agent(mock_odoo, mock_llm):
|
||||
a = FinanceAgent(odoo=mock_odoo, llm=mock_llm)
|
||||
return a
|
||||
|
||||
|
||||
def test_finance_agent_has_8_tools():
|
||||
assert len(FINANCE_TOOLS) == 8
|
||||
|
||||
|
||||
def test_finance_agent_name():
|
||||
assert FinanceAgent.name == 'finance_agent'
|
||||
|
||||
|
||||
def test_finance_agent_hipaa_domain():
|
||||
from agent_service.llm.llm_router import HIPAA_LOCKED_AGENTS
|
||||
assert 'finance_agent' in HIPAA_LOCKED_AGENTS
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plan_overdue_intent(agent, mock_ft):
|
||||
agent._ft = mock_ft
|
||||
directive = AgentDirective(
|
||||
directive_id='d1', user_id='1', intent='show overdue invoices',
|
||||
context={}, agent_name='finance_agent',
|
||||
)
|
||||
plan = await agent._plan(directive)
|
||||
assert plan['fetch_overdue'] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plan_send_reminders(agent, mock_ft):
|
||||
agent._ft = mock_ft
|
||||
directive = AgentDirective(
|
||||
directive_id='d2', user_id='1', intent='send payment reminders to overdue customers',
|
||||
context={}, agent_name='finance_agent',
|
||||
)
|
||||
plan = await agent._plan(directive)
|
||||
assert plan['send_reminders'] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gather_fetches_overdue(agent, mock_ft):
|
||||
agent._ft = mock_ft
|
||||
agent._plan_result = {'fetch_overdue': True, 'fetch_summary': False,
|
||||
'fetch_invoices': False, 'partner_id': None, 'period': 'this_month'}
|
||||
data = await agent._gather({})
|
||||
assert 'overdue' in data
|
||||
mock_ft.get_overdue_invoices.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reason_flags_90_day_invoices(agent, mock_ft):
|
||||
agent._ft = mock_ft
|
||||
agent._gathered_data = {
|
||||
'overdue': [
|
||||
{'id': 2, 'partner_name': 'BigCo', 'amount_residual': 120000.0, 'days_overdue': 95},
|
||||
],
|
||||
}
|
||||
analysis = await agent._reason({})
|
||||
assert len(analysis['flags']) == 1
|
||||
assert analysis['flags'][0]['invoice_id'] == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reason_escalates_high_overdue(agent, mock_ft):
|
||||
agent._ft = mock_ft
|
||||
agent._gathered_data = {
|
||||
'overdue': [{'id': 1, 'amount_residual': 60000.0, 'days_overdue': 5, 'partner_name': 'X'}],
|
||||
}
|
||||
analysis = await agent._reason({})
|
||||
assert any('60000' in e or '60' in e for e in analysis['escalations'])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_report_includes_summary(agent, mock_ft):
|
||||
agent._ft = mock_ft
|
||||
agent._gathered_data = {'summary': {'total_invoiced': 100000.0, 'collection_rate': 80.0}}
|
||||
agent._actions_taken = []
|
||||
agent._escalations_list = []
|
||||
agent._recommendations = []
|
||||
report = await agent._report({'analysis': {'overdue_count': 0}})
|
||||
assert isinstance(report, AgentReport)
|
||||
assert '100000' in report.summary or '80' in report.summary
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sweep_returns_findings(agent, mock_ft):
|
||||
agent._ft = mock_ft
|
||||
mock_ft.get_overdue_invoices = AsyncMock(return_value=[
|
||||
{'id': 3, 'partner_name': 'OldDebt', 'amount_residual': 2000.0, 'days_overdue': 65},
|
||||
])
|
||||
result = await agent.sweep()
|
||||
assert isinstance(result, SweepReport)
|
||||
assert len(result.findings) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_peer_request_overdue_summary(agent, mock_ft):
|
||||
agent._ft = mock_ft
|
||||
result = await agent.handle_peer_request({'type': 'overdue_summary'})
|
||||
assert 'overdue_count' in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_tool_unknown_raises(agent, mock_ft):
|
||||
agent._ft = mock_ft
|
||||
with pytest.raises(ValueError, match='Unknown tool'):
|
||||
await agent._dispatch_tool('nonexistent', {})
|
||||
76
tests/test_llm_router.py
Normal file
76
tests/test_llm_router.py
Normal file
@@ -0,0 +1,76 @@
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from agent_service.llm.llm_router import LLMRouter, HIPAA_LOCKED_AGENTS
|
||||
from agent_service.llm.llm_types import LLMResponse
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_ollama():
|
||||
ollama = MagicMock()
|
||||
ollama.complete = AsyncMock(return_value=LLMResponse(
|
||||
content='test', tool_calls=[], backend_used='ollama',
|
||||
model_used='llama3', tokens_in=5, tokens_out=10, latency_ms=50,
|
||||
))
|
||||
ollama.ping = AsyncMock(return_value=True)
|
||||
return ollama
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_claude():
|
||||
claude = MagicMock()
|
||||
claude.complete = AsyncMock(return_value=LLMResponse(
|
||||
content='test claude', tool_calls=[], backend_used='claude',
|
||||
model_used='claude-sonnet-4-6', tokens_in=5, tokens_out=10, latency_ms=100,
|
||||
))
|
||||
return claude
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_local_mode_always_ollama(mock_ollama, mock_claude):
|
||||
router = LLMRouter(ollama=mock_ollama, claude=mock_claude, privacy_mode='local')
|
||||
backend = await router.get_backend('crm_agent')
|
||||
assert backend == 'ollama'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cloud_mode_uses_claude(mock_ollama, mock_claude):
|
||||
router = LLMRouter(ollama=mock_ollama, claude=mock_claude, privacy_mode='cloud')
|
||||
backend = await router.get_backend('crm_agent')
|
||||
assert backend == 'claude'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hipaa_locked_always_ollama(mock_ollama, mock_claude):
|
||||
router = LLMRouter(ollama=mock_ollama, claude=mock_claude, privacy_mode='cloud')
|
||||
for agent in HIPAA_LOCKED_AGENTS:
|
||||
backend = await router.get_backend(agent)
|
||||
assert backend == 'ollama', f'{agent} should be ollama-only'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cloud_mode_no_claude_fallback(mock_ollama):
|
||||
router = LLMRouter(ollama=mock_ollama, claude=None, privacy_mode='cloud')
|
||||
backend = await router.get_backend('crm_agent')
|
||||
assert backend == 'ollama'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_env_override_respected(mock_ollama, mock_claude):
|
||||
router = LLMRouter(
|
||||
ollama=mock_ollama, claude=mock_claude,
|
||||
privacy_mode='hybrid',
|
||||
env_overrides={'crm_agent': 'claude'},
|
||||
)
|
||||
backend = await router.get_backend('crm_agent')
|
||||
assert backend == 'claude'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_env_override_cannot_override_hipaa(mock_ollama, mock_claude):
|
||||
router = LLMRouter(
|
||||
ollama=mock_ollama, claude=mock_claude,
|
||||
privacy_mode='hybrid',
|
||||
env_overrides={'finance_agent': 'claude'},
|
||||
)
|
||||
backend = await router.get_backend('finance_agent')
|
||||
assert backend == 'ollama'
|
||||
69
tests/test_memory_manager.py
Normal file
69
tests/test_memory_manager.py
Normal file
@@ -0,0 +1,69 @@
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_conv_store():
|
||||
store = MagicMock()
|
||||
store.get = AsyncMock(return_value=[
|
||||
{'role': 'user', 'content': 'What are my overdue invoices?'},
|
||||
{'role': 'assistant', 'content': 'You have 3 overdue invoices.'},
|
||||
])
|
||||
store.append = AsyncMock()
|
||||
store.count = AsyncMock(return_value=2)
|
||||
store.prune_old = AsyncMock()
|
||||
return store
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_op_store():
|
||||
store = MagicMock()
|
||||
store.get_recent = AsyncMock(return_value=[])
|
||||
store.store = AsyncMock()
|
||||
store.prune_expired = AsyncMock()
|
||||
return store
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_know_store():
|
||||
store = MagicMock()
|
||||
store.get_client_profile = AsyncMock(return_value={})
|
||||
store.upsert = AsyncMock()
|
||||
store.get = AsyncMock(return_value=None)
|
||||
return store
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_context_returns_master_context(mock_pool, mock_conv_store,
|
||||
mock_op_store, mock_know_store):
|
||||
with patch('agent_service.memory.memory_manager.ConversationStore', return_value=mock_conv_store), \
|
||||
patch('agent_service.memory.memory_manager.OperationalStore', return_value=mock_op_store), \
|
||||
patch('agent_service.memory.memory_manager.KnowledgeStore', return_value=mock_know_store):
|
||||
from agent_service.memory.memory_manager import MemoryManager
|
||||
mgr = MemoryManager(pool=mock_pool, llm=None)
|
||||
ctx = await mgr.build_context(user_id='1', intent_hint='finance')
|
||||
assert ctx is not None
|
||||
assert hasattr(ctx, 'conversation')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_message(mock_pool, mock_conv_store, mock_op_store, mock_know_store):
|
||||
with patch('agent_service.memory.memory_manager.ConversationStore', return_value=mock_conv_store), \
|
||||
patch('agent_service.memory.memory_manager.OperationalStore', return_value=mock_op_store), \
|
||||
patch('agent_service.memory.memory_manager.KnowledgeStore', return_value=mock_know_store):
|
||||
from agent_service.memory.memory_manager import MemoryManager
|
||||
mgr = MemoryManager(pool=mock_pool, llm=None)
|
||||
await mgr.append_message(user_id='1', role='user', content='Hello')
|
||||
mock_conv_store.append.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hard_cap_enforced(mock_pool, mock_conv_store, mock_op_store, mock_know_store):
|
||||
mock_conv_store.count = AsyncMock(return_value=201)
|
||||
with patch('agent_service.memory.memory_manager.ConversationStore', return_value=mock_conv_store), \
|
||||
patch('agent_service.memory.memory_manager.OperationalStore', return_value=mock_op_store), \
|
||||
patch('agent_service.memory.memory_manager.KnowledgeStore', return_value=mock_know_store):
|
||||
from agent_service.memory.memory_manager import MemoryManager
|
||||
mgr = MemoryManager(pool=mock_pool, llm=None)
|
||||
await mgr.append_message(user_id='1', role='user', content='test')
|
||||
mock_conv_store.prune_old.assert_awaited()
|
||||
55
tests/test_odoo_client.py
Normal file
55
tests/test_odoo_client.py
Normal file
@@ -0,0 +1,55 @@
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from agent_service.tools.odoo_client import OdooClient, WriteResult
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def odoo():
|
||||
client = OdooClient(url='http://localhost:8069', db='test', api_key='testkey')
|
||||
return client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_read_returns_list(odoo):
|
||||
with patch.object(odoo, '_call', new=AsyncMock(return_value=[
|
||||
{'id': 1, 'name': 'Invoice 1'},
|
||||
])):
|
||||
result = await odoo.search_read('account.move', [('state', '=', 'posted')], ['name'])
|
||||
assert isinstance(result, list)
|
||||
assert result[0]['id'] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_returns_write_result(odoo):
|
||||
with patch.object(odoo, '_call', new=AsyncMock(return_value=True)), \
|
||||
patch.object(odoo, 'search_read', new=AsyncMock(return_value=[{'id': 1, 'name': 'test'}])):
|
||||
result = await odoo.write('account.move', [1], {'state': 'posted'})
|
||||
assert isinstance(result, WriteResult)
|
||||
assert result.success is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_result_has_before_after(odoo):
|
||||
before = [{'id': 1, 'amount_residual': 1000.0}]
|
||||
after = [{'id': 1, 'amount_residual': 0.0}]
|
||||
call_count = 0
|
||||
|
||||
async def mock_search_read(*args, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return before if call_count == 1 else after
|
||||
|
||||
with patch.object(odoo, '_call', new=AsyncMock(return_value=True)), \
|
||||
patch.object(odoo, 'search_read', new=mock_search_read):
|
||||
result = await odoo.write('account.move', [1], {'amount_residual': 0})
|
||||
assert result.before == before[0]
|
||||
assert result.after == after[0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unlink_logs_warning(odoo, caplog):
|
||||
import logging
|
||||
with patch.object(odoo, '_call', new=AsyncMock(return_value=True)):
|
||||
with caplog.at_level(logging.WARNING):
|
||||
await odoo.unlink('account.move', [99])
|
||||
assert any('unlink' in r.message.lower() or '99' in r.message for r in caplog.records)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user