File size: 37,201 Bytes
a4db4ad
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
# =============================================================================
# engine/explainer.py β€” Explainability Engine + Interview Question Generator
# =============================================================================
# Takes a completed match result (from matcher.py) and adds two things:
#
#   1. A structured plain-language explanation of the score β€” the "why"
#      behind every number. This is the core USP of the entire system.
#
#   2. Targeted interview questions generated from the candidate's specific
#      CV profile β€” skill gaps, unverified claims, and red flags.
#
# LLM STRATEGY (three-tier, most reliable first):
#   Tier 1 β€” Groq API  (llama-3.1-70b-versatile, free tier, fast)
#   Tier 2 β€” OpenAI API (gpt-4o, paid, best quality)
#   Tier 3 β€” Template fallback (no API needed, always works)
#
#   The system tries Tier 1 first. On failure, falls back to Tier 2, then
#   Tier 3. Every candidate always gets interview questions β€” no silent gaps.
#
# PUBLIC API:
#   build_full_output(match_result, resume, jd)  β†’ dict   ← only call this
#   generate_summary(match_result, resume, jd)   β†’ str
#   generate_interview_questions(match_result, resume, jd) β†’ list[dict]
# =============================================================================

import os
import sys
import re
import json
import time

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import config


# ── LLM client setup ─────────────────────────────────────────────────────────
from dotenv import load_dotenv
load_dotenv()

_GROQ_OK     = False
_groq_client = None
_groq_model  = ""

# Priority 1: Groq via OpenAI-compatible API (no groq package needed)
_groq_key = os.getenv("GROQ_API_KEY", "")
if _groq_key:
    try:
        from openai import OpenAI as _OpenAI
        _groq_client = _OpenAI(
            api_key  = _groq_key,
            base_url = "https://api.groq.com/openai/v1",
        )
        _groq_model = "llama-3.3-70b-versatile"
        _GROQ_OK    = True
    except ImportError:
        pass

# Priority 2: OpenAI directly
if not _GROQ_OK:
    _openai_key = os.getenv("OPENAI_API_KEY", "")
    if _openai_key:
        try:
            from openai import OpenAI as _OpenAI
            _groq_client = _OpenAI(api_key=_openai_key)
            _groq_model  = "gpt-4o-mini"
            _GROQ_OK     = True
        except ImportError:
            pass

# =============================================================================
# INTERNAL HELPERS
# =============================================================================

def _load_json(value, fallback):
    if not value:
        return fallback
    if isinstance(value, (list, dict)):
        return value
    try:
        return json.loads(value)
    except (json.JSONDecodeError, TypeError):
        return fallback


def _llm_call(prompt: str, system: str, max_tokens: int = 800) -> str:
    """
    Call the configured LLM (Groq or OpenAI). Returns response text or "".
    """
    if not _GROQ_OK or not _groq_client:
        return ""
    try:
        response = _groq_client.chat.completions.create(
            model       = _groq_model,
            messages    = [
                {"role": "system", "content": system},
                {"role": "user",   "content": prompt},
            ],
            max_tokens  = max_tokens,
            temperature = 0.4,
            timeout     = config.OPENAI_TIMEOUT,
        )
        return response.choices[0].message.content.strip()
    except Exception:
        return ""


# =============================================================================
# 1. EXPLAINABILITY SUMMARY
# =============================================================================

def _verdict_from_tier(tier: str) -> str:
    """Map matcher tier β†’ human verdict label used in the recruiter summary."""
    t = (tier or "").strip().lower()
    if "strong" in t:    return "Strong Fit"
    if "border" in t or "moderate" in t or "medium" in t: return "Moderate Fit"
    return "Weak Fit"


