@gafj/gafj 0.1.15 → 0.1.17
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/bridge/byo_key.js +1 -1
- package/bridge/providers/anthropic.js +22 -3
- package/core/migrations/004-position.sql +24 -0
- package/core/rules/extract.md +4 -0
- package/core/schemas/extract.js +8 -0
- package/http/api.js +7 -0
- package/package.json +1 -1
- package/store/kb.js +3 -3
- package/store/migrate.js +1 -0
- package/store/onboarding.js +17 -3
- package/store/positions.js +210 -0
- package/store/setup.js +4 -1
- package/ui/screens/kb.js +59 -0
- package/ui/screens/welcome.js +4 -3
package/bridge/byo_key.js
CHANGED
|
@@ -70,7 +70,7 @@ async function runPacket(homeDir, id, { packet, fetchImpl }) {
|
|
|
70
70
|
const started = Date.now();
|
|
71
71
|
try {
|
|
72
72
|
const op = packet.operation || packet.op;
|
|
73
|
-
const r = await adapter.complete({ provider: p, system: "You are a careful writing engine. Reply with exactly one fenced json block matching OUTPUT SCHEMA and nothing else. Write the JSON compact, on one line, with no indentation and no spaces after commas or colons.", user: renderPasteText(packet), max_tokens: MAX_OUT[op] || 16000, effort: EFFORT[op], fetch: fetchImpl });
|
|
73
|
+
const r = await adapter.complete({ provider: p, system: "You are a careful writing engine. Reply with exactly one fenced json block matching OUTPUT SCHEMA and nothing else. Write the JSON compact, on one line, with no indentation and no spaces after commas or colons.", user: renderPasteText(packet), max_tokens: MAX_OUT[op] || 16000, effort: EFFORT[op], schema: packet.output_schema, fetch: fetchImpl });
|
|
74
74
|
return { content: r.text, provider: p.kind, model: r.model || p.model, tokens_in: r.tokens_in, tokens_out: r.tokens_out, latency_ms: Date.now() - started, stop_reason: r.stop_reason || null };
|
|
75
75
|
} catch (e) {
|
|
76
76
|
throw Object.assign(new Error(scrub(e.message, p.api_key)), { status: 502 });
|
|
@@ -9,12 +9,31 @@ const DEFAULT_MODEL = "claude-sonnet-5";
|
|
|
9
9
|
* passes effort ("low" for extraction) and it goes out as output_config.effort; the thinking
|
|
10
10
|
* parameter itself is never sent, which is the setting every current model accepts.
|
|
11
11
|
*/
|
|
12
|
-
|
|
12
|
+
/**
|
|
13
|
+
* The API's structured-output mode accepts a subset of JSON Schema: no length, range, or count
|
|
14
|
+
* limits, no vendor keys. This strips those so the schema the store validates against can be
|
|
15
|
+
* handed to the model as the shape it must produce. Enums, required, and additionalProperties
|
|
16
|
+
* stay, which is what makes the reply valid by construction.
|
|
17
|
+
*/
|
|
18
|
+
const DROP = new Set(["maxLength", "minLength", "minimum", "maximum", "minItems", "maxItems", "multipleOf", "pattern", "format"]);
|
|
19
|
+
function strictSchema(node) {
|
|
20
|
+
if (Array.isArray(node)) return node.map(strictSchema);
|
|
21
|
+
if (!node || typeof node !== "object") return node;
|
|
22
|
+
const out = {};
|
|
23
|
+
for (const [k, v] of Object.entries(node)) {
|
|
24
|
+
if (DROP.has(k) || k.startsWith("x-")) continue;
|
|
25
|
+
out[k] = k === "properties" ? Object.fromEntries(Object.entries(v).map(([pk, pv]) => [pk, strictSchema(pv)])) : strictSchema(v);
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function complete({ provider, system, user, max_tokens = 4000, effort, schema, fetch }) {
|
|
13
31
|
const { post } = require("./index");
|
|
14
32
|
const base = (provider.base_url || DEFAULT_URL).replace(/\/$/, "");
|
|
15
33
|
const model = provider.model || DEFAULT_MODEL;
|
|
16
34
|
const body = { model, max_tokens, system, messages: [{ role: "user", content: user }] };
|
|
17
|
-
|
|
35
|
+
const usable = schema && typeof schema === "object" && schema.type === "object" && schema.properties && Object.keys(schema.properties).length;
|
|
36
|
+
if (effort || usable) body.output_config = { ...(effort ? { effort } : {}), ...(usable ? { format: { type: "json_schema", schema: strictSchema(schema) } } : {}) };
|
|
18
37
|
const data = await post(fetch, `${base}/v1/messages`, { "x-api-key": provider.api_key || "", "anthropic-version": "2023-06-01" }, body);
|
|
19
38
|
if (data.stop_reason === "refusal") throw new Error(`the model declined this request${data.stop_details && data.stop_details.category ? ` (${data.stop_details.category})` : ""}`);
|
|
20
39
|
const text = (data.content || []).filter((c) => c.type === "text").map((c) => c.text).join("\n");
|
|
@@ -29,4 +48,4 @@ async function listModels({ provider, fetch }) {
|
|
|
29
48
|
return (data.data || []).map((m) => ({ id: m.id, name: m.display_name || m.id, created: m.created_at || null }));
|
|
30
49
|
}
|
|
31
50
|
|
|
32
|
-
module.exports = { complete, listModels, DEFAULT_URL, DEFAULT_MODEL };
|
|
51
|
+
module.exports = { complete, listModels, strictSchema, DEFAULT_URL, DEFAULT_MODEL };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
-- v4: positions. One row per job (employer and role), and likewise per school, volunteer role,
|
|
2
|
+
-- award, certification, or affiliation, proposed from what the model quoted and confirmed once;
|
|
3
|
+
-- the records under a job inherit its company, role, and dates, so a tenure is corrected in one
|
|
4
|
+
-- place instead of on thirty bullets. The other kinds carry no records; they are the resume's
|
|
5
|
+
-- own sections, kept as facts the candidate confirmed.
|
|
6
|
+
CREATE TABLE position (
|
|
7
|
+
id TEXT PRIMARY KEY,
|
|
8
|
+
candidate_id TEXT NOT NULL REFERENCES candidate(id),
|
|
9
|
+
kind TEXT NOT NULL DEFAULT 'employment' CHECK (kind IN ('employment', 'education', 'volunteer', 'award', 'certification', 'affiliation')),
|
|
10
|
+
company TEXT NOT NULL,
|
|
11
|
+
role TEXT NOT NULL,
|
|
12
|
+
location TEXT,
|
|
13
|
+
date_start TEXT,
|
|
14
|
+
date_end TEXT,
|
|
15
|
+
dates_text TEXT,
|
|
16
|
+
detail TEXT,
|
|
17
|
+
status TEXT NOT NULL CHECK (status IN ('proposed', 'confirmed')),
|
|
18
|
+
source_document_id TEXT REFERENCES source_document(id) ON DELETE SET NULL,
|
|
19
|
+
created_at TEXT NOT NULL,
|
|
20
|
+
confirmed_at TEXT
|
|
21
|
+
);
|
|
22
|
+
CREATE INDEX position_candidate ON position(candidate_id);
|
|
23
|
+
ALTER TABLE pending_accomplishment ADD COLUMN position_id TEXT REFERENCES position(id) ON DELETE SET NULL;
|
|
24
|
+
ALTER TABLE accomplishment ADD COLUMN position_id TEXT REFERENCES position(id) ON DELETE SET NULL;
|
package/core/rules/extract.md
CHANGED
|
@@ -22,6 +22,10 @@ The ownership verb is the lead verb of the wording, in the form the source uses.
|
|
|
22
22
|
|
|
23
23
|
The bullet or sentence that states the result, verbatim; usually one, at most three when the source states the same result more than once. The first wording is the one you would recommend. Never repeat a wording across records. Keep the source's punctuation.
|
|
24
24
|
|
|
25
|
+
## The other sections
|
|
26
|
+
|
|
27
|
+
Education, volunteer and community work, awards, certifications and licences, memberships and affiliations go in `entries`, one each, never as accomplishments. `org` is the school, organisation, or issuer as written; `title` the degree, role, award, or credential as written; `dates` as written; `detail` one quoted line if the source gives one (honours, GPA, a description). Quote each; leave out what the source does not state. Employment itself is not an entry: it is carried by the accomplishments' company, role, and dates.
|
|
28
|
+
|
|
25
29
|
## Style observed
|
|
26
30
|
|
|
27
31
|
Report what the source does: dashes (none, hyphens, em dashes, mixed), how it spells ecommerce, whether percent is a symbol or a word. Observations only; the candidate chooses their house rules.
|
package/core/schemas/extract.js
CHANGED
|
@@ -25,6 +25,14 @@ module.exports = obj({
|
|
|
25
25
|
scope: ATOM,
|
|
26
26
|
tags: arr(str("identifier", { max: 40 }), 0, 8),
|
|
27
27
|
}, ["title", "company", "role", "wordings"]), 1, 40),
|
|
28
|
+
// the resume's other sections, each an org, a title, dates, and one line of detail, all quoted
|
|
29
|
+
entries: arr(obj({
|
|
30
|
+
kind: str("identifier", { enum: ["education", "volunteer", "award", "certification", "affiliation"] }),
|
|
31
|
+
org: ATOM,
|
|
32
|
+
title: ATOM,
|
|
33
|
+
dates: ATOM,
|
|
34
|
+
detail: ATOM,
|
|
35
|
+
}, ["kind", "org", "title"]), 0, 30),
|
|
28
36
|
style_observed: obj({
|
|
29
37
|
dashes: str("identifier", { enum: ["none", "hyphens", "em_dashes", "mixed"] }),
|
|
30
38
|
ecommerce_spelling: str("identifier", { max: 20 }),
|
package/http/api.js
CHANGED
|
@@ -143,6 +143,13 @@ route("POST", "/api/postings/:id/freeze", (c, p, b) => freezeScoring(c.db, { pos
|
|
|
143
143
|
const kb = require("../store/kb");
|
|
144
144
|
route("GET", "/api/kb", (c) => ({ ...kb.summary(c.db, c.candidate_id), gaps: kb.listGaps(c.db, c.candidate_id), holes: kb.listHoles(c.db, c.candidate_id), questions: require("../store/discover").listQuestions(c.db, c.candidate_id), pending: kb.listPending(c.db, c.candidate_id), review: listReview(c.db, c.candidate_id, {}) }));
|
|
145
145
|
route("POST", "/api/kb/gaps/:id/answer", (c, p, b) => kb.answerGap(c.db, { candidate_id: c.candidate_id, gap_id: p.id, draft: need(b, "draft"), now: c.now(), actor: ACTOR("answer_gap") }));
|
|
146
|
+
const positions = require("../store/positions");
|
|
147
|
+
route("GET", "/api/kb/positions", (c) => positions.list(c.db, c.candidate_id, { now: c.now() }));
|
|
148
|
+
route("POST", "/api/kb/positions", (c, p, b) => positions.create(c.db, { candidate_id: c.candidate_id, kind: b.kind || "employment", edits: b.edits || b, now: c.now(), actor: ACTOR("write_position") }));
|
|
149
|
+
route("POST", "/api/kb/positions/merge", (c, p, b) => positions.merge(c.db, { candidate_id: c.candidate_id, keep_id: need(b, "keep_id"), drop_id: need(b, "drop_id"), now: c.now(), actor: ACTOR("merge_position") }));
|
|
150
|
+
route("POST", "/api/kb/positions/:id", (c, p, b) => positions.update(c.db, { candidate_id: c.candidate_id, position_id: p.id, edits: b.edits || b, confirm: !!b.confirm, now: c.now(), actor: ACTOR("edit_position") }));
|
|
151
|
+
route("POST", "/api/kb/positions/:id/confirm", (c, p, b) => positions.update(c.db, { candidate_id: c.candidate_id, position_id: p.id, edits: b.edits || {}, confirm: true, now: c.now(), actor: ACTOR("confirm_position") }));
|
|
152
|
+
route("DELETE", "/api/kb/positions/:id", (c, p) => positions.remove(c.db, { candidate_id: c.candidate_id, position_id: p.id, now: c.now(), actor: ACTOR("remove_position") }));
|
|
146
153
|
route("POST", "/api/kb/pending", (c, p, b) => kb.proposeManual(c.db, { candidate_id: c.candidate_id, draft: need(b, "draft"), now: c.now(), actor: ACTOR("propose_manual") }));
|
|
147
154
|
route("POST", "/api/kb/pending/:id/confirm", (c, p, b) => kb.confirmPending(c.db, { candidate_id: c.candidate_id, pending_id: p.id, edits: b.edits || {}, now: c.now(), actor: ACTOR("confirm_accomplishment") }));
|
|
148
155
|
route("POST", "/api/kb/pending/:id/dismiss", (c, p) => kb.dismissPending(c.db, { candidate_id: c.candidate_id, pending_id: p.id, now: c.now(), actor: ACTOR("dismiss_pending") }));
|
package/package.json
CHANGED
package/store/kb.js
CHANGED
|
@@ -111,9 +111,9 @@ function confirmPending(db, { candidate_id, pending_id, edits, now, actor }) {
|
|
|
111
111
|
const verbSet = new Map(extractVerbs(wordings).map((v) => [v.verb, { ...v, review_state: "confirmed" }]));
|
|
112
112
|
for (const v of draft.verbs) if (!verbSet.has(v)) verbSet.set(v, { verb: v, tier: tier(v), source_variation_ids: [], review_state: "confirmed" });
|
|
113
113
|
const [ds, de] = draft.dates.split(/\s+(?:to|-|–)\s+/).map((x) => x && x.trim());
|
|
114
|
-
d.prepare(`INSERT INTO accomplishment (id, candidate_id, title, company, role, date_start, date_end, summary, metrics_json, verbatim_claims_json, verbs_json, subjects_json, wordings_json, tags_json, strength_score, source, confirmed_at)
|
|
115
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '[]', ?, ?, NULL, 'confirmed_pending', ?)`)
|
|
116
|
-
.run(id, candidate_id, draft.title, draft.company, draft.role, ds || null, de || null, draft.summary || null, JSON.stringify(metrics), JSON.stringify(claims), JSON.stringify([...verbSet.values()]), JSON.stringify(wordings), JSON.stringify(draft.tags), now);
|
|
114
|
+
d.prepare(`INSERT INTO accomplishment (id, candidate_id, title, company, role, date_start, date_end, summary, metrics_json, verbatim_claims_json, verbs_json, subjects_json, wordings_json, tags_json, strength_score, source, confirmed_at, position_id)
|
|
115
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '[]', ?, ?, NULL, 'confirmed_pending', ?, ?)`)
|
|
116
|
+
.run(id, candidate_id, draft.title, draft.company, draft.role, ds || null, de || null, draft.summary || null, JSON.stringify(metrics), JSON.stringify(claims), JSON.stringify([...verbSet.values()]), JSON.stringify(wordings), JSON.stringify(draft.tags), now, p.position_id || null);
|
|
117
117
|
d.prepare("DELETE FROM pending_accomplishment WHERE id = ?").run(p.id);
|
|
118
118
|
d.prepare("UPDATE candidate SET kb_revision = kb_revision + 1 WHERE id = ?").run(candidate_id);
|
|
119
119
|
appendEvent(d, { candidate_id, entity: "accomplishment", entity_id: id, event: "confirmed", actor_type: actor.type, actor_ref: actor.ref, payload: { pending_id: p.id, source: p.source, metrics: metrics.length, verbs: verbSet.size }, at: now });
|
package/store/migrate.js
CHANGED
|
@@ -15,6 +15,7 @@ const MIGRATIONS = [
|
|
|
15
15
|
{ version: 1, file: path.join(__dirname, "..", "core", "schema.sql") },
|
|
16
16
|
{ version: 2, file: path.join(__dirname, "..", "core", "migrations", "002-profile.sql") },
|
|
17
17
|
{ version: 3, file: path.join(__dirname, "..", "core", "migrations", "003-discover.sql") },
|
|
18
|
+
{ version: 4, file: path.join(__dirname, "..", "core", "migrations", "004-position.sql") },
|
|
18
19
|
];
|
|
19
20
|
|
|
20
21
|
const CURRENT_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|
package/store/onboarding.js
CHANGED
|
@@ -111,7 +111,9 @@ function checkAtom(atom, text, label, errors) {
|
|
|
111
111
|
let hits = [];
|
|
112
112
|
if (v.length) for (let i = text.indexOf(v); i !== -1; i = text.indexOf(v, i + 1)) hits.push([i, i + v.length]);
|
|
113
113
|
if (!hits.length && v.trim()) {
|
|
114
|
-
|
|
114
|
+
// the model tends to straighten quotes and dashes; match either form in the source
|
|
115
|
+
const cls = (ch) => (/['\u2018\u2019]/.test(ch) ? "['\u2018\u2019]" : /["\u201c\u201d]/.test(ch) ? "[\"\u201c\u201d]" : /[-\u2013\u2014]/.test(ch) ? "[-\u2013\u2014]" : ch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
|
116
|
+
const re = new RegExp(v.trim().split(/\s+/).map((w) => Array.from(w).map(cls).join("")).join("\\s+"), "g");
|
|
115
117
|
for (let m = re.exec(text); m; m = re.exec(text)) { hits.push([m.index, m.index + m[0].length]); if (!m[0].length) break; }
|
|
116
118
|
}
|
|
117
119
|
if (!hits.length) { errors.push(`${label}: "${v.slice(0, 40)}" is not the source text at [${start}, ${end})`); return; }
|
|
@@ -130,6 +132,7 @@ function normalizeAtoms(content) {
|
|
|
130
132
|
if (Array.isArray(a.wordings)) a.wordings = a.wordings.map(q);
|
|
131
133
|
if (Array.isArray(a.metrics)) a.metrics = a.metrics.map((m) => (m && typeof m === "object" ? { value: String(m.value), ...(m.outcome !== undefined ? { outcome: m.outcome } : {}) } : m));
|
|
132
134
|
}
|
|
135
|
+
if (Array.isArray(content.entries)) for (const e of content.entries) { if (e && typeof e === "object") for (const k of ["org", "title", "dates", "detail"]) if (k in e) e[k] = q(e[k]); }
|
|
133
136
|
return content;
|
|
134
137
|
}
|
|
135
138
|
|
|
@@ -145,6 +148,14 @@ function checkDraft(content, text) {
|
|
|
145
148
|
a.wordings = (a.wordings || []).map((w) => (typeof w === "string" ? { value: w, start: -1, end: -1 } : w));
|
|
146
149
|
a.metrics = (a.metrics || []).map((m) => ({ ...m, start: -1, end: -1 }));
|
|
147
150
|
}
|
|
151
|
+
for (const e of content.entries || []) for (const k of ["org", "title", "dates", "detail"]) if (typeof e[k] === "string") e[k] = { value: e[k], start: -1, end: -1 };
|
|
152
|
+
(content.entries || []).forEach((e, i) => {
|
|
153
|
+
const p = `entries[${i}]`;
|
|
154
|
+
checkAtom(e.org, text, `${p}.org`, errors);
|
|
155
|
+
checkAtom(e.title, text, `${p}.title`, errors);
|
|
156
|
+
if (e.dates) checkAtom(e.dates, text, `${p}.dates`, errors);
|
|
157
|
+
if (e.detail) checkAtom(e.detail, text, `${p}.detail`, errors);
|
|
158
|
+
});
|
|
148
159
|
content.accomplishments.forEach((a, i) => {
|
|
149
160
|
const p = `accomplishments[${i}]`;
|
|
150
161
|
checkAtom(a.company, text, `${p}.company`, errors);
|
|
@@ -183,8 +194,11 @@ function proposeFromSource(db, { candidate_id, source_document_id, content, ai_a
|
|
|
183
194
|
}
|
|
184
195
|
ids.push(id);
|
|
185
196
|
}
|
|
186
|
-
|
|
187
|
-
|
|
197
|
+
const positions = require("./positions");
|
|
198
|
+
positions.derive(d, { candidate_id, now });
|
|
199
|
+
const entries = positions.addEntries(d, { candidate_id, source_document_id: s.id, entries: (content.entries || []).map((e) => ({ kind: e.kind, org: e.org.value, title: e.title.value, dates: e.dates ? e.dates.value : "", detail: e.detail ? e.detail.value : "" })), now });
|
|
200
|
+
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, entries, style: content.style_observed || null }, at: now });
|
|
201
|
+
return { accepted: true, errors: [], pending_ids: ids, entries, style_observed: content.style_observed || null };
|
|
188
202
|
});
|
|
189
203
|
}
|
|
190
204
|
const span = (a) => (a ? { start: a.start, end: a.end } : null);
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Employment positions: one row per employer and role. Proposed from the company, role, and
|
|
5
|
+
* dates the model quoted on each pending record, confirmed once by the candidate, and the
|
|
6
|
+
* records under a position inherit its company, role, and dates whenever it is edited. The
|
|
7
|
+
* link is by a normalised (company, role) key, so a record proposed later, or written by hand,
|
|
8
|
+
* attaches to the position it belongs to the next time the list is read.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const { ulid } = require("../core/ulid");
|
|
12
|
+
const { withTx } = require("./tx");
|
|
13
|
+
const { appendEvent } = require("./events");
|
|
14
|
+
const { TransitionError, NotFound } = require("./transitions");
|
|
15
|
+
|
|
16
|
+
const MONTHS = { jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6, jul: 7, aug: 8, sep: 9, sept: 9, oct: 10, nov: 11, dec: 12 };
|
|
17
|
+
const norm = (s) => String(s || "").toLowerCase().replace(/[^a-z0-9]+/g, " ").replace(/\b(inc|llc|corp|corporation|ltd|co|company)\b/g, "").replace(/\s+/g, " ").trim();
|
|
18
|
+
const keyOf = (company, role, kind = "employment") => kind + "|" + norm(company) + "|" + norm(role);
|
|
19
|
+
const KINDS = ["employment", "education", "volunteer", "award", "certification", "affiliation"];
|
|
20
|
+
|
|
21
|
+
/** "Nov 2022 – Oct 2023", "2019-2021", "March 2020 to Present", "2018" → { start: "2022-11", end: "2023-10" | null }. Unreadable text gives nulls. */
|
|
22
|
+
function parseDates(text) {
|
|
23
|
+
const s = String(text || "").toLowerCase();
|
|
24
|
+
const points = [];
|
|
25
|
+
const re = /\b(?:(jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)[a-z]*\.?\s+(\d{4})|(\d{4})-(\d{2})\b|(\d{1,2})\/(\d{4})|(\d{4}))\b/g;
|
|
26
|
+
for (let m = re.exec(s); m; m = re.exec(s)) {
|
|
27
|
+
if (m[1]) points.push(`${m[2]}-${String(MONTHS[m[1]]).padStart(2, "0")}`);
|
|
28
|
+
else if (m[3]) points.push(`${m[3]}-${m[4]}`);
|
|
29
|
+
else if (m[5]) points.push(`${m[6]}-${m[5].padStart(2, "0")}`);
|
|
30
|
+
else points.push(m[7]);
|
|
31
|
+
}
|
|
32
|
+
const present = /\b(present|current|now|today)\b/.test(s);
|
|
33
|
+
if (!points.length) return { start: null, end: null, present };
|
|
34
|
+
if (points.length === 1) return { start: points[0], end: present ? null : points[0], present };
|
|
35
|
+
return { start: points[0], end: present ? null : points[points.length - 1], present };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const datesText = (start, end) => (start ? `${start} to ${end || "present"}` : "");
|
|
39
|
+
|
|
40
|
+
/** Attach every unlinked record (pending or confirmed) to a position, creating proposed positions where none matches. Returns the number linked. */
|
|
41
|
+
function derive(db, { candidate_id, now }) {
|
|
42
|
+
return withTx(db, (d) => {
|
|
43
|
+
const have = d.prepare("SELECT * FROM position WHERE candidate_id = ? AND kind = 'employment'").all(candidate_id);
|
|
44
|
+
const byKey = new Map(have.map((p) => [keyOf(p.company, p.role), p]));
|
|
45
|
+
let linked = 0;
|
|
46
|
+
const attach = (company, role, dates, source_document_id, link) => {
|
|
47
|
+
const key = keyOf(company, role);
|
|
48
|
+
if (!norm(company) && !norm(role)) return;
|
|
49
|
+
let pos = byKey.get(key);
|
|
50
|
+
const parsed = parseDates(dates);
|
|
51
|
+
if (!pos) {
|
|
52
|
+
pos = { id: ulid(Date.parse(now) + have.length + byKey.size), candidate_id, company: String(company).trim(), role: String(role).trim(), location: null, date_start: parsed.start, date_end: parsed.end, dates_text: dates || null, status: "proposed", source_document_id: source_document_id || null, created_at: now, confirmed_at: null };
|
|
53
|
+
d.prepare("INSERT INTO position (id, candidate_id, company, role, location, date_start, date_end, dates_text, status, source_document_id, created_at) VALUES (?, ?, ?, ?, NULL, ?, ?, ?, 'proposed', ?, ?)")
|
|
54
|
+
.run(pos.id, candidate_id, pos.company, pos.role, pos.date_start, pos.date_end, pos.dates_text, pos.source_document_id, now);
|
|
55
|
+
byKey.set(key, pos);
|
|
56
|
+
} else if (pos.status === "proposed" && !pos.date_start && parsed.start) {
|
|
57
|
+
// a proposed position with no dates takes the first dated record's
|
|
58
|
+
d.prepare("UPDATE position SET date_start = ?, date_end = ?, dates_text = ? WHERE id = ?").run(parsed.start, parsed.end, dates || null, pos.id);
|
|
59
|
+
Object.assign(pos, { date_start: parsed.start, date_end: parsed.end, dates_text: dates || null });
|
|
60
|
+
}
|
|
61
|
+
link(pos.id);
|
|
62
|
+
linked++;
|
|
63
|
+
};
|
|
64
|
+
for (const r of d.prepare("SELECT id, draft_json, source_document_id FROM pending_accomplishment WHERE candidate_id = ? AND position_id IS NULL ORDER BY proposed_at").all(candidate_id)) {
|
|
65
|
+
const draft = JSON.parse(r.draft_json);
|
|
66
|
+
if (draft.replaces) continue; // an answer extends a record; it follows that record's position
|
|
67
|
+
attach(draft.company, draft.role, draft.dates, r.source_document_id, (pid) => d.prepare("UPDATE pending_accomplishment SET position_id = ? WHERE id = ?").run(pid, r.id));
|
|
68
|
+
}
|
|
69
|
+
for (const a of d.prepare("SELECT id, company, role, date_start, date_end FROM accomplishment WHERE candidate_id = ? AND position_id IS NULL ORDER BY confirmed_at").all(candidate_id)) {
|
|
70
|
+
attach(a.company, a.role, [a.date_start, a.date_end].filter(Boolean).join(" to "), null, (pid) => d.prepare("UPDATE accomplishment SET position_id = ? WHERE id = ?").run(pid, a.id));
|
|
71
|
+
}
|
|
72
|
+
// a proposed position that nothing points at any more (its records were removed with their source) goes away
|
|
73
|
+
d.prepare("DELETE FROM position WHERE candidate_id = ? AND kind = 'employment' AND status = 'proposed' AND id NOT IN (SELECT position_id FROM pending_accomplishment WHERE position_id IS NOT NULL UNION SELECT position_id FROM accomplishment WHERE position_id IS NOT NULL)").run(candidate_id);
|
|
74
|
+
return linked;
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The resume's other sections, quoted by the model: one position per (kind, org, title); a repeat from another resume is skipped. Returns the number added. */
|
|
79
|
+
function addEntries(db, { candidate_id, source_document_id, entries, now }) {
|
|
80
|
+
return withTx(db, (d) => {
|
|
81
|
+
const have = new Set(d.prepare("SELECT kind, company, role FROM position WHERE candidate_id = ? AND kind <> 'employment'").all(candidate_id).map((p) => keyOf(p.company, p.role, p.kind)));
|
|
82
|
+
let added = 0;
|
|
83
|
+
for (const e of entries || []) {
|
|
84
|
+
if (!KINDS.includes(e.kind) || e.kind === "employment" || !norm(e.org) || !norm(e.title)) continue;
|
|
85
|
+
const key = keyOf(e.org, e.title, e.kind);
|
|
86
|
+
if (have.has(key)) continue;
|
|
87
|
+
const parsed = parseDates(e.dates);
|
|
88
|
+
d.prepare("INSERT INTO position (id, candidate_id, kind, company, role, location, date_start, date_end, dates_text, detail, status, source_document_id, created_at) VALUES (?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, 'proposed', ?, ?)")
|
|
89
|
+
.run(ulid(Date.parse(now) + 500 + added), candidate_id, e.kind, String(e.org).trim().slice(0, 120), String(e.title).trim().slice(0, 160), parsed.start, parsed.end, e.dates || null, String(e.detail || "").trim().slice(0, 300) || null, source_document_id || null, now);
|
|
90
|
+
have.add(key);
|
|
91
|
+
added++;
|
|
92
|
+
}
|
|
93
|
+
return added;
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Positions newest first, each with its record counts. Reads derive first, so the list is never behind the records. */
|
|
98
|
+
function list(db, candidate_id, { now = new Date().toISOString() } = {}) {
|
|
99
|
+
derive(db, { candidate_id, now });
|
|
100
|
+
return db.prepare(`SELECT p.*,
|
|
101
|
+
(SELECT count(*) FROM pending_accomplishment x WHERE x.position_id = p.id) AS pending,
|
|
102
|
+
(SELECT count(*) FROM accomplishment a WHERE a.position_id = p.id) AS confirmed
|
|
103
|
+
FROM position p WHERE p.candidate_id = ? ORDER BY p.kind = 'employment' DESC, p.kind, p.date_start IS NULL, p.date_start DESC, p.created_at`).all(candidate_id);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const DATE_RE = /^\d{4}(-\d{2})?$/;
|
|
107
|
+
function cleanEdits(edits, current) {
|
|
108
|
+
const out = { ...current };
|
|
109
|
+
const str = (k, max) => { if (edits[k] === undefined) return; out[k] = String(edits[k] || "").trim().slice(0, max) || null; };
|
|
110
|
+
str("company", 120); str("role", 160); str("location", 120); str("detail", 300);
|
|
111
|
+
if (!out.company) throw new TransitionError("company is required");
|
|
112
|
+
if (!out.role) throw new TransitionError("role is required");
|
|
113
|
+
for (const k of ["date_start", "date_end"]) {
|
|
114
|
+
if (edits[k] === undefined) continue;
|
|
115
|
+
const v = String(edits[k] || "").trim();
|
|
116
|
+
if (v && !DATE_RE.test(v)) {
|
|
117
|
+
const p = parseDates(v);
|
|
118
|
+
if (!p.start) throw new TransitionError(`${k}: write a year or year-month, like 2022 or 2022-11`);
|
|
119
|
+
out[k] = p.start;
|
|
120
|
+
} else out[k] = v || null;
|
|
121
|
+
}
|
|
122
|
+
if (out.date_start && out.date_end && out.date_end < out.date_start) throw new TransitionError("the end date is before the start");
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Push a position's company, role, and dates onto every record under it. */
|
|
127
|
+
function propagate(d, pos) {
|
|
128
|
+
const dates = datesText(pos.date_start, pos.date_end);
|
|
129
|
+
let changed = 0;
|
|
130
|
+
for (const r of d.prepare("SELECT id, draft_json FROM pending_accomplishment WHERE position_id = ?").all(pos.id)) {
|
|
131
|
+
const draft = JSON.parse(r.draft_json);
|
|
132
|
+
if (draft.company === pos.company && draft.role === pos.role && (draft.dates || "") === dates) continue;
|
|
133
|
+
d.prepare("UPDATE pending_accomplishment SET draft_json = ? WHERE id = ?").run(JSON.stringify({ ...draft, company: pos.company, role: pos.role, dates }), r.id);
|
|
134
|
+
changed++;
|
|
135
|
+
}
|
|
136
|
+
const r = d.prepare("UPDATE accomplishment SET company = ?, role = ?, date_start = ?, date_end = ? WHERE position_id = ? AND (company <> ? OR role <> ? OR date_start IS NOT ? OR date_end IS NOT ?)")
|
|
137
|
+
.run(pos.company, pos.role, pos.date_start, pos.date_end, pos.id, pos.company, pos.role, pos.date_start, pos.date_end);
|
|
138
|
+
if (r.changes) d.prepare("UPDATE candidate SET kb_revision = kb_revision + 1 WHERE id = ?").run(pos.candidate_id);
|
|
139
|
+
return changed + r.changes;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function get(d, candidate_id, position_id) {
|
|
143
|
+
const p = d.prepare("SELECT * FROM position WHERE id = ? AND candidate_id = ?").get(position_id, candidate_id);
|
|
144
|
+
if (!p) throw new NotFound("position");
|
|
145
|
+
return p;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Edit a position; the records under it follow. With confirm, it is marked confirmed as well. */
|
|
149
|
+
function update(db, { candidate_id, position_id, edits = {}, confirm = false, now, actor }) {
|
|
150
|
+
return withTx(db, (d) => {
|
|
151
|
+
const cur = get(d, candidate_id, position_id);
|
|
152
|
+
const next = cleanEdits(edits, cur);
|
|
153
|
+
if (confirm) { next.status = "confirmed"; next.confirmed_at = now; }
|
|
154
|
+
d.prepare("UPDATE position SET company = ?, role = ?, location = ?, date_start = ?, date_end = ?, detail = ?, status = ?, confirmed_at = ? WHERE id = ?")
|
|
155
|
+
.run(next.company, next.role, next.location, next.date_start, next.date_end, next.detail, next.status, next.confirmed_at, position_id);
|
|
156
|
+
const records = propagate(d, next);
|
|
157
|
+
appendEvent(d, { candidate_id, entity: "position", entity_id: position_id, event: confirm ? "confirmed" : "edited", actor_type: actor.type, actor_ref: actor.ref, payload: { records }, at: now });
|
|
158
|
+
return { position_id, status: next.status, records };
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Two spellings of one job: the records under drop move to keep and take its details; drop goes away. */
|
|
163
|
+
function merge(db, { candidate_id, keep_id, drop_id, now, actor }) {
|
|
164
|
+
return withTx(db, (d) => {
|
|
165
|
+
if (keep_id === drop_id) throw new TransitionError("pick a different position to merge into");
|
|
166
|
+
const keep = get(d, candidate_id, keep_id);
|
|
167
|
+
const drop = get(d, candidate_id, drop_id);
|
|
168
|
+
if (keep.kind !== drop.kind) throw new TransitionError("these are different kinds of entry");
|
|
169
|
+
d.prepare("UPDATE pending_accomplishment SET position_id = ? WHERE position_id = ?").run(keep_id, drop_id);
|
|
170
|
+
d.prepare("UPDATE accomplishment SET position_id = ? WHERE position_id = ?").run(keep_id, drop_id);
|
|
171
|
+
d.prepare("DELETE FROM position WHERE id = ?").run(drop_id);
|
|
172
|
+
const records = propagate(d, keep);
|
|
173
|
+
appendEvent(d, { candidate_id, entity: "position", entity_id: keep_id, event: "merged", actor_type: actor.type, actor_ref: actor.ref, payload: { dropped: drop_id, records }, at: now });
|
|
174
|
+
return { position_id: keep_id, records };
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** One written by hand: confirmed on arrival, since the candidate typed it. */
|
|
179
|
+
function create(db, { candidate_id, kind = "employment", edits = {}, now, actor }) {
|
|
180
|
+
if (!KINDS.includes(kind)) throw new TransitionError("unknown kind");
|
|
181
|
+
return withTx(db, (d) => {
|
|
182
|
+
const next = cleanEdits(edits, { company: null, role: null, location: null, date_start: null, date_end: null, detail: null });
|
|
183
|
+
const id = ulid(Date.parse(now) + 900);
|
|
184
|
+
d.prepare("INSERT INTO position (id, candidate_id, kind, company, role, location, date_start, date_end, dates_text, detail, status, source_document_id, created_at, confirmed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, 'confirmed', NULL, ?, ?)")
|
|
185
|
+
.run(id, candidate_id, kind, next.company, next.role, next.location, next.date_start, next.date_end, next.detail, now, now);
|
|
186
|
+
appendEvent(d, { candidate_id, entity: "position", entity_id: id, event: "written", actor_type: actor.type, actor_ref: actor.ref, payload: { kind }, at: now });
|
|
187
|
+
return { position_id: id, status: "confirmed" };
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Remove a position nothing points at. */
|
|
192
|
+
function remove(db, { candidate_id, position_id, now, actor }) {
|
|
193
|
+
return withTx(db, (d) => {
|
|
194
|
+
get(d, candidate_id, position_id);
|
|
195
|
+
const n = d.prepare("SELECT (SELECT count(*) FROM pending_accomplishment WHERE position_id = ?) + (SELECT count(*) FROM accomplishment WHERE position_id = ?) AS c").get(position_id, position_id).c;
|
|
196
|
+
if (n) throw new TransitionError(`${n} record${n === 1 ? "" : "s"} still under this position; merge it into another instead`);
|
|
197
|
+
d.prepare("DELETE FROM position WHERE id = ?").run(position_id);
|
|
198
|
+
appendEvent(d, { candidate_id, entity: "position", entity_id: position_id, event: "removed", actor_type: actor.type, actor_ref: actor.ref, payload: {}, at: now });
|
|
199
|
+
return { removed: true };
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Counts for the setup card: how many positions, how many still proposed. */
|
|
204
|
+
function counts(db, candidate_id, { now } = {}) {
|
|
205
|
+
derive(db, { candidate_id, now: now || new Date().toISOString() });
|
|
206
|
+
const r = db.prepare("SELECT count(*) AS total, sum(status = 'proposed') AS proposed FROM position WHERE candidate_id = ?").get(candidate_id);
|
|
207
|
+
return { total: r.total, proposed: r.proposed || 0 };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
module.exports = { derive, addEntries, list, update, merge, remove, create, counts, parseDates, keyOf, KINDS };
|
package/store/setup.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* The guided setup (first run):
|
|
4
|
+
* The guided setup (first run): seven steps in the order they must happen,
|
|
5
5
|
* each computed from the store, so the checklist is never out of date and
|
|
6
6
|
* nothing is stored except two flags in config: which AI route the user
|
|
7
7
|
* chose on purpose (the default is paste, which says nothing), and whether
|
|
@@ -25,10 +25,13 @@ function setupView(db, homeDir, candidate_id) {
|
|
|
25
25
|
const providers = cfg.providers || [];
|
|
26
26
|
const usable = cfg.route !== "byo_key" || !!(cfg.active_provider_id && providers.some((x) => x.id === cfg.active_provider_id)) || providers.length === 1;
|
|
27
27
|
const aiChosen = !!(cfg.setup && cfg.setup.ai_chosen) && usable;
|
|
28
|
+
const pos = require("./positions").counts(db, candidate_id);
|
|
29
|
+
const posDone = pos.total > 0 && pos.proposed === 0;
|
|
28
30
|
const steps = [
|
|
29
31
|
{ key: "ai", title: "Tell GAF-J which AI you use", done: aiChosen, detail: aiChosen ? `Using ${ROUTE_LABEL[cfg.route] || cfg.route}` : null },
|
|
30
32
|
{ key: "upload", title: "Upload your resumes", done: sources > 0, detail: sources ? `${sources} file${sources === 1 ? "" : "s"} uploaded` : null },
|
|
31
33
|
{ key: "extract", title: "Extract records from them", done: extracted > 0 || pending > 0 || confirmed > 0, detail: extracted ? `${extracted} extraction${extracted === 1 ? "" : "s"} saved` : null },
|
|
34
|
+
{ key: "positions", title: "Confirm your jobs, schools, and awards", done: posDone, detail: pos.total ? (posDone ? `${pos.total} position${pos.total === 1 ? "" : "s"} confirmed` : `${pos.proposed} of ${pos.total} to confirm`) : null },
|
|
32
35
|
{ key: "confirm", title: "Confirm the records you stand behind", done: confirmed > 0, detail: confirmed ? `${confirmed} confirmed${pending ? `, ${pending} still pending` : ""}` : (pending ? `${pending} waiting for you` : null) },
|
|
33
36
|
{ key: "discover", title: "Answer what's missing", done: questions > 0, detail: questions ? `${questions} question${questions === 1 ? "" : "s"} asked` : null },
|
|
34
37
|
{ key: "posting", title: "Paste your first job posting", done: postings > 0, detail: postings ? `${postings} posting${postings === 1 ? "" : "s"}` : null },
|
package/ui/screens/kb.js
CHANGED
|
@@ -90,6 +90,63 @@ function Excerpt({ text, span }) {
|
|
|
90
90
|
const EMPTY = { title: "", company: "", role: "", dates: "", summary: "", metrics: "", verbs: "", wordings: "", tenure: "" };
|
|
91
91
|
const toDraft = (f) => ({ title: f.title, company: f.company, role: f.role, dates: f.dates, summary: f.summary,
|
|
92
92
|
metrics: f.metrics.split(/[;,]/).map((s) => s.trim()).filter(Boolean), verbs: f.verbs.split(/[;,]/).map((s) => s.trim()).filter(Boolean), wordings: f.wordings.split(/\n/).map((s) => s.trim()).filter(Boolean), ...(f.tenure && f.tenure.trim() ? { tenure: f.tenure.trim() } : {}) });
|
|
93
|
+
/** Positions: jobs, schools, volunteer roles, awards, certifications, affiliations. One row each, confirmed once; a job's records inherit its company, role, and dates. */
|
|
94
|
+
const KIND = {
|
|
95
|
+
employment: { heading: "Jobs", org: "company", title: "title", records: true, where: true },
|
|
96
|
+
education: { heading: "Education", org: "school", title: "degree or program", where: true, detail: "honors, GPA, focus" },
|
|
97
|
+
volunteer: { heading: "Volunteer and community", org: "organization", title: "role", where: true, detail: "what you did" },
|
|
98
|
+
award: { heading: "Awards", org: "issuer", title: "award", detail: "for what" },
|
|
99
|
+
certification: { heading: "Certifications and licenses", org: "issuer", title: "credential", detail: "number or scope" },
|
|
100
|
+
affiliation: { heading: "Affiliations and memberships", org: "organization", title: "role or membership", detail: "" },
|
|
101
|
+
};
|
|
102
|
+
export function Positions({ reload, setErr, compact }) {
|
|
103
|
+
const [rows, setRows] = useState(null);
|
|
104
|
+
const [edits, setEdits] = useState({});
|
|
105
|
+
const [busy, setBusy] = useState("");
|
|
106
|
+
const [adding, setAdding] = useState(null); // a kind being written by hand
|
|
107
|
+
const load = () => api.get("/api/kb/positions").then((r) => { setRows(r); setEdits({}); }).catch((e) => setErr(e.message));
|
|
108
|
+
useEffect(() => { load(); }, []);
|
|
109
|
+
if (!rows) return html`<p class="muted">loading</p>`;
|
|
110
|
+
const val = (p, k) => (edits[p.id] && edits[p.id][k] !== undefined ? edits[p.id][k] : (p[k] || ""));
|
|
111
|
+
const set = (p, k, v) => setEdits({ ...edits, [p.id]: { ...(edits[p.id] || {}), [k]: v } });
|
|
112
|
+
const dirty = (p) => !!edits[p.id] && Object.keys(edits[p.id]).some((k) => (edits[p.id][k] || "") !== (p[k] || ""));
|
|
113
|
+
const act = async (label, fn) => { setBusy(label); try { setErr(""); await fn(); await load(); if (reload) await reload(); } catch (e) { setErr(e.message); } setBusy(""); };
|
|
114
|
+
const save = (p, confirm) => act(p.id, () => api.post(`/api/kb/positions/${p.id}${confirm ? "/confirm" : ""}`, { edits: edits[p.id] || {} }));
|
|
115
|
+
const proposed = rows.filter((p) => p.status === "proposed");
|
|
116
|
+
const fmt = (p) => (p.date_start && p.date_end === p.date_start ? p.date_start : [p.date_start, p.date_end || (p.date_start ? "present" : "")].filter(Boolean).join(" to "));
|
|
117
|
+
const kinds = Object.keys(KIND).filter((k) => k === "employment" || rows.some((p) => p.kind === k) || adding === k);
|
|
118
|
+
const fields = (p, k) => html`
|
|
119
|
+
<input placeholder=${k.org} value=${val(p, "company")} onInput=${(e) => set(p, "company", e.target.value)} />
|
|
120
|
+
<input placeholder=${k.title} value=${val(p, "role")} onInput=${(e) => set(p, "role", e.target.value)} />
|
|
121
|
+
${k.where ? html`<input placeholder="city, state" value=${val(p, "location")} onInput=${(e) => set(p, "location", e.target.value)} />` : ""}
|
|
122
|
+
<input class="narrow" placeholder=${k.records ? "2022-11" : "2022"} title="start or date, year or year-month" value=${val(p, "date_start")} onInput=${(e) => set(p, "date_start", e.target.value)} />
|
|
123
|
+
<input class="narrow" placeholder=${k.records ? "present" : "end"} title="end, year or year-month; blank means present or none" value=${val(p, "date_end")} onInput=${(e) => set(p, "date_end", e.target.value)} />
|
|
124
|
+
${k.detail !== undefined ? html`<input placeholder=${k.detail || "detail"} value=${val(p, "detail")} onInput=${(e) => set(p, "detail", e.target.value)} />` : ""}`;
|
|
125
|
+
return html`<div>
|
|
126
|
+
${compact ? "" : html`<p class="small muted">Your AI quoted an employer, a title, and dates on every record, and read the education, volunteer, award, and certification lines. Here each appears once. Fix a name or a date here and every record under a job follows; confirm each once.</p>`}
|
|
127
|
+
${kinds.map((kind) => { const k = KIND[kind]; const mine = rows.filter((p) => p.kind === kind); return html`<div key=${kind}>
|
|
128
|
+
<div class="small muted" style="margin:14px 0 4px;text-transform:uppercase;letter-spacing:.04em">${k.heading}</div>
|
|
129
|
+
<ul class="list">${mine.map((p) => html`<li key=${p.id}><div style="flex:1">
|
|
130
|
+
<div class="row"><b>${p.company}</b><span class="muted">${p.role}</span><span class="small muted">${fmt(p) || "no dates"}${p.location ? " · " + p.location : ""}${p.detail ? " · " + p.detail : ""}</span>
|
|
131
|
+
<span class="tag">${p.status === "confirmed" ? "confirmed" : "proposed"}</span>${k.records ? html`<span class="small muted">${p.pending + p.confirmed} record${p.pending + p.confirmed === 1 ? "" : "s"}${p.pending ? ` (${p.pending} pending)` : ""}</span>` : ""}</div>
|
|
132
|
+
<form class="inline" onSubmit=${(e) => { e.preventDefault(); save(p, p.status !== "confirmed"); }}>
|
|
133
|
+
${fields(p, k)}
|
|
134
|
+
${p.status === "confirmed" ? html`<button class="small" disabled=${!dirty(p) || !!busy}>Save</button>` : html`<button class="primary small" disabled=${!!busy}>${busy === p.id ? "saving" : "Confirm"}</button>`}
|
|
135
|
+
${mine.length > 1 ? html`<select title="this is the same as another row" value="" onChange=${(e) => { const keep = e.target.value; if (keep) act(p.id, () => api.post("/api/kb/positions/merge", { keep_id: keep, drop_id: p.id })); }}>
|
|
136
|
+
<option value="">same as…</option>${mine.filter((o) => o.id !== p.id).map((o) => html`<option key=${o.id} value=${o.id}>${o.company}, ${o.role}</option>`)}</select>` : ""}
|
|
137
|
+
${p.pending + p.confirmed === 0 ? html`<button type="button" class="small danger" onClick=${() => act(p.id, () => api.del(`/api/kb/positions/${p.id}`))}>Remove</button>` : ""}
|
|
138
|
+
</form></div></li>`)}
|
|
139
|
+
${mine.length ? "" : html`<li class="muted small">${kind === "employment" ? "no jobs yet; extract a resume first, or add one" : "none yet"}</li>`}</ul>
|
|
140
|
+
${adding === kind ? html`<form class="inline" onSubmit=${(e) => { e.preventDefault(); act("new", () => api.post("/api/kb/positions", { kind, edits: edits.new || {} })); setAdding(null); }}>
|
|
141
|
+
${fields({ id: "new" }, k)}<button class="primary small" disabled=${!!busy}>Add</button><button type="button" class="small" onClick=${() => setAdding(null)}>cancel</button></form>` : ""}
|
|
142
|
+
</div>`; })}
|
|
143
|
+
<div class="row" style="margin-top:10px">
|
|
144
|
+
${proposed.length > 1 ? html`<button disabled=${!!busy} onClick=${() => act("all", async () => { for (const p of proposed) await api.post(`/api/kb/positions/${p.id}/confirm`, { edits: edits[p.id] || {} }); })}>Confirm all ${proposed.length}</button><span class="small muted">only if the names and dates are right</span>` : ""}
|
|
145
|
+
<select value="" onChange=${(e) => { if (e.target.value) { setEdits({ ...edits, new: {} }); setAdding(e.target.value); } e.target.value = ""; }}><option value="">+ add by hand…</option>${Object.keys(KIND).map((kd) => html`<option key=${kd} value=${kd}>${KIND[kd].heading}</option>`)}</select>
|
|
146
|
+
</div>
|
|
147
|
+
</div>`;
|
|
148
|
+
}
|
|
149
|
+
|
|
93
150
|
export const fromDraft = (d) => ({ title: d.title || "", company: d.company || "", role: d.role || "", dates: d.dates || "", summary: d.summary || "", metrics: (d.metrics || []).join(", "), verbs: (d.verbs || []).join(", "), wordings: (d.wordings || []).join("\n"), tenure: d.tenure || "" });
|
|
94
151
|
|
|
95
152
|
export function DraftForm({ initial, onSubmit, label }) {
|
|
@@ -149,6 +206,8 @@ export function KB({ live }) {
|
|
|
149
206
|
<div class="card"><p class="small muted">A record with no confirmed figure, or an ownership verb with no scope. An answer extends that record; nothing is stored until you confirm it under Pending.</p><ul class="list">${(d.holes || []).map((h) => html`<li key=${h.hole_id}><div><b>?</b> <b>${h.title}</b> <span class="muted">${h.company}, ${h.role}</span><br />${h.question}
|
|
150
207
|
${answering === h.hole_id ? html`<${DraftForm} label="Add answer as pending" initial=${fromDraft(h.draft)} onSubmit=${(draft) => { post("/api/kb/pending", { draft: { ...draft, replaces: h.draft.replaces } }); setAnswering(null); }} />` : html`<button class="small" onClick=${() => setAnswering(h.hole_id)}>Answer</button>`}</div></li>`)}
|
|
151
208
|
${(d.holes || []).length ? "" : html`<li class="muted">no holes: every record carries a confirmed figure and every ownership claim a scope</li>`}</ul></div>
|
|
209
|
+
<h2>Jobs, education, and the rest (confirm each once)</h2>
|
|
210
|
+
<div class="card"><${Positions} reload=${load} setErr=${setErr} /></div>
|
|
152
211
|
<h2>Pending (written, not yet confirmed)</h2>
|
|
153
212
|
<div class="card"><ul class="list">${d.pending.map((p) => html`<li key=${p.pending_id}><div><b>${p.draft.title}</b> <span class="muted">${p.draft.company}, ${p.draft.role}</span> <span class="tag">${p.source}</span>${p.draft.replaces ? html` <span class="tag">extends an existing record</span>` : ""}
|
|
154
213
|
<div class="small muted">metrics: ${(p.draft.metrics || []).join(", ") || "none"} · verbs: ${(p.draft.verbs || []).join(", ") || "none"}${p.draft.dates ? " · " + p.draft.dates : ""}</div>
|
package/ui/screens/welcome.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { html, useState, useEffect } from "../vendor/preact.mjs";
|
|
2
2
|
import { api } from "../lib.js";
|
|
3
|
-
import { Onboarding, DraftForm, fromDraft } from "./kb.js";
|
|
3
|
+
import { Onboarding, DraftForm, fromDraft, Positions } from "./kb.js";
|
|
4
4
|
import { PacketModal } from "./packet.js";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* The first run, before the app: one step at a time, everything done on this
|
|
8
8
|
* screen. The order is the order the data needs: an AI to read with, files to
|
|
9
|
-
* read, records to confirm, questions to answer, a posting to aim at. Each
|
|
9
|
+
* read, jobs and records to confirm, questions to answer, a posting to aim at. Each
|
|
10
10
|
* step flips when the store says so; "skip for now" opens the app anyway.
|
|
11
11
|
*/
|
|
12
12
|
|
|
@@ -101,6 +101,7 @@ export function Welcome({ live, onDone }) {
|
|
|
101
101
|
ai: html`<${StepAi} s=${setup} reload=${load} />`,
|
|
102
102
|
upload: html`<div><p>Your resumes: Word, PDF, Markdown, or plain text. Five to ten of your most different versions is plenty; near-identical copies only add duplicates to merge. The text is read on this PC. Nothing is uploaded anywhere.</p><${Onboarding} d=${ob} reload=${load} setErr=${setErr} route=${me.route} compact=${true} /></div>`,
|
|
103
103
|
extract: html`<div><p>${direct ? "Your AI reads each file and proposes records where every number and phrase is quoted from the file; anything it cannot quote is refused. One click runs the whole pile." : "For each file, Extract opens a packet: copy it into your chat, paste the JSON reply back. Your AI proposes records where every number and phrase is quoted from the file."}</p><${Onboarding} d=${ob} reload=${load} setErr=${setErr} route=${me.route} compact=${true} /></div>`,
|
|
104
|
+
positions: html`<div><p>Before the records: the jobs, schools, volunteer roles, awards, and certifications themselves, each once. Your AI quoted them from your resumes. Fix a name, add a city, correct a date, then confirm. Every record under a job follows it.</p><${Positions} reload=${load} setErr=${setErr} compact=${true} /></div>`,
|
|
104
105
|
confirm: html`<div><p>These are the records your AI proposed. Confirm the ones you can stand behind in an interview, edit what is off, dismiss the rest. Only confirmed records can ever appear in a document.</p>
|
|
105
106
|
<ul class="list">${kb.pending.map((p) => html`<li key=${p.pending_id}><div><b>${p.draft.title}</b> <span class="muted">${p.draft.company}, ${p.draft.role}</span>
|
|
106
107
|
<div class="small muted">metrics: ${(p.draft.metrics || []).join(", ") || "none"} · verbs: ${(p.draft.verbs || []).join(", ") || "none"}${p.draft.dates ? " · " + p.draft.dates : ""}</div>
|
|
@@ -115,7 +116,7 @@ export function Welcome({ live, onDone }) {
|
|
|
115
116
|
posting: html`<${StepPosting} reload=${load} />`,
|
|
116
117
|
};
|
|
117
118
|
return html`<div class="welcome">
|
|
118
|
-
<div class="welcome-head"><img src="/icon-192.png" alt="" width="40" height="40" /><div><b>Welcome to GAF-J${me.name ? ", " + me.name : ""}</b><div class="small muted">
|
|
119
|
+
<div class="welcome-head"><img src="/icon-192.png" alt="" width="40" height="40" /><div><b>Welcome to GAF-J${me.name ? ", " + me.name : ""}</b><div class="small muted">Seven short steps, then the app is yours.</div></div>
|
|
119
120
|
<span class="links"><a class="small" href="#/settings">Settings</a><a class="small" href="#/" onClick=${async (e) => { e.preventDefault(); await api.post("/api/setup/dismiss", {}); onDone(); }}>skip for now</a></span></div>
|
|
120
121
|
<ol class="stepper">${setup.steps.map((st, i) => html`<li key=${st.key} class=${(st.done ? "done" : "") + (i === idx ? " now" : "") + (i <= nextIdx ? " can" : "")} onClick=${() => { if (i <= nextIdx) setView(i); }} title=${i <= nextIdx ? "open this step" : ""}><span class="n mono">${st.done ? "✓" : i + 1}</span>${st.title}</li>`)}</ol>
|
|
121
122
|
${err ? html`<p class="error">${err}</p>` : ""}
|