# Multi-Vertical Chatbot Backend

FastAPI + LangGraph agent service powering the embeddable chat widget for three demo verticals:
hospital (`aarogya-hospital`), real estate (`skyline-realty`), and tourism (`wanderlust-tours`).

## Setup

```bash
python -m venv .venv
./.venv/Scripts/activate        # Windows: .venv\Scripts\activate
pip install -r requirements.txt
cp .env.example .env            # then set GROQ_API_KEY
python seed_run.py              # (re)populates data/app.db with the 3 demo tenants
uvicorn app.main:app --reload
```

Server runs at `http://127.0.0.1:8000`. Health check: `GET /healthz`.

## API

- `GET /api/v1/widget/{tenant_id}/config` — public widget bootstrap (branding, welcome message, quick actions).
- `POST /api/v1/chat/{tenant_id}` — `{"message": "...", "session_id": "optional"}`, streams the agent's
  reply as Server-Sent Events (`event: token` chunks, then one `event: done` with the full text + session_id,
  or `event: error` on failure). `session_id` ties a conversation to a LangGraph checkpoint thread — omit it
  on the first message and reuse the one returned in `done`/the `X-Session-Id` response header afterwards.
- Doctor/property/package search tools also emit an `event: cards` frame (`{"kind": "doctors"|"properties"|"packages", "items": [...]}`)
  carrying structured results for rendering as UI cards, alongside the natural-language reply.

## Architecture

- `app/db/models.py` — SQLAlchemy models: `Tenant`, `Faq`, plus per-vertical tables (`Department`/`Doctor`,
  `Agent`/`Property`, `Destination`/`Package`), all scoped by `tenant_id` in one shared SQLite file.
- `app/db/fts.py` — SQLite FTS5 full-text index over FAQs (rebuilt by `seed.py`).
- `app/agent/tools/` — one tool module per vertical, plus a shared `search_faq` tool. Tools are built as
  closures bound to a `tenant_id` so the LLM never sees or controls tenant scoping. The three "search"
  tools use `response_format="content_and_artifact"` so structured rows travel to the API layer separately
  from the text summary the LLM reasons over.
- `app/agent/graph.py` — a 2-node LangGraph (`agent` ⇄ `tools`) per tenant, cached in memory, checkpointed
  via `AsyncSqliteSaver` (`data/checkpoints.db`) so multi-turn conversations persist across restarts.
- `app/agent/prompts.py` — per-vertical system prompt + the guardrail rule: only answer from tool results,
  otherwise reply with the fixed fallback sentence verbatim.

## Multiple Groq API keys (automatic fallback)

`GROQ_API_KEY` accepts one key, or several comma-separated (`GROQ_API_KEY=key1,key2,key3`). If a key hits
its rate limit, is out of quota, or gets revoked (`RateLimitError` / `AuthenticationError` /
`PermissionDeniedError`), the agent automatically retries the same request with the next key before giving
up — see `app/agent/llm.py` (`get_llm_pool`, `KeyRotator`) and the retry loop in `agent_node` in
`app/agent/graph.py`. The "current" key index is shared globally across all tenants (keys are an
account-level resource), and once a fallback key succeeds, it becomes the new starting point for future
requests instead of re-trying the dead key every time. Errors that aren't key-related (malformed requests,
Groq server errors) are *not* retried across keys — they surface immediately, same as before.

## Notes on the LLM model

Default model is `openai/gpt-oss-120b` on Groq (`GROQ_MODEL` in `.env`). During development,
`llama-3.3-70b-versatile` occasionally emitted malformed tool calls (its function-calling grammar struggles
with `int | None`-style optional parameters); `openai/gpt-oss-120b` was consistently reliable in testing.
If you change the model, avoid `Optional`/union-typed tool parameters — use plain-typed defaults
(e.g. `max_price: int = 0` meaning "no filter") instead.

## Known limitations (by design, for this pass)

- Admin CRUD for tenants/FAQs/vertical data is not exposed via API — edit via `app/db/seed.py` and re-run
  `seed_run.py`, or use a DB browser against `data/app.db`.
- The per-tenant LangGraph is cached at first use; changes to `Tenant` branding require a server restart
  to be picked up by the compiled graph's system prompt (the widget's `/config` endpoint always reads live).
