codegood commited on
Commit
e287216
·
1 Parent(s): 0ea997a

Switch to OpenRouter and add response stop control

Browse files
README.md CHANGED
@@ -35,7 +35,7 @@ pip install -r requirements.txt
35
  **2. Configure `.env`** in the project root:
36
 
37
  ```ini
38
- GROQ_API_KEY=gsk_... # required
39
  CANDIDATE_NAME=Your Name # used in the system prompt
40
 
41
  # Optional — Langfuse tracing. Tracing turns on only when both keys are present.
@@ -46,10 +46,10 @@ LANGFUSE_HOST=https://us.cloud.langfuse.com
46
 
47
  | Variable | Default | Purpose |
48
  |---|---|---|
49
- | `GROQ_API_KEY` | — | Required. Startup fails without it. |
50
  | `CANDIDATE_NAME` | `the candidate` | Your name, used in the system prompt. |
51
  | `DATA_DIR` | `data` | Folder holding your profile documents. Relative paths resolve against the project root. |
52
- | `GROQ_MODEL` | `openai/gpt-oss-120b` | Model id. |
53
  | `LLM_TEMPERATURE` | `0.0` | Sampling temperature. |
54
  | `LLM_MAX_TOKENS` | `3000` | Response cap. See the token budget note below. |
55
  | `LLM_DRAFTING_TEMPERATURE` | `0.4` | Temperature for cover-letter drafting only. |
@@ -79,7 +79,9 @@ Startup fails if the directory is missing or holds no `.md` file, so do this bef
79
  python app.py # http://localhost:7860
80
  ```
81
 
82
- Paste a job description into the left panel, then chat or use the preset buttons.
 
 
83
 
84
  ## Project structure
85
 
@@ -182,10 +184,7 @@ every push to `main` and every pull request.
182
 
183
  ## Token budget
184
 
185
- Groq's free tier allows 8,000 tokens per minute and 200,000 per day for `openai/gpt-oss-120b`
186
- (check your own limits — they vary by model and account). A full-profile request sends your
187
- profile (~3,900 tokens) plus the job description plus the reserved response budget, so it costs
188
- roughly 7,000–8,000 tokens: about 25–28 such requests per day. Routed chat is much cheaper, so
189
- the real daily count is higher whenever questions take the `rag_answer` or `trivial_answer`
190
- route. `LLM_MAX_TOKENS` defaults to 3,000 to keep a full-profile request under the per-minute
191
- ceiling; raising it can push a request past the limit, which Groq rejects with HTTP 413.
 
35
  **2. Configure `.env`** in the project root:
36
 
37
  ```ini
38
+ OPENROUTER_API_KEY=... # required
39
  CANDIDATE_NAME=Your Name # used in the system prompt
40
 
41
  # Optional — Langfuse tracing. Tracing turns on only when both keys are present.
 
46
 
47
  | Variable | Default | Purpose |
48
  |---|---|---|
49
+ | `OPENROUTER_API_KEY` | — | Required. Startup fails without it. |
50
  | `CANDIDATE_NAME` | `the candidate` | Your name, used in the system prompt. |
51
  | `DATA_DIR` | `data` | Folder holding your profile documents. Relative paths resolve against the project root. |
52
+ | `OPENROUTER_MODEL` | `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free` | OpenRouter model id. |
53
  | `LLM_TEMPERATURE` | `0.0` | Sampling temperature. |
54
  | `LLM_MAX_TOKENS` | `3000` | Response cap. See the token budget note below. |
55
  | `LLM_DRAFTING_TEMPERATURE` | `0.4` | Temperature for cover-letter drafting only. |
 
79
  python app.py # http://localhost:7860
80
  ```
81
 
82
+ Paste a job description into the left panel, then chat or use the preset buttons. Select
83
+ **Stop** beside the message box to cancel a response that's being generated; any text already
84
+ received remains in the conversation.
85
 
86
  ## Project structure
87
 
 
184
 
185
  ## Token budget
186
 
187
+ `LLM_MAX_TOKENS` defaults to 3,000 to cap the response size. A full-profile request also sends
188
+ your profile (~3,900 tokens), the job description, and recent conversation history. OpenRouter
189
+ model context windows and account limits vary, so shorten the job description or lower
190
+ `LLM_MAX_TOKENS` if a request exceeds the model's limits.
 
 
 
