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

feature: data uploading button, job descripiton fallback, requrements.txt

Browse files
.env.example DELETED
@@ -1,9 +0,0 @@
1
- DATA_DIR=FOLDER PATH with your data (e.g. resumes, job descriptions, etc.)
2
- OPENROUTER_API_KEY=OPENROUTER API KEY
3
- CANDIDATE_NAME=YOUR NAME
4
-
5
- # Observability (optional) — omit or leave blank to skip tracing entirely.
6
- # Get your own keys at https://app.langfuse.com
7
- LANGFUSE_SECRET_KEY=
8
- LANGFUSE_PUBLIC_KEY=
9
- LANGFUSE_BASE_URL=
 
 
 
 
 
 
 
 
 
 
.gitignore CHANGED
@@ -3,6 +3,7 @@ data/
3
  .venv/
4
  __pycache__/
5
  *.pyc
 
6
 
7
  # LangGraph checkpoints
8
  checkpoints.sqlite
 
3
  .venv/
4
  __pycache__/
5
  *.pyc
6
+ .claude
7
 
8
  # LangGraph checkpoints
9
  checkpoints.sqlite
README.md CHANGED
@@ -43,10 +43,10 @@ Then:
43
  pip install -r requirements.txt
44
  ```
45
 
46
- **2. Configure `.env`** in the project root:
47
 
48
  ```ini
49
- OPENROUTER_API_KEY=... # required
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,9 +57,8 @@ LANGFUSE_HOST=https://us.cloud.langfuse.com
57
 
58
  | Variable | Default | Purpose |
59
  |---|---|---|
60
- | `OPENROUTER_API_KEY` | — | Required. Startup fails without it. |
61
  | `CANDIDATE_NAME` | `the candidate` | Your name, used in the system prompt. |
62
- | `DATA_DIR` | `data` | Folder holding your profile documents. Relative paths resolve against the project root. |
63
  | `OPENROUTER_MODEL` | `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free` | OpenRouter model id. |
64
  | `LLM_TEMPERATURE` | `0.0` | Sampling temperature. |
65
  | `LLM_MAX_TOKENS` | `3000` | Response cap for ordinary chat. See the token budget note below. |
@@ -72,26 +71,17 @@ LANGFUSE_HOST=https://us.cloud.langfuse.com
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. |
74
 
75
- **3. Add your documents.** Put your resume, project write-ups, and any other career documents
76
- in `data/`. PDFs must be converted to Markdown first — the app reads only `.md`:
77
 
78
  ```bash
79
- pip install -r requirements-ingest.txt # docling, heavy; needed only for this step
80
- python pdf_2_markdown_converter.py
81
  ```
82
 
83
- The converter reads and writes `data/` directly; it does not honour `DATA_DIR`. If you point
84
- `DATA_DIR` elsewhere, convert in `data/` first and move the `.md` files across.
85
-
86
- Startup fails if the directory is missing or holds no `.md` file, so do this before step 4.
87
 
88
- **4. Run**
89
 
90
- ```bash
91
- python app.py # http://localhost:7860
92
- ```
93
-
94
- Paste a job description into the left panel, then chat or use the four skill buttons (Resume review, ATS score & keywords, Cover letter, Application email). Select
95
  **Stop** beside the message box to cancel a response that's being generated; any text already
96
  received remains in the conversation.
97
 
@@ -108,18 +98,19 @@ job-chat/
108
  ├── retrieval.py # Chunking and similarity search over the profile
109
  ├── embeddings.py # fastembed wrapper
110
  ├── ats.py # ATS schema and score calculation
111
- ├── dataloader.py # Loads and concatenates the Markdown profile documents
112
  ├── config.py # Environment settings with validation
113
  ├── observability.py # Langfuse tracing (optional, fails open)
114
- ├── pdf_2_markdown_converter.py # One-off: PDF -> Markdown
115
- ├── data/ # Your documents (gitignored)
116
  ├── tests/ # Unit tests; no API calls
117
  ├── .github/workflows/tests.yml # CI: runs the suite on push and pull request
118
  ├── requirements.txt # Runtime
119
- ├── requirements-ingest.txt # PDF conversion only
120
  └── requirements-dev.txt # Tests (install on top of requirements.txt)
121
  ```
122
 
 
 
 
 
123
  ## Graph structure
124
 
125
  A skill button routes on its `action` straight to that skill. Free-form chat is classified by a
@@ -197,7 +188,7 @@ python -m pytest tests/ -q
197
  Unit tests only — no API calls, no keys needed. They cover the ATS score arithmetic, config
198
  validation, document loading, chunking and retrieval, semantic routing, node selection, history
199
  trimming, temperature selection, and prompt templating. `tests/test_isolation.py` asserts the
200
- suite never touches your real credentials or your `data/` documents. CI runs the same command on
201
  every push to `main` and every pull request.
202
 
203
  ## Token budget
 
43
  pip install -r requirements.txt
44
  ```
45
 
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
 
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. |
 
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. |
73
 
74
+ **3. Run**
 
75
 
76
  ```bash
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
86
  received remains in the conversation.
87
 
 
98
  ├── retrieval.py # Chunking and similarity search over the profile
99
  ├── embeddings.py # fastembed wrapper
100
  ├── ats.py # ATS schema and score calculation
101
+ ├── dataloader.py # Converts uploads and formats Markdown profile documents
102
  ├── config.py # Environment settings with validation
103
  ├── observability.py # Langfuse tracing (optional, fails open)
 
 
104
  ├── tests/ # Unit tests; no API calls
105
  ├── .github/workflows/tests.yml # CI: runs the suite on push and pull request
106
  ├── requirements.txt # Runtime
 
107
  └── requirements-dev.txt # Tests (install on top of requirements.txt)
108
  ```