def _template_recruiter_summary(name: str, title: str, exp_years: float,
                                current_role: str, matched: list, missing: list,
                                standout: list, achievements: list, tier: str,
                                req_years: float, education: str) -> str:
    """
    Deterministic 80-120 word recruiter summary used when the LLM is
    unavailable or returns an unusable response. Hits the same six bullets the
    spec asks for, just less fluent than an LLM output.
    """
    verdict   = _verdict_from_tier(tier)
    role_str  = current_role or "previous role unspecified in CV"
    exp_str   = (f"{exp_years:.0f}+ years" if exp_years >= 1
                 else "early-career (under a year)")
    top_match = (", ".join(matched[:4]) if matched
                 else "no directly matching skills")

    edu_str = ""
    if education:
        edu_str = f" Education on file: {education}."

    standout_str = ""
    if standout:
        standout_str = (
            f" Brings depth beyond the JD in {', '.join(standout[:3])}, "
            f"which can add lift on adjacent work."
        )

    gap_str = ""
    if missing:
        gap_str = (
            f" Important gap to probe at interview: {', '.join(missing[:2])}, "
            f"both called out in the JD as required."
        )
    elif req_years > exp_years > 0:
        gap_str = (
            f" Experience runs {req_years - exp_years:.1f} years short of the "
            f"JD's {req_years:.0f}-year bar; weigh against demonstrated impact."
        )

    ach_str = ""
    if achievements:
        a = achievements[0]
        if len(a) > 110:
            a = a[:107] + "…"
        ach_str = f" Notable on record: {a.rstrip('.')}."

    strength_str = ""
    if matched:
        strength_str = (
            f" Strongest direct alignment is on {matched[0]} and "
            f"{matched[1] if len(matched) > 1 else 'the JD core stack'}, "
            f"making them worth a screen for {title}."
        )

    fit_reason = {
        "Strong Fit"  : "most must-haves are present and experience clears the bar.",
        "Moderate Fit": "core skills match but key gaps remain; suitable with caveats.",
        "Weak Fit"    : "major JD requirements are unmet; pass unless pipeline is thin.",
    }[verdict]

    parts = [
        f"{name} brings {exp_str} of experience, currently {role_str}.",
        f"Strongest direct matches for the {title} role: {top_match}.",
        edu_str,
        ach_str,
        strength_str,
        standout_str,
        gap_str,
        f"Overall: {verdict} β€” {fit_reason}",
    ]
    out = " ".join(p.strip() for p in parts if p and p.strip())
    return re.sub(r"\s+", " ", out).strip()


