- 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>
303 lines
12 KiB
Markdown
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.
|