File size: 12,022 Bytes
f39e265 4638978 9d8bbd6 f39e265 d7d27a3 f39e265 9d8bbd6 f39e265 be97847 8a3375f f39e265 9d8bbd6 f39e265 9d8bbd6 8a3375f f39e265 9d8bbd6 d7d27a3 9d8bbd6 f39e265 4638978 9d8bbd6 4638978 9d8bbd6 4638978 9d8bbd6 4638978 be97847 4638978 f39e265 9d8bbd6 f39e265 4638978 f39e265 4638978 f39e265 be97847 4638978 e287216 be97847 9d8bbd6 be97847 e287216 f39e265 d7d27a3 4638978 f39e265 9d8bbd6 f39e265 be97847 f39e265 d7d27a3 f39e265 9d8bbd6 f39e265 4638978 f39e265 e287216 f39e265 9d8bbd6 f39e265 e287216 f39e265 9d8bbd6 f39e265 e287216 f39e265 4638978 e287216 4638978 9d8bbd6 4638978 e287216 4638978 f39e265 6ff0b81 f39e265 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 | import logging
import sys
import uuid
from functools import partial
from pathlib import Path
import gradio as gr
from langchain_core.messages import AIMessage, HumanMessage
from openai import LengthFinishReasonError
from config import ConfigError, get_settings
from dataloader import convert_uploaded_documents
from graph import ATS_NODE, build_graph
from observability import build_run_config, flush
from skills import ACTIONS
import spaces
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
logger = logging.getLogger("job-chat")
try:
SETTINGS = get_settings(require_api_key=False)
except ConfigError as exc:
sys.exit(f"Startup failed: {exc}")
SESSION_DATA = {}
@spaces.GPU
def temp():
pass
def new_thread_id():
return str(uuid.uuid4())
def discard_session_data(session_id):
SESSION_DATA.pop(session_id, None)
def graph_for_session(session_id):
session = SESSION_DATA.get(session_id)
return session.get("graph") if session else None
def request_error_detail(exc):
if isinstance(exc, LengthFinishReasonError):
return (
"The ATS report exceeded the model's response-token limit and was incomplete. "
"Retry with a shorter job description, choose a model with a larger output "
"limit, or raise ATS_MAX_TOKENS if the model supports it."
)
detail = str(exc)
if "rate_limit" in detail or "429" in detail:
return (
"OpenRouter rate limit reached. Check your account's limits or wait "
"before retrying."
)
if "413" in detail:
return (
"The request is too large for the OpenRouter model. Shorten the job "
"description or lower LLM_MAX_TOKENS, then retry."
)
return detail
def connect_api_key(api_key, session_id):
api_key = (api_key or "").strip()
if not api_key:
return "", "Paste your OpenRouter API key to connect."
session = SESSION_DATA.setdefault(session_id, {})
session["api_key"] = api_key
profile_data = session.get("profile_data")
if not profile_data:
return "", "Key saved for this browser session. Upload your profile documents to continue."
try:
session["graph"] = build_graph(api_key=api_key, profile_data=profile_data)
except Exception as exc:
detail = str(exc).replace(api_key, "[redacted]")
logger.error("Could not initialize an OpenRouter graph: %s", detail)
return "", f"**Could not connect.** {detail}"
return "", "Key and profile ready for this browser session. Send a message to verify the key."
def process_profile_upload(files, session_id):
if not files:
return "Choose one or more profile documents to upload."
try:
profile_data = convert_uploaded_documents(files)
except Exception as exc:
logger.error("Could not process profile upload: %s", exc)
return f"**Upload failed.** {exc}"
session = SESSION_DATA.setdefault(session_id, {})
session["profile_data"] = profile_data
session["graph"] = None
api_key = session.get("api_key") or SETTINGS.openrouter_api_key
if not api_key:
return "Profile converted to Markdown and held for this session. Connect your OpenRouter key to continue."
try:
session["graph"] = build_graph(api_key=api_key, profile_data=profile_data)
except Exception as exc:
detail = str(exc).replace(api_key, "[redacted]")
logger.error("Could not initialize the uploaded profile: %s", detail)
return f"**Profile converted, but setup failed.** {detail}"
names = ", ".join(Path(file_path).name for file_path in files)
return f"**Profile ready.** Converted and loaded: {names}"
def clear_profile(session_id):
session = SESSION_DATA.get(session_id)
if session:
session.pop("profile_data", None)
session.pop("graph", None)
return None, "Uploaded profile cleared from this session."
def jd_status(job_description):
if job_description and job_description.strip():
return f"**Loaded** — {len(job_description.split())} words"
return "*No job description. Fit questions need one.*"
def add_user_message(user_query, chat_history):
if not user_query.strip():
return "", chat_history
return "", chat_history + [{"role": "user", "content": user_query}]
def show_action_label(label, chat_history):
"""Show the button's short label in the chat instead of its full instruction."""
return chat_history + [{"role": "user", "content": f"**{label}**"}]
def run_action(key, chat_history, job_description, thread_id, session_id):
_, instruction = ACTIONS[key]
yield from stream_response(
chat_history,
job_description,
thread_id,
model_query=instruction,
action=key,
session_id=session_id,
)
def stream_response(
chat_history, job_description, thread_id, session_id=None, model_query=None, action=""
):
"""Stream the assistant reply for the last user message.
model_query lets a skill button send its instruction to the model while the
chat shows only a short label. The ATS node returns one structured report
rather than a token stream, so its text arrives as a single update (a
placeholder is shown meanwhile when the button asked for it). Every other
node streams token by token, whether a button or the router chose it.
"""
if not chat_history or chat_history[-1]["role"] != "user":
yield chat_history
return
job_description = (job_description or "").strip()
if not job_description:
chat_history = chat_history + [{
"role": "assistant",
"content": "Paste a job description in the **Target role** panel before asking a question or using a skill.",
}]
yield chat_history
return
graph = graph_for_session(session_id)
if graph is None:
chat_history = chat_history + [{
"role": "assistant",
"content": "Upload your profile documents and connect an OpenRouter API key before chatting.",
}]
yield chat_history
return
state = {
"messages": [HumanMessage(content=model_query or chat_history[-1]["content"])],
"job_description": job_description,
"action": action,
}
config = build_run_config(thread_id, job_description)
chat_history = chat_history + [{"role": "assistant", "content": ""}]
try:
if action == "ats":
chat_history[-1]["content"] = "_Extracting and scoring keywords…_"
yield chat_history
streamed = False
for mode, chunk in graph.stream(state, config, stream_mode=["messages", "updates"]):
if mode == "messages":
message, meta = chunk
# The ATS node's model call emits structured-output fragments, not prose.
if meta.get("langgraph_node") == ATS_NODE:
continue
if isinstance(message, AIMessage) and message.content:
if not streamed:
chat_history[-1]["content"] = ""
streamed = True
chat_history[-1]["content"] += message.content
yield chat_history
elif mode == "updates":
ats_update = chunk.get(ATS_NODE)
if ats_update and ats_update.get("messages"):
chat_history[-1]["content"] = str(ats_update["messages"][-1].text)
yield chat_history
except Exception as exc:
logger.exception("Request failed")
detail = request_error_detail(exc)
chat_history[-1]["content"] = f"**Request failed.** {detail}"
yield chat_history
with gr.Blocks(title="job-chat", fill_height=True, analytics_enabled=False) as demo:
thread_id = gr.State(new_thread_id)
session_id = gr.State(new_thread_id, delete_callback=discard_session_data)
gr.Markdown("## Job chat")
gr.Markdown("Ask about your background, draft interview answers, or test your fit against a role.")
gr.Markdown("### OpenRouter API key")
api_key_box = gr.Textbox(
label="Paste your OpenRouter API key",
placeholder="sk-or-v1-…",
type="password",
show_label=True,
)
connect_key_btn = gr.Button("Connect key")
key_status = gr.Markdown(
"Using the server-configured key."
if SETTINGS.openrouter_api_key
else "An API key is required to use the model. Your key is kept in server memory for this browser session and is not written to disk."
)
with gr.Row(equal_height=False):
with gr.Column(scale=1):
gr.Markdown("### Your profile")
profile_upload = gr.File(
label="Upload profile documents",
file_count="multiple",
file_types=[".pdf", ".docx", ".md", ".txt"],
type="filepath",
)
profile_status = gr.Markdown(
"Upload PDF, DOCX, Markdown, or text files. A new upload replaces the previous profile."
)
clear_profile_btn = gr.Button("Clear uploaded profile")
gr.Markdown("### Target role")
jd_box = gr.Textbox(
lines=18,
max_lines=18,
placeholder="Paste the full job description here.",
show_label=False,
)
jd_state = gr.Markdown(jd_status(""))
clear_jd_btn = gr.Button("Clear job description")
with gr.Column(scale=2):
with gr.Row():
action_buttons = {
key: gr.Button(label, size="sm")
for key, (label, _) in ACTIONS.items()
}
chatbot = gr.Chatbot(height=520, show_label=False, resizable=True)
with gr.Row():
query_box = gr.Textbox(
placeholder="Ask a question...",
show_label=False,
submit_btn=True,
scale=5,
)
stop_btn = gr.Button("Stop", variant="stop", scale=1)
clear_chat_btn = gr.Button("Reset conversation")
jd_box.change(jd_status, inputs=jd_box, outputs=jd_state)
clear_jd_btn.click(lambda: "", outputs=jd_box)
connect_key_btn.click(
connect_api_key,
inputs=[api_key_box, session_id],
outputs=[api_key_box, key_status],
)
profile_upload.upload(
process_profile_upload,
inputs=[profile_upload, session_id],
outputs=profile_status,
)
clear_profile_btn.click(
clear_profile,
inputs=session_id,
outputs=[profile_upload, profile_status],
)
# A new thread id starts a fresh checkpointed conversation; the old one stays on disk.
clear_chat_btn.click(lambda: ([], new_thread_id()), outputs=[chatbot, thread_id])
generation_events = []
generation_events.append(query_box.submit(
add_user_message,
inputs=[query_box, chatbot],
outputs=[query_box, chatbot],
queue=False,
).then(
stream_response,
inputs=[chatbot, jd_box, thread_id, session_id],
outputs=chatbot,
))
for key, button in action_buttons.items():
generation_events.append(button.click(
partial(show_action_label, ACTIONS[key][0]),
inputs=chatbot,
outputs=chatbot,
queue=False,
).then(
partial(run_action, key),
inputs=[chatbot, jd_box, thread_id, session_id],
outputs=chatbot,
))
stop_btn.click(fn=None, cancels=generation_events, queue=False)
if __name__ == "__main__":
logger.info("Model: %s | tracing: %s", SETTINGS.model, SETTINGS.tracing_enabled)
try:
demo.launch(server_name="0.0.0.0", server_port=7860, share=False)
finally:
flush()
|