رغد
feat: complete platform — auth, deployment, hardening
344e369
# تبيان الطبي — Agent System
## Overview
The multi-agent pipeline in `backend/services/agents/` processes each medical file through five sequential agents. Each agent reads from and writes to a shared `AgentContext` dataclass, making the full pipeline observable and independently testable.
---
## Base Classes (`agents/base.py`)
### `AgentContext`
Shared state object threaded through all agents. Key fields:
| Field | Type | Set by |
|---|---|---|
| `file_bytes` | `bytes` | Coordinator |
| `file_type` | `"pdf" \| "image"` | Coordinator |
| `raw_text` | `str` | OCRAgent |
| `findings` | `list[dict]` | ExtractionAgent |
| `panel_code` | `str` | ClassificationAgent |
| `rag_context` | `str` | MedicalReasoningAgent |
| `report` | `dict` | MedicalReasoningAgent |
| `logs` | `list[AgentLogEntry]` | All agents |
### `AgentBase`
Abstract base with:
- **Retry logic**: up to `max_retries=2` attempts with `0.3 s × 2^attempt` backoff
- **`_on_failure(ctx, exc)`**: each subclass overrides to provide a safe fallback when all retries fail
- **Timing**: each `run()` call records `duration_ms` in `AgentLogEntry`
---
## Agents
### 1. `OCRAgent`
**File**: `agents/ocr_agent.py`
**Input**: `ctx.file_bytes`, `ctx.file_type`
**Output**: `ctx.raw_text`
**Strategy**:
- PDF: extracts text with PyMuPDF (`fitz`); falls back to EasyOCR page-by-page if text layer is empty
- Image: tries Google Cloud Vision first (higher accuracy for Arabic); falls back to EasyOCR with contrast/sharpness preprocessing
**Failure mode**: sets `raw_text = ""` — downstream agents handle empty text gracefully.
---
### 2. `ExtractionAgent`
**File**: `agents/extraction_agent.py`
**Input**: `ctx.raw_text`
**Output**: `ctx.findings` (list of `{name, value, unit, range, status}`)
**Strategy**:
1. Regex patterns matching common Arabic/English lab report formats
2. LLM extraction via Groq if regex yields < 2 findings
3. Physiological bounds filter (`_validators.py`) removes impossible values (e.g., hemoglobin = 400)
4. Deduplication by normalized test name
**Failure mode**: sets `findings = []`.
---
### 3. `ClassificationAgent`
**File**: `agents/classification_agent.py`
**Input**: `ctx.findings`, `ctx.raw_text`
**Output**: `ctx.panel_code`, `ctx.panel_confidence`
**Strategy**: Uses `services/classifier.py` which scores text against panel-specific keyword sets. Falls back to `detect_panel()` heuristic if primary classifier returns low confidence.
**Panels**: `cbc`, `thyroid`, `liver`, `kidney`, `lipid`, `diabetes`, `urine`, `mixed`
**Failure mode**: sets `panel_code = "mixed"` (general analysis).
---
### 4. `MedicalReasoningAgent`
**File**: `agents/reasoning_agent.py`
**Input**: `ctx.findings`, `ctx.panel_code`, `ctx.analysis_type`
**Output**: `ctx.rag_context`, `ctx.report`
**Strategy**:
1. Checks `rag_cache` (TTL 5 min) for identical query
2. Retrieves relevant medical knowledge via `Retriever` (BM25 + pgvector + Cohere rerank)
3. Selects panel-specific prompt template from `prompts/`
4. Calls Groq `llama-3.3-70b-versatile` with findings + RAG context
5. Parses JSON response into structured report
**Failure mode**: generates a fallback report from raw findings without LLM, appends disclaimer.
---
### 5. `SafetyAgent`
**File**: `agents/safety_agent.py`
**Input**: `ctx.report`
**Output**: `ctx.report` (filtered in-place)
**Strategy**: Applies `services/safety.filter_analysis_report()` which:
- Removes diagnostic certainty claims ("you have diabetes")
- Adds standard medical disclaimer
- Detects emergency patterns (very high/low critical values) and prepends urgent notice
**Failure mode**: appends `DISCLAIMER_AR` manually to ensure minimum safety even if filter itself errors.
---
## `AgentCoordinator` (`agents/coordinator.py`)
Instantiates all five agents and runs them in sequence. Returns `CoordinatorResult`:
```python
@dataclass
class CoordinatorResult:
findings: list[dict]
summary: str
report: dict
panel_code: str
logs: list[dict] # exposed in dev mode via _agents field
ok: bool
error: str
total_ms: float
```
The coordinator is loaded once via `@lru_cache` and reused across requests. Agent instances are stateless — all state lives in the per-request `AgentContext`.
---
## Adding a New Agent
1. Create `agents/my_agent.py`, subclass `AgentBase`
2. Implement `_execute(self, ctx: AgentContext) -> AgentContext`
3. Implement `_on_failure(self, ctx, exc)` with a safe fallback
4. Add new fields to `AgentContext` if needed
5. Register in `AgentCoordinator.__init__()` agent list at the correct position