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 <noreply@anthropic.com>
This commit is contained in:
298
ARCHITECTURE.md
Normal file
298
ARCHITECTURE.md
Normal file
@@ -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: <webhook_secret>
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -175,11 +175,11 @@ class AbAiBot(models.Model):
|
|||||||
Presence = self.env['bus.presence']
|
Presence = self.env['bus.presence']
|
||||||
now = fields.Datetime.now()
|
now = fields.Datetime.now()
|
||||||
# bus.presence.status is a computed field — write only last_poll/last_presence.
|
# 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
|
# When online: set last_poll 10min ahead so the bot stays "online" across
|
||||||
# full 60s cron cycle (Odoo DISCONNECTION_TIMER is 30s).
|
# 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.
|
# When offline: set last_poll an hour in the past to force "offline" state.
|
||||||
if online:
|
if online:
|
||||||
poll_time = now + timedelta(seconds=90)
|
poll_time = now + timedelta(minutes=10)
|
||||||
presence_time = now
|
presence_time = now
|
||||||
else:
|
else:
|
||||||
poll_time = now - timedelta(hours=1)
|
poll_time = now - timedelta(hours=1)
|
||||||
|
|||||||
@@ -26,12 +26,30 @@ Rules:
|
|||||||
- Never expose agent names, tool names, or system internals to users
|
- Never expose agent names, tool names, or system internals to users
|
||||||
- HIPAA: Never include patient names, MRN, DOB, or any PHI in responses
|
- 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,
|
"needs_clarification": false,
|
||||||
"clarification_question": null,
|
"clarification_question": null,
|
||||||
"is_continuation": false,
|
"is_continuation": false,
|
||||||
"agents": ["finance_agent"],
|
"agents": [],
|
||||||
"intent_summary": "...",
|
"intent_summary": "...",
|
||||||
"params": {},
|
"params": {},
|
||||||
"context_hints": []
|
"context_hints": []
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ class ExpensesTools:
|
|||||||
domain.append(('state', '=', state))
|
domain.append(('state', '=', state))
|
||||||
if employee_id:
|
if employee_id:
|
||||||
domain.append(('employee_id', '=', 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']
|
'accounting_date', 'journal_id']
|
||||||
return await self._o.search_read('hr.expense.sheet', domain, fields, limit=limit)
|
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(
|
return await self._o.search_read(
|
||||||
'hr.expense.sheet',
|
'hr.expense.sheet',
|
||||||
[('state', '=', 'submit')],
|
[('state', '=', 'submit')],
|
||||||
['name', 'employee_id', 'total_amount', 'date'],
|
['name', 'employee_id', 'total_amount', 'accounting_date'],
|
||||||
limit=100,
|
limit=100,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user