# ActiveBlue AI — System Architecture ## Overview ActiveBlue AI is a multi-agent system that adds a conversational AI assistant to the Active Blue Odoo 18 instance. Users chat with the bot through Odoo Discuss (direct message). The bot dispatches work to specialist agents that read and write Odoo data via XML-RPC, then synthesises their reports into a single reply. ``` User (Odoo Discuss) │ chat message / file upload ▼ ab_ai_mail.py ←── Odoo addon (monkey-patches discuss.channel.message_post) │ daemon thread (non-blocking) ▼ Agent Service (Docker: activeblue-agent, port 8001) │ ├── MasterAgent ←── intent router (LLM → JSON) │ │ │ ├── expenses_agent │ ├── sales_agent │ ├── accounting_agent │ ├── finance_agent │ ├── crm_agent │ ├── project_agent │ ├── employees_agent │ └── odoo_doc_agent ←── RAG (Qdrant @ 192.168.2.9:8000) │ ├── LLM Router ──► Ollama (192.168.2.9:11434) activeblue-chat (Llama 3.1 8B) │ └► Claude (Anthropic API) only when mode != local │ ├── agent-db (Postgres 15, Docker: activeblue-agent-db) │ ├── ab_directive_log (every request lifecycle) │ ├── ab_conversation (per-user message history) │ └── ab_llm_config (runtime backend overrides) │ └── Odoo XML-RPC (http://odoo-web-1:8069) ``` --- ## Docker Layout | Container | Image | Role | |---|---|---| | `odoo-web-1` | `odoo:18.0` | Odoo application + ActiveBlue AI addon | | `odoo-db-1` | `postgres:16` | Odoo database | | `activeblue-agent` | `odoo-ai-agent-service` | FastAPI agent service | | `activeblue-agent-db` | `postgres:15-alpine` | Agent conversation/directive DB | All containers share the `activeblue-net` Docker bridge network. DNS resolves by container name (e.g. `odoo-web-1`, `activeblue-agent`, `agent-db`). Compose files: - `/root/odoo/docker-compose.yml` — Odoo + Postgres (network: external) - `/root/odoo/odoo-ai/docker-compose.yml` — Agent service + agent-db (network: owner) --- ## Bot Online / Presence Flow ``` [Every ~5 min] ir.cron "ActiveBlue AI: Ping Service" │ ▼ ab_ai_bot.cron_ping_all() │ ├── GET http://activeblue-agent:8001/health/detailed │ returns: {status, db, odoo, ollama, master_agent, privacy_mode} │ ├── Evaluate: db==ok AND master_agent==ok AND ollama==ok (local mode) │ ├── ab.ai.bot.status = 'online' (or 'error'/'offline') │ └── _sync_bot_user_presence(online=True) │ ▼ bus.presence for user 'activeblue_ai_bot@local' last_poll = now + 10min ← keeps green dot alive last_presence = now between cron runs ``` **Why 10 minutes?** Odoo's cron scheduler fires the 20-second-configured job only every ~5 minutes in practice. Setting `last_poll` 10 minutes ahead ensures the green dot stays on between runs. If the agent service goes down, the next cron cycle sets `last_poll` an hour in the past, making the dot go grey within 30 seconds. **Health endpoint** (`/health/detailed`) checks: - `db` — asyncpg pool can acquire a connection to agent-db - `odoo` — XML-RPC auth against Odoo succeeded at startup - `ollama` — GET `/api/tags` on the Ollama host returns 200 - `master_agent` — MasterAgent instance is loaded and ready - `privacy_mode` — current LLM routing mode (`local` / `hybrid` / `cloud`) --- ## Request Flow: Text Message ``` 1. User sends message in Odoo Discuss (direct chat with ActiveBlue AI) 2. Odoo: discuss.channel.message_post() is called ab_ai_mail.DiscussChannel.message_post() intercepts it: - Ignores non-chat channels and the bot's own messages - Reads bot URL + secret from ab.ai.bot record - Launches _agent_thread() as a daemon thread (returns immediately) 3. _agent_thread() (background, outside Odoo HTTP request): POST http://activeblue-agent:8001/dispatch {user_id, message, context: {source: "discuss"}} Header: X-ActiveBlue-Signature: 4. Agent Service /dispatch router: - Validates HMAC signature - Creates directive_id (UUID) - Calls MasterAgent.handle_message() 5. MasterAgent.handle_message(): a. Persists user message to ab_conversation (agent-db) b. Builds context: last 20 conversation turns + operational findings c. _classify_intent(): sends system prompt + history + message to LLM - LLM returns JSON: {agents: [...], intent_summary, params, needs_clarification} - caller='master' → format='json' forced on Ollama d. If agents=[]: _direct_answer() → LLM answers conversationally, done e. If needs_clarification: returns clarification question, done f. _check_access(): verifies user has required Odoo groups for each agent g. _dispatch_agents(): calls each agent's execute() concurrently h. _synthesize(): if >1 agent, merges reports via LLM i. Persists assistant reply to ab_conversation j. Returns MasterResponse {reply, status, actions_taken, escalations} 6. _agent_thread() receives response, calls _post_bot_reply() - Opens fresh DB cursor (new transaction) - env['discuss.channel'].message_post() with bot as author - User sees the reply ``` --- ## Request Flow: File Upload (Receipts) ``` 1. User uploads file(s) in Odoo Discuss 2. ab_ai_mail intercepts: - Files only, no text → _post_file_clarification(): bot asks "what to do?" - Files + text (or text after pending upload) → _agent_thread() with att_data 3. _agent_thread() → POST /upload (multipart) Fields: user_id, message, session_id Files: one part per attachment 4. /upload router: - Calls receipt_parser.parse_upload() for each file - ZIPs are recursively unpacked - Images: vision OCR (Ollama llama3.2-vision) → fallback Tesseract - PDFs: pdfplumber text extraction - Returns parsed receipt list to MasterAgent as extra_context['receipts'] 5. MasterAgent routes to expenses_agent (always, when receipts present) 6. ExpensesAgent._plan(): - Checks for duplicates against existing Odoo expenses (fuzzy match) - If duplicates found: returns clarification asking user to confirm/skip - Otherwise: plans expense creation 7. ExpensesAgent._act(): - create_expense_sheet() → hr.expense.sheet - create_expense() per receipt → hr.expense - attach_receipt() → ir.attachment on each hr.expense - Posts chatter note on the sheet ``` --- ## LLM Routing Controlled by `LLM_PRIVACY_MODE` in `.env`: | Mode | Master | Specialist Agents | Notes | |---|---|---|---| | `local` | Ollama | Ollama | All traffic stays on-prem | | `hybrid` | Per `AGENT_BACKEND_*` env vars | Per env vars or Ollama | Mixed routing | | `cloud` | Claude | Claude (except HIPAA-locked) | Requires Anthropic credits | **HIPAA-locked agents** (always Ollama regardless of mode): `finance_agent`, `accounting_agent`, `employees_agent`, `expenses_agent` **Current production setting:** `LLM_PRIVACY_MODE=local` **Models:** - Chat/routing: `activeblue-chat` (Llama 3.1 8B Q8_0, no embedded system prompt) - Vision OCR: `llama3.2-vision:11b` - Claude fallback: `claude-sonnet-4-6` (requires API credits) **Known limitation:** Llama 3.1 8B sometimes misroutes general questions to `expenses_agent`. Mitigated with few-shot examples in `master_system.txt`. For reliable routing, switch to `hybrid` mode with `AGENT_BACKEND_MASTER=claude` once Anthropic credits are available. --- ## Specialist Agents Each agent follows the same lifecycle: `execute()` → `_plan()` → `_act()` → `AgentReport`. | Agent | Odoo Models | Key Capabilities | |---|---|---| | `expenses_agent` | `hr.expense`, `hr.expense.sheet` | Receipt OCR → expense creation, approval, reporting | | `sales_agent` | `sale.order`, `res.partner` | Pipeline, quotes, customer lookup | | `accounting_agent` | `account.move`, `account.payment` | Invoices, journal entries, reconciliation | | `finance_agent` | `account.*` | P&L, cash flow, budget analysis | | `crm_agent` | `crm.lead` | Leads, opportunities, pipeline | | `project_agent` | `project.project`, `project.task` | Tasks, deadlines, progress | | `employees_agent` | `hr.employee`, `hr.leave` | Headcount, leave, HR queries | | `odoo_doc_agent` | RAG (Qdrant) | Odoo documentation Q&A | **`elearning_agent`** — registered. Reduced from 14 → 8 tools by merging the three per-course read calls (`get_course_stats`, `get_enrolled_users`, `get_slide_completion`) into `get_course_details`, folding `publish_course` into `update_course` via a `website_published` param, and dropping `flag_low_completion` (superseded by `post_chatter_note`) and `suggest_next_course` (still available internally via the peer-bus `suggest_courses` request). --- ## Access Control Each agent maps to an Odoo security group. Before dispatching, MasterAgent checks that the requesting user belongs to the required group: | Agent | Required Group | |---|---| | `finance_agent` / `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` | If denied, the bot replies with a "you don't have access" message and logs the denial. --- ## Agent Memory Per-user conversation history is stored in `ab_conversation` (agent-db Postgres). On each request, MasterAgent loads: - Last 20 message turns (for intent classification context) - Last 10 turns (for direct answers) - Stored operational findings from past agent runs (e.g. "user is employee #4") This allows the bot to handle follow-up questions and maintain context across turns without the user repeating themselves. --- ## Directive Logging Every request is logged to `ab_directive_log` (agent-db): - `directive_id` (UUID) — correlates across logs - `status`: `processing` → `complete` / `partial` / `failed` / `awaiting_approval` - `raw_message`, `final_response`, `actions_taken`, `escalations`, `error` Cleanup cron removes entries older than 30 days. --- ## Odoo Addon Components | File | Role | |---|---| | `ab_ai_mail.py` | Intercepts Discuss messages, launches agent thread | | `ab_ai_bot.py` | Bot config record, ping, presence sync, dispatch helpers | | `ab_ai_registry.py` | Mirrors agent registry from service into Odoo | | `ab_ai_directive.py` | Odoo-side directive/log records | | `health_proxy.py` | `/ai/health` endpoint proxies to agent service | | `webhook.py` | Receives async callbacks from agent service | | `approval.py` | Handles human-in-the-loop escalation approvals | --- ## Known Issues & Fixes (2026-05-19) | Issue | Root Cause | Fix | |---|---|---| | Agent service offline at startup | Odoo not ready when agent starts (race condition) | Restart agent after Odoo is up; both now share network | | `Vision OCR failed: 'dict' object has no attribute 'message'` | ollama-python newer versions return dict not object | `receipt_parser.py`: `hasattr(response, 'message')` check | | `Invalid field 'date' on model 'hr.expense.sheet'` | Odoo 18 uses `accounting_date`, not `date` on sheets | Removed `'date'` from `hr.expense.sheet` field lists in `expenses_tools.py` | | Bot routes all messages to `expenses_agent` | Llama 3.1 8B needs few-shot examples to output `agents:[]` | Added routing examples to `master_system.txt` | | Bot green dot always offline | `last_poll` offset (90s) shorter than cron cycle (~5min) | `ab_ai_bot.py`: increased offset to 10 minutes | --- ## Future: Self-Healing via LLM OS Access A planned capability is to give the local Llama instance shell access to the agent service container so it can diagnose and fix its own runtime issues based on health check failures — e.g. automatically restarting a stuck worker, adjusting Ollama parameters, or patching a known model misbehaviour. This would require a sandboxed tool interface (likely a restricted bash tool exposed via the agent's tool registry) with clear authorization boundaries.