@gafj/gafj 0.1.16 → 0.1.19
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/core/migrations/004-position.sql +24 -0
- package/core/rules/extract.md +8 -0
- package/core/schemas/extract.js +10 -0
- package/http/api.js +10 -0
- package/http/hosted.js +1 -0
- package/package.json +1 -1
- package/store/contact.js +74 -0
- package/store/kb.js +3 -3
- package/store/migrate.js +1 -0
- package/store/onboarding.js +17 -2
- package/store/positions.js +210 -0
- package/store/render.js +5 -3
- package/store/setup.js +4 -1
- package/ui/hosted/signin.js +12 -5
- package/ui/lib.js +1 -1
- package/ui/screens/kb.js +77 -0
- package/ui/screens/settings.js +15 -2
- package/ui/screens/welcome.js +17 -5
|
@@ -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,14 @@ 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 header
|
|
26
|
+
|
|
27
|
+
The candidate's name, email, phone, city and state, LinkedIn, and website go in `contact`, each quoted from the source; leave out any the source does not show.
|
|
28
|
+
|
|
29
|
+
## The other sections
|
|
30
|
+
|
|
31
|
+
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.
|
|
32
|
+
|
|
25
33
|
## Style observed
|
|
26
34
|
|
|
27
35
|
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,16 @@ 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 header: whatever the source states, quoted
|
|
29
|
+
contact: obj({ name: ATOM, email: ATOM, phone: ATOM, location: ATOM, linkedin: ATOM, website: ATOM }, []),
|
|
30
|
+
// the resume's other sections, each an org, a title, dates, and one line of detail, all quoted
|
|
31
|
+
entries: arr(obj({
|
|
32
|
+
kind: str("identifier", { enum: ["education", "volunteer", "award", "certification", "affiliation"] }),
|
|
33
|
+
org: ATOM,
|
|
34
|
+
title: ATOM,
|
|
35
|
+
dates: ATOM,
|
|
36
|
+
detail: ATOM,
|
|
37
|
+
}, ["kind", "org", "title"]), 0, 30),
|
|
28
38
|
style_observed: obj({
|
|
29
39
|
dashes: str("identifier", { enum: ["none", "hyphens", "em_dashes", "mixed"] }),
|
|
30
40
|
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") }));
|
|
@@ -173,6 +180,9 @@ route("POST", "/api/setup/dismiss", (c, p, b) => setup.dismiss(c.db, c.home, c.c
|
|
|
173
180
|
|
|
174
181
|
// settings (screen 6)
|
|
175
182
|
const settings = require("../store/settings");
|
|
183
|
+
const contact = require("../store/contact");
|
|
184
|
+
route("GET", "/api/profile/contact", (c) => contact.get(c.db, c.candidate_id));
|
|
185
|
+
route("POST", "/api/profile/contact", (c, p, b) => contact.set(c.db, { candidate_id: c.candidate_id, contact: b.contact || b, now: c.now(), actor: ACTOR("contact") }));
|
|
176
186
|
route("GET", "/api/settings", (c) => settings.getSettings(c.db, c.home, c.candidate_id));
|
|
177
187
|
route("PUT", "/api/settings", (c, p, b) => { const r = settings.putSettings(c.db, c.home, c.candidate_id, b, { now: c.now(), actor: ACTOR("settings") }); if (c.reload) c.reload(); return r; });
|
|
178
188
|
route("PUT", "/api/settings/providers/:id", (c, p, b) => settings.putProvider(c.home, p.id, b));
|
package/http/hosted.js
CHANGED
|
@@ -91,6 +91,7 @@ class Tenants {
|
|
|
91
91
|
cfg = { candidate_id: id, route: "credits", providers: [], thresholds: { ...DEFAULT_THRESHOLDS }, hosted: true, email: who.email || null };
|
|
92
92
|
writeConfig(home, cfg);
|
|
93
93
|
require("../store/onboarding").openBatch(t.db, { candidate_id: id, now: nowIso, actor: { type: "ui", ref: "signup" } });
|
|
94
|
+
try { require("../store/contact").set(t.db, { candidate_id: id, contact: { name: who.name || "", email: who.email || "" }, now: nowIso, actor: { type: "ui", ref: "signup" } }); } catch (e) { /* an odd address from the provider is not worth failing sign-up over */ }
|
|
94
95
|
}
|
|
95
96
|
return t;
|
|
96
97
|
}
|
package/package.json
CHANGED
package/store/contact.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The resume header: name, email, phone, city, and links. Lives in the candidate's profile,
|
|
5
|
+
* edited in Settings, and prefilled once from what the model quoted off the first resume;
|
|
6
|
+
* a prefill never overwrites a field the candidate has typed.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const { withTx } = require("./tx");
|
|
10
|
+
const { appendEvent } = require("./events");
|
|
11
|
+
const { TransitionError } = require("./transitions");
|
|
12
|
+
|
|
13
|
+
const FIELDS = ["name", "email", "phone", "location", "linkedin", "website"];
|
|
14
|
+
const MAX = { name: 120, email: 160, phone: 40, location: 120, linkedin: 200, website: 200 };
|
|
15
|
+
const EMPTY = Object.fromEntries(FIELDS.map((k) => [k, ""]));
|
|
16
|
+
|
|
17
|
+
function stored(db, candidate_id) {
|
|
18
|
+
const row = db.prepare("SELECT name, profile_json FROM candidate WHERE id = ?").get(candidate_id);
|
|
19
|
+
if (!row) throw new TransitionError("unknown candidate");
|
|
20
|
+
return { name: row.name || "", contact: { ...EMPTY, ...((JSON.parse(row.profile_json || "{}").contact) || {}) } };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The header as typed; with no header name yet, the candidate's display name stands in. */
|
|
24
|
+
function get(db, candidate_id) {
|
|
25
|
+
const { name, contact } = stored(db, candidate_id);
|
|
26
|
+
return contact.name ? contact : { ...contact, name };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function clean(patch) {
|
|
30
|
+
const out = {};
|
|
31
|
+
for (const k of FIELDS) {
|
|
32
|
+
if (patch[k] === undefined) continue;
|
|
33
|
+
let v = String(patch[k] || "").trim().replace(/\s+/g, " ").slice(0, MAX[k]);
|
|
34
|
+
if (k === "email" && v && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v)) throw new TransitionError("email does not look like an address");
|
|
35
|
+
if ((k === "linkedin" || k === "website") && v && !/^https?:\/\//i.test(v)) v = "https://" + v.replace(/^\/+/, "");
|
|
36
|
+
out[k] = v;
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Save the fields given; the others stay. The candidate's display name follows the header name. */
|
|
42
|
+
function set(db, { candidate_id, contact, now, actor }) {
|
|
43
|
+
const patch = clean(contact || {});
|
|
44
|
+
return withTx(db, (d) => {
|
|
45
|
+
const row = d.prepare("SELECT profile_json FROM candidate WHERE id = ?").get(candidate_id);
|
|
46
|
+
if (!row) throw new TransitionError("unknown candidate");
|
|
47
|
+
const profile = JSON.parse(row.profile_json || "{}");
|
|
48
|
+
profile.contact = { ...EMPTY, ...(profile.contact || {}), ...patch };
|
|
49
|
+
d.prepare("UPDATE candidate SET profile_json = ?, name = CASE WHEN ? <> '' THEN ? ELSE name END WHERE id = ?").run(JSON.stringify(profile), profile.contact.name, profile.contact.name, candidate_id);
|
|
50
|
+
appendEvent(d, { candidate_id, entity: "candidate", entity_id: candidate_id, event: "contact_set", actor_type: actor.type, actor_ref: actor.ref, payload: { keys: Object.keys(patch) }, at: now });
|
|
51
|
+
return profile.contact;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Fill only the empty fields from what a resume quoted. Returns the keys filled. */
|
|
56
|
+
function prefill(db, { candidate_id, contact, now, actor }) {
|
|
57
|
+
let patch;
|
|
58
|
+
try { patch = clean(contact || {}); } catch (e) { return []; }
|
|
59
|
+
const cur = stored(db, candidate_id).contact; // the typed header only: a display name that was never typed here does not block the resume's
|
|
60
|
+
const fill = {};
|
|
61
|
+
for (const k of FIELDS) if (patch[k] && !cur[k]) fill[k] = patch[k];
|
|
62
|
+
if (!Object.keys(fill).length) return [];
|
|
63
|
+
set(db, { candidate_id, contact: fill, now, actor });
|
|
64
|
+
return Object.keys(fill);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The header lines for a rendered resume: the name, then whatever contact the candidate gave. */
|
|
68
|
+
function headerLines(db, candidate_id) {
|
|
69
|
+
const c = get(db, candidate_id);
|
|
70
|
+
const line = [c.location, c.email, c.phone, c.linkedin, c.website].filter(Boolean).join(" | ");
|
|
71
|
+
return [c.name, line].filter(Boolean);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports = { get, set, prefill, headerLines, FIELDS };
|
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
|
@@ -132,6 +132,8 @@ function normalizeAtoms(content) {
|
|
|
132
132
|
if (Array.isArray(a.wordings)) a.wordings = a.wordings.map(q);
|
|
133
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));
|
|
134
134
|
}
|
|
135
|
+
if (content.contact && typeof content.contact === "object") for (const k of Object.keys(content.contact)) content.contact[k] = q(content.contact[k]);
|
|
136
|
+
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]); }
|
|
135
137
|
return content;
|
|
136
138
|
}
|
|
137
139
|
|
|
@@ -147,6 +149,15 @@ function checkDraft(content, text) {
|
|
|
147
149
|
a.wordings = (a.wordings || []).map((w) => (typeof w === "string" ? { value: w, start: -1, end: -1 } : w));
|
|
148
150
|
a.metrics = (a.metrics || []).map((m) => ({ ...m, start: -1, end: -1 }));
|
|
149
151
|
}
|
|
152
|
+
for (const k of Object.keys(content.contact || {})) { if (typeof content.contact[k] === "string") content.contact[k] = { value: content.contact[k], start: -1, end: -1 }; checkAtom(content.contact[k], text, `contact.${k}`, errors); }
|
|
153
|
+
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 };
|
|
154
|
+
(content.entries || []).forEach((e, i) => {
|
|
155
|
+
const p = `entries[${i}]`;
|
|
156
|
+
checkAtom(e.org, text, `${p}.org`, errors);
|
|
157
|
+
checkAtom(e.title, text, `${p}.title`, errors);
|
|
158
|
+
if (e.dates) checkAtom(e.dates, text, `${p}.dates`, errors);
|
|
159
|
+
if (e.detail) checkAtom(e.detail, text, `${p}.detail`, errors);
|
|
160
|
+
});
|
|
150
161
|
content.accomplishments.forEach((a, i) => {
|
|
151
162
|
const p = `accomplishments[${i}]`;
|
|
152
163
|
checkAtom(a.company, text, `${p}.company`, errors);
|
|
@@ -185,8 +196,12 @@ function proposeFromSource(db, { candidate_id, source_document_id, content, ai_a
|
|
|
185
196
|
}
|
|
186
197
|
ids.push(id);
|
|
187
198
|
}
|
|
188
|
-
|
|
189
|
-
|
|
199
|
+
const positions = require("./positions");
|
|
200
|
+
positions.derive(d, { candidate_id, now });
|
|
201
|
+
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 });
|
|
202
|
+
const contact = require("./contact").prefill(d, { candidate_id, contact: Object.fromEntries(Object.entries(content.contact || {}).map(([k, v]) => [k, v && v.value])), now, actor });
|
|
203
|
+
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, contact, style: content.style_observed || null }, at: now });
|
|
204
|
+
return { accepted: true, errors: [], pending_ids: ids, entries, contact, style_observed: content.style_observed || null };
|
|
190
205
|
});
|
|
191
206
|
}
|
|
192
207
|
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/render.js
CHANGED
|
@@ -40,8 +40,10 @@ function renderNode(value, node, depth, out) {
|
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
function renderResume(c, out) {
|
|
44
|
-
out.push(`# ${
|
|
43
|
+
function renderResume(c, out, who = []) {
|
|
44
|
+
if (who.length) { out.push(`# ${who[0]}`, ""); for (const l of who.slice(1)) out.push(l); out.push("", `## ${c.headline}`, ""); }
|
|
45
|
+
else out.push(`# ${c.headline}`, "");
|
|
46
|
+
out.push((c.specialties || []).join(" · "), "");
|
|
45
47
|
if (c.headerLinks && c.headerLinks.length) out.push(c.headerLinks.join(" | "), "");
|
|
46
48
|
out.push("## Career Highlights", "");
|
|
47
49
|
for (const h of c.careerHighlights || []) out.push(`- **${h.leadIn}** ${h.proof && h.proof.text || ""}`);
|
|
@@ -77,7 +79,7 @@ function renderMarkdown(db, doc) {
|
|
|
77
79
|
const content = JSON.parse(doc.content_json);
|
|
78
80
|
const out = [];
|
|
79
81
|
const head = header(db, doc);
|
|
80
|
-
if (doc.kind === "resume") renderResume(content, out);
|
|
82
|
+
if (doc.kind === "resume") renderResume(content, out, require("./contact").headerLines(db, doc.candidate_id));
|
|
81
83
|
else if (doc.kind === "email") out.push(`**Subject:** ${content.subject}`, "", content.body && content.body.text || "", "");
|
|
82
84
|
else {
|
|
83
85
|
out.push(`# ${label(doc.kind)}${head.length ? ": " + head[0] : ""}`, "");
|
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/hosted/signin.js
CHANGED
|
@@ -10,10 +10,7 @@
|
|
|
10
10
|
if (!c.firebase || !c.firebase.apiKey) { say("sign-in is not configured on this server yet", true); document.getElementById("google").disabled = true; return; }
|
|
11
11
|
if (typeof firebase === "undefined") { say("could not load the sign-in library", true); return; }
|
|
12
12
|
firebase.initializeApp({ apiKey: c.firebase.apiKey, authDomain: c.firebase.authDomain, projectId: c.firebase.projectId });
|
|
13
|
-
|
|
14
|
-
say("opening Google");
|
|
15
|
-
var provider = new firebase.auth.GoogleAuthProvider();
|
|
16
|
-
firebase.auth().signInWithPopup(provider).then(function (r) { return r.user.getIdToken(true); }).then(function (idToken) {
|
|
13
|
+
var finish = function (idToken) {
|
|
17
14
|
tokenBox.value = idToken;
|
|
18
15
|
return fetch("/auth/session", { method: "POST", credentials: "same-origin", headers: { "content-type": "application/json" }, body: JSON.stringify({ id_token: idToken }) })
|
|
19
16
|
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); })
|
|
@@ -22,7 +19,17 @@
|
|
|
22
19
|
if (document.getElementById("local").open) { say("signed in as " + (x.j.email || x.j.uid) + "; copy the token for your PC, or open the app"); return; }
|
|
23
20
|
location.href = "/";
|
|
24
21
|
});
|
|
25
|
-
|
|
22
|
+
};
|
|
23
|
+
var fail = function (e) { say(e && e.message ? e.message : "sign-in failed", true); };
|
|
24
|
+
// a phone, or a browser that blocks popups, comes back here from Google's own page
|
|
25
|
+
firebase.auth().getRedirectResult().then(function (r) { if (r && r.user) { say("finishing sign-in"); return r.user.getIdToken(true).then(finish); } }).catch(fail);
|
|
26
|
+
document.getElementById("google").addEventListener("click", function () {
|
|
27
|
+
say("opening Google");
|
|
28
|
+
var provider = new firebase.auth.GoogleAuthProvider();
|
|
29
|
+
var small = window.innerWidth < 700 || /Android|iPhone|iPad/i.test(navigator.userAgent);
|
|
30
|
+
if (small) { firebase.auth().signInWithRedirect(provider).catch(fail); return; }
|
|
31
|
+
firebase.auth().signInWithPopup(provider).then(function (r) { return r.user.getIdToken(true); }).then(finish)
|
|
32
|
+
.catch(function (e) { if (e && /popup/i.test(e.code || "")) firebase.auth().signInWithRedirect(provider).catch(fail); else fail(e); });
|
|
26
33
|
});
|
|
27
34
|
}).catch(function () { say("could not reach the server", true); });
|
|
28
35
|
})();
|
package/ui/lib.js
CHANGED
|
@@ -41,7 +41,7 @@ function zoneOffsetMs(utcMs, tz) {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
export function useRoute() {
|
|
44
|
-
const parse = () =>
|
|
44
|
+
const parse = () => { const h = location.hash.replace(/^#/, "") || "/"; const q = h.indexOf("?"); return { path: q < 0 ? h : h.slice(0, q) || "/", query: new URLSearchParams(q < 0 ? "" : h.slice(q + 1)) }; };
|
|
45
45
|
const [route, setRoute] = useState(parse);
|
|
46
46
|
useEffect(() => { const on = () => setRoute(parse()); addEventListener("hashchange", on); return () => removeEventListener("hashchange", on); }, []);
|
|
47
47
|
return route;
|
package/ui/screens/kb.js
CHANGED
|
@@ -90,6 +90,79 @@ 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
|
+
/** The resume header: name, email, phone, city, links. Prefilled from the first resume; yours to change here or in Settings. */
|
|
94
|
+
export function ContactForm({ setErr, compact }) {
|
|
95
|
+
const [c, setC] = useState(null);
|
|
96
|
+
const [saved, setSaved] = useState(null);
|
|
97
|
+
const [busy, setBusy] = useState(false);
|
|
98
|
+
useEffect(() => { api.get("/api/profile/contact").then((r) => { setC(r); setSaved(r); }).catch((e) => setErr(e.message)); }, []);
|
|
99
|
+
if (!c) return html`<p class="muted">loading</p>`;
|
|
100
|
+
const dirty = saved && Object.keys(c).some((k) => (c[k] || "") !== (saved[k] || ""));
|
|
101
|
+
const f = (k, ph, type = "text") => html`<input type=${type} placeholder=${ph} value=${c[k] || ""} onInput=${(e) => setC({ ...c, [k]: e.target.value })} />`;
|
|
102
|
+
return html`<form class="inline" onSubmit=${async (e) => { e.preventDefault(); setBusy(true); try { setErr(""); const r = await api.post("/api/profile/contact", { contact: c }); setC(r); setSaved(r); } catch (e2) { setErr(e2.message); } setBusy(false); }}>
|
|
103
|
+
${compact ? "" : html`<p class="small muted" style="flex-basis:100%;margin:0">What goes at the top of every resume. Nothing here is checked against a source; it is yours to type.</p>`}
|
|
104
|
+
${f("name", "full name")}${f("email", "email", "email")}${f("phone", "phone")}${f("location", "city, state")}${f("linkedin", "linkedin.com/in/…")}${f("website", "website")}
|
|
105
|
+
<button class="primary small" disabled=${!dirty || busy}>${busy ? "saving" : "Save"}</button>
|
|
106
|
+
</form>`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Positions: jobs, schools, volunteer roles, awards, certifications, affiliations. One row each, confirmed once; a job's records inherit its company, role, and dates. */
|
|
110
|
+
const KIND = {
|
|
111
|
+
employment: { heading: "Jobs", org: "company", title: "title", records: true, where: true },
|
|
112
|
+
education: { heading: "Education", org: "school", title: "degree or program", where: true, detail: "honors, GPA, focus" },
|
|
113
|
+
volunteer: { heading: "Volunteer and community", org: "organization", title: "role", where: true, detail: "what you did" },
|
|
114
|
+
award: { heading: "Awards", org: "issuer", title: "award", detail: "for what" },
|
|
115
|
+
certification: { heading: "Certifications and licenses", org: "issuer", title: "credential", detail: "number or scope" },
|
|
116
|
+
affiliation: { heading: "Affiliations and memberships", org: "organization", title: "role or membership", detail: "" },
|
|
117
|
+
};
|
|
118
|
+
export function Positions({ reload, setErr, compact }) {
|
|
119
|
+
const [rows, setRows] = useState(null);
|
|
120
|
+
const [edits, setEdits] = useState({});
|
|
121
|
+
const [busy, setBusy] = useState("");
|
|
122
|
+
const [adding, setAdding] = useState(null); // a kind being written by hand
|
|
123
|
+
const load = () => api.get("/api/kb/positions").then((r) => { setRows(r); setEdits({}); }).catch((e) => setErr(e.message));
|
|
124
|
+
useEffect(() => { load(); }, []);
|
|
125
|
+
if (!rows) return html`<p class="muted">loading</p>`;
|
|
126
|
+
const val = (p, k) => (edits[p.id] && edits[p.id][k] !== undefined ? edits[p.id][k] : (p[k] || ""));
|
|
127
|
+
const set = (p, k, v) => setEdits({ ...edits, [p.id]: { ...(edits[p.id] || {}), [k]: v } });
|
|
128
|
+
const dirty = (p) => !!edits[p.id] && Object.keys(edits[p.id]).some((k) => (edits[p.id][k] || "") !== (p[k] || ""));
|
|
129
|
+
const act = async (label, fn) => { setBusy(label); try { setErr(""); await fn(); await load(); if (reload) await reload(); } catch (e) { setErr(e.message); } setBusy(""); };
|
|
130
|
+
const save = (p, confirm) => act(p.id, () => api.post(`/api/kb/positions/${p.id}${confirm ? "/confirm" : ""}`, { edits: edits[p.id] || {} }));
|
|
131
|
+
const proposed = rows.filter((p) => p.status === "proposed");
|
|
132
|
+
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 "));
|
|
133
|
+
const kinds = Object.keys(KIND).filter((k) => k === "employment" || rows.some((p) => p.kind === k) || adding === k);
|
|
134
|
+
const fields = (p, k) => html`
|
|
135
|
+
<input placeholder=${k.org} value=${val(p, "company")} onInput=${(e) => set(p, "company", e.target.value)} />
|
|
136
|
+
<input placeholder=${k.title} value=${val(p, "role")} onInput=${(e) => set(p, "role", e.target.value)} />
|
|
137
|
+
${k.where ? html`<input placeholder="city, state" value=${val(p, "location")} onInput=${(e) => set(p, "location", e.target.value)} />` : ""}
|
|
138
|
+
<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)} />
|
|
139
|
+
<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)} />
|
|
140
|
+
${k.detail !== undefined ? html`<input placeholder=${k.detail || "detail"} value=${val(p, "detail")} onInput=${(e) => set(p, "detail", e.target.value)} />` : ""}`;
|
|
141
|
+
return html`<div>
|
|
142
|
+
${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>`}
|
|
143
|
+
${kinds.map((kind) => { const k = KIND[kind]; const mine = rows.filter((p) => p.kind === kind); return html`<div key=${kind}>
|
|
144
|
+
<div class="small muted" style="margin:14px 0 4px;text-transform:uppercase;letter-spacing:.04em">${k.heading}</div>
|
|
145
|
+
<ul class="list">${mine.map((p) => html`<li key=${p.id}><div style="flex:1">
|
|
146
|
+
<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>
|
|
147
|
+
<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>
|
|
148
|
+
<form class="inline" onSubmit=${(e) => { e.preventDefault(); save(p, p.status !== "confirmed"); }}>
|
|
149
|
+
${fields(p, k)}
|
|
150
|
+
${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>`}
|
|
151
|
+
${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 })); }}>
|
|
152
|
+
<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>` : ""}
|
|
153
|
+
${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>` : ""}
|
|
154
|
+
</form></div></li>`)}
|
|
155
|
+
${mine.length ? "" : html`<li class="muted small">${kind === "employment" ? "no jobs yet; extract a resume first, or add one" : "none yet"}</li>`}</ul>
|
|
156
|
+
${adding === kind ? html`<form class="inline" onSubmit=${(e) => { e.preventDefault(); act("new", () => api.post("/api/kb/positions", { kind, edits: edits.new || {} })); setAdding(null); }}>
|
|
157
|
+
${fields({ id: "new" }, k)}<button class="primary small" disabled=${!!busy}>Add</button><button type="button" class="small" onClick=${() => setAdding(null)}>cancel</button></form>` : ""}
|
|
158
|
+
</div>`; })}
|
|
159
|
+
<div class="row" style="margin-top:10px">
|
|
160
|
+
${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>` : ""}
|
|
161
|
+
<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>
|
|
162
|
+
</div>
|
|
163
|
+
</div>`;
|
|
164
|
+
}
|
|
165
|
+
|
|
93
166
|
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
167
|
|
|
95
168
|
export function DraftForm({ initial, onSubmit, label }) {
|
|
@@ -149,6 +222,10 @@ export function KB({ live }) {
|
|
|
149
222
|
<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
223
|
${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
224
|
${(d.holes || []).length ? "" : html`<li class="muted">no holes: every record carries a confirmed figure and every ownership claim a scope</li>`}</ul></div>
|
|
225
|
+
<h2>Header (name and contact)</h2>
|
|
226
|
+
<div class="card"><${ContactForm} setErr=${setErr} /></div>
|
|
227
|
+
<h2>Jobs, education, and the rest (confirm each once)</h2>
|
|
228
|
+
<div class="card"><${Positions} reload=${load} setErr=${setErr} /></div>
|
|
152
229
|
<h2>Pending (written, not yet confirmed)</h2>
|
|
153
230
|
<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
231
|
<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/settings.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { html, useState, useEffect } from "../vendor/preact.mjs";
|
|
2
2
|
import { api, useInstall } from "../lib.js";
|
|
3
|
+
import { ContactForm } from "./kb.js";
|
|
3
4
|
|
|
4
5
|
/** Screen 6: AI route, providers (keys write-only), house rules per kind, data folder, backup, startup. */
|
|
5
6
|
|
|
@@ -18,6 +19,14 @@ export function Settings({ live }) {
|
|
|
18
19
|
const inst = useInstall();
|
|
19
20
|
const load = () => Promise.all([api.get("/api/settings"), api.get("/api/usage").catch(() => null)]).then(([x, u]) => { setS(x); setUsage(u); setErr(""); }).catch((e) => setErr(e.message));
|
|
20
21
|
useEffect(() => { load(); }, [live]);
|
|
22
|
+
// back from Stripe: the URL carries paid=1 (or paid=0 for a cancel); check the balance once and drop the flag
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
const m = location.hash.match(/[?&]paid=(\d)/);
|
|
25
|
+
if (!m) return;
|
|
26
|
+
history.replaceState(null, "", location.pathname + location.hash.replace(/[?&]paid=\d/, ""));
|
|
27
|
+
if (m[1] !== "1") { setMsg("checkout cancelled; nothing was charged"); return; }
|
|
28
|
+
api.get("/api/credits/balance").then((r) => { setMsg(`payment received: ${r.available} credits available`); load(); }).catch((e) => setErr(e.message));
|
|
29
|
+
}, []);
|
|
21
30
|
const put = async (patch) => { setMsg(""); try { setS(await api.put("/api/settings", patch)); setMsg("saved"); } catch (e) { setErr(e.message); } };
|
|
22
31
|
const post = async (path, body, label) => { setMsg(""); try { const r = await api.post(path, body); setMsg(label ? `${label}: ${r.file || r.message || JSON.stringify(r)}` : JSON.stringify(r)); await load(); } catch (e) { setErr(e.message); } };
|
|
23
32
|
if (err && !s) return html`<p class="error">${err}</p>`;
|
|
@@ -31,10 +40,13 @@ export function Settings({ live }) {
|
|
|
31
40
|
${err ? html`<p class="error">${err}</p>` : ""}${msg ? html`<p class="small muted">${msg}</p>` : ""}
|
|
32
41
|
<h2>App</h2>
|
|
33
42
|
<div class="card">
|
|
34
|
-
${
|
|
43
|
+
${s.hosted ? html`<p class="small muted">On a phone: your browser's share or menu button, then "Add to Home Screen". On a PC: the install icon at the right end of the address bar, or the browser menu, "Install GAF-J". Either way it opens in its own window and stays signed in.</p>`
|
|
44
|
+
: inst.installed ? html`<p>Installed as an app. It opens in its own window from the Start menu or dock.</p>`
|
|
35
45
|
: inst.canInstall ? html`<div class="row"><button class="primary" onClick=${inst.install}>Install GAF-J as an app</button><span class="small muted">own window, Start menu entry, no browser chrome</span></div>`
|
|
36
46
|
: html`<p class="small muted">To install as an app: in Chrome or Edge use the install icon at the right end of the address bar, or the browser menu, "Install GAF-J". The page must be open at its 127.0.0.1 address (it is). No service worker is registered on purpose: the UI is never served from a cache, so a stale screen cannot outlive the store.</p>`}
|
|
37
47
|
</div>
|
|
48
|
+
<h2>Header (name and contact)</h2>
|
|
49
|
+
<div class="card"><${ContactForm} setErr=${setErr} /></div>
|
|
38
50
|
<h2>AI route</h2>
|
|
39
51
|
<div class="card">
|
|
40
52
|
${(s.hosted
|
|
@@ -85,7 +97,8 @@ export function Settings({ live }) {
|
|
|
85
97
|
<div class="card">
|
|
86
98
|
${s.hosted ? html`<p class="small muted">Signed in as ${s.email || "you"}. Documents run on the included AI and are paid in credits; the relay runs named operations on the packet the app built, never a free prompt, and never stores packet or reply text.</p>
|
|
87
99
|
<div class="row"><button type="button" onClick=${async () => { setMsg(""); try { const r = await api.get("/api/credits/balance"); setMsg(`balance: ${r.available} available`); await load(); } catch (e2) { setErr(e2.message); } }}>check balance</button>
|
|
88
|
-
${[["starter", "
|
|
100
|
+
${[["starter", "25 credits, $9"], ["campaign", "100 credits, $29"], ["season", "300 credits, $69"]].map(([k, label]) => html`<button type="button" key=${k} onClick=${async () => { setMsg(""); try { const r = await api.post("/api/credits/checkout", { pack: k }); location.href = r.url; } catch (e2) { setErr(e2.message); } }}>buy ${label}</button>`)}</div>
|
|
101
|
+
<p class="small muted">Stripe takes the card; GAF-J never sees it. Credits arrive within a few seconds of paying and never expire. A document costs a few credits, reading a resume a few more; the exact prices are shown before each run.</p>
|
|
89
102
|
${s.credits && s.credits.last_balance ? html`<p class="small">${s.credits.last_balance.available} credits available (${s.credits.last_balance.reserved} held), checked ${s.credits.last_balance.at}</p>` : ""}` : html`<p class="small muted">A paid route for people without their own AI subscription. The relay is written and tested but not deployed; nothing here works until it is. The relay runs named operations on the packet the app built, never a free prompt, and never stores packet or reply text.</p>
|
|
90
103
|
<form class="inline" onSubmit=${(e) => { e.preventDefault(); const f = e.target.elements; put({ credits: { relay_url: f.relay.value, ...(f.token.value ? { id_token: f.token.value } : {}) } }); f.token.value = ""; }}>
|
|
91
104
|
<input name="relay" placeholder="relay url (https)" value=${(s.credits && s.credits.relay_url) || ""} />
|
package/ui/screens/welcome.js
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
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, ContactForm } 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
|
|
|
13
13
|
const KIND_LABEL = { anthropic: "Anthropic (Claude)", openai_compatible: "OpenAI, or any OpenAI-compatible server", gemini: "Google Gemini" };
|
|
14
14
|
|
|
15
|
-
function StepAi({ s, reload }) {
|
|
15
|
+
function StepAi({ s, reload, hosted }) {
|
|
16
16
|
const [pick, setPick] = useState(null);
|
|
17
17
|
const [key, setKey] = useState("");
|
|
18
18
|
const [kind, setKind] = useState("anthropic");
|
|
@@ -32,6 +32,15 @@ function StepAi({ s, reload }) {
|
|
|
32
32
|
await reload();
|
|
33
33
|
} catch (e2) { setMsg(e2.message); }
|
|
34
34
|
};
|
|
35
|
+
if (hosted) return html`<div>
|
|
36
|
+
<p>GAF-J never writes text itself. It hands a packet of your confirmed facts to an AI, then checks every figure that comes back. Here the AI is included and paid in credits; or use a chat you already pay for and paste.</p>
|
|
37
|
+
<div class="ai-options">
|
|
38
|
+
<button class=${pick === "credits" ? "picked" : ""} onClick=${() => setPick("credits")}><b>Included AI</b><span class="small muted">Best. Everything runs in one click. Paid in credits per document; reading your resumes costs a few. Buy credits under Settings when you are ready; the first steps here are free.</span></button>
|
|
39
|
+
<button class=${pick === "paste" ? "picked" : ""} onClick=${() => setPick("paste")}><b>Paste</b><span class="small muted">Any chat you already pay for: ChatGPT, Claude, Gemini. You copy a packet in and paste the reply back, each time.</span></button>
|
|
40
|
+
</div>
|
|
41
|
+
${pick === "credits" ? html`<div style="margin-top:12px"><button class="primary" onClick=${() => choose("credits")}>Use the included AI</button></div>` : ""}
|
|
42
|
+
${pick === "paste" ? html`<div style="margin-top:12px"><p class="small muted">Fine for a first try. Each step below will show a packet to copy into your chat and a box to paste the reply into.</p><button class="primary" onClick=${() => choose("paste")}>Use paste</button></div>` : ""}
|
|
43
|
+
</div>`;
|
|
35
44
|
return html`<div>
|
|
36
45
|
<p>GAF-J never writes text itself. It hands a packet of your confirmed facts to an AI you already have, then checks every figure that comes back. How will you connect one?</p>
|
|
37
46
|
<div class="ai-options">
|
|
@@ -98,9 +107,12 @@ export function Welcome({ live, onDone }) {
|
|
|
98
107
|
setBusy("asking"); try { const r = await api.post("/api/run/discover", {}); if (r.status !== "passed") setErr("refused: " + (r.errors || []).join("; ")); await load(); } catch (e) { setErr(e.message); } setBusy("");
|
|
99
108
|
};
|
|
100
109
|
const body = {
|
|
101
|
-
ai: html`<${StepAi} s=${setup} reload=${load} />`,
|
|
110
|
+
ai: html`<${StepAi} s=${setup} reload=${load} hosted=${!!me.hosted} />`,
|
|
102
111
|
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
112
|
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>`,
|
|
113
|
+
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>
|
|
114
|
+
<div class="small muted" style="margin:14px 0 4px;text-transform:uppercase;letter-spacing:.04em">Your header</div><${ContactForm} setErr=${setErr} compact=${true} />
|
|
115
|
+
<${Positions} reload=${load} setErr=${setErr} compact=${true} /></div>`,
|
|
104
116
|
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
117
|
<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
118
|
<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 +127,7 @@ export function Welcome({ live, onDone }) {
|
|
|
115
127
|
posting: html`<${StepPosting} reload=${load} />`,
|
|
116
128
|
};
|
|
117
129
|
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">
|
|
130
|
+
<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
131
|
<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
132
|
<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
133
|
${err ? html`<p class="error">${err}</p>` : ""}
|