codegood commited on
Commit
87149fa
·
1 Parent(s): 532111b

Add semantic routing and retrieval to cut per-request token cost

Browse files
.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
- .venv\Scripts\activate # Windows
14
- source .venv/bin/activate # macOS / Linux
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- LANGFUSE_BASE_URL=https://us.cloud.langfuse.com
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` or `LANGFUSE_BASE_URL` | `https://cloud.langfuse.com` | Langfuse region. Use the US host if your keys are US. |
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 prompt and the three preset action instructions
 
 
 
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
- One state graph with two nodes. Routing depends on `action` in the state: the ATS button needs
81
- structured output, everything else streams as chat.
82
 
83
  ```mermaid
84
- flowchart LR
85
- START([START]) -->|action == "ats"| ATS["ats_analysis<br/>structured output"]
86
- START -->|otherwise| ANSWER["answer<br/>streaming chat"]
 
 
 
 
 
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
- **`answer`** renders the system prompt with your documents and the job description, replays the
100
- last `MAX_HISTORY_TURNS` turns, and streams the reply. Older turns stay in the checkpoint but are
101
- not resent, so cost per turn stays flat however long the conversation runs.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
 
103
- **`ats_analysis`** asks the model only to classify each job-description keyword as matched or
104
- missing. The percentage is computed in Python from the list lengths, so the score cannot
105
- contradict the lists it is derived from.
 
 
 
 
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, routing, history trimming, and prompt templating.
 
 
 
119
 
120
  ## Token budget
121
 
122
- Groq's free tier allows 8,000 tokens per minute and 200,000 per day. Each request sends your full
123
- profile (~3,900 tokens) plus the job description plus the reserved response budget, so a single
124
- request costs roughly 7,000–8,000 tokens. That is about 25–40 requests per day. `LLM_MAX_TOKENS`
125
- is capped at 3,000 to keep requests under the per-minute ceiling; raising it triggers HTTP 413.
 
 
 
 
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].content
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
- Today the graph is a single node, which is deliberate: it buys checkpointed
4
- conversation state (threads that survive a restart) and gives the planned
5
- cover-letter flow (draft -> critique -> revise) somewhere to live without a
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 prompts import NO_JD_PLACEHOLDER, build_qa_prompt, render_system_prompt
 
 
 
 
 
 
 
 
 
 
 
22
 
23
 
24
  class ChatState(TypedDict):
@@ -29,17 +42,54 @@ class ChatState(TypedDict):
29
  action: str
30
 
31
 
32
- def route_action(state) -> str:
33
- """Pick the node for this turn. Only 'ats' needs structured output."""
34
- return "ats_analysis" if state.get("action") == "ats" else "answer"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- chain = build_qa_prompt() | build_llm()
51
- structured_llm = build_llm().with_structured_output(ATSAnalysis)
 
 
 
 
 
 
 
 
 
52
 
53
  # Two messages per turn (human + ai).
54
  max_history_messages = settings.max_history_turns * 2
55
 
56
- def answer(state: ChatState):
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
- history = trim_messages(
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
- user_query = state["messages"][-1].content
68
- response = chain.invoke({
 
 
69
  "context_text": profile_data,
70
  "job_description": state.get("job_description") or NO_JD_PLACEHOLDER,
71
- "user_query": user_query,
72
- "history": 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"][-1].content),
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(START, route_action, ["answer", "ats_analysis"])
 
 
 
 
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 build_qa_prompt() -> ChatPromptTemplate:
74
  return ChatPromptTemplate.from_messages([
75
- ("system", QA_SYSTEM_PROMPT),
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
- monkeypatch.setenv("DATA_DIR", "data")
78
- assert get_settings().data_dir == config.PROJECT_ROOT / "data"
 
 
 
 
 
 
 
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()