Nutrition Research Assistant
README.md
# Nutrition Research Assistant
Production-grade **multi-agent Indian nutrition research assistant**: answers questions about Indian food and nutrition by combining a curated internal knowledge base, hybrid RAG, specialized agents, MCP tools, conversational memory, optional live web search, graceful failure handling, and end-to-end OpenTelemetry-compatible tracing.
```mermaid
graph TD
Client[CLI / Swagger / Demo script] --> API[FastAPI :8000]
API --> Sup[Agno Supervisor]
Sup --> KA[Knowledge Agent]
Sup --> CA[Calculator Agent]
Sup --> DA[Document Agent]
Sup --> WA[Health/Web Agent]
KA --> MCP[FastMCP Server :8001/mcp]
CA --> MCP
DA --> MCP
WA --> MCP
MCP --> Chroma[ChromaDB + BM25 + RRF + Reranker]
MCP --> Calc[Safe calculator]
MCP --> Web[Agno DDGSTools]
MCP --> Docs[documents.json]
Sup --> Mem[(SQLite memory)]
API --> OTel[OpenTelemetry + structlog]
```
**Core principle (PRD §89):** the LLM decides _what_ should happen; MCP tools perform _what must happen deterministically_. Agents never touch Chroma directly — everything flows through MCP.
## Tech Stack
| Layer | Choice |
| ----------------- | ------------------------------------------------------------ |
| Python / packages | 3.12+ · uv (`pyproject.toml` + lock) |
| LLM | DeepSeek `deepseek-v4-flash` (OpenAI-compatible) |
| Agents | Agno (Supervisor + 4 specialized agents) |
| MCP | FastMCP server, streamable-http on `:8001/mcp` |
| Vector DB | ChromaDB (local, cosine) |
| Embeddings | `BAAI/bge-small-en-v1.5` (local) |
| Reranker | `BAAI/bge-reranker-base` (local cross-encoder) |
| BM25 | `rank_bm25` |
| Memory | Agno sessions + SQLite (`data/app.db`) |
| API | FastAPI + uvicorn |
| Observability | OpenTelemetry (console exporter; OTLP-swappable) + structlog |
| Web search | Agno built-in `DDGSTools` (optional) |
## Repository Layout
```
corpus/ 8 curated nutrition documents (frontmatter + per-food tables)
scripts/ ingest.py · rebuild_index.py · test_retrieval.py · eval_questions.json
test_mcp_wiring.py · test_agents.py · test_memory.py · demo.py
src/
api/ FastAPI app, routes, schemas (chat / health / ready)
agents/ models.py (DeepSeek) · mcp_client.py · agents.py (specialists + supervisor)
mcp_server/ server.py + tools/{knowledge,calculator,documents,web}.py
rag/ embeddings · chroma · bm25 · fusion (RRF) · reranker · pipeline
memory/ SQLite storage
services/ circuit breaker · retry
observability tracing (OTel) · logging (structlog)
config/ settings.py core/ errors.py · models.py
cli.py REPL client
tests/ unit + API tests
```
## Quick Start
```bash
# 1. Environment
uv venv --python 3.12
uv sync
# 2. Secrets
cp .env.example .env # set DEEPSEEK_API_KEY
# 3. Ingest the corpus (downloads bge-small-en-v1.5 on first run)
uv run python scripts/ingest.py
# 4. Start the MCP tool server (terminal 1)
uv run python -m src.mcp_server.server
# 5. Start the API (terminal 2)
uv run uvicorn src.api.app:app --reload --port 8000
```
Swagger UI: <http://127.0.0.1:8000/docs> (API port is configurable via `API_PORT` in `.env` — this workspace uses 8002 because 8000 is taken)
Try it:
```bash
uv run python -m src.cli # interactive REPL
uv run python scripts/demo.py # scripted 5-question demo
curl -X POST http://127.0.0.1:8000/api/v1/chat \
-H "Content-Type: application/json" \
-d '{"session_id":"demo","message":"How much protein is in 100g cooked chickpeas?"}'
```
## API
| Endpoint | Purpose |
| ------------------- | ------------------------------------------------------------------------------- |
| `POST /api/v1/chat` | `{session_id, message}` → `{status, session_id, response, sources[], trace_id}` |
| `GET /health` | `{status, mcp, chroma, llm}` — liveness, no secrets |
| `GET /ready` | `{status, checks}` — alive vs ready distinction |
Degraded responses carry `status: "degraded"`, a human-readable message, and the `trace_id` — never a 500 traceback.
## MCP Tools (port 8001)
| Tool | Backend | Notes |
| ----------------------------------------- | ---------------------------------------- | ------------------------------------------------ |
| `search_knowledge(query, top_k, filters)` | hybrid RAG (dense + BM25 + RRF + rerank) | returns chunks with document_id / score / method |
| `calculate(expression)` | AST-whitelisted safe evaluator | no `eval`, no code execution |
| `get_document(document_id)` | ingested `documents.json` | never fabricates ids |
| `search_web(query)` | Agno `DDGSTools` | optional; `WEB_SEARCH_ENABLED` |
| `search_health_information(query)` | DDGSTools + domain ranking | prioritizes WHO/ICMR/NIH/CDC |
## Agents
| Agent | Tools | Handles |
| ---------- | ------------------- | ----------------------------------------------- |
| Supervisor | team of 4 | intent, routing, composition, memory |
| Knowledge | `search_knowledge` | nutrition facts, comparisons, raw vs cooked |
| Calculator | `calculate` | serving scaling, totals |
| Document | `get_document` | document retrieval |
| Health/Web | web + health search | current info, general health (educational only) |
## Failure Handling
- **MCP down** → controlled `degraded` response with trace_id (no 500); circuit breaker fails fast, probes after cooldown (PRD §70/§85)
- **MCP timeout** — 10s transport read timeout; agent run bounded by `AGENT_TIMEOUT_SECONDS`
- **Chroma down** → "Knowledge retrieval temporarily unavailable" (optionally falls back to web)
- **LLM down / bad key** → `LLM_UNAVAILABLE`-style degraded message
- **Web disabled** → "Live search is currently unavailable."
- Retry policy: 2 attempts with backoff, then degrade — never endless
## Observability
Every request produces one trace: `api.request → supervisor.run → mcp.<tool> → rag.dense_search / rag.bm25 / rag.rrf / rag.reranker`, with trace_id echoed in the API response, logs, and spans. All logs are JSON (structlog) with timestamp, level, logger, trace_id, span_id. Set `OTEL_EXPORTER_OTLP_ENDPOINT` to export to Jaeger/Tempo/Collector (`uv sync --extra otel`).
## Tests & Retrieval Eval
```bash
uv run pytest -q
uv run python scripts/test_retrieval.py --rerank # Recall@5/10, MRR, Hit@1/3 over 24 questions
```
The eval dataset (`scripts/eval_questions.json`, 24 questions across 7 categories) measures retrieval quality; the reranker lifts Hit@1/3 over raw fusion. Results land in `data/eval/results.json`.
## Definition of Done (PRD §87)
- **Core** — FastAPI runs; DeepSeek via OpenAI-compatible client; Agno agents operational; FastMCP operational; Chroma populated; 8 documents; memory works
- **MCP** — all 5 tools work and agents use MCP as the real execution path
- **RAG** — dense + BM25 + RRF + cross-encoder rerank; metadata preserved; eval exists
- **Agents** — Supervisor + 4 specialists; multi-agent composition works
- **Memory** — session ids; follow-ups resolve; persisted in SQLite
- **Observability** — trace_id per request; nested spans; MCP/RAG/LLM/errors traced
- **Failure handling** — MCP unavailable/timeout, Chroma, LLM, web all controlled; no unhandled exception reaches the user
## Troubleshooting
- **Hugging Face symlink warning** — Windows-only; set `HF_HUB_DISABLE_SYMLINKS_WARNING=1` (cosmetic)
- **Retrieval returns nothing** — Chroma empty: run `python scripts/ingest.py`
- **"tool service unavailable"** — MCP server not running (start it) or breaker cooling down (wait ~30s, or restart API)
- **Health says llm not_configured** — `DEEPSEEK_API_KEY` missing in `.env`
This server cannot be deployed
Maintenance
ActivitySlowing
ResponsivenessNo issues