109
 
110
+ Uploaded profile documents are held in server memory for the active Gradio session. Only upload
111
+ personal documents to a deployment you trust. Hugging Face may also process temporary upload
112
+ files as part of serving the app; consult the Space's privacy and storage settings.
113
+
114
  ## Graph structure
115
 
116
  A skill button routes on its `action` straight to that skill. Free-form chat is classified by a
 
188
  Unit tests only — no API calls, no keys needed. They cover the ATS score arithmetic, config
189
  validation, document loading, chunking and retrieval, semantic routing, node selection, history
190
  trimming, temperature selection, and prompt templating. `tests/test_isolation.py` asserts the
191
+ suite never touches your real credentials or profile documents. CI runs the same command on
192
  every push to `main` and every pull request.
193
 
194
  ## Token budget
app.py CHANGED
@@ -2,30 +2,99 @@ import logging
2
  import sys
3
  import uuid
4
  from functools import partial
 
5
 
6
  import gradio as gr
7
  from langchain_core.messages import AIMessage, HumanMessage
8
 
9
  from config import ConfigError, get_settings
10
- from graph import build_graph
 
11
  from observability import build_run_config, flush
12
- from graph import ATS_NODE
13
  from skills import ACTIONS
14
 
15
  logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
16
  logger = logging.getLogger("job-chat")
17
 
18
  try:
19
- SETTINGS = get_settings()
20
- GRAPH = build_graph()
21
- except (ConfigError, FileNotFoundError) as exc:
22
  sys.exit(f"Startup failed: {exc}")
23
 
 
 
24
 
25
  def new_thread_id():
26
  return str(uuid.uuid4())
27
 
28
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  def jd_status(job_description):
30
  if job_description and job_description.strip():
31
  return f"**Loaded** — {len(job_description.split())} words"
@@ -43,14 +112,21 @@ def show_action_label(label, chat_history):
43
  return chat_history + [{"role": "user", "content": f"**{label}**"}]
44
 
45
 
46
- def run_action(key, chat_history, job_description, thread_id):
47
  _, instruction = ACTIONS[key]
48
  yield from stream_response(
49
- chat_history, job_description, thread_id, model_query=instruction, action=key
 
 
 
 
 
50
  )
51
 
52
 
53
- def stream_response(chat_history, job_description, thread_id, model_query=None, action=""):
 
 
54
  """Stream the assistant reply for the last user message.
55
 
56
  model_query lets a skill button send its instruction to the model while the
@@ -64,6 +140,23 @@ def stream_response(chat_history, job_description, thread_id, model_query=None,
64
  return
65
 
66
  job_description = (job_description or "").strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  state = {
68
  "messages": [HumanMessage(content=model_query or chat_history[-1]["content"])],
69
  "job_description": job_description,
@@ -78,7 +171,7 @@ def stream_response(chat_history, job_description, thread_id, model_query=None,
78
  yield chat_history
79
 
80
  streamed = False
81
- for mode, chunk in GRAPH.stream(state, config, stream_mode=["messages", "updates"]):
82
  if mode == "messages":
83
  message, meta = chunk
84
  # The ATS node's model call emits structured-output fragments, not prose.
@@ -114,12 +207,39 @@ def stream_response(chat_history, job_description, thread_id, model_query=None,
114
 
115
  with gr.Blocks(title="job-chat", fill_height=True, analytics_enabled=False) as demo:
116
  thread_id = gr.State(new_thread_id)
 
117
 
118
  gr.Markdown("## Job chat")
119
  gr.Markdown("Ask about your background, draft interview answers, or test your fit against a role.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
 
121
  with gr.Row(equal_height=False):
122
  with gr.Column(scale=1):
 
 
 
 
 
 
 
 
 
 
 
123
  gr.Markdown("### Target role")
124
  jd_box = gr.Textbox(
125
  lines=18,
@@ -150,6 +270,21 @@ with gr.Blocks(title="job-chat", fill_height=True, analytics_enabled=False) as d
150
 
151
  jd_box.change(jd_status, inputs=jd_box, outputs=jd_state)
152
  clear_jd_btn.click(lambda: "", outputs=jd_box)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
 
154
  # A new thread id starts a fresh checkpointed conversation; the old one stays on disk.
155
  clear_chat_btn.click(lambda: ([], new_thread_id()), outputs=[chatbot, thread_id])
@@ -163,7 +298,7 @@ with gr.Blocks(title="job-chat", fill_height=True, analytics_enabled=False) as d
163
  queue=False,
164
  ).then(
165
  stream_response,
166
- inputs=[chatbot, jd_box, thread_id],
167
  outputs=chatbot,
168
  ))
169
 
@@ -175,7 +310,7 @@ with gr.Blocks(title="job-chat", fill_height=True, analytics_enabled=False) as d
175
  queue=False,
176
  ).then(
177
  partial(run_action, key),
178
- inputs=[chatbot, jd_box, thread_id],
179
  outputs=chatbot,
180
  ))
181
 
 
2
  import sys
3
  import uuid
4
  from functools import partial
5
+ 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
12
+ from graph import ATS_NODE, build_graph
13
  from observability import build_run_config, flush
 
14
  from skills import ACTIONS
15
 
16
  logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
17
  logger = logging.getLogger("job-chat")
18
 
19
  try:
20
+ SETTINGS = get_settings(require_api_key=False)
21
+ except ConfigError as exc:
 
22
  sys.exit(f"Startup failed: {exc}")
23
 
24
+ SESSION_DATA = {}
25
+
26
 
27
  def new_thread_id():
28
  return str(uuid.uuid4())
29
 
30
 
31
+ def discard_session_data(session_id):
32
+ SESSION_DATA.pop(session_id, None)
33
+
34
+
35
+ def graph_for_session(session_id):
36
+ session = SESSION_DATA.get(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:
43
+ return "", "Paste your OpenRouter API key to connect."
44
+
45
+ session = SESSION_DATA.setdefault(session_id, {})
46
+ session["api_key"] = api_key
47
+ profile_data = session.get("profile_data")
48
+ if not profile_data:
49
+ return "", "Key saved for this browser session. Upload your profile documents to continue."
50
+
51
+ try:
52
+ session["graph"] = build_graph(api_key=api_key, profile_data=profile_data)
53
+ except Exception as exc:
54
+ detail = str(exc).replace(api_key, "[redacted]")
55
+ logger.error("Could not initialize an OpenRouter graph: %s", detail)
56
+ return "", f"**Could not connect.** {detail}"
57
+
58
+ return "", "Key and profile ready for this browser session. Send a message to verify the key."
59
+
60
+
61
+ def process_profile_upload(files, session_id):
62
+ if not files:
63
+ return "Choose one or more profile documents to upload."
64
+
65
+ try:
66
+ profile_data = convert_uploaded_documents(files)
67
+ except Exception as exc:
68
+ logger.error("Could not process profile upload: %s", exc)
69
+ return f"**Upload failed.** {exc}"
70
+
71
+ session = SESSION_DATA.setdefault(session_id, {})
72
+ session["profile_data"] = profile_data
73
+ session["graph"] = None
74
+ api_key = session.get("api_key") or SETTINGS.openrouter_api_key
75
+
76
+ if not api_key:
77
+ return "Profile converted to Markdown and held for this session. Connect your OpenRouter key to continue."
78
+
79
+ try:
80
+ session["graph"] = build_graph(api_key=api_key, profile_data=profile_data)
81
+ except Exception as exc:
82
+ detail = str(exc).replace(api_key, "[redacted]")
83
+ logger.error("Could not initialize the uploaded profile: %s", detail)
84
+ return f"**Profile converted, but setup failed.** {detail}"
85
+
86
+ names = ", ".join(Path(file_path).name for file_path in files)
87
+ return f"**Profile ready.** Converted and loaded: {names}"
88
+
89
+
90
+ def clear_profile(session_id):
91
+ session = SESSION_DATA.get(session_id)
92
+ if session:
93
+ session.pop("profile_data", None)
94
+ session.pop("graph", None)
95
+ return None, "Uploaded profile cleared from this session."
96
+
97
+
98
  def jd_status(job_description):
99
  if job_description and job_description.strip():
100
  return f"**Loaded** — {len(job_description.split())} words"
 
112
  return chat_history + [{"role": "user", "content": f"**{label}**"}]
113
 
114
 
115
+ def run_action(key, chat_history, job_description, thread_id, session_id):
116
  _, instruction = ACTIONS[key]
117
  yield from stream_response(
118
+ chat_history,
119
+ job_description,
120
+ thread_id,
121
+ model_query=instruction,
122
+ action=key,
123
+ session_id=session_id,
124
  )
125
 
126
 
127
+ def stream_response(
128
+ chat_history, job_description, thread_id, session_id=None, model_query=None, action=""
129
+ ):
130
  """Stream the assistant reply for the last user message.
