File size: 65,519 Bytes
7ff6662
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22120b8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
972aba6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bd9ddce
 
 
 
 
972aba6
 
 
bd9ddce
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47b43eb
 
 
 
 
 
 
 
 
 
 
 
972aba6
 
 
 
 
 
 
 
 
 
 
4485b7c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
972aba6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4485b7c
 
 
 
ab9af33
 
 
 
 
 
 
 
 
4485b7c
 
ab9af33
 
 
 
 
4485b7c
ab9af33
 
4485b7c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4f65a1d
 
 
 
 
 
 
 
 
 
4485b7c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ab9af33
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
07a2f3f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7ff6662
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ab9af33
 
 
 
 
 
7ff6662
 
 
 
 
 
 
 
 
 
 
0c179aa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f479a80
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0c179aa
f479a80
 
 
 
 
 
 
 
 
 
 
 
 
0c179aa
 
 
f479a80
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0c179aa
 
 
f479a80
0c179aa
 
f479a80
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47b43eb
 
 
 
 
 
 
 
 
 
 
f479a80
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0c179aa
 
7ff6662
 
b0e9d71
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7ff6662
 
 
 
 
22120b8
 
972aba6
 
 
 
 
 
22120b8
 
972aba6
22120b8
 
 
 
 
 
7ff6662
972aba6
22120b8
972aba6
22120b8
 
972aba6
 
 
 
 
 
 
 
22120b8
 
 
972aba6
22120b8
 
972aba6
22120b8
7ff6662
0c179aa
 
 
 
f479a80
0c179aa
972aba6
7ff6662
 
 
 
22120b8
7ff6662
 
 
972aba6
 
 
 
 
 
 
 
f479a80
 
 
 
7ff6662
 
972aba6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7ff6662
 
4485b7c
 
 
 
 
 
 
 
 
7ff6662
4485b7c
 
 
7ff6662
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8e3dfba
 
 
 
7ff6662
8e3dfba
7ff6662
8e3dfba
7ff6662
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
07a2f3f
 
 
 
 
7ff6662
 
 
07a2f3f
 
 
7ff6662
 
 
 
 
 
 
 
 
 
07a2f3f
 
7ff6662
 
 
 
 
 
 
 
 
 
07a2f3f
 
 
 
 
 
 
 
ab9af33
8e3dfba
 
 
ab9af33
8e3dfba
 
 
 
07a2f3f
 
ab9af33
 
07a2f3f
33ec77b
 
 
 
07a2f3f
 
 
 
 
 
 
 
 
ab9af33
 
 
07a2f3f
 
7ff6662
 
 
 
 
 
 
 
 
 
 
 
 
07a2f3f
 
 
 
7ff6662
 
 
 
 
 
 
 
 
 
 
07a2f3f
7ff6662
 
 
 
bd9ddce
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0c179aa
 
 
 
 
 
bd9ddce
 
7ff6662
 
bd9ddce
 
 
 
 
 
7ff6662
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
"""
ATS Scorer β€” Resume-Matcher style hybrid.

HOW IT WORKS (plain English):
─────────────────────────────────────────────────────────────
Step 1: Extract keywords FROM the job description
   Method A (regex):  finds PM tools, skills, domain terms in the JD text
   Method B (LLM):    a fast model reads the JD and extracts EXACTLY what
                      the employer wants (required + preferred + general)
                      β†’ This is what Resume-Matcher does; catches synonyms
                        and context that regex misses

Step 2: Check each keyword in YOUR resume
   Uses word-boundary regex: (?<!\\w)keyword(?!\\w)
   Example: "sql" matches "SQL skills" but NOT "visual" or "casual"
   Multi-word: "product roadmap" matches as full phrase

Step 3: Calculate JD Match Score
   matched_keywords / total_jd_keywords Γ— 100
   β†’ 90% means 9 out of 10 JD keywords appear in your resume
   β†’ 40% means the resume is missing most of what the JD asks for

Step 4: Resume Quality Score (independent of JD)
   Checks: sections present, action verbs, quantified metrics,
           skill count, bullet count, formatting
   This measures "is this a good PM resume?" regardless of which job

Step 5: Combined ATS Score
   Final = JD Match Γ— 70%  +  Resume Quality Γ— 30%
   β†’ JD match dominates because that's what ATS systems actually filter on

BEFORE tailoring: maybe 55-70% (missing JD-specific tools/keywords)
AFTER tailoring:  85-95% (LLM added the missing JD keywords naturally)
"""

import re
from typing import Dict, List, Tuple


# ── JD Keyword Categories (extracted from job descriptions) ─────────────────

# PM domain base keywords (always checked against any PM JD)
PM_BASE_KEYWORDS = [
    "product manager", "product roadmap", "product strategy", "product vision",
    "go-to-market", "mvp", "agile", "scrum", "sprint", "backlog",
    "user story", "stakeholder", "cross-functional", "a/b testing",
    "funnel optimization", "conversion rate", "retention", "kpi",
    "user research", "ux", "data-driven", "analytics", "growth",
    "product lifecycle", "feature prioritization", "product discovery",
]

# Tools and platforms commonly required in PM JDs
PM_TOOLS = [
    "jira", "confluence", "notion", "asana", "trello", "linear",
    "figma", "miro", "amplitude", "mixpanel", "segment", "hotjar",
    "tableau", "power bi", "looker", "google analytics",
    "salesforce", "hubspot", "webengage", "clevertap",
    "sql", "python", "api", "crm", "automation",
]

# Action verbs (quality signal for bullet points)
ACTION_VERBS = [
    "achieved", "built", "created", "delivered", "enhanced",
    "generated", "improved", "launched", "managed", "optimized",
    "led", "developed", "designed", "implemented", "analyzed",
    "automated", "scaled", "reduced", "increased", "drove",
    "spearheaded", "pioneered", "transformed", "streamlined",
]

# PM skills for quality score
PM_SKILLS = {
    "tools": [
        "jira", "confluence", "notion", "figma", "miro", "amplitude",
        "mixpanel", "segment", "tableau", "power bi", "looker",
        "google analytics", "salesforce", "hubspot", "webengage",
        "clevertap", "slack", "airtable", "productboard",
    ],
    "frameworks": [
        "agile", "scrum", "kanban", "lean", "okr", "design thinking",
        "sprint planning", "story mapping", "hypothesis testing",
    ],
    "technical": [
        "sql", "python", "api", "crm", "automation", "llm",
        "conversational ai", "ocr", "machine learning", "ai",
    ],
    "soft_skills": [
        "leadership", "communication", "stakeholder management",
        "cross-functional", "mentoring", "prioritization",
        "problem-solving", "strategic thinking", "collaboration",
    ],
}

SECTION_HEADERS = {
    "experience": ["experience", "work experience", "employment", "professional experience"],
    "education":  ["education", "academic", "qualification"],
    "skills":     ["skills", "technical skills", "competencies", "expertise", "core competencies"],
    "summary":    ["summary", "professional summary", "profile", "objective", "about me"],
    "projects":   ["projects", "key projects", "products", "key achievements"],
    "achievements": ["achievements", "key metrics", "highlights", "accomplishments"],
}

IMPACT_KEYWORDS = [
    "improved", "increased", "reduced", "users", "revenue",
    "growth", "efficiency", "conversion", "retention", "leads",
    "cost", "performance", "engagement", "scale",
]


