@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
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Onboarding (plan section U): batches of source documents, extraction
|
|
5
|
+
* runs through the same routes as any op, and propose_from_source, the
|
|
6
|
+
* onboarding kind of ingest_proposal. It writes pending rows only: every
|
|
7
|
+
* factual atom must be an excerpt of its source text or the draft is
|
|
8
|
+
* rejected whole. Dedup suggests groups; the candidate merges by a click.
|
|
9
|
+
* Nothing here touches ingest_document or the document ledger.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const { ulid } = require("../core/ulid");
|
|
13
|
+
const { extractSource } = require("../core/parsers");
|
|
14
|
+
const { extractJson } = require("../core/extract");
|
|
15
|
+
const { SCHEMAS, validate } = require("../core/schemas");
|
|
16
|
+
const { figureKey } = require("../core/text");
|
|
17
|
+
const { withTx } = require("./tx");
|
|
18
|
+
const { appendEvent } = require("./events");
|
|
19
|
+
const { TransitionError, NotFound } = require("./transitions");
|
|
20
|
+
|
|
21
|
+
const MAX_ATTEMPTS = 3;
|
|
22
|
+
|
|
23
|
+
// ----- batches and sources -----
|
|
24
|
+
|
|
25
|
+
function openBatch(db, { candidate_id, now, actor }) {
|
|
26
|
+
return withTx(db, (d) => {
|
|
27
|
+
const existing = d.prepare("SELECT id FROM onboarding_batch WHERE candidate_id = ? AND status IN ('open', 'review') ORDER BY created_at DESC LIMIT 1").get(candidate_id);
|
|
28
|
+
if (existing) return { batch_id: existing.id, created: false };
|
|
29
|
+
const id = ulid(Date.parse(now));
|
|
30
|
+
d.prepare("INSERT INTO onboarding_batch (id, candidate_id, status, created_at) VALUES (?, ?, 'open', ?)").run(id, candidate_id, now);
|
|
31
|
+
appendEvent(d, { candidate_id, entity: "onboarding_batch", entity_id: id, event: "opened", actor_type: actor.type, actor_ref: actor.ref, at: now });
|
|
32
|
+
return { batch_id: id, created: true };
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function setBatchStatus(db, { candidate_id, batch_id, status, now, actor }) {
|
|
37
|
+
if (!["open", "review", "completed", "abandoned"].includes(status)) throw new TransitionError("bad batch status");
|
|
38
|
+
return withTx(db, (d) => {
|
|
39
|
+
const b = d.prepare("SELECT * FROM onboarding_batch WHERE id = ?").get(batch_id);
|
|
40
|
+
if (!b || (candidate_id && b.candidate_id !== candidate_id)) throw new NotFound("batch");
|
|
41
|
+
d.prepare("UPDATE onboarding_batch SET status = ?, completed_at = CASE WHEN ? IN ('completed', 'abandoned') THEN ? ELSE completed_at END WHERE id = ?").run(status, status, now, b.id);
|
|
42
|
+
appendEvent(d, { candidate_id: b.candidate_id, entity: "onboarding_batch", entity_id: b.id, event: status, actor_type: actor.type, actor_ref: actor.ref, prior: { status: b.status }, at: now });
|
|
43
|
+
return { batch_id: b.id, status };
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Upload one file: extract under the caps, store immutable text, dedupe by file hash. */
|
|
48
|
+
async function addSource(db, { candidate_id, batch_id, filename, buffer, now, actor, limits }) {
|
|
49
|
+
const r = await extractSource(filename, buffer, limits);
|
|
50
|
+
return withTx(db, (d) => {
|
|
51
|
+
const b = d.prepare("SELECT id, status, candidate_id FROM onboarding_batch WHERE id = ?").get(batch_id);
|
|
52
|
+
if (!b || b.candidate_id !== candidate_id) throw new NotFound("batch");
|
|
53
|
+
if (b.status !== "open") throw new TransitionError(`batch is ${b.status}; open a new one`);
|
|
54
|
+
const dup = d.prepare("SELECT id, batch_id FROM source_document WHERE candidate_id = ? AND file_hash = ?").get(candidate_id, r.file_hash);
|
|
55
|
+
if (dup) return { source_document_id: dup.id, created: false, empty: false, failure: null, chars: null };
|
|
56
|
+
const id = ulid(Date.parse(now));
|
|
57
|
+
d.prepare(`INSERT INTO source_document (id, candidate_id, batch_id, filename, kind, file_hash, text_hash, extractor, extractor_version, text, uploaded_at)
|
|
58
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, candidate_id, b.id, String(filename).slice(0, 200), r.kind, r.file_hash, r.text_hash, r.extractor, r.extractor_version, r.text, now);
|
|
59
|
+
appendEvent(d, { candidate_id, entity: "source_document", entity_id: id, event: r.empty ? "extraction_failed" : "uploaded", actor_type: actor.type, actor_ref: actor.ref,
|
|
60
|
+
payload: { kind: r.kind, chars: r.text.length, pages: r.pages, failure: r.failure }, at: now });
|
|
61
|
+
return { source_document_id: id, created: true, empty: r.empty, failure: r.failure, chars: r.text.length, kind: r.kind };
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function listSources(db, candidate_id, batch_id) {
|
|
66
|
+
return db.prepare(`SELECT s.id AS source_document_id, s.batch_id, s.filename, s.kind, s.extractor, s.extractor_version, length(s.text) AS chars, s.uploaded_at,
|
|
67
|
+
(SELECT count(*) FROM pending_accomplishment p WHERE p.source_document_id = s.id) AS pending,
|
|
68
|
+
(SELECT count(*) FROM ai_run r WHERE r.source_document_id = s.id) AS runs
|
|
69
|
+
FROM source_document s WHERE s.candidate_id = ? AND (? IS NULL OR s.batch_id = ?) ORDER BY s.uploaded_at DESC`).all(candidate_id, batch_id || null, batch_id || null)
|
|
70
|
+
.map((s) => ({ ...s, empty: s.chars < 20 }));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function sourceText(db, source_document_id) {
|
|
74
|
+
const s = db.prepare("SELECT * FROM source_document WHERE id = ?").get(source_document_id);
|
|
75
|
+
if (!s) throw new NotFound("source document");
|
|
76
|
+
return s;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ----- propose_from_source -----
|
|
80
|
+
|
|
81
|
+
function checkAtom(atom, text, label, errors) {
|
|
82
|
+
if (!atom) return;
|
|
83
|
+
const { value, start, end } = atom;
|
|
84
|
+
if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end > text.length || end <= start) { errors.push(`${label}: span [${start}, ${end}) is not inside the source`); return; }
|
|
85
|
+
if (text.slice(start, end) !== value) errors.push(`${label}: "${String(value).slice(0, 40)}" is not the source text at [${start}, ${end})`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Validate a draft against its source: every atom cited and verbatim, else the whole draft is rejected. */
|
|
89
|
+
function checkDraft(content, text) {
|
|
90
|
+
const errors = validate(content, SCHEMAS.extract).map((e) => `${e.path}: ${e.message}`);
|
|
91
|
+
if (errors.length) return errors;
|
|
92
|
+
content.accomplishments.forEach((a, i) => {
|
|
93
|
+
const p = `accomplishments[${i}]`;
|
|
94
|
+
checkAtom(a.company, text, `${p}.company`, errors);
|
|
95
|
+
checkAtom(a.role, text, `${p}.role`, errors);
|
|
96
|
+
if (a.dates) checkAtom(a.dates, text, `${p}.dates`, errors);
|
|
97
|
+
if (a.scope) checkAtom(a.scope, text, `${p}.scope`, errors);
|
|
98
|
+
(a.metrics || []).forEach((m, j) => checkAtom(m, text, `${p}.metrics[${j}]`, errors));
|
|
99
|
+
(a.verbs || []).forEach((v, j) => checkAtom(v, text, `${p}.verbs[${j}]`, errors));
|
|
100
|
+
(a.wordings || []).forEach((w, j) => checkAtom(w, text, `${p}.wordings[${j}]`, errors));
|
|
101
|
+
});
|
|
102
|
+
return errors;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The onboarding kind of ingest_proposal: pending rows with batch, source, attempt, and excerpts. */
|
|
106
|
+
function proposeFromSource(db, { candidate_id, source_document_id, content, ai_attempt_id, now, actor }) {
|
|
107
|
+
const s = sourceText(db, source_document_id);
|
|
108
|
+
if (s.candidate_id !== candidate_id) throw new NotFound("source document");
|
|
109
|
+
const errors = checkDraft(content, s.text);
|
|
110
|
+
if (errors.length) return { accepted: false, errors, pending_ids: [] };
|
|
111
|
+
return withTx(db, (d) => {
|
|
112
|
+
const ids = [];
|
|
113
|
+
for (const a of content.accomplishments) {
|
|
114
|
+
const id = ulid(Date.parse(now) + ids.length);
|
|
115
|
+
const draft = {
|
|
116
|
+
title: a.title, company: a.company.value, role: a.role.value, dates: a.dates ? a.dates.value : "", summary: a.summary || "",
|
|
117
|
+
metrics: (a.metrics || []).map((m) => m.value), metric_outcomes: (a.metrics || []).map((m) => m.outcome || ""),
|
|
118
|
+
verbs: (a.verbs || []).map((v) => v.value), wordings: (a.wordings || []).map((w) => w.value), scope: a.scope ? a.scope.value : "", tags: a.tags || [],
|
|
119
|
+
excerpts: { company: span(a.company), role: span(a.role), dates: span(a.dates), scope: span(a.scope), metrics: (a.metrics || []).map(span), verbs: (a.verbs || []).map(span), wordings: (a.wordings || []).map(span) },
|
|
120
|
+
source_document_id: s.id,
|
|
121
|
+
};
|
|
122
|
+
try {
|
|
123
|
+
d.prepare("INSERT INTO pending_accomplishment (id, candidate_id, draft_json, source, batch_id, source_document_id, ai_attempt_id, proposed_at) VALUES (?, ?, ?, 'onboarding', ?, ?, ?, ?)")
|
|
124
|
+
.run(id, candidate_id, JSON.stringify(draft), s.batch_id, s.id, ai_attempt_id || null, now);
|
|
125
|
+
} catch (e) {
|
|
126
|
+
throw new TransitionError(/proposals per/.test(e.message) ? e.message : e.message);
|
|
127
|
+
}
|
|
128
|
+
ids.push(id);
|
|
129
|
+
}
|
|
130
|
+
appendEvent(d, { candidate_id, entity: "source_document", entity_id: s.id, event: "proposed", actor_type: actor.type, actor_ref: actor.ref, payload: { pending: ids.length, style: content.style_observed || null }, at: now });
|
|
131
|
+
return { accepted: true, errors: [], pending_ids: ids, style_observed: content.style_observed || null };
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
const span = (a) => (a ? { start: a.start, end: a.end } : null);
|
|
135
|
+
|
|
136
|
+
/** Save the model's extraction for a run: counts the attempt, then propose_from_source. Three attempts then stop. */
|
|
137
|
+
function saveExtraction(db, { run_id, content, route, actor, now, provider, model, tokens_in, tokens_out, latency_ms }) {
|
|
138
|
+
return withTx(db, (d) => {
|
|
139
|
+
const run = d.prepare("SELECT * FROM ai_run WHERE id = ?").get(run_id);
|
|
140
|
+
if (!run) throw new NotFound("run");
|
|
141
|
+
if (run.operation !== "extract") throw new TransitionError("this run is not an extraction; use the document door");
|
|
142
|
+
const attempts = d.prepare("SELECT count(*) c FROM ai_attempt WHERE ai_run_id = ?").get(run.id).c;
|
|
143
|
+
if (attempts >= MAX_ATTEMPTS) return { run_id: run.id, status: "blocked", attempt: attempts, stop: true, errors: ["three attempts used; open a fresh packet"], pending_ids: [] };
|
|
144
|
+
const attemptNo = attempts + 1;
|
|
145
|
+
const ex = extractJson(content);
|
|
146
|
+
let result;
|
|
147
|
+
let parsed = null;
|
|
148
|
+
if (ex.error) result = { accepted: false, errors: ["schema: " + ex.error], pending_ids: [] };
|
|
149
|
+
else { parsed = ex.value; result = { accepted: false, errors: [], pending_ids: [] }; }
|
|
150
|
+
const attemptId = ulid(Date.parse(now) + 100 + attemptNo);
|
|
151
|
+
d.prepare(`INSERT INTO ai_attempt (id, ai_run_id, attempt_no, provider, model, tokens_in, tokens_out, latency_ms, output_hash, result, error_code, started_at, finished_at)
|
|
152
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'blocked', ?, ?, ?)`).run(attemptId, run.id, attemptNo, provider || null, model || null, tokens_in ?? null, tokens_out ?? null, latency_ms ?? null, null, ex.error ? "schema" : null, now, now);
|
|
153
|
+
if (parsed) result = proposeFromSource(d, { candidate_id: run.candidate_id, source_document_id: run.source_document_id, content: parsed, ai_attempt_id: attemptId, now, actor: { type: actor.type, ref: actor.ref } });
|
|
154
|
+
const status = result.accepted ? "passed" : "blocked";
|
|
155
|
+
d.prepare("UPDATE ai_attempt SET result = ?, error_code = ? WHERE id = ?").run(status, result.accepted ? null : (ex.error ? "schema" : "excerpt"), attemptId);
|
|
156
|
+
const final = status === "passed" ? "passed" : attemptNo >= MAX_ATTEMPTS ? "blocked" : "pending";
|
|
157
|
+
d.prepare("UPDATE ai_run SET final_result = ?, finished_at = CASE WHEN ? = 'pending' THEN finished_at ELSE ? END WHERE id = ?").run(final, final, now, run.id);
|
|
158
|
+
appendEvent(d, { candidate_id: run.candidate_id, entity: "ai_run", entity_id: run.id, event: status === "passed" ? "extraction_saved" : "extraction_blocked", actor_type: actor.type, actor_ref: actor.ref, payload: { attempt: attemptNo, pending: result.pending_ids.length, errors: result.errors.length, route }, at: now });
|
|
159
|
+
return { run_id: run.id, status, attempt: attemptNo, stop: attemptNo >= MAX_ATTEMPTS && status !== "passed", errors: result.errors, pending_ids: result.pending_ids, style_observed: result.style_observed || null };
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ----- dedup -----
|
|
164
|
+
|
|
165
|
+
function monthRange(dates) {
|
|
166
|
+
const m = String(dates || "").match(/(\d{4})(?:-(\d{2}))?/g);
|
|
167
|
+
if (!m || !m.length) return null;
|
|
168
|
+
const a = m[0], b = m[m.length - 1];
|
|
169
|
+
const to = (s) => { const [y, mo] = s.split("-"); return Number(y) * 12 + (mo ? Number(mo) - 1 : 0); };
|
|
170
|
+
return [to(a), to(b) + (m.length === 1 ? 11 : 0)];
|
|
171
|
+
}
|
|
172
|
+
const overlaps = (x, y) => x && y && x[0] <= y[1] && y[0] <= x[1];
|
|
173
|
+
const normCo = (s) => String(s || "").toLowerCase().replace(/[^a-z0-9]+/g, " ").replace(/\b(inc|llc|corp|corporation|ltd|co)\b/g, "").trim();
|
|
174
|
+
|
|
175
|
+
/** Cluster pending onboarding rows in a batch: same company, overlapping dates, and a shared metric key make a strong group; company and dates alone a weak one. */
|
|
176
|
+
function suggestDedup(db, { candidate_id, batch_id, now, actor }) {
|
|
177
|
+
const owner = db.prepare("SELECT candidate_id FROM onboarding_batch WHERE id = ?").get(batch_id);
|
|
178
|
+
if (!owner || owner.candidate_id !== candidate_id) throw new NotFound("batch");
|
|
179
|
+
const rows = db.prepare("SELECT id, draft_json FROM pending_accomplishment WHERE candidate_id = ? AND batch_id = ? AND source = 'onboarding' ORDER BY proposed_at").all(candidate_id, batch_id)
|
|
180
|
+
.map((r) => { const d = JSON.parse(r.draft_json); return { id: r.id, company: normCo(d.company), range: monthRange(d.dates), keys: new Set((d.metrics || []).map(figureKey)), source: d.source_document_id }; });
|
|
181
|
+
const groups = [];
|
|
182
|
+
const assigned = new Map();
|
|
183
|
+
for (let i = 0; i < rows.length; i++) {
|
|
184
|
+
for (let j = i + 1; j < rows.length; j++) {
|
|
185
|
+
const a = rows[i], b = rows[j];
|
|
186
|
+
if (a.source === b.source || !a.company || a.company !== b.company) continue;
|
|
187
|
+
const shared = [...a.keys].filter((k) => b.keys.has(k));
|
|
188
|
+
const dates = overlaps(a.range, b.range);
|
|
189
|
+
// a weak link (company and dates only) is offered only when neither row carries a figure; otherwise every bullet at one employer would chain into one group
|
|
190
|
+
const strength = shared.length && dates ? "strong" : shared.length ? "shared_metric" : dates && !a.keys.size && !b.keys.size ? "weak" : null;
|
|
191
|
+
if (!strength) continue;
|
|
192
|
+
let g = assigned.get(a.id) || assigned.get(b.id);
|
|
193
|
+
if (!g) { g = { dedup_group_id: ulid(Date.parse(now) + groups.length), ids: new Set(), strength }; groups.push(g); }
|
|
194
|
+
g.ids.add(a.id); g.ids.add(b.id);
|
|
195
|
+
if (strength === "strong") g.strength = "strong";
|
|
196
|
+
assigned.set(a.id, g); assigned.set(b.id, g);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
withTx(db, (d) => {
|
|
200
|
+
d.prepare("UPDATE pending_accomplishment SET dedup_group_id = NULL WHERE candidate_id = ? AND batch_id = ?").run(candidate_id, batch_id);
|
|
201
|
+
for (const g of groups) for (const id of g.ids) d.prepare("UPDATE pending_accomplishment SET dedup_group_id = ? WHERE id = ?").run(g.dedup_group_id, id);
|
|
202
|
+
appendEvent(d, { candidate_id, entity: "onboarding_batch", entity_id: batch_id, event: "dedup_suggested", actor_type: actor.type, actor_ref: actor.ref, payload: { groups: groups.length }, at: now });
|
|
203
|
+
});
|
|
204
|
+
return groups.map((g) => ({ dedup_group_id: g.dedup_group_id, strength: g.strength, pending_ids: [...g.ids] }));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** The click: merge a group into its first row (wordings and metrics unioned, excerpts kept per source); the rest are deleted. */
|
|
208
|
+
function mergeGroup(db, { candidate_id, dedup_group_id, keep_id, now, actor }) {
|
|
209
|
+
return withTx(db, (d) => {
|
|
210
|
+
const rows = d.prepare("SELECT id, draft_json FROM pending_accomplishment WHERE candidate_id = ? AND dedup_group_id = ? ORDER BY proposed_at").all(candidate_id, dedup_group_id);
|
|
211
|
+
if (rows.length < 2) throw new TransitionError("nothing to merge");
|
|
212
|
+
const keep = rows.find((r) => r.id === keep_id) || rows[0];
|
|
213
|
+
const base = JSON.parse(keep.draft_json);
|
|
214
|
+
base.sources = [{ source_document_id: base.source_document_id, excerpts: base.excerpts }];
|
|
215
|
+
for (const r of rows) {
|
|
216
|
+
if (r.id === keep.id) continue;
|
|
217
|
+
const o = JSON.parse(r.draft_json);
|
|
218
|
+
for (const [i, w] of (o.wordings || []).entries()) if (!base.wordings.includes(w)) { base.wordings.push(w); base.excerpts.wordings.push(null); }
|
|
219
|
+
for (const [i, m] of (o.metrics || []).entries()) if (!base.metrics.some((x) => figureKey(x) === figureKey(m))) { base.metrics.push(m); base.metric_outcomes.push((o.metric_outcomes || [])[i] || ""); base.excerpts.metrics.push(null); }
|
|
220
|
+
for (const v of o.verbs || []) if (!base.verbs.includes(v)) { base.verbs.push(v); base.excerpts.verbs.push(null); }
|
|
221
|
+
base.sources.push({ source_document_id: o.source_document_id, excerpts: o.excerpts });
|
|
222
|
+
d.prepare("DELETE FROM pending_accomplishment WHERE id = ?").run(r.id);
|
|
223
|
+
}
|
|
224
|
+
d.prepare("UPDATE pending_accomplishment SET draft_json = ?, dedup_group_id = NULL WHERE id = ?").run(JSON.stringify(base), keep.id);
|
|
225
|
+
appendEvent(d, { candidate_id, entity: "pending_accomplishment", entity_id: keep.id, event: "merged", actor_type: actor.type, actor_ref: actor.ref, payload: { from: rows.filter((r) => r.id !== keep.id).map((r) => r.id) }, at: now });
|
|
226
|
+
return { pending_id: keep.id, merged: rows.length - 1 };
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function keepSeparate(db, { candidate_id, dedup_group_id, now, actor }) {
|
|
231
|
+
return withTx(db, (d) => {
|
|
232
|
+
const n = d.prepare("UPDATE pending_accomplishment SET dedup_group_id = NULL WHERE candidate_id = ? AND dedup_group_id = ?").run(candidate_id, dedup_group_id).changes;
|
|
233
|
+
if (!n) throw new NotFound("duplicate group");
|
|
234
|
+
appendEvent(d, { candidate_id, entity: "pending_accomplishment", entity_id: dedup_group_id, event: "kept_separate", actor_type: actor.type, actor_ref: actor.ref, payload: { rows: n }, at: now });
|
|
235
|
+
return { rows: n };
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ----- style proposal -----
|
|
240
|
+
|
|
241
|
+
/** Scan the candidate's own sources for style; proposed for confirmation, never imposed. */
|
|
242
|
+
function proposeStyle(db, candidate_id) {
|
|
243
|
+
const texts = db.prepare("SELECT text FROM source_document WHERE candidate_id = ?").all(candidate_id).map((r) => r.text).join("\n");
|
|
244
|
+
const count = (re) => (texts.match(re) || []).length;
|
|
245
|
+
const em = count(/[–—]/g), hy = count(/\b\w+-\w+\b/g);
|
|
246
|
+
const ecom = count(/\becommerce\b/gi), ecomH = count(/\be-commerce\b/gi);
|
|
247
|
+
const pctSym = count(/\d\s?%/g), pctWord = count(/\d\s+percent\b/gi);
|
|
248
|
+
return {
|
|
249
|
+
observed: { em_dashes: em, hyphen_compounds: hy, ecommerce: ecom, e_commerce: ecomH, percent_symbol: pctSym, percent_word: pctWord, chars: texts.length },
|
|
250
|
+
proposed: {
|
|
251
|
+
no_dashes: em ? { resume: "warn", cover: "warn", onepager: "warn", email: "warn" } : {},
|
|
252
|
+
vocabulary: ecomH > ecom ? [{ pattern: "\\becommerce\\b", flags: "i", message: 'this candidate writes "e-commerce"' }] : undefined,
|
|
253
|
+
note: "A new candidate defaults to warn until they choose; the sources may themselves have been machine-written.",
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function batchView(db, candidate_id) {
|
|
259
|
+
const batch = db.prepare("SELECT * FROM onboarding_batch WHERE candidate_id = ? ORDER BY created_at DESC LIMIT 1").get(candidate_id);
|
|
260
|
+
if (!batch) return null;
|
|
261
|
+
const sources = listSources(db, candidate_id, batch.id);
|
|
262
|
+
const pending = db.prepare("SELECT id AS pending_id, draft_json, dedup_group_id, source_document_id, proposed_at FROM pending_accomplishment WHERE candidate_id = ? AND batch_id = ? ORDER BY dedup_group_id, proposed_at").all(candidate_id, batch.id)
|
|
263
|
+
.map((r) => ({ ...r, draft: JSON.parse(r.draft_json), draft_json: undefined }));
|
|
264
|
+
return { batch_id: batch.id, status: batch.status, created_at: batch.created_at, sources, pending, groups: [...new Set(pending.map((p) => p.dedup_group_id).filter(Boolean))] };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
module.exports = { openBatch, setBatchStatus, addSource, listSources, sourceText, checkDraft, proposeFromSource, saveExtraction, suggestDedup, mergeGroup, keepSeparate, proposeStyle, batchView, MAX_ATTEMPTS };
|
package/store/packets.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Gathers one operation's facts from the store, builds the packet, and
|
|
5
|
+
* records the run: an ai_run row in pending with the packet hash and the
|
|
6
|
+
* selected kb ids, which ingest later uses as the slice. Nothing else is
|
|
7
|
+
* written. The comp floor lives in config, which this module never reads.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const { ulid } = require("../core/ulid");
|
|
11
|
+
const { buildPacket, PACKET_VERSION, POSTING_OPS } = require("../core/generators/packet");
|
|
12
|
+
const { rulesVersion } = require("../core/rules");
|
|
13
|
+
const { SCHEMA_VERSION } = require("../core/schemas");
|
|
14
|
+
const { loadSlice } = require("./context");
|
|
15
|
+
const { targetFor } = require("./ingest");
|
|
16
|
+
const { withTx } = require("./tx");
|
|
17
|
+
const { appendEvent } = require("./events");
|
|
18
|
+
const { houseFor } = require("../core/house_rules");
|
|
19
|
+
|
|
20
|
+
function latestFrozenScoring(db, posting_id, preferred_id) {
|
|
21
|
+
const row = (preferred_id && db.prepare("SELECT * FROM scoring WHERE id = ? AND status = 'frozen'").get(preferred_id))
|
|
22
|
+
|| db.prepare("SELECT * FROM scoring WHERE posting_id = ? AND status = 'frozen' ORDER BY scored_at DESC, rowid DESC LIMIT 1").get(posting_id)
|
|
23
|
+
|| db.prepare("SELECT * FROM scoring WHERE posting_id = ? ORDER BY scored_at DESC, rowid DESC LIMIT 1").get(posting_id);
|
|
24
|
+
if (!row) return null;
|
|
25
|
+
return { ...row, categories: JSON.parse(row.categories_json || "[]") };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function peopleFor(db, application_id) {
|
|
29
|
+
return db.prepare(`SELECT pe.id, pe.name, pe.title, pe.org, pe.intel_text, ap.role FROM application_person ap
|
|
30
|
+
JOIN person pe ON pe.id = ap.person_id WHERE ap.application_id = ? AND pe.merged_into_id IS NULL ORDER BY pe.name`).all(application_id);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function interviewPeople(db, interview_id) {
|
|
34
|
+
return db.prepare(`SELECT pe.id, pe.name, pe.title, pe.org, pe.intel_text, 'interviewer' AS role FROM interview_person ip
|
|
35
|
+
JOIN person pe ON pe.id = ip.person_id WHERE ip.interview_id = ? AND pe.merged_into_id IS NULL ORDER BY pe.name`).all(interview_id);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Everything the builder needs for one target, read in one place. */
|
|
39
|
+
function gather(db, op, { interview_id, posting_id, source_document_id, candidate_id }) {
|
|
40
|
+
if (op === "extract") {
|
|
41
|
+
if (!source_document_id) throw new Error("extract needs a source_document_id");
|
|
42
|
+
const src = db.prepare("SELECT * FROM source_document WHERE id = ?").get(source_document_id);
|
|
43
|
+
if (!src) throw new Error("unknown source document");
|
|
44
|
+
const cand = db.prepare("SELECT id, name, kb_revision FROM candidate WHERE id = ?").get(src.candidate_id);
|
|
45
|
+
return { op, target: { candidate_id: cand.id, interview_id: null, posting_id: null, source_document_id: src.id }, candidate: { id: cand.id, name: cand.name, profile: {}, kb_revision: cand.kb_revision },
|
|
46
|
+
source: src, posting: null, scoring: null, application: null, pinned_ids: [], records: [] };
|
|
47
|
+
}
|
|
48
|
+
if (op === "discover") {
|
|
49
|
+
const cand = db.prepare("SELECT id, name, kb_revision FROM candidate WHERE id = ?").get(candidate_id);
|
|
50
|
+
if (!cand) throw new Error("discover needs a candidate_id");
|
|
51
|
+
const kb = require("./kb");
|
|
52
|
+
const { listQuestions } = require("./discover");
|
|
53
|
+
return { op, target: { candidate_id: cand.id, interview_id: null, posting_id: null, source_document_id: null }, candidate: { id: cand.id, name: cand.name, profile: {}, kb_revision: cand.kb_revision },
|
|
54
|
+
holes: kb.listHoles(db, cand.id, { limit: 40 }), prior_questions: listQuestions(db, cand.id, { all: true }).map((q) => ({ accomplishment_id: q.accomplishment_id, kind: q.kind, question: q.question, status: q.status })),
|
|
55
|
+
records: [...loadSlice(db, cand.id).values()], posting: null, scoring: null, application: null, pinned_ids: [] };
|
|
56
|
+
}
|
|
57
|
+
const t = targetFor(db, op, { interview_id, posting_id });
|
|
58
|
+
const cand = db.prepare("SELECT id, name, profile_json, kb_revision, style_config_json FROM candidate WHERE id = ?").get(t.candidate_id);
|
|
59
|
+
let posting;
|
|
60
|
+
let application = null;
|
|
61
|
+
let scoring = null;
|
|
62
|
+
let pinned = [];
|
|
63
|
+
if (POSTING_OPS.has(op)) {
|
|
64
|
+
posting = db.prepare("SELECT * FROM posting WHERE id = ?").get(t.posting_id);
|
|
65
|
+
const app = db.prepare("SELECT scoring_id FROM application WHERE posting_id = ?").get(posting.id);
|
|
66
|
+
scoring = latestFrozenScoring(db, posting.id, app && app.scoring_id);
|
|
67
|
+
} else {
|
|
68
|
+
const app = db.prepare(`SELECT a.*, p.company, p.title FROM application a JOIN posting p ON p.id = a.posting_id WHERE a.id = ?`).get(t.application_id);
|
|
69
|
+
posting = db.prepare("SELECT * FROM posting WHERE id = ?").get(app.posting_id);
|
|
70
|
+
scoring = latestFrozenScoring(db, posting.id, app.scoring_id);
|
|
71
|
+
const interviews = db.prepare("SELECT * FROM interview WHERE application_id = ? ORDER BY scheduled_at").all(app.id)
|
|
72
|
+
.map((i) => ({ ...i, people: interviewPeople(db, i.id) }));
|
|
73
|
+
const documents = db.prepare(`SELECT d.id, d.kind, d.status, d.interview_id, d.content_json, d.created_at FROM document d
|
|
74
|
+
LEFT JOIN interview i ON i.id = d.interview_id
|
|
75
|
+
WHERE d.application_id = ? OR i.application_id = ? OR d.posting_id = ? ORDER BY d.created_at`).all(app.id, app.id, posting.id);
|
|
76
|
+
const me = interviews.find((i) => i.id === t.interview_id);
|
|
77
|
+
pinned = JSON.parse((me && me.pinned_ids_json) || "[]");
|
|
78
|
+
const prepRow = documents.filter((d) => d.kind === "prep" && d.status === "passed" && d.interview_id === t.interview_id).pop();
|
|
79
|
+
application = { id: app.id, company: app.company, title: app.title, status: app.status, interview_id: t.interview_id,
|
|
80
|
+
interviews, people: peopleFor(db, app.id), documents, prep: prepRow ? JSON.parse(prepRow.content_json) : null };
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
op, target: t,
|
|
84
|
+
candidate: { id: cand.id, name: cand.name, profile: JSON.parse(cand.profile_json || "{}"), kb_revision: cand.kb_revision },
|
|
85
|
+
house: houseFor(JSON.parse(cand.style_config_json || "{}")),
|
|
86
|
+
posting, scoring, application, pinned_ids: pinned,
|
|
87
|
+
records: [...loadSlice(db, cand.id).values()],
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Build the packet and open the run. Returns { run_id, packet }.
|
|
93
|
+
* @param {object} a { op, interview_id?, posting_id?, route, actor, now, budgets? }
|
|
94
|
+
*/
|
|
95
|
+
function openRun(db, a) {
|
|
96
|
+
const g = gather(db, a.op, a);
|
|
97
|
+
const packet = buildPacket({ ...g, budgets: a.budgets });
|
|
98
|
+
return withTx(db, (d) => {
|
|
99
|
+
const id = ulid(Date.parse(a.now));
|
|
100
|
+
d.prepare(`INSERT INTO ai_run (id, candidate_id, route, operation, interview_id, posting_id, source_document_id, rules_version, schema_version, packet_version, kb_revision, packet_hash, kb_ids_json, started_at)
|
|
101
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
102
|
+
.run(id, g.candidate.id, a.route || "paste", a.op, g.target.interview_id, g.target.posting_id, g.target.source_document_id || null, rulesVersion(), SCHEMA_VERSION, PACKET_VERSION,
|
|
103
|
+
g.candidate.kb_revision, packet.packet_hash, JSON.stringify(packet.kb_ids), a.now);
|
|
104
|
+
appendEvent(d, { candidate_id: g.candidate.id, entity: "ai_run", entity_id: id, event: "packet_built", actor_type: a.actor.type, actor_ref: a.actor.ref,
|
|
105
|
+
payload: { operation: a.op, route: a.route || "paste", kb_ids: packet.kb_ids.length, packet_hash: packet.packet_hash }, at: a.now });
|
|
106
|
+
return { run_id: id, packet };
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
module.exports = { openRun, gather, latestFrozenScoring };
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/** Writes the practice workbook with exceljs from a passed practice document; the manifest stays on the row. */
|
|
4
|
+
|
|
5
|
+
const fs = require("fs");
|
|
6
|
+
const path = require("path");
|
|
7
|
+
const { buildWorkbook } = require("../core/practice");
|
|
8
|
+
const { withTx } = require("./tx");
|
|
9
|
+
const { appendEvent } = require("./events");
|
|
10
|
+
|
|
11
|
+
async function writeWorkbook(db, { document_id, outDir, homeDir, now, actor }) {
|
|
12
|
+
const doc = db.prepare("SELECT * FROM document WHERE id = ?").get(document_id);
|
|
13
|
+
if (!doc) throw new Error("unknown document");
|
|
14
|
+
if (doc.kind !== "practice") throw new Error("only a practice document becomes a workbook");
|
|
15
|
+
if (doc.status !== "passed") throw new Error("only a passed document renders; this one is blocked");
|
|
16
|
+
const ExcelJS = require("exceljs");
|
|
17
|
+
const wb = buildWorkbook(JSON.parse(doc.content_json), doc.id);
|
|
18
|
+
const book = new ExcelJS.Workbook();
|
|
19
|
+
const brief = book.addWorksheet("Brief");
|
|
20
|
+
const content = JSON.parse(doc.content_json);
|
|
21
|
+
brief.addRow(["Scenario"]); brief.addRow([content.scenario]); brief.addRow([]);
|
|
22
|
+
brief.addRow(["Tasks", "Minutes"]);
|
|
23
|
+
for (const t of wb.tasks) brief.addRow([t.text, t.minutes]);
|
|
24
|
+
brief.getColumn(1).width = 90;
|
|
25
|
+
for (const s of wb.sheets) {
|
|
26
|
+
const ws = book.addWorksheet(s.name);
|
|
27
|
+
ws.addRow(s.columns);
|
|
28
|
+
ws.getRow(1).font = { bold: true };
|
|
29
|
+
for (const row of s.rows) ws.addRow(row);
|
|
30
|
+
ws.columns.forEach((c) => { c.width = 16; });
|
|
31
|
+
}
|
|
32
|
+
const key = book.addWorksheet("AnswerKey");
|
|
33
|
+
key.addRow(["Task", "Formula (as text)", "Expected"]);
|
|
34
|
+
for (const k of wb.answerKey) key.addRow([k.task, k.formula, k.expected]);
|
|
35
|
+
key.getColumn(2).width = 60;
|
|
36
|
+
const sol = book.addWorksheet("Solution");
|
|
37
|
+
sol.addRow(["Sheet", "Row", "Kind", "Column", "Was", "Now", "Description"]);
|
|
38
|
+
for (const p of wb.planted) sol.addRow([p.sheet, p.row, p.kind, p.column || "", p.was == null ? "" : String(p.was), p.now == null ? "" : String(p.now), p.note ? `${p.description} (${p.note})` : p.description]);
|
|
39
|
+
sol.getColumn(7).width = 70;
|
|
40
|
+
if (wb.lesson) { sol.addRow([]); sol.addRow(["Lesson", wb.lesson]); }
|
|
41
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
42
|
+
const file = path.join(outDir, `practice-${doc.id.slice(-6).toLowerCase()}.xlsx`);
|
|
43
|
+
await book.xlsx.writeFile(file);
|
|
44
|
+
const rel = homeDir ? path.relative(homeDir, file) : file;
|
|
45
|
+
withTx(db, (d) => {
|
|
46
|
+
d.prepare("UPDATE document SET rendered_rel_path = ? WHERE id = ?").run(rel, doc.id);
|
|
47
|
+
appendEvent(d, { candidate_id: doc.candidate_id, entity: "document", entity_id: doc.id, event: "rendered", actor_type: actor.type, actor_ref: actor.ref, payload: { path: rel, planted: wb.planted.length }, at: now });
|
|
48
|
+
});
|
|
49
|
+
return { file, rel, planted: wb.planted.length };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
module.exports = { writeWorkbook };
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ingest_proposal: the typed gateway for model proposals. It can only create
|
|
5
|
+
* proposed or pending rows: a posting in proposed, a scoring row in
|
|
6
|
+
* proposed, a gap question, a pending accomplishment. It never runs the
|
|
7
|
+
* document ledger and never touches trusted state. Every row carries the
|
|
8
|
+
* actor that proposed it. Budgets are the store's triggers.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const crypto = require("crypto");
|
|
12
|
+
const { ulid } = require("../core/ulid");
|
|
13
|
+
const { SCHEMAS, validate } = require("../core/schemas");
|
|
14
|
+
const { rulesVersion } = require("../core/rules");
|
|
15
|
+
const { checkCategories, ENGINE_VERSION } = require("./scoring");
|
|
16
|
+
const { withTx } = require("./tx");
|
|
17
|
+
const { appendEvent } = require("./events");
|
|
18
|
+
|
|
19
|
+
const sha = (s) => crypto.createHash("sha256").update(s).digest("hex");
|
|
20
|
+
|
|
21
|
+
function need(v, name, max = 4000) {
|
|
22
|
+
const s = typeof v === "string" ? v.trim() : "";
|
|
23
|
+
if (!s) throw new Error(`${name} is required`);
|
|
24
|
+
if (s.length > max) throw new Error(`${name} is longer than ${max} characters`);
|
|
25
|
+
return s;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** store_posting: idempotent on (candidate, company, title, hash of raw_text); lands proposed. */
|
|
29
|
+
function storePosting(db, { candidate_id, company, title, raw_text, source_url, comp_text, location, now, actor }) {
|
|
30
|
+
company = need(company, "company", 200);
|
|
31
|
+
title = need(title, "title", 200);
|
|
32
|
+
raw_text = need(raw_text, "raw_text", 262144);
|
|
33
|
+
const hash = sha(raw_text);
|
|
34
|
+
return withTx(db, (d) => {
|
|
35
|
+
const existing = d.prepare("SELECT id, status FROM posting WHERE candidate_id = ? AND company = ? AND title = ? AND content_hash = ?").get(candidate_id, company, title, hash);
|
|
36
|
+
if (existing) return { posting_id: existing.id, status: existing.status, created: false };
|
|
37
|
+
const id = ulid(Date.parse(now));
|
|
38
|
+
d.prepare(`INSERT INTO posting (id, candidate_id, company, title, raw_text, content_hash, status, source_url, comp_text, location, captured_at)
|
|
39
|
+
VALUES (?, ?, ?, ?, ?, ?, 'proposed', ?, ?, ?, ?)`).run(id, candidate_id, company, title, raw_text, hash, source_url || null, comp_text || null, location || null, now);
|
|
40
|
+
appendEvent(d, { candidate_id, entity: "posting", entity_id: id, event: "proposed", actor_type: actor.type, actor_ref: actor.ref, payload: { company, title, bytes: raw_text.length }, at: now });
|
|
41
|
+
return { posting_id: id, status: "proposed", created: true };
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** propose_categories: a scoring row in proposed; every evidence line verbatim or the whole proposal is rejected. */
|
|
46
|
+
function proposeCategories(db, { candidate_id, posting_id, categories, now, actor }) {
|
|
47
|
+
const p = db.prepare("SELECT id, candidate_id, raw_text FROM posting WHERE id = ?").get(posting_id);
|
|
48
|
+
if (!p || p.candidate_id !== candidate_id) throw new Error("unknown posting");
|
|
49
|
+
if (!Array.isArray(categories)) throw new Error("categories must be an array");
|
|
50
|
+
const cats = categories.map((c) => ({ category: c.category, weight: c.weight, jd_evidence: c.jd_evidence, is_hard_gate: !!c.is_hard_gate }));
|
|
51
|
+
const errors = checkCategories(cats, p.raw_text);
|
|
52
|
+
if (errors.length) throw new Error("proposal rejected: " + errors.join("; "));
|
|
53
|
+
return withTx(db, (d) => {
|
|
54
|
+
const id = ulid(Date.parse(now));
|
|
55
|
+
const kb = d.prepare("SELECT kb_revision FROM candidate WHERE id = ?").get(candidate_id).kb_revision;
|
|
56
|
+
d.prepare(`INSERT INTO scoring (id, posting_id, categories_json, status, engine_version, rubric_version, kb_revision, scored_at)
|
|
57
|
+
VALUES (?, ?, ?, 'proposed', ?, ?, ?, ?)`).run(id, p.id, JSON.stringify(cats), ENGINE_VERSION, rulesVersion(), kb, now);
|
|
58
|
+
appendEvent(d, { candidate_id, entity: "scoring", entity_id: id, event: "proposed", actor_type: actor.type, actor_ref: actor.ref, payload: { posting_id: p.id, categories: cats.length, from: actor.type }, at: now });
|
|
59
|
+
return { scoring_id: id, status: "proposed", categories: cats.length };
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** kb_gap_request: a question derived from a posting requirement, for the human to answer. */
|
|
64
|
+
function requestGap(db, { candidate_id, posting_id, requirement_text, question, now, actor }) {
|
|
65
|
+
const p = db.prepare("SELECT id, candidate_id, raw_text FROM posting WHERE id = ?").get(posting_id);
|
|
66
|
+
if (!p || p.candidate_id !== candidate_id) throw new Error("unknown posting");
|
|
67
|
+
requirement_text = need(requirement_text, "requirement_text", 400);
|
|
68
|
+
question = need(question, "question", 600);
|
|
69
|
+
if (!p.raw_text.includes(requirement_text)) throw new Error("requirement_text must be a verbatim substring of the posting");
|
|
70
|
+
return withTx(db, (d) => {
|
|
71
|
+
const dup = d.prepare("SELECT id FROM kb_gap WHERE posting_id = ? AND requirement_text = ? AND resolved_at IS NULL").get(p.id, requirement_text);
|
|
72
|
+
if (dup) return { gap_id: dup.id, created: false };
|
|
73
|
+
const id = ulid(Date.parse(now));
|
|
74
|
+
d.prepare("INSERT INTO kb_gap (id, candidate_id, posting_id, requirement_text, question, created_at) VALUES (?, ?, ?, ?, ?, ?)").run(id, candidate_id, p.id, requirement_text, question, now);
|
|
75
|
+
appendEvent(d, { candidate_id, entity: "kb_gap", entity_id: id, event: "requested", actor_type: actor.type, actor_ref: actor.ref, payload: { posting_id: p.id }, at: now });
|
|
76
|
+
return { gap_id: id, created: true };
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** propose_accomplishment: a pending row the human confirms on the review screen; 20 open per candidate. */
|
|
81
|
+
function proposeAccomplishment(db, { candidate_id, draft, gap_id, now, actor }) {
|
|
82
|
+
if (!draft || typeof draft !== "object") throw new Error("draft is required");
|
|
83
|
+
const clean = {
|
|
84
|
+
title: need(draft.title, "draft.title", 200), company: need(draft.company, "draft.company", 200), role: need(draft.role, "draft.role", 200),
|
|
85
|
+
dates: typeof draft.dates === "string" ? draft.dates.slice(0, 60) : "", summary: typeof draft.summary === "string" ? draft.summary.slice(0, 1200) : "",
|
|
86
|
+
metrics: Array.isArray(draft.metrics) ? draft.metrics.slice(0, 12).map((m) => String(m).slice(0, 80)) : [],
|
|
87
|
+
verbs: Array.isArray(draft.verbs) ? draft.verbs.slice(0, 8).map((v) => String(v).slice(0, 40)) : [],
|
|
88
|
+
wordings: Array.isArray(draft.wordings) ? draft.wordings.slice(0, 6).map((w) => String(w).slice(0, 400)) : [],
|
|
89
|
+
};
|
|
90
|
+
return withTx(db, (d) => {
|
|
91
|
+
if (gap_id) {
|
|
92
|
+
const g = d.prepare("SELECT id FROM kb_gap WHERE id = ? AND candidate_id = ?").get(gap_id, candidate_id);
|
|
93
|
+
if (!g) throw new Error("unknown gap_id");
|
|
94
|
+
}
|
|
95
|
+
const id = ulid(Date.parse(now));
|
|
96
|
+
try {
|
|
97
|
+
d.prepare("INSERT INTO pending_accomplishment (id, candidate_id, draft_json, source, gap_id, proposed_at) VALUES (?, ?, ?, ?, ?, ?)")
|
|
98
|
+
.run(id, candidate_id, JSON.stringify(clean), gap_id ? "gap_answer" : "proposal", gap_id || null, now);
|
|
99
|
+
} catch (e) {
|
|
100
|
+
throw new Error(/pending queue is full/.test(e.message) ? "pending queue is full (20 open); the candidate confirms or dismisses first" : e.message);
|
|
101
|
+
}
|
|
102
|
+
appendEvent(d, { candidate_id, entity: "pending_accomplishment", entity_id: id, event: "proposed", actor_type: actor.type, actor_ref: actor.ref, payload: { title: clean.title, gap_id: gap_id || null }, at: now });
|
|
103
|
+
const open = d.prepare("SELECT count(*) c FROM pending_accomplishment WHERE candidate_id = ? AND source <> 'onboarding'").get(candidate_id).c;
|
|
104
|
+
return { pending_id: id, open, status: "pending" };
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The one gateway: kind names the proposal; nothing here can create trusted state. */
|
|
109
|
+
function ingestProposal(db, kind, args) {
|
|
110
|
+
switch (kind) {
|
|
111
|
+
case "posting": return storePosting(db, args);
|
|
112
|
+
case "categories": return proposeCategories(db, args);
|
|
113
|
+
case "gap": return requestGap(db, args);
|
|
114
|
+
case "accomplishment": return proposeAccomplishment(db, args);
|
|
115
|
+
default: throw new Error(`unknown proposal kind ${kind}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
module.exports = { ingestProposal, storePosting, proposeCategories, requestGap, proposeAccomplishment };
|