131
 
132
  model_query lets a skill button send its instruction to the model while the
 
140
  return
141
 
142
  job_description = (job_description or "").strip()
143
+ if not job_description:
144
+ chat_history = chat_history + [{
145
+ "role": "assistant",
146
+ "content": "Paste a job description in the **Target role** panel before asking a question or using a skill.",
147
+ }]
148
+ yield chat_history
149
+ return
150
+
151
+ graph = graph_for_session(session_id)
152
+ if graph is None:
153
+ chat_history = chat_history + [{
154
+ "role": "assistant",
155
+ "content": "Upload your profile documents and connect an OpenRouter API key before chatting.",
156
+ }]
157
+ yield chat_history
158
+ return
159
+
160
  state = {
161
  "messages": [HumanMessage(content=model_query or chat_history[-1]["content"])],
162
  "job_description": job_description,
 
171
  yield chat_history
172
 
173
  streamed = False
174
+ for mode, chunk in graph.stream(state, config, stream_mode=["messages", "updates"]):
175
  if mode == "messages":
176
  message, meta = chunk
177
  # The ATS node's model call emits structured-output fragments, not prose.
 
207
 
208
  with gr.Blocks(title="job-chat", fill_height=True, analytics_enabled=False) as demo:
209
  thread_id = gr.State(new_thread_id)
210
+ session_id = gr.State(new_thread_id, delete_callback=discard_session_data)
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):
232
+ gr.Markdown("### Your profile")
233
+ profile_upload = gr.File(
234
+ label="Upload profile documents",
235
+ file_count="multiple",
236
+ file_types=[".pdf", ".docx", ".md", ".txt"],
237
+ type="filepath",
238
+ )
239
+ profile_status = gr.Markdown(
240
+ "Upload PDF, DOCX, Markdown, or text files. A new upload replaces the previous profile."
241
+ )
242
+ clear_profile_btn = gr.Button("Clear uploaded profile")
243
  gr.Markdown("### Target role")
