Switch to OpenRouter and add response stop control
Browse files- README.md +10 -11
- app.py +37 -18
- config.py +9 -9
- email_writer.py +80 -0
- graph.py +5 -4
- requirements.txt +1 -1
- skills/analyze-ats-keywords.md +29 -0
- skills/email-writing.md +35 -0
- skills/review-resume-detailed.md +34 -0
- skills/write-cover-letter.md +33 -0
- tests/conftest.py +1 -1
- tests/test_config.py +6 -5
- tests/test_isolation.py +2 -2
- tests/test_temperature.py +1 -1
README.md
CHANGED
|
@@ -35,7 +35,7 @@ pip install -r requirements.txt
|
|
| 35 |
**2. Configure `.env`** in the project root:
|
| 36 |
|
| 37 |
```ini
|
| 38 |
-
|
| 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 |
-
| `
|
| 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 |
-
| `
|
| 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 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 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 |
-
|
| 81 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 "
|
| 93 |
detail = (
|
| 94 |
-
"
|
| 95 |
-
"
|
| 96 |
)
|
| 97 |
-
elif "
|
| 98 |
detail = (
|
| 99 |
-
"
|
| 100 |
-
"
|
| 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 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 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 = "
|
|
|
|
| 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.
|
| 21 |
-
#
|
| 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 |
-
|
| 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("
|
| 105 |
if not api_key:
|
| 106 |
-
raise ConfigError("
|
| 107 |
|
| 108 |
return Settings(
|
| 109 |
-
|
| 110 |
candidate_name=os.getenv("CANDIDATE_NAME", "").strip() or "the candidate",
|
| 111 |
data_dir=_resolve_data_dir(),
|
| 112 |
-
model=os.getenv("
|
| 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
|
| 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
|
| 93 |
-
api_key=settings.
|
|
|
|
| 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-
|
| 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["
|
| 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("
|
| 23 |
monkeypatch.setenv("DATA_DIR", str(data_dir))
|
| 24 |
-
for name in ("
|
| 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("
|
| 32 |
-
with pytest.raises(ConfigError, match="
|
| 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("
|
| 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
|
| 21 |
-
assert get_settings().
|
| 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():
|