ocr, email writing, rag
Browse files- .gitignore +2 -0
- Dockerfile +0 -16
- README.md +17 -11
- app.py +37 -26
- config.py +7 -2
- dataloader.py +68 -0
- graph.py +3 -2
- requirements.txt +6 -0
- retrieval.py +77 -47
- skills/email-writing.md +3 -4
- tests/test_app.py +12 -0
- tests/test_config.py +13 -2
- tests/test_dataloader.py +60 -0
- tests/test_retrieval.py +57 -2
- tests/test_skills.py +11 -2
.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=... #
|
| 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` | — |
|
| 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` | `
|
| 65 |
-
| `SKILL_MAX_TOKENS` | `6500` | Response cap for
|
|
|
|
| 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` | `
|
| 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**
|
| 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
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
|
|
|
| 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 =
|
| 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 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 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 |
-
#
|
| 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 =
|
| 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.
|
| 136 |
).with_structured_output(ATSAnalysis)
|
| 137 |
|
| 138 |
-
|
|
|
|
| 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 |
-
"""
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
for
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 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,
|
| 50 |
-
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
|
| 53 |
|
| 54 |
class ProfileIndex:
|
| 55 |
def __init__(self, chunks, embed_fn):
|
| 56 |
self.chunks = list(chunks)
|
|
|
|
|
|
|
|
|
|
| 57 |
if self.chunks:
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
else:
|
| 62 |
-
self.
|
| 63 |
-
|
| 64 |
|
| 65 |
def search(self, query, k=5):
|
| 66 |
-
"""Return
|
| 67 |
-
if not self.chunks or not query.strip():
|
| 68 |
return []
|
| 69 |
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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. **
|
| 33 |
-
2. **Draft
|
| 34 |
-
3. **
|
| 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 = "
|
| 38 |
-
chunks = chunk_documents(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|