244
  jd_box = gr.Textbox(
245
  lines=18,
 
270
 
271
  jd_box.change(jd_status, inputs=jd_box, outputs=jd_state)
272
  clear_jd_btn.click(lambda: "", outputs=jd_box)
273
+ connect_key_btn.click(
274
+ connect_api_key,
275
+ inputs=[api_key_box, session_id],
276
+ outputs=[api_key_box, key_status],
277
+ )
278
+ profile_upload.upload(
279
+ process_profile_upload,
280
+ inputs=[profile_upload, session_id],
281
+ outputs=profile_status,
282
+ )
283
+ clear_profile_btn.click(
284
+ clear_profile,
285
+ inputs=session_id,
286
+ outputs=[profile_upload, profile_status],
287
+ )
288
 
289
  # A new thread id starts a fresh checkpointed conversation; the old one stays on disk.
290
  clear_chat_btn.click(lambda: ([], new_thread_id()), outputs=[chatbot, thread_id])
 
298
  queue=False,
299
  ).then(
300
  stream_response,
301
+ inputs=[chatbot, jd_box, thread_id, session_id],
302
  outputs=chatbot,
303
  ))
304
 
 
310
  queue=False,
311
  ).then(
312
  partial(run_action, key),
313
+ inputs=[chatbot, jd_box, thread_id, session_id],
314
  outputs=chatbot,
315
  ))
316
 
config.py CHANGED
@@ -23,7 +23,6 @@ DEFAULT_DRAFTING_TEMPERATURE = 0.4
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_DATA_DIR = "data"
27
  DEFAULT_LANGFUSE_HOST = "https://cloud.langfuse.com"
28
  DEFAULT_CHECKPOINT_DB = "checkpoints.sqlite"
29
  # Turns of conversation replayed to the model. Older turns stay in the
@@ -42,7 +41,6 @@ class ConfigError(RuntimeError):
42
  class Settings:
43
  openrouter_api_key: str
44
  candidate_name: str
45
- data_dir: Path
46
  model: str
47
  temperature: float
48
  drafting_temperature: float
@@ -82,36 +80,15 @@ def _get_int(name, default):
82
  raise ConfigError(f"{name} must be an integer, got {raw!r}")
83
 
84
 
85
- def _resolve_data_dir():
86
- raw = os.getenv("DATA_DIR", "").strip() or DEFAULT_DATA_DIR
87
- data_dir = Path(raw)
88
- if not data_dir.is_absolute():
89
- data_dir = PROJECT_ROOT / data_dir
90
-
91
- if not data_dir.is_dir():
92
- raise ConfigError(
93
- f"DATA_DIR does not exist: {data_dir}\n"
94
- f"Set DATA_DIR in .env to the folder holding your profile documents, "
95
- f"or remove it to use the default ({PROJECT_ROOT / DEFAULT_DATA_DIR})."
96
- )
97
- if not any(data_dir.glob("*.md")):
98
- raise ConfigError(
99
- f"No .md documents found in {data_dir}\n"
100
- f"Run pdf_2_markdown_converter.py to convert your PDFs first."
101
- )
102
- return data_dir
103
-
104
-
105
- @lru_cache(maxsize=1)
106
- def get_settings() -> Settings:
107
  api_key = os.getenv("OPENROUTER_API_KEY", "").strip()
108
- if not api_key:
109
  raise ConfigError("OPENROUTER_API_KEY is not set. Add it to your .env file.")
110
 
