From a0fc1396a9e21776d19ed41a243160406c8dc56a Mon Sep 17 00:00:00 2001 From: Carlos Garcia Date: Tue, 19 May 2026 15:47:48 -0400 Subject: [PATCH] fix: Odoo 18 field errors, routing quality, bot presence, and add architecture docs - expenses_tools: remove 'date' from hr.expense.sheet field lists (Odoo 18 uses accounting_date; querying 'date' raised ValueError at runtime) - master_system.txt: add few-shot routing examples so Llama 3.1 8B correctly outputs agents=[] for general questions instead of defaulting to expenses_agent - ab_ai_bot.py: increase bot presence last_poll offset from 90s to 10min so the green dot stays on between cron runs (cron fires every ~5min in practice, not every 20s as configured) - ARCHITECTURE.md: full system documentation covering component layout, request flow, LLM routing, agent registry, access control, health/presence mechanism, known issues fixed today, and future self-healing concept Co-Authored-By: Claude Sonnet 4.6 --- ARCHITECTURE.md | 298 +++++++++++++++++++++++ addons/activeblue_ai/models/ab_ai_bot.py | 6 +- agent_service/prompts/master_system.txt | 22 +- agent_service/tools/expenses_tools.py | 4 +- 4 files changed, 323 insertions(+), 7 deletions(-) create mode 100644 ARCHITECTURE.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..e6f06aa --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,298 @@ +# 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`** — defined but not registered: exceeds the 8-tool limit per +agent. Needs to be split into contextual tool groups before it can be used. + +--- + +## 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. diff --git a/addons/activeblue_ai/models/ab_ai_bot.py b/addons/activeblue_ai/models/ab_ai_bot.py index 879ecdc..f8e50a7 100644 --- a/addons/activeblue_ai/models/ab_ai_bot.py +++ b/addons/activeblue_ai/models/ab_ai_bot.py @@ -175,11 +175,11 @@ class AbAiBot(models.Model): Presence = self.env['bus.presence'] now = fields.Datetime.now() # bus.presence.status is a computed field — write only last_poll/last_presence. - # When online: set last_poll 90s ahead so the bot stays "online" across the - # full 60s cron cycle (Odoo DISCONNECTION_TIMER is 30s). + # When online: set last_poll 10min ahead so the bot stays "online" across + # the full cron cycle (Odoo cron scheduler fires every ~5min in practice). # When offline: set last_poll an hour in the past to force "offline" state. if online: - poll_time = now + timedelta(seconds=90) + poll_time = now + timedelta(minutes=10) presence_time = now else: poll_time = now - timedelta(hours=1) diff --git a/agent_service/prompts/master_system.txt b/agent_service/prompts/master_system.txt index 8db04fd..51419a2 100644 --- a/agent_service/prompts/master_system.txt +++ b/agent_service/prompts/master_system.txt @@ -26,12 +26,30 @@ Rules: - 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: +CRITICAL ROUTING RULE: Most messages are general conversation and require NO specialist agent. +Only route to a specialist agent when the user explicitly asks for Odoo data or actions. +When in doubt, use "agents": []. + +Examples of correct routing: + +User: "hello" or "hi" or "what can you do?" or "what does that mean?" or "ok" or "thanks" +-> {"needs_clarification": false, "clarification_question": null, "is_continuation": false, "agents": [], "intent_summary": "general greeting or question", "params": {}, "context_hints": []} + +User: "show me my expenses" or "what are my pending expense reports?" +-> {"needs_clarification": false, "clarification_question": null, "is_continuation": false, "agents": ["expenses_agent"], "intent_summary": "retrieve user expense records", "params": {}, "context_hints": []} + +User: "how are sales this month?" or "show me the pipeline" +-> {"needs_clarification": false, "clarification_question": null, "is_continuation": false, "agents": ["sales_agent"], "intent_summary": "retrieve monthly sales data", "params": {}, "context_hints": []} + +User: "what projects are overdue?" +-> {"needs_clarification": false, "clarification_question": null, "is_continuation": false, "agents": ["project_agent"], "intent_summary": "find overdue projects", "params": {}, "context_hints": []} + +Now classify the user's message in JSON only: { "needs_clarification": false, "clarification_question": null, "is_continuation": false, - "agents": ["finance_agent"], + "agents": [], "intent_summary": "...", "params": {}, "context_hints": [] diff --git a/agent_service/tools/expenses_tools.py b/agent_service/tools/expenses_tools.py index 454b1eb..f5e218e 100644 --- a/agent_service/tools/expenses_tools.py +++ b/agent_service/tools/expenses_tools.py @@ -31,7 +31,7 @@ class ExpensesTools: domain.append(('state', '=', state)) if employee_id: domain.append(('employee_id', '=', employee_id)) - fields = ['name', 'employee_id', 'state', 'total_amount', 'date', + fields = ['name', 'employee_id', 'state', 'total_amount', 'accounting_date', 'journal_id'] return await self._o.search_read('hr.expense.sheet', domain, fields, limit=limit) @@ -39,7 +39,7 @@ class ExpensesTools: return await self._o.search_read( 'hr.expense.sheet', [('state', '=', 'submit')], - ['name', 'employee_id', 'total_amount', 'date'], + ['name', 'employee_id', 'total_amount', 'accounting_date'], limit=100, )