job-chat / app.py
codegood's picture
placeholder function for hf gpu
8a3375f
Raw History Blame Contribute Delete
12 kB
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()