# ─────────────────────────────────────────────────────────────────────────
# PM SKILL TAXONOMY β€” the allowlist that drives JD keyword extraction.
#
# A JD token/phrase becomes a "keyword" ONLY if it is here (or matches a
# skill regex below). This makes extraction robust across ANY company / JD:
# company names, locations, stock tickers, JD prose, and section headers are
# never in the taxonomy, so they can never pollute the keyword set.
#
# Organized by category for readability. All entries are lowercase.
# ─────────────────────────────────────────────────────────────────────────
PM_SKILL_TAXONOMY = {
    # ── Core PM craft ──
    "product manager", "product owner", "product management", "product strategy",
    "product roadmap", "roadmap", "product vision", "product discovery",
    "product lifecycle", "product development", "feature prioritization",
    "prioritization", "go-to-market", "gtm", "mvp", "product-market fit",
    "0 to 1", "0β†’1", "zero to one", "product analytics", "product sense",
    # ── Agile / delivery ──
    "agile", "scrum", "kanban", "lean", "sprint", "sprint planning",
    "backlog", "backlog grooming", "backlog management", "story mapping",
    "user story", "user stories", "epics", "epic", "acceptance criteria",
    "release notes", "release management", "iteration", "retrospective",
    "scrum master", "agile methodology", "agile/scrum", "ceremonies",
    # ── Requirements / documentation ──
    "prd", "prds", "fsd", "brd", "wireframes", "wireframing", "mockups",
    "requirements gathering", "requirements elicitation", "elicitation",
    "documentation", "functional specification", "specifications",
    "user acceptance testing", "uat", "test plans", "gap analysis",
    # ── Research / analytics ──
    "user research", "market research", "competitive analysis",
    "competitor analysis", "competitive benchmarking", "ux research",
    "usability testing", "user testing", "a/b testing", "ab testing",
    "experimentation", "hypothesis testing", "cohort analysis",
    "funnel analysis", "funnel optimization", "data analysis",
    "data-driven", "analytics", "kpi", "kpis", "okr", "okrs", "metrics",
    "conversion rate", "conversion rate optimization", "retention",
    "activation", "adoption", "engagement", "churn", "ltv", "arpu",
    # ── Design / UX ──
    "ux", "ui", "user experience", "user-centric", "design thinking",
    "customer journey", "user journey", "journey mapping", "personas",
    "customer empathy", "design systems",
    # ── Technical ──
    "api", "apis", "webhooks", "sql", "python", "rest", "graphql",
    "microservices", "system architecture", "databases", "data pipelines",
    "etl", "cloud", "aws", "azure", "gcp", "saas", "paas",
    "integrations", "automation", "ci/cd", "devops", "machine learning",
    "ml", "ai", "artificial intelligence", "llm", "llms", "generative ai",
    "conversational ai", "nlp", "ocr", "mlops", "model validation",
    "foundation models", "data science", "rca", "observability",
    # ── Security domain (for security PM roles) ──
    "siem", "soar", "xdr", "edr", "threat detection", "threat intelligence",
    "security operations", "secops", "incident response", "vulnerability",
    # ── Domain / business ──
    "b2b", "b2c", "saas", "martech", "fintech", "edtech", "healthtech",
    "ecommerce", "e-commerce", "marketplace", "payments", "lending",
    "credit", "banking", "insurance", "crm", "erp", "supply chain",
    "logistics", "growth", "growth hacking", "user acquisition",
    "monetization", "pricing", "billing", "subscription", "onboarding",
    "campaign management", "personalization", "recommendation",
    "chatbot", "chatbots", "whatsapp business api", "messaging",
    # ── Tools / platforms ──
    "jira", "confluence", "notion", "asana", "trello", "linear", "monday",
    "figma", "sketch", "miro", "mural", "amplitude", "mixpanel", "segment",
    "hotjar", "fullstory", "pendo", "heap", "tableau", "power bi", "looker",
    "metabase", "google analytics", "ga4", "salesforce", "hubspot",
    "webengage", "clevertap", "braze", "moengage", "zendesk", "intercom",
    "slack", "airtable", "productboard", "aha", "google ads", "zoom",
    # ── Leadership / collaboration ──
    "stakeholder management", "stakeholder", "cross-functional",
    "cross functional", "leadership", "team leadership", "mentoring",
    "communication", "collaboration", "strategic thinking", "problem-solving",
    "problem solving", "stakeholder communication", "change management",
    "vendor management", "p&l", "go-to-market strategy",
}

# Multi-word phrases in the taxonomy (matched as phrases, not single tokens)
PM_SKILL_PHRASES = sorted(
    [s for s in PM_SKILL_TAXONOMY if " " in s or "/" in s or "β†’" in s or "-" in s],
    key=len, reverse=True,  # longest first so "product roadmap" beats "product"
)

# Regex patterns for skills that may appear in many surface forms.
_TAXONOMY_PATTERNS = [
    re.compile(r"\b\d+\s*to\s*\d+\b"),          # "0 to 1"
    re.compile(r"\ba/?b\s*test\w*\b", re.I),     # a/b testing, ab test
]


def _is_taxonomy_skill(token_or_phrase: str) -> bool:
    """True if the token/phrase is a recognized PM skill (allowlist)."""
    t = token_or_phrase.strip().lower()
    if not t:
        return False
    if t in PM_SKILL_TAXONOMY:
        return True
    for pat in _TAXONOMY_PATTERNS:
        if pat.fullmatch(t) or pat.match(t):
            return True
    return False


# ─────────────────────────────────────────────────────────────────────────
# GENERIC PROFESSIONAL VOCABULARY β€” the terms real ATS checkers (Jobalytics,
# Simplify, JobScan) count that our skill taxonomy deliberately excluded.
#
# These are NOT PM-specific skills, but they ARE legitimate professional
# words that appear in JDs and that real checkers extract as keywords
# (Jobalytics counted "development", "application", "software", "solutions",
# "market" for the Experian JD). Including them is what makes our score
# track real checkers. They are safe to carry in a resume (a PM resume
# naturally says "product development", "software solutions", "go-to-market").
#
# Proper-noun noise (company names, locations, tickers) is STILL excluded
# because it's in neither this set nor the taxonomy.
# ─────────────────────────────────────────────────────────────────────────
GENERIC_PROFESSIONAL_VOCAB = {
    # Work/output nouns
    "development", "design", "engineering", "implementation", "delivery",
    "execution", "deployment", "operations", "maintenance", "support",
    "documentation", "testing", "validation", "monitoring", "reporting",
    "planning", "management", "administration", "coordination", "facilitation",
    # Product/tech nouns
    "software", "application", "applications", "platform", "platforms",
    "system", "systems", "technology", "technologies", "infrastructure",
    "architecture", "solution", "solutions", "product", "products", "feature",
    "features", "module", "modules", "tool", "tools", "service", "services",
    "data", "database", "databases", "dashboard", "dashboards", "interface",
    "integration", "integrations", "pipeline", "pipelines", "workflow",
    "workflows", "framework", "frameworks", "environment", "release",
    # Business nouns
    "market", "business", "strategy", "growth", "revenue", "customer",
    "customers", "user", "users", "stakeholder", "stakeholders", "team",
    "teams", "process", "processes", "quality", "performance", "efficiency",
    "impact", "outcome", "outcomes", "initiative", "initiatives", "project",
    "projects", "program", "programs", "portfolio", "roadmap", "vision",
    "requirements", "specification", "specifications", "scope", "priorities",
    "prioritization", "metrics", "kpis", "analytics", "insights", "research",
    "experimentation", "optimization", "automation", "innovation",
    # Collaboration / methodology nouns
    "collaboration", "collaborate", "collaborative", "communication",
    "communicate", "leadership", "ownership", "mentoring", "mentor",
    "agile", "scrum", "sprint", "iteration", "iterative", "backlog",
    "discovery", "launch", "lifecycle", "feedback", "alignment", "governance",
    "agile methodologies", "end-to-end", "cross-functional",
    # Domain-adjacent (kept generic)
    "cloud", "api", "apis", "frontend", "backend", "fullstack", "mobile",
    "web", "ml", "ai", "ux", "ui",
    # ── Additional terms real checkers flagged on the user's resumes
    # (Meta / Sitetracker / Vuori / Rupeek Jobalytics + Simplify screenshots).
    # These are common JD/PM vocabulary the resume should carry β€” adding them
    # broadens the denominator (more honest score) AND tells the tailoring to
    # cover them (higher real-checker score).
    "consumer", "consumers", "engineer", "engineers", "analysis",
    "competitive analysis", "customer needs", "data-driven", "data driven",
    "problem-solving", "problem solving", "decision-making", "decision making",
    "go-to-market", "user research", "user experience", "user-centric",
    "wireframes", "wireframing", "prototyping", "prototype", "prototypes",
    "acceptance criteria", "user stories", "user story", "epics", "epic",
    "personas", "journey", "segmentation", "positioning", "messaging",
    "experiments", "experiment", "a/b testing", "hypothesis", "validation",
    "instrumentation", "tracking", "funnel", "conversion", "retention",
    "activation", "adoption", "engagement", "churn", "ltv", "arpu", "nps",
    "scalable", "scalability", "reliability", "availability", "latency",
    "stakeholder management", "vendor", "partners", "partnerships",
    "negotiation", "influence", "presentation", "storytelling",
    "competitor", "competitors", "benchmarking", "market research",
    "gtm", "monetization", "pricing", "billing", "subscription",
    "onboarding", "activation", "personalization", "recommendation",
    "requirements gathering", "documentation", "specs", "prd", "prds",
    "okr", "okrs", "kpi", "kpis", "north star", "metrics-driven",
    "quantitative", "qualitative", "sql", "excel", "spreadsheets",
    "tableau", "looker", "powerbi", "amplitude", "mixpanel", "ga4",
    "jira", "confluence", "figma", "notion", "asana", "miro",
    # ── Common PM/business JD terms real checkers extract (so coverage stays
    # high now that extraction is skills-only). Multi-word forms also live in
    # PM_SKILL_PHRASES via the taxonomy; these single tokens + phrases fill gaps
    # seen on real PM JDs (Ema/Jobalytics): use cases, business objectives, etc.
    "use cases", "use case", "business objectives", "market trends",
    "user personas", "customer support", "product strategy", "product vision",
    "product development", "product management", "product manager",
    "product features", "product requirements",
    "competitor analysis", "competitive", "roadmapping", "gap analysis",
    "performance tracking", "iteration", "go-to-market strategy",
    "cross-functional collaboration", "senior management",
    "user-friendly", "milestones", "timelines",
}


