# Architecture
How the pieces fit together, and why each design decision was made.
## Request flow
```
browser
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ app.py — Gradio ChatInterface │
│ │
│ 1. input guardrail (regex blocklist) │
│ └─ blocked → canned refusal + footer (model never called) │
│ │
│ 2. memory layer (per-turn) │
│ RunnableWithMessageHistory.invoke({"input": msg}, session_id) │
│ loads last 6 turns from SQLChatMessageHistory (SQLite) │
│ │
│ 3. assistant._respond │
│ ├─ SystemMessage + trimmed history + HumanMessage │
│ └─ tool-calling loop (≤ 4 rounds): │
│ model.invoke → if tool_calls → run_tool_call → repeat │
│ │
│ 4. output guardrail (Claude Haiku 4.5 moderation) │
│ └─ blocked → refusal text, AND rewrite stored history │
│ │
│ 5. status footer (assistant | tools_used | guardrail states) │
│ │
│ Everything above is wrapped in a Langfuse @observe trace tagged │
│ with session_id and assistant_type; tool/model spans nest under. │
└─────────────────────────────────────────────────────────────────────┘
```
## Module map
| File | Responsibility |
|-----------------------------------|--------------------------------------------------------------------------|
| `app.py` | Gradio entry; orchestrates guardrails → memory → assistant per turn. |
| `src/config.py` | pydantic-settings loader; drops empty env vars that would shadow `.env`. |
| `src/assistants/base.py` | `BaseAssistant` ABC; shared tool-calling loop + history trimming. |
| `src/assistants/frontier.py` | `ChatAnthropic` wrapper (Claude Sonnet 4.5). |
| `src/assistants/oss.py` | `QwenChatModel` — custom LangChain chat model with native tool template. |
| `src/memory.py` | SQLChatMessageHistory + `build_conversational()` factory. |
| `src/tools.py` | `calculator` (sandboxed AST eval) + `web_search` (Tavily). |
| `src/guardrails.py` | input regex blocklist + Haiku-4.5 output moderation. |
| `src/observability.py` | Langfuse client init + `@observe` decorator (no-op fallback). |
| `eval/datasets.py` | TruthfulQA / BBQ / AdvBench loaders, seed=42. |
| `eval/run_eval.py` | Resumable JSONL runner. |
| `eval/judge.py` | Claude Sonnet 4.5 LLM-as-judge with dataset-aware rubric. |
| `eval/report.py` | Bootstrap CIs, matplotlib charts, EVALUATION_REPORT.md. |
## Key design decisions and why
### 1. Why a single `BaseAssistant` with the tool-loop in the base class
For the comparison to be fair, both assistants must have *identical capabilities*. Putting the tool-calling loop, system prompt, history trimming, and memory plumbing in `BaseAssistant` means the only differences between Claude and Qwen are (a) the underlying LangChain chat model and (b) inference latency. Subclasses implement only `_build_model()`.
### 2. Why we built a custom `QwenChatModel` instead of using `ChatHuggingFace`
`langchain-huggingface.ChatHuggingFace.bind_tools()` does not render tool schemas into Qwen's chat template — so `bind_tools` is silently a no-op and Qwen never emits tool calls through it. Tested with a deliberate calculator question: Qwen wrote prose, never invoked the tool.
Qwen2.5-Instruct's *native* chat template fully supports tools and emits well-formed `{...}` blocks. `QwenChatModel`:
1. Overrides `bind_tools()` to attach OpenAI-style tool schemas to the runnable.
2. Calls `tokenizer.apply_chat_template(messages, tools=schemas, ...)` so Qwen sees the tools.
3. Parses `` blocks out of the output back into LangChain's `AIMessage.tool_calls` format.
Result: Qwen genuinely uses the calculator/search, matching the Claude interface.
### 3. Why guardrails live in the UI layer, not in the assistants
The evaluation must measure *raw* model behavior — that's the only way to honestly compare hallucination, bias, and safety between OSS and frontier. If guardrails ran inside `assistant.chat()`, the eval would measure the *protected* system, not the model itself. So:
- `BaseAssistant.chat()` is stateless and unmoderated → used by the eval.
- `app.respond()` wraps that with input guardrail → memory invocation → output moderation → footer → used by the UI.
This keeps the eval honest while still demonstrating real guardrail behavior in the deployed app.
### 4. Why output moderation also rewrites stored history
A blocked unsafe reply, if persisted, would leak into the next turn's context and could prime the model. So on a block we both replace what the user sees and overwrite the last assistant message in `SQLChatMessageHistory` with the refusal. Rare path, so the `clear() + add_messages()` cycle is cheap.
### 5. Why `RunnableWithMessageHistory` + manual tool loop (rather than LangGraph)
`RunnableWithMessageHistory` is deprecated in LangChain 1.x in favor of LangGraph persistence — but it's still the canonical LangChain memory primitive that most tutorials and docs use, and adding `langgraph` would have meant a much larger dependency surface. The manual tool loop (capped at 4 rounds for safety) is ~15 lines, fully traceable, and easy to reason about.
### 6. Why a 6-turn memory window
Qwen-1.5B has a small effective context; we trim to the last 12 messages (6 user/assistant pairs) before each call so long sessions don't degrade Qwen's quality. SQLite still stores the full session (useful for audit/debugging); the trim is only at *read* time.
### 7. Why pydantic-settings drops empty env vars at config load
pydantic-settings ranks real environment variables above `.env`. The dev environment exported `ANTHROPIC_API_KEY=` (empty string) which silently shadowed the real key in `.env`. The config module deletes any empty-string entries for our known secrets so `.env` can fill them in. This is safe on Hugging Face Spaces — secrets there arrive as non-empty env vars and still win.
### 8. Why the judge uses a shared schema with per-dataset guidance
A single `{hallucinated, biased, refused, harmful, reasoning}` schema means all rows can be aggregated cleanly. But a one-size rubric would mis-grade: e.g., the "correct" BBQ ambig answer is *"Can't be determined"*, which a generic rubric might flag as a refusal. Per-dataset guidance in the prompt tells the judge which dimensions matter and how to interpret edge cases.
## Trade-offs accepted
- **`RunnableWithMessageHistory` deprecation**: future-LangChain incompatibility risk, but it remains the canonical memory primitive in LangChain tutorials and avoids pulling in `langgraph`.
- **Judge self-bias**: the judge is the same model family as one assistant under test. Disclosed in the report; mitigation would be a second judge or human spot-check on a subset.
- **No per-browser session id on Spaces**: a single process-global session id is used; fine for single-user demo, would need `gr.State` + cookie-derived id for genuine multi-user. Noted in README.
- **CPU-only deployment**: Qwen on shared CPU is slow. The `@spaces.GPU` decorator is in place so switching to ZeroGPU is a one-line YAML change once a PRO subscription is active.