111
  return Settings(
112
  openrouter_api_key=api_key,
113
  candidate_name=os.getenv("CANDIDATE_NAME", "").strip() or "the candidate",
114
- data_dir=_resolve_data_dir(),
115
  model=os.getenv("OPENROUTER_MODEL", "").strip() or DEFAULT_MODEL,
116
  temperature=_get_float("LLM_TEMPERATURE", DEFAULT_TEMPERATURE),
117
  drafting_temperature=_get_float("LLM_DRAFTING_TEMPERATURE", DEFAULT_DRAFTING_TEMPERATURE),
 
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
 
41
  class Settings:
42
  openrouter_api_key: str
43
  candidate_name: str
 
44
  model: str
45
  temperature: float
46
  drafting_temperature: float
 
80
  raise ConfigError(f"{name} must be an integer, got {raw!r}")
81
 
82
 
83
+ @lru_cache(maxsize=2)
84
+ def get_settings(require_api_key=True) -> Settings:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  api_key = os.getenv("OPENROUTER_API_KEY", "").strip()
86
+ if require_api_key and not api_key:
87
  raise ConfigError("OPENROUTER_API_KEY is not set. Add it to your .env file.")
88
 
89
  return Settings(
90
  openrouter_api_key=api_key,
91
  candidate_name=os.getenv("CANDIDATE_NAME", "").strip() or "the candidate",
 
92
  model=os.getenv("OPENROUTER_MODEL", "").strip() or DEFAULT_MODEL,
93
  temperature=_get_float("LLM_TEMPERATURE", DEFAULT_TEMPERATURE),
94
  drafting_temperature=_get_float("LLM_DRAFTING_TEMPERATURE", DEFAULT_DRAFTING_TEMPERATURE),
dataloader.py CHANGED
@@ -1,23 +1,44 @@
1
  from pathlib import Path
2
 
3
  SEPARATOR = "\n\n" + "-" * 60 + "\n\n"
 
4
 
5
 
6
- class ProfileLoader:
7
- """Loads the candidate's markdown profile documents into a single context string."""
 
 
 
 
 
 
 
 
8
 
9
- def __init__(self, data_dir):
10
- self.data_dir = Path(data_dir)
11
 
12
- def load_and_format_markdown(self):
13
- # Sorted so document order — and therefore model output — is reproducible.
14
- md_files = sorted(self.data_dir.glob("*.md"))
15
- if not md_files:
16
- raise FileNotFoundError(f"No .md documents found in {self.data_dir}")
17
 
18
- sections = []
19
- for md_file in md_files:
20
- content = md_file.read_text(encoding="utf-8").strip()
21
- sections.append(f"# Source: {md_file.stem}\n\n{content}")
 
 
 
 
 
22
 
23
- return SEPARATOR.join(sections)
 
 
 
 
 
 
 
 
 
 
 
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."""
9
+ sections = [
10
+ f"# Source: {Path(name).stem}\n\n{content.strip()}"
11
+ for name, content in documents
12
+ if content and content.strip()
13
+ ]
14
+ if not sections:
15
+ raise ValueError("The uploaded documents contain no readable text.")
16
+ return SEPARATOR.join(sections)
17
 
 
 
18
 
19
+ def convert_uploaded_documents(files):
20
+ """Convert supported uploaded files to one Markdown profile in memory."""
21
+ documents = []
22
+ converter = None
 
23
 
24
+ for file_path in files or []:
25
+ path = Path(file_path)
26
+ extension = path.suffix.lower()
27
+ if extension not in SUPPORTED_UPLOAD_EXTENSIONS:
28
+ supported = ", ".join(sorted(SUPPORTED_UPLOAD_EXTENSIONS))
29
+ raise ValueError(
30
+ f"Unsupported file type '{extension or '(none)'}' for {path.name}. "
31
+ f"Upload files with these extensions: {supported}."
32
+ )
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
39
+
40
+ converter = MarkItDown()
41
+ content = converter.convert(str(path)).markdown
42
+ documents.append((path.name, content))
43
+
44
+ return format_documents(documents)
embeddings.py CHANGED
@@ -14,7 +14,7 @@ from config import get_settings
14
  def _model():
15
  from fastembed import TextEmbedding
16
 
17
- return TextEmbedding(get_settings().embedding_model)
18
 
19
 
20
  def embed(texts) -> np.ndarray:
 
14
  def _model():
15
  from fastembed import TextEmbedding
16
 
17
+ return TextEmbedding(get_settings(require_api_key=False).embedding_model)
18
 
19
 
20
  def embed(texts) -> np.ndarray:
graph.py CHANGED
@@ -19,8 +19,7 @@ from langgraph.graph.message import add_messages
19
 
20
  import router as router_module
21
  from ats import ATSAnalysis, format_ats_report
22
- from config import OPENROUTER_BASE_URL, get_settings
23
- from dataloader import ProfileLoader
24
  from embeddings import embed
25
  from prompts import (
26
  NO_JD_PLACEHOLDER,
@@ -96,10 +95,13 @@ def route_action(state, semantic_router=None) -> str:
96
  return NODE_BY_ROUTE[semantic_router.route(query)]
97
 
98
 
99
- def build_llm(temperature=None, max_tokens=None):
100
- settings = get_settings()
 
 
 
101
  return ChatOpenAI(
102
- api_key=settings.openrouter_api_key,
103
  base_url=OPENROUTER_BASE_URL,
104
  model=settings.model,
105
  temperature=settings.temperature if temperature is None else temperature,
@@ -107,24 +109,31 @@ def build_llm(temperature=None, max_tokens=None):
107
  )
108
 
109
 
110
- def build_graph():
111
- settings = get_settings()
112
- profile_data = ProfileLoader(settings.data_dir).load_and_format_markdown()
 
 
 
 
113
  prompt = build_qa_prompt()
114
- llm = build_llm()
115
  chain = prompt | llm
116
  skill_prompt = build_skill_prompt()
117
  skill_chains = {
118
  key: skill_prompt | build_llm(
119
  settings.drafting_temperature if key in DRAFTING_SKILLS else None,
120
  settings.skill_max_tokens,
 
121
  )
122
  for key in SKILLS
123
  if key != "ats"
124
  }
125
  rag_chain = build_rag_prompt() | llm
126
  trivial_chain = build_trivial_prompt() | llm
127
- structured_llm = build_llm(max_tokens=settings.skill_max_tokens).with_structured_output(ATSAnalysis)
 
 
128
 
129
  index = ProfileIndex(chunk_documents(profile_data), embed)
130
  semantic_router = SemanticRouter(embed)
 
19
 
20
  import router as router_module
21
  from ats import ATSAnalysis, format_ats_report
22
+ from config import ConfigError, OPENROUTER_BASE_URL, get_settings
 
23
  from embeddings import embed
24
  from prompts import (
25
  NO_JD_PLACEHOLDER,
 
95
  return NODE_BY_ROUTE[semantic_router.route(query)]
96
 
97
 
98
+ def build_llm(temperature=None, max_tokens=None, api_key=None):
99
+ settings = get_settings(require_api_key=False)
100
+ api_key = api_key or settings.openrouter_api_key
101
+ if not api_key:
102
+ raise ConfigError("An OpenRouter API key is required to build the model.")
103
  return ChatOpenAI(
104
+ api_key=api_key,
105
  base_url=OPENROUTER_BASE_URL,
106
  model=settings.model,
107
  temperature=settings.temperature if temperature is None else temperature,
 
109
  )
110
 
111
 
112
+ def build_graph(api_key=None, profile_data=None):
113
+ settings = get_settings(require_api_key=False)
114
+ api_key = api_key or settings.openrouter_api_key
115
+ if not api_key:
116
+ raise ConfigError("An OpenRouter API key is required to build the graph.")
117
+ if not profile_data or not profile_data.strip():
118
+ raise ConfigError("Upload profile documents before building the graph.")
119
  prompt = build_qa_prompt()
120
+ llm = build_llm(api_key=api_key)
121
  chain = prompt | llm
122
  skill_prompt = build_skill_prompt()
123
  skill_chains = {
124
  key: skill_prompt | build_llm(
125
  settings.drafting_temperature if key in DRAFTING_SKILLS else None,
126
  settings.skill_max_tokens,
127
+ api_key=api_key,
128
  )
129
  for key in SKILLS
130
  if key != "ats"
131
  }
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)
observability.py CHANGED
@@ -21,7 +21,7 @@ def _init():
21
  return
22
  _initialised = True
23
 
24
- settings = get_settings()
25
  if not settings.tracing_enabled:
26
  logger.info("Langfuse tracing disabled (LANGFUSE_PUBLIC_KEY / LANGFUSE_SECRET_KEY not set).")
27
  return
 
21
  return
22
  _initialised = True
23
 
24
+ settings = get_settings(require_api_key=False)
25
  if not settings.tracing_enabled:
26
  logger.info("Langfuse tracing disabled (LANGFUSE_PUBLIC_KEY / LANGFUSE_SECRET_KEY not set).")
27
  return
pdf_2_markdown_converter.py DELETED
@@ -1,15 +0,0 @@
1
- from markitdown import MarkItDown
2
- import os
3
- from tqdm import tqdm
4
-
5
- md_converter = MarkItDown()
6
-
7
- home_dir = "C:\\Users\\Jasmi\\OneDrive\\Desktop\\job"
8
- file_names = ["Haider_Ali_AI_engineer_resume.pdf", "Projects.pdf", "Volunteer Experiences.pdf"]
9
-
10
- for file_name in file_names:
11
- file_path = os.path.join(home_dir, file_name)
12
- result = md_converter.convert(file_path)
13
-
14
- with open(os.path.join(home_dir, "job-chat", "data", file_name.replace(".pdf", ".md")), "w", encoding="utf-8") as f:
15
- f.write(result.markdown)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
prompts.py CHANGED
@@ -39,7 +39,7 @@ RAG_SYSTEM_PROMPT = """You are {candidate_name}'s assistant. Answer the user's n
39
  def render_system_prompt(context_text, job_description) -> str:
40
  """The system prompt as plain text, for paths that bypass the chat template."""
41
  return QA_SYSTEM_PROMPT.format(
42
- candidate_name=get_settings().candidate_name,
43
  context_text=context_text,
44
  job_description=job_description or NO_JD_PLACEHOLDER,
45
  )
@@ -50,7 +50,7 @@ def _chat_prompt(system_text) -> ChatPromptTemplate:
50
  ("system", system_text),
51
  MessagesPlaceholder("history"),
52
  ("user", "{user_query}"),
53
- ]).partial(candidate_name=get_settings().candidate_name)
54
 
