Files
odoo-ai/ARCHITECTURE.md
Carlos Garcia 6c22a9a128 feat: elearning_agent — reduce tools 14 → 8 so it registers at startup
- Merge get_course_stats + get_enrolled_users + get_slide_completion → get_course_details
- Fold publish_course into update_course via website_published param
- Drop flag_low_completion (replaced by post_chatter_note) and suggest_next_course
  (still callable internally via peer-bus suggest_courses request)
- elearning_tools: add get_course_details(), extend update_course() signature
- ARCHITECTURE.md: mark elearning_agent as registered

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 23:02:51 -04:00

303 lines
12 KiB
Markdown

# 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`** — 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.