def _is_professional_term(token_or_phrase: str) -> bool:
    """True if the term is a real skill OR generic professional vocabulary."""
    t = token_or_phrase.strip().lower()
    if not t:
        return False
    return _is_taxonomy_skill(t) or t in GENERIC_PROFESSIONAL_VOCAB


# ── Rules-based lemmatizer (no NLTK dependency, deterministic on HF Spaces) ──

# Order matters: longer suffixes first so we don't strip "s" before "ses".
_LEMMA_RULES: List[Tuple[str, str]] = [
    ("ies",  "y"),    # categories β†’ category
    ("ied",  "y"),    # categorized β†’ category-ish; close enough for matching
    ("ying", "y"),    # carrying β†’ carry
    ("sses", "ss"),   # processes β†’ process
    ("ches", "ch"),   # batches β†’ batch
    ("shes", "sh"),   # finishes β†’ finish
    ("oes",  "o"),    # goes β†’ go
    ("ses",  "s"),    # houses β†’ house (acceptable lossy)
    ("ings", ""),     # ratings β†’ rat β€” only fires if longer than ings+3
    ("ing",  ""),     # running β†’ runn ; close enough β€” we compare stems
    ("ed",   ""),     # automated β†’ automat ; matches "automation" prefix
    ("er",   ""),     # builder β†’ build
    ("est",  ""),     # fastest β†’ fast
    ("ly",   ""),     # quickly β†’ quick
    ("s",    ""),     # roadmaps β†’ roadmap
]

# Small alias map for cases the rule-based stemmer can't bridge cleanly.
# Keys and values are both lemmatized forms β€” these become equivalent.
_LEMMA_ALIASES: Dict[str, str] = {
    "automat": "automat",       # auto-canonicalize automate/automated/automation/automating
    "automation": "automat",
    "automate": "automat",
    "implementatio": "implement",
    "implementation": "implement",
    "configuratio": "configur",
    "configuration": "configur",
    "communicatio": "communic",
    "communication": "communic",
    "applicatio": "applic",
    "application": "applic",
    "integration": "integrat",
    "integrations": "integrat",
    "operatio": "operat",
    "operation": "operat",
    "operations": "operat",
    "optimizatio": "optim",
    "optimization": "optim",
    "documentatio": "document",
    "documentation": "document",
    "specificatio": "specif",
    "specification": "specif",
}


def _lemma(word: str) -> str:
    """Reduce a word to a stem so morphological variants compare equal.

    Examples:
      automated β†’ automat      automation β†’ automat       automate β†’ automat
      roadmaps β†’ roadmap       wireframes β†’ wirefram      authoring β†’ author
      API β†’ api                PRDs β†’ prd                 SaaS β†’ saa
    """
    w = word.lower().strip()
    if len(w) <= 3:
        return w
    # Honor aliases first
    if w in _LEMMA_ALIASES:
        return _LEMMA_ALIASES[w]
    for suffix, replacement in _LEMMA_RULES:
        if w.endswith(suffix) and len(w) - len(suffix) >= 3:
            stem = w[: -len(suffix)] + replacement
            return _LEMMA_ALIASES.get(stem, stem)
    return w


_TOKEN_RE = re.compile(r"\w+")
_STOPWORDS = {"the", "a", "an", "of", "and", "or", "to", "in", "on", "for", "with"}


def _tokens(text: str) -> List[str]:
    return _TOKEN_RE.findall(text.lower())


def _lemma_tokens(text: str) -> List[str]:
    return [_lemma(t) for t in _tokens(text)]


def _phrase_in_text(phrase: str, text: str, _cached_lemmas: List[str] = None) -> bool:
    """Match a (possibly multi-word) phrase via lemma + sliding-window.

    - Exact substring match (case-insensitive) returns True immediately
    - Single-word phrase: lemma-equal to any text token
    - Multi-word phrase: all phrase-lemmas appear within a 5-token window
      in the text (allows reordering and intervening words)
    """
    if not phrase:
        return False
    phrase_low = phrase.lower()
    text_low = text.lower()
    if phrase_low in text_low:
        return True

    p_lemmas = [_lemma(t) for t in _tokens(phrase_low) if t not in _STOPWORDS]
    if not p_lemmas:
        return False

    t_lemmas = _cached_lemmas if _cached_lemmas is not None else _lemma_tokens(text_low)

    # Single-word phrase: any text token whose lemma matches
    if len(p_lemmas) == 1:
        return p_lemmas[0] in t_lemmas

    # Multi-word: require the phrase lemmas to appear IN ORDER within a tight
    # window (matches how real ATS checkers score phrases β€” they want the
    # actual phrase, not its words scattered across the resume). A loose
    # any-order 5-token window over-matched and inflated our score vs real
    # checkers; in-order with a small gap is stricter and calibrated.
    n = len(p_lemmas)
    max_gap = 2  # allow up to 2 filler tokens between phrase words
    for i in range(len(t_lemmas)):
        if t_lemmas[i] != p_lemmas[0]:
            continue
        # Try to match the rest in order, allowing small gaps
        pos = i + 1
        matched = 1
        for target_lemma in p_lemmas[1:]:
            found_at = None
            for j in range(pos, min(pos + max_gap + 1, len(t_lemmas))):
                if t_lemmas[j] == target_lemma:
                    found_at = j
                    break
            if found_at is None:
                break
            matched += 1
            pos = found_at + 1
        if matched == n:
            return True
    return False


# ── JD keyword cleanup: drop company names and marketing noise ───────────────

