File size: 13,435 Bytes
8d3a7b6
 
 
 
 
 
 
 
 
 
 
504c48d
8d3a7b6
504c48d
 
8d3a7b6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146e133
8d3a7b6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146e133
 
8d3a7b6
146e133
 
504c48d
146e133
8d3a7b6
 
 
 
 
 
 
 
 
 
 
91bae01
8d3a7b6
 
 
 
 
05888f3
56f43fc
05888f3
56f43fc
8d3a7b6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91bae01
 
8d3a7b6
504c48d
8d3a7b6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d50ecc1
56f43fc
8d3a7b6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146e133
 
8d3a7b6
 
 
 
 
 
 
146e133
8d3a7b6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146e133
 
8d3a7b6
 
 
 
 
 
 
 
 
 
 
146e133
 
8d3a7b6
 
 
 
 
 
 
 
 
 
 
 
146e133
 
8d3a7b6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146e133
 
8d3a7b6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
import os
import fitz
import json
import re
from shiny import reactive, render, ui
from context import get_candidate_context, save_candidate_context, get_team_summary, get_job_context, get_all_jobs, get_all_candidates
from llm_connect import get_response
import html
import markdown


RESUME_DIR = "/tmp/data/"

def extract_text_from_pdf(filename, job_id):
    path = os.path.join(RESUME_DIR, job_id, 'resumes', filename) + '.pdf'
    if not os.path.exists(path):
        print(f"❌ Resume not found: {path}")
        return None, None
    try:
        doc = fitz.open(path)
        return "\n".join([page.get_text() for page in doc]), path
    except Exception as e:
        print("❌ PDF error:", e)
        return None, None

def parse_resume_with_llm(resume_text, job_description_text, team_profiles, team_summary):
    prompt = (
        f"You are evaluating a candidate for the following job posting:\n\n"
        f"{job_description_text}\n\n"
        f"Here is the candidate's resume:\n\n"
        f"{resume_text}\n\n"
        f"Here are the profiles of the current team members:\n\n{team_profiles}\n\n"
        f"Here is the team summary:\n\n{team_summary}\n\n"
        "Extract the following fields into a valid JSON object:\n"
        "- Name\n"
        "- Email\n"
        "- Years of Experience\n"
        "- Key Skills (as a list)\n"
        "- Llama Score (judge the candidate's overall fit for the job on a scale of 1–10)\n\n"
        "⚠️ Return ONLY a single valid JSON object and nothing else.\n"
    )

    response_text = get_response(
        input=prompt,
        template=lambda x: x,
        llm="llama",
        md=False,
        temperature=0.0,
        max_tokens=700,
    )

    response_text = response_text.strip().replace("```json", "").replace("```", "").strip()
    match = re.search(r'\{\s*".+?"\s*:.+?\}', response_text, re.DOTALL)
    if not match:
        raise ValueError("No valid JSON object found in LLM response.")
    return json.loads(match.group(0))

def review_llama_score(resume_text, job_description_text, score, team_profiles, team_summary):
    prompt = (
        f"You are evaluating a candidate for the following posting:\n\n"
        f"{job_description_text}\n\n"
        f"Resume:\n{resume_text}\n\n"
        f"Team Profiles:\n{team_profiles}\n\n"
        f"Team Summary:\n{team_summary}\n\n"
        f"Llama gave this candidate a score of {score}/10.\n"
        "What is your score (1–10)? Only return the number."
    )

    return get_response(
        input=prompt,
        template=lambda x: x,
        llm="gemini",
        md=False,
        temperature=0.0,
        max_tokens=10,
        model_name ='gemini-2.0-flash-lite'
    ).strip()

def summarize_entire_resume(resume_text, job_description_text, score, team_profiles, team_summary):
    prompt = (
        f"Job Description:\n{job_description_text}\n\n"
        f"Resume:\n{resume_text}\n\n"
        f"Team Profiles:\n{team_profiles}\n\n"
        f"Team Summary:\n{team_summary}\n\n"
        f"The candidate received a score of {score}/10.\n"
        "Write a detailed, honest summary of this candidate's qualifications and fit."
    )

    return get_response(
        input=prompt,
        template=lambda x: x,
        llm="llama",
        md=False,
        temperature=0.7,
        max_tokens=500
    ).strip()

