Compare commits

...

10 Commits

Author SHA1 Message Date
ActiveBlue Build
fb4bf56816 feat(packaging): add Debian packaging and APT repository scripts
debian/DEBIAN/control: package metadata, depends on python3.11+, postgresql-client
debian/DEBIAN/postinst: creates activeblue-ai system user, installs venv, enables service
debian/DEBIAN/prerm: stops and disables service before removal
debian/DEBIAN/postrm: purge removes config, logs, venv, and system user
debian/lib/systemd/system/activeblue-ai.service:
  - Runs as dedicated user with PrivateTmp + ProtectSystem hardening
  - EnvironmentFile=/etc/activeblue-ai/.env
  - Restart=on-failure with 5s backoff
debian/usr/bin/activeblue-ai: CLI with start/stop/restart/status/logs/migrate/health/sweep/privacy/version
build_deb.sh: builds activeblue-ai_X.Y.Z_all.deb in dist/
publish_repo.sh: scans packages, generates Release + checksums, optional GPG signing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 18:09:48 -04:00
ActiveBlue Build
7487fc73f9 feat(infra): add sweep coordinator, structured logging, test suite, and README
Sweep coordinator (Step 16):
- SweepCoordinator runs all 8 agents in parallel with 60s per-agent / 300s total timeout
- Aggregates findings, actions, errors into SweepCoordinatorResult
- Registered in FastAPI lifespan; triggered via POST /sweep

Structured logging (Step 18):
- logging_utils/structured.py: JSONFormatter emitting ts/level/logger/msg + custom fields
- log_directive_event() for structured directive lifecycle logging
- push_to_loki() async Loki push (graceful no-op if LOKI_URL unset)
- configure_logging() replaces root handler at startup

Tests (Steps 17+19):
- conftest.py: mock_odoo, mock_pool, mock_llm fixtures
- test_tool_validator.py: 9 tests covering validation, coercion, hallucination stripping
- test_llm_router.py: 6 tests covering local/cloud/hybrid modes and HIPAA enforcement
- test_peer_bus.py: 6 tests covering registration, timeout, depth, circular detection
- test_finance_agent.py: 10 tests covering all 6 steps + sweep + peer request
- test_memory_manager.py: 3 tests covering context build + hard cap enforcement
- test_dispatch_router.py: 3 tests covering dispatch, rate limit, health endpoint
- test_odoo_client.py: 4 tests covering search_read, write result, unlink warning
- test_e2e_dispatch.py: 2 E2E tests - full dispatch cycle + peer bus communication

README (Step 20): architecture diagram, privacy modes, quick start, env vars, structure

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 18:08:11 -04:00
ActiveBlue Build
fe47f950e4 feat(agents): add 7 specialist agents with tools and system prompts
Agents (all following 6-step contract: _plan/_gather/_reason/_act/_report):
- AccountingAgent: trial balance, chart of accounts, tax summary (HIPAA-locked)
- CrmAgent: pipeline summary, lead/opportunity management, won/lost analysis
- SalesAgent: sales orders, quotations, revenue by rep, expired quote detection
- ProjectAgent: task tracking, blocked/overdue detection, timesheet logging
- ElearningAgent: course completion, low-engagement flagging, next-course suggestion
- ExpensesAgent: expense sheets, pending approvals, policy violations (HIPAA-locked)
- EmployeesAgent: headcount, contracts, leaves, attendance, expired contract sweep (HIPAA-locked)

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 18:04:32 -04:00
ActiveBlue Build
29409ed71d feat(odoo): add activeblue_ai Odoo 18 module with OWL2 frontend
Models:
- ab.ai.bot: service URL, webhook secret, privacy mode, ping/dispatch
- ab.ai.directive: full directive lifecycle log with status tracking
- ab.ai.log: activity log with level/agent/record linkage
- ab.ai.agent.registry: agent list synced from agent service

Controllers:
- webhook.py: /ai/webhook/callback handles directive_completed, escalation, sweep_findings
- health_proxy.py: /ai/health proxies agent service detailed health
- approval.py: /ai/chat dispatch, /ai/approval/pending, /ai/approval/respond

Security:
- group_ai_user (chat) + group_ai_manager (configure, approve, logs)
- ir.model.access.csv for all 4 models

Views: list/form for bot, directives, logs, registry; main menu with AI brain icon

OWL2 frontend:
- systray_button.js: brain icon in top bar, status dot, pending approval badge
- ai_panel.js: slide-in chat panel, approval workflow, 30s poll for pending items
- CSS: slide-in animation, message bubbles, loading dots, approval section