def generate_summary(match_result: dict, resume: dict, jd: dict) -> str:
    """
    Recruiter-focused candidate-vs-JD summary (80-120 words).

    Format spec (per project requirements):
      - Mention total experience and current role
      - Highlight strongest matching skills / domain relevant to the JD
      - Mention notable achievements or responsibilities
      - Identify strengths that make the candidate suitable
      - Briefly mention any important gap or limitation if relevant
      - End with overall fit verdict (Strong / Moderate / Weak Fit with reason)

    Uses the LLM when available (Groq β†’ OpenAI). Falls back to a deterministic
    template that still covers all six points if no LLM is reachable.

    Args:
        match_result : Dict from matcher.match_resume_to_jd()
        resume       : Dict from extractor.build_resume_record()
        jd           : Dict from jd_parser.parse_jd_text()

    Returns:
        Plain-text paragraph, ~80-120 words.
    """
    name     = resume.get("candidate_name") or "The candidate"
    title    = jd.get("jd_title")           or "the role"
    tier     = match_result.get("confidence_tier", "Unknown")
    score    = match_result.get("final_score", 0)

    matched   = _load_json(match_result.get("matched_skills"),  [])
    missing   = _load_json(match_result.get("missing_skills"),  [])
    standout  = _load_json(match_result.get("standout_skills"), [])
    required  = _load_json(jd.get("required_skills"),           [])

    exp_years    = float(resume.get("total_experience_years") or
                         resume.get("experience_years") or 0)
    req_years    = float(jd.get("required_experience")    or 0)
    avg_tenure   = float(resume.get("avg_tenure_months")  or 0)
    recency_flag = (resume.get("recency_flag") or "Unknown").title()

    job_titles   = _load_json(resume.get("job_titles"),    [])
    achievements = _load_json(resume.get("achievements"),  [])
    last_company = (resume.get("last_company") or "").strip()
    current_role = (job_titles[0] if job_titles else "").strip()
    if current_role and last_company:
        current_role = f"{current_role} at {last_company}"

    edu_data      = _load_json(resume.get("education"), {})
    candidate_deg = edu_data.get("degree", "")

    # ── LLM path ──────────────────────────────────────────────────────────────
    if _GROQ_OK:
        concerns = []
        if avg_tenure > 0 and avg_tenure < config.MIN_TENURE_MONTHS:
            concerns.append(
                f"avg tenure {avg_tenure:.0f} months (below "
                f"{config.MIN_TENURE_MONTHS}-month threshold)"
            )
        if recency_flag == "Stale":
            concerns.append("key skills not used in most recent role")
        if req_years and exp_years < req_years:
            concerns.append(
                f"experience {exp_years:.1f} yrs vs {req_years:.0f} yrs required"
            )

        system = (
            "You are a senior technical recruiter writing a decision-oriented "
            "summary for a hiring manager. Output ONE paragraph of plain prose, "
            "80 to 120 words. No bullet points, no headings, no markdown, no "
            "preamble like 'Here is the summary'. Be specific to the candidate "
            "and the JD; do not paraphrase the resume. Do not invent facts "
            "that are not in the data provided."
        )
        prompt = f"""Write a recruiter summary comparing this candidate to the JD.

Candidate: {name}
Total experience (computed to today's date): {exp_years:.1f} years
Current role: {current_role or 'unknown'}
Education: {candidate_deg or 'not stated'}
Top notable achievements: {'; '.join(achievements[:3]) if achievements else 'none listed'}

JD title: {title}
JD required experience: {req_years:.0f} years
JD required skills: {', '.join(required[:10]) if required else 'unspecified'}

Match details:
- Skills matched with JD: {', '.join(matched[:8]) if matched else 'none'}
- Strong/standout skills beyond JD: {', '.join(standout[:5]) if standout else 'none'}
- Missing required skills: {', '.join(missing[:5]) if missing else 'none'}
- Concerns: {'; '.join(concerns) if concerns else 'none'}
- Composite match score: {score:.1f}/100
- System verdict: {_verdict_from_tier(tier)}

Required content, in order, all in one paragraph (80–120 words):
1) Total experience and current role.
2) Strongest matching skills / domain relevance to the JD.
3) One notable achievement or responsibility (only if listed above).
4) Strengths that make this candidate suitable for the role.
5) The most important gap or limitation (only if relevant β€” skip if none).
6) End with EXACTLY one of these verdicts followed by a short reason:
   "Overall: Strong Fit β€” <reason>." / "Overall: Moderate Fit β€” <reason>." / "Overall: Weak Fit β€” <reason>."

Do not repeat the resume verbatim. Do not hallucinate skills, employers, or
achievements not listed above. Output ONLY the paragraph."""

        raw = _llm_call(prompt, system, max_tokens=400)
        if raw:
            # Strip stray markdown fences / leading labels
            clean = re.sub(r"^```(?:\w+)?|```$", "", raw.strip(),
                           flags=re.MULTILINE).strip()
            clean = re.sub(r"^(summary|recruiter summary)\s*[:\-]\s*", "",
                           clean, flags=re.IGNORECASE).strip()
            # Sanity-check word count (50–180 keeps near-misses)
            wc = len(clean.split())
            if 50 <= wc <= 180 and "Overall:" in clean:
                return clean

    # ── Template fallback ────────────────────────────────────────────────────
    edu_str = candidate_deg
    if candidate_deg and edu_data.get("field"):
        edu_str = f"{candidate_deg} in {edu_data.get('field')}"
    return _template_recruiter_summary(
        name=name, title=title, exp_years=exp_years,
        current_role=current_role, matched=matched, missing=missing,
        standout=standout, achievements=achievements, tier=tier,
        req_years=req_years, education=edu_str,
    )


# =============================================================================
# 2. INTERVIEW QUESTION GENERATOR
# =============================================================================