def review_llama_summary(resume_text, job_description_text, score, llama_review, team_profiles, team_summary):
    prompt = (
        f"You are reviewing this Llama summary for a candidate:\n\n"
        f"Job Description:\n{job_description_text}\n\n"
        f"Resume:\n{resume_text}\n\n"
        f"Llama Summary:\n{llama_review}\n\n"
        f"Team Profiles:\n{team_profiles}\n\n"
        f"Team Summary:\n{team_summary}\n\n"
        f"Llama scored this candidate {score}/10.\n"
        "Write your own short evaluation and state if you agree or disagree with Llama’s score."
    )

    return get_response(
        input=prompt,
        template=lambda x: x,
        llm="gemini",
        md=False,
        temperature=0.7,
        max_tokens=500
    ).strip()

def server(input, output, session):


    @reactive.effect
    def _populate_job_dropdown():
        jobs = get_all_jobs()
        job_choices = {
            k: f"{v.get('title', 'Untitled')} ({k[:8]})"
            for k, v in jobs.items()
        }
        print(job_choices)
        ui.update_select("job_dropdown_for_doc", choices=job_choices)


    @reactive.effect
    def _populate_candidate_dropdown():
        job_id = input.job_dropdown_for_doc()
        print("πŸ“Ž selected job_id:", job_id)

        if not job_id:
            ui.update_select("candidate_dropdown_for_doc", choices={"⬅️ Select a job first": ""})
            return

        candidates = get_all_candidates()

        filtered = {
            cid: f"{v.get('Name', cid)} ({v.get('Resume File', 'N/A')})"
            for cid, v in candidates.items()
            if str(v.get("job_id")) == str(job_id) and v.get("Resume File")
        }

        print(f"βœ… Found {len(filtered)} candidates for job {job_id}")

        if filtered:
            ui.update_select("candidate_dropdown_for_doc", choices=filtered)
        else:
            ui.update_select("candidate_dropdown_for_doc", choices={"❌ No matching resumes": ""})




    @output
    @render.ui
    def summary():
        input.show_gemini()             # βœ… force reactive trigger
        input.job_dropdown_for_doc()
        input.candidate_dropdown_for_doc()

        filename = input.candidate_dropdown_for_doc()
        job_id = input.job_dropdown_for_doc()  # πŸ”§ ADD THIS LINE
        use_gemini = input.show_gemini() 

        if not filename or not job_id:
            return "Please select both resume and job ID."

        job_context = get_job_context(job_id)  # βœ… This now works
        job_description_text = job_context.get("job_description", "No job description available.")
        team_profiles = job_context.get("team_profiles", "No team profile available.")
        team_summary = get_team_summary()

        candidate_id = os.path.splitext(filename)[0]
        ctx = get_candidate_context(candidate_id)


        # βœ… If already evaluated for this job, return cached summary
        if ctx.get("job_id") == job_id and "Llama Summary" in ctx:
            use_gemini = input.show_gemini()
            print(f"πŸ§ͺ Cached summary found for {candidate_id} / job {job_id} | Gemini: {use_gemini}")

            if 'Note' not in ctx.keys():
                ctx['Note'] = ''
                save_candidate_context(candidate_id, ctx)

            raw = ctx.get("Gemini Summary" if use_gemini else "Llama Summary", "No summary available")
            rendered = markdown.markdown(raw.strip())

            return ui.HTML(
                f"""
                <div style="
                    font-family: 'Inter', 'Segoe UI', 'Helvetica Neue', sans-serif;
                    font-size: 1rem;
                    line-height: 1.6;
                    white-space: normal;
                    word-wrap: break-word;
                    max-width: 900px;
                ">
                    {rendered}
                </div>
                """
            )



        # βœ… Run full pipeline
        resume_text, resume_path = extract_text_from_pdf(filename, job_id)
        if not resume_text:
            return "Failed to extract resume."

        try:
            parsed = parse_resume_with_llm(resume_text, job_description_text, team_profiles, team_summary)
        except Exception as e:
            return f"❌ LLM field extraction failed: {e}"

        llama_score = parsed["Llama Score"]
        gemini_score = review_llama_score(resume_text, job_description_text, llama_score, team_profiles, team_summary)
        try:
            gemini_score = int(gemini_score)
        except:
            gemini_score = None

        avg_score = (
            (llama_score + gemini_score) / 2
            if isinstance(llama_score, int) and isinstance(gemini_score, int)
            else "N/A"
        )

        llama_summary = summarize_entire_resume(resume_text, job_description_text, llama_score, team_profiles, team_summary)
        gemini_review = review_llama_summary(resume_text, job_description_text, llama_score, llama_summary, team_profiles, team_summary)

        # βœ… Save new result
        ctx.update({
            "job_id": job_id,
            "Resume File": filename,
            "Name": parsed.get("Name"),
            "Email": parsed.get("Email"),
            "Years of Experience": parsed.get("Years of Experience"),
            "Key Skills": parsed.get("Key Skills", []),
            "Llama Score": llama_score,
            "Gemini Score": gemini_score,
            "avg_score": avg_score,
            "Llama Summary": llama_summary,
            "Gemini Summary": gemini_review,
            "Note": ""
        })

        save_candidate_context(candidate_id, ctx)

        print(use_gemini)
        summary_text = gemini_review if use_gemini else llama_summary
        rendered = markdown.markdown(summary_text)


        return ui.HTML(f"""
            <div style="
                font-family: 'Inter', 'Segoe UI', 'Helvetica Neue', sans-serif;
                font-size: 1rem;
                line-height: 1.6;
                white-space: normal;
                word-wrap: break-word;
                max-width: 900px;
            ">
                {rendered}
            </div>
        """)

    
    @output
    @render.ui
    def score():
        filename = input.candidate_dropdown_for_doc()
        job_id = input.job_dropdown_for_doc()

        if not filename or not job_id:
            return ui.HTML("<p style='color: #888;'>Select a resume and job to view score.</p>")

        candidate_id = os.path.splitext(filename)[0]
        ctx = get_candidate_context(candidate_id)

        if ctx.get("job_id") == str(job_id) and "avg_score" in ctx:
            score = ctx["avg_score"]

            # Choose a color based on the score
            if isinstance(score, (int, float)):
                color = (
                    "green" if score >= 8 else
                    "orange" if score >= 5 else
                    "red"
                )
            else:
                color = "gray"

            return ui.HTML(f"""
                <div style="
                    background-color: {color};
                    color: white;
                    font-weight: bold;
                    font-size: 1.1rem;
                    padding: 0.6rem 1.2rem;
                    border-radius: 8px;
                    display: inline-block;
                ">
                    Average Score: {score}
                </div>
            """)

        return ui.HTML("<p style='color: #888;'>Score not available. Generate profile first.</p>")

    
    @output
    @render.text
    def candidate_note_ui():
        filename = input.candidate_dropdown_for_doc()
        job_id = input.job_dropdown_for_doc()
        if not filename or not job_id:
            return ui.input_text_area("candidate_note", "Add a note:", rows=3)

        candidate_id = os.path.splitext(filename)[0]
        ctx = get_candidate_context(candidate_id)
        note = ctx.get("Note", "") if ctx.get("job_id") == job_id else ""
        return ui.input_text_area("candidate_note", "Add a note:", value=note, rows=3)

    @output
    @render.ui
    def candidate_tags_ui():
        filename = input.candidate_dropdown_for_doc()
        job_id = input.job_dropdown_for_doc()
        if not filename or not job_id:
            return ui.input_text("candidate_tags", "Tags (comma-separated):")

        candidate_id = os.path.splitext(filename)[0]
        ctx = get_candidate_context(candidate_id)
        tags = ", ".join(ctx.get("Tags", [])) if ctx.get("job_id") == job_id else ""
        return ui.input_text("candidate_tags", "Tags (comma-separated):", value=tags)


    @output
    @render.text
    def note_preview():
        filename = input.candidate_dropdown_for_doc()
        job_id = input.job_dropdown_for_doc()
        if not filename or not job_id:
            return ""

        candidate_id = os.path.splitext(filename)[0]
        ctx = get_candidate_context(candidate_id)

        if ctx.get("job_id") != job_id:
            return ""

        note = ctx.get("Note", "[No note]")
        tags = ctx.get("Tags", [])
        return f"πŸ“ Note:\n{note}\n\n🏷️ Tags: {', '.join(tags)}"
    
    @output
    @render.text
    @reactive.event(input.save_note_tags)
    def note_tag_status():
        filename = input.candidate_dropdown_for_doc()
        job_id = input.job_dropdown_for_doc()
        if not filename or not job_id:
            return "❌ Please select both a resume and a job ID."

        candidate_id = os.path.splitext(filename)[0]
        ctx = get_candidate_context(candidate_id)

        # Only update if job_id matches
        if ctx.get("job_id") != job_id:
            return "⚠️ Cannot save notes β€” no profile generated for this candidate/job combination."

        # Get input
        note = input.candidate_note().strip()
        tags_raw = input.candidate_tags()
        tags = [tag.strip() for tag in tags_raw.split(",") if tag.strip()]

        # Save to context
        ctx["Note"] = note
        ctx["Tags"] = tags
        save_candidate_context(candidate_id, ctx)

        return "βœ… Note and tags saved."