# Words that surface from JD "about us" / "our clients" sections but aren't
# real skills. They shouldn't be counted as JD requirements.
_JD_NOISE_WORDS = {
    # Company / brand names commonly in "clients include" lists
    "adani", "godrej", "yakult", "wipro", "physicswallah", "physics wallah",
    "asian", "asian paints", "bluelotus", "marsshot", "skullcandy", "vivo",
    "cosco", "aditya", "aditya birla", "delhi", "transport", "corporation",
    "birla", "paints", "physics", "wallah", "aisensy", "navi", "zenda",
    "edgeverve", "ainext", "sumo", "logic", "airtel",
    # Generic prose / marketing
    "businesses", "businesses grow", "revenues", "high revenues",
    "messages", "working", "platform", "mission", "startup", "angel",
    "angel investors", "investors", "crores", "crore", "today",
    "enabling", "group", "about", "high", "team", "teams",
    "billion", "million", "hundred", "thousand",
    # Section labels rather than skills
    "requirements", "responsibilities", "preferred", "background",
    "qualifications", "opportunity", "company", "role", "roles",
    "summary", "overview", "purpose", "context", "challenges",
    # Adjectives describing requirements (not skills)
    "proven", "solid", "basic", "strong", "deep", "advanced", "excellent",
    "extensive", "demonstrated", "fundamental", "good", "great", "passionate",
    "results", "driven", "detail", "oriented", "proactive", "hands",
    "exceptional", "highly", "deeply", "structured", "scalable", "impactful",
    "innovative", "cutting", "intuitive", "powerful", "complex", "critical",
    "fast", "paced", "first", "minimal", "oversight",
    # Modals & generic action words that get extracted as proper nouns
    "will", "must", "can", "should", "would", "shall", "may", "might",
    "has", "have", "had", "able", "ability", "want", "wants",
    # Bullet-starter verbs (not skills)
    "develop", "drive", "drives", "drove", "deliver", "delivers", "delivered",
    "define", "defines", "defined", "ensure", "ensures", "ensured",
    "support", "supports", "supported", "execute", "executes", "executed",
    "engage", "engages", "engaged", "manage", "manages", "managed",
    "lead", "leads", "led", "create", "creates", "created",
    "design", "designs", "designed", "implement", "implements", "implemented",
    "build", "builds", "built", "launch", "launches", "launched",
    "monitor", "monitors", "monitored", "track", "tracks", "tracked",
    "improve", "improves", "improved", "review", "reviews", "reviewed",
    # Generic non-skill nouns
    "level", "year", "years", "candidate", "candidates", "month", "months",
    "position", "positions", "function", "functions", "process", "processes",
    "experience", "experiences", "knowledge", "exposure", "needs", "need",
    "outcomes", "outcome", "value", "values", "voice", "users", "user",
    "customer", "customers", "stakeholder", "stakeholders", "feedback",
    "insight", "insights", "vision", "decision", "decisions", "decisioning",
    # Adverbs
    "continuously", "regularly", "frequently", "occasionally", "primarily",
    "directly", "independently", "effectively", "successfully", "actively",
    # Joining phrases / generic
    "best", "key", "major", "core", "various", "multiple", "several",
    "many", "few", "additional",
    # More process verbs that leak through proper-noun extraction
    "perform", "performs", "performed", "performing",
    "present", "presents", "presented", "presenting",
    "establish", "establishes", "established", "establishing",
    "evangelize", "evangelizes", "evangelized", "evangelizing",
    "stay", "stays", "stayed", "staying",
    "integrate", "integrates", "integrated", "integrating",
    "sign", "signs", "signed", "signing",
    "publish", "publishes", "published", "publishing",
    "handle", "handles", "handled", "handling",
    "moving", "moved", "move",
    "provide", "provides", "provided", "providing",
    "evaluate", "evaluates", "evaluated", "evaluating",
    "meet", "meets", "met", "meeting",
    "gather", "gathers", "gathered", "gathering",
    "champion", "champions", "championed",
    "transform", "transforms", "transformed", "transforming",
    "spearhead", "spearheads", "spearheaded", "spearheading",
    "contribute", "contributes", "contributed", "contributing",
    "represent", "represents", "represented", "representing",
    "navigate", "navigates", "navigated", "navigating",
    # JD section headers + meta words
    "what", "doing", "bring", "join", "located", "location",
    "inc", "ltd", "limited", "llc", "pvt", "private",
    "experience", "experiences", "background", "knowledge",
    "result", "results", "areas", "kra", "kras",
    # Education noise (it's required, not a skill)
    "bachelor", "bachelors", "master", "masters", "degree", "phd",
    "mba", "btech", "bsc", "msc", "diploma", "certificate",
    "computer", "science", "administration",
    # City/region names
    "bangalore", "bengaluru", "pune", "hyderabad", "nellore",
    "mumbai", "delhi", "chennai", "noida", "gurgaon", "gurugram",
    "india", "remote", "worldwide", "us", "uk", "usa",
    # Generic role-context words
    "purpose", "context", "challenges", "summary", "overview",
    "title", "field", "related", "relevant", "responsible",
    "internal", "external", "across", "between", "around",
    "across", "real", "complex", "diverse",
    # Outcome words (not skills)
    "ownership", "mindset", "drive", "passion", "thinking",
    "thinker", "thinkers", "approach", "approaches",
    # JD table-cell boilerplate (Aditya Birla and similar tabular JDs)
    "accountabilities", "accountability",
    "max", "characters", "character",
    "supporting", "supports",
    "kra", "kras",
    "show", "shows", "showing",
    "actions", "action",  # only as a standalone capitalized table column header
    "result", "results", "areas", "area",
    "key", "keys",
    "moving", "handing",
    # JD section / boilerplate words that get extracted as proper nouns
    "job", "jobs", "title", "purpose", "scope", "cost", "time",
    "assistance", "acceptance",  # leak from "Assistance is provided" / "...arrive at"
    "intelligent",  # from "Intelligent Operations Platform" β€” marketing adjective
    "iterative", "iteration", "iterations",
    "voice", "core", "central", "main", "primary", "secondary",
    "agreed", "appropriate", "applicable",
    # Standalone words from compound JD terms (e.g. "Product Road Mapping" β†’ "Road",
    # "Machine Learning Algorithms" β†’ "Machine" alone). These aren't skills on their own.
    "road", "mapping", "industry", "industries", "field", "fields",
    "talent", "talented", "talents", "candidate", "talent-driven",
    "world", "global", "international", "national", "domestic",
    # NOTE: keeping skill keywords intentionally: ai, ml, saas, api, siem, soar,
    # xdr, elicitation, fsd, uat, mlops, prd β€” all are legit JD-specific skills
    # the LLM should weave into the resume.
}


def _is_real_jd_keyword(kw: str) -> bool:
    """Return False for company names, marketing prose, and noise words."""
    k = kw.strip().lower()
    if not k or len(k) < 2:
        return False
    if k in _JD_NOISE_WORDS:
        return False
    # Single ALL-CAPS-extracted noun that's just a word like "the" / "you"
    # has already been filtered by extract_jd_keywords' stoplist. But other
    # short verbs like "join", "build", "help" can slip through if used in
    # a sentence β€” drop if too generic.
    if k in {
        # Modal / generic
        "will", "must", "able", "good", "strong", "great", "make",
        "need", "join", "look", "looking", "help", "build", "work",
        # Generic JD action verbs that get extracted as proper nouns when
        # they start a bullet. None of these are skills.
        "own", "translate", "gather", "produce", "partner", "prioritize",
        "conduct", "collaborate", "improve", "track", "manage", "drive",
        "develop", "support", "ensure", "deliver", "execute", "engage",
        "analyze", "analytical", "review", "lead", "create", "design",
        "implement", "launch", "ship", "validate", "evaluate", "identify",
        "monitor", "report", "communicate", "negotiate", "demonstrate",
        "understand", "convert", "scale", "grow", "test", "research",
        "interview", "advise", "coach", "mentor", "facilitate", "assist",
        # Generic bullet-starter words from JDs
        "own", "owns", "owning", "tracks", "tracking", "tracked",
        "responsible", "expected", "successful", "preferred", "required",
        "experience", "background", "exposure", "knowledge", "ability",
        "level", "senior", "junior", "principal", "associate", "head",
        # Numeric / quantifier
        "many", "several", "various", "multiple", "few",
    }:
        return False
    # Single-word verbs ending in -ing / -ed are usually not skills
    if re.fullmatch(r"[a-z]{4,}(?:ing|ed)", k) and " " not in k:
        # Allow specific skills that end this way
        if k not in {"testing", "coaching", "mentoring", "engineering",
                     "training", "scaling", "marketing", "messaging",
                     "branding", "billing", "onboarding", "fundraising",
                     "consulting", "shipping", "tracking"}:
            return False
    return True


# ── Anti-spam: strip keyword-stuffing sections before scoring ────────────────

def _strip_keyword_spam(resume_text: str) -> str:
    """
    Remove keyword-stuffing sections (e.g. "ADDITIONAL SKILLS & KEYWORDS" with
    raw comma/bullet-separated dumps) so they can't inflate the ATS score.

    Also collapses bullet-only lines containing 15+ words separated by bullets,
    which are a classic keyword-spam pattern regardless of header.
    """
    if not resume_text:
        return resume_text

    # 1) Drop any section literally titled "ADDITIONAL SKILLS & KEYWORDS"
    text = re.sub(
        r"ADDITIONAL\s+SKILLS\s*&\s*KEYWORDS.*?(?=\n[A-Z][A-Z\s&]{2,}\n|\Z)",
        "",
        resume_text,
        flags=re.IGNORECASE | re.DOTALL,
    )

    # 2) Drop lines that look like keyword dumps:
    #    15+ short tokens separated by bullets / pipes / commas, no real sentence
    clean_lines = []
    for line in text.split("\n"):
        stripped = line.strip()
        # Count separators
        sep_count = stripped.count("β€’") + stripped.count("|") + stripped.count(",")
        if sep_count >= 15 and len(stripped.split()) <= sep_count * 2 + 5:
            # Looks like a keyword dump β€” drop it
            continue
        clean_lines.append(line)

    return "\n".join(clean_lines)