def _template_questions(match_result: dict,
                         resume: dict,
                         jd: dict) -> list:
    """
    Generate interview questions from templates β€” no LLM needed.

    This is Tier 3: always available, always produces sensible questions.
    Used when both Groq and OpenAI are unavailable or fail.

    Returns:
        List of question dicts:
        [{"type": "Skill Validation", "question": "..."}]
    """
    name       = resume.get("candidate_name") or "the candidate"
    matched    = _load_json(match_result.get("matched_skills"),  [])
    missing    = _load_json(match_result.get("missing_skills"),  [])
    avg_tenure = float(resume.get("avg_tenure_months") or 0)
    recency    = (resume.get("recency_flag") or "Unknown").title()
    exp_years  = float(resume.get("experience_years") or 0)
    req_years  = float(jd.get("required_experience")  or 0)
    projects   = _load_json(resume.get("projects"), [])
    jd_title   = jd.get("jd_title") or "this role"

    questions = []
    cfg       = config.INTERVIEW_QUESTIONS_PER_TYPE

    # ── Skill Validation (verify top matched skills) ──────────────────────────
    sv_count = 0
    for skill in matched[:4]:
        if sv_count >= cfg.get("skill_validation", 2):
            break
        questions.append({
            "type"    : "Skill Validation",
            "question": (
                f"Your CV highlights {skill} as a key skill. "
                f"Can you walk me through the most complex problem "
                f"you solved using {skill}, and what the business impact was?"
            ),
        })
        sv_count += 1

    # If no matched skills, ask about top resume skills
    if not matched:
        resume_skills = _load_json(resume.get("skills"), [])
        for skill in resume_skills[:cfg.get("skill_validation", 2)]:
            questions.append({
                "type"    : "Skill Validation",
                "question": (
                    f"Tell me about a project where {skill} was central "
                    f"to your work. What was your specific contribution?"
                ),
            })

    # ── Gap Probing (explore missing JD requirements) ─────────────────────────
    gp_count = 0
    for skill in missing[:4]:
        if gp_count >= cfg.get("gap_probing", 2):
            break
        questions.append({
            "type"    : "Gap Probing",
            "question": (
                f"This role requires experience with {skill}, which isn't "
                f"explicitly mentioned on your CV. Have you worked with it "
                f"in any capacity, and how quickly do you think you could "
                f"get up to speed?"
            ),
        })
        gp_count += 1

    # If no missing skills, probe the JD preferred skills
    if not missing:
        preferred = _load_json(jd.get("preferred_skills"), [])
        for skill in preferred[:cfg.get("gap_probing", 2)]:
            questions.append({
                "type"    : "Gap Probing",
                "question": (
                    f"We'd ideally like someone with {skill} exposure. "
                    f"What's your familiarity with it, and how have you "
                    f"approached learning new tools in the past?"
                ),
            })

    # ── Behavioural / Concern ─────────────────────────────────────────────────
    beh_count = 0
    limit     = cfg.get("behavioural", 1)

    if avg_tenure > 0 and avg_tenure < config.MIN_TENURE_MONTHS and beh_count < limit:
        questions.append({
            "type"    : "Behavioural",
            "question": (
                f"Looking at your career history, your average tenure per role "
                f"is around {avg_tenure:.0f} months. Can you help me understand "
                f"what drove those transitions, and what would make "
                f"{jd_title} a long-term commitment for you?"
            ),
        })
        beh_count += 1

    if recency == "Stale" and beh_count < limit:
        top_skills = _load_json(resume.get("skills"), [])[:2]
        skill_str  = " and ".join(top_skills) if top_skills else "your listed skills"
        questions.append({
            "type"    : "Behavioural",
            "question": (
                f"It looks like {skill_str} may not have been used actively "
                f"in your most recent role. How have you kept those skills "
                f"current, and can you share a recent example of applying them?"
            ),
        })
        beh_count += 1

    if beh_count < limit and exp_years < req_years and req_years > 0:
        shortfall = round(req_years - exp_years, 1)
        questions.append({
            "type"    : "Behavioural",
            "question": (
                f"This role typically requires around {req_years:.0f} years of "
                f"experience, and you have {exp_years:.0f}. What specific "
                f"experiences do you feel compensate for that gap, and how "
                f"have you handled situations that required knowledge beyond "
                f"your experience level?"
            ),
        })
        beh_count += 1

    if beh_count < limit and projects:
        questions.append({
            "type"    : "Behavioural",
            "question": (
                f"Tell me about '{projects[0]}' from your CV. "
                f"What was the biggest challenge you faced, and how did "
                f"you handle it under pressure?"
            ),
        })

    return questions[:sum(cfg.values())]


