codegood commited on
Commit
d7d27a3
·
1 Parent(s): 9d8bbd6

ocr, email writing, rag

Browse files
.gitignore CHANGED
@@ -8,3 +8,5 @@ __pycache__/
8
  # LangGraph checkpoints
9
  checkpoints.sqlite
10
  checkpoints.sqlite-*
 
 
 
8
  # LangGraph checkpoints
9
  checkpoints.sqlite
10
  checkpoints.sqlite-*
11
+
12
+ experiment.ipynb
Dockerfile DELETED
@@ -1,16 +0,0 @@
1
- FROM python:3.13
2
- WORKDIR /
3
-
4
- # Install the application dependencies
5
- COPY requirements.txt ./
6
- RUN pip install --no-cache-dir -r requirements.txt
7
-
8
- # Copy in the source code
9
- COPY src ./src
10
- EXPOSE 8080
11
-
12
- # Setup an app user so the container doesn't run as the root user
13
- RUN useradd app
14
- USER app
15
-
16
- CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
README.md CHANGED
@@ -46,7 +46,7 @@ pip install -r requirements.txt
46
  **2. Configure `.env`** in the project root (optional if you will paste your key into the UI):
47
 
48
  ```ini
49
- OPENROUTER_API_KEY=... # optional; can also be entered in the UI
50
  CANDIDATE_NAME=Your Name # used in the system prompt
51
 
52
  # Optional — Langfuse tracing. Tracing turns on only when both keys are present.
@@ -57,16 +57,17 @@ LANGFUSE_HOST=https://us.cloud.langfuse.com
57
 
58
  | Variable | Default | Purpose |
59
  |---|---|---|
60
- | `OPENROUTER_API_KEY` | — | Optional. If omitted, paste a key into the app's OpenRouter API key panel. |
61
  | `CANDIDATE_NAME` | `the candidate` | Your name, used in the system prompt. |
62
  | `OPENROUTER_MODEL` | `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free` | OpenRouter model id. |
63
  | `LLM_TEMPERATURE` | `0.0` | Sampling temperature. |
64
- | `LLM_MAX_TOKENS` | `3000` | Response cap for ordinary chat. See the token budget note below. |
65
- | `SKILL_MAX_TOKENS` | `6500` | Response cap for the four skills (review, ATS, cover letter, email). |
 
66
  | `LLM_DRAFTING_TEMPERATURE` | `0.4` | Temperature for cover-letter drafting only. |
67
  | `MAX_HISTORY_TURNS` | `10` | Turns replayed to the model per request. |
68
  | `EMBEDDING_MODEL` | `sentence-transformers/all-MiniLM-L6-v2` | Routing and retrieval embeddings, run through fastembed (ONNX, no torch). Downloaded on first run. |
69
- | `RETRIEVAL_TOP_K` | `5` | Excerpts retrieved for a narrow question. |
70
  | `CHECKPOINT_DB` | `checkpoints.sqlite` | Where conversation threads persist. |
71
  | `LANGFUSE_PUBLIC_KEY` / `LANGFUSE_SECRET_KEY` | — | Enable tracing when both are set. |
72
  | `LANGFUSE_HOST` (alias: `LANGFUSE_BASE_URL`) | `https://cloud.langfuse.com` | Langfuse region. Use `https://us.cloud.langfuse.com` if your keys are US. |
@@ -77,9 +78,9 @@ LANGFUSE_HOST=https://us.cloud.langfuse.com
77
  python app.py # http://localhost:7860
78
  ```
79
 
80
- If you did not set `OPENROUTER_API_KEY` in `.env`, paste it into the **OpenRouter API key** panel and select **Connect key**. The key stays in server memory for that browser session and is not written to disk. Only enter a key when you trust the server running this app.
81
 
82
- Upload one or more PDF, DOCX, Markdown, or text files in **Your profile**. The app converts them to Markdown in memory and loads them into the profile pipeline; a later upload replaces the current profile. Uploads are not written to the project `data/` directory. Add a job description in **Target role** before chatting or using a skill button; requests without one do not call the model.
83
 
84
  Select
85
  **Stop** beside the message box to cancel a response that's being generated; any text already
@@ -159,6 +160,11 @@ checkpoint but are not resent, so cost per turn stays flat however long the conv
159
  The percentage is computed in Python from the list lengths, so the score cannot contradict the
160
  lists it is derived from.
161
 
 
 
 
 
 
162
  **Routing rules.** Every skill button bypasses the router and receives the full profile: a
163
  retrieval miss inside an ATS score or a resume review would silently drop real evidence. For free
164
  chat, the router compares the question against the centroid of reference examples per route
@@ -193,7 +199,7 @@ every push to `main` and every pull request.
193
 
194
  ## Token budget
195
 
196
- `LLM_MAX_TOKENS` defaults to 3,000 to cap the response size. A full-profile request also sends
197
- your profile (~3,900 tokens), the job description, and recent conversation history. OpenRouter
198
- model context windows and account limits vary, so shorten the job description or lower
199
- `LLM_MAX_TOKENS` if a request exceeds the model's limits.
 
46
  **2. Configure `.env`** in the project root (optional if you will paste your key into the UI):
47
 
48
  ```ini
49
+ OPENROUTER_API_KEY=... # required to use the model; can also be entered in the UI
50
  CANDIDATE_NAME=Your Name # used in the system prompt
51
 
52
  # Optional — Langfuse tracing. Tracing turns on only when both keys are present.
 
57
 
58
  | Variable | Default | Purpose |
59
  |---|---|---|
60
+ | `OPENROUTER_API_KEY` | — | Required to use the model. Set it here or paste a key into the always-visible UI field. |
61
  | `CANDIDATE_NAME` | `the candidate` | Your name, used in the system prompt. |