# ── LLM keyword extraction (Resume-Matcher approach) ─────────────────────────

_LLM_KW_CACHE: dict = {}

def extract_jd_keywords_llm(jd_text: str, fast_model_cfg: dict = None) -> List[str]:
    """
    Use a fast LLM to extract exactly what the employer wants.
    Resume-Matcher approach β€” catches synonyms + context that regex misses.
    Falls back to regex if LLM unavailable.

    fast_model_cfg: dict with model/api_key/base_url/extra_body keys.
    """
    if not jd_text or len(jd_text.strip()) < 50:
        return []

    cache_key = hash(jd_text[:500])
    if cache_key in _LLM_KW_CACHE:
        return _LLM_KW_CACHE[cache_key]

    if not fast_model_cfg:
        return extract_jd_keywords(jd_text)

    prompt = (
        "Extract keywords from this job description for ATS resume matching.\n"
        "Return ONLY valid JSON (no markdown):\n"
        '{"required_skills":["s1","s2"],"preferred_skills":["t1"],"keywords":["k1","k2"]}\n\n'
        f"Job Description:\n{jd_text[:1500]}"
    )

    try:
        import json as _json, re as _re
        from openai import OpenAI

        client = OpenAI(
            base_url=fast_model_cfg["base_url"],
            api_key=fast_model_cfg["api_key"],
            timeout=25,
        )
        extra = fast_model_cfg.get("extra_body", {})
        kwargs = dict(
            model=fast_model_cfg["model"],
            messages=[
                {"role": "system", "content": "Return ONLY valid JSON. No markdown."},
                {"role": "user",   "content": prompt},
            ],
            temperature=0.1,
            max_tokens=400,
            stream=False,
        )
        if extra:
            kwargs["extra_body"] = extra

        text = client.chat.completions.create(**kwargs).choices[0].message.content or ""
        text = _re.sub(r'^```(?:json)?\s*', '', text.strip())
        text = _re.sub(r'\s*```$', '', text)
        data = _json.loads(text)

        keywords = []
        for field in ("required_skills", "preferred_skills", "keywords"):
            for kw in data.get(field, []):
                if kw and isinstance(kw, str):
                    keywords.append(kw.lower().strip())

        seen = set()
        # Drop noise words / company names; the LLM occasionally picks up
        # client names from "about us" prose.
        unique = [
            k for k in keywords
            if k not in seen and _is_real_jd_keyword(k) and not seen.add(k)
        ]
        _LLM_KW_CACHE[cache_key] = unique[:45]
        return unique[:45]

    except Exception:
        result = extract_jd_keywords(jd_text)
        _LLM_KW_CACHE[cache_key] = result
        return result


# ── Regex keyword extraction (fast fallback) ─────────────────────────────────

# Locations β€” never skills. Used to exclude city/country tokens from extraction.
_LOCATIONS = {
    "india", "usa", "us", "uk", "uae", "canada", "australia", "germany",
    "france", "ireland", "singapore", "dublin", "london", "bengaluru",
    "bangalore", "hyderabad", "mumbai", "delhi", "pune", "chennai", "noida",
    "gurgaon", "gurugram", "kolkata", "ahmedabad", "remote", "onsite",
    "hybrid", "worldwide", "global", "sunnyvale", "carlsbad", "california",
    "ca", "ny", "york", "francisco", "seattle", "austin", "boston", "chicago",
    "telangana", "karnataka", "maharashtra", "haryana", "tamil", "nadu",
}

# Comprehensive English/JD stopword set β€” words real ATS checkers do NOT
# count as keywords. Anything NOT here (and not a proper-noun) is fair game.
_CONTENT_STOPWORDS = {
    # articles/conjunctions/prepositions/pronouns
    "the", "a", "an", "and", "or", "but", "nor", "for", "yet", "so", "of",
    "to", "in", "on", "at", "by", "with", "from", "as", "into", "onto",
    "upon", "about", "above", "below", "over", "under", "between", "through",
    "during", "before", "after", "this", "that", "these", "those", "it",
    "its", "they", "them", "their", "you", "your", "yours", "we", "our",
    "ours", "us", "i", "me", "my", "he", "she", "his", "her", "who", "whom",
    "which", "what", "whose", "where", "when", "why", "how", "all", "any",
    "both", "each", "few", "more", "most", "other", "some", "such", "no",
    "not", "only", "own", "same", "than", "too", "very", "can", "will",
    "just", "should", "now", "is", "are", "was", "were", "be", "been",
    "being", "have", "has", "had", "do", "does", "did", "doing", "would",
    "could", "shall", "may", "might", "must", "ought",
    # JD boilerplate / filler
    "job", "role", "roles", "team", "teams", "work", "working", "company",
    "looking", "join", "help", "make", "need", "able", "good", "great",
    "strong", "able", "well", "across", "within", "while", "also", "etc",
    "including", "include", "includes", "ability", "experience", "years",
    "year", "month", "months", "responsibilities", "requirements", "must",
    "haves", "have", "preferred", "qualifications", "candidate", "candidates",
    "opportunity", "about", "us", "you", "your", "we", "are", "seeking",
    "responsible", "expected", "ideal", "plus", "bonus", "nice", "based",
    "level", "senior", "junior", "lead", "minimum", "least", "demonstrated",
    "proven", "track", "record", "deep", "solid", "excellent", "exceptional",
    "highly", "ability", "skills", "skill", "knowledge", "understanding",
    "passion", "passionate", "drive", "driven", "self", "fast", "paced",
    "environment", "culture", "mission", "values", "value", "world", "global",
    "millions", "million", "billion", "thousands", "today", "future", "every",
    "real", "true", "best", "leading", "leader", "leaders", "top", "high",
    "new", "key", "core", "major", "main", "multiple", "various", "several",
    "many", "first", "one", "two", "three", "day", "days", "week", "weeks",
    "time", "times", "way", "ways", "thing", "things", "people", "person",
    "someone", "anyone", "everyone", "something", "anything", "everything",
    "here", "there", "then", "once", "out", "up", "down", "off", "again",
    "further", "because", "until", "against", "per", "via", "like", "want",
    "wants", "wanted", "get", "got", "set", "go", "going", "come", "coming",
    "know", "knowing", "see", "seeing", "use", "using", "used", "made",
    "take", "taking", "give", "giving", "keep", "keeping", "let", "even",
    "ensure", "ensuring", "provide", "providing", "support", "supporting",
}

# Generic English words that look like content but aren't useful resume
# keywords β€” drop these too even though they're not classic stopwords.
_CONTENT_DROP = {
    "everything", "anyone", "someone", "everyone", "yourself", "themselves",
    "ourselves", "myself", "himself", "herself", "itself", "whatever",
    "whenever", "wherever", "however", "therefore", "moreover", "furthermore",
    "additionally", "essentially", "basically", "literally", "actually",
    "clearly", "simply", "really", "truly", "fully", "quite", "rather",
    "around", "along", "across", "behind", "beyond", "toward", "towards",
}


# Narrative/verb/prose words that slip through (GENERAL English JD prose β€”
# never tuned to a specific JD; applies to every job description).
_NARRATIVE_NOISE = {
    "night", "calls", "call", "sat", "wrote", "queried", "watched",
    "shipped", "owned", "personally", "familiarity", "yourself",
    "anyone", "everyone", "bar", "line", "code", "clause", "policy",
    "spec", "name", "named", "phase", "stage", "step", "point", "thing",
    "stuff", "lot", "bit", "kind", "sort", "type", "part", "side", "end",
    "place", "area", "areas", "case", "cases", "fact", "idea", "ideas",
    "reason", "result", "results", "example", "examples", "number", "numbers",
    "amount", "rate", "rates", "list", "lists", "group", "groups",
}


def _is_proper_noun_noise(tok: str, lower_seen: set) -> bool:
    """Proper-noun noise (company/person/product name): never appears lowercase
    in the JD AND isn't a known professional term."""
    return (tok not in lower_seen) and (not _is_professional_term(tok))