def _llm_questions(match_result: dict,
                   resume: dict,
                   jd: dict) -> list:
    """
    Generate interview questions using an LLM (Groq β†’ OpenAI fallback).

    Prompts the model for structured JSON output β€” one question object per
    type. Falls back to template questions if LLM output is invalid.

    Returns:
        List of {"type": str, "question": str} dicts,
        or empty list if LLM is unavailable.
    """
    if not _GROQ_OK:
        return []

    name       = resume.get("candidate_name") or "the candidate"
    jd_title   = jd.get("jd_title")           or "the role"
    matched    = _load_json(match_result.get("matched_skills"),  [])
    missing    = _load_json(match_result.get("missing_skills"),  [])
    standout   = _load_json(match_result.get("standout_skills"), [])
    avg_tenure = float(resume.get("avg_tenure_months") or 0)
    recency    = resume.get("recency_flag") or "Unknown"
    exp_years  = float(resume.get("experience_years") or 0)
    req_years  = float(jd.get("required_experience")  or 0)
    projects   = _load_json(resume.get("projects"), [])
    cfg        = config.INTERVIEW_QUESTIONS_PER_TYPE
    sv         = cfg.get("skill_validation", 2)
    gp         = cfg.get("gap_probing",      2)
    bh         = cfg.get("behavioural",      1)

    concern_lines = []
    if avg_tenure > 0 and avg_tenure < config.MIN_TENURE_MONTHS:
        concern_lines.append(
            f"- Average tenure is {avg_tenure:.0f} months "
            f"(below {config.MIN_TENURE_MONTHS}-month threshold)"
        )
    if recency == "Stale":
        concern_lines.append("- Key skills may be out of date β€” not used recently")
    if exp_years < req_years and req_years > 0:
        concern_lines.append(
            f"- Experience gap: {exp_years} yrs vs {req_years} yrs required"
        )

    system = (
        "You are an expert HR interviewer helping a recruiter evaluate candidates. "
        "Generate precise, role-specific interview questions based on the candidate profile provided. "
        "Return ONLY a valid JSON array. No explanation, no markdown fences, no extra text. "
        "Each item: {\"type\": string, \"question\": string}"
    )

    prompt = f"""Candidate: {name}
Role: {jd_title}

Matched skills: {', '.join(matched) if matched else 'None'}
Missing required skills: {', '.join(missing) if missing else 'None'}
Standout skills (beyond JD): {', '.join(standout[:3]) if standout else 'None'}
Notable projects: {', '.join(projects[:2]) if projects else 'None'}
Red flags / concerns:
{chr(10).join(concern_lines) if concern_lines else '- None identified'}

Generate exactly {sv + gp + bh} interview questions:
- {sv} "Skill Validation" questions: probe depth of claimed skills or standout skills
- {gp} "Gap Probing" questions: explore missing skills or weak areas  
- {bh} "Behavioural" questions: address the red flags above, or strongest project

Rules:
- Each question must be specific to THIS candidate β€” no generic questions
- Reference actual skill names, tenure numbers, or project names from above
- Questions should be open-ended, not yes/no
- Return ONLY the JSON array"""

    raw = _llm_call(prompt, system, max_tokens=900)

    if not raw:
        return []

    # Parse JSON β€” strip accidental markdown fences
    clean = re.sub(r"^```(?:json)?|```$", "", raw.strip(), flags=re.MULTILINE).strip()

    try:
        parsed = json.loads(clean)
        if not isinstance(parsed, list):
            return []

        # Validate structure
        valid = []
        for item in parsed:
            if (isinstance(item, dict) and
                    "type"     in item and
                    "question" in item and
                    len(item["question"]) > 10):
                valid.append({
                    "type"    : str(item["type"]).strip(),
                    "question": str(item["question"]).strip(),
                })

        return valid

    except (json.JSONDecodeError, KeyError, TypeError):
        return []