app.py CHANGED
@@ -77,8 +77,21 @@ def stream_response(chat_history, job_description, thread_id, model_query=None,
77
  if action in STRUCTURED_ACTIONS:
78
  chat_history[-1]["content"] = "_Extracting and scoring keywords…_"
79
  yield chat_history
80
- result = GRAPH.invoke(state, config)
81
- chat_history[-1]["content"] = str(result["messages"][-1].text)
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  yield chat_history
83
  return
84
 
@@ -89,16 +102,15 @@ def stream_response(chat_history, job_description, thread_id, model_query=None,
89
  except Exception as exc:
90
  logger.exception("Request failed")
91
  detail = str(exc)
92
- if "tokens per day" in detail or "TPD" in detail:
93
  detail = (
94
- "Groq daily token limit reached. The quota resets on a rolling "
95
- "24-hour basis, or you can raise it on the Groq billing page."
96
  )
97
- elif "rate_limit" in detail or "413" in detail:
98
  detail = (
99
- "Groq per-minute token limit hit. The profile, job description, and "
100
- "reserved response tokens exceed the allowance for one request. Shorten "
101
- "the job description, lower LLM_MAX_TOKENS, or wait a minute and retry."
102
  )
103
  chat_history[-1]["content"] = f"**Request failed.** {detail}"
104
  yield chat_history
@@ -130,11 +142,14 @@ with gr.Blocks(title="job-chat", fill_height=True, analytics_enabled=False) as d
130
  }
131
 
132
  chatbot = gr.Chatbot(height=520, show_label=False, resizable=True)
133
- query_box = gr.Textbox(
134
- placeholder="Ask a question...",
135
- show_label=False,
136
- submit_btn=True,
137
- )
 
 
 
138
  clear_chat_btn = gr.Button("Reset conversation")
139
 
140
  jd_box.change(jd_status, inputs=jd_box, outputs=jd_state)
@@ -143,7 +158,9 @@ with gr.Blocks(title="job-chat", fill_height=True, analytics_enabled=False) as d
143
  # A new thread id starts a fresh checkpointed conversation; the old one stays on disk.
144
  clear_chat_btn.click(lambda: ([], new_thread_id()), outputs=[chatbot, thread_id])
145
 
146
- query_box.submit(
 
 
147
  add_user_message,
148
  inputs=[query_box, chatbot],
149
  outputs=[query_box, chatbot],
@@ -152,10 +169,10 @@ with gr.Blocks(title="job-chat", fill_height=True, analytics_enabled=False) as d
152
  stream_response,
153
  inputs=[chatbot, jd_box, thread_id],
154
  outputs=chatbot,
155
- )
156
 
157
  for key, button in action_buttons.items():
158
- button.click(
159
  partial(show_action_label, ACTIONS[key][0]),
160
  inputs=chatbot,
161
  outputs=chatbot,
@@ -164,7 +181,9 @@ with gr.Blocks(title="job-chat", fill_height=True, analytics_enabled=False) as d
164
  partial(run_action, key),
165
  inputs=[chatbot, jd_box, thread_id],
166
  outputs=chatbot,
167
- )
 
 
168
 
169
 
170
  if __name__ == "__main__":
 
77
  if action in STRUCTURED_ACTIONS:
78
  chat_history[-1]["content"] = "_Extracting and scoring keywords…_"
79
  yield chat_history
80
+
81
+ response = None
82
+ for mode, chunk in GRAPH.stream(
83
+ state, config, stream_mode=["messages", "updates"]
84
+ ):
85
+ if mode == "messages":
86
+ yield chat_history
87
+ elif mode == "updates":
88
+ ats_update = chunk.get("ats_analysis")
89
+ if ats_update and ats_update.get("messages"):
90
+ response = ats_update["messages"][-1]
91
+
92
+ if response is None:
93
+ raise RuntimeError("ATS analysis completed without a response.")
94
+ chat_history[-1]["content"] = str(response.text)
95
  yield chat_history
96
  return
97
 
 
102
  except Exception as exc:
103
  logger.exception("Request failed")
104
  detail = str(exc)
105
+ if "rate_limit" in detail or "429" in detail:
106
  detail = (
107
+ "OpenRouter rate limit reached. Check your account's limits or wait "
108
+ "before retrying."
109
  )
110
+ elif "413" in detail:
111
  detail = (
112
+ "The request is too large for the OpenRouter model. Shorten the job "
113
+ "description or lower LLM_MAX_TOKENS, then retry."
 
114
  )
115
  chat_history[-1]["content"] = f"**Request failed.** {detail}"
116
  yield chat_history
 
142
  }
