Add semantic routing and retrieval to cut per-request token cost
Browse files- .github/workflows/tests.yml +27 -0
- README.md +89 -23
- app.py +1 -1
- config.py +12 -0
- embeddings.py +25 -0
- graph.py +105 -20
- prompts.py +57 -2
- requirements.txt +1 -0
- retrieval.py +81 -0
- router.py +94 -0
- tests/conftest.py +26 -1
- tests/test_config.py +10 -3
- tests/test_graph.py +82 -2
- tests/test_isolation.py +27 -0
- tests/test_retrieval.py +87 -0
- tests/test_router.py +97 -0
- tests/test_temperature.py +39 -0
.github/workflows/tests.yml
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: tests
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches: [main]
|
| 6 |
+
pull_request:
|
| 7 |
+
|
| 8 |
+
jobs:
|
| 9 |
+
pytest:
|
| 10 |
+
runs-on: ubuntu-latest
|
| 11 |
+
steps:
|
| 12 |
+
- uses: actions/checkout@v4
|
| 13 |
+
|
| 14 |
+
- uses: actions/setup-python@v5
|
| 15 |
+
with:
|
| 16 |
+
python-version: "3.13"
|
| 17 |
+
cache: pip
|
| 18 |
+
|
| 19 |
+
- name: Install dependencies
|
| 20 |
+
run: |
|
| 21 |
+
python -m pip install --upgrade pip
|
| 22 |
+
pip install -r requirements.txt -r requirements-dev.txt
|
| 23 |
+
|
| 24 |
+
# No secrets and no data/ directory: tests/conftest.py pins the
|
| 25 |
+
# environment so the suite runs without credentials.
|
| 26 |
+
- name: Run tests
|
| 27 |
+
run: python -m pytest tests/ -q
|
README.md
CHANGED
|
@@ -8,10 +8,27 @@ grounded only in your own career documents.
|
|
| 8 |
|
| 9 |
**1. Create the environment**
|
| 10 |
|
|
|
|
|
|
|
| 11 |
```bash
|
| 12 |
python -m venv .venv
|
| 13 |
-
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
pip install -r requirements.txt
|
| 16 |
```
|
| 17 |
|
|
@@ -24,7 +41,7 @@ CANDIDATE_NAME=Your Name # used in the system prompt
|
|
| 24 |
# Optional — Langfuse tracing. Tracing turns on only when both keys are present.
|
| 25 |
LANGFUSE_PUBLIC_KEY=pk-lf-...
|
| 26 |
LANGFUSE_SECRET_KEY=sk-lf-...
|
| 27 |
-
|
| 28 |
```
|
| 29 |
|
| 30 |
| Variable | Default | Purpose |
|
|
@@ -35,10 +52,13 @@ LANGFUSE_BASE_URL=https://us.cloud.langfuse.com
|
|
| 35 |
| `GROQ_MODEL` | `openai/gpt-oss-120b` | Model id. |
|
| 36 |
| `LLM_TEMPERATURE` | `0.0` | Sampling temperature. |
|
| 37 |
| `LLM_MAX_TOKENS` | `3000` | Response cap. See the token budget note below. |
|
|
|
|
| 38 |
| `MAX_HISTORY_TURNS` | `10` | Turns replayed to the model per request. |
|
|
|
|
|
|
|
| 39 |
| `CHECKPOINT_DB` | `checkpoints.sqlite` | Where conversation threads persist. |
|
| 40 |
| `LANGFUSE_PUBLIC_KEY` / `LANGFUSE_SECRET_KEY` | — | Enable tracing when both are set. |
|
| 41 |
-
| `LANGFUSE_HOST`
|
| 42 |
|
| 43 |
**3. Add your documents.** Put your resume, project write-ups, and any other career documents
|
| 44 |
in `data/`. PDFs must be converted to Markdown first — the app reads only `.md`:
|
|
@@ -48,6 +68,11 @@ pip install -r requirements-ingest.txt # docling, heavy; needed only for this
|
|
| 48 |
python pdf_2_markdown_converter.py
|
| 49 |
```
|
| 50 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
**4. Run**
|
| 52 |
|
| 53 |
```bash
|
|
@@ -62,7 +87,10 @@ Paste a job description into the left panel, then chat or use the preset buttons
|
|
| 62 |
job-chat/
|
| 63 |
├── app.py # Gradio UI and event wiring — the entry point
|
| 64 |
├── graph.py # LangGraph state graph, routing, LLM construction
|
| 65 |
-
├── prompts.py # System
|
|
|
|
|
|
|
|
|
|
| 66 |
├── ats.py # ATS schema and score calculation
|
| 67 |
├── dataloader.py # Loads and concatenates the Markdown profile documents
|
| 68 |
├── config.py # Environment settings with validation
|
|
@@ -70,22 +98,30 @@ job-chat/
|
|
| 70 |
├── pdf_2_markdown_converter.py # One-off: PDF -> Markdown
|
| 71 |
├── data/ # Your documents (gitignored)
|
| 72 |
├── tests/ # Unit tests; no API calls
|
|
|
|
| 73 |
├── requirements.txt # Runtime
|
| 74 |
├── requirements-ingest.txt # PDF conversion only
|
| 75 |
-
└── requirements-dev.txt # Tests
|
| 76 |
```
|
| 77 |
|
| 78 |
## Graph structure
|
| 79 |
|
| 80 |
-
|
| 81 |
-
|
| 82 |
|
| 83 |
```mermaid
|
| 84 |
-
flowchart
|
| 85 |
-
START([START]) -->
|
| 86 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
ATS --> FINISH([END])
|
| 88 |
ANSWER --> FINISH
|
|
|
|
|
|
|
| 89 |
```
|
| 90 |
|
| 91 |
**State**
|
|
@@ -96,13 +132,37 @@ flowchart LR
|
|
| 96 |
| `job_description` | The pasted target role. |
|
| 97 |
| `action` | `ats`, `review`, `cover_letter`, or empty for free chat. |
|
| 98 |
|
| 99 |
-
**
|
| 100 |
-
|
| 101 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
|
| 103 |
-
**
|
| 104 |
-
|
| 105 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
|
| 107 |
**Persistence.** A SQLite checkpointer keeps each thread on disk, so conversations survive a
|
| 108 |
restart. "Reset conversation" starts a new thread; the previous one is retained.
|
|
@@ -110,16 +170,22 @@ restart. "Reset conversation" starts a new thread; the previous one is retained.
|
|
| 110 |
## Tests
|
| 111 |
|
| 112 |
```bash
|
| 113 |
-
pip install -r requirements-dev.txt
|
| 114 |
python -m pytest tests/ -q
|
| 115 |
```
|
| 116 |
|
| 117 |
Unit tests only — no API calls, no keys needed. They cover the ATS score arithmetic, config
|
| 118 |
-
validation, document loading,
|
|
|
|
|
|
|
|
|
|
| 119 |
|
| 120 |
## Token budget
|
| 121 |
|
| 122 |
-
Groq's free tier allows 8,000 tokens per minute and 200,000 per day
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
**1. Create the environment**
|
| 10 |
|
| 11 |
+
Python 3.13 is what CI runs on; 3.11+ works.
|
| 12 |
+
|
| 13 |
```bash
|
| 14 |
python -m venv .venv
|
| 15 |
+
```
|
| 16 |
+
|
| 17 |
+
Activate it — Windows:
|
| 18 |
+
|
| 19 |
+
```powershell
|
| 20 |
+
.venv\Scripts\activate
|
| 21 |
+
```
|
| 22 |
+
|
| 23 |
+
macOS / Linux:
|
| 24 |
+
|
| 25 |
+
```bash
|
| 26 |
+
source .venv/bin/activate
|
| 27 |
+
```
|
| 28 |
+
|
| 29 |
+
Then:
|
| 30 |
+
|
| 31 |
+
```bash
|
| 32 |
pip install -r requirements.txt
|
| 33 |
```
|
| 34 |
|
|
|
|
| 41 |
# Optional — Langfuse tracing. Tracing turns on only when both keys are present.
|
| 42 |
LANGFUSE_PUBLIC_KEY=pk-lf-...
|
| 43 |
LANGFUSE_SECRET_KEY=sk-lf-...
|
| 44 |
+
LANGFUSE_HOST=https://us.cloud.langfuse.com
|
| 45 |
```
|
| 46 |
|
| 47 |
| Variable | Default | Purpose |
|
|
|
|
| 52 |
| `GROQ_MODEL` | `openai/gpt-oss-120b` | Model id. |
|
| 53 |
| `LLM_TEMPERATURE` | `0.0` | Sampling temperature. |
|
| 54 |
| `LLM_MAX_TOKENS` | `3000` | Response cap. See the token budget note below. |
|
| 55 |
+
| `LLM_DRAFTING_TEMPERATURE` | `0.4` | Temperature for cover-letter drafting only. |
|
| 56 |
| `MAX_HISTORY_TURNS` | `10` | Turns replayed to the model per request. |
|
| 57 |
+
| `EMBEDDING_MODEL` | `sentence-transformers/all-MiniLM-L6-v2` | Routing and retrieval embeddings, run through fastembed (ONNX, no torch). Downloaded on first run. |
|
| 58 |
+
| `RETRIEVAL_TOP_K` | `5` | Excerpts retrieved for a narrow question. |
|
| 59 |
| `CHECKPOINT_DB` | `checkpoints.sqlite` | Where conversation threads persist. |
|
| 60 |
| `LANGFUSE_PUBLIC_KEY` / `LANGFUSE_SECRET_KEY` | — | Enable tracing when both are set. |
|
| 61 |
+
| `LANGFUSE_HOST` (alias: `LANGFUSE_BASE_URL`) | `https://cloud.langfuse.com` | Langfuse region. Use `https://us.cloud.langfuse.com` if your keys are US. |
|
| 62 |
|
| 63 |
**3. Add your documents.** Put your resume, project write-ups, and any other career documents
|
| 64 |
in `data/`. PDFs must be converted to Markdown first — the app reads only `.md`:
|
|
|
|
| 68 |
python pdf_2_markdown_converter.py
|
| 69 |
```
|
| 70 |
|
| 71 |
+
The converter reads and writes `data/` directly; it does not honour `DATA_DIR`. If you point
|
| 72 |
+
`DATA_DIR` elsewhere, convert in `data/` first and move the `.md` files across.
|
| 73 |
+
|
| 74 |
+
Startup fails if the directory is missing or holds no `.md` file, so do this before step 4.
|
| 75 |
+
|
| 76 |
**4. Run**
|
| 77 |
|
| 78 |
```bash
|
|
|
|
| 87 |
job-chat/
|
| 88 |
├── app.py # Gradio UI and event wiring — the entry point
|
| 89 |
├── graph.py # LangGraph state graph, routing, LLM construction
|
| 90 |
+
├── prompts.py # System prompts and the three preset action instructions
|
| 91 |
+
├── router.py # Semantic router: trivial / specific / full
|
| 92 |
+
├── retrieval.py # Chunking and similarity search over the profile
|
| 93 |
+
├── embeddings.py # fastembed wrapper
|
| 94 |
├── ats.py # ATS schema and score calculation
|
| 95 |
├── dataloader.py # Loads and concatenates the Markdown profile documents
|
| 96 |
├── config.py # Environment settings with validation
|
|
|
|
| 98 |
├── pdf_2_markdown_converter.py # One-off: PDF -> Markdown
|
| 99 |
├── data/ # Your documents (gitignored)
|
| 100 |
├── tests/ # Unit tests; no API calls
|
| 101 |
+
├── .github/workflows/tests.yml # CI: runs the suite on push and pull request
|
| 102 |
├── requirements.txt # Runtime
|
| 103 |
├── requirements-ingest.txt # PDF conversion only
|
| 104 |
+
└── requirements-dev.txt # Tests (install on top of requirements.txt)
|
| 105 |
```
|
| 106 |
|
| 107 |
## Graph structure
|
| 108 |
|
| 109 |
+
A preset button routes on its `action`. Free-form chat is classified by a semantic router, which
|
| 110 |
+
decides how much of your profile the question actually needs.
|
| 111 |
|
| 112 |
```mermaid
|
| 113 |
+
flowchart TD
|
| 114 |
+
START([START]) --> DECIDE{action set?}
|
| 115 |
+
DECIDE -->|ats| ATS["ats_analysis<br/>full profile, structured output"]
|
| 116 |
+
DECIDE -->|review, cover_letter| ANSWER["answer<br/>full profile + job description"]
|
| 117 |
+
DECIDE -->|no action| ROUTER{{semantic router}}
|
| 118 |
+
ROUTER -->|trivial| TRIVIAL["trivial_answer<br/>no profile"]
|
| 119 |
+
ROUTER -->|specific| RAG["rag_answer<br/>retrieved excerpts"]
|
| 120 |
+
ROUTER -->|full / uncertain| ANSWER
|
| 121 |
ATS --> FINISH([END])
|
| 122 |
ANSWER --> FINISH
|
| 123 |
+
TRIVIAL --> FINISH
|
| 124 |
+
RAG --> FINISH
|
| 125 |
```
|
| 126 |
|
| 127 |
**State**
|
|
|
|
| 132 |
| `job_description` | The pasted target role. |
|
| 133 |
| `action` | `ats`, `review`, `cover_letter`, or empty for free chat. |
|
| 134 |
|
| 135 |
+
**Nodes**
|
| 136 |
+
|
| 137 |
+
| Node | Context sent | Used for |
|
| 138 |
+
|---|---|---|
|
| 139 |
+
| `answer` | Full profile + job description | Preset review and cover letter, and any broad question |
|
| 140 |
+
| `rag_answer` | Top `RETRIEVAL_TOP_K` excerpts | Narrow factual questions |
|
| 141 |
+
| `trivial_answer` | None | Greetings and pleasantries |
|
| 142 |
+
| `ats_analysis` | Full profile + job description | ATS button only |
|
| 143 |
+
|
| 144 |
+
All three chat nodes replay only the last `MAX_HISTORY_TURNS` turns. Older turns stay in the
|
| 145 |
+
checkpoint but are not resent, so cost per turn stays flat however long the conversation runs.
|
| 146 |
+
|
| 147 |
+
`ats_analysis` asks the model only to classify each job-description keyword as matched or missing.
|
| 148 |
+
The percentage is computed in Python from the list lengths, so the score cannot contradict the
|
| 149 |
+
lists it is derived from.
|
| 150 |
+
|
| 151 |
+
**Routing rules.** Every preset action bypasses the router and receives the full profile: a
|
| 152 |
+
retrieval miss inside an ATS score or a fit review would silently drop real evidence. For free
|
| 153 |
+
chat, the router compares the question against the centroid of reference examples per route
|
| 154 |
+
(`router.py`) and falls back to the full profile whenever the best match is weak or close to the
|
| 155 |
+
runner-up. The asymmetry is deliberate — routing a greeting to the full profile only wastes
|
| 156 |
+
tokens, while routing a real question away from it produces a confident answer with no evidence
|
| 157 |
+
behind it.
|
| 158 |
|
| 159 |
+
**Measured cost per request** (this profile, one narrow question):
|
| 160 |
+
|
| 161 |
+
| Route | Approximate prompt tokens | Saving |
|
| 162 |
+
|---|---|---|
|
| 163 |
+
| `answer` | 4,110 | — |
|
| 164 |
+
| `rag_answer` | 1,028 | 75% |
|
| 165 |
+
| `trivial_answer` | 195 | 95% |
|
| 166 |
|
| 167 |
**Persistence.** A SQLite checkpointer keeps each thread on disk, so conversations survive a
|
| 168 |
restart. "Reset conversation" starts a new thread; the previous one is retained.
|
|
|
|
| 170 |
## Tests
|
| 171 |
|
| 172 |
```bash
|
| 173 |
+
pip install -r requirements-dev.txt # on top of requirements.txt
|
| 174 |
python -m pytest tests/ -q
|
| 175 |
```
|
| 176 |
|
| 177 |
Unit tests only — no API calls, no keys needed. They cover the ATS score arithmetic, config
|
| 178 |
+
validation, document loading, chunking and retrieval, semantic routing, node selection, history
|
| 179 |
+
trimming, temperature selection, and prompt templating. `tests/test_isolation.py` asserts the
|
| 180 |
+
suite never touches your real credentials or your `data/` documents. CI runs the same command on
|
| 181 |
+
every push to `main` and every pull request.
|
| 182 |
|
| 183 |
## Token budget
|
| 184 |
|
| 185 |
+
Groq's free tier allows 8,000 tokens per minute and 200,000 per day for `openai/gpt-oss-120b`
|
| 186 |
+
(check your own limits — they vary by model and account). A full-profile request sends your
|
| 187 |
+
profile (~3,900 tokens) plus the job description plus the reserved response budget, so it costs
|
| 188 |
+
roughly 7,000–8,000 tokens: about 25–28 such requests per day. Routed chat is much cheaper, so
|
| 189 |
+
the real daily count is higher whenever questions take the `rag_answer` or `trivial_answer`
|
| 190 |
+
route. `LLM_MAX_TOKENS` defaults to 3,000 to keep a full-profile request under the per-minute
|
| 191 |
+
ceiling; raising it can push a request past the limit, which Groq rejects with HTTP 413.
|
app.py
CHANGED
|
@@ -78,7 +78,7 @@ def stream_response(chat_history, job_description, thread_id, model_query=None,
|
|
| 78 |
chat_history[-1]["content"] = "_Extracting and scoring keywords…_"
|
| 79 |
yield chat_history
|
| 80 |
result = GRAPH.invoke(state, config)
|
| 81 |
-
chat_history[-1]["content"] = result["messages"][-1].
|
| 82 |
yield chat_history
|
| 83 |
return
|
| 84 |
|
|
|
|
| 78 |
chat_history[-1]["content"] = "_Extracting and scoring keywords…_"
|
| 79 |
yield chat_history
|
| 80 |
result = GRAPH.invoke(state, config)
|
| 81 |
+
chat_history[-1]["content"] = str(result["messages"][-1].text)
|
| 82 |
yield chat_history
|
| 83 |
return
|
| 84 |
|
config.py
CHANGED
|
@@ -13,6 +13,9 @@ PROJECT_ROOT = Path(__file__).parent
|
|
| 13 |
|
| 14 |
DEFAULT_MODEL = "openai/gpt-oss-120b"
|
| 15 |
DEFAULT_TEMPERATURE = 0.0
|
|
|
|
|
|
|
|
|
|
| 16 |
# The preset actions (ATS keyword tables, detailed reviews) run long, so 2048
|
| 17 |
# truncated them mid-answer. The ceiling is Groq's free-tier limit of 8000
|
| 18 |
# tokens per minute, which counts input + max_tokens together: profile (~3.9k)
|
|
@@ -24,6 +27,9 @@ DEFAULT_CHECKPOINT_DB = "checkpoints.sqlite"
|
|
| 24 |
# Turns of conversation replayed to the model. Older turns stay in the
|
| 25 |
# checkpoint (nothing is deleted) but are not resent.
|
| 26 |
DEFAULT_MAX_HISTORY_TURNS = 10
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
|
| 29 |
class ConfigError(RuntimeError):
|
|
@@ -37,8 +43,11 @@ class Settings:
|
|
| 37 |
data_dir: Path
|
| 38 |
model: str
|
| 39 |
temperature: float
|
|
|
|
| 40 |
max_tokens: int
|
| 41 |
max_history_turns: int
|
|
|
|
|
|
|
| 42 |
checkpoint_db: Path
|
| 43 |
langfuse_public_key: str
|
| 44 |
langfuse_secret_key: str
|
|
@@ -102,8 +111,11 @@ def get_settings() -> Settings:
|
|
| 102 |
data_dir=_resolve_data_dir(),
|
| 103 |
model=os.getenv("GROQ_MODEL", "").strip() or DEFAULT_MODEL,
|
| 104 |
temperature=_get_float("LLM_TEMPERATURE", DEFAULT_TEMPERATURE),
|
|
|
|
| 105 |
max_tokens=_get_int("LLM_MAX_TOKENS", DEFAULT_MAX_TOKENS),
|
| 106 |
max_history_turns=_get_int("MAX_HISTORY_TURNS", DEFAULT_MAX_HISTORY_TURNS),
|
|
|
|
|
|
|
| 107 |
checkpoint_db=PROJECT_ROOT / (
|
| 108 |
os.getenv("CHECKPOINT_DB", "").strip() or DEFAULT_CHECKPOINT_DB
|
| 109 |
),
|
|
|
|
| 13 |
|
| 14 |
DEFAULT_MODEL = "openai/gpt-oss-120b"
|
| 15 |
DEFAULT_TEMPERATURE = 0.0
|
| 16 |
+
# Drafting a cover letter is writing, not analysis: a little sampling gives
|
| 17 |
+
# natural prose. Kept low so it does not start inventing detail.
|
| 18 |
+
DEFAULT_DRAFTING_TEMPERATURE = 0.4
|
| 19 |
# The preset actions (ATS keyword tables, detailed reviews) run long, so 2048
|
| 20 |
# truncated them mid-answer. The ceiling is Groq's free-tier limit of 8000
|
| 21 |
# tokens per minute, which counts input + max_tokens together: profile (~3.9k)
|
|
|
|
| 27 |
# Turns of conversation replayed to the model. Older turns stay in the
|
| 28 |
# checkpoint (nothing is deleted) but are not resent.
|
| 29 |
DEFAULT_MAX_HISTORY_TURNS = 10
|
| 30 |
+
# Routing and retrieval. ONNX runtime, so no torch dependency.
|
| 31 |
+
DEFAULT_EMBEDDING_MODEL = "sentence-transformers/all-MiniLM-L6-v2"
|
| 32 |
+
DEFAULT_RETRIEVAL_TOP_K = 5
|
| 33 |
|
| 34 |
|
| 35 |
class ConfigError(RuntimeError):
|
|
|
|
| 43 |
data_dir: Path
|
| 44 |
model: str
|
| 45 |
temperature: float
|
| 46 |
+
drafting_temperature: float
|
| 47 |
max_tokens: int
|
| 48 |
max_history_turns: int
|
| 49 |
+
embedding_model: str
|
| 50 |
+
retrieval_top_k: int
|
| 51 |
checkpoint_db: Path
|
| 52 |
langfuse_public_key: str
|
| 53 |
langfuse_secret_key: str
|
|
|
|
| 111 |
data_dir=_resolve_data_dir(),
|
| 112 |
model=os.getenv("GROQ_MODEL", "").strip() or DEFAULT_MODEL,
|
| 113 |
temperature=_get_float("LLM_TEMPERATURE", DEFAULT_TEMPERATURE),
|
| 114 |
+
drafting_temperature=_get_float("LLM_DRAFTING_TEMPERATURE", DEFAULT_DRAFTING_TEMPERATURE),
|
| 115 |
max_tokens=_get_int("LLM_MAX_TOKENS", DEFAULT_MAX_TOKENS),
|
| 116 |
max_history_turns=_get_int("MAX_HISTORY_TURNS", DEFAULT_MAX_HISTORY_TURNS),
|
| 117 |
+
embedding_model=os.getenv("EMBEDDING_MODEL", "").strip() or DEFAULT_EMBEDDING_MODEL,
|
| 118 |
+
retrieval_top_k=_get_int("RETRIEVAL_TOP_K", DEFAULT_RETRIEVAL_TOP_K),
|
| 119 |
checkpoint_db=PROJECT_ROOT / (
|
| 120 |
os.getenv("CHECKPOINT_DB", "").strip() or DEFAULT_CHECKPOINT_DB
|
| 121 |
),
|
embeddings.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Sentence embeddings via fastembed (ONNX runtime, no torch dependency).
|
| 2 |
+
|
| 3 |
+
Vectors come back L2-normalised, so a dot product is cosine similarity.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from functools import lru_cache
|
| 7 |
+
|
| 8 |
+
import numpy as np
|
| 9 |
+
|
| 10 |
+
from config import get_settings
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@lru_cache(maxsize=1)
|
| 14 |
+
def _model():
|
| 15 |
+
from fastembed import TextEmbedding
|
| 16 |
+
|
| 17 |
+
return TextEmbedding(get_settings().embedding_model)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def embed(texts) -> np.ndarray:
|
| 21 |
+
"""Embed a sequence of strings into a (n, dim) float32 matrix."""
|
| 22 |
+
texts = list(texts)
|
| 23 |
+
if not texts:
|
| 24 |
+
return np.zeros((0, 0), dtype=np.float32)
|
| 25 |
+
return np.asarray(list(_model().embed(texts)), dtype=np.float32)
|
graph.py
CHANGED
|
@@ -1,12 +1,13 @@
|
|
| 1 |
"""LangGraph application graph.
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
rewrite.
|
| 7 |
"""
|
| 8 |
|
|
|
|
| 9 |
import sqlite3
|
|
|
|
| 10 |
from typing import Annotated, TypedDict
|
| 11 |
|
| 12 |
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, trim_messages
|
|
@@ -15,10 +16,22 @@ from langgraph.checkpoint.sqlite import SqliteSaver
|
|
| 15 |
from langgraph.graph import START, StateGraph
|
| 16 |
from langgraph.graph.message import add_messages
|
| 17 |
|
|
|
|
| 18 |
from ats import ATSAnalysis, format_ats_report
|
| 19 |
from config import get_settings
|
| 20 |
from dataloader import ProfileLoader
|
| 21 |
-
from
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
|
| 24 |
class ChatState(TypedDict):
|
|
@@ -29,17 +42,54 @@ class ChatState(TypedDict):
|
|
| 29 |
action: str
|
| 30 |
|
| 31 |
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
|
| 36 |
|
| 37 |
-
def build_llm():
|
| 38 |
settings = get_settings()
|
| 39 |
return ChatGroq(
|
| 40 |
api_key=settings.groq_api_key,
|
| 41 |
model=settings.model,
|
| 42 |
-
temperature=settings.temperature,
|
| 43 |
max_tokens=settings.max_tokens,
|
| 44 |
)
|
| 45 |
|
|
@@ -47,16 +97,25 @@ def build_llm():
|
|
| 47 |
def build_graph():
|
| 48 |
settings = get_settings()
|
| 49 |
profile_data = ProfileLoader(settings.data_dir).load_and_format_markdown()
|
| 50 |
-
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
|
| 53 |
# Two messages per turn (human + ai).
|
| 54 |
max_history_messages = settings.max_history_turns * 2
|
| 55 |
|
| 56 |
-
def
|
| 57 |
# Older turns remain in the checkpoint; they are simply not resent,
|
| 58 |
# which keeps per-turn cost flat instead of growing with the thread.
|
| 59 |
-
|
| 60 |
state["messages"][:-1],
|
| 61 |
max_tokens=max_history_messages,
|
| 62 |
token_counter=len,
|
|
@@ -64,12 +123,32 @@ def build_graph():
|
|
| 64 |
start_on="human",
|
| 65 |
include_system=False,
|
| 66 |
)
|
| 67 |
-
|
| 68 |
-
|
|
|
|
|
|
|
| 69 |
"context_text": profile_data,
|
| 70 |
"job_description": state.get("job_description") or NO_JD_PLACEHOLDER,
|
| 71 |
-
"user_query":
|
| 72 |
-
"history":
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
})
|
| 74 |
return {"messages": [response]}
|
| 75 |
|
|
@@ -77,14 +156,20 @@ def build_graph():
|
|
| 77 |
"""Classify keywords with structured output, then score them in Python."""
|
| 78 |
analysis = structured_llm.invoke([
|
| 79 |
SystemMessage(content=render_system_prompt(profile_data, state.get("job_description"))),
|
| 80 |
-
HumanMessage(content=state["messages"]
|
| 81 |
])
|
| 82 |
return {"messages": [AIMessage(content=format_ats_report(analysis))]}
|
| 83 |
|
| 84 |
builder = StateGraph(ChatState)
|
| 85 |
builder.add_node("answer", answer)
|
|
|
|
|
|
|
| 86 |
builder.add_node("ats_analysis", ats_analysis)
|
| 87 |
-
builder.add_conditional_edges(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
|
| 89 |
# check_same_thread=False: Gradio serves requests from a pool of threads.
|
| 90 |
conn = sqlite3.connect(str(settings.checkpoint_db), check_same_thread=False)
|
|
|
|
| 1 |
"""LangGraph application graph.
|
| 2 |
|
| 3 |
+
Preset actions route by their action name and always receive the full profile.
|
| 4 |
+
Free-form chat is classified by the semantic router, which decides whether the
|
| 5 |
+
question needs no profile, retrieved excerpts, or all of it.
|
|
|
|
| 6 |
"""
|
| 7 |
|
| 8 |
+
import logging
|
| 9 |
import sqlite3
|
| 10 |
+
from functools import partial
|
| 11 |
from typing import Annotated, TypedDict
|
| 12 |
|
| 13 |
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, trim_messages
|
|
|
|
| 16 |
from langgraph.graph import START, StateGraph
|
| 17 |
from langgraph.graph.message import add_messages
|
| 18 |
|
| 19 |
+
import router as router_module
|
| 20 |
from ats import ATSAnalysis, format_ats_report
|
| 21 |
from config import get_settings
|
| 22 |
from dataloader import ProfileLoader
|
| 23 |
+
from embeddings import embed
|
| 24 |
+
from prompts import (
|
| 25 |
+
NO_JD_PLACEHOLDER,
|
| 26 |
+
build_qa_prompt,
|
| 27 |
+
build_rag_prompt,
|
| 28 |
+
build_trivial_prompt,
|
| 29 |
+
render_system_prompt,
|
| 30 |
+
)
|
| 31 |
+
from retrieval import ProfileIndex, chunk_documents
|
| 32 |
+
from router import SemanticRouter
|
| 33 |
+
|
| 34 |
+
logger = logging.getLogger(__name__)
|
| 35 |
|
| 36 |
|
| 37 |
class ChatState(TypedDict):
|
|
|
|
| 42 |
action: str
|
| 43 |
|
| 44 |
|
| 45 |
+
NODE_BY_ROUTE = {
|
| 46 |
+
router_module.TRIVIAL: "trivial_answer",
|
| 47 |
+
router_module.SPECIFIC: "rag_answer",
|
| 48 |
+
router_module.FULL: "answer",
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def latest_text(messages) -> str:
|
| 53 |
+
"""Text of the most recent message.
|
| 54 |
+
|
| 55 |
+
Message content survives a checkpoint round trip as a list of content
|
| 56 |
+
blocks rather than a plain string, so `.content` cannot be used directly.
|
| 57 |
+
`.text` returns a str for either shape.
|
| 58 |
+
"""
|
| 59 |
+
if not messages:
|
| 60 |
+
return ""
|
| 61 |
+
return str(messages[-1].text)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def route_action(state, semantic_router=None) -> str:
|
| 65 |
+
"""Pick the node for this turn.
|
| 66 |
+
|
| 67 |
+
Preset actions always get the full profile: a retrieval miss would silently
|
| 68 |
+
corrupt an ATS score or a fit review. Only free-form chat is routed.
|
| 69 |
+
"""
|
| 70 |
+
action = state.get("action")
|
| 71 |
+
if action == "ats":
|
| 72 |
+
return "ats_analysis"
|
| 73 |
+
if action:
|
| 74 |
+
return "answer"
|
| 75 |
+
if semantic_router is None:
|
| 76 |
+
return "answer"
|
| 77 |
+
|
| 78 |
+
messages = state.get("messages") or []
|
| 79 |
+
query = latest_text(messages)
|
| 80 |
+
return NODE_BY_ROUTE[semantic_router.route(query)]
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
# Actions that write prose rather than analyse, and so use a higher temperature.
|
| 84 |
+
DRAFTING_ACTIONS = {"cover_letter"}
|
| 85 |
|
| 86 |
|
| 87 |
+
def build_llm(temperature=None):
|
| 88 |
settings = get_settings()
|
| 89 |
return ChatGroq(
|
| 90 |
api_key=settings.groq_api_key,
|
| 91 |
model=settings.model,
|
| 92 |
+
temperature=settings.temperature if temperature is None else temperature,
|
| 93 |
max_tokens=settings.max_tokens,
|
| 94 |
)
|
| 95 |
|
|
|
|
| 97 |
def build_graph():
|
| 98 |
settings = get_settings()
|
| 99 |
profile_data = ProfileLoader(settings.data_dir).load_and_format_markdown()
|
| 100 |
+
prompt = build_qa_prompt()
|
| 101 |
+
llm = build_llm()
|
| 102 |
+
chain = prompt | llm
|
| 103 |
+
drafting_chain = prompt | build_llm(settings.drafting_temperature)
|
| 104 |
+
rag_chain = build_rag_prompt() | llm
|
| 105 |
+
trivial_chain = build_trivial_prompt() | llm
|
| 106 |
+
structured_llm = llm.with_structured_output(ATSAnalysis)
|
| 107 |
+
|
| 108 |
+
index = ProfileIndex(chunk_documents(profile_data), embed)
|
| 109 |
+
semantic_router = SemanticRouter(embed)
|
| 110 |
+
logger.info("Indexed %d profile chunks for retrieval.", len(index.chunks))
|
| 111 |
|
| 112 |
# Two messages per turn (human + ai).
|
| 113 |
max_history_messages = settings.max_history_turns * 2
|
| 114 |
|
| 115 |
+
def recent_history(state):
|
| 116 |
# Older turns remain in the checkpoint; they are simply not resent,
|
| 117 |
# which keeps per-turn cost flat instead of growing with the thread.
|
| 118 |
+
return trim_messages(
|
| 119 |
state["messages"][:-1],
|
| 120 |
max_tokens=max_history_messages,
|
| 121 |
token_counter=len,
|
|
|
|
| 123 |
start_on="human",
|
| 124 |
include_system=False,
|
| 125 |
)
|
| 126 |
+
|
| 127 |
+
def answer(state: ChatState):
|
| 128 |
+
active = drafting_chain if state.get("action") in DRAFTING_ACTIONS else chain
|
| 129 |
+
response = active.invoke({
|
| 130 |
"context_text": profile_data,
|
| 131 |
"job_description": state.get("job_description") or NO_JD_PLACEHOLDER,
|
| 132 |
+
"user_query": latest_text(state["messages"]),
|
| 133 |
+
"history": recent_history(state),
|
| 134 |
+
})
|
| 135 |
+
return {"messages": [response]}
|
| 136 |
+
|
| 137 |
+
def rag_answer(state: ChatState):
|
| 138 |
+
"""Narrow question: send only the excerpts that match it."""
|
| 139 |
+
query = latest_text(state["messages"])
|
| 140 |
+
response = rag_chain.invoke({
|
| 141 |
+
"context_text": index.search_text(query, k=settings.retrieval_top_k),
|
| 142 |
+
"user_query": query,
|
| 143 |
+
"history": recent_history(state),
|
| 144 |
+
})
|
| 145 |
+
return {"messages": [response]}
|
| 146 |
+
|
| 147 |
+
def trivial_answer(state: ChatState):
|
| 148 |
+
"""Greeting or pleasantry: no profile at all."""
|
| 149 |
+
response = trivial_chain.invoke({
|
| 150 |
+
"user_query": latest_text(state["messages"]),
|
| 151 |
+
"history": recent_history(state),
|
| 152 |
})
|
| 153 |
return {"messages": [response]}
|
| 154 |
|
|
|
|
| 156 |
"""Classify keywords with structured output, then score them in Python."""
|
| 157 |
analysis = structured_llm.invoke([
|
| 158 |
SystemMessage(content=render_system_prompt(profile_data, state.get("job_description"))),
|
| 159 |
+
HumanMessage(content=latest_text(state["messages"])),
|
| 160 |
])
|
| 161 |
return {"messages": [AIMessage(content=format_ats_report(analysis))]}
|
| 162 |
|
| 163 |
builder = StateGraph(ChatState)
|
| 164 |
builder.add_node("answer", answer)
|
| 165 |
+
builder.add_node("rag_answer", rag_answer)
|
| 166 |
+
builder.add_node("trivial_answer", trivial_answer)
|
| 167 |
builder.add_node("ats_analysis", ats_analysis)
|
| 168 |
+
builder.add_conditional_edges(
|
| 169 |
+
START,
|
| 170 |
+
partial(route_action, semantic_router=semantic_router),
|
| 171 |
+
["answer", "rag_answer", "trivial_answer", "ats_analysis"],
|
| 172 |
+
)
|
| 173 |
|
| 174 |
# check_same_thread=False: Gradio serves requests from a pool of threads.
|
| 175 |
conn = sqlite3.connect(str(settings.checkpoint_db), check_same_thread=False)
|
prompts.py
CHANGED
|
@@ -61,6 +61,46 @@ If the documents genuinely do not cover something, say so directly and stop. "Th
|
|
| 61 |
"""
|
| 62 |
|
| 63 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
def render_system_prompt(context_text, job_description) -> str:
|
| 65 |
"""The system prompt as plain text, for paths that bypass the chat template."""
|
| 66 |
return QA_SYSTEM_PROMPT.format(
|
|
@@ -70,14 +110,29 @@ def render_system_prompt(context_text, job_description) -> str:
|
|
| 70 |
)
|
| 71 |
|
| 72 |
|
| 73 |
-
def
|
| 74 |
return ChatPromptTemplate.from_messages([
|
| 75 |
-
("system",
|
| 76 |
MessagesPlaceholder("history"),
|
| 77 |
("user", "{user_query}"),
|
| 78 |
]).partial(candidate_name=get_settings().candidate_name)
|
| 79 |
|
| 80 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
# ---------------------------------------------------------------------------
|
| 82 |
# Preset actions.
|
| 83 |
#
|
|
|
|
| 61 |
"""
|
| 62 |
|
| 63 |
|
| 64 |
+
TRIVIAL_SYSTEM_PROMPT = """You are the interview-preparation assistant for {candidate_name}. The user has sent a greeting, a pleasantry, or a question about what you do — nothing that requires their career documents, which is why you have not been given them.
|
| 65 |
+
|
| 66 |
+
Reply in one or two short sentences. Be warm but brief, and do not pad.
|
| 67 |
+
|
| 68 |
+
If the user asks what you can do, say that you can answer questions about their background, analyse how well they match a job description they paste in, draft interview answers, and write cover letters.
|
| 69 |
+
|
| 70 |
+
You do not have their documents in front of you for this message, so make no claims about their experience, skills, or history. If the message turns out to need that detail, say you will need to look at their profile and invite them to ask directly."""
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
RAG_SYSTEM_PROMPT = """You are the interview-preparation assistant for {candidate_name}, referred to below as "the candidate". The user asked a narrow question, so you have been given the excerpts from their career documents that best match it — not the complete profile.
|
| 74 |
+
|
| 75 |
+
## What the excerpts are
|
| 76 |
+
|
| 77 |
+
They are verbatim passages from the candidate's resume, project write-ups, and volunteer experience, each tagged with its source document. They are the only evidence you have.
|
| 78 |
+
|
| 79 |
+
## Grounding rules
|
| 80 |
+
|
| 81 |
+
- STATED: written in the excerpts. Assert it freely.
|
| 82 |
+
- INFERRED: a reasonable read of what the excerpts say. Allowed, but mark it as inference.
|
| 83 |
+
- UNSUPPORTED: anything else. Never assert it.
|
| 84 |
+
|
| 85 |
+
## The distinction that matters here
|
| 86 |
+
|
| 87 |
+
You are working from a subset of the candidate's documents, so absence from these excerpts is not proof of absence from their record. Never say a skill or experience does not exist. Say instead that it does not appear in the excerpts you were given, and suggest they ask again for a full review if they want certainty. Wrongly telling someone they lack experience they actually have is the worst outcome here.
|
| 88 |
+
|
| 89 |
+
If the excerpts do not address the question at all, say so plainly and stop.
|
| 90 |
+
|
| 91 |
+
## Style
|
| 92 |
+
|
| 93 |
+
- Answer directly, in one short paragraph unless the question needs more.
|
| 94 |
+
- Name the specific project, role, or skill you are drawing on.
|
| 95 |
+
- No preamble, no praise, no restating the question.
|
| 96 |
+
|
| 97 |
+
## Retrieved excerpts
|
| 98 |
+
|
| 99 |
+
<excerpts>
|
| 100 |
+
{context_text}
|
| 101 |
+
</excerpts>"""
|
| 102 |
+
|
| 103 |
+
|
| 104 |
def render_system_prompt(context_text, job_description) -> str:
|
| 105 |
"""The system prompt as plain text, for paths that bypass the chat template."""
|
| 106 |
return QA_SYSTEM_PROMPT.format(
|
|
|
|
| 110 |
)
|
| 111 |
|
| 112 |
|
| 113 |
+
def _chat_prompt(system_text) -> ChatPromptTemplate:
|
| 114 |
return ChatPromptTemplate.from_messages([
|
| 115 |
+
("system", system_text),
|
| 116 |
MessagesPlaceholder("history"),
|
| 117 |
("user", "{user_query}"),
|
| 118 |
]).partial(candidate_name=get_settings().candidate_name)
|
| 119 |
|
| 120 |
|
| 121 |
+
def build_qa_prompt() -> ChatPromptTemplate:
|
| 122 |
+
"""Full profile plus job description."""
|
| 123 |
+
return _chat_prompt(QA_SYSTEM_PROMPT)
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def build_rag_prompt() -> ChatPromptTemplate:
|
| 127 |
+
"""Retrieved excerpts only, for narrowly scoped questions."""
|
| 128 |
+
return _chat_prompt(RAG_SYSTEM_PROMPT)
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def build_trivial_prompt() -> ChatPromptTemplate:
|
| 132 |
+
"""No profile at all, for greetings and pleasantries."""
|
| 133 |
+
return _chat_prompt(TRIVIAL_SYSTEM_PROMPT)
|
| 134 |
+
|
| 135 |
+
|
| 136 |
# ---------------------------------------------------------------------------
|
| 137 |
# Preset actions.
|
| 138 |
#
|
requirements.txt
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
|
|
| 1 |
gradio==6.22.0
|
| 2 |
langchain==1.3.14
|
| 3 |
langchain-core==1.5.1
|
|
|
|
| 1 |
+
fastembed==0.8.0
|
| 2 |
gradio==6.22.0
|
| 3 |
langchain==1.3.14
|
| 4 |
langchain-core==1.5.1
|
retrieval.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Retrieval over the profile documents.
|
| 2 |
+
|
| 3 |
+
The corpus is three documents and a few dozen chunks, so similarity is computed
|
| 4 |
+
directly against an in-memory matrix. A vector database would add a dependency
|
| 5 |
+
and an external service without changing the result at this size.
|
| 6 |
+
|
| 7 |
+
Retrieval is used only for narrowly scoped questions. The preset actions always
|
| 8 |
+
receive the full profile, because a retrieval miss there would silently corrupt
|
| 9 |
+
an ATS score or a fit review.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import numpy as np
|
| 13 |
+
|
| 14 |
+
from dataloader import SEPARATOR
|
| 15 |
+
|
| 16 |
+
TARGET_CHUNK_CHARS = 600
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def chunk_documents(profile_text, target_chars=TARGET_CHUNK_CHARS):
|
| 20 |
+
"""Split the profile into retrievable chunks, each labelled with its source."""
|
| 21 |
+
chunks = []
|
| 22 |
+
|
| 23 |
+
for document in profile_text.split(SEPARATOR):
|
| 24 |
+
document = document.strip()
|
| 25 |
+
if not document:
|
| 26 |
+
continue
|
| 27 |
+
|
| 28 |
+
lines = document.splitlines()
|
| 29 |
+
source = lines[0].removeprefix("# Source:").strip() if lines[0].startswith("# Source:") else ""
|
| 30 |
+
body = "\n".join(lines[1:]).strip() if source else document
|
| 31 |
+
|
| 32 |
+
current = []
|
| 33 |
+
length = 0
|
| 34 |
+
for paragraph in (p.strip() for p in body.split("\n\n")):
|
| 35 |
+
if not paragraph:
|
| 36 |
+
continue
|
| 37 |
+
if current and length + len(paragraph) > target_chars:
|
| 38 |
+
chunks.append(_label(source, current))
|
| 39 |
+
current, length = [], 0
|
| 40 |
+
current.append(paragraph)
|
| 41 |
+
length += len(paragraph)
|
| 42 |
+
|
| 43 |
+
if current:
|
| 44 |
+
chunks.append(_label(source, current))
|
| 45 |
+
|
| 46 |
+
return chunks
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _label(source, paragraphs):
|
| 50 |
+
body = "\n\n".join(paragraphs)
|
| 51 |
+
return f"[{source}]\n{body}" if source else body
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class ProfileIndex:
|
| 55 |
+
def __init__(self, chunks, embed_fn):
|
| 56 |
+
self.chunks = list(chunks)
|
| 57 |
+
if self.chunks:
|
| 58 |
+
vectors = embed_fn(self.chunks)
|
| 59 |
+
norms = np.linalg.norm(vectors, axis=1, keepdims=True)
|
| 60 |
+
self.vectors = vectors / np.where(norms == 0, 1, norms)
|
| 61 |
+
else:
|
| 62 |
+
self.vectors = np.zeros((0, 0), dtype=np.float32)
|
| 63 |
+
self.embed_fn = embed_fn
|
| 64 |
+
|
| 65 |
+
def search(self, query, k=5):
|
| 66 |
+
"""Return the k chunks most similar to the query, best first."""
|
| 67 |
+
if not self.chunks or not query.strip():
|
| 68 |
+
return []
|
| 69 |
+
|
| 70 |
+
vector = self.embed_fn([query])[0]
|
| 71 |
+
norm = np.linalg.norm(vector)
|
| 72 |
+
if norm:
|
| 73 |
+
vector = vector / norm
|
| 74 |
+
|
| 75 |
+
similarities = self.vectors @ vector
|
| 76 |
+
top = np.argsort(similarities)[::-1][:k]
|
| 77 |
+
return [self.chunks[i] for i in top]
|
| 78 |
+
|
| 79 |
+
def search_text(self, query, k=5):
|
| 80 |
+
found = self.search(query, k)
|
| 81 |
+
return "\n\n---\n\n".join(found) if found else "(No relevant excerpts found.)"
|
router.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Three-way semantic router.
|
| 2 |
+
|
| 3 |
+
Queries are compared against the centroid of a few reference examples per
|
| 4 |
+
route. Routing is deliberately asymmetric: sending a real question to a route
|
| 5 |
+
without the profile produces a confident answer about the candidate with no
|
| 6 |
+
evidence behind it, while sending a greeting to the full profile merely wastes
|
| 7 |
+
tokens. So anything short of a clear, confident match falls back to FULL.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import numpy as np
|
| 11 |
+
|
| 12 |
+
TRIVIAL = "trivial"
|
| 13 |
+
SPECIFIC = "specific"
|
| 14 |
+
FULL = "full"
|
| 15 |
+
|
| 16 |
+
REFERENCE_QUERIES = {
|
| 17 |
+
TRIVIAL: [
|
| 18 |
+
"hello",
|
| 19 |
+
"hi there",
|
| 20 |
+
"hey",
|
| 21 |
+
"how are you?",
|
| 22 |
+
"thanks!",
|
| 23 |
+
"thank you",
|
| 24 |
+
"good morning",
|
| 25 |
+
"good evening",
|
| 26 |
+
"who are you?",
|
| 27 |
+
"what can you do?",
|
| 28 |
+
"bye",
|
| 29 |
+
],
|
| 30 |
+
SPECIFIC: [
|
| 31 |
+
"do I know Rust?",
|
| 32 |
+
"what is my Python experience?",
|
| 33 |
+
"have I used Kubernetes?",
|
| 34 |
+
"which projects used FAISS?",
|
| 35 |
+
"tell me about my volunteer work",
|
| 36 |
+
"what certifications do I have?",
|
| 37 |
+
"where did I use Docker?",
|
| 38 |
+
"what did I do at my last job?",
|
| 39 |
+
"which languages do I know?",
|
| 40 |
+
],
|
| 41 |
+
FULL: [
|
| 42 |
+
"am I a fit for this role?",
|
| 43 |
+
"what are my weakest areas?",
|
| 44 |
+
"how should I position myself?",
|
| 45 |
+
"summarise my background",
|
| 46 |
+
"what should I emphasise in the interview?",
|
| 47 |
+
"what gaps do I have for this job?",
|
| 48 |
+
"what do I bring to the table?",
|
| 49 |
+
"write me a cover letter",
|
| 50 |
+
"how do I compare to this job description?",
|
| 51 |
+
"what will they push back on?",
|
| 52 |
+
],
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
# Tuned against the reference set: correct routes score 0.38-0.60 with margins
|
| 56 |
+
# above 0.25, so these thresholds reject genuinely ambiguous queries only.
|
| 57 |
+
MIN_SCORE = 0.35
|
| 58 |
+
MIN_MARGIN = 0.10
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class SemanticRouter:
|
| 62 |
+
def __init__(self, embed_fn, references=None, min_score=MIN_SCORE, min_margin=MIN_MARGIN):
|
| 63 |
+
self.min_score = min_score
|
| 64 |
+
self.min_margin = min_margin
|
| 65 |
+
references = references or REFERENCE_QUERIES
|
| 66 |
+
|
| 67 |
+
self.routes = list(references)
|
| 68 |
+
centroids = np.stack([embed_fn(references[route]).mean(axis=0) for route in self.routes])
|
| 69 |
+
norms = np.linalg.norm(centroids, axis=1, keepdims=True)
|
| 70 |
+
self.centroids = centroids / np.where(norms == 0, 1, norms)
|
| 71 |
+
self.embed_fn = embed_fn
|
| 72 |
+
|
| 73 |
+
def scores(self, query) -> dict:
|
| 74 |
+
vector = self.embed_fn([query])[0]
|
| 75 |
+
norm = np.linalg.norm(vector)
|
| 76 |
+
if norm:
|
| 77 |
+
vector = vector / norm
|
| 78 |
+
return dict(zip(self.routes, (self.centroids @ vector).tolist()))
|
| 79 |
+
|
| 80 |
+
def route(self, query) -> str:
|
| 81 |
+
# Defensive: an unroutable query must never crash the turn.
|
| 82 |
+
if not isinstance(query, str) or not query.strip():
|
| 83 |
+
return FULL
|
| 84 |
+
|
| 85 |
+
scores = self.scores(query)
|
| 86 |
+
ranked = sorted(scores.items(), key=lambda item: item[1], reverse=True)
|
| 87 |
+
(winner, top), (_, runner_up) = ranked[0], ranked[1]
|
| 88 |
+
|
| 89 |
+
if winner == FULL:
|
| 90 |
+
return FULL
|
| 91 |
+
# Leaving the profile behind requires confidence; FULL is the safe default.
|
| 92 |
+
if top < self.min_score or (top - runner_up) < self.min_margin:
|
| 93 |
+
return FULL
|
| 94 |
+
return winner
|
tests/conftest.py
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import sys
|
|
|
|
| 2 |
from pathlib import Path
|
| 3 |
|
| 4 |
-
# Tests import the application modules from the project root.
|
| 5 |
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Test bootstrap.
|
| 2 |
+
|
| 3 |
+
The environment is pinned here before any application module is imported, so
|
| 4 |
+
the suite never reads the developer's real .env or personal documents and runs
|
| 5 |
+
identically on a clean CI machine.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import atexit
|
| 9 |
+
import os
|
| 10 |
+
import shutil
|
| 11 |
import sys
|
| 12 |
+
import tempfile
|
| 13 |
from pathlib import Path
|
| 14 |
|
|
|
|
| 15 |
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 16 |
+
|
| 17 |
+
_FIXTURE_DIR = tempfile.mkdtemp(prefix="job-chat-tests-")
|
| 18 |
+
(Path(_FIXTURE_DIR) / "fixture.md").write_text("# Fixture profile", encoding="utf-8")
|
| 19 |
+
atexit.register(shutil.rmtree, _FIXTURE_DIR, True)
|
| 20 |
+
|
| 21 |
+
# load_dotenv() does not override variables that are already set, so these win
|
| 22 |
+
# over any real .env on a developer machine.
|
| 23 |
+
os.environ["GROQ_API_KEY"] = "test-key-not-used"
|
| 24 |
+
os.environ["DATA_DIR"] = _FIXTURE_DIR
|
| 25 |
+
os.environ["CANDIDATE_NAME"] = "Test Candidate"
|
| 26 |
+
# Set to empty rather than deleted: load_dotenv() skips keys already present, so
|
| 27 |
+
# an empty value keeps real Langfuse credentials out and stops the suite emitting
|
| 28 |
+
# traces to a live project.
|
| 29 |
+
for _optional in ("LANGFUSE_PUBLIC_KEY", "LANGFUSE_SECRET_KEY", "LANGFUSE_HOST", "LANGFUSE_BASE_URL"):
|
| 30 |
+
os.environ[_optional] = ""
|
tests/test_config.py
CHANGED
|
@@ -73,9 +73,16 @@ def test_overrides_are_read_from_the_environment(base_env, monkeypatch):
|
|
| 73 |
assert settings.max_history_turns == 3
|
| 74 |
|
| 75 |
|
| 76 |
-
def test_relative_data_dir_resolves_against_the_project_root(base_env, monkeypatch):
|
| 77 |
-
|
| 78 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
|
| 80 |
|
| 81 |
class TestLangfuseHost:
|
|
|
|
| 73 |
assert settings.max_history_turns == 3
|
| 74 |
|
| 75 |
|
| 76 |
+
def test_relative_data_dir_resolves_against_the_project_root(base_env, monkeypatch, tmp_path):
|
| 77 |
+
"""A relative DATA_DIR must not depend on the process working directory."""
|
| 78 |
+
project_root = tmp_path / "project"
|
| 79 |
+
(project_root / "docs").mkdir(parents=True)
|
| 80 |
+
(project_root / "docs" / "resume.md").write_text("# Resume", encoding="utf-8")
|
| 81 |
+
|
| 82 |
+
monkeypatch.setattr(config, "PROJECT_ROOT", project_root)
|
| 83 |
+
monkeypatch.setenv("DATA_DIR", "docs")
|
| 84 |
+
|
| 85 |
+
assert get_settings().data_dir == project_root / "docs"
|
| 86 |
|
| 87 |
|
| 88 |
class TestLangfuseHost:
|
tests/test_graph.py
CHANGED
|
@@ -3,7 +3,7 @@
|
|
| 3 |
import pytest
|
| 4 |
from langchain_core.messages import AIMessage, HumanMessage, trim_messages
|
| 5 |
|
| 6 |
-
from graph import route_action
|
| 7 |
|
| 8 |
|
| 9 |
@pytest.mark.parametrize(
|
|
@@ -14,7 +14,6 @@ from graph import route_action
|
|
| 14 |
("cover_letter", "answer"),
|
| 15 |
("", "answer"),
|
| 16 |
(None, "answer"),
|
| 17 |
-
("unknown", "answer"),
|
| 18 |
],
|
| 19 |
)
|
| 20 |
def test_only_ats_routes_to_structured_output(action, expected):
|
|
@@ -25,6 +24,87 @@ def test_missing_action_key_falls_back_to_chat():
|
|
| 25 |
assert route_action({}) == "answer"
|
| 26 |
|
| 27 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
def conversation(turns):
|
| 29 |
messages = []
|
| 30 |
for i in range(turns):
|
|
|
|
| 3 |
import pytest
|
| 4 |
from langchain_core.messages import AIMessage, HumanMessage, trim_messages
|
| 5 |
|
| 6 |
+
from graph import latest_text, route_action
|
| 7 |
|
| 8 |
|
| 9 |
@pytest.mark.parametrize(
|
|
|
|
| 14 |
("cover_letter", "answer"),
|
| 15 |
("", "answer"),
|
| 16 |
(None, "answer"),
|
|
|
|
| 17 |
],
|
| 18 |
)
|
| 19 |
def test_only_ats_routes_to_structured_output(action, expected):
|
|
|
|
| 24 |
assert route_action({}) == "answer"
|
| 25 |
|
| 26 |
|
| 27 |
+
class StubRouter:
|
| 28 |
+
def __init__(self, decision):
|
| 29 |
+
self.decision = decision
|
| 30 |
+
self.seen = []
|
| 31 |
+
|
| 32 |
+
def route(self, query):
|
| 33 |
+
self.seen.append(query)
|
| 34 |
+
return self.decision
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@pytest.mark.parametrize(
|
| 38 |
+
"decision,expected",
|
| 39 |
+
[("trivial", "trivial_answer"), ("specific", "rag_answer"), ("full", "answer")],
|
| 40 |
+
)
|
| 41 |
+
def test_free_chat_follows_the_semantic_router(decision, expected):
|
| 42 |
+
state = {"action": "", "messages": [HumanMessage(content="a question")]}
|
| 43 |
+
assert route_action(state, semantic_router=StubRouter(decision)) == expected
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
@pytest.mark.parametrize("action", ["review", "cover_letter"])
|
| 47 |
+
def test_preset_actions_bypass_the_router_and_keep_full_context(action):
|
| 48 |
+
"""A retrieval miss inside a review or cover letter would drop real evidence."""
|
| 49 |
+
stub = StubRouter("trivial")
|
| 50 |
+
state = {"action": action, "messages": [HumanMessage(content="hello")]}
|
| 51 |
+
assert route_action(state, semantic_router=stub) == "answer"
|
| 52 |
+
assert stub.seen == []
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def test_the_ats_action_never_reaches_the_router():
|
| 56 |
+
"""Retrieval could omit a skill and silently corrupt the score."""
|
| 57 |
+
stub = StubRouter("specific")
|
| 58 |
+
state = {"action": "ats", "messages": [HumanMessage(content="hi")]}
|
| 59 |
+
assert route_action(state, semantic_router=stub) == "ats_analysis"
|
| 60 |
+
assert stub.seen == []
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def test_the_router_sees_the_latest_message():
|
| 64 |
+
stub = StubRouter("full")
|
| 65 |
+
state = {"action": "", "messages": [HumanMessage(content="old"), HumanMessage(content="newest")]}
|
| 66 |
+
route_action(state, semantic_router=stub)
|
| 67 |
+
assert stub.seen == ["newest"]
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
class TestContentBlocks:
|
| 71 |
+
"""Checkpointed messages come back with list content, not a plain string.
|
| 72 |
+
|
| 73 |
+
Reading .content directly crashed the first real conversation with
|
| 74 |
+
'list object has no attribute strip'.
|
| 75 |
+
"""
|
| 76 |
+
|
| 77 |
+
def test_latest_text_reads_plain_string_content(self):
|
| 78 |
+
assert latest_text([HumanMessage(content="hello")]) == "hello"
|
| 79 |
+
|
| 80 |
+
def test_latest_text_reads_block_content(self):
|
| 81 |
+
blocks = [HumanMessage(content=[{"type": "text", "text": "hi there"}])]
|
| 82 |
+
assert latest_text(blocks) == "hi there"
|
| 83 |
+
|
| 84 |
+
def test_latest_text_returns_a_real_string(self):
|
| 85 |
+
text = latest_text([HumanMessage(content=[{"type": "text", "text": "x"}])])
|
| 86 |
+
assert type(text) is str
|
| 87 |
+
assert text.strip() == "x"
|
| 88 |
+
|
| 89 |
+
def test_latest_text_handles_no_messages(self):
|
| 90 |
+
assert latest_text([]) == ""
|
| 91 |
+
|
| 92 |
+
def test_routing_survives_block_content(self):
|
| 93 |
+
stub = StubRouter("specific")
|
| 94 |
+
state = {"action": "", "messages": [HumanMessage(content=[{"type": "text", "text": "do I know Rust?"}])]}
|
| 95 |
+
assert route_action(state, semantic_router=stub) == "rag_answer"
|
| 96 |
+
assert stub.seen == ["do I know Rust?"]
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def test_router_rejects_non_string_queries_without_crashing():
|
| 100 |
+
from router import FULL, SemanticRouter
|
| 101 |
+
from tests.test_router import REFERENCES, fake_embed
|
| 102 |
+
|
| 103 |
+
router = SemanticRouter(fake_embed, references=REFERENCES)
|
| 104 |
+
for bad in ([{"type": "text", "text": "hi"}], None, 42):
|
| 105 |
+
assert router.route(bad) == FULL
|
| 106 |
+
|
| 107 |
+
|
| 108 |
def conversation(turns):
|
| 109 |
messages = []
|
| 110 |
for i in range(turns):
|
tests/test_isolation.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The suite must not touch the developer's real credentials or documents.
|
| 2 |
+
|
| 3 |
+
Without this, load_dotenv() silently pulls the real .env into every test run,
|
| 4 |
+
which sent traces to the live Langfuse project.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from config import get_settings
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def test_real_langfuse_credentials_are_not_loaded():
|
| 11 |
+
settings = get_settings()
|
| 12 |
+
assert settings.langfuse_public_key == ""
|
| 13 |
+
assert settings.langfuse_secret_key == ""
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def test_tracing_is_disabled_during_tests():
|
| 17 |
+
assert get_settings().tracing_enabled is False
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def test_the_real_groq_key_is_not_used():
|
| 21 |
+
assert get_settings().groq_api_key == "test-key-not-used"
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def test_personal_documents_are_not_read():
|
| 25 |
+
data_dir = get_settings().data_dir
|
| 26 |
+
assert data_dir.name != "data"
|
| 27 |
+
assert [p.name for p in data_dir.glob("*.md")] == ["fixture.md"]
|
tests/test_retrieval.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Chunking and retrieval, with a bag-of-words embedder so tests stay offline."""
|
| 2 |
+
|
| 3 |
+
import numpy as np
|
| 4 |
+
import pytest
|
| 5 |
+
|
| 6 |
+
from dataloader import SEPARATOR
|
| 7 |
+
from retrieval import ProfileIndex, chunk_documents
|
| 8 |
+
|
| 9 |
+
VOCAB = ["faiss", "kubernetes", "volunteer", "python", "rust"]
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def fake_embed(texts):
|
| 13 |
+
vectors = []
|
| 14 |
+
for text in texts:
|
| 15 |
+
lowered = text.lower()
|
| 16 |
+
vectors.append([float(word in lowered) for word in VOCAB])
|
| 17 |
+
return np.array(vectors, dtype=np.float32)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def profile(*documents):
|
| 21 |
+
return SEPARATOR.join(documents)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def test_chunks_carry_their_source_label():
|
| 25 |
+
text = profile("# Source: resume\n\nWorked with FAISS.", "# Source: volunteer\n\nTaught Python.")
|
| 26 |
+
chunks = chunk_documents(text)
|
| 27 |
+
assert any(c.startswith("[resume]") for c in chunks)
|
| 28 |
+
assert any(c.startswith("[volunteer]") for c in chunks)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def test_content_survives_chunking():
|
| 32 |
+
chunks = chunk_documents(profile("# Source: resume\n\nWorked with FAISS."))
|
| 33 |
+
assert "Worked with FAISS." in "\n".join(chunks)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def test_long_documents_split_into_several_chunks():
|
| 37 |
+
body = "\n\n".join(f"Paragraph {i} " + "x" * 200 for i in range(10))
|
| 38 |
+
chunks = chunk_documents(profile(f"# Source: resume\n\n{body}"), target_chars=300)
|
| 39 |
+
assert len(chunks) > 1
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def test_short_documents_stay_whole():
|
| 43 |
+
assert len(chunk_documents(profile("# Source: resume\n\nShort."))) == 1
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def test_empty_input_yields_no_chunks():
|
| 47 |
+
assert chunk_documents("") == []
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def test_documents_without_a_source_header_still_chunk():
|
| 51 |
+
chunks = chunk_documents("Just a body with no header.")
|
| 52 |
+
assert len(chunks) == 1
|
| 53 |
+
assert "Just a body" in chunks[0]
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class TestSearch:
|
| 57 |
+
@pytest.fixture
|
| 58 |
+
def index(self):
|
| 59 |
+
return ProfileIndex(
|
| 60 |
+
["[resume] Built a FAISS index", "[volunteer] Volunteer teaching", "[projects] Python tooling"],
|
| 61 |
+
fake_embed,
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
def test_search_ranks_the_matching_chunk_first(self, index):
|
| 65 |
+
assert "FAISS" in index.search("faiss", k=1)[0]
|
| 66 |
+
assert "Volunteer" in index.search("volunteer", k=1)[0]
|
| 67 |
+
|
| 68 |
+
def test_search_respects_k(self, index):
|
| 69 |
+
assert len(index.search("python", k=2)) == 2
|
| 70 |
+
|
| 71 |
+
def test_k_larger_than_the_corpus_is_safe(self, index):
|
| 72 |
+
assert len(index.search("python", k=99)) == 3
|
| 73 |
+
|
| 74 |
+
def test_empty_query_returns_nothing(self, index):
|
| 75 |
+
assert index.search(" ") == []
|
| 76 |
+
|
| 77 |
+
def test_search_text_joins_results(self, index):
|
| 78 |
+
assert "---" in index.search_text("python", k=2)
|
| 79 |
+
|
| 80 |
+
def test_search_text_reports_when_nothing_is_found(self, index):
|
| 81 |
+
assert "No relevant excerpts" in index.search_text("", k=3)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def test_an_empty_index_does_not_crash():
|
| 85 |
+
empty = ProfileIndex([], fake_embed)
|
| 86 |
+
assert empty.search("anything") == []
|
| 87 |
+
assert "No relevant excerpts" in empty.search_text("anything")
|
tests/test_router.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Router logic, tested with a controlled embedder rather than the real model.
|
| 2 |
+
|
| 3 |
+
Model quality is a separate concern; what matters here is that ambiguity falls
|
| 4 |
+
back to the full profile instead of silently answering without evidence.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
import pytest
|
| 9 |
+
|
| 10 |
+
from router import FULL, SPECIFIC, TRIVIAL, SemanticRouter
|
| 11 |
+
|
| 12 |
+
REFERENCES = {TRIVIAL: ["GREET"], SPECIFIC: ["NARROW"], FULL: ["BROAD"]}
|
| 13 |
+
AXIS = {"GREET": 0, "NARROW": 1, "BROAD": 2}
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def fake_embed(texts):
|
| 17 |
+
"""One-hot per route keyword; anything else is equidistant from all three."""
|
| 18 |
+
vectors = []
|
| 19 |
+
for text in texts:
|
| 20 |
+
vector = np.zeros(3, dtype=np.float32)
|
| 21 |
+
for token, axis in AXIS.items():
|
| 22 |
+
if token in text:
|
| 23 |
+
vector[axis] = 1.0
|
| 24 |
+
if not vector.any():
|
| 25 |
+
vector[:] = 1 / np.sqrt(3)
|
| 26 |
+
vectors.append(vector)
|
| 27 |
+
return np.array(vectors, dtype=np.float32)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@pytest.fixture
|
| 31 |
+
def router():
|
| 32 |
+
return SemanticRouter(fake_embed, references=REFERENCES)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@pytest.mark.parametrize(
|
| 36 |
+
"query,expected",
|
| 37 |
+
[("GREET", TRIVIAL), ("NARROW", SPECIFIC), ("BROAD", FULL)],
|
| 38 |
+
)
|
| 39 |
+
def test_confident_queries_reach_their_route(router, query, expected):
|
| 40 |
+
assert router.route(query) == expected
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def test_ambiguous_queries_fall_back_to_full(router):
|
| 44 |
+
"""Equidistant from every route: answering without the profile is the costly error."""
|
| 45 |
+
assert router.route("nothing in particular") == FULL
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def test_a_tie_between_trivial_and_specific_falls_back_to_full(router):
|
| 49 |
+
assert router.route("GREET NARROW") == FULL
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
@pytest.mark.parametrize("query", ["", " ", None])
|
| 53 |
+
def test_empty_queries_fall_back_to_full(router, query):
|
| 54 |
+
assert router.route(query) == FULL
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def test_a_high_threshold_forces_the_safe_route():
|
| 58 |
+
"""An exact match scores 1.0 here, so the bar has to sit above it."""
|
| 59 |
+
strict = SemanticRouter(fake_embed, references=REFERENCES, min_score=1.1)
|
| 60 |
+
assert strict.route("GREET") == FULL
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def test_a_wide_margin_requirement_forces_the_safe_route():
|
| 64 |
+
strict = SemanticRouter(fake_embed, references=REFERENCES, min_margin=1.1)
|
| 65 |
+
assert strict.route("NARROW") == FULL
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def test_shipped_thresholds_leave_room_for_a_confident_match():
|
| 69 |
+
from router import MIN_MARGIN, MIN_SCORE
|
| 70 |
+
|
| 71 |
+
assert 0 < MIN_SCORE < 1
|
| 72 |
+
assert 0 < MIN_MARGIN < 1
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def test_scores_cover_every_route(router):
|
| 76 |
+
assert set(router.scores("GREET")) == {TRIVIAL, SPECIFIC, FULL}
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def test_centroids_are_normalised(router):
|
| 80 |
+
assert np.allclose(np.linalg.norm(router.centroids, axis=1), 1.0)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
class TestRealReferenceSet:
|
| 84 |
+
"""The shipped reference queries must be well formed."""
|
| 85 |
+
|
| 86 |
+
def test_every_route_has_examples(self):
|
| 87 |
+
from router import REFERENCE_QUERIES
|
| 88 |
+
|
| 89 |
+
assert set(REFERENCE_QUERIES) == {TRIVIAL, SPECIFIC, FULL}
|
| 90 |
+
for examples in REFERENCE_QUERIES.values():
|
| 91 |
+
assert len(examples) >= 5
|
| 92 |
+
|
| 93 |
+
def test_examples_are_unique_across_routes(self):
|
| 94 |
+
from router import REFERENCE_QUERIES
|
| 95 |
+
|
| 96 |
+
seen = [q for examples in REFERENCE_QUERIES.values() for q in examples]
|
| 97 |
+
assert len(seen) == len(set(seen))
|
tests/test_temperature.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Drafting uses a higher temperature than analysis."""
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
|
| 5 |
+
import config
|
| 6 |
+
from config import get_settings
|
| 7 |
+
from graph import DRAFTING_ACTIONS, build_llm
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def test_drafting_is_warmer_than_analysis():
|
| 11 |
+
settings = get_settings()
|
| 12 |
+
assert settings.drafting_temperature > settings.temperature
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def test_only_the_cover_letter_drafts():
|
| 16 |
+
assert DRAFTING_ACTIONS == {"cover_letter"}
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def test_default_temperatures():
|
| 20 |
+
assert config.DEFAULT_TEMPERATURE == 0.0
|
| 21 |
+
assert 0 < config.DEFAULT_DRAFTING_TEMPERATURE < 1
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def test_build_llm_defaults_to_the_analysis_temperature():
|
| 25 |
+
# ChatGroq clamps an exact 0.0 to 1e-08, since Groq rejects zero.
|
| 26 |
+
assert build_llm().temperature == pytest.approx(get_settings().temperature, abs=1e-6)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def test_build_llm_accepts_an_override():
|
| 30 |
+
assert build_llm(0.4).temperature == 0.4
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def test_drafting_temperature_is_configurable(monkeypatch):
|
| 34 |
+
monkeypatch.setenv("LLM_DRAFTING_TEMPERATURE", "0.9")
|
| 35 |
+
get_settings.cache_clear()
|
| 36 |
+
try:
|
| 37 |
+
assert get_settings().drafting_temperature == 0.9
|
| 38 |
+
finally:
|
| 39 |
+
get_settings.cache_clear()
|