def generate_interview_questions(match_result: dict,
                                  resume: dict,
                                  jd: dict) -> list:
    """
    Generate targeted interview questions for a shortlisted candidate.

    Three-tier strategy:
      1. LLM (Groq β†’ OpenAI): personalised, context-aware questions
      2. Templates: rule-based questions from structured profile data
         (automatic fallback if LLM unavailable or returns invalid output)

    Args:
        match_result : Dict from matcher.match_resume_to_jd()
        resume       : Dict from extractor.build_resume_record()
        jd           : Dict from jd_parser.parse_jd_text()

    Returns:
        List of dicts: [{"type": "Skill Validation", "question": "..."}, ...]
        Always returns at least 3 questions.
    """
    # Try LLM first
    questions = _llm_questions(match_result, resume, jd)

    # Fall back to templates if LLM returned nothing useful
    if len(questions) < 2:
        questions = _template_questions(match_result, resume, jd)

    return questions


# =============================================================================
# 3. BIAS REPORT
# =============================================================================

def generate_bias_report(match_results: list, jd: dict) -> dict:
    """
    Analyse a shortlist for potential proxy bias signals.

    Checks:
    - Over-representation of a single institution type in top-N
    - Score distribution across candidates (wide gap may suggest threshold bias)

    Args:
        match_results : List of match result dicts, sorted by final_score DESC
                        (output of database.get_matches_by_jd())
        jd            : JD dict

    Returns:
        {
            "top5_institution_flag" : bool,
            "institution_breakdown" : dict,
            "score_range"           : {"min": float, "max": float, "spread": float},
            "flags"                 : list[str],   # plain-language warnings
            "recommendation"        : str,
        }
    """
    top5 = match_results[:5]
    flags = []

    # Institution analysis β€” count how many top-5 share the same institution
    # Education is a JSON string in match_results joined from resumes table
    institution_counts = {}
    for r in top5:
        edu = _load_json(r.get("education"), {})
        inst = (edu.get("institution") or "Unknown").strip().title()
        institution_counts[inst] = institution_counts.get(inst, 0) + 1

    top_inst       = max(institution_counts, key=institution_counts.get, default="")
    top_inst_share = institution_counts.get(top_inst, 0) / max(len(top5), 1)

    if top_inst_share > config.BIAS_INSTITUTION_THRESHOLD:
        flags.append(
            f"{top_inst_share:.0%} of your top-5 shortlist are from "
            f"'{top_inst}'. Review whether institution prestige is "
            f"influencing rankings beyond skill relevance."
        )

    # Score spread analysis
    scores = [r.get("final_score", 0) for r in match_results]
    if scores:
        score_min    = min(scores)
        score_max    = max(scores)
        score_spread = score_max - score_min
        if score_spread > 60:
            flags.append(
                f"Score spread is {score_spread:.0f} points "
                f"({score_min:.0f}–{score_max:.0f}). "
                f"Candidates near the cutoff threshold deserve manual review."
            )
    else:
        score_min = score_max = score_spread = 0.0

    recommendation = (
        "No significant bias signals detected in this shortlist."
        if not flags else
        "Manual review recommended for the flagged items above before finalising shortlist."
    )

    return {
        "top5_institution_flag" : top_inst_share > config.BIAS_INSTITUTION_THRESHOLD,
        "institution_breakdown" : institution_counts,
        "score_range"           : {
            "min"   : round(score_min,    2),
            "max"   : round(score_max,    2),
            "spread": round(score_spread, 2),
        },
        "flags"          : flags,
        "recommendation" : recommendation,
    }


# =============================================================================
# 4. JD-AWARE STRENGTHS / WEAKNESSES
# =============================================================================