def _extract_content_terms(jd_text: str, max_terms: int = 0) -> List[str]:
    """
    Comprehensive, UNCAPPED content extraction β€” capture EVERY meaningful
    term/phrase IN THE JD ITSELF, driven by the JD (not our curated vocab).
    Our vocab only ASSISTS filtering; it never limits what's extracted.

    Returns unigrams AND bigrams:
    - unigrams: content words (nouns/skills) β€” drops stopwords, locations,
      company/person names (capitalized-only unknowns), narrative verbs.
    - bigrams: consecutive content-word pairs (competitive analysis, customer
      needs, user research, data analysis…) not already captured.

    max_terms=0 β†’ NO CAP. If the JD has N meaningful terms, return all N.
    This is the explicit design: keywords ALWAYS derive from the JD; a JD with
    100 keywords yields 100, a JD with 20 new ones yields those 20.
    """
    lower_seen = set(re.findall(r"\b[a-z][a-z]{2,}\b", jd_text))
    text_low = jd_text.lower()

    def _good(tok: str) -> bool:
        tok = tok.strip(".-/")
        if len(tok) < 3:
            return False
        if tok in _CONTENT_STOPWORDS or tok in _CONTENT_DROP or tok in _NARRATIVE_NOISE:
            return False
        if tok in _LOCATIONS or tok in _JD_NOISE_WORDS:
            return False
        known = _is_professional_term(tok)
        if not known and (tok.endswith("ed") or tok.endswith("ly")):
            return False
        if _is_proper_noun_noise(tok, lower_seen):
            return False
        return True

    # ── Unigrams ──
    freq: dict = {}
    for tok in re.findall(r"\b[a-z][a-z+/.\-]{2,}\b", text_low):
        tok = tok.strip(".-/")
        if tok and _good(tok):
            freq[tok] = freq.get(tok, 0) + 1

    # ── Bigrams ── consecutive content words (captures JD multi-word skills)
    bigram_freq: dict = {}
    seq = re.findall(r"\b[a-z][a-z+/.\-]{1,}\b", text_low)
    for i in range(len(seq) - 1):
        w1 = seq[i].strip(".-/")
        w2 = seq[i + 1].strip(".-/")
        if (len(w1) >= 3 and len(w2) >= 3
                and w1 not in _CONTENT_STOPWORDS and w2 not in _CONTENT_STOPWORDS
                and w1 not in _LOCATIONS and w2 not in _LOCATIONS
                and w1 not in _NARRATIVE_NOISE and w2 not in _NARRATIVE_NOISE
                and not _is_proper_noun_noise(w1, lower_seen)
                and not _is_proper_noun_noise(w2, lower_seen)):
            bg = f"{w1} {w2}"
            bigram_freq[bg] = bigram_freq.get(bg, 0) + 1

    # SKILLS ONLY β€” match how real ATS checkers (Jobalytics/Resume Worded)
    # actually work: they compare against a curated gazetteer of hard skills,
    # tools, methods, domains, and real soft skills β€” NOT "any noun in the JD".
    # We therefore keep a discovered unigram ONLY if it is a recognised
    # professional term (in our skill vocab/taxonomy). This is what stops prose
    # nouns like "Goals", "Enterprise", "Authority", "Productivity",
    # "Generation", "Organisation" from ever being treated as keywords. Coverage
    # of genuinely common PM/business terms comes from expanding the vocab, not
    # from blindly grabbing every noun (which produced garbage and lowered the
    # real-checker score).
    meaningful = {t: c for t, c in freq.items() if _is_professional_term(t)}
    # Rank unigrams: known skills first, then frequency
    uni = sorted(meaningful.items(),
                 key=lambda x: (_is_professional_term(x[0]), x[1]), reverse=True)
    # Keep a bigram only if it's a GENUINE skill phrase, not a prose-adjacency
    # artifact. Require BOTH tokens to be real skill/professional terms AND the
    # pair to either recur (β‰₯2Γ—) or be a known curated phrase. This admits
    # "product roadmap"/"data analysis"/"cross-functional teams" while rejecting
    # junk like "shape products"/"gather platform"/"directly impact" that would
    # otherwise flood the keyword set and crater the JD-match ratio.
    _known_phrases = {p.lower() for p in PM_SKILL_PHRASES}
    big = [bg for bg, c in bigram_freq.items()
           if all(_is_professional_term(w) for w in bg.split())
           and (c >= 2 or bg in _known_phrases)]

    result = [t for (t, _c) in uni] + big
    if max_terms and max_terms > 0:
        return result[:max_terms]
    return result


def extract_jd_keywords(jd_text: str) -> List[str]:
    """
    Extract keywords FROM any job description without per-JD blocklist tuning.

    Strategy (in order of confidence):
      1. PM base keywords found in the JD (high signal β€” known PM terms)
      2. PM tools found in the JD (high signal β€” known tool names)
      3. Common PM requirement phrases found in the JD (high signal)
      4. Multi-occurrence capitalized terms (β‰₯2 times) β€” distinguishes
         legitimate skills from one-off proper nouns like company names
         or table-header words

    Step 4 replaces the old "every capitalized word becomes a keyword"
    extraction that was the source of cross-JD noise. Words like
    "Accountabilities", "Bachelor", "Sumo" appear ONCE in their JD;
    real skills like "Jira", "Mixpanel", "PRDs", "MLOps" appear multiple
    times because the JD repeats them in requirements + responsibilities.

    This makes the extractor work on ANY new JD without needing per-JD
    noise additions.
    """
    if not jd_text:
        return []

    text = jd_text.lower()
    keywords: list[str] = []

    # ── CALIBRATED extraction (Phase 5) ──
    # Matches what real ATS checkers (Jobalytics/Simplify) count: PM skills
    # PLUS generic professional vocabulary (development/application/software/
    # solutions/market…). Proper-noun noise (company names, locations,
    # tickers) is still excluded because it's in NEITHER the taxonomy NOR the
    # generic professional vocab.

    # 1. Multi-word skill phrases first (longest-first to avoid double-count)
    consumed_spans: list[tuple] = []
    for phrase in PM_SKILL_PHRASES:
        for m in re.finditer(r"(?<!\w)" + re.escape(phrase) + r"(?!\w)", text):
            span = (m.start(), m.end())
            if any(span[0] < e and s < span[1] for (s, e) in consumed_spans):
                continue
            consumed_spans.append(span)
            keywords.append(phrase)
            break

    # 2. Single-word taxonomy tokens (high-signal PM skills)
    for token in PM_SKILL_TAXONOMY:
        if " " in token or "/" in token or "β†’" in token or "-" in token:
            continue
        if re.search(r"(?<!\w)" + re.escape(token) + r"(?!\w)", text):
            keywords.append(token)

    # 3. Generic professional vocabulary present in the JD β€” this is the
    # Phase 5 broadening that makes our score track real checkers. These are
    # the words Jobalytics/Simplify count that our taxonomy alone missed.
    for token in GENERIC_PROFESSIONAL_VOCAB:
        if re.search(r"(?<!\w)" + re.escape(token) + r"(?!\w)", text):
            keywords.append(token)

    # 4. Regex-pattern skills (a/b testing variants, "0 to 1", etc.)
    for pat in _TAXONOMY_PATTERNS:
        for m in pat.finditer(text):
            kw = re.sub(r"\s+", " ", m.group().strip().lower())
            keywords.append(kw)

    # 5. COMPREHENSIVE content terms β€” capture every meaningful JD word/skill
    # that our curated lists missed (this is what makes coverage match what
    # Jobalytics extracts). Proper-noun noise (company/location/person names)
    # is filtered inside _extract_content_terms.
    keywords.extend(_extract_content_terms(jd_text, max_terms=0))

    # Deduplicate exact repeats
    seen = set()
    unique = []
    for kw in keywords:
        kw = kw.strip()
        if kw and kw not in seen:
            seen.add(kw)
            unique.append(kw)

    # Collapse redundancy so our count tracks real checkers (~32, not 40):
    #  - lemma-equal singular/plural (stakeholder/stakeholders,
    #    application/applications, solution/solutions)
    #  - single-word token subsumed by a multiword phrase already present
    #    (product βŠ‚ product strategy; backlog βŠ‚ backlog grooming;
    #     agile βŠ‚ agile/scrum; discovery βŠ‚ product discovery)
    unique = _collapse_redundant_keywords(unique)

    # NO CAP β€” keywords always derive from the JD. A JD with 100 meaningful
    # terms yields 100; a JD with 20 yields 20. We never truncate to a stored
    # ceiling (per design: coverage must match what real ATS checkers extract).
    return unique


