@gafj/gafj 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +99 -0
- package/bin/cli.js +628 -0
- package/bridge/byo_key.js +50 -0
- package/bridge/credits.js +72 -0
- package/bridge/paste.js +44 -0
- package/bridge/providers/anthropic.js +16 -0
- package/bridge/providers/gemini.js +15 -0
- package/bridge/providers/index.js +39 -0
- package/bridge/providers/openai_compatible.js +17 -0
- package/core/extract.js +55 -0
- package/core/generators/excerpt.js +64 -0
- package/core/generators/packet.js +197 -0
- package/core/generators/serialize.js +56 -0
- package/core/generators/slice.js +61 -0
- package/core/house_rules.js +49 -0
- package/core/kb_import.js +257 -0
- package/core/migrations/002-profile.sql +3 -0
- package/core/migrations/003-discover.sql +49 -0
- package/core/parsers.js +79 -0
- package/core/practice.js +75 -0
- package/core/provenance.js +90 -0
- package/core/qr.js +185 -0
- package/core/rules/categories.md +5 -0
- package/core/rules/deep_answers.md +7 -0
- package/core/rules/discover.md +30 -0
- package/core/rules/extract.md +31 -0
- package/core/rules/followup.md +9 -0
- package/core/rules/index.js +32 -0
- package/core/rules/onepager.md +15 -0
- package/core/rules/practice.md +10 -0
- package/core/rules/prep.md +47 -0
- package/core/rules/resume.md +49 -0
- package/core/rules/rubric.md +120 -0
- package/core/schema.sql +342 -0
- package/core/schemas/categories.js +16 -0
- package/core/schemas/cover.js +10 -0
- package/core/schemas/deep_answers.js +16 -0
- package/core/schemas/discover.js +19 -0
- package/core/schemas/extract.js +32 -0
- package/core/schemas/followup.js +10 -0
- package/core/schemas/index.js +32 -0
- package/core/schemas/lang.js +111 -0
- package/core/schemas/onepager.js +26 -0
- package/core/schemas/practice.js +18 -0
- package/core/schemas/prep.js +15 -0
- package/core/schemas/resume.js +25 -0
- package/core/scoring.js +586 -0
- package/core/text.js +98 -0
- package/core/tracker_map.js +94 -0
- package/core/ulid.js +38 -0
- package/core/validators/highlight_body_match.js +49 -0
- package/core/validators/index.js +43 -0
- package/core/validators/metric_pairing.js +52 -0
- package/core/validators/no_confabulated_history.js +63 -0
- package/core/validators/no_dashes.js +31 -0
- package/core/validators/no_derived_tenure.js +60 -0
- package/core/validators/style_config.js +18 -0
- package/core/validators/verb_ladder.js +51 -0
- package/core/verbs.js +43 -0
- package/http/api.js +260 -0
- package/http/guard.js +106 -0
- package/http/headers.js +17 -0
- package/http/hosted.js +273 -0
- package/http/identity.js +70 -0
- package/http/server.js +136 -0
- package/http/sse.js +41 -0
- package/mcp/server.js +25 -0
- package/mcp/tools.js +237 -0
- package/package.json +59 -0
- package/store/backup.js +63 -0
- package/store/backup_dir.js +60 -0
- package/store/config.js +32 -0
- package/store/context.js +74 -0
- package/store/db.js +37 -0
- package/store/discover.js +120 -0
- package/store/events.js +28 -0
- package/store/export_xlsx.js +62 -0
- package/store/home.js +36 -0
- package/store/import_kb.js +88 -0
- package/store/import_tracker.js +195 -0
- package/store/ingest.js +140 -0
- package/store/kb.js +202 -0
- package/store/log.js +88 -0
- package/store/migrate.js +55 -0
- package/store/onboarding.js +267 -0
- package/store/packets.js +110 -0
- package/store/practice.js +52 -0
- package/store/proposals.js +119 -0
- package/store/reads.js +110 -0
- package/store/render.js +111 -0
- package/store/reply.js +19 -0
- package/store/review.js +64 -0
- package/store/scoring.js +134 -0
- package/store/settings.js +142 -0
- package/store/snapshot.js +88 -0
- package/store/transitions.js +391 -0
- package/store/tx.js +71 -0
- package/store/tz.js +24 -0
- package/ui/app.css +137 -0
- package/ui/app.js +75 -0
- package/ui/fonts/plexmono-400.woff2 +0 -0
- package/ui/fonts/plexmono-500.woff2 +0 -0
- package/ui/fonts/plexmono-600.woff2 +0 -0
- package/ui/fonts/spacegrotesk-var.woff2 +0 -0
- package/ui/hosted/signin.css +8 -0
- package/ui/hosted/signin.html +30 -0
- package/ui/hosted/signin.js +28 -0
- package/ui/icon-192.png +0 -0
- package/ui/icon-512.png +0 -0
- package/ui/index.html +20 -0
- package/ui/lib.js +92 -0
- package/ui/manifest.webmanifest +18 -0
- package/ui/screens/application.js +73 -0
- package/ui/screens/dashboard.js +37 -0
- package/ui/screens/document.js +64 -0
- package/ui/screens/interview.js +85 -0
- package/ui/screens/kb.js +142 -0
- package/ui/screens/packet.js +122 -0
- package/ui/screens/postings.js +92 -0
- package/ui/screens/settings.js +115 -0
- package/ui/vendor/preact.mjs +1 -0
package/core/scoring.js
ADDED
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Requirement Scoring Engine (Update Spec Part K).
|
|
5
|
+
*
|
|
6
|
+
* Turns the loop from "build -> send -> rejected -> diagnose" into
|
|
7
|
+
* "build -> score -> fix weak category -> send". The Quince rejection cost a
|
|
8
|
+
* real opportunity before anyone learned what was wrong with the document.
|
|
9
|
+
*
|
|
10
|
+
* DIVISION OF LABOUR. These helpers do the deterministic half and leave the
|
|
11
|
+
* judgement to the model that called the MCP tool. That split is deliberate:
|
|
12
|
+
*
|
|
13
|
+
* - K6 names self-grading as the core hazard: a generator scoring its own
|
|
14
|
+
* output against its own rubric will flatter itself. The mitigation that
|
|
15
|
+
* actually bites is structural, so the rubric is pinned to the posting's
|
|
16
|
+
* verbatim text and the mechanical checks below run on the document rather
|
|
17
|
+
* than on anyone's opinion of the candidate.
|
|
18
|
+
* - exports.mcp is public and token authed, unlike generateResume which is
|
|
19
|
+
* locked to an allow listed Google account. Calling Claude from inside it
|
|
20
|
+
* would turn a shared token into a spend proxy for arbitrary input.
|
|
21
|
+
*
|
|
22
|
+
* So: the server extracts evidence, runs the mechanical gates, and hands back a
|
|
23
|
+
* rubric. The caller reasons. Nothing here needs an API key.
|
|
24
|
+
*
|
|
25
|
+
* The pre-send gate near the bottom of this file sits on the deterministic side
|
|
26
|
+
* of that line, and only just: it does no judging of its own. The caller still
|
|
27
|
+
* supplies every weight and every score. Arithmetic on numbers the caller chose
|
|
28
|
+
* is not judgement, and it is the one part of Part K that a caller must not be
|
|
29
|
+
* able to decline.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
// G1 detection triggers, verbatim from the spec.
|
|
33
|
+
const AI_TRIGGERS = [
|
|
34
|
+
"artificial intelligence", "machine learning", "agent orchestration",
|
|
35
|
+
"prompt engineering", "context engineering", "responsible ai", "ai governance",
|
|
36
|
+
"agentic", "automation", "emerging technolog", "llm", "agent", "ai", "ml",
|
|
37
|
+
];
|
|
38
|
+
// Matched whole-word so "ai" does not fire on "email" and "ml" not on "html".
|
|
39
|
+
const WORD_TRIGGERS = new Set(["ai", "ml", "llm", "agent", "agentic", "automation"]);
|
|
40
|
+
|
|
41
|
+
const HARD_GATE_CUES = [
|
|
42
|
+
"required", "must have", "must be", "minimum", "at least", "years of experience",
|
|
43
|
+
"years experience", "bachelor", "degree in", "certification",
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
// A4 retired phrasings that must never reach a document.
|
|
47
|
+
const RETIRED = [
|
|
48
|
+
{ pattern: /\$?~?\s*8M\s+annual\s+book/i, use: "Grew new business 70%+, from $5.68M (2024) to $9.7M annualized (2025), or the $1.44M monthly run rate" },
|
|
49
|
+
{ pattern: /havenly/i, use: "Onboarded 30+ furniture retail clients in 9 months" },
|
|
50
|
+
{ pattern: /15\+?\s*years\s+selling/i, use: "15+ years across operations, distribution, and fulfillment, including 3+ years in dedicated 3PL and logistics business development" },
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
// G2 protected bullets for operations, process improvement, and supply chain
|
|
54
|
+
// roles. Trimming one of these is a regression, not a length decision.
|
|
55
|
+
const PROTECTED = [
|
|
56
|
+
{ id: "acc-062", label: "Damage 12% to 4%, roughly $120K per year, root cause" },
|
|
57
|
+
{ id: "acc-046", label: "$430K to $75K aged returns liability" },
|
|
58
|
+
{ id: "acc-054", label: "$100K+ carrier misbillings recovered in 60 days" },
|
|
59
|
+
{ id: "acc-038", label: "Freight down 29.3%" },
|
|
60
|
+
{ id: "acc-051", label: "Shipment cost $178 to $138" },
|
|
61
|
+
{ id: "acc-052", label: "Freight 11.3% to 8.9% of revenue" },
|
|
62
|
+
{ id: "acc-073", label: "50+ person organization" },
|
|
63
|
+
{ id: "acc-011", label: "30+ client onboardings in 9 months" },
|
|
64
|
+
{ id: "acc-034", label: "Open tickets down 87%" },
|
|
65
|
+
{ id: "acc-037", label: "Zero untouched cases" },
|
|
66
|
+
{ id: "acc-064", label: "Internal theft discovery, $70K, conviction, controls" },
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
const STOP = new Set(("a an the and or of to in on for with by at from into as is are was were that which " +
|
|
70
|
+
"through while over under across all any not no more than its their our this these those it be been being " +
|
|
71
|
+
"have has had do does did will would can could should you your we they i he she them his her ability " +
|
|
72
|
+
"experience work working role team teams years year new other including include includes such etc via per " +
|
|
73
|
+
"using use used strong excellent ensure ensuring support supporting manage managing management").split(" "));
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Conservative suffix stemmer. Without it "organizations" misses
|
|
77
|
+
* "organization" and "mentoring" misses "mentorship", which is most of why a
|
|
78
|
+
* leadership requirement failed to find the one leadership accomplishment.
|
|
79
|
+
* Deliberately shallow: over stemming invents matches, which is worse here than
|
|
80
|
+
* missing one, because a false Strong sends a bullet to a posting it does not
|
|
81
|
+
* answer.
|
|
82
|
+
*/
|
|
83
|
+
function stem(w) {
|
|
84
|
+
if (w.length > 4 && w.endsWith("ies")) return w.slice(0, -3) + "y";
|
|
85
|
+
if (w.length > 6 && (w.endsWith("ship") || w.endsWith("ment"))) return w.slice(0, -4);
|
|
86
|
+
if (w.length > 5 && w.endsWith("ing")) return w.slice(0, -3);
|
|
87
|
+
if (w.length > 5 && w.endsWith("ed")) return w.slice(0, -2);
|
|
88
|
+
if (w.length > 4 && w.endsWith("ly")) return w.slice(0, -2);
|
|
89
|
+
if (w.length > 3 && w.endsWith("s") && !w.endsWith("ss")) return w.slice(0, -1);
|
|
90
|
+
return w;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function words(text) {
|
|
94
|
+
return (String(text).toLowerCase().match(/[a-z][a-z+&.]{2,}/g) || [])
|
|
95
|
+
.filter((w) => !STOP.has(w))
|
|
96
|
+
.map(stem)
|
|
97
|
+
.filter((w) => w.length > 2 && !STOP.has(w));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Concept bridges from a posting's vocabulary to the knowledge base's curated
|
|
102
|
+
* tags. Lexical overlap alone cannot connect "influencing partners,
|
|
103
|
+
* organizations, successful mentoring" to a bullet whose words are "de facto
|
|
104
|
+
* leadership", "directing", "BPO", and "temporary staff", and that specific
|
|
105
|
+
* failure is the one K2 exists to prevent: a leadership requirement answered
|
|
106
|
+
* with revenue bullets because they happened to share the word "growth".
|
|
107
|
+
* Tags are curated, so bridging to them is the honest fix.
|
|
108
|
+
*/
|
|
109
|
+
const CONCEPT_TAGS = [
|
|
110
|
+
{ cue: /\b(leader|leadership|mentor|coach|influenc|supervis|direct report|people manage|growing teams?|develop(ing)? (people|others|talent))/i,
|
|
111
|
+
tags: ["people leadership", "leading leaders", "direct reports", "leadership"] },
|
|
112
|
+
{ cue: /\b(root cause|corrective action|discrepanc|defect|quality review|damage)/i,
|
|
113
|
+
tags: ["process improvement", "analytics"] },
|
|
114
|
+
{ cue: /\b(ai\b|artificial intelligence|machine learning|agent|prompt|automation)/i,
|
|
115
|
+
tags: ["ai and automation", "technology"] },
|
|
116
|
+
{ cue: /\b(return|reverse logistic|rma|refund|claim)/i, tags: ["reverse logistics"] },
|
|
117
|
+
{ cue: /\b(freight|carrier|parcel|transportation|shipping cost)/i, tags: ["transportation"] },
|
|
118
|
+
{ cue: /\b(vendor|supplier|3pl|outsourc|partner performance)/i, tags: ["vendor management"] },
|
|
119
|
+
{ cue: /\b(forecast|inventory|stock|replenish)/i, tags: ["inventory", "forecasting", "supply chain"] },
|
|
120
|
+
{ cue: /\b(p&l|budget|cost control|margin|financial)/i, tags: ["financial impact"] },
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
function conceptTags(requirementText) {
|
|
124
|
+
const hit = new Set();
|
|
125
|
+
for (const c of CONCEPT_TAGS) {
|
|
126
|
+
if (c.cue.test(requirementText)) for (const t of c.tags) hit.add(t);
|
|
127
|
+
}
|
|
128
|
+
return hit;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function lines(text) {
|
|
132
|
+
return String(text).split(/\r?\n|(?<=[.;])\s{2,}/).map((l) => l.trim()).filter(Boolean);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Every figure in a document: money, percents, counts, durations. */
|
|
136
|
+
function metrics(text) {
|
|
137
|
+
const out = new Set();
|
|
138
|
+
const re = /\$\s?\d[\d,]*(?:\.\d+)?\s?[MK]?\+?|\d[\d,]*(?:\.\d+)?\s?%\+?|\b\d[\d,]{2,}\+?\b|\b\d+\+\s*(?:person|member|people|clients?|employees?|years?|days?|months?|weeks?)\b|\b\d+\s*(?:days?|months?|weeks?|years?)\b/gi;
|
|
139
|
+
let m;
|
|
140
|
+
while ((m = re.exec(text))) out.add(m[0].replace(/\s+/g, " ").trim());
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** $430,000 and $430K are the same claim; compare on digits alone. */
|
|
145
|
+
function metricKey(s) {
|
|
146
|
+
const digits = String(s).replace(/[^0-9.]/g, "");
|
|
147
|
+
const scale = /k\b/i.test(s) ? 1e3 : /m\b/i.test(s) ? 1e6 : 1;
|
|
148
|
+
const n = parseFloat(digits);
|
|
149
|
+
if (!isFinite(n)) return String(s).toLowerCase();
|
|
150
|
+
const unit = /%/.test(s) ? "%" : /\$/.test(s) ? "$" : "";
|
|
151
|
+
return unit + String(Math.round(n * scale));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* PASS 1 (K1) — deterministic reconnaissance on the posting.
|
|
156
|
+
* Produces the scaffolding a category extraction needs, never the categories
|
|
157
|
+
* themselves: weighting has to come from the employer's emphasis, and that is
|
|
158
|
+
* a judgement the caller makes against the quoted text.
|
|
159
|
+
*/
|
|
160
|
+
function scanJobDescription(jdText) {
|
|
161
|
+
const text = String(jdText || "");
|
|
162
|
+
const ls = lines(text);
|
|
163
|
+
const lower = text.toLowerCase();
|
|
164
|
+
|
|
165
|
+
const aiHits = [];
|
|
166
|
+
for (const t of AI_TRIGGERS) {
|
|
167
|
+
const re = WORD_TRIGGERS.has(t)
|
|
168
|
+
? new RegExp("(^|[^a-z])" + t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "([^a-z]|$)", "i")
|
|
169
|
+
: new RegExp(t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i");
|
|
170
|
+
const line = ls.find((l) => re.test(l));
|
|
171
|
+
if (line) aiHits.push({ term: t, line });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const hardGateLines = ls.filter((l) => HARD_GATE_CUES.some((c) => l.toLowerCase().includes(c)));
|
|
175
|
+
|
|
176
|
+
// Minimum vs preferred matters for weighting: a requirement in the minimum
|
|
177
|
+
// block outranks the same words in preferred.
|
|
178
|
+
const sections = {};
|
|
179
|
+
let current = "body";
|
|
180
|
+
for (const l of ls) {
|
|
181
|
+
const h = l.toLowerCase();
|
|
182
|
+
if (/^(minimum|basic|required)\b.*qualification|^requirements\b/.test(h)) current = "minimum";
|
|
183
|
+
else if (/^(preferred|nice to have|bonus)\b/.test(h)) current = "preferred";
|
|
184
|
+
else if (/^(responsibilities|what you.?ll do|the role)\b/.test(h)) current = "responsibilities";
|
|
185
|
+
(sections[current] = sections[current] || []).push(l);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const excluded = ["uline", "estes forwarding", "efw"].filter((e) => lower.includes(e));
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
aiTriggerFired: aiHits.length > 0,
|
|
192
|
+
aiTriggers: aiHits,
|
|
193
|
+
hardGateLines,
|
|
194
|
+
sections: Object.fromEntries(Object.entries(sections).map(([k, v]) => [k, v.length])),
|
|
195
|
+
sectionText: sections,
|
|
196
|
+
excludedEmployerDetected: excluded,
|
|
197
|
+
lineCount: ls.length,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* PASS 1b (K2) — evidence ranking, the highest value part of Part K. It has to
|
|
203
|
+
* make the JobDox miss structurally impossible: a Leadership category surfaces
|
|
204
|
+
* the 50+ person organization whether or not anyone remembered it existed.
|
|
205
|
+
*
|
|
206
|
+
* Plain term overlap does NOT do that, which the first cut proved. A leadership
|
|
207
|
+
* requirement mentions "growth", "partners", and "organizations", and those
|
|
208
|
+
* words are everywhere in a logistics record, so revenue bullets outranked the
|
|
209
|
+
* one accomplishment that exists to answer leadership. Three corrections:
|
|
210
|
+
*
|
|
211
|
+
* - IDF. A term that appears in half the knowledge base carries almost no
|
|
212
|
+
* signal; one that appears twice carries most of it. "mentoring" should
|
|
213
|
+
* outweigh "experience" by an order of magnitude, and it now does.
|
|
214
|
+
* - Bigrams. "root cause" and "agent orchestration" are single concepts, and
|
|
215
|
+
* scoring their halves separately is what let an LTL rate discrepancy win a
|
|
216
|
+
* root cause query on the word "discrepancy".
|
|
217
|
+
* - Tag weighting. Tags are curated rather than incidental, so a hit there is
|
|
218
|
+
* worth more than the same word buried in prose.
|
|
219
|
+
*/
|
|
220
|
+
let IDF_CACHE = null;
|
|
221
|
+
function idf(KB) {
|
|
222
|
+
if (IDF_CACHE && IDF_CACHE.kb === KB) return IDF_CACHE.map;
|
|
223
|
+
const df = new Map();
|
|
224
|
+
for (const a of KB) {
|
|
225
|
+
for (const w of new Set(terms(accomplishmentText(a)))) df.set(w, (df.get(w) || 0) + 1);
|
|
226
|
+
}
|
|
227
|
+
const N = KB.length;
|
|
228
|
+
const map = new Map();
|
|
229
|
+
for (const [w, n] of df) map.set(w, Math.log((N + 1) / (n + 0.5)));
|
|
230
|
+
IDF_CACHE = { kb: KB, map };
|
|
231
|
+
return map;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function accomplishmentText(a) {
|
|
235
|
+
return [a.title, a.summary, (a.wordings || []).map((w) => w.text).join(" "),
|
|
236
|
+
(a.tags || []).join(" "), (a.skills || []).join(" "), (a.industries || []).join(" ")].join(" ");
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Unigrams plus adjacent bigrams, so multi word concepts survive. */
|
|
240
|
+
function terms(text) {
|
|
241
|
+
const w = words(text);
|
|
242
|
+
const out = w.slice();
|
|
243
|
+
for (let i = 0; i < w.length - 1; i += 1) out.push(w[i] + " " + w[i + 1]);
|
|
244
|
+
return out;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function rankEvidence(requirementText, KB, limit) {
|
|
248
|
+
const want = new Map();
|
|
249
|
+
for (const t of terms(requirementText)) want.set(t, (want.get(t) || 0) + 1);
|
|
250
|
+
if (!want.size) return [];
|
|
251
|
+
const weights = idf(KB);
|
|
252
|
+
// An unseen term is rare by definition, so give it the ceiling rather than
|
|
253
|
+
// dropping it: a posting saying "dock scheduler" should not score as silence.
|
|
254
|
+
const MAX_IDF = Math.log((KB.length + 1) / 0.5);
|
|
255
|
+
let total = 0;
|
|
256
|
+
for (const t of want.keys()) total += weights.get(t) || MAX_IDF;
|
|
257
|
+
|
|
258
|
+
const concepts = conceptTags(requirementText);
|
|
259
|
+
const scored = KB.map((a) => {
|
|
260
|
+
const have = new Set(terms(accomplishmentText(a)));
|
|
261
|
+
const tagged = new Set(terms([(a.tags || []).join(" "), (a.skills || []).join(" ")].join(" ")));
|
|
262
|
+
let earned = 0;
|
|
263
|
+
let hits = 0;
|
|
264
|
+
for (const t of want.keys()) {
|
|
265
|
+
if (!have.has(t)) continue;
|
|
266
|
+
hits += 1;
|
|
267
|
+
earned += (weights.get(t) || MAX_IDF) * (tagged.has(t) ? 1.6 : 1);
|
|
268
|
+
}
|
|
269
|
+
// A curated tag the requirement's concept maps to is stronger evidence than
|
|
270
|
+
// any single shared word, so it is scored against the same denominator
|
|
271
|
+
// rather than as a tiebreak.
|
|
272
|
+
let conceptHits = 0;
|
|
273
|
+
if (concepts.size) {
|
|
274
|
+
const own = new Set((a.tags || []).map((t) => t.toLowerCase()));
|
|
275
|
+
for (const c of concepts) if (own.has(c)) conceptHits += 1;
|
|
276
|
+
earned += conceptHits * MAX_IDF * 0.9;
|
|
277
|
+
hits += conceptHits;
|
|
278
|
+
}
|
|
279
|
+
const coverage = total ? earned / total : 0;
|
|
280
|
+
return { id: a.id, title: a.title, company: a.company, hits, conceptHits, coverage,
|
|
281
|
+
score: coverage * 100 + (a.strengthScore || 0) / 1000 };
|
|
282
|
+
}).filter((r) => r.hits > 0).sort((x, y) => y.score - x.score);
|
|
283
|
+
|
|
284
|
+
return scored.slice(0, limit || 5).map((r) => ({
|
|
285
|
+
accomplishmentId: r.id,
|
|
286
|
+
title: r.title,
|
|
287
|
+
company: r.company,
|
|
288
|
+
termsMatched: r.hits,
|
|
289
|
+
weightedCoverage: Number(r.coverage.toFixed(3)),
|
|
290
|
+
// Coarse on purpose. The caller assigns final strength after reading the
|
|
291
|
+
// text; this is a shortlist, not a verdict.
|
|
292
|
+
evidence_strength: r.coverage >= 0.20 ? "Strong" : r.coverage >= 0.08 ? "Moderate" : "Weak",
|
|
293
|
+
}));
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* PASS 2 mechanical gates (F2, F3, A4, B7, C3).
|
|
298
|
+
* F2 says the substantiation check "is a mechanical check. Automate it if
|
|
299
|
+
* possible." This is that automation, plus the style and traceability rules
|
|
300
|
+
* that never needed a model in the first place.
|
|
301
|
+
*/
|
|
302
|
+
function auditResume(resumeText, KB) {
|
|
303
|
+
const raw = String(resumeText || "");
|
|
304
|
+
// The phone number is digits that are not a metric. Strip it before any
|
|
305
|
+
// figure extraction or 847, 302, and 2310 get audited as career claims.
|
|
306
|
+
const text = raw.replace(/\b\d{3}[-.\s]?\d{3}[-.\s]?\d{4}\b/g, " ");
|
|
307
|
+
const findings = [];
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* A highlight metric is substantiated when the body carries it, OR when the
|
|
311
|
+
* body carries a different metric from the same accomplishment. "Recovered
|
|
312
|
+
* inside 60 days" in a highlight and "within the first two months in role" in
|
|
313
|
+
* the body are one claim in two units, and F2 exists to catch missing
|
|
314
|
+
* evidence, not to police unit choice.
|
|
315
|
+
*/
|
|
316
|
+
const sameEvidenceInBody = (metricKeyValue, bodyKeys) =>
|
|
317
|
+
KB.some((a) => {
|
|
318
|
+
const own = new Set([...metrics([a.summary, (a.metrics || []).join(" "),
|
|
319
|
+
(a.wordings || []).map((w) => w.text).join(" ")].join(" "))].map(metricKey));
|
|
320
|
+
if (!own.has(metricKeyValue)) return false;
|
|
321
|
+
for (const k of own) if (k !== metricKeyValue && bodyKeys.has(k)) return true;
|
|
322
|
+
return false;
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
// F2: every metric claimed in Career Highlights must appear in a body bullet.
|
|
326
|
+
const hi = text.search(/career\s+highlights/i);
|
|
327
|
+
const body = text.search(/\b(experience|logistics\s*&?\s*operations\s+experience|process\s+improvement)\b/i);
|
|
328
|
+
if (hi !== -1 && body > hi) {
|
|
329
|
+
const highlightMetrics = metrics(text.slice(hi, body));
|
|
330
|
+
const bodyKeys = new Set([...metrics(text.slice(body))].map(metricKey));
|
|
331
|
+
const unsubstantiated = [...highlightMetrics].filter((m) => {
|
|
332
|
+
const k = metricKey(m);
|
|
333
|
+
return !bodyKeys.has(k) && !sameEvidenceInBody(k, bodyKeys);
|
|
334
|
+
});
|
|
335
|
+
if (unsubstantiated.length) {
|
|
336
|
+
findings.push({
|
|
337
|
+
rule: "F2", severity: "blocker",
|
|
338
|
+
detail: "Career Highlights claim metrics with no supporting body bullet: " + unsubstantiated.join(", "),
|
|
339
|
+
action: "Restore the body bullet, or drop the claim from the highlight. Never cut the evidence to keep the summary.",
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
} else if (hi === -1) {
|
|
343
|
+
findings.push({ rule: "B1", severity: "blocker", detail: "No Career Highlights block found.", action: "Add five highlights immediately after the header." });
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// A4: retired phrasings.
|
|
347
|
+
for (const r of RETIRED) {
|
|
348
|
+
if (r.pattern.test(text)) {
|
|
349
|
+
findings.push({ rule: "A4", severity: "blocker", detail: "Retired phrasing present: " + (text.match(r.pattern) || [])[0], action: "Use instead: " + r.use });
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// B7 house style.
|
|
354
|
+
const dashes = (text.match(/[‐-―]/g) || []).length;
|
|
355
|
+
const hyphens = (text.replace(/\b\d{3}-\d{3}-\d{4}\b/g, "").match(/-/g) || []).length;
|
|
356
|
+
if (dashes + hyphens > 0) {
|
|
357
|
+
findings.push({ rule: "B7", severity: "blocker", detail: `${dashes} en/em dashes and ${hyphens} hyphens outside the phone number.`, action: "Remove every dash. Compound modifiers are written as separate words." });
|
|
358
|
+
}
|
|
359
|
+
if (/e-commerce/i.test(text)) findings.push({ rule: "B7", severity: "blocker", detail: 'Spelled "e-commerce".', action: 'Write "ecommerce".' });
|
|
360
|
+
if (/\bP and L\b/i.test(text)) findings.push({ rule: "B7", severity: "blocker", detail: 'Spelled "P and L".', action: 'Write "P&L".' });
|
|
361
|
+
if (/\bpercent\b/i.test(text)) findings.push({ rule: "B7", severity: "warning", detail: 'Uses the word "percent".', action: "Use the % symbol." });
|
|
362
|
+
|
|
363
|
+
// C3: every figure in the document must be traceable to the knowledge base.
|
|
364
|
+
const kbKeys = new Set();
|
|
365
|
+
for (const a of KB) {
|
|
366
|
+
for (const m of metrics([a.summary, (a.metrics || []).join(" "), (a.wordings || []).map((w) => w.text).join(" ")].join(" "))) kbKeys.add(metricKey(m));
|
|
367
|
+
}
|
|
368
|
+
const YEARS = /^(19|20)\d\d$/;
|
|
369
|
+
const untraceable = [...metrics(text)].filter((m) => !kbKeys.has(metricKey(m)) && !YEARS.test(m.replace(/[^0-9]/g, "")));
|
|
370
|
+
if (untraceable.length) {
|
|
371
|
+
findings.push({
|
|
372
|
+
rule: "C3", severity: "review",
|
|
373
|
+
detail: "Figures with no exact knowledge base match: " + untraceable.slice(0, 12).join(", "),
|
|
374
|
+
action: "Confirm each is a compact rendering of a KB value ($430K for $430,000) and not a calculation, rounding, or combination. Anything else must come out.",
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// F3 bullet floors, counted per employer entry.
|
|
379
|
+
const entries = [...text.matchAll(/^(.+?)\s*\|\s*(.+?)\s+((?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec|\d{4}).*)$/gim)];
|
|
380
|
+
if (entries.length) {
|
|
381
|
+
const counts = entries.map((m, i) => {
|
|
382
|
+
const start = m.index + m[0].length;
|
|
383
|
+
const end = i + 1 < entries.length ? entries[i + 1].index : text.length;
|
|
384
|
+
const seg = text.slice(start, end);
|
|
385
|
+
return { entry: (m[1] + " | " + m[2]).trim(), bullets: lines(seg).filter((l) => l.length > 40).length };
|
|
386
|
+
});
|
|
387
|
+
const thin = counts.slice(0, 4).filter((c) => c.bullets < 2);
|
|
388
|
+
if (thin.length) {
|
|
389
|
+
findings.push({ rule: "F3", severity: "warning", detail: "Entries among the four most recent with fewer than two bullets: " + thin.map((t) => t.entry).join("; "), action: "Minimum 2, target 3. A one bullet entry reads as a thin role." });
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// H1: Civil Air Patrol survives section moves.
|
|
394
|
+
if (!/civil air patrol/i.test(text)) {
|
|
395
|
+
findings.push({ rule: "H1", severity: "warning", detail: "Civil Air Patrol is absent.", action: "Mandatory whenever the posting states a leadership requirement. It is one of only two places in the record showing leadership at 50+ scale." });
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return findings;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* PASS 2 pre-send gate (Part K).
|
|
403
|
+
*
|
|
404
|
+
* "If any category with weight >= 8 scores below 7, regenerate before sending.
|
|
405
|
+
* Do not treat the score as advisory." That rule lived only as prose inside the
|
|
406
|
+
* rubric, which made it a request rather than a gate: a caller could read it,
|
|
407
|
+
* agree with it, and send anyway. Below it is arithmetic.
|
|
408
|
+
*
|
|
409
|
+
* Note the deliberate asymmetry with the rest of this file. The caller still
|
|
410
|
+
* does the judgement — it assigns every weight and every score, and nothing here
|
|
411
|
+
* second guesses either number. What it no longer does is decide whether its own
|
|
412
|
+
* numbers mean "sendable". That decision is mechanical, so it belongs on this
|
|
413
|
+
* side of the line, next to the F2 substantiation check.
|
|
414
|
+
*/
|
|
415
|
+
const GATE_WEIGHT_THRESHOLD = 8;
|
|
416
|
+
const GATE_SCORE_FLOOR = 7;
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Verdict from the caller's Pass 2 scores plus the mechanical findings.
|
|
420
|
+
*
|
|
421
|
+
* A blocker severity finding blocks on its own, with or without scores. F2 in
|
|
422
|
+
* particular exists to stop a summary outrunning its evidence, so a highlight
|
|
423
|
+
* claiming a metric no body bullet supports is not sendable at any score: the
|
|
424
|
+
* document is making a claim it cannot back, and a 9.4 overall does not make
|
|
425
|
+
* that true.
|
|
426
|
+
*/
|
|
427
|
+
function evaluateGate(findings, scores) {
|
|
428
|
+
const suppliedScores = Array.isArray(scores);
|
|
429
|
+
// This endpoint is public and token authed, so treat every field as hostile:
|
|
430
|
+
// a malformed row is dropped, never thrown. Dropping one silently is safe in
|
|
431
|
+
// one direction only, and it is this one — a row that cannot be read cannot
|
|
432
|
+
// clear the gate, it just stops counting toward the average.
|
|
433
|
+
const rows = (suppliedScores ? scores : [])
|
|
434
|
+
.filter((s) => s && typeof s === "object")
|
|
435
|
+
.map((s) => ({
|
|
436
|
+
category: String(s.category || "(unnamed category)"),
|
|
437
|
+
weight: Number(s.weight),
|
|
438
|
+
score: Number(s.score),
|
|
439
|
+
}))
|
|
440
|
+
.filter((r) => isFinite(r.score));
|
|
441
|
+
|
|
442
|
+
// Weighted, never a plain mean. A 9 weight scoring 8 and a 1 weight scoring 2
|
|
443
|
+
// is a 7.4 document, not a 5.0 one, and averaging them flat would both
|
|
444
|
+
// understate it here and, in the mirror case, let a heavy category's failure
|
|
445
|
+
// hide behind light categories that scored well.
|
|
446
|
+
let totalWeight = 0;
|
|
447
|
+
let earned = 0;
|
|
448
|
+
for (const r of rows) {
|
|
449
|
+
if (!isFinite(r.weight) || r.weight <= 0) continue;
|
|
450
|
+
totalWeight += r.weight;
|
|
451
|
+
earned += r.weight * r.score;
|
|
452
|
+
}
|
|
453
|
+
const overall = totalWeight > 0 ? Number((earned / totalWeight).toFixed(1)) : null;
|
|
454
|
+
|
|
455
|
+
const blocking = rows
|
|
456
|
+
.filter((r) => isFinite(r.weight) && r.weight >= GATE_WEIGHT_THRESHOLD && r.score < GATE_SCORE_FLOOR)
|
|
457
|
+
.map((r) => ({ category: r.category, weight: r.weight, score: r.score }));
|
|
458
|
+
|
|
459
|
+
const blockingFindings = (Array.isArray(findings) ? findings : [])
|
|
460
|
+
.filter((f) => f && f.severity === "blocker");
|
|
461
|
+
|
|
462
|
+
return {
|
|
463
|
+
suppliedScores,
|
|
464
|
+
categoriesScored: rows.length,
|
|
465
|
+
overall,
|
|
466
|
+
gate: blocking.length || blockingFindings.length ? "BLOCKED" : "PASS",
|
|
467
|
+
blocking,
|
|
468
|
+
blockingFindings,
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* The opening statement, when blocked. It leads the payload because a verdict
|
|
474
|
+
* buried under four sections of rubric is the same as no verdict: by the time a
|
|
475
|
+
* caller reaches it, it has already read the rubric telling it to reason for
|
|
476
|
+
* itself.
|
|
477
|
+
*/
|
|
478
|
+
function renderGateBanner(g) {
|
|
479
|
+
if (g.gate !== "BLOCKED") return "";
|
|
480
|
+
const out = [
|
|
481
|
+
"!!! GATE: BLOCKED. THIS RESUME IS NOT SENDABLE. REGENERATE IT BEFORE SENDING. !!!",
|
|
482
|
+
"",
|
|
483
|
+
"This is an enforced verdict, not a suggestion and not a score to weigh against",
|
|
484
|
+
"anything else. Do not send, export, attach, or paste this document anywhere until",
|
|
485
|
+
"every item below is fixed and score_resume returns PASS.",
|
|
486
|
+
"",
|
|
487
|
+
];
|
|
488
|
+
if (g.blocking.length) {
|
|
489
|
+
out.push(`BLOCKING CATEGORIES (weight >= ${GATE_WEIGHT_THRESHOLD} scoring below ${GATE_SCORE_FLOOR}):`);
|
|
490
|
+
for (const b of g.blocking) out.push(` - ${b.category}: weight ${b.weight}, scored ${b.score}`);
|
|
491
|
+
out.push("");
|
|
492
|
+
}
|
|
493
|
+
if (g.blockingFindings.length) {
|
|
494
|
+
out.push("BLOCKING MECHANICAL FINDINGS (severity blocker):");
|
|
495
|
+
for (const f of g.blockingFindings) out.push(` - ${f.rule}: ${f.detail}`);
|
|
496
|
+
out.push("");
|
|
497
|
+
}
|
|
498
|
+
if (g.overall !== null) out.push(`Weighted overall: ${g.overall.toFixed(1)}`, "");
|
|
499
|
+
out.push(
|
|
500
|
+
"Regenerate the resume so each item above is answered by the document, then call",
|
|
501
|
+
"score_resume again. The gap is in the resume, never in the evidence: fix it by",
|
|
502
|
+
"restoring the bullet that proves the claim, not by deleting the claim's metric.",
|
|
503
|
+
);
|
|
504
|
+
return out.join("\n") + "\n\n" + "=".repeat(78) + "\n\n";
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* The whole score_resume payload. It lives here rather than in the request
|
|
509
|
+
* handler so the gate can be exercised without standing up a Cloud Function,
|
|
510
|
+
* and so index.js keeps doing routing rather than analysis.
|
|
511
|
+
*
|
|
512
|
+
* BACKWARD COMPATIBILITY: with no "scores" and nothing blocking, this returns
|
|
513
|
+
* byte for byte what it returned before the gate existed. A PASS is only
|
|
514
|
+
* announced when scores were actually supplied, because "PASS" on a call that
|
|
515
|
+
* never sent the weighted half would read as sendable on evidence nobody
|
|
516
|
+
* checked.
|
|
517
|
+
*/
|
|
518
|
+
function buildScoreResumeReport(args, KB, rubric) {
|
|
519
|
+
const a = args || {};
|
|
520
|
+
const findings = auditResume(a.resume_text, KB);
|
|
521
|
+
const missingProtected = protectedBulletsMissing(a.resume_text, KB);
|
|
522
|
+
const cats = Array.isArray(a.categories) ? a.categories : [];
|
|
523
|
+
const unused = cats.map((c) => ({
|
|
524
|
+
category: c.category,
|
|
525
|
+
weight: c.weight,
|
|
526
|
+
missing_evidence: missingEvidence([c.category, c.jd_evidence || ""].join(" "), a.resume_text, KB, 4),
|
|
527
|
+
}));
|
|
528
|
+
|
|
529
|
+
const g = evaluateGate(findings, a.scores);
|
|
530
|
+
const gateSection = g.suppliedScores || g.gate === "BLOCKED"
|
|
531
|
+
? "\n\n## PRE-SEND GATE (enforced, computed by the server)\n\n" + JSON.stringify({
|
|
532
|
+
gate: g.gate,
|
|
533
|
+
overall: g.overall,
|
|
534
|
+
blocking: g.blocking,
|
|
535
|
+
blocking_findings: g.blockingFindings,
|
|
536
|
+
categories_scored: g.categoriesScored,
|
|
537
|
+
rule: `A category with weight >= ${GATE_WEIGHT_THRESHOLD} scoring below ${GATE_SCORE_FLOOR} blocks. ` +
|
|
538
|
+
"Any blocker severity mechanical finding blocks on its own, scores or no scores. " +
|
|
539
|
+
"BLOCKED is a verdict on the document, not advice about it.",
|
|
540
|
+
}, null, 1)
|
|
541
|
+
: "";
|
|
542
|
+
|
|
543
|
+
return renderGateBanner(g) +
|
|
544
|
+
rubric +
|
|
545
|
+
gateSection +
|
|
546
|
+
"\n\n## MECHANICAL FINDINGS\n\n" + JSON.stringify(findings, null, 1) +
|
|
547
|
+
"\n\n## PROTECTED BULLETS NOT PRESENT (G2)\n\n" + JSON.stringify(missingProtected, null, 1) +
|
|
548
|
+
"\n\n## UNUSED KNOWLEDGE BASE MATERIAL BY CATEGORY\n\n" + JSON.stringify(unused, null, 1) +
|
|
549
|
+
(a.export_for_recruiter
|
|
550
|
+
? "\n\n## EXPORT MODE\n\nOmit the gap list entirely. Present categories, weights, scores, rationale, and strengths as a requirement to evidence map. Do not brand it as an automated score."
|
|
551
|
+
: "\n\n## OUTPUT HANDLING\n\nInternal diagnostic. Do not write a shareable file and do not produce a candidate facing match report.");
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** G2: which protected bullets did not make it into the document. */
|
|
555
|
+
function protectedBulletsMissing(resumeText, KB) {
|
|
556
|
+
const text = String(resumeText || "").toLowerCase();
|
|
557
|
+
const byPrefix = {};
|
|
558
|
+
for (const a of KB) byPrefix[a.id.slice(0, 7)] = a;
|
|
559
|
+
return PROTECTED.filter((p) => {
|
|
560
|
+
const a = byPrefix[p.id];
|
|
561
|
+
if (!a) return false;
|
|
562
|
+
const ms = [...metrics(a.summary)].map(metricKey);
|
|
563
|
+
const textKeys = new Set([...metrics(text)].map(metricKey));
|
|
564
|
+
return !ms.some((k) => textKeys.has(k));
|
|
565
|
+
}).map((p) => ({ accomplishmentId: p.id, label: p.label }));
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/** K3: KB material that beat what was selected. The money field. */
|
|
569
|
+
function missingEvidence(categoryText, resumeText, KB, limit) {
|
|
570
|
+
const inResume = new Set([...metrics(resumeText)].map(metricKey));
|
|
571
|
+
const rt = String(resumeText || "").toLowerCase();
|
|
572
|
+
return rankEvidence(categoryText, KB, 12).filter((r) => {
|
|
573
|
+
const a = KB.find((x) => x.id === r.accomplishmentId);
|
|
574
|
+
if (!a) return false;
|
|
575
|
+
if (rt.includes((a.title || "").toLowerCase().slice(0, 28))) return false;
|
|
576
|
+
const ms = [...metrics(a.summary)].map(metricKey);
|
|
577
|
+
return ms.length ? !ms.some((k) => inResume.has(k)) : true;
|
|
578
|
+
}).slice(0, limit || 4);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
module.exports = {
|
|
582
|
+
scanJobDescription, rankEvidence, auditResume, protectedBulletsMissing,
|
|
583
|
+
missingEvidence, metrics, metricKey, PROTECTED, words, stem, lines,
|
|
584
|
+
evaluateGate, renderGateBanner, buildScoreResumeReport,
|
|
585
|
+
GATE_WEIGHT_THRESHOLD, GATE_SCORE_FLOOR,
|
|
586
|
+
};
|