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()