62
  | `OPENROUTER_MODEL` | `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free` | OpenRouter model id. |
63
  | `LLM_TEMPERATURE` | `0.0` | Sampling temperature. |
64
+ | `LLM_MAX_TOKENS` | `20000` | Response cap for ordinary chat. See the token budget note below. |
65
+ | `SKILL_MAX_TOKENS` | `6500` | Response cap for prose skills (review, cover letter, email). |
66
+ | `ATS_MAX_TOKENS` | `12000` | Response cap for ATS structured output. Increase only if the selected model supports it. |
67
  | `LLM_DRAFTING_TEMPERATURE` | `0.4` | Temperature for cover-letter drafting only. |
68
  | `MAX_HISTORY_TURNS` | `10` | Turns replayed to the model per request. |
69
  | `EMBEDDING_MODEL` | `sentence-transformers/all-MiniLM-L6-v2` | Routing and retrieval embeddings, run through fastembed (ONNX, no torch). Downloaded on first run. |
70
+ | `RETRIEVAL_TOP_K` | `10` | Excerpts retrieved for a narrow question. |
71
  | `CHECKPOINT_DB` | `checkpoints.sqlite` | Where conversation threads persist. |
72
  | `LANGFUSE_PUBLIC_KEY` / `LANGFUSE_SECRET_KEY` | — | Enable tracing when both are set. |
73
  | `LANGFUSE_HOST` (alias: `LANGFUSE_BASE_URL`) | `https://cloud.langfuse.com` | Langfuse region. Use `https://us.cloud.langfuse.com` if your keys are US. |
 
78
  python app.py # http://localhost:7860
