Download app.py from codegood/job-chat: direct link, hf CLI and curl.
- Browser
- Download file 12 kB
-
https://huggingface.co/spaces/codegood/job-chat/resolve/main/app.py
- Command line
-
hf download hf://spaces/codegood/job-chat/app.py
-
curl -L -o app.py https://huggingface.co/spaces/codegood/job-chat/resolve/main/app.py
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 = {} | |
| 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() | |