def _collapse_redundant_keywords(keywords: List[str]) -> List[str]:
    """Collapse lemma-duplicate and phrase-subsumed keywords."""
    # 1. Lemma-dedup: group by lemma-of-each-word, keep longest surface form
    best_by_key: dict = {}
    order: list = []
    for kw in keywords:
        k = " ".join(_lemma(w) for w in re.split(r"[\s/]+", kw.lower()))
        if k not in best_by_key:
            best_by_key[k] = kw
            order.append(k)
        elif len(kw) > len(best_by_key[k]):
            best_by_key[k] = kw
    deduped = [best_by_key[k] for k in order]

    # 2. Drop a single-word kw if it's a token inside any multiword kw
    multiword_tokens = set()
    for kw in deduped:
        parts = re.split(r"[\s/]+", kw.lower())
        if len(parts) > 1:
            multiword_tokens.update(parts)
    final = []
    for kw in deduped:
        parts = re.split(r"[\s/]+", kw.lower())
        if len(parts) == 1 and parts[0] in multiword_tokens:
            continue  # subsumed by a phrase
        final.append(kw)
    return final


def _kw_in_text(keyword: str, text: str) -> bool:
    """
    Lemma + phrase aware matching.

    - Exact substring (cheapest, catches most matches) β€” return True
    - Single-word: lemma-equal to any text token (so "automation" matches
      a resume that says "automated"; "roadmap" matches "roadmaps")
    - Multi-word: all component lemmas within a 5-token window

    This is materially more forgiving than the prior word-boundary regex
    and recovers ~15-20pp of false-negative misses observed in production.
    """
    if not keyword:
        return False
    return _phrase_in_text(keyword, text)


# ── JD Match Score (PRIMARY β€” 70% weight) ────────────────────────────────────

def jd_match_score(resume_text: str, jd_text: str, extra_keywords: List[str] = None) -> Dict:
    """
    PRIMARY ATS metric: what % of JD keywords appear in the resume?

    This is the Resume-Matcher approach:
      1. Extract keywords from JD
      2. Check each in resume using word-boundary regex
      3. Score = matched / total * 100

    Args:
      extra_keywords: keywords already extracted by LLM (from job assessment),
                      merged with regex-extracted keywords for better coverage
    """
    jd_keywords = extract_jd_keywords(jd_text)

    # Merge with LLM-extracted keywords if provided
    if extra_keywords:
        for kw in extra_keywords:
            if kw and kw.lower() not in jd_keywords:
                jd_keywords.append(kw.lower())

    if not jd_keywords:
        return {"score": 0, "matched": [], "missing": [], "total": 0}

    text = resume_text.lower()
    matched = [kw for kw in jd_keywords if _kw_in_text(kw, text)]
    missing = [kw for kw in jd_keywords if not _kw_in_text(kw, text)]

    score = int(len(matched) / len(jd_keywords) * 100)

    return {
        "score":   score,
        "matched": matched[:15],
        "missing": missing[:12],
        "total":   len(jd_keywords),
        "matched_count": len(matched),
    }


# ── Resume Quality Score (SECONDARY β€” 30% weight) ────────────────────────────