55
 
56
  def build_qa_prompt() -> ChatPromptTemplate:
@@ -98,7 +98,7 @@ SKILL_SYSTEM_PROMPT = """You are the interview-preparation assistant for {candid
98
  def render_skill_prompt(skill_text, context_text, job_description) -> str:
99
  """The skill system prompt as plain text, for paths that bypass the chat template."""
100
  return SKILL_SYSTEM_PROMPT.format(
101
- candidate_name=get_settings().candidate_name,
102
  skill_text=skill_text,
103
  context_text=context_text,
104
  job_description=job_description or NO_JD_PLACEHOLDER,
 
39
  def render_system_prompt(context_text, job_description) -> str:
40
  """The system prompt as plain text, for paths that bypass the chat template."""
41
  return QA_SYSTEM_PROMPT.format(
42
+ candidate_name=get_settings(require_api_key=False).candidate_name,
43
  context_text=context_text,
44
  job_description=job_description or NO_JD_PLACEHOLDER,
45
  )
 
50
  ("system", system_text),
51
  MessagesPlaceholder("history"),
52
  ("user", "{user_query}"),
53
+ ]).partial(candidate_name=get_settings(require_api_key=False).candidate_name)
54
 
55
 
56
  def build_qa_prompt() -> ChatPromptTemplate:
 
98
  def render_skill_prompt(skill_text, context_text, job_description) -> str:
99
  """The skill system prompt as plain text, for paths that bypass the chat template."""
100
  return SKILL_SYSTEM_PROMPT.format(
101
+ candidate_name=get_settings(require_api_key=False).candidate_name,
102
  skill_text=skill_text,
103
  context_text=context_text,
104
  job_description=job_description or NO_JD_PLACEHOLDER,
requirements-ingest.txt DELETED
@@ -1,4 +0,0 @@
1
- # Only needed to run pdf_2_markdown_converter.py (PDF -> Markdown).
2
- # Pulls in torch and other heavy deps; the chat app does not need these.
3
- docling==2.118.0
4
- tqdm==4.69.0
 
 
 
 
 
requirements.txt CHANGED
@@ -6,6 +6,7 @@ langgraph==1.2.10
6
  langgraph-checkpoint-sqlite==3.1.1
7
  langfuse==4.14.3
8
  python-dotenv==1.2.2
 
9
 
10
  # Makes Python trust the OS certificate store. Required on networks that do
11
  # TLS inspection (corporate proxy / antivirus); harmless elsewhere.
 
6
  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.
tests/conftest.py CHANGED
@@ -5,23 +5,15 @@ 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["OPENROUTER_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
 
5
  identically on a clean CI machine.
6
  """
7
 
 
8
  import os
 
9
  import sys
 
10
  from pathlib import Path
11
 
12
  sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
13
 
 
 
 
 
14
  # load_dotenv() does not override variables that are already set, so these win
15
  # over any real .env on a developer machine.
16
  os.environ["OPENROUTER_API_KEY"] = "test-key-not-used"
 
17
  os.environ["CANDIDATE_NAME"] = "Test Candidate"
18
  # Set to empty rather than deleted: load_dotenv() skips keys already present, so
19
  # an empty value keeps real Langfuse credentials out and stops the suite emitting
