KevinMerchant13 commited on
Commit
3683c14
Β·
verified Β·
1 Parent(s): 35c0d38

polish: add MIT license

Browse files
Files changed (4) hide show
  1. LICENSE +21 -0
  2. README.md +5 -1
  3. docs/ARCHITECTURE.md +103 -3
  4. docs/DEPLOY_GUIDE.md +124 -3
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kevin Merchant
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md CHANGED
@@ -19,7 +19,7 @@ UI and capabilities β€” multi-turn chat with persistent short-term memory, a
19
  calculator + web-search tool, and a two-layer input/output guardrail β€” and are
20
  evaluated on hallucination, demographic bias, and safety / jailbreak resistance.
21
 
22
- 🌐 **Live demo:** _added after first deploy_
23
  πŸ“Š **Evaluation report:** [`docs/EVALUATION_REPORT.md`](docs/EVALUATION_REPORT.md)
24
  πŸ› οΈ **Architecture notes:** [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) Β· **Deploy guide:** [`docs/DEPLOY_GUIDE.md`](docs/DEPLOY_GUIDE.md)
25
 
@@ -129,6 +129,10 @@ A 100-turn Claude conversation runs **~$0.50**; the same 100 turns on Qwen-via-Z
129
  - Try a **larger OSS model** (Qwen2.5-7B or Llama-3.1-8B) on ZeroGPU's `xlarge` tier to quantify the OSS↔frontier gap-vs-size curve.
130
  - Add **session-scoped Gradio state** (per-browser-tab session id) for true multi-user deployment.
131
 
 
 
 
 
132
  ## Project layout
133
 
134
  ```
 
19
  calculator + web-search tool, and a two-layer input/output guardrail β€” and are
20
  evaluated on hallucination, demographic bias, and safety / jailbreak resistance.
21
 
22
+ 🌐 **Live demo:** [huggingface.co/spaces/KevinMerchant13/oss-vs-frontier-assistant](https://huggingface.co/spaces/KevinMerchant13/oss-vs-frontier-assistant)
23
  πŸ“Š **Evaluation report:** [`docs/EVALUATION_REPORT.md`](docs/EVALUATION_REPORT.md)
24
  πŸ› οΈ **Architecture notes:** [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) Β· **Deploy guide:** [`docs/DEPLOY_GUIDE.md`](docs/DEPLOY_GUIDE.md)
25
 
 
129
  - Try a **larger OSS model** (Qwen2.5-7B or Llama-3.1-8B) on ZeroGPU's `xlarge` tier to quantify the OSS↔frontier gap-vs-size curve.
130
  - Add **session-scoped Gradio state** (per-browser-tab session id) for true multi-user deployment.
131
 
132
+ ## License
133
+
134
+ [MIT](LICENSE) β€” free for any use with attribution.
135
+
136
  ## Project layout
137
 
138
  ```
docs/ARCHITECTURE.md CHANGED
@@ -1,6 +1,106 @@
1
  # Architecture
2
 
3
- _Stub β€” written in Phase 8._
4
 
5
- Will document: component diagram, request flow (UI β†’ guardrails β†’ assistant β†’
6
- tools β†’ memory β†’ observability), and the key design tradeoffs.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  # Architecture
2
 
3
+ How the pieces fit together, and why each design decision was made.
4
 