143
 
144
  chatbot = gr.Chatbot(height=520, show_label=False, resizable=True)
145
+ with gr.Row():
146
+ query_box = gr.Textbox(
147
+ placeholder="Ask a question...",
148
+ show_label=False,
149
+ submit_btn=True,
150
+ scale=5,
151
+ )
152
+ stop_btn = gr.Button("Stop", variant="stop", scale=1)
153
  clear_chat_btn = gr.Button("Reset conversation")
154
 
155
  jd_box.change(jd_status, inputs=jd_box, outputs=jd_state)
 
158
  # A new thread id starts a fresh checkpointed conversation; the old one stays on disk.
159
  clear_chat_btn.click(lambda: ([], new_thread_id()), outputs=[chatbot, thread_id])
160
 
161
+ generation_events = []
162
+
163
+ generation_events.append(query_box.submit(
164
  add_user_message,
165
  inputs=[query_box, chatbot],
166
  outputs=[query_box, chatbot],
 
169
  stream_response,
170
  inputs=[chatbot, jd_box, thread_id],
171
  outputs=chatbot,
172
+ ))
173
 
174
  for key, button in action_buttons.items():
175
+ generation_events.append(button.click(
176
  partial(show_action_label, ACTIONS[key][0]),
177
  inputs=chatbot,
178
  outputs=chatbot,
 
181
  partial(run_action, key),
182
  inputs=[chatbot, jd_box, thread_id],
183
  outputs=chatbot,
184
+ ))
185
+
186
+ stop_btn.click(fn=None, cancels=generation_events, queue=False)
187
 
188
 
189
  if __name__ == "__main__":
config.py CHANGED
@@ -11,15 +11,15 @@ load_dotenv()
11
 
12
  PROJECT_ROOT = Path(__file__).parent
13
 
14
- DEFAULT_MODEL = "openai/gpt-oss-120b"
 
15
  DEFAULT_TEMPERATURE = 0.0
16
  # Drafting a cover letter is writing, not analysis: a little sampling gives
17
  # natural prose. Kept low so it does not start inventing detail.
18
  DEFAULT_DRAFTING_TEMPERATURE = 0.4
19
  # The preset actions (ATS keyword tables, detailed reviews) run long, so 2048
20
- # truncated them mid-answer. The ceiling is Groq's free-tier limit of 8000
21
- # tokens per minute, which counts input + max_tokens together: profile (~3.9k)
22
- # plus job description plus this value must stay under it.
23
  DEFAULT_MAX_TOKENS = 3000
24
  DEFAULT_DATA_DIR = "data"
25
  DEFAULT_LANGFUSE_HOST = "https://cloud.langfuse.com"
@@ -38,7 +38,7 @@ class ConfigError(RuntimeError):
38
 
39
  @dataclass(frozen=True)
40
  class Settings:
41
- groq_api_key: str
42
  candidate_name: str
43
  data_dir: Path
44
  model: str
@@ -101,15 +101,15 @@ def _resolve_data_dir():
101
 
102
  @lru_cache(maxsize=1)
103
  def get_settings() -> Settings:
104
- api_key = os.getenv("GROQ_API_KEY", "").strip()
105
  if not api_key:
106
- raise ConfigError("GROQ_API_KEY is not set. Add it to your .env file.")
107
 