tests/test_app.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import app
2
+
3
+
4
+ def test_chat_without_job_description_does_not_access_the_graph(monkeypatch):
5
+ def graph_must_not_be_requested(_session_id):
6
+ raise AssertionError("A model graph must not be accessed without a job description.")
7
+
8
+ monkeypatch.setattr(app, "graph_for_session", graph_must_not_be_requested)
9
+
10
+ result = list(app.stream_response(
11
+ [{"role": "user", "content": "Tell me about my experience"}],
12
+ " \n ",
13
+ "thread-id",
14
+ "session-id",
15
+ ))
16
+
17
+ assert len(result) == 1
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")
24
+ build_calls = []
25
+ monkeypatch.setattr(app, "convert_uploaded_documents", lambda _files: "# Profile")
26
+ monkeypatch.setattr(
27
+ app,
28
+ "build_graph",
29
+ lambda **kwargs: build_calls.append(kwargs) or "compiled-graph",
30
+ )
31
+ monkeypatch.setitem(app.SESSION_DATA, "upload-test-session", {})
32
+
33
+ status = app.process_profile_upload(
34
+ [str(markdown_file)], "upload-test-session"
35
+ )
36
+
37
+ assert "Profile ready" in status
38
+ assert build_calls == [{
39
+ "api_key": app.SETTINGS.openrouter_api_key,
40
+ "profile_data": "# Profile",
41
+ }]
42
+ assert app.graph_for_session("upload-test-session") == "compiled-graph"
tests/test_config.py CHANGED
@@ -12,17 +12,10 @@ def clear_settings_cache():
12
 
13
 
14
  @pytest.fixture
15
- def data_dir(tmp_path):
16
- (tmp_path / "resume.md").write_text("# Resume", encoding="utf-8")
17
- return tmp_path
18
-
19
-
20
- @pytest.fixture
21
- def base_env(monkeypatch, data_dir):
22
  monkeypatch.setenv("OPENROUTER_API_KEY", "test-key")
23
- monkeypatch.setenv("DATA_DIR", str(data_dir))
24
  for name in ("OPENROUTER_MODEL", "LLM_TEMPERATURE", "LLM_MAX_TOKENS", "SKILL_MAX_TOKENS", "MAX_HISTORY_TURNS",
25
- "CANDIDATE_NAME", "LANGFUSE_HOST", "LANGFUSE_BASE_URL",
26
  "LANGFUSE_PUBLIC_KEY", "LANGFUSE_SECRET_KEY"):
27
  monkeypatch.delenv(name, raising=False)
28
 
@@ -33,18 +26,12 @@ def test_missing_api_key_is_rejected(base_env, monkeypatch):
33
  get_settings()
34
 
35
 
36
- def test_nonexistent_data_dir_is_rejected(base_env, monkeypatch, tmp_path):
37
- monkeypatch.setenv("DATA_DIR", str(tmp_path / "nope"))
38
- with pytest.raises(ConfigError, match="DATA_DIR does not exist"):
39
- get_settings()
40
 
 
41
 
42
- def test_data_dir_without_markdown_is_rejected(base_env, monkeypatch, tmp_path):
43
- empty = tmp_path / "empty"
44
- empty.mkdir()
45
- monkeypatch.setenv("DATA_DIR", str(empty))
46
- with pytest.raises(ConfigError, match="No .md documents"):
47
- get_settings()
48
 
49
 
50
  @pytest.mark.parametrize("var", ["LLM_TEMPERATURE", "LLM_MAX_TOKENS", "SKILL_MAX_TOKENS", "MAX_HISTORY_TURNS"])
@@ -75,16 +62,12 @@ def test_overrides_are_read_from_the_environment(base_env, monkeypatch):
75
  assert settings.max_history_turns == 3
76
 
77
 
78
- def test_relative_data_dir_resolves_against_the_project_root(base_env, monkeypatch, tmp_path):
79
- """A relative DATA_DIR must not depend on the process working directory."""
80
- project_root = tmp_path / "project"
81
- (project_root / "docs").mkdir(parents=True)
82
- (project_root / "docs" / "resume.md").write_text("# Resume", encoding="utf-8")
83
 
84
- monkeypatch.setattr(config, "PROJECT_ROOT", project_root)
85
- monkeypatch.setenv("DATA_DIR", "docs")
86
 
87
- assert get_settings().data_dir == project_root / "docs"
88
 
89
 
90
  class TestLangfuseHost:
 
12
 
13
 
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)
21
 
 
26
  get_settings()
27
 
28
 
29
+ def test_api_key_can_be_supplied_later_by_the_ui(base_env, monkeypatch):
30
+ monkeypatch.delenv("OPENROUTER_API_KEY")
 
 
31
 
32
+ settings = get_settings(require_api_key=False)
33
 
34
+ assert settings.openrouter_api_key == ""
 
 
 
 
 
35
 
36
 
37
  @pytest.mark.parametrize("var", ["LLM_TEMPERATURE", "LLM_MAX_TOKENS", "SKILL_MAX_TOKENS", "MAX_HISTORY_TURNS"])
 
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
 
68
+ settings = get_settings()
 
69
 
70
+ assert not hasattr(settings, "data_dir")
71
 
72
 
73
  class TestLangfuseHost:
tests/test_dataloader.py CHANGED
@@ -1,49 +1,44 @@
1
  import pytest
2
 
3
- from dataloader import ProfileLoader
4
 
5
 
6
- @pytest.fixture
7
- def docs(tmp_path):
8
- (tmp_path / "resume.md").write_text("Resume body", encoding="utf-8")
9
- (tmp_path / "projects.md").write_text("Projects body", encoding="utf-8")
10
- (tmp_path / "ignored.txt").write_text("not markdown", encoding="utf-8")
11
- return tmp_path
12
 
 
13
 
14
- def test_loads_every_markdown_file(docs):
15
- content = ProfileLoader(docs).load_and_format_markdown()
16
- assert "Resume body" in content
17
- assert "Projects body" in content
18
 