def generate_jd_pros_cons(match_result: dict, resume: dict, jd: dict) -> tuple:
    """
    Produce JD-aware Strengths (pros) and Weaknesses (cons) for the candidate
    card. These differ from the generic resume_data pros/cons because they
    EXPLICITLY compare the candidate against the JD, so a recruiter gets a
    clear "where they fit / where they don't" signal at a glance.

    Output format:
        (pros: list[str], cons: list[str])

    Each list contains 3-5 short, action-oriented bullets. Strategy:
      1. Try LLM (Groq β†’ OpenAI) for nuanced, candidate-specific phrasing.
      2. Fall back to a deterministic rule-based builder that hits the same
         points using matched/missing skills, experience gap, recency,
         stability, and achievement signals.
    """
    name       = resume.get("candidate_name") or "the candidate"
    title      = jd.get("jd_title")           or "the role"
    matched    = _load_json(match_result.get("matched_skills"),  [])
    missing    = _load_json(match_result.get("missing_skills"),  [])
    standout   = _load_json(match_result.get("standout_skills"), [])
    required   = _load_json(jd.get("required_skills"),           [])

    exp_years    = float(resume.get("total_experience_years") or
                         resume.get("experience_years") or 0)
    req_years    = float(jd.get("required_experience")    or 0)
    avg_tenure   = float(resume.get("avg_tenure_months")  or 0)
    recency_flag = (resume.get("recency_flag") or "Unknown").title()
    achievements = _load_json(resume.get("achievements"),   [])
    certs        = _load_json(resume.get("certifications"), [])

    edu_data      = _load_json(resume.get("education"), {})
    candidate_deg = edu_data.get("degree", "")
    jd_edu_req    = jd.get("education_requirement", "")

    # ── LLM path ──────────────────────────────────────────────────────────────
    if _GROQ_OK:
        system = (
            "You are a senior recruiter. Produce ONE JSON object with exactly "
            "two keys: \"pros\" and \"cons\". Each value is a JSON list of 3-5 "
            "short bullet strings (≀18 words each). Every bullet MUST compare "
            "the candidate to the JD β€” not just describe the resume. NO markdown, "
            "NO commentary, NO preamble. Output ONLY the JSON object."
        )
        prompt = f"""Compare this candidate to the JD and produce JD-aware pros and cons.

Candidate: {name}
Total experience (today): {exp_years:.1f} yrs
Education: {candidate_deg or 'not stated'}
Avg tenure per role: {avg_tenure:.0f} months
Recency: {recency_flag}
Top achievements: {'; '.join(achievements[:3]) if achievements else 'none'}
Certifications: {', '.join(certs[:5]) if certs else 'none'}

JD title: {title}
JD required experience: {req_years:.0f} yrs
JD required skills: {', '.join(required[:10]) if required else 'unspecified'}
JD education requirement: {jd_edu_req or 'unspecified'}

Match details:
- Matched skills (resume vs JD): {', '.join(matched[:8]) if matched else 'none'}
- Standout skills beyond JD: {', '.join(standout[:5]) if standout else 'none'}
- Missing JD-required skills: {', '.join(missing[:5]) if missing else 'none'}

Pros rules:
- Each bullet must explain WHY it's a strength FOR THIS JD.
- Reference an actual matched skill, year count, achievement, or cert.
- No generic praise ("strong candidate", "good fit").

Cons rules:
- Each bullet must explain WHY it's a gap FOR THIS JD.
- Reference an actual missing skill, experience shortfall, tenure issue,
  recency issue, or education mismatch.
- If there are genuinely no significant gaps, return a one-bullet cons list
  saying so.

Output JSON only, e.g. {{"pros":["..."], "cons":["..."]}}"""

        raw = _llm_call(prompt, system, max_tokens=500)
        if raw:
            clean = re.sub(r"^```(?:\w+)?|```$", "", raw.strip(),
                           flags=re.MULTILINE).strip()
            try:
                obj = json.loads(clean)
                if (isinstance(obj, dict) and
                        isinstance(obj.get("pros"), list) and
                        isinstance(obj.get("cons"), list)):
                    pros = [str(p).strip() for p in obj["pros"] if str(p).strip()][:5]
                    cons = [str(c).strip() for c in obj["cons"] if str(c).strip()][:5]
                    if pros and cons:
                        return pros, cons
            except (json.JSONDecodeError, TypeError):
                pass

    # ── Rule-based fallback ───────────────────────────────────────────────────
    pros: list = []
    cons: list = []

    # Pros β€” start with the strongest skill match
    if matched:
        top = matched[:3]
        if len(top) == 1:
            pros.append(f"Direct match on JD-required {top[0]}.")
        else:
            pros.append(f"Direct matches on JD-required {', '.join(top)}.")
    if exp_years > 0 and req_years > 0 and exp_years >= req_years:
        pros.append(
            f"Experience of {exp_years:.1f} yrs clears the JD's "
            f"{req_years:.0f}-yr bar."
        )
    elif exp_years >= 3:
        pros.append(f"Brings {exp_years:.1f} yrs of relevant experience.")
    if standout:
        pros.append(
            f"Adds depth beyond the JD in {', '.join(standout[:3])}."
        )
    if recency_flag == "Recent":
        pros.append("Currently active in the field β€” skills look current.")
    if avg_tenure >= 24:
        pros.append(
            f"Stable career history (avg {avg_tenure:.0f} months per role)."
        )
    if achievements:
        a = achievements[0]
        if len(a) > 110:
            a = a[:107] + "…"
        pros.append(f"Notable achievement: {a.rstrip('.')}.")
    if certs:
        pros.append(f"Holds {len(certs)} relevant certification(s).")
    if not pros:
        pros.append(
            f"Resume on file but limited direct alignment with {title}; "
            f"manual review recommended."
        )

    # Cons β€” start with the most damaging gap
    if missing:
        if len(missing) <= 2:
            cons.append(
                f"Missing JD-required {', '.join(missing)} β€” verify at interview."
            )
        else:
            cons.append(
                f"Missing {len(missing)} JD-required skills "
                f"({', '.join(missing[:3])}…)."
            )
    if req_years > 0 and exp_years < req_years:
        shortfall = req_years - exp_years
        cons.append(
            f"Experience runs {shortfall:.1f} yrs short of the "
            f"{req_years:.0f}-yr JD bar."
        )
    if avg_tenure > 0 and avg_tenure < config.MIN_TENURE_MONTHS:
        cons.append(
            f"Avg tenure of {avg_tenure:.0f} months suggests job-hopping risk "
            f"(JD threshold: {config.MIN_TENURE_MONTHS} months)."
        )
    if recency_flag == "Stale":
        cons.append(
            "Key skills may be stale β€” not visible in most-recent role."
        )
    if jd_edu_req and candidate_deg:
        if not any(d.lower() in candidate_deg.lower()
                   for d in jd_edu_req.split("/")):
            cons.append(
                f"Education mismatch: JD asks {jd_edu_req}, candidate has "
                f"{candidate_deg}."
            )
    if not cons:
        cons.append(
            "No significant gaps detected against this JD β€” strong alignment."
        )

    return pros[:5], cons[:5]