def resume_quality_score(resume_text: str) -> Dict:
    """
    SECONDARY metric: Resume-ATS style quality score.
    Checks structure, formatting, action verbs, skills.
    Independent of JD β€” measures raw resume quality.
    """
    text = resume_text.lower()
    words = text.split()
    word_count = len(words)
    bullet_count = sum(1 for ch in resume_text if ch in "β€’β–ͺ") + resume_text.count(" - ")

    sections = _detect_sections(resume_text)

    # Keyword quality (PM domain verbs + keywords)
    verb_count = sum(1 for v in ACTION_VERBS if v in text)
    pm_kw_count = sum(1 for kw in PM_BASE_KEYWORDS if kw in text)
    kw_score = min(100, int((pm_kw_count / len(PM_BASE_KEYWORDS)) * 60 + min(1.0, verb_count / 8) * 40))

    # Sections
    # Section score β€” canonical Phase 4 format has NO Skills section by policy,
    # so we don't count it against the resume. Experience and Education each
    # get 30pts (was 20pts each, with Skills also at 20pts β€” total budget kept
    # the same at 60pts for required sections).
    sec_score = 0
    for sec in ["experience", "education"]:
        if len(sections.get(sec, "")) > 50:
            sec_score += 30
    if re.search(r'[\w.+-]+@[\w-]+\.\w{2,}', resume_text): sec_score += 10
    if re.search(r'\+?[\d\s\-()]{10,}', resume_text):       sec_score += 10
    for sec in ["summary", "projects", "achievements", "certifications"]:
        if len(sections.get(sec, "")) > 20:
            sec_score += 7
    sec_score = min(100, sec_score)

    # Formatting
    fmt_score = 100
    if word_count < 200:  fmt_score -= 20
    if word_count > 1500: fmt_score -= 10
    if bullet_count < 5:  fmt_score -= 15
    if bullet_count > 50: fmt_score -= 5
    fmt_score = max(0, fmt_score)

    # Skills
    total_skills = sum(1 for cat in PM_SKILLS.values() for s in cat if s in text)
    cat_bonus = sum(15 if any(s in text for s in PM_SKILLS["tools"]) else 0 for _ in [1])
    cat_bonus += sum(15 if any(s in text for s in PM_SKILLS["frameworks"]) else 0 for _ in [1])
    cat_bonus += sum(10 if any(s in text for s in PM_SKILLS["technical"]) else 0 for _ in [1])
    cat_bonus += sum(10 if any(s in text for s in PM_SKILLS["soft_skills"]) else 0 for _ in [1])
    skill_base = 40 if total_skills >= 15 else (30 if total_skills >= 10 else (20 if total_skills >= 5 else 10))
    skl_score = min(100, skill_base + cat_bonus)

    # Experience
    exp_text = sections.get("experience", "")
    positions = max(1, len(re.findall(
        r'(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\w*[\s,]*\d{4}',
        exp_text.lower()
    )) // 2)
    exp_base = 30 + (20 if positions >= 3 else 15 if positions >= 2 else 10)
    has_metrics = bool(re.search(r'\d+%|β‚Ή[\d,]+|\$[\d,]+|\d+x|\d+\s*(?:users|leads|crore|lakh|k\b)', exp_text))
    exp_verbs = sum(1 for v in ACTION_VERBS if v in exp_text.lower())
    exp_quality = min(100, int(min(1.0, exp_verbs / 8) * 70) + (30 if has_metrics else 0))
    exp_score = min(100, exp_base + int(exp_quality * 0.5))

    # Projects
    prj_text = sections.get("projects", "") or sections.get("achievements", "")
    prj_score = 50
    if prj_text:
        prj_score = 50
        if any(t in prj_text.lower() for t in PM_TOOLS): prj_score += 20
        if sum(1 for k in IMPACT_KEYWORDS if k in prj_text.lower()) >= 2: prj_score += 15
        if len(prj_text) > 100: prj_score += 15
    prj_score = min(100, prj_score)

    quality = int(kw_score*0.20 + sec_score*0.20 + fmt_score*0.15 + skl_score*0.20 + exp_score*0.15 + prj_score*0.10)

    return {
        "quality_score":     quality,
        "keyword_score":     kw_score,
        "section_score":     sec_score,
        "formatting_score":  fmt_score,
        "skill_score":       skl_score,
        "experience_score":  exp_score,
        "project_score":     prj_score,
        "word_count":        word_count,
        "bullet_count":      bullet_count,
    }


# ── Combined ATS Score ────────────────────────────────────────────────────────

def score_resume(resume_text: str, jd_text: str = "", extra_kw: List[str] = None,
                 fast_model_cfg: dict = None) -> Dict:
    """
    Full ATS score combining JD match (70%) + resume quality (30%).

    Anti-cheat: strips keyword-spam sections from the resume before scoring so
    raw keyword dumps can't inflate the score. Also applies penalties for
    structurally incomplete resumes (missing education, single role, low word
    count) so an aggressively trimmed resume can't outscore a complete one.

    fast_model_cfg: if provided, uses LLM to extract JD keywords (more accurate).
    extra_kw: additional keywords already extracted by the job assessment LLM.
    """
    # Strip keyword-spam sections so they can't inflate the score
    clean_resume = _strip_keyword_spam(resume_text)

    # Use LLM keyword extraction if a fast model is available
    if fast_model_cfg and jd_text:
        llm_kw = extract_jd_keywords_llm(jd_text, fast_model_cfg)
        regex_kw = extract_jd_keywords(jd_text)
        combined = llm_kw[:]
        for kw in (regex_kw + (extra_kw or [])):
            if kw.lower() not in {k.lower() for k in combined}:
                combined.append(kw)
        extra_kw = combined

    jd_result  = jd_match_score(clean_resume, jd_text, extra_kw)
    qlt_result = resume_quality_score(clean_resume)

    jd_score  = jd_result["score"]
    qlt_score = qlt_result["quality_score"]

    # Combined: JD match weighted 70%, resume quality 30%
    if jd_text:
        final = int(jd_score * 0.70 + qlt_score * 0.30)
    else:
        final = qlt_score  # No JD β†’ quality only

    # ── Structural-integrity penalties ───────────────────────────────────────
    # An ATS-friendly resume needs: a real experience section, education, and
    # enough content. Penalize anything that's structurally hollow so a keyword-
    # stuffed 1-page resume cannot outscore a complete, well-structured one.
    sections = _detect_sections(clean_resume)
    word_count = qlt_result["word_count"]
    penalties: List[str] = []

    # Hard cap only when the resume is essentially empty (<300 words).
    # The canonical Phase 4 format is intentionally tight β€” 2 pages, 5-7
    # bullets per role. Typical word count is 450-650. Anything β‰₯350 is fine.
    if word_count < 250:
        final = min(final, 55)
        penalties.append(f"Resume too short ({word_count} words; min 250)")
    elif word_count < 400:
        final = max(0, final - 3)
        penalties.append(f"Resume short ({word_count} words; recommended 400+)")

    if len(sections.get("education", "")) < 30:
        final = max(0, final - 8)
        penalties.append("Missing or empty Education section (-8 pts)")

    # NOTE: No penalty for missing Skills/Core Competencies section.
    # Per project policy R6, the tailored resume intentionally has no skills
    # section β€” keywords live in the summary and experience bullets instead.
    # Penalizing here would create the opposite incentive.

    # Count distinct role headers (date ranges) in experience β€” single-role
    # resumes for a 5+ year candidate are a red flag. Match both
    # "Jan 2023 - Present" and "Oct 2021 - Dec 2022" formats.
    exp_text = sections.get("experience", "")
    role_count = len(re.findall(
        r"\d{4}\s*[-–—to]+\s*(?:(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\w*\s+)?(?:\d{4}|Present|Current|Now|Date)",
        exp_text, re.IGNORECASE,
    ))
    if exp_text and role_count <= 1 and word_count < 700:
        final = max(0, final - 6)
        penalties.append("Experience shows only one role (-6 pts)")

    final = max(0, min(100, final))
    label = "Excellent" if final >= 80 else ("Good" if final >= 60 else ("Needs Improvement" if final >= 40 else "Poor"))

    # Identify gaps
    gaps = []
    if jd_score < 85 and jd_result["missing"]:
        gaps.append(f"Add JD keywords to resume: {', '.join(jd_result['missing'][:6])}")
    if qlt_result["section_score"] < 80:
        gaps.append("Add missing sections: Summary, Skills, Projects/Achievements")
    if qlt_result["experience_score"] < 80:
        gaps.append("Add action verbs + quantified metrics (%, numbers) to experience bullets")
    if qlt_result["skill_score"] < 70:
        gaps.append("List 15+ skills: tools (Jira/Figma/Amplitude), frameworks (Agile/Scrum), technical (SQL/API)")

    # Add structural penalties to the gap list so the LLM retry loop sees them
    for p in penalties:
        gaps.append(p)

    return {
        "ats_score":          final,
        "jd_match_score":     jd_score,
        "resume_quality":     qlt_score,
        "matched_kw":         jd_result["matched"][:10],
        "missing_kw":         jd_result["missing"][:10],
        "total_jd_kw":        jd_result["total"],
        "matched_count":      jd_result["matched_count"],
        "word_count":         qlt_result["word_count"],
        "label":              label,
        "gaps":               gaps,
        "penalties":          penalties,
        "quality_breakdown":  qlt_result,
    }


def conservative_display_score(raw: int) -> int:
    """
    Convert our RAW internal keyword-coverage score into a CONSERVATIVE,
    honest estimate that lands near real third-party checkers (Jobalytics et al).

    Why: our raw score measures coverage of OUR keyword set, which a tailored
    resume covers very well (~85-95%). Real checkers use their own (broader,
    proprietary) keyword lists and stricter matching, so they report ~15-25
    points lower. Calibrated against the user's data point (our raw 78 β†’
    Jobalytics 58) plus a safety margin, we discount by ~0.72 and lean low.

    The RAW score is still used internally by the tailoring loop (so it keeps
    aggressively maximizing real coverage); only the DISPLAYED number is
    discounted so we never overstate to the user.
    """
    if raw <= 0:
        return 0
    # Extraction now comprehensively matches real-checker breadth (Phase 5.x),
    # so raw coverage is a closer proxy. Mild discount keeps us honest/
    # conservative (real checkers still vary), without absurdly understating.
    est = int(round(raw * 0.85))
    # Never claim a perfect score β€” cap at 92.
    return max(0, min(est, 92))


def score_before_after(original_resume: str, tailored_text: str,
                       jd_text: str = "", extra_kw: List[str] = None) -> Tuple[int, int, int]:
    """Returns (score_before, score_after, improvement) as CONSERVATIVE display
    values calibrated to track real third-party checkers."""
    before_raw = score_resume(original_resume, jd_text, extra_kw)["ats_score"]
    after_raw  = score_resume(tailored_text,   jd_text, extra_kw)["ats_score"]
    before = conservative_display_score(before_raw)
    after  = conservative_display_score(after_raw)
    return before, after, after - before


# Backward-compat alias used by resume_customizer.py
def score_resume_against_jd(resume_text: str, jd_text: str = "") -> Dict:
    """Alias for score_resume β€” kept for backward compatibility."""
    result = score_resume(resume_text, jd_text)
    # Map to old dict shape that resume_customizer.py expects
    result["ats_score"] = result["ats_score"]  # already present
    return result


def get_gap_report(resume_text: str, jd_text: str = "", extra_kw: List[str] = None) -> str:
    """Human-readable gap report for the LLM to fix."""
    r = score_resume(resume_text, jd_text, extra_kw)
    lines = [
        f"Current ATS Score: {r['ats_score']}/100  (Target: 95+)",
        f"  JD Match Score:    {r['jd_match_score']}/100  (matched {r['matched_count']}/{r['total_jd_kw']} JD keywords) [weight 70%]",
        f"  Resume Quality:    {r['resume_quality']}/100  [weight 30%]",
        f"",
        f"JD keywords MISSING from resume (add these naturally):",
        f"  {', '.join(r['missing_kw'])}",
        f"",
        f"Gaps to fix:",
    ]
    for g in r["gaps"]:
        lines.append(f"  - {g}")
    return "\n".join(lines)


# ── Section detection helper ──────────────────────────────────────────────────

def _detect_sections(text: str) -> Dict[str, str]:
    sections: Dict[str, str] = {}
    lines = text.split("\n")
    current_section = None
    current_lines: List[str] = []

    for line in lines:
        stripped = line.strip().lower()
        found_section = None
        for sec_name, headers in SECTION_HEADERS.items():
            for header in headers:
                if stripped == header or stripped.startswith(header):
                    found_section = sec_name
                    break
            if found_section:
                break

        if found_section:
            if current_section and current_lines:
                sections[current_section] = "\n".join(current_lines).strip()
            current_section = found_section
            current_lines = []
        elif current_section:
            current_lines.append(line)

    if current_section and current_lines:
        sections[current_section] = "\n".join(current_lines).strip()

    return sections