5
+ ## Request flow
6
+
7
+ ```
8
+ browser
9
+ β”‚
10
+ β–Ό
11
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
12
+ β”‚ app.py β€” Gradio ChatInterface β”‚
13
+ β”‚ β”‚
14
+ β”‚ 1. input guardrail (regex blocklist) β”‚
15
+ β”‚ └─ blocked β†’ canned refusal + footer (model never called) β”‚
16
+ β”‚ β”‚
17
+ β”‚ 2. memory layer (per-turn) β”‚
18
+ β”‚ RunnableWithMessageHistory.invoke({"input": msg}, session_id) β”‚
19
+ β”‚ loads last 6 turns from SQLChatMessageHistory (SQLite) β”‚
20
+ β”‚ β”‚
21
+ β”‚ 3. assistant._respond β”‚
22
+ β”‚ β”œβ”€ SystemMessage + trimmed history + HumanMessage β”‚
23
+ β”‚ └─ tool-calling loop (≀ 4 rounds): β”‚
24
+ β”‚ model.invoke β†’ if tool_calls β†’ run_tool_call β†’ repeat β”‚
25
+ β”‚ β”‚
26
+ β”‚ 4. output guardrail (Claude Haiku 4.5 moderation) β”‚
27
+ β”‚ └─ blocked β†’ refusal text, AND rewrite stored history β”‚
28
+ β”‚ β”‚
29
+ β”‚ 5. status footer (assistant | tools_used | guardrail states) β”‚
30
+ β”‚ β”‚
31
+ β”‚ Everything above is wrapped in a Langfuse @observe trace tagged β”‚
32
+ β”‚ with session_id and assistant_type; tool/model spans nest under. β”‚
33
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
34
+ ```
35
+
36
+ ## Module map
37
+
38
+ | File | Responsibility |
39
+ |-----------------------------------|--------------------------------------------------------------------------|
40
+ | `app.py` | Gradio entry; orchestrates guardrails β†’ memory β†’ assistant per turn. |
41
+ | `src/config.py` | pydantic-settings loader; drops empty env vars that would shadow `.env`. |
42
+ | `src/assistants/base.py` | `BaseAssistant` ABC; shared tool-calling loop + history trimming. |
43
+ | `src/assistants/frontier.py` | `ChatAnthropic` wrapper (Claude Sonnet 4.5). |
44
+ | `src/assistants/oss.py` | `QwenChatModel` β€” custom LangChain chat model with native tool template. |
45
+ | `src/memory.py` | SQLChatMessageHistory + `build_conversational()` factory. |
46
+ | `src/tools.py` | `calculator` (sandboxed AST eval) + `web_search` (Tavily). |
47
+ | `src/guardrails.py` | input regex blocklist + Haiku-4.5 output moderation. |
48
+ | `src/observability.py` | Langfuse client init + `@observe` decorator (no-op fallback). |
49
+ | `eval/datasets.py` | TruthfulQA / BBQ / AdvBench loaders, seed=42. |
50
+ | `eval/run_eval.py` | Resumable JSONL runner. |
51
+ | `eval/judge.py` | Claude Sonnet 4.5 LLM-as-judge with dataset-aware rubric. |
52
+ | `eval/report.py` | Bootstrap CIs, matplotlib charts, EVALUATION_REPORT.md. |
53
+
54
+ ## Key design decisions and why
55
+
56
+ ### 1. Why a single `BaseAssistant` with the tool-loop in the base class
57
+
58
+ Both assistants must have *identical capabilities* (per assessment spec) for the comparison to be fair. 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()`.
59
+
60
+ ### 2. Why we built a custom `QwenChatModel` instead of using `ChatHuggingFace`
61
+
62
+ `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.
63
+
64
+ Qwen2.5-Instruct's *native* chat template fully supports tools and emits well-formed `<tool_call>{...}</tool_call>` blocks. `QwenChatModel`:
65
+
66
+ 1. Overrides `bind_tools()` to attach OpenAI-style tool schemas to the runnable.
67
+ 2. Calls `tokenizer.apply_chat_template(messages, tools=schemas, ...)` so Qwen sees the tools.
68
+ 3. Parses `<tool_call>` blocks out of the output back into LangChain's `AIMessage.tool_calls` format.
69
+
70
+ Result: Qwen genuinely uses the calculator/search, matching the Claude interface.
71
+
72
+ ### 3. Why guardrails live in the UI layer, not in the assistants
73
+
74
+ The evaluation must measure *raw* model behavior (per spec β€” that's how we honestly compare hallucination/bias/safety between OSS and frontier). If guardrails ran inside `assistant.chat()`, the eval would measure the *protected* system, not the model. So:
75
+
76
+ - `BaseAssistant.chat()` is stateless and unmoderated β†’ used by the eval.
77
+ - `app.respond()` wraps that with input guardrail β†’ memory invocation β†’ output moderation β†’ footer β†’ used by the UI.
78
+
79
+ This keeps the eval honest while still demonstrating real guardrail behavior in the deployed app.
80
+
81
+ ### 4. Why output moderation also rewrites stored history
82
+
83
+ 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.
84
+
85
+ ### 5. Why `RunnableWithMessageHistory` + manual tool loop (rather than LangGraph)
86
+
87
+ `RunnableWithMessageHistory` is deprecated in LangChain 1.x in favor of LangGraph persistence β€” but the spec called for it explicitly, 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.
88
+
89
+ ### 6. Why a 6-turn memory window
90
+
91
+ 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.
92
+
93
+ ### 7. Why pydantic-settings drops empty env vars at config load
94
+
95
+ 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.
96
+
97
+ ### 8. Why the judge uses a shared schema with per-dataset guidance
98
+
99
+ 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.
100
+
101
+ ## Trade-offs accepted
102
+
103
+ - **`RunnableWithMessageHistory` deprecation**: future-LangChain incompatibility risk, but spec-required and matches existing tutorials.
104
+ - **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.
105
+ - **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.
106
+ - **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.
docs/DEPLOY_GUIDE.md CHANGED
@@ -1,6 +1,127 @@
1
  # Deploy Guide
2
 
3
- _Stub β€” written in Phase 7._
 
 
4
 
5
- Will document: HF Spaces setup, README YAML frontmatter, ZeroGPU notes, and the
6
- step-by-step push via the huggingface_hub CLI.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  # Deploy Guide
2
 
3
+ End-to-end deployment of this project to a Hugging Face Space. Covers both the
4
+ **free CPU** path (used for this deploy) and the **ZeroGPU** path (for when a
5
+ HF PRO subscription is available).
6
 
7
+ ## Prerequisites
8
+
9
+ - A Hugging Face account with an access token (User Settings β†’ Access Tokens; needs **write** scope).
10
+ - The token in your local `.env` as `HF_TOKEN=hf_…`.
11
+ - The Python deps installed (`uv sync` in the project root).
12
+
13
+ ## One-shot deploy via `huggingface_hub` Python API
14
+
15
+ Everything below β€” create the Space, set secrets, upload files β€” can be done in one Python script. This is what was actually run to produce the live demo.
16
+
17
+ ```python
18
+ from huggingface_hub import HfApi
19
+ from src.config import settings
20
+
21
+ REPO_ID = "<your-username>/oss-vs-frontier-assistant"
22
+ api = HfApi(token=settings.hf_token)
23
+
24
+ # 1) Create the Space (idempotent; safe to re-run).
25
+ api.create_repo(
26
+ repo_id=REPO_ID,
27
+ repo_type="space",
28
+ space_sdk="gradio",
29
+ space_hardware="cpu-basic", # or "zero-a10g" if you have HF PRO
30
+ exist_ok=True,
31
+ private=False,
32
+ )
33
+
34
+ # 2) Set every required secret. These show up as env vars in the Space runtime.
35
+ for k, v in {
36
+ "ANTHROPIC_API_KEY": settings.anthropic_api_key,
37
+ "HF_TOKEN": settings.hf_token,
38
+ "TAVILY_API_KEY": settings.tavily_api_key,
39
+ "LANGFUSE_PUBLIC_KEY": settings.langfuse_public_key,
40
+ "LANGFUSE_SECRET_KEY": settings.langfuse_secret_key,
41
+ "LANGFUSE_HOST": settings.langfuse_host,
42
+ }.items():
43
+ if v:
44
+ api.add_space_secret(repo_id=REPO_ID, key=k, value=v)
45
+
46
+ # 3) Upload the project, excluding local-only / sensitive files.
47
+ api.upload_folder(
48
+ repo_id=REPO_ID,
49
+ repo_type="space",
50
+ folder_path=".",
51
+ commit_message="deploy",
52
+ ignore_patterns=[
53
+ ".env", ".git/**", ".venv/**", ".pytest_cache/**", ".claude/**",
54
+ "data/**", "results/**", "__pycache__/**", "**/__pycache__/**",
55
+ "*.pyc", ".gitignore", "uv.lock",
56
+ ],
57
+ )
58
+ ```
59
+
60
+ Why this approach over the web UI:
61
+
62
+ - **No browser steps.** Reproducible from any machine with the token.
63
+ - **Secrets travel safely.** They never leave your machine in plaintext; the SDK posts them over HTTPS directly to the Space config.
64
+ - **Re-runnable.** `exist_ok=True` + `upload_folder` overwrite makes re-deploys trivial.
65
+
66
+ ## What HF Spaces reads
67
+
68
+ | File | Role on Spaces |
69
+ |--------------|--------------------------------------------------------------------------------|
70
+ | `README.md` | The YAML frontmatter at the top configures the Space (sdk, hardware, etc.). |
71
+ | `requirements.txt` | Installed at build time. **Must be kept in sync with `pyproject.toml`.** |
72
+ | `app.py` | Entry point (`app_file: app.py` in the YAML); HF imports it and finds `demo`. |
73
+
74
+ The YAML frontmatter currently used:
75
+
76
+ ```yaml
77
+ ---
78
+ title: OSS vs Frontier Assistant
79
+ emoji: πŸ€–
80
+ colorFrom: indigo
81
+ colorTo: purple
82
+ sdk: gradio
83
+ sdk_version: 6.14.0
84
+ python_version: "3.11"
85
+ app_file: app.py
86
+ hardware: cpu-basic
87
+ pinned: false
88
+ ---
89
+ ```
90
+
91
+ ## Switching to ZeroGPU later
92
+
93
+ 1. Subscribe to HF PRO ($9/mo).
94
+ 2. In the Space's **Settings β†’ Hardware**, switch to `Nvidia A10G - Zero` (or rerun the deploy script with `space_hardware="zero-a10g"`).
95
+ 3. Update the YAML in `README.md` to `hardware: zero-a10g`.
96
+ 4. Re-upload: the `@spaces.GPU(duration=120)` decorator already on `QwenChatModel._generate` will start allocating real GPU time β€” Qwen latency drops from ~30-60s to ~3-8s per reply.
97
+
98
+ ## Re-deploy after code changes
99
+
100
+ ```bash
101
+ # Bump requirements.txt if pyproject.toml changed, then:
102
+ uv run python - <<'PY'
103
+ from huggingface_hub import HfApi
104
+ from src.config import settings
105
+ HfApi(token=settings.hf_token).upload_folder(
106
+ repo_id="<your-username>/oss-vs-frontier-assistant",
107
+ repo_type="space",
108
+ folder_path=".",
109
+ commit_message="update",
110
+ ignore_patterns=[".env", ".git/**", ".venv/**", ".pytest_cache/**", ".claude/**",
111
+ "data/**", "results/**", "__pycache__/**", "**/__pycache__/**",
112
+ "*.pyc", ".gitignore", "uv.lock"],
113
+ )
114
+ PY
115
+ ```
116
+
117
+ HF triggers a new build automatically when files change.
118
+
119
+ ## Troubleshooting
120
+
121
+ | Symptom on Spaces | Likely cause / fix |
122
+ |---------------------------------------------|-------------------------------------------------------------------------------|
123
+ | Build fails on `torch`/`transformers` install | Mismatch between `requirements.txt` pin and HF base image β€” check `python_version`. |
124
+ | `ANTHROPIC_API_KEY is not set` at runtime | Secret not added in Space settings, or empty. Re-run the secrets loop above. |
125
+ | 403 on `create_repo` mentioning ZeroGPU | ZeroGPU is gated behind HF PRO; use `space_hardware="cpu-basic"` instead. |
126
+ | Qwen replies very slowly (30-60s) | Expected on `cpu-basic`. Switch to ZeroGPU per the section above. |
127
+ | Tracing missing from Langfuse | Network timeout on the Space β†’ traces. Non-fatal; bump `LANGFUSE_TIMEOUT=30`. |