Data: 4 cron jobs (ping, registry sync, directive/log cleanup)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 17:59:02 -04:00
ActiveBlue Build
430ab966b2 feat(service): add FastAPI agent service with 5 routers and Docker setup
- config.py: pydantic-settings with all env vars, privacy mode, per-agent overrides
- app_state.py: global singletons (pool, master agent, registry, llm_router, sweep)
- main.py: FastAPI lifespan startup — DB pool, LLM router, Odoo client, agents, master
- routers/dispatch.py: POST /dispatch with rate limiting and webhook secret auth
- routers/approval.py: GET /approval/pending, POST /approval/respond
- routers/registry.py: GET/POST /registry/agents, POST /registry/backend overrides
- routers/sweep.py: POST /sweep trigger, GET /sweep/status
- routers/health.py: GET /health, GET /health/detailed (DB/Odoo/Ollama checks)
- requirements.txt: pinned deps (fastapi, uvicorn, asyncpg, anthropic, alembic)
- Dockerfile: python:3.11-slim, single uvicorn worker
- docker-compose.yml: agent-service + postgres:15, bound to 192.168.2.47:8001

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 17:54:28 -04:00
ActiveBlue Build
dab6354d09 feat(finance): add FinanceAgent with full 6-step contract and FinanceTools
- FinanceAgent implements _plan/_gather/_reason/_act/_report lifecycle
- Proactive sweep flags 30+ day overdue invoices, auto-sends reminders >60d/>$1k
- PeerBus handler exposes overdue_summary, payment_history, financial_summary
- HIPAA-locked: Ollama only, no cloud LLM routing
- FinanceTools wraps OdooClient with 9 read/write methods on account.move
- finance_system.txt prompt enforces no-write-to-invoices rule
- research/finance_research.md documents Odoo 18 account model details

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 17:51:49 -04:00
ActiveBlue Build
4ca62ee54b feat: add Master AI with directive builder and synthesis 2026-04-12 17:17:44 -04:00
ActiveBlue Build
21998f76aa feat: add base agent and peer bus 2026-04-12 16:55:30 -04:00
ActiveBlue Build
9ab1f8bbf8 feat: add 3-tier memory layer 2026-04-12 16:51:39 -04:00
ActiveBlue Build
7d92c2ea6f feat: add LLM abstraction layer (router, Ollama backend, Claude backend) 2026-04-12 16:46:18 -04:00
102 changed files with 7292 additions and 0 deletions

22
Dockerfile Normal file
View 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
View 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

View File

@@ -0,0 +1 @@
from . import models, controllers

View 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,
}

View File

@@ -0,0 +1 @@
from . import webhook, health_proxy, approval

View 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

View 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,
)

View 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)

View 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>

View File

@@ -0,0 +1 @@
from . import ab_ai_bot, ab_ai_directive, ab_ai_log, ab_ai_registry

View 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)

View 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)

View 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()

View 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)

View 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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_ab_ai_bot_manager ab.ai.bot manager model_ab_ai_bot group_ai_manager 1 1 1 1
3 access_ab_ai_bot_user ab.ai.bot user model_ab_ai_bot group_ai_user 1 0 0 0
4 access_ab_ai_directive_manager ab.ai.directive manager model_ab_ai_directive group_ai_manager 1 1 1 1
5 access_ab_ai_directive_user ab.ai.directive user model_ab_ai_directive group_ai_user 1 0 0 0
6 access_ab_ai_log_manager ab.ai.log manager model_ab_ai_log group_ai_manager 1 1 1 1
7 access_ab_ai_log_user ab.ai.log user model_ab_ai_log group_ai_user 1 0 0 0
8 access_ab_ai_registry_manager ab.ai.agent.registry manager model_ab_ai_agent_registry group_ai_manager 1 1 1 1
9 access_ab_ai_registry_user ab.ai.agent.registry user model_ab_ai_agent_registry group_ai_user 1 0 0 0

View 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>

View 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;
}

View 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);
}
}
}

View File

@@ -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,
});

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

View File

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

View File

@@ -0,0 +1,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=[])

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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.',
)

View 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)

View 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

View File

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

View File

@@ -0,0 +1,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)

View File

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

View File

@@ -0,0 +1,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)

View 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
View 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()

View File

View 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

View 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)

View 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

View 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

View 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

View 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

View 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']

View 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
View 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()

View File

View 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)

View 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}')

View 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)

View 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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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

View 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": []
}

View File

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

View File

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

View File

View 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}

View 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,
)

View 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,
)

View 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))

View 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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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}

View File

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

View File

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

72
build_deb.sh Normal file
View 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
View 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
View 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
View 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
View 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/"

View 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
View 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
View 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
View 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
View 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
View 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

View 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
View File

47
tests/conftest.py Normal file
View 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

View 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
View 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
View 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
View 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'

View 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
View 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