# =============================================================================
# MASTER FUNCTION β€” Only Call From main.py
# =============================================================================

def build_full_output(match_result: dict,
                       resume: dict,
                       jd: dict) -> dict:
    """
    Enrich a match result with explainability summary, interview questions
    and JD-aware strengths/weaknesses.

    Takes the raw match result from matcher.match_resume_to_jd() and adds:
      - A recruiter-focused summary (80-120 words, JD-comparative)
      - Targeted interview questions (LLM or template-based)
      - JD-aware pros and cons (strengths/weaknesses vs the specific JD)

    The returned dict is a direct drop-in replacement for the match_result β€”
    all original keys are preserved and the following fields are added/updated:
      - "summary"             : recruiter-focused JD-comparative paragraph
      - "interview_questions" : JSON list of question dicts
      - "pros_jd"             : JSON list of JD-aware strength bullets
      - "cons_jd"             : JSON list of JD-aware weakness bullets

    Args:
        match_result : Dict from matcher.match_resume_to_jd()
        resume       : Dict from extractor.build_resume_record()
        jd           : Dict from jd_parser.parse_jd_text()

    Returns:
        Enriched match_result dict, ready for database.insert_match_result()
    """
    enriched = dict(match_result)  # Shallow copy β€” do not mutate the original

    # Generate structured summary
    enriched["summary"] = generate_summary(match_result, resume, jd)

    # Generate interview questions
    questions = generate_interview_questions(match_result, resume, jd)
    enriched["interview_questions"] = json.dumps(questions)

    # Generate JD-aware pros and cons
    pros_jd, cons_jd = generate_jd_pros_cons(match_result, resume, jd)
    enriched["pros_jd"] = json.dumps(pros_jd)
    enriched["cons_jd"] = json.dumps(cons_jd)

    return enriched