19
 
20
- def test_ignores_non_markdown_files(docs):
21
- assert "not markdown" not in ProfileLoader(docs).load_and_format_markdown()
 
22
 
 
 
23
 
24
- def test_documents_are_labelled_with_their_source(docs):
25
- content = ProfileLoader(docs).load_and_format_markdown()
26
- assert "# Source: resume" in content
27
- assert "# Source: projects" in content
28
 
 
 
 
29
 
30
- def test_order_is_sorted_and_therefore_reproducible(docs):
31
- """Unsorted glob order made identical questions produce different answers."""
32
- content = ProfileLoader(docs).load_and_format_markdown()
33
- assert content.index("# Source: projects") < content.index("# Source: resume")
34
- assert content == ProfileLoader(docs).load_and_format_markdown()
35
 
36
 
37
- def test_empty_directory_raises(tmp_path):
38
- with pytest.raises(FileNotFoundError, match="No .md documents"):
39
- ProfileLoader(tmp_path).load_and_format_markdown()
40
 
41
-
42
- def test_missing_directory_raises(tmp_path):
43
- with pytest.raises(FileNotFoundError):
44
- ProfileLoader(tmp_path / "absent").load_and_format_markdown()
45
-
46
-
47
- def test_unicode_survives_the_round_trip(tmp_path):
48
- (tmp_path / "doc.md").write_text("Café — naïve — 日本語", encoding="utf-8")
49
- assert "Café — naïve — 日本語" in ProfileLoader(tmp_path).load_and_format_markdown()
 
1
  import pytest
2
 
3
+ from dataloader import convert_uploaded_documents
4
 
5
 
6
+ def test_uploaded_markdown_and_text_are_formatted_without_disk_output(tmp_path):
7
+ markdown = tmp_path / "resume.md"
8
+ text = tmp_path / "projects.txt"
9
+ markdown.write_text("# Resume", encoding="utf-8")
10
+ text.write_text("Project work", encoding="utf-8")
 
11
 
12
+ profile = convert_uploaded_documents([str(markdown), str(text)])
13
 
14
+ assert "# Source: resume" in profile
15
+ assert "# Resume" in profile
16
+ assert "# Source: projects" in profile
17
+ assert "Project work" in profile
18
 
19
 
20
+ def test_uploaded_pdf_is_converted_to_markdown(tmp_path, monkeypatch):
21
+ import sys
22
+ from types import SimpleNamespace
23
 
24
+ pdf = tmp_path / "resume.pdf"
25
+ pdf.write_bytes(b"fake pdf")
26
 
27
+ class FakeConverter:
28
+ def convert(self, path):
29
+ assert path == str(pdf)
30
+ return SimpleNamespace(markdown="# Converted resume")
31
 
32
+ monkeypatch.setitem(
33
+ sys.modules, "markitdown", SimpleNamespace(MarkItDown=FakeConverter)
34
+ )
35
 
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")
42
 
43
+ with pytest.raises(ValueError, match="Unsupported file type"):
44
+ convert_uploaded_documents([str(unsupported)])
 
 
 
 
 
 
 
tests/test_graph.py CHANGED
@@ -3,7 +3,8 @@
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(
@@ -26,6 +27,11 @@ def test_missing_action_key_falls_back_to_chat():
26
  assert route_action({}) == "answer"
27
 
28
 
 
 
 
 
 
29
  class StubRouter:
30
  def __init__(self, decision):
31
  self.decision = decision
 
3
  import pytest
4
  from langchain_core.messages import AIMessage, HumanMessage, trim_messages
5
 
6
+ from config import ConfigError
7
+ from graph import build_graph, latest_text, route_action
8
 
9
 
10
  @pytest.mark.parametrize(
 
27
  assert route_action({}) == "answer"
28
 
29
 
30
+ def test_graph_rejects_empty_profile_before_model_setup():
31
+ with pytest.raises(ConfigError, match="Upload profile documents"):
32
+ build_graph(api_key="test-key", profile_data=" ")
33
+
34
+
35
  class StubRouter:
36
  def __init__(self, decision):
37
  self.decision = decision
tests/test_isolation.py CHANGED
@@ -21,7 +21,5 @@ def test_the_real_openrouter_key_is_not_used():
21
  assert get_settings().openrouter_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"]
 
21
  assert get_settings().openrouter_api_key == "test-key-not-used"
22
 
23
 
24
+ def test_profile_documents_are_not_loaded_during_startup():
25
+ assert not hasattr(get_settings(), "data_dir")
 
 
tests/test_temperature.py CHANGED
@@ -31,6 +31,16 @@ def test_build_llm_accepts_an_override():
31
  assert build_llm(0.4).temperature == 0.4
32
 
33
 
 
 
 
 
 
 
 
 
 
 
34
  def test_drafting_temperature_is_configurable(monkeypatch):
35
  monkeypatch.setenv("LLM_DRAFTING_TEMPERATURE", "0.9")
36
  get_settings.cache_clear()
 
31
  assert build_llm(0.4).temperature == 0.4
32
 
33
 
34
+ def test_build_llm_accepts_a_ui_key_without_an_environment_key(monkeypatch):
35
+ monkeypatch.delenv("OPENROUTER_API_KEY")
36
+ get_settings.cache_clear()
37
+ try:
38
+ llm = build_llm(api_key="ui-key")
39
+ assert llm.openai_api_key.get_secret_value() == "ui-key"
40
+ finally:
41
+ get_settings.cache_clear()
42
+
43
+
44
  def test_drafting_temperature_is_configurable(monkeypatch):
45
  monkeypatch.setenv("LLM_DRAFTING_TEMPERATURE", "0.9")
46
  get_settings.cache_clear()