79
  ```
80
 
81
+ If you did not set `OPENROUTER_API_KEY` in `.env`, paste it into the **OpenRouter API key** field and select **Connect key**. An API key is required to use the model. A key entered in the UI stays in server memory for that browser session and is not written to disk. Only enter a key when you trust the server running this app.
82
 
83
+ Upload one or more PDF, DOCX, Markdown, or text files in **Your profile**. The app converts them to Markdown in memory and loads them into the profile pipeline; a later upload replaces the current profile. For PDFs, RapidOCR with ONNX Runtime is used as a fallback if MarkItDown fails or extracts no text, including for scanned/image-only pages. Uploads are not written to the project `data/` directory. Add a job description in **Target role** before chatting or using a skill button; requests without one do not call the model.
84
 
85
  Select
86
  **Stop** beside the message box to cancel a response that's being generated; any text already
 
160
  The percentage is computed in Python from the list lengths, so the score cannot contradict the
161
  lists it is derived from.
162
 
163
+ **Retrieval.** Uploaded profile documents are split into 100-word windows with 15 words of
164
+ overlap, keeping adjacent context available to narrow searches. The `rag_answer` index combines
165
+ BM25 keyword rankings with FAISS cosine-similarity rankings over FastEmbed vectors, then merges
166
+ the results with reciprocal-rank fusion. This hybrid index is rebuilt for each uploaded profile.
167
+
168
  **Routing rules.** Every skill button bypasses the router and receives the full profile: a
169
  retrieval miss inside an ATS score or a resume review would silently drop real evidence. For free
170
  chat, the router compares the question against the centroid of reference examples per route
 
199
 
200
  ## Token budget
201
 
202
+ `LLM_MAX_TOKENS` defaults to 20,000 to cap ordinary chat responses. ATS uses its own
203
+ 12,000-token cap because it returns a structured classification of every job-description
204
+ keyword. OpenRouter model output limits vary; if ATS reports a truncated response, choose a
205
+ model with a larger output limit or lower `ATS_MAX_TOKENS` to fit the selected model.
app.py CHANGED
@@ -6,6 +6,7 @@ from pathlib import Path
6
 
7
  import gradio as gr
8
  from langchain_core.messages import AIMessage, HumanMessage
 
9
 
10
  from config import ConfigError, get_settings
11
  from dataloader import convert_uploaded_documents
@@ -37,6 +38,28 @@ def graph_for_session(session_id):
37
  return session.get("graph") if session else None
38
 
39
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  def connect_api_key(api_key, session_id):
41
  api_key = (api_key or "").strip()
42
  if not api_key:
@@ -190,17 +213,7 @@ def stream_response(
190
  yield chat_history
191
  except Exception as exc:
192
  logger.exception("Request failed")
193
- detail = str(exc)
194
- if "rate_limit" in detail or "429" in detail:
195
- detail = (
196
- "OpenRouter rate limit reached. Check your account's limits or wait "
197
- "before retrying."
198
- )
199
- elif "413" in detail:
200
- detail = (
201
- "The request is too large for the OpenRouter model. Shorten the job "
202
- "description or lower LLM_MAX_TOKENS, then retry."
203
- )
204
  chat_history[-1]["content"] = f"**Request failed.** {detail}"
205
  yield chat_history
206
 
@@ -211,21 +224,19 @@ with gr.Blocks(title="job-chat", fill_height=True, analytics_enabled=False) as d
211
 
212
  gr.Markdown("## Job chat")
213
  gr.Markdown("Ask about your background, draft interview answers, or test your fit against a role.")
214
- with gr.Accordion(
215
- "OpenRouter API key", open=not bool(SETTINGS.openrouter_api_key)
216
- ):
217
- api_key_box = gr.Textbox(
218
- label="Paste your OpenRouter API key",
219
- placeholder="sk-or-v1-…",
220
- type="password",
221
- show_label=True,
222
- )
223
- connect_key_btn = gr.Button("Connect key")
224
- key_status = gr.Markdown(
225
- "Using the server-configured key."
226
- if SETTINGS.openrouter_api_key
227
- else "Your key is kept in server memory for this browser session and is not written to disk."
228
- )
229
 
230
  with gr.Row(equal_height=False):
231
  with gr.Column(scale=1):
 
6
 
7
  import gradio as gr
8
  from langchain_core.messages import AIMessage, HumanMessage
9
+ from openai import LengthFinishReasonError
10
 
11
  from config import ConfigError, get_settings
12
  from dataloader import convert_uploaded_documents
 
38
  return session.get("graph") if session else None
39
 
40
 
41
+ def request_error_detail(exc):
42
+ if isinstance(exc, LengthFinishReasonError):
43
+ return (
44
+ "The ATS report exceeded the model's response-token limit and was incomplete. "
45
+ "Retry with a shorter job description, choose a model with a larger output "
46
+ "limit, or raise ATS_MAX_TOKENS if the model supports it."
47
+ )
48
+
49
+ detail = str(exc)
50
+ if "rate_limit" in detail or "429" in detail:
51
+ return (
52
+ "OpenRouter rate limit reached. Check your account's limits or wait "
53
+ "before retrying."
54
+ )
55
+ if "413" in detail:
56
+ return (
57
+ "The request is too large for the OpenRouter model. Shorten the job "
58
+ "description or lower LLM_MAX_TOKENS, then retry."
59
+ )
60
+ return detail
61
+
62
+
63
  def connect_api_key(api_key, session_id):
64
  api_key = (api_key or "").strip()
65
  if not api_key:
 
213
  yield chat_history
214
  except Exception as exc:
215
  logger.exception("Request failed")
216
+ detail = request_error_detail(exc)
 
 
 
 
 
 
 
 
 
 
217
  chat_history[-1]["content"] = f"**Request failed.** {detail}"
218
  yield chat_history
219
 
 
224
 
225
  gr.Markdown("## Job chat")
226
  gr.Markdown("Ask about your background, draft interview answers, or test your fit against a role.")
227
+ gr.Markdown("### OpenRouter API key")
228
+ api_key_box = gr.Textbox(
229
+ label="Paste your OpenRouter API key",
230
+ placeholder="sk-or-v1-…",
231
+ type="password",
232
+ show_label=True,
233
+ )
234
+ connect_key_btn = gr.Button("Connect key")
235
+ key_status = gr.Markdown(
236
+ "Using the server-configured key."
237
+ if SETTINGS.openrouter_api_key
238
+ else "An API key is required to use the model. Your key is kept in server memory for this browser session and is not written to disk."
239
+ )
 
 
240
 
241
  with gr.Row(equal_height=False):
242
  with gr.Column(scale=1):
config.py CHANGED
@@ -21,8 +21,11 @@ DEFAULT_DRAFTING_TEMPERATURE = 0.4
21
  # truncated them mid-answer. This leaves room for the profile, job description,
22
  # and conversation history while avoiding an unnecessarily large response cap.
23
  DEFAULT_MAX_TOKENS = 20000
24
- # Skill output (cover letters, reviews, ATS reports) gets its own response cap.
25
  DEFAULT_SKILL_MAX_TOKENS = 6500
 
 
 
26
  DEFAULT_LANGFUSE_HOST = "https://cloud.langfuse.com"
27
  DEFAULT_CHECKPOINT_DB = "checkpoints.sqlite"
28
  # Turns of conversation replayed to the model. Older turns stay in the
@@ -30,7 +33,7 @@ DEFAULT_CHECKPOINT_DB = "checkpoints.sqlite"
30
  DEFAULT_MAX_HISTORY_TURNS = 10
31
  # Routing and retrieval. ONNX runtime, so no torch dependency.
32
  DEFAULT_EMBEDDING_MODEL = "sentence-transformers/all-MiniLM-L6-v2"
33
- DEFAULT_RETRIEVAL_TOP_K = 5
34
 
35
 
36
  class ConfigError(RuntimeError):
@@ -46,6 +49,7 @@ class Settings:
46
  drafting_temperature: float
47
  max_tokens: int
48
  skill_max_tokens: int
 
49
  max_history_turns: int
50
  embedding_model: str
51
  retrieval_top_k: int
@@ -94,6 +98,7 @@ def get_settings(require_api_key=True) -> Settings:
94
  drafting_temperature=_get_float("LLM_DRAFTING_TEMPERATURE", DEFAULT_DRAFTING_TEMPERATURE),
95
  max_tokens=_get_int("LLM_MAX_TOKENS", DEFAULT_MAX_TOKENS),
96
  skill_max_tokens=_get_int("SKILL_MAX_TOKENS", DEFAULT_SKILL_MAX_TOKENS),
 
97
  max_history_turns=_get_int("MAX_HISTORY_TURNS", DEFAULT_MAX_HISTORY_TURNS),
98
  embedding_model=os.getenv("EMBEDDING_MODEL", "").strip() or DEFAULT_EMBEDDING_MODEL,
99
  retrieval_top_k=_get_int("RETRIEVAL_TOP_K", DEFAULT_RETRIEVAL_TOP_K),
 
21
  # truncated them mid-answer. This leaves room for the profile, job description,
22
  # and conversation history while avoiding an unnecessarily large response cap.
23
  DEFAULT_MAX_TOKENS = 20000
24
+ # Prose skill output gets its own response cap.
25
  DEFAULT_SKILL_MAX_TOKENS = 6500
26
+ # ATS returns a structured classification for every keyword, so it needs more
27
+ # output room than the prose skills.
28
+ DEFAULT_ATS_MAX_TOKENS = 12000
29
  DEFAULT_LANGFUSE_HOST = "https://cloud.langfuse.com"
30
  DEFAULT_CHECKPOINT_DB = "checkpoints.sqlite"
31
  # Turns of conversation replayed to the model. Older turns stay in the
 
33
  DEFAULT_MAX_HISTORY_TURNS = 10
34
  # Routing and retrieval. ONNX runtime, so no torch dependency.
35
  DEFAULT_EMBEDDING_MODEL = "sentence-transformers/all-MiniLM-L6-v2"
36
+ DEFAULT_RETRIEVAL_TOP_K = 10
37
 
38
 
39
  class ConfigError(RuntimeError):
 
49
  drafting_temperature: float
50
  max_tokens: int
51
  skill_max_tokens: int
52
+ ats_max_tokens: int
53
  max_history_turns: int
54
  embedding_model: str
55
  retrieval_top_k: int
 
98
  drafting_temperature=_get_float("LLM_DRAFTING_TEMPERATURE", DEFAULT_DRAFTING_TEMPERATURE),
99
  max_tokens=_get_int("LLM_MAX_TOKENS", DEFAULT_MAX_TOKENS),
100
  skill_max_tokens=_get_int("SKILL_MAX_TOKENS", DEFAULT_SKILL_MAX_TOKENS),
101
+ ats_max_tokens=_get_int("ATS_MAX_TOKENS", DEFAULT_ATS_MAX_TOKENS),
102
  max_history_turns=_get_int("MAX_HISTORY_TURNS", DEFAULT_MAX_HISTORY_TURNS),
103
  embedding_model=os.getenv("EMBEDDING_MODEL", "").strip() or DEFAULT_EMBEDDING_MODEL,
104
  retrieval_top_k=_get_int("RETRIEVAL_TOP_K", DEFAULT_RETRIEVAL_TOP_K),
dataloader.py CHANGED
@@ -1,8 +1,74 @@
 
 
1
  from pathlib import Path
2
 
3
  SEPARATOR = "\n\n" + "-" * 60 + "\n\n"
4
  SUPPORTED_UPLOAD_EXTENSIONS = {".pdf", ".docx", ".md", ".txt"}
5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
 
7
  def format_documents(documents):
8
  """Combine named Markdown strings into the profile context."""
@@ -33,6 +99,8 @@ def convert_uploaded_documents(files):
33
 
34
  if extension in {".md", ".txt"}:
35
  content = path.read_text(encoding="utf-8-sig")
 
 
36
  else:
37
  if converter is None:
38
  from markitdown import MarkItDown
 
1
+ import logging
2
+ from functools import lru_cache
3
  from pathlib import Path
4
 
5
  SEPARATOR = "\n\n" + "-" * 60 + "\n\n"
6
  SUPPORTED_UPLOAD_EXTENSIONS = {".pdf", ".docx", ".md", ".txt"}
7
 
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ @lru_cache(maxsize=1)
12
+ def _get_ocr_engine():
13
+ from rapidocr import RapidOCR
14
+
15
+ return RapidOCR()
16
+
17
+
18
+ def _convert_pdf_with_ocr(path):
19
+ """Render a PDF page-by-page and extract text with RapidOCR."""
20
+ import pypdfium2
21
+
22
+ ocr = _get_ocr_engine()
23
+ pdf = pypdfium2.PdfDocument(str(path))
24
+ pages = []
25
+ try:
26
+ for page_number in range(len(pdf)):
27
+ page = pdf[page_number]
28
+ try:
29
+ image = page.render(scale=2).to_numpy()
30
+ result = ocr(image)
31
+ if result is not None and result.txts:
32
+ pages.append("\n".join(text for text in result.txts if text.strip()))
33
+ finally:
34
+ page.close()
35
+ finally:
36
+ pdf.close()
37
+ return "\n\n".join(pages)
38
+
39
+
40
+ def _convert_pdf(path):
41
+ """Prefer MarkItDown and use OCR when extraction fails or returns no text."""
42
+ try:
43
+ from markitdown import MarkItDown
44
+
45
+ content = MarkItDown().convert(str(path)).markdown
46
+ except Exception as exc:
47
+ markitdown_error = exc
48
+ logger.warning(
49
+ "MarkItDown failed for PDF %s; trying RapidOCR: %s",
50
+ path.name,
51
+ exc,
52
+ )
53
+ else:
54
+ if content and content.strip():
55
+ return content
56
+ logger.info("MarkItDown returned no text for PDF %s; trying RapidOCR.", path.name)
57
+ markitdown_error = None
58
+
59
+ try:
60
+ return _convert_pdf_with_ocr(path)
61
+ except Exception as ocr_exc:
62
+ markitdown_detail = (
63
+ f"MarkItDown failed: {markitdown_error}"
64
+ if markitdown_error
65
+ else "MarkItDown returned no readable text"
66
+ )
67
+ raise RuntimeError(
68
+ f"{markitdown_detail}; RapidOCR could not read PDF "
69
+ f"'{path.name}': {ocr_exc}"
70
+ ) from ocr_exc
71
+
72
 
73
  def format_documents(documents):
74
  """Combine named Markdown strings into the profile context."""
 
99
 
100
  if extension in {".md", ".txt"}:
101
  content = path.read_text(encoding="utf-8-sig")
102
+ elif extension == ".pdf":
103
+ content = _convert_pdf(path)
104
  else:
105
  if converter is None:
106
  from markitdown import MarkItDown
graph.py CHANGED
@@ -132,10 +132,11 @@ def build_graph(api_key=None, profile_data=None):
132
  rag_chain = build_rag_prompt() | llm
133
  trivial_chain = build_trivial_prompt() | llm
134
  structured_llm = build_llm(
135
- max_tokens=settings.skill_max_tokens, api_key=api_key
136
  ).with_structured_output(ATSAnalysis)
137
 
138
- index = ProfileIndex(chunk_documents(profile_data), embed)
 
139
  semantic_router = SemanticRouter(embed)
140
  logger.info("Indexed %d profile chunks for retrieval.", len(index.chunks))
141
 
 
132
  rag_chain = build_rag_prompt() | llm
133
  trivial_chain = build_trivial_prompt() | llm
134
  structured_llm = build_llm(
135
+ max_tokens=settings.ats_max_tokens, api_key=api_key
136
  ).with_structured_output(ATSAnalysis)
137
 
138
+ chunks = chunk_documents(profile_data)
139
+ index = ProfileIndex(chunks, embed)
140
  semantic_router = SemanticRouter(embed)
141
  logger.info("Indexed %d profile chunks for retrieval.", len(index.chunks))
142
 
requirements.txt CHANGED
@@ -1,4 +1,7 @@
1
  fastembed==0.8.0
 
 
 
2
  langchain==1.3.14
3
  langchain-core==1.5.1
4
  langchain-openai==1.1.3
@@ -7,6 +10,9 @@ langgraph-checkpoint-sqlite==3.1.1
7
  langfuse==4.14.3
8
  python-dotenv==1.2.2
9
  markitdown[docx,pdf]==0.1.9
 
 
 
10
 
11
  # Makes Python trust the OS certificate store. Required on networks that do
12
  # TLS inspection (corporate proxy / antivirus); harmless elsewhere.
 
1
  fastembed==0.8.0
2
+ bm25s==0.3.15
3
+ faiss-cpu==1.15.1
4
+ PyStemmer==3.1.0
5
  langchain==1.3.14
6
  langchain-core==1.5.1
7
  langchain-openai==1.1.3
 
10
  langfuse==4.14.3
11
  python-dotenv==1.2.2
12
  markitdown[docx,pdf]==0.1.9
13
+ rapidocr==3.10.0
14
+ onnxruntime==1.31.1
15
+ pypdfium2==5.14.0
16
 
17
  # Makes Python trust the OS certificate store. Required on networks that do
18
  # TLS inspection (corporate proxy / antivirus); harmless elsewhere.
retrieval.py CHANGED
@@ -1,24 +1,28 @@
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()
@@ -26,55 +30,81 @@ def chunk_documents(profile_text, target_chars=TARGET_CHUNK_CHARS):
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)
 
1
+ """Hybrid BM25 and FAISS retrieval over the uploaded profile."""
 
 
 
 
 
 
 
 
 
2
 
3
+ import bm25s
4
+ import faiss
5
  import numpy as np
6
+ import Stemmer
7
 
8
  from dataloader import SEPARATOR
9
 
10
+ WORDS_PER_CHUNK = 100
11
+ CHUNK_OVERLAP_WORDS = 15
12
+ RRF_K = 60
13
+
14
 
15
+ def chunk_documents(
16
+ profile_text,
17
+ words_per_chunk=WORDS_PER_CHUNK,
18
+ overlap=CHUNK_OVERLAP_WORDS,
19
+ ):
20
+ """Split each source document into word windows with exact overlap."""
21
+ if words_per_chunk <= 0 or overlap < 0 or overlap >= words_per_chunk:
22
+ raise ValueError("Require words_per_chunk > overlap >= 0")
23
 
 
 
24
  chunks = []
25
+ step = words_per_chunk - overlap
26
 
27
  for document in profile_text.split(SEPARATOR):
28
  document = document.strip()
 
30
  continue
31
 
32
  lines = document.splitlines()
33
+ has_source = lines[0].startswith("# Source:")
34
+ source = lines[0].removeprefix("# Source:").strip() if has_source else ""
35
+ body = "\n".join(lines[1:]).strip() if has_source else document
36
+ words = body.split()
37
+
38
+ for start in range(0, len(words), step):
39
+ chunk = " ".join(words[start : start + words_per_chunk])
40
+ if chunk:
41
+ chunks.append(_label(source, chunk))
42
+ if start + words_per_chunk >= len(words):
43
+ break
 
 
 
 
 
44
 
45
  return chunks
46
 
47
 
48
+ def _label(source, text):
49
+ return f"[{source}]\n{text}" if source else text
50
+
51
+
52
+ def _reciprocal_rank_fusion(*rankings, k=RRF_K):
53
+ scores = {}
54
+ for ranking in rankings:
55
+ for rank, chunk_id in enumerate(ranking, start=1):
56
+ if chunk_id >= 0:
57
+ scores[chunk_id] = scores.get(chunk_id, 0.0) + 1.0 / (k + rank)
58
+ return sorted(scores, key=scores.get, reverse=True)
59
 
60
 
61
  class ProfileIndex:
62
  def __init__(self, chunks, embed_fn):
63
  self.chunks = list(chunks)
64
+ self.embed_fn = embed_fn
65
+ self.stemmer = Stemmer.Stemmer("english")
66
+
67
  if self.chunks:
68
+ tokens = bm25s.tokenize(
69
+ self.chunks,
70
+ stopwords="en",
71
+ stemmer=self.stemmer,
72
+ show_progress=False,
73
+ )
74
+ self.bm25 = bm25s.BM25()
75
+ self.bm25.index(tokens, show_progress=False)
76
+
77
+ vectors = np.ascontiguousarray(embed_fn(self.chunks), dtype=np.float32)
78
+ faiss.normalize_L2(vectors)
79
+ self.faiss_index = faiss.IndexFlatIP(vectors.shape[1])
80
+ self.faiss_index.add(vectors)
81
  else:
82
+ self.bm25 = None
83
+ self.faiss_index = None
84
 
85
  def search(self, query, k=5):
86
+ """Return chunks ranked by reciprocal-rank fusion of BM25 and cosine."""
87
+ if not self.chunks or not query.strip() or k <= 0:
88
  return []
89
 
90
+ candidate_count = min(k, len(self.chunks))
91
+ query_tokens = bm25s.tokenize(
92
+ [query],
93
+ stopwords="en",
94
+ stemmer=self.stemmer,
95
+ show_progress=False,
96
+ )
97
+ bm25_ids, _ = self.bm25.retrieve(
98
+ query_tokens,
99
+ k=candidate_count,
100
+ show_progress=False,
101
+ )
102
+ dense_query = np.ascontiguousarray(self.embed_fn([query]), dtype=np.float32)
103
+ faiss.normalize_L2(dense_query)
104
+ _, faiss_ids = self.faiss_index.search(dense_query, candidate_count)
105
+
106
+ fused_ids = _reciprocal_rank_fusion(bm25_ids[0], faiss_ids[0])
107
+ return [self.chunks[i] for i in fused_ids[:candidate_count]]
108
 
109
  def search_text(self, query, k=5):
110
  found = self.search(query, k)
skills/email-writing.md CHANGED
@@ -29,7 +29,6 @@ Construct the email body using this exact 3-part sequence:
29
  - **Do Not Highlight Gaps:** If the user's data does not perfectly match every requirement of the job description, focus entirely on the overlapping strengths. Do not apologize for or acknowledge missing skills.
30
 
31
  ## Execution Steps
32
- 1. **Analyze:** Ingest all provided background material. Silently discard anything irrelevant to the target job.
33
- 2. **Draft Subject Lines:** Generate 3 highly optimized, scannable subject line options (e.g., "[Role] Application - [Name] - [Top Credential]").
34
- 3. **Draft the Email:** Write the email strictly following the structure and tone rules above. Target length: ~100 words.
35
- 4. **Format Output:** Present the subject lines, the email draft, and a brief 1-bullet explanation of why those specific bullets were chosen.
 
29
  - **Do Not Highlight Gaps:** If the user's data does not perfectly match every requirement of the job description, focus entirely on the overlapping strengths. Do not apologize for or acknowledge missing skills.
30
 
31
  ## Execution Steps
32
+ 1. **Think privately:** Compare the job description with the provided background, select only relevant and supported evidence, and plan a concise email. Do not reveal analysis or reasoning.
33
+ 2. **Draft the email:** Follow the structure and tone rules above. Use one subject line, not options. Do not invent facts or metrics, and do not include bracketed placeholders; omit unsupported details instead.
34
+ 3. **Return only the finished email:** Include a single `Subject:` line, greeting, email body, and sign-off. Do not add a heading, introduction, alternatives, explanation, rationale, suggestions, or any other text outside the email.
 
tests/test_app.py CHANGED
@@ -18,6 +18,18 @@ def test_chat_without_job_description_does_not_access_the_graph(monkeypatch):
18
  assert "Paste a job description" in result[0][-1]["content"]
19
 
20
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  def test_upload_builds_a_per_session_graph(monkeypatch, tmp_path):
22
  markdown_file = tmp_path / "resume.md"
23
  markdown_file.write_text("# Resume", encoding="utf-8")
 
18
  assert "Paste a job description" in result[0][-1]["content"]
19
 
20
 
21
+ def test_length_limit_error_explains_how_to_retry():
22
+ from types import SimpleNamespace
23
+
24
+ from openai import LengthFinishReasonError
25
+
26
+ completion = SimpleNamespace(usage=None)
27
+ detail = app.request_error_detail(LengthFinishReasonError(completion=completion))
28
+
29
+ assert "ATS report exceeded" in detail
30
+ assert "ATS_MAX_TOKENS" in detail
31
+
32
+
33
  def test_upload_builds_a_per_session_graph(monkeypatch, tmp_path):
34
  markdown_file = tmp_path / "resume.md"
35
  markdown_file.write_text("# Resume", encoding="utf-8")
tests/test_config.py CHANGED
@@ -14,7 +14,7 @@ def clear_settings_cache():
14
  @pytest.fixture
15
  def base_env(monkeypatch):
16
  monkeypatch.setenv("OPENROUTER_API_KEY", "test-key")
17
- for name in ("OPENROUTER_MODEL", "LLM_TEMPERATURE", "LLM_MAX_TOKENS", "SKILL_MAX_TOKENS", "MAX_HISTORY_TURNS",
18
  "CANDIDATE_NAME", "DATA_DIR", "LANGFUSE_HOST", "LANGFUSE_BASE_URL",
19
  "LANGFUSE_PUBLIC_KEY", "LANGFUSE_SECRET_KEY"):
20
  monkeypatch.delenv(name, raising=False)
@@ -34,7 +34,7 @@ def test_api_key_can_be_supplied_later_by_the_ui(base_env, monkeypatch):
34
  assert settings.openrouter_api_key == ""
35
 
36
 
37
- @pytest.mark.parametrize("var", ["LLM_TEMPERATURE", "LLM_MAX_TOKENS", "SKILL_MAX_TOKENS", "MAX_HISTORY_TURNS"])
38
  def test_non_numeric_values_are_rejected(base_env, monkeypatch, var):
39
  monkeypatch.setenv(var, "hot")
40
  with pytest.raises(ConfigError, match=var):
@@ -49,19 +49,30 @@ def test_defaults_apply_when_optional_vars_are_unset(base_env):
49
  assert settings.max_tokens == config.DEFAULT_MAX_TOKENS
50
  assert settings.max_history_turns == config.DEFAULT_MAX_HISTORY_TURNS
51
  assert settings.skill_max_tokens == config.DEFAULT_SKILL_MAX_TOKENS == 6500
 
52
  assert settings.candidate_name == "the candidate"
53
 
54
 
55
  def test_overrides_are_read_from_the_environment(base_env, monkeypatch):
56
  monkeypatch.setenv("OPENROUTER_MODEL", "some/other-model")
57
  monkeypatch.setenv("LLM_TEMPERATURE", "0.7")
 
58
  monkeypatch.setenv("MAX_HISTORY_TURNS", "3")
59
  settings = get_settings()
60
  assert settings.model == "some/other-model"
61
  assert settings.temperature == 0.7
 
62
  assert settings.max_history_turns == 3
63
 
64
 
 
 
 
 
 
 
 
 
65
  def test_settings_do_not_require_a_data_directory(base_env, monkeypatch, tmp_path):
66
  monkeypatch.setenv("DATA_DIR", str(tmp_path / "missing"))
67
 
 
14
  @pytest.fixture
15
  def base_env(monkeypatch):
16
  monkeypatch.setenv("OPENROUTER_API_KEY", "test-key")
17
+ for name in ("OPENROUTER_MODEL", "LLM_TEMPERATURE", "LLM_MAX_TOKENS", "SKILL_MAX_TOKENS", "ATS_MAX_TOKENS", "MAX_HISTORY_TURNS",
18
  "CANDIDATE_NAME", "DATA_DIR", "LANGFUSE_HOST", "LANGFUSE_BASE_URL",
19
  "LANGFUSE_PUBLIC_KEY", "LANGFUSE_SECRET_KEY"):
20
  monkeypatch.delenv(name, raising=False)
 
34
  assert settings.openrouter_api_key == ""
35
 
36
 
37
+ @pytest.mark.parametrize("var", ["LLM_TEMPERATURE", "LLM_MAX_TOKENS", "SKILL_MAX_TOKENS", "ATS_MAX_TOKENS", "MAX_HISTORY_TURNS"])
38
  def test_non_numeric_values_are_rejected(base_env, monkeypatch, var):
39
  monkeypatch.setenv(var, "hot")
40
  with pytest.raises(ConfigError, match=var):
 
49
  assert settings.max_tokens == config.DEFAULT_MAX_TOKENS
50
  assert settings.max_history_turns == config.DEFAULT_MAX_HISTORY_TURNS
51
  assert settings.skill_max_tokens == config.DEFAULT_SKILL_MAX_TOKENS == 6500
52
+ assert settings.ats_max_tokens == config.DEFAULT_ATS_MAX_TOKENS == 12000
53
  assert settings.candidate_name == "the candidate"
54
 
55
 
56
  def test_overrides_are_read_from_the_environment(base_env, monkeypatch):
57
  monkeypatch.setenv("OPENROUTER_MODEL", "some/other-model")
58
  monkeypatch.setenv("LLM_TEMPERATURE", "0.7")
59
+ monkeypatch.setenv("ATS_MAX_TOKENS", "9000")
60
  monkeypatch.setenv("MAX_HISTORY_TURNS", "3")
61
  settings = get_settings()
62
  assert settings.model == "some/other-model"
63
  assert settings.temperature == 0.7
64
+ assert settings.ats_max_tokens == 9000
65
  assert settings.max_history_turns == 3
66
 
67
 
68
+ def test_ats_llm_uses_its_separate_response_cap(base_env):
69
+ from graph import build_llm
70
+
71
+ settings = get_settings()
72
+
73
+ assert build_llm(max_tokens=settings.ats_max_tokens).max_tokens == 12000
74
+
75
+
76
  def test_settings_do_not_require_a_data_directory(base_env, monkeypatch, tmp_path):
77
  monkeypatch.setenv("DATA_DIR", str(tmp_path / "missing"))
78
 
tests/test_dataloader.py CHANGED
@@ -36,6 +36,66 @@ def test_uploaded_pdf_is_converted_to_markdown(tmp_path, monkeypatch):
36
  assert "# Converted resume" in convert_uploaded_documents([str(pdf)])
37
 
38
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  def test_uploaded_unsupported_file_type_is_rejected(tmp_path):
40
  unsupported = tmp_path / "profile.exe"
41
  unsupported.write_text("not a document", encoding="utf-8")
 
36
  assert "# Converted resume" in convert_uploaded_documents([str(pdf)])
37
 
38
 
39
+ @pytest.mark.parametrize("markitdown_result", ["", "raise"])
40
+ def test_uploaded_pdf_falls_back_to_rapidocr(tmp_path, monkeypatch, markitdown_result):
41
+ import sys
42
+ from types import SimpleNamespace
43
+ import dataloader
44
+
45
+ pdf = tmp_path / "scanned.pdf"
46
+ pdf.write_bytes(b"fake pdf")
47
+
48
+ class FakeConverter:
49
+ def convert(self, _path):
50
+ if markitdown_result == "raise":
51
+ raise ValueError("No text layer")
52
+ return SimpleNamespace(markdown=" ")
53
+
54
+ class FakeBitmap:
55
+ def to_numpy(self):
56
+ return object()
57
+
58
+ class FakePage:
59
+ def render(self, scale):
60
+ assert scale == 2
61
+ return FakeBitmap()
62
+
63
+ def close(self):
64
+ pass
65
+
66
+ class FakePdfDocument:
67
+ def __init__(self, path):
68
+ assert path == str(pdf)
69
+
70
+ def __len__(self):
71
+ return 2
72
+
73
+ def __getitem__(self, _index):
74
+ return FakePage()
75
+
76
+ def close(self):
77
+ pass
78
+
79
+ class FakeOCR:
80
+ def __call__(self, _image):
81
+ return SimpleNamespace(txts=("recognized text",))
82
+
83
+ monkeypatch.setitem(
84
+ sys.modules, "markitdown", SimpleNamespace(MarkItDown=FakeConverter)
85
+ )
86
+ monkeypatch.setitem(
87
+ sys.modules,
88
+ "pypdfium2",
89
+ SimpleNamespace(PdfDocument=FakePdfDocument),
90
+ )
91
+ dataloader._get_ocr_engine.cache_clear()
92
+ monkeypatch.setattr(dataloader, "_get_ocr_engine", FakeOCR)
93
+
94
+ profile = convert_uploaded_documents([str(pdf)])
95
+
96
+ assert profile.count("recognized text") == 2
97
+
98
+
99
  def test_uploaded_unsupported_file_type_is_rejected(tmp_path):
100
  unsupported = tmp_path / "profile.exe"
101
  unsupported.write_text("not a document", encoding="utf-8")
tests/test_retrieval.py CHANGED
@@ -34,11 +34,59 @@ def test_content_survives_chunking():
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
 
@@ -68,12 +116,19 @@ class TestSearch:
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
 
 
34
 
35
 
36
  def test_long_documents_split_into_several_chunks():
37
+ body = " ".join(f"word{i}" for i in range(40))
38
+ chunks = chunk_documents(
39
+ profile(f"# Source: resume\n\n{body}"),
40
+ words_per_chunk=10,
41
+ overlap=3,
42
+ )
43
  assert len(chunks) > 1
44
 
45
 
46
+ def test_adjacent_chunks_overlap_by_the_requested_words():
47
+ body = " ".join(f"word{i}" for i in range(23))
48
+ chunks = chunk_documents(
49
+ profile(f"# Source: resume\n\n{body}"),
50
+ words_per_chunk=10,
51
+ overlap=3,
52
+ )
53
+ chunk_words = [chunk.split("]\n", 1)[1].split() for chunk in chunks]
54
+
55
+ assert all(
56
+ previous[-3:] == current[:3]
57
+ for previous, current in zip(chunk_words, chunk_words[1:])
58
+ )
59
+
60
+
61
+ def test_chunks_do_not_cross_source_document_boundaries():
62
+ chunks = chunk_documents(
63
+ profile(
64
+ "# Source: resume\n\n" + " ".join(f"resume{i}" for i in range(12)),
65
+ "# Source: projects\n\n" + " ".join(f"project{i}" for i in range(12)),
66
+ ),
67
+ words_per_chunk=8,
68
+ overlap=2,
69
+ )
70
+
71
+ assert all(
72
+ ("resume" in chunk) != ("project" in chunk)
73
+ for chunk in chunks
74
+ )
75
+
76
+
77
+ @pytest.mark.parametrize(
78
+ "words_per_chunk,overlap",
79
+ [(0, 0), (10, -1), (10, 10), (10, 11)],
80
+ )
81
+ def test_invalid_word_chunk_settings_are_rejected(words_per_chunk, overlap):
82
+ with pytest.raises(ValueError, match="words_per_chunk > overlap"):
83
+ chunk_documents(
84
+ "text",
85
+ words_per_chunk=words_per_chunk,
86
+ overlap=overlap,
87
+ )
88
+
89
+
90
  def test_short_documents_stay_whole():
91
  assert len(chunk_documents(profile("# Source: resume\n\nShort."))) == 1
92
 
 
116
  def test_search_respects_k(self, index):
117
  assert len(index.search("python", k=2)) == 2
118
 
119
+ def test_hybrid_search_returns_bm25_and_dense_matches(self, index):
120
+ results = index.search("volunteer", k=3)
121
+ assert "Volunteer" in results[0]
122
+
123
  def test_k_larger_than_the_corpus_is_safe(self, index):
124
  assert len(index.search("python", k=99)) == 3
125
 
126
  def test_empty_query_returns_nothing(self, index):
127
  assert index.search(" ") == []
128
 
129
+ def test_non_positive_k_returns_nothing(self, index):
130
+ assert index.search("python", k=0) == []
131
+
132
  def test_search_text_joins_results(self, index):
133
  assert "---" in index.search_text("python", k=2)
134
 
tests/test_skills.py CHANGED
@@ -1,4 +1,4 @@
1
- """Skill files, registry, and the 6500-token cap."""
2
 
3
  import pytest
4
 
@@ -47,7 +47,16 @@ def test_cover_letter_skill_requires_a_conventional_letter_only():
47
  assert "bullet points" in text
48
 
49
 
50
- def test_skill_responses_are_capped_at_6500_tokens():
 
 
 
 
 
 
 
 
 
51
  assert get_settings().skill_max_tokens == 6500 == config.DEFAULT_SKILL_MAX_TOKENS
52
 
53
 
 
1
+ """Skill files, registry, and skill-specific response caps."""
2
 
3
  import pytest
4
 
 
47
  assert "bullet points" in text
48
 
49
 
50
+ def test_email_skill_returns_only_one_final_email_without_explanation():
51
+ text = load_skill("email")
52
+ assert "one subject line, not options" in text
53
+ assert "Do not reveal analysis or reasoning" in text
54
+ assert "Return only the finished email" in text
55
+ assert "Do not add a heading, introduction, alternatives, explanation, rationale, suggestions" in text
56
+ assert "3 highly optimized" not in text
57
+
58
+
59
+ def test_prose_skill_responses_are_capped_at_6500_tokens():
60
  assert get_settings().skill_max_tokens == 6500 == config.DEFAULT_SKILL_MAX_TOKENS
61
 
62