108
  return Settings(
109
- groq_api_key=api_key,
110
  candidate_name=os.getenv("CANDIDATE_NAME", "").strip() or "the candidate",
111
  data_dir=_resolve_data_dir(),
112
- model=os.getenv("GROQ_MODEL", "").strip() or DEFAULT_MODEL,
113
  temperature=_get_float("LLM_TEMPERATURE", DEFAULT_TEMPERATURE),
114
  drafting_temperature=_get_float("LLM_DRAFTING_TEMPERATURE", DEFAULT_DRAFTING_TEMPERATURE),
115
  max_tokens=_get_int("LLM_MAX_TOKENS", DEFAULT_MAX_TOKENS),
 
11
 
12
  PROJECT_ROOT = Path(__file__).parent
13
 
14
+ DEFAULT_MODEL = "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free"
15
+ OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
16
  DEFAULT_TEMPERATURE = 0.0
17
  # Drafting a cover letter is writing, not analysis: a little sampling gives
18
  # natural prose. Kept low so it does not start inventing detail.
19
  DEFAULT_DRAFTING_TEMPERATURE = 0.4
20
  # The preset actions (ATS keyword tables, detailed reviews) run long, so 2048
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 = 3000
24
  DEFAULT_DATA_DIR = "data"
25
  DEFAULT_LANGFUSE_HOST = "https://cloud.langfuse.com"
 
38
 
39
  @dataclass(frozen=True)
40
  class Settings:
41
+ openrouter_api_key: str
42
  candidate_name: str
43
  data_dir: Path
44
  model: str
 
101
 
102
  @lru_cache(maxsize=1)
103
  def get_settings() -> Settings:
104
+ api_key = os.getenv("OPENROUTER_API_KEY", "").strip()
105
  if not api_key:
106
+ raise ConfigError("OPENROUTER_API_KEY is not set. Add it to your .env file.")
107
 
108
  return Settings(
109
+ openrouter_api_key=api_key,
110
  candidate_name=os.getenv("CANDIDATE_NAME", "").strip() or "the candidate",
111
  data_dir=_resolve_data_dir(),
112
+ model=os.getenv("OPENROUTER_MODEL", "").strip() or DEFAULT_MODEL,
113
  temperature=_get_float("LLM_TEMPERATURE", DEFAULT_TEMPERATURE),
114
  drafting_temperature=_get_float("LLM_DRAFTING_TEMPERATURE", DEFAULT_DRAFTING_TEMPERATURE),
115
  max_tokens=_get_int("LLM_MAX_TOKENS", DEFAULT_MAX_TOKENS),
email_writer.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from google import genai
3
+ from google.genai import types
4
+
5
+ def generate_application_email(
6
+ resume_text: str,
7
+ projects_text: str,
8
+ volunteer_text: str,
9
+ target_company: str,
10
+ job_title: str,
11
+ recruiter_name: str = "Hiring Manager"
12
+ ):
13
+ # 1. Initialize the AI client (ensure GEMINI_API_KEY is in your environment variables)
14
+ client = genai.Client(api_key=os.environ.get("GEMINI_API_KEY"))
15
+
16
+ # 2. Load the skill instructions
17
+ # In production, you would read this from your skill.md file
18
+ with open("skill.md", "r") as file:
19
+ skill_instructions = file.read()
20
+
21
+ # 3. Construct the user prompt with all inputs
22
+ user_prompt = f"""
23
+ Please write my job application email based on the following information:
24
+
25
+ TARGET JOB:
26
+ - Company: {target_company}
27
+ - Role: {job_title}
28
+ - Recruiter/Contact: {recruiter_name}
29
+
30
+ MY BACKGROUND:
31
+ [Resume]
32
+ {resume_text}
33
+
34
+ [Projects]
35
+ {projects_text}
36
+
37
+ [Volunteer Experience]
38
+ {volunteer_text}
39
+ """
40
+
41
+ # 4. Call the LLM with the skill.md as the system instruction
42
+ print(f"Generating email for {job_title} at {target_company}...\n")
43
+
44
+ response = client.models.generate_content(
45
+ model='gemini-2.5-pro',
46
+ contents=user_prompt,
47
+ config=types.GenerateContentConfig(
48
+ # The system instruction forces the AI to obey skill.md
49
+ system_instruction=skill_instructions,
50
+ # Lower temperature keeps the AI factual and prevents creative fluff
51
+ temperature=0.3,
52
+ )
53
+ )
54
+
55
+ return response.text
56
+
57
+ # ==========================================
58
+ # Example Usage
59
+ # ==========================================
60
+ if __name__ == "__main__":
61
+ # Mock inputs based on your profile
62
+ my_resume = "AI/ML Engineer with experience in deep learning frameworks. Tech stack: Python, PyTorch, Docker, Kubernetes."
63
+ my_projects = "Co-founder of Cysora: Built autonomous sales AI agents. Optimized PyTorch FSDP memory management for vision models."
64
+ my_volunteer = "Organized local tree-planting initiatives, coordinating 50+ volunteers."
65
+
66
+ company = "Anthropic"
67
+ role = "Senior Machine Learning Engineer"
68
+ contact = "Sarah (Technical Recruiter)"
69
+
70
+ # Run the function
71
+ email_draft = generate_application_email(
72
+ resume_text=my_resume,
73
+ projects_text=my_projects,
74
+ volunteer_text=my_volunteer,
75
+ target_company=company,
76
+ job_title=role,
77
+ recruiter_name=contact
78
+ )
79
+
80
+ print(email_draft)
graph.py CHANGED
@@ -11,14 +11,14 @@ from functools import partial
11
  from typing import Annotated, TypedDict
12
 
13
  from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, trim_messages
14
- from langchain_groq import ChatGroq
15
  from langgraph.checkpoint.sqlite import SqliteSaver
16
  from langgraph.graph import START, StateGraph
17
  from langgraph.graph.message import add_messages
18
 
19
  import router as router_module
20
  from ats import ATSAnalysis, format_ats_report
21
- from config import get_settings
22
  from dataloader import ProfileLoader
23
  from embeddings import embed
24
  from prompts import (
@@ -89,8 +89,9 @@ DRAFTING_ACTIONS = {"cover_letter"}
89
 
90
  def build_llm(temperature=None):
91
  settings = get_settings()
92
- return ChatGroq(
93
- api_key=settings.groq_api_key,
 
94
  model=settings.model,
95
  temperature=settings.temperature if temperature is None else temperature,
96
  max_tokens=settings.max_tokens,
 
11
  from typing import Annotated, TypedDict
12
 
13
  from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, trim_messages
14
+ from langchain_openai import ChatOpenAI
15
  from langgraph.checkpoint.sqlite import SqliteSaver
16
  from langgraph.graph import START, StateGraph
17
  from langgraph.graph.message import add_messages
18
 
19
  import router as router_module
20
  from ats import ATSAnalysis, format_ats_report
21
+ from config import OPENROUTER_BASE_URL, get_settings
22
  from dataloader import ProfileLoader
23
  from embeddings import embed
24
  from prompts import (
 
89
 
90
  def build_llm(temperature=None):
91
  settings = get_settings()
92
+ return ChatOpenAI(
93
+ api_key=settings.openrouter_api_key,
94
+ base_url=OPENROUTER_BASE_URL,
95
  model=settings.model,
96
  temperature=settings.temperature if temperature is None else temperature,
97
  max_tokens=settings.max_tokens,
requirements.txt CHANGED
@@ -2,7 +2,7 @@ fastembed==0.8.0
2
  gradio==6.22.0
3
  langchain==1.3.14
4
  langchain-core==1.5.1
5
- langchain-groq==1.1.3
6
  langgraph==1.2.10
7
  langgraph-checkpoint-sqlite==3.1.1
8
  langfuse==4.14.3
 
2
  gradio==6.22.0
3
  langchain==1.3.14
4
  langchain-core==1.5.1
5
+ langchain-openai==1.1.3
6
  langgraph==1.2.10
7
  langgraph-checkpoint-sqlite==3.1.1
8
  langfuse==4.14.3
skills/analyze-ats-keywords.md ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Skill: ATS Keyword Matcher & Optimization Engine
2
+
3
+ ## Role & Description
4
+ You are an elite Applicant Tracking System (ATS) algorithm and technical recruiter. Your task is to mathematically analyze a user's resume against a specific Job Description (JD), calculate a match score, and provide highly actionable, organic integration strategies for missing keywords.
5
+
6
+ ## Inputs Provided by User
7
+ - **User Background:** Resume (text).
8
+ - **Target Data:** Job Description (text).
9
+
10
+ ## Core Directives & Rules
11
+
12
+ ### 1. The Strict Keyword Extraction
13
+ - Extract "Hard Skills" (frameworks, languages, tools, e.g., PyTorch, Kubernetes, RAG).
14
+ - Extract "Business Impact Skills" (e.g., cross-functional leadership, product strategy, A/B testing).
15
+ - Discard generic corporate buzzwords (e.g., "fast-paced environment", "self-starter").
16
+
17
+ ### 2. The ATS Scoring Logic
18
+ - **Score (0-100%):** Calculate based on the exact presence of critical Hard Skills and Business Impact Skills.
19
+ - Penalize heavily if core technical requirements (the first 3 bullets of a JD) are missing.
20
+
21
+ ### 3. The "No-Stuffing" Rule
22
+ - Never recommend blindly listing keywords at the bottom of a resume.
23
+ - For every missing keyword, suggest a way to integrate it organically into a past project or experience *only if* it aligns with the user's actual background (e.g., "If you used Elasticsearch for your project, explicitly name it in bullet 2").
24
+
25
+ ## Execution Steps
26
+ 1. **The Score:** Output a bolded ATS Match Score (e.g., **ATS Match: 72%**).
27
+ 2. **The Gap Analysis (Markdown Table):** Create a 3-column table:
28
+ | Keyword Needed | Missing/Found | Context in JD |
29
+ 3. **Actionable Rewrites:** Select 2-3 existing resume bullets and rewrite them to naturally incorporate the highest-priority missing keywords.
skills/email-writing.md ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Skill: Direct Job Application Email Writer
2
+
3
+ ## Role & Description
4
+ You are an elite career coach and executive assistant. Your task is to synthesize a user's resume, projects, and volunteer experiences into highly optimized, perfectly structured direct job application emails. Your outputs strictly adhere to the proven best practices of top-tier university career centers (Stanford, CMU, MIT) and tech industry recruiters.
5
+
6
+ ## Inputs Provided by User
7
+ - **User Background:** Resume, Relevant Projects, Volunteer Experience.
8
+ - **Target Data:** Target Company, Job Title/Link, Hiring Manager/Recruiter Name (if known).
9
+
10
+ ## Core Directives & Rules
11
+
12
+ ### 1. The Bulleted Application Structure
13
+ Construct the email body using this exact 3-part sequence:
14
+ 1. **The Direct Hook (1 sentence):** State exactly what role the user is applying for and mention one overarching theme of their background that makes them a perfect fit.
15
+ 2. **The Bulleted Value Pitch (2-3 bullets max):**
16
+ - Extract the most relevant technical or business accomplishments from the user's data that directly solve a problem for the target role.
17
+ - Start each bullet with a strong active verb (e.g., "Architected," "Optimized," "Led").
18
+ - Quantify the result or specify the technical stack in every bullet.
19
+ - Keep each bullet punchy and to a single line.
20
+ 3. **The Low-Friction Close (1 sentence):** State that the resume is attached for their review and propose a confident, low-friction next step.
21
+
22
+ ### 2. "Human-Written" Tone Guardrails
23
+ - **Peer-to-Peer Tone:** Write like a competent professional, not a subordinate. Avoid overly formal or subservient language (e.g., "esteemed company," "humble," "It would be an honor").
24
+ - **Plain English:** Use active voice and contractions (I'm, I've). Replace corporate jargon ("utilize," "leverage") with simple verbs ("use," "build").
25
+ - **Zero Fluff:** Never use filler phrases like "I hope this email finds you well," "My name is X," or "Please find my resume attached below."
26
+
27
+ ### 3. Absolute Integrity & Strength-Focus
28
+ - **Never Lie:** Do not fabricate metrics, skills, or experiences. Stick strictly to the provided user data.
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.
skills/review-resume-detailed.md ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Skill: FAANG-Level Resume Reviewer & Architect
2
+
3
+ ## Role & Description
4
+ You are a Principal Technical Recruiter at a top-tier tech company (e.g., Meta, Google, Anthropic). Your task is to ruthlessly critique a user's resume for impact, clarity, and signal-to-noise ratio. You do not offer vague encouragement; you offer surgical, line-by-line optimization based on industry standards.
5
+
6
+ ## Inputs Provided by User
7
+ - **User Background:** Resume (text or current draft).
8
+ - **Target Data:** Target Role/Level (e.g., Senior ML Engineer, Staff Software Engineer).
9
+
10
+ ## Core Directives & Rules
11
+
12
+ ### 1. The Google "XYZ" Formula Enforcement
13
+ Every bullet point on the resume must be evaluated against the XYZ format:
14
+ * **"Accomplished [X] as measured by [Y], by doing [Z]."**
15
+ * If a bullet lacks a quantifiable metric (Y) or a specific technical implementation (Z), flag it as weak.
16
+
17
+ ### 2. The "So What?" Test
18
+ - Identify responsibilities disguised as achievements. (e.g., "Responsible for maintaining databases" is a failure. "Reduced database query latency by 40% by implementing Elasticsearch" is a pass).
19
+ - Flag any use of passive language or weak verbs ("helped," "assisted," "worked on").
20
+
21
+ ### 3. Visual & Cognitive Load Reduction
22
+ - Identify redundant technical skills listed in multiple places.
23
+ - Flag overly dense paragraphs. Resumes should be strictly bulleted.
24
+ - Ensure the most impressive, highest-impact bullet point is listed *first* under every job title.
25
+
26
+ ## Execution Steps
27
+ 1. **The Executive Summary (2-3 sentences):** Give a blunt, professional assessment of the resume's overall strength and primary weakness for the target role.
28
+ 2. **The Brutal Tear-Down (Section by Section):**
29
+ - **Strengths:** 2-3 bullets highlighting what is currently working well.
30
+ - **Weaknesses:** 2-3 bullets highlighting fluff, missing metrics, or poor formatting.
31
+ 3. **The Surgical Rewrites:**
32
+ - Select the 3 weakest or most poorly phrased bullet points from the user's draft.
33
+ - Present the "Before" (original text).
34
+ - Present the "After" (rewritten text applying the XYZ formula, inferring structure where necessary but explicitly instructing the user to fill in the exact metrics).
skills/write-cover-letter.md ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Skill: Elite Tech Cover Letter Writer
2
+
3
+ ## Role & Description
4
+ You are a Tier-1 tech industry copywriter. Your task is to write high-converting, fluff-free cover letters. You completely reject the outdated, 5-paragraph essay format in favor of a modern, highly scannable, bullet-driven structure designed for hiring managers who only have 15 seconds to read.
5
+
6
+ ## Inputs Provided by User
7
+ - **User Background:** Resume, Relevant Projects, Portfolio links.
8
+ - **Target Data:** Target Company, Job Title, Job Description.
9
+
10
+ ## Core Directives & Rules
11
+
12
+ ### 1. The Scannable "T-Format" Structure
13
+ Construct the cover letter using this exact sequence:
14
+ 1. **The Hook (1-2 sentences):** Skip the pleasantries. State the role, and immediately mention the specific company product/challenge you want to work on, backed by your overarching expertise.
15
+ 2. **The "Why I'm a Fit" Bullet Section:**
16
+ - Write a bolded introductory half-sentence (e.g., "How my background maps to your requirements:").
17
+ - Provide 3 punchy bullet points.
18
+ - Each bullet must pair a specific requirement from the JD with a quantified accomplishment from the user's resume.
19
+ 3. **The Culture/Scale Tie-in (1 sentence):** A brief sentence demonstrating you understand the company's current stage (e.g., scaling infrastructure, finding product-market fit, enterprise sales).
20
+ 4. **The Close (1 sentence):** Confident, low-friction sign-off.
21
+
22
+ ### 2. Tone Guardrails
23
+ - **Peer-to-Peer:** Write as a competent professional offering solutions, not a subordinate begging for a job.
24
+ - **Active Voice Only:** "I architected the pipeline," not "The pipeline was architected by me."
25
+ - **Zero Fluff:** Banned phrases: "I am writing to express my interest," "I believe I am the perfect candidate," "Enclosed please find my resume."
26
+
27
+ ### 3. Integrity
28
+ - **Never Lie:** Stick strictly to the provided user data. Do not hallucinate metrics or skills.
29
+
30
+ ## Execution Steps
31
+ 1. **Analyze:** Cross-reference the user's resume with the JD to find the 3 strongest overlaps.
32
+ 2. **Draft:** Write the cover letter strictly following the T-Format structure. Maximum length: 200 words.
33
+ 3. **Output:** Present the cover letter draft, followed by a brief list of the 3 specific JD requirements targeted.
tests/conftest.py CHANGED
@@ -20,7 +20,7 @@ atexit.register(shutil.rmtree, _FIXTURE_DIR, True)
20
 
21
  # load_dotenv() does not override variables that are already set, so these win
22
  # over any real .env on a developer machine.
23
- os.environ["GROQ_API_KEY"] = "test-key-not-used"
24
  os.environ["DATA_DIR"] = _FIXTURE_DIR
25
  os.environ["CANDIDATE_NAME"] = "Test Candidate"
26
  # Set to empty rather than deleted: load_dotenv() skips keys already present, so
 
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
tests/test_config.py CHANGED
@@ -19,17 +19,17 @@ def data_dir(tmp_path):
19
 
20
  @pytest.fixture
21
  def base_env(monkeypatch, data_dir):
22
- monkeypatch.setenv("GROQ_API_KEY", "test-key")
23
  monkeypatch.setenv("DATA_DIR", str(data_dir))
24
- for name in ("GROQ_MODEL", "LLM_TEMPERATURE", "LLM_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
 
29
 
30
  def test_missing_api_key_is_rejected(base_env, monkeypatch):
31
- monkeypatch.setenv("GROQ_API_KEY", " ")
32
- with pytest.raises(ConfigError, match="GROQ_API_KEY"):
33
  get_settings()
34
 
35
 
@@ -56,6 +56,7 @@ def test_non_numeric_values_are_rejected(base_env, monkeypatch, var):
56
 
57
  def test_defaults_apply_when_optional_vars_are_unset(base_env):
58
  settings = get_settings()
 
59
  assert settings.model == config.DEFAULT_MODEL
60
  assert settings.temperature == config.DEFAULT_TEMPERATURE
61
  assert settings.max_tokens == config.DEFAULT_MAX_TOKENS
@@ -64,7 +65,7 @@ def test_defaults_apply_when_optional_vars_are_unset(base_env):
64
 
65
 
66
  def test_overrides_are_read_from_the_environment(base_env, monkeypatch):
67
- monkeypatch.setenv("GROQ_MODEL", "some/other-model")
68
  monkeypatch.setenv("LLM_TEMPERATURE", "0.7")
69
  monkeypatch.setenv("MAX_HISTORY_TURNS", "3")
70
  settings = get_settings()
 
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", "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
 
29
 
30
  def test_missing_api_key_is_rejected(base_env, monkeypatch):
31
+ monkeypatch.setenv("OPENROUTER_API_KEY", " ")
32
+ with pytest.raises(ConfigError, match="OPENROUTER_API_KEY"):
33
  get_settings()
34
 
35
 
 
56
 
57
  def test_defaults_apply_when_optional_vars_are_unset(base_env):
58
  settings = get_settings()
59
+ assert settings.openrouter_api_key == "test-key"
60
  assert settings.model == config.DEFAULT_MODEL
61
  assert settings.temperature == config.DEFAULT_TEMPERATURE
62
  assert settings.max_tokens == config.DEFAULT_MAX_TOKENS
 
65
 
66
 
67
  def test_overrides_are_read_from_the_environment(base_env, monkeypatch):
68
+ monkeypatch.setenv("OPENROUTER_MODEL", "some/other-model")
69
  monkeypatch.setenv("LLM_TEMPERATURE", "0.7")
70
  monkeypatch.setenv("MAX_HISTORY_TURNS", "3")
71
  settings = get_settings()
tests/test_isolation.py CHANGED
@@ -17,8 +17,8 @@ def test_tracing_is_disabled_during_tests():
17
  assert get_settings().tracing_enabled is False
18
 
19
 
20
- def test_the_real_groq_key_is_not_used():
21
- assert get_settings().groq_api_key == "test-key-not-used"
22
 
23
 
24
  def test_personal_documents_are_not_read():
 
17
  assert get_settings().tracing_enabled is False
18
 
19
 
20
+ 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():
tests/test_temperature.py CHANGED
@@ -22,8 +22,8 @@ def test_default_temperatures():
22
 
23
 
24
  def test_build_llm_defaults_to_the_analysis_temperature():
25
- # ChatGroq clamps an exact 0.0 to 1e-08, since Groq rejects zero.
26
  assert build_llm().temperature == pytest.approx(get_settings().temperature, abs=1e-6)
 
27
 
28
 
29
  def test_build_llm_accepts_an_override():
 
22
 
23
 
24
  def test_build_llm_defaults_to_the_analysis_temperature():
 
25
  assert build_llm().temperature == pytest.approx(get_settings().temperature, abs=1e-6)
26
+ assert build_llm().openai_api_base == config.OPENROUTER_BASE_URL
27
 
28
 
29
  def test_build_llm_accepts_an_override():