@gafj/gafj 0.1.21 → 0.1.28
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/credits.js +2 -1
- package/http/api.js +4 -1
- package/package.json +1 -1
- package/store/categories.js +52 -0
- package/store/reply.js +1 -0
- package/store/scoring.js +2 -2
- package/store/setup.js +4 -1
- package/ui/app.css +50 -5
- package/ui/app.js +20 -8
- package/ui/screens/dashboard.js +9 -1
- package/ui/screens/kb.js +38 -3
- package/ui/screens/postings.js +19 -20
- package/ui/screens/settings.js +7 -7
- package/ui/screens/welcome.js +61 -15
package/bridge/credits.js
CHANGED
|
@@ -53,7 +53,8 @@ async function relayFetch(homeDir, path, { method = "GET", body, fetchImpl, auth
|
|
|
53
53
|
async function runPacket(homeDir, { packet, fetchImpl, auth }) {
|
|
54
54
|
const started = Date.now();
|
|
55
55
|
const op_id = ulid(started);
|
|
56
|
-
const
|
|
56
|
+
const op = packet.operation || packet.op;
|
|
57
|
+
const r = await relayFetch(homeDir, "/op", { method: "POST", body: { op, op_id, packet }, fetchImpl, auth });
|
|
57
58
|
if (!r || typeof r.content !== "string") throw new CreditsError("relay reply had no content", 502);
|
|
58
59
|
return { content: r.content, provider: "credits", model: r.model || null, tokens_in: r.tokens_in ?? null, tokens_out: r.tokens_out ?? null, latency_ms: Date.now() - started, charged: r.charged, credits_left: r.credits_left };
|
|
59
60
|
}
|
package/http/api.js
CHANGED
|
@@ -118,7 +118,10 @@ route("POST", "/api/kb/:id/review", (c, p, b) => mutate(c.db, { candidate_id: c.
|
|
|
118
118
|
|
|
119
119
|
// postings: UI paste inserts accepted; accept promotes a proposed row
|
|
120
120
|
route("GET", "/api/postings", (c) => c.db.prepare(`SELECT p.id AS posting_id, p.status, p.company, p.title, p.location, p.comp_text, p.captured_at, p.taken_down_at,
|
|
121
|
-
(SELECT count(*) FROM scoring s WHERE s.posting_id = p.id AND s.status = 'frozen') > 0 AS scoring_frozen,
|
|
121
|
+
(SELECT count(*) FROM scoring s WHERE s.posting_id = p.id AND s.status = 'frozen') > 0 AS scoring_frozen,
|
|
122
|
+
(SELECT s.fit_score FROM scoring s WHERE s.posting_id = p.id AND s.status = 'frozen' ORDER BY s.scored_at DESC, s.rowid DESC LIMIT 1) AS fit_score,
|
|
123
|
+
(SELECT s.verdict FROM scoring s WHERE s.posting_id = p.id AND s.status = 'frozen' ORDER BY s.scored_at DESC, s.rowid DESC LIMIT 1) AS verdict,
|
|
124
|
+
a.id AS application_id FROM posting p LEFT JOIN application a ON a.posting_id = p.id
|
|
122
125
|
WHERE p.candidate_id = ? ORDER BY p.captured_at DESC`).all(c.candidate_id).map((r) => ({ ...r, scoring_frozen: !!r.scoring_frozen })));
|
|
123
126
|
route("GET", "/api/postings/:id", (c, p) => {
|
|
124
127
|
const r = c.db.prepare("SELECT id AS posting_id, status, company, title, location, comp_text, source_url, captured_at, taken_down_at, raw_text FROM posting WHERE id = ? AND candidate_id = ?").get(p.id, c.candidate_id);
|
package/package.json
CHANGED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The categories operation's reply: the model reads a posting and proposes the five to eight
|
|
5
|
+
* things the employer is really asking for, each with its verbatim lines. Saving one records the
|
|
6
|
+
* attempt on its run and lands a proposed scoring row through the same door the paste route and
|
|
7
|
+
* MCP use; every evidence line must be a substring of the posting or the whole reply is refused.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const { ulid } = require("../core/ulid");
|
|
11
|
+
const { extractJson } = require("../core/extract");
|
|
12
|
+
const { SCHEMAS, validate } = require("../core/schemas");
|
|
13
|
+
const { withTx } = require("./tx");
|
|
14
|
+
const { appendEvent } = require("./events");
|
|
15
|
+
const { TransitionError, NotFound } = require("./transitions");
|
|
16
|
+
const { proposeCategories } = require("./proposals");
|
|
17
|
+
|
|
18
|
+
const MAX_ATTEMPTS = 3;
|
|
19
|
+
|
|
20
|
+
function saveCategories(db, { run_id, content, route, actor, now, provider, model, tokens_in, tokens_out, latency_ms }) {
|
|
21
|
+
return withTx(db, (d) => {
|
|
22
|
+
const run = d.prepare("SELECT * FROM ai_run WHERE id = ?").get(run_id);
|
|
23
|
+
if (!run) throw new NotFound("run");
|
|
24
|
+
if (run.operation !== "categories") throw new TransitionError("this run is not a categories run; use the document door");
|
|
25
|
+
const attempts = d.prepare("SELECT count(*) c FROM ai_attempt WHERE ai_run_id = ?").get(run.id).c;
|
|
26
|
+
if (attempts >= MAX_ATTEMPTS) return { run_id: run.id, status: "blocked", attempt: attempts, stop: true, errors: ["three attempts used; open a fresh packet"] };
|
|
27
|
+
const attemptNo = attempts + 1;
|
|
28
|
+
const ex = extractJson(content);
|
|
29
|
+
const errors = [];
|
|
30
|
+
let parsed = null;
|
|
31
|
+
if (ex.error) errors.push("schema: " + ex.error + (typeof content === "string" && content.trim() ? ` (reply began: "${content.trim().slice(0, 160).replace(/\s+/g, " ")}")` : " (the reply was empty)"));
|
|
32
|
+
else {
|
|
33
|
+
parsed = ex.value;
|
|
34
|
+
for (const e of validate(parsed, SCHEMAS.categories)) errors.push(`${e.path}: ${e.message}`);
|
|
35
|
+
}
|
|
36
|
+
let result = null;
|
|
37
|
+
if (parsed && !errors.length) {
|
|
38
|
+
try { result = proposeCategories(d, { candidate_id: run.candidate_id, posting_id: run.posting_id, categories: parsed.categories, now, actor }); }
|
|
39
|
+
catch (e) { errors.push(e.message); }
|
|
40
|
+
}
|
|
41
|
+
const ok = !!result;
|
|
42
|
+
const attemptId = ulid(Date.parse(now) + 100 + attemptNo);
|
|
43
|
+
d.prepare(`INSERT INTO ai_attempt (id, ai_run_id, attempt_no, provider, model, tokens_in, tokens_out, latency_ms, output_hash, result, error_code, started_at, finished_at)
|
|
44
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(attemptId, run.id, attemptNo, provider || null, model || null, tokens_in ?? null, tokens_out ?? null, latency_ms ?? null, null, ok ? "passed" : "blocked", ok ? null : (ex.error ? "schema" : "evidence"), now, now);
|
|
45
|
+
const final = ok ? "passed" : attemptNo >= MAX_ATTEMPTS ? "blocked" : "pending";
|
|
46
|
+
d.prepare("UPDATE ai_run SET final_result = ?, finished_at = CASE WHEN ? = 'pending' THEN finished_at ELSE ? END WHERE id = ?").run(final, final, now, run.id);
|
|
47
|
+
appendEvent(d, { candidate_id: run.candidate_id, entity: "ai_run", entity_id: run.id, event: ok ? "categories_saved" : "categories_blocked", actor_type: actor.type, actor_ref: actor.ref, payload: { attempt: attemptNo, categories: result ? result.categories : 0, errors: errors.length, route }, at: now });
|
|
48
|
+
return { run_id: run.id, status: ok ? "passed" : "blocked", attempt: attemptNo, stop: attemptNo >= MAX_ATTEMPTS && !ok, errors, scoring_id: result ? result.scoring_id : null, categories: result ? result.categories : 0 };
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
module.exports = { saveCategories };
|
package/store/reply.js
CHANGED
|
@@ -13,6 +13,7 @@ function saveReply(db, a) {
|
|
|
13
13
|
const run = a.run_id ? db.prepare("SELECT operation FROM ai_run WHERE id = ?").get(a.run_id) : null;
|
|
14
14
|
if (run && run.operation === "extract") return require("./onboarding").saveExtraction(db, a);
|
|
15
15
|
if (run && run.operation === "discover") return require("./discover").saveDiscovery(db, a);
|
|
16
|
+
if (run && run.operation === "categories") return require("./categories").saveCategories(db, a);
|
|
16
17
|
return ingestDocument(db, a);
|
|
17
18
|
}
|
|
18
19
|
|
package/store/scoring.js
CHANGED
|
@@ -53,7 +53,7 @@ function proposeFromScan(db, { posting_id, now, actor }) {
|
|
|
53
53
|
const title = String(p.title || "").toLowerCase();
|
|
54
54
|
const add = (line, weight, hard) => {
|
|
55
55
|
const l = String(line).trim();
|
|
56
|
-
const name = l.replace(/^[-*•\u2022
|
|
56
|
+
const name = l.replace(/^(?:[-*•\u2022]+\s*|\d{1,2}[.)]\s+)/, "").replace(/[^\w&/ ,'()+%$-]+/g, " ").replace(/\s+/g, " ").trim();
|
|
57
57
|
if (!l || l.length < 12 || seen.has(l) || cats.length >= 8 || HEADING.test(name) || name.split(/\s+/).length < 3) return;
|
|
58
58
|
if (title && name.toLowerCase().includes(title)) return; // the posting's own title line is not a requirement
|
|
59
59
|
seen.add(l);
|
|
@@ -70,7 +70,7 @@ function proposeFromScan(db, { posting_id, now, actor }) {
|
|
|
70
70
|
for (const l of all) {
|
|
71
71
|
if (cats.length >= 5) break;
|
|
72
72
|
const t = String(l).trim();
|
|
73
|
-
const name = t.replace(/^[-*•\u2022
|
|
73
|
+
const name = t.replace(/^(?:[-*•\u2022]+\s*|\d{1,2}[.)]\s+)/, "").replace(/\s+/g, " ").trim();
|
|
74
74
|
if (t.length < 8 || seen.has(t) || HEADING.test(name) || META.test(t)) continue;
|
|
75
75
|
seen.add(t);
|
|
76
76
|
cats.push({ category: name.slice(0, 80), weight: 5, jd_evidence: [t.slice(0, 400)], is_hard_gate: false });
|
package/store/setup.js
CHANGED
|
@@ -27,8 +27,11 @@ function setupView(db, homeDir, candidate_id) {
|
|
|
27
27
|
const aiChosen = !!(cfg.setup && cfg.setup.ai_chosen) && usable;
|
|
28
28
|
const pos = require("./positions").counts(db, candidate_id);
|
|
29
29
|
const posDone = pos.total > 0 && pos.proposed === 0;
|
|
30
|
+
const bal = cfg.credits && cfg.credits.last_balance;
|
|
31
|
+
const creditsDone = !!(bal && Number(bal.available) > 0);
|
|
30
32
|
const steps = [
|
|
31
33
|
{ key: "ai", title: "Tell GAF-J which AI you use", done: aiChosen, detail: aiChosen ? `Using ${ROUTE_LABEL[cfg.route] || cfg.route}` : null },
|
|
34
|
+
{ key: "credits", title: "Get credits", done: creditsDone, detail: bal ? `${bal.available} available` : null },
|
|
32
35
|
{ key: "upload", title: "Upload your resumes", done: sources > 0, detail: sources ? `${sources} file${sources === 1 ? "" : "s"} uploaded` : null },
|
|
33
36
|
{ key: "extract", title: "Extract records from them", done: extracted > 0 || pending > 0 || confirmed > 0, detail: extracted ? `${extracted} extraction${extracted === 1 ? "" : "s"} saved` : null },
|
|
34
37
|
{ 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 },
|
|
@@ -37,7 +40,7 @@ function setupView(db, homeDir, candidate_id) {
|
|
|
37
40
|
{ key: "posting", title: "Paste your first job posting", done: postings > 0, detail: postings ? `${postings} posting${postings === 1 ? "" : "s"}` : null },
|
|
38
41
|
];
|
|
39
42
|
// a hosted account signed in to use the included AI; which AI is not a question there (Paste stays a Settings option)
|
|
40
|
-
const shown = cfg.hosted ? steps.filter((s) => s.key !== "ai") : steps;
|
|
43
|
+
const shown = cfg.hosted ? steps.filter((s) => s.key !== "ai") : steps.filter((s) => s.key !== "credits");
|
|
41
44
|
const next = shown.find((s) => !s.done);
|
|
42
45
|
return { steps: shown, next: next ? next.key : null, complete: !next, dismissed: !!(cfg.setup && cfg.setup.dismissed), route: cfg.route || "paste" };
|
|
43
46
|
}
|
package/ui/app.css
CHANGED
|
@@ -53,7 +53,10 @@ textarea { width: 100%; min-height: 120px; font-family: var(--mono); font-size:
|
|
|
53
53
|
.rail .brand .mark { width: 40px; height: 40px; border-radius: 9px; margin-right: 10px; border: 1px solid #333744; }
|
|
54
54
|
.rail .brand span { flex: 1; }
|
|
55
55
|
.rail nav, .rail > a { display: flex; flex-direction: column; gap: 2px; }
|
|
56
|
-
.rail a { color: var(--rail-muted); padding: 9px 10px; border-radius: 6px; display:
|
|
56
|
+
.rail a { color: var(--rail-muted); padding: 9px 10px; border-radius: 6px; display: flex; align-items: center; gap: 9px; font-size: 13.5px; }
|
|
57
|
+
.rail a .ico { flex: none; opacity: .75; }
|
|
58
|
+
.rail a.active .ico { opacity: 1; }
|
|
59
|
+
.rail a .short { display: none; }
|
|
57
60
|
.rail a.active { background: var(--rail-active); color: var(--rail-text); font-weight: 600; }
|
|
58
61
|
.rail a:hover { background: var(--rail-active); color: var(--rail-text); text-decoration: none; }
|
|
59
62
|
.rail .foot { margin-top: auto; font-size: 11.5px; color: var(--rail-dim); display: flex; flex-direction: column; gap: 4px; }
|
|
@@ -62,7 +65,7 @@ textarea { width: 100%; min-height: 120px; font-family: var(--mono); font-size:
|
|
|
62
65
|
.rail .theme-toggle { background: transparent; border: 1px solid #333744; color: var(--rail-muted); padding: 3px 6px; font-size: 11px; border-radius: 5px; }
|
|
63
66
|
.rail .theme-toggle:hover { border-color: var(--accent); color: var(--rail-text); }
|
|
64
67
|
|
|
65
|
-
.main { padding: 28px 32px; max-width: 1200px; background: var(--surface); }
|
|
68
|
+
.main { padding: 28px 32px; max-width: 1200px; background: var(--surface); min-width: 0; }
|
|
66
69
|
h1 { font-size: 22px; margin: 0 0 4px; font-weight: 700; }
|
|
67
70
|
h2 { font-family: var(--mono); font-size: 11px; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); margin: 22px 0 8px; font-weight: 500; }
|
|
68
71
|
h3 { font-size: 15px; margin: 12px 0 6px; font-weight: 600; }
|
|
@@ -131,8 +134,9 @@ table.cats { border-collapse: collapse; width: 100%; font-size: 13px; }
|
|
|
131
134
|
table.cats th, table.cats td { border-bottom: 1px solid var(--line); padding: 6px; text-align: left; vertical-align: top; }
|
|
132
135
|
table.cats th { font-family: var(--mono); font-size: 10.5px; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); font-weight: 500; }
|
|
133
136
|
table.cats input:not([type=checkbox]) { width: 100%; box-sizing: border-box; }
|
|
134
|
-
table.cats td:first-child { min-width:
|
|
135
|
-
table.cats td:nth-child(2), table.cats td:nth-child(3) { width:
|
|
137
|
+
table.cats td:first-child { min-width: 220px; }
|
|
138
|
+
table.cats td:nth-child(2), table.cats td:nth-child(3) { width: 64px; }
|
|
139
|
+
table.cats input.narrow { min-width: 54px; padding: 6px 6px; }
|
|
136
140
|
table.cats td:nth-child(5) { font-family: var(--sans); font-size: 12.5px; color: var(--muted); }
|
|
137
141
|
input.narrow { width: 76px; }
|
|
138
142
|
pre.wrap { white-space: pre-wrap; }
|
|
@@ -162,6 +166,7 @@ mark { background: var(--good-bg); color: var(--good-bright); border-radius: 3px
|
|
|
162
166
|
.welcome-head { display: flex; align-items: center; gap: 14px; margin-bottom: 18px; }
|
|
163
167
|
.welcome-head img { border-radius: 9px; }
|
|
164
168
|
.welcome-head a { margin-left: auto; }
|
|
169
|
+
.stepper-compact { display: none; }
|
|
165
170
|
.stepper { list-style: none; margin: 0 0 16px; padding: 0; display: flex; flex-wrap: wrap; gap: 6px 14px; font-size: 12.5px; color: var(--dim); }
|
|
166
171
|
.stepper li { display: inline-flex; align-items: center; gap: 6px; }
|
|
167
172
|
.stepper li.done { color: var(--muted); }
|
|
@@ -176,7 +181,8 @@ mark { background: var(--good-bg); color: var(--good-bright); border-radius: 3px
|
|
|
176
181
|
.welcome-head .links { margin-left: auto; display: flex; gap: 14px; }
|
|
177
182
|
.stepper li.can { cursor: pointer; }
|
|
178
183
|
.stepper li.can:hover { color: var(--accent); }
|
|
179
|
-
.welcome-nav { margin
|
|
184
|
+
.welcome-nav { margin: 0 0 6px; justify-content: space-between; align-items: baseline; }
|
|
185
|
+
.welcome-nav h3 { margin: 0; }
|
|
180
186
|
/* a switch: one provider is on at a time; off everywhere is a warning, not a state to sit in */
|
|
181
187
|
.switch { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; }
|
|
182
188
|
.switch input { position: absolute; opacity: 0; width: 0; height: 0; }
|
|
@@ -186,6 +192,45 @@ mark { background: var(--good-bg); color: var(--good-bright); border-radius: 3px
|
|
|
186
192
|
.switch input:checked + .track::after { left: 18px; }
|
|
187
193
|
.switch input:focus-visible + .track { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
188
194
|
/* a small spinner beside anything that is with the model */
|
|
195
|
+
.tablewrap { overflow-x: auto; max-width: 100%; margin: 4px 0; }
|
|
196
|
+
.fit { font-family: var(--mono); font-weight: 600; min-width: 34px; text-align: center; padding: 2px 6px; border-radius: 6px; background: var(--surface-2); border: 1px solid var(--line-2); }
|
|
197
|
+
.fit.good { color: var(--accent); border-color: var(--accent); }
|
|
198
|
+
.row.wide > input { flex: 1; min-width: 220px; }
|
|
199
|
+
form.answer { margin: 6px 0 2px; display: grid; gap: 6px; }
|
|
200
|
+
form.answer input, form.answer textarea { width: 100%; box-sizing: border-box; }
|
|
201
|
+
.bar { display: inline-block; width: 160px; height: 8px; border-radius: 4px; background: var(--line-2); overflow: hidden; vertical-align: middle; }
|
|
202
|
+
.bar > span { display: block; height: 100%; background: var(--accent); border-radius: 4px; transition: width .9s linear; }
|
|
189
203
|
.spin { display: inline-block; width: 10px; height: 10px; border: 2px solid var(--line-2); border-top-color: var(--accent); border-radius: 50%; animation: spin .8s linear infinite; vertical-align: -1px; }
|
|
190
204
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
191
205
|
@media (prefers-reduced-motion: reduce) { .spin { animation: none; border-top-color: var(--line-2); } }
|
|
206
|
+
|
|
207
|
+
/* Phones: the rail becomes a bottom bar; everything else stacks. */
|
|
208
|
+
@media (max-width: 720px) {
|
|
209
|
+
html, body { overflow-x: hidden; }
|
|
210
|
+
.layout { grid-template-columns: 1fr; }
|
|
211
|
+
.rail { position: fixed; left: 0; right: 0; bottom: 0; z-index: 20; flex-direction: row; gap: 0; padding: 4px 2px calc(4px + env(safe-area-inset-bottom)); border-top: 1px solid #2a2e38; }
|
|
212
|
+
.rail .brand, .rail .foot { display: none; }
|
|
213
|
+
.rail a { flex: 1; flex-direction: column; gap: 3px; padding: 6px 2px; font-size: 10.5px; border-radius: 8px; text-align: center; letter-spacing: .01em; }
|
|
214
|
+
.rail a .full { display: none; }
|
|
215
|
+
.rail a .short { display: block; }
|
|
216
|
+
.rail a .ico { width: 20px; height: 20px; }
|
|
217
|
+
.main { padding: 16px 14px calc(76px + env(safe-area-inset-bottom)); max-width: none; }
|
|
218
|
+
.grid2 { grid-template-columns: 1fr; gap: 12px; }
|
|
219
|
+
h1 { font-size: 20px; }
|
|
220
|
+
.card { padding: 12px; }
|
|
221
|
+
.row > input, .row > select, form.inline input, form.inline select { min-width: 0; max-width: 100%; flex: 1 1 140px; }
|
|
222
|
+
.row.wide > input { min-width: 0; }
|
|
223
|
+
textarea { max-width: 100%; }
|
|
224
|
+
pre { max-width: 100%; }
|
|
225
|
+
.tablewrap { overflow-x: auto; max-width: 100%; }
|
|
226
|
+
.welcome { padding: 18px 14px 40px; }
|
|
227
|
+
.stepper { display: none; }
|
|
228
|
+
.stepper-compact { display: flex; align-items: center; gap: 10px; margin: 0 0 12px; font: 500 12px var(--mono); color: var(--muted); }
|
|
229
|
+
.stepper-compact .bar { flex: 1; width: auto; }
|
|
230
|
+
.welcome-head { flex-wrap: wrap; gap: 10px; }
|
|
231
|
+
.welcome-head > div { flex: 1 1 200px; min-width: 0; }
|
|
232
|
+
.welcome-head .links { flex-direction: column; gap: 4px; white-space: nowrap; align-items: flex-end; margin-left: 0; }
|
|
233
|
+
.welcome-head .small { white-space: nowrap; }
|
|
234
|
+
.ai-options { grid-template-columns: 1fr; }
|
|
235
|
+
.bar { width: 100px; }
|
|
236
|
+
}
|
package/ui/app.js
CHANGED
|
@@ -7,19 +7,31 @@ import { Document } from "./screens/document.js";
|
|
|
7
7
|
import { Postings } from "./screens/postings.js";
|
|
8
8
|
import { KB } from "./screens/kb.js";
|
|
9
9
|
import { Settings } from "./screens/settings.js";
|
|
10
|
-
import { Welcome } from "./screens/welcome.js";
|
|
10
|
+
import { Welcome, wizardHold } from "./screens/welcome.js";
|
|
11
|
+
|
|
12
|
+
// line icons for the nav; on a phone the rail folds into a bottom bar of these with short labels
|
|
13
|
+
const ICON = {
|
|
14
|
+
home: "M3 11 12 3l9 8v10h-6v-6H9v6H3z",
|
|
15
|
+
apps: "M4 7h16v13H4zM9 7V4h6v3M4 12h16",
|
|
16
|
+
interviews: "M4 5h16v15H4zM4 10h16M8 3v4M16 3v4",
|
|
17
|
+
postings: "M5 4h14v16H5zM8 8h8M8 12h8M8 16h5",
|
|
18
|
+
kb: "M4 4h7a2 2 0 0 1 2 2v14a2 2 0 0 0-2-2H4zM20 4h-7a2 2 0 0 0-2 2v14a2 2 0 0 1 2-2h7z",
|
|
19
|
+
settings: "M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8zM19 12l2-1-1.5-3-2.2.6a7 7 0 0 0-1.6-1l-.3-2.3h-3.4l-.3 2.3a7 7 0 0 0-1.6 1L7.9 8 6.4 11l2 1a7 7 0 0 0 0 2l-2 1 1.5 3 2.2-.6a7 7 0 0 0 1.6 1l.3 2.3h3.4l.3-2.3a7 7 0 0 0 1.6-1l2.2.6 1.5-3-2-1a7 7 0 0 0 0-2z",
|
|
20
|
+
};
|
|
21
|
+
const Icon = ({ d }) => html`<svg class="ico" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" stroke-linecap="round" aria-hidden="true"><path d=${d} /></svg>`;
|
|
11
22
|
|
|
12
23
|
function Rail({ route, me }) {
|
|
13
24
|
const is = (p) => (route.path === p || (p !== "/" && route.path.startsWith(p)) ? "active" : "");
|
|
14
25
|
const theme = useTheme();
|
|
26
|
+
const item = (href, key, full, short) => html`<a href=${href} class=${is(href.slice(1))}><${Icon} d=${ICON[key]} /><span class="full">${full}</span><span class="short">${short}</span></a>`;
|
|
15
27
|
return html`<nav class="rail">
|
|
16
28
|
<div class="brand"><img class="mark" src="/icon-192.png" alt="" width="40" height="40" /><span>GAF-J</span><button class="theme-toggle" title=${theme.dark ? "switch to light" : "switch to dark"} onClick=${theme.toggle}>${theme.dark ? "☾" : "☀"}</button></div>
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
29
|
+
${item("#/", "home", "Dashboard", "Home")}
|
|
30
|
+
${item("#/applications", "apps", "Applications", "Apps")}
|
|
31
|
+
${item("#/interviews", "interviews", "Interviews", "Prep")}
|
|
32
|
+
${item("#/postings", "postings", "Postings", "Jobs")}
|
|
33
|
+
${item("#/kb", "kb", "Knowledge base", "Record")}
|
|
34
|
+
${item("#/settings", "settings", "Settings", "Settings")}
|
|
23
35
|
<div class="foot">
|
|
24
36
|
<div class="name">${me ? me.name : ""}</div>
|
|
25
37
|
<div>AI: ${me ? me.route : ""}</div>
|
|
@@ -60,7 +72,7 @@ function App() {
|
|
|
60
72
|
const [me, setMe] = useState(null);
|
|
61
73
|
const [gate, setGate] = useState(null); // null: unknown; true: show the first-run screen; false: the app
|
|
62
74
|
useEffect(() => { api.get("/api/me").then(setMe).catch(() => setMe({ name: "no session", route: "", version: "" })); }, []);
|
|
63
|
-
useEffect(() => { if (gate === false) return; api.get("/api/setup").then((s) => setGate(!s.complete && !s.dismissed)).catch(() => setGate(false)); }, [live]);
|
|
75
|
+
useEffect(() => { if (gate === false || wizardHold.on) return; api.get("/api/setup").then((s) => setGate(!s.complete && !s.dismissed)).catch(() => setGate(false)); }, [live]);
|
|
64
76
|
const seg = route.path.split("/").filter(Boolean);
|
|
65
77
|
if (gate === null) return html`<div class="welcome"><p class="muted">loading</p></div>`;
|
|
66
78
|
if (gate && seg[0] !== "settings") return html`<${Welcome} live=${live} onDone=${() => setGate(false)} />`;
|
package/ui/screens/dashboard.js
CHANGED
|
@@ -40,11 +40,12 @@ function Setup({ s, reload }) {
|
|
|
40
40
|
|
|
41
41
|
export function Dashboard({ live }) {
|
|
42
42
|
const [snap, setSnap] = useState(null);
|
|
43
|
+
const [postings, setPostings] = useState([]);
|
|
43
44
|
const [setup, setSetup] = useState(null);
|
|
44
45
|
const [msg, setMsg] = useState("");
|
|
45
46
|
const [err, setErr] = useState("");
|
|
46
47
|
const loadSetup = () => api.get("/api/setup").then(setSetup).catch(() => setSetup(null));
|
|
47
|
-
useEffect(() => { api.get("/api/snapshot").then(setSnap).catch((e) => setErr(e.message)); loadSetup(); }, [live]);
|
|
48
|
+
useEffect(() => { api.get("/api/snapshot").then(setSnap).catch((e) => setErr(e.message)); api.get("/api/postings").then(setPostings).catch(() => {}); loadSetup(); }, [live]);
|
|
48
49
|
const act = async (path, label) => { setMsg(""); setErr(""); try { const r = await api.post(path); setMsg(`${label}: ${r.file}`); } catch (e) { setErr(e.message); } };
|
|
49
50
|
if (err && !snap) return html`<p class="error">${err}</p>`;
|
|
50
51
|
if (!snap) return html`<p class="muted">loading</p>`;
|
|
@@ -54,6 +55,13 @@ export function Dashboard({ live }) {
|
|
|
54
55
|
<div class="sub">${new Date(snap.now).toLocaleString()}</div>
|
|
55
56
|
${setup && !setup.complete && !setup.dismissed ? html`<${Setup} s=${setup} reload=${loadSetup} />` : ""}
|
|
56
57
|
${setup && !setup.complete && setup.dismissed ? html`<p class="small muted">Setup is hidden. <a href="#/" onClick=${async (e) => { e.preventDefault(); await api.post("/api/setup/dismiss", { dismissed: false }); loadSetup(); }}>Show the steps again</a>.</p>` : ""}
|
|
58
|
+
<h2>Postings (${postings.length})</h2>
|
|
59
|
+
<div class="card">${postings.length ? html`<ul class="list">${postings.slice(0, 12).map((p) => html`<li key=${p.posting_id}>
|
|
60
|
+
${p.fit_score != null ? html`<span class=${"fit " + (p.verdict === "pursue" ? "good" : "")} title=${p.verdict}>${Number(p.fit_score).toFixed(1)}</span>` : html`<span class="fit muted" title="not scored yet">–</span>`}
|
|
61
|
+
<a href=${"#/postings/" + p.posting_id}><b>${p.company}</b>: ${p.title}</a>
|
|
62
|
+
${p.verdict ? html`<span class=${"tag " + (p.verdict === "pursue" ? "passed" : "warn")}>${p.verdict}</span>` : html`<span class="tag warn">unscored</span>`}
|
|
63
|
+
${p.application_id ? html`<a class="small" href=${"#/applications/" + p.application_id}>application</a>` : p.status === "accepted" && p.fit_score != null ? html`<span class="small muted">open it to log an application and make the resume</span>` : ""}</li>`)}</ul>`
|
|
64
|
+
: html`<span class="muted">no postings yet; paste one under Postings</span>`}</div>
|
|
57
65
|
<h2>Needs you now</h2>
|
|
58
66
|
<div class="card">${snap.needs_you.length ? html`<ul class="list">${snap.needs_you.map((n, i) => html`<li key=${i}>
|
|
59
67
|
<span class=${"need " + (n.kind === "thankyou_due" || n.kind === "review" ? "soft" : "")}>●</span> <a href=${link(n)}>${n.text}</a></li>`)}</ul>` : html`<span class="muted">nothing waiting on you</span>`}</div>
|
package/ui/screens/kb.js
CHANGED
|
@@ -30,6 +30,11 @@ export function Onboarding({ d, reload, setErr, route, compact }) {
|
|
|
30
30
|
} catch (e) { setRunning((x) => ({ ...x, [s.source_document_id]: "failed: " + e.message })); }
|
|
31
31
|
};
|
|
32
32
|
const isRunning = (id) => running[id] && typeof running[id] === "object";
|
|
33
|
+
// a progress bar with no signal from the model: it fills against the time a file of this size usually takes,
|
|
34
|
+
// eases toward ninety-five percent past that, and completes only when the reply lands
|
|
35
|
+
const expectMs = (chars) => 12000 + (Number(chars) || 0) * 9;
|
|
36
|
+
const progress = (id, chars) => { const t = Date.now() - running[id].started; const e = expectMs(chars); return t < e ? (t / e) * 85 : 85 + 10 * (1 - Math.exp(-(t - e) / e)); };
|
|
37
|
+
const Bar = ({ id, chars }) => html`<span class="bar" title=${`usually about ${Math.round(expectMs(chars) / 1000 / 10) * 10} seconds for a file this size`}><span style=${`width:${progress(id, chars).toFixed(1)}%`}></span></span>`;
|
|
33
38
|
const extract = async (s) => {
|
|
34
39
|
if (!direct) { setModal({ source: s }); return; }
|
|
35
40
|
await runOne(s);
|
|
@@ -65,7 +70,8 @@ export function Onboarding({ d, reload, setErr, route, compact }) {
|
|
|
65
70
|
${b ? html`<ul class="list">${b.sources.map((s) => html`<li key=${s.source_document_id}><b>${s.filename}</b> <span class="tag">${s.kind}</span>
|
|
66
71
|
${s.empty ? html`<span class="tag blocked">no text</span>` : html`<span class="muted small">${s.chars} chars</span>`}
|
|
67
72
|
<span class="muted small">${s.pending} pending, ${s.runs} runs</span>
|
|
68
|
-
${!s.empty ? html`<button class="small" disabled=${isRunning(s.source_document_id)} onClick=${() => extract(s)}>${isRunning(s.source_document_id) ? html`<span class="spin"></span>
|
|
73
|
+
${!s.empty ? html`<button class="small" disabled=${isRunning(s.source_document_id)} onClick=${() => extract(s)}>${isRunning(s.source_document_id) ? html`<span class="spin"></span> reading · ${elapsed(Date.now() - running[s.source_document_id].started)}` : direct ? "Extract" : "Extract (paste)"}</button>` : ""}
|
|
74
|
+
${isRunning(s.source_document_id) ? html`<${Bar} id=${s.source_document_id} chars=${s.chars} />` : ""}
|
|
69
75
|
<button class="small danger" title="remove this file and anything proposed from it; confirmed records stay" onClick=${async () => { if (!confirm(`Remove ${s.filename}? Records already confirmed stay; pending rows from this file go.`)) return; try { await api.del(`/api/sources/${s.source_document_id}`); await reload(); } catch (e) { setErr(e.message); } }}>remove</button>
|
|
70
76
|
${running[s.source_document_id] && !isRunning(s.source_document_id) ? html`<span class="small muted">${running[s.source_document_id]}</span>` : ""}</li>`)}
|
|
71
77
|
${b.sources.length ? "" : html`<li class="muted">upload a resume to start</li>`}</ul>` : ""}
|
|
@@ -90,6 +96,36 @@ function Excerpt({ text, span }) {
|
|
|
90
96
|
const EMPTY = { title: "", company: "", role: "", dates: "", summary: "", metrics: "", verbs: "", wordings: "", tenure: "" };
|
|
91
97
|
const toDraft = (f) => ({ title: f.title, company: f.company, role: f.role, dates: f.dates, summary: f.summary,
|
|
92
98
|
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() } : {}) });
|
|
99
|
+
/** One box per question. The kind says which field the answer is; the record's own details ride along unchanged. */
|
|
100
|
+
const ANSWER = {
|
|
101
|
+
tenure: { hint: "the phrase you would put on a resume: nine years in operations roles", field: "tenure" },
|
|
102
|
+
metric: { hint: "the figure with its unit, as you would state it: $430,000, 18%, 12-person team", field: "metric" },
|
|
103
|
+
scope: { hint: "one resume line that states the size: led a team of 12 across 3 sites", field: "wording" },
|
|
104
|
+
skill: { hint: "one resume line that names the skill and where it showed", field: "wording" },
|
|
105
|
+
context: { hint: "a few sentences, in your own words", field: "summary", long: true },
|
|
106
|
+
story: { hint: "a few sentences, in your own words: the situation, what you did, what came of it", field: "summary", long: true },
|
|
107
|
+
};
|
|
108
|
+
export function AnswerBox({ q, onSubmit, onSkip }) {
|
|
109
|
+
const [text, setText] = useState("");
|
|
110
|
+
const a = ANSWER[q.kind] || ANSWER.context;
|
|
111
|
+
const submit = (e) => {
|
|
112
|
+
e.preventDefault();
|
|
113
|
+
const v = text.trim();
|
|
114
|
+
if (!v) return;
|
|
115
|
+
const d = { ...fromDraft(q.draft), metrics: [], verbs: [], wordings: [], tenure: "" };
|
|
116
|
+
const base = { title: d.title, company: d.company, role: d.role, dates: d.dates, summary: "", metrics: [], verbs: [], wordings: [], tags: [] };
|
|
117
|
+
if (a.field === "tenure") base.tenure = v;
|
|
118
|
+
else if (a.field === "metric") base.metrics = [v];
|
|
119
|
+
else if (a.field === "wording") base.wordings = [v];
|
|
120
|
+
else base.summary = v;
|
|
121
|
+
onSubmit(base);
|
|
122
|
+
};
|
|
123
|
+
return html`<form class="answer" onSubmit=${submit}>
|
|
124
|
+
${a.long ? html`<textarea required placeholder=${a.hint} value=${text} onInput=${(e) => setText(e.target.value)} rows="3"></textarea>` : html`<input required placeholder=${a.hint} value=${text} onInput=${(e) => setText(e.target.value)} />`}
|
|
125
|
+
<div class="row"><button class="primary small">Add answer</button>${onSkip ? html`<button type="button" class="small" onClick=${onSkip}>Skip</button>` : ""}<span class="small muted">lands as a pending row on this record; nothing changes until you confirm it</span></div>
|
|
126
|
+
</form>`;
|
|
127
|
+
}
|
|
128
|
+
|
|
93
129
|
/** The resume header: name, email, phone, city, links. Prefilled from the first resume; yours to change here or in Settings. */
|
|
94
130
|
export function ContactForm({ setErr, compact }) {
|
|
95
131
|
const [c, setC] = useState(null);
|
|
@@ -214,8 +250,7 @@ export function KB({ live }) {
|
|
|
214
250
|
<div class="row"><button disabled=${(d.questions || []).length > 0 || d.confirmed === 0} onClick=${async () => { if (me && (me.route === "byo_key" || me.route === "credits")) { try { setErr(""); const r = await api.post("/api/run/discover", {}); if (r.status !== "passed") setErr("refused: " + (r.errors || []).join("; ")); await load(); } catch (e) { setErr(e.message); } } else setModal({ op: "discover" }); }}>Ask what's missing</button>
|
|
215
251
|
<span class="small muted">${d.confirmed === 0 ? "confirm at least one record first" : (d.questions || []).length ? `${d.questions.length} open` : "twelve questions at most per round"}</span></div>
|
|
216
252
|
<ul class="list">${(d.questions || []).map((q) => html`<li key=${q.question_id}><div><span class="tag">${q.kind}</span> <b>${q.title}</b> <span class="muted">${q.company}, ${q.role}</span><br />${q.question}${q.why ? html` <span class="small muted">(${q.why})</span>` : ""}
|
|
217
|
-
|
|
218
|
-
: html`<div class="row"><button class="small" onClick=${() => setAnswering(q.question_id)}>Answer</button><button class="small danger" onClick=${() => post(`/api/kb/questions/${q.question_id}/dismiss`, {})}>Dismiss</button></div>`}</div></li>`)}
|
|
253
|
+
<${AnswerBox} q=${q} onSubmit=${(draft) => post(`/api/kb/questions/${q.question_id}/answer`, { draft })} onSkip=${() => post(`/api/kb/questions/${q.question_id}/dismiss`, {})} /></div></li>`)}
|
|
219
254
|
${(d.questions || []).length ? "" : html`<li class="muted">no open questions</li>`}</ul></div>
|
|
220
255
|
${modal && modal.op === "discover" ? html`<${PacketModal} op="discover" label="Discovery questions" target=${{}} onClose=${() => setModal(null)} onSaved=${load} />` : ""}
|
|
221
256
|
<h2>Holes (questions your own records raise)</h2>
|
package/ui/screens/postings.js
CHANGED
|
@@ -28,9 +28,9 @@ function CategoryEditor({ posting, scoring, onDone }) {
|
|
|
28
28
|
<td class="small mono">${c.jd_evidence.map((e, j) => html`<div key=${j}>"${e}"</div>`)}</td>
|
|
29
29
|
<td><button class="small" onClick=${() => setCats(cats.filter((_, j) => j !== i))}>drop</button></td></tr>`)}
|
|
30
30
|
</tbody></table>
|
|
31
|
-
<p class="small muted">Five to eight categories.
|
|
31
|
+
<p class="small muted">Five to eight categories. Every evidence line must be verbatim from the posting or scoring is refused.</p>
|
|
32
32
|
${err ? html`<p class="error">${err}</p>` : ""}
|
|
33
|
-
<div class="row"><button class="primary" disabled=${busy || cats.length < 5} onClick=${freeze}>
|
|
33
|
+
<div class="row"><button class="primary" disabled=${busy || cats.length < 5} onClick=${freeze}>${busy ? "scoring" : "Score my record against these"}</button><span class="small muted">weight is the employer's emphasis; score is how well your confirmed records meet it, computed unless you type one</span></div>
|
|
34
34
|
</div>`;
|
|
35
35
|
}
|
|
36
36
|
|
|
@@ -70,6 +70,8 @@ export function Postings({ live, id }) {
|
|
|
70
70
|
try {
|
|
71
71
|
const r = await api.post("/api/postings", form);
|
|
72
72
|
await api.post(`/api/postings/${r.posting_id}/scan`);
|
|
73
|
+
if (direct) { try { await api.post(`/api/run/categories?posting_id=${encodeURIComponent(r.posting_id)}`, {}); } catch (e3) { setErr("your AI could not propose categories (" + e3.message + "); the local scan's draft is scored instead"); } }
|
|
74
|
+
try { await api.post(`/api/postings/${r.posting_id}/freeze`, {}); } catch (e4) { /* fewer than five usable lines; the editor below says so */ }
|
|
73
75
|
setForm({ company: "", title: "", source_url: "", location: "", comp_text: "", raw_text: "" });
|
|
74
76
|
setGuessed({ company: true, title: true });
|
|
75
77
|
location.hash = "#/postings/" + r.posting_id;
|
|
@@ -82,20 +84,8 @@ export function Postings({ live, id }) {
|
|
|
82
84
|
return html`<div>
|
|
83
85
|
<h1>Postings</h1>
|
|
84
86
|
${err ? html`<p class="error">${err}</p>` : ""}${msg ? html`<p class="small muted">${msg}</p>` : ""}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
<h2>Paste posting</h2>
|
|
88
|
-
<form class="card" onSubmit=${store}>
|
|
89
|
-
<textarea required placeholder="Paste the whole job posting here, exactly as the site shows it. Company and title fill in below from what you paste." value=${form.raw_text} onInput=${(e) => onPaste(e.target.value)}></textarea>
|
|
90
|
-
<div class="row"><input required placeholder="company (guessed from the paste; fix if wrong)" value=${form.company} onInput=${(e) => { setGuessed({ ...guessed, company: false }); setForm({ ...form, company: e.target.value }); }} /><input required placeholder="title (guessed; fix if wrong)" value=${form.title} onInput=${(e) => { setGuessed({ ...guessed, title: false }); setForm({ ...form, title: e.target.value }); }} /></div>
|
|
91
|
-
<div class="row"><input placeholder="link to the posting (optional)" value=${form.source_url} onInput=${(e) => setForm({ ...form, source_url: e.target.value })} /></div>
|
|
92
|
-
<div class="row"><button class="primary">Store + scan</button><span class="small muted">stored on this PC and scored locally; no AI is called for this step</span></div>
|
|
93
|
-
</form>
|
|
94
|
-
<h2>Stored</h2>
|
|
95
|
-
<div class="card"><ul class="list">${rows.map((r) => html`<li key=${r.posting_id}><a href=${"#/postings/" + r.posting_id}>${r.company}: ${r.title}</a> <span class="tag">${r.status}</span> ${r.scoring_frozen ? html`<span class="tag passed">frozen</span>` : html`<span class="tag warn">unscored</span>`} ${r.application_id ? html`<a class="small" href=${"#/applications/" + r.application_id}>application</a>` : ""} ${r.taken_down_at ? html`<span class="tag warn">taken down</span>` : ""}</li>`)}${rows.length ? "" : html`<li class="muted">none yet</li>`}</ul></div>
|
|
96
|
-
</div>
|
|
97
|
-
<div>
|
|
98
|
-
${sel ? html`<h2>${sel.company}: ${sel.title}</h2>
|
|
87
|
+
${sel ? html`<p class="small"><a href="#/postings">← all postings</a></p>
|
|
88
|
+
<h2>${sel.company}: ${sel.title}</h2>
|
|
99
89
|
<div class="card">
|
|
100
90
|
<div class="row"><span class="tag">${sel.status}</span><span class="muted small">${[sel.location, sel.comp_text].filter(Boolean).join(" · ")}</span>
|
|
101
91
|
${sel.status === "proposed" ? html`<button class="primary" onClick=${() => act(`/api/postings/${sel.posting_id}/accept`)}>Accept</button>` : ""}
|
|
@@ -105,15 +95,24 @@ export function Postings({ live, id }) {
|
|
|
105
95
|
${modal ? html`<${PacketModal} op="categories" label="Categories" target=${{ posting_id: modal.posting_id }} onClose=${() => setModal(null)} onSaved=${load} />` : ""}
|
|
106
96
|
<details><summary>raw text (${sel.raw_text.length} chars)</summary><pre class="mono wrap">${sel.raw_text}</pre></details>
|
|
107
97
|
</div>
|
|
108
|
-
${frozen ? html`<div class="card"><b>
|
|
98
|
+
${frozen ? html`<div class="card"><b>Fit ${frozen.fit_score} of 10 · ${frozen.verdict.toUpperCase()}</b> <span class="muted small">${frozen.verdict_reason}</span>
|
|
109
99
|
<ul class="list">${frozen.categories.map((c, i) => html`<li key=${i}><span class="mono">w${c.weight} s${c.score}</span> ${c.category} ${c.is_hard_gate ? html`<span class="tag warn">hard gate</span>` : ""} <span class="small muted">${(c.evidence_ids || []).slice(0, 3).join(", ")}</span></li>`)}</ul>
|
|
110
100
|
${!rows.find((r) => r.posting_id === sel.posting_id && r.application_id) && sel.status === "accepted" ? html`<div class="row"><button class="primary" onClick=${async () => { const r = await act("/api/applications", { posting_id: sel.posting_id }); if (r) location.hash = "#/applications/" + r.application_id; }}>Log application</button></div>` : ""}
|
|
111
101
|
</div>` : ""}
|
|
112
102
|
<h3>${frozen ? "Re-score" : "Proposed categories"} ${proposed ? html`<span class="tag">draft from ${proposed.scored_at.slice(0, 10)}</span>` : ""}</h3>
|
|
113
103
|
${proposed || frozen ? html`<div class="card">${proposed && !frozen ? html`<p class="small muted">${direct ? "A quick local scan made this draft. \"Ask your AI for categories\" above replaces it with a read of the whole posting." : "A quick local scan made this draft; your AI can do better through the button above."}</p>` : ""}<${CategoryEditor} key=${(proposed || frozen).scoring_id} posting=${sel} scoring=${proposed || frozen} onDone=${load} /></div>` : html`<p class="muted">no categories yet; ask your AI above, or scan the posting</p>`}
|
|
114
104
|
${sel.gaps.length ? html`<h3>KB gaps (${sel.gaps.length})</h3><ul class="list">${sel.gaps.map((g) => html`<li key=${g.gap_id}>${g.question} <span class="small muted">"${g.requirement_text}"</span> ${g.resolved_at ? html`<span class="tag passed">answered</span>` : html`<a class="small" href="#/kb">answer</a>`}</li>`)}</ul>` : ""}
|
|
115
|
-
` : html`<
|
|
116
|
-
|
|
117
|
-
|
|
105
|
+
` : html`<div>
|
|
106
|
+
<h2>Paste posting</h2>
|
|
107
|
+
<form class="card" onSubmit=${store}>
|
|
108
|
+
<textarea required placeholder="Paste the whole job posting here, exactly as the site shows it. Company and title fill in below from what you paste." value=${form.raw_text} onInput=${(e) => onPaste(e.target.value)}></textarea>
|
|
109
|
+
<div class="row"><input required placeholder="company (guessed from the paste; fix if wrong)" value=${form.company} onInput=${(e) => { setGuessed({ ...guessed, company: false }); setForm({ ...form, company: e.target.value }); }} /><input required placeholder="title (guessed; fix if wrong)" value=${form.title} onInput=${(e) => { setGuessed({ ...guessed, title: false }); setForm({ ...form, title: e.target.value }); }} /></div>
|
|
110
|
+
<div class="row"><input placeholder="link to the posting (optional)" value=${form.source_url} onInput=${(e) => setForm({ ...form, source_url: e.target.value })} /></div>
|
|
111
|
+
<div class="row"><button class="primary">Store + scan</button><span class="small muted">stored, scanned, then your AI proposes what the employer wants and your record is scored against it</span></div>
|
|
112
|
+
</form>
|
|
113
|
+
<h2>Stored</h2>
|
|
114
|
+
<div class="card"><ul class="list">${rows.map((r) => html`<li key=${r.posting_id}><a href=${"#/postings/" + r.posting_id}>${r.company}: ${r.title}</a> <span class="tag">${r.status}</span> ${r.scoring_frozen ? html`<span class="tag passed">frozen</span>` : html`<span class="tag warn">unscored</span>`} ${r.application_id ? html`<a class="small" href=${"#/applications/" + r.application_id}>application</a>` : ""} ${r.taken_down_at ? html`<span class="tag warn">taken down</span>` : ""}</li>`)}${rows.length ? "" : html`<li class="muted">none yet</li>`}</ul></div>
|
|
115
|
+
|
|
116
|
+
</div>`}
|
|
118
117
|
</div>`;
|
|
119
118
|
}
|
package/ui/screens/settings.js
CHANGED
|
@@ -51,7 +51,7 @@ export function Settings({ live }) {
|
|
|
51
51
|
<div class="card">
|
|
52
52
|
${(s.hosted
|
|
53
53
|
? [["credits", "Credits: your documents run on the included AI, paid in credits"], ["paste", "Paste: copy a packet into any chat you already pay for, paste the JSON back"]]
|
|
54
|
-
: [["paste", "Paste: copy a packet into any chat, paste the JSON back"], ["mcp", "Claude Desktop / Claude Code through the local MCP server"], ["byo_key", "My providers: the local server calls the one you mark active"], ["credits", "Credits: the local server sends packets to the hosted relay with your sign-in token
|
|
54
|
+
: [["paste", "Paste: copy a packet into any chat, paste the JSON back"], ["mcp", "Claude Desktop / Claude Code through the local MCP server"], ["byo_key", "My providers: the local server calls the one you mark active"], ["credits", "Credits: the local server sends packets to the hosted relay with your sign-in token"]]
|
|
55
55
|
).map(([r, label]) => html`<div key=${r}><label><input type="radio" name="route" checked=${s.route === r} disabled=${r === "credits" && !s.hosted && !(s.credits && s.credits.has_token)} onChange=${() => put({ route: r })} /> ${label}</label></div>`)}
|
|
56
56
|
${s.hosted ? "" : html`<div class="row"><button onClick=${async () => setMcp(mcp ? null : await api.get("/api/mcp-config"))}>${mcp ? "hide" : "Print MCP config"}</button></div>`}
|
|
57
57
|
${mcp ? html`<div><p class="small">Claude Desktop, merge into mcpServers:</p><textarea readonly value=${JSON.stringify(mcp.desktop, null, 2)}></textarea><p class="small">Claude Code:</p><textarea readonly value=${mcp.code}></textarea></div>` : ""}
|
|
@@ -99,7 +99,7 @@ export function Settings({ live }) {
|
|
|
99
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>
|
|
100
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
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>
|
|
102
|
-
|
|
102
|
+
` : html`<p class="small muted">A paid route for people without their own AI subscription. Sign in at https://app.gaf-j.com/signin, copy the token it shows, paste it here with the relay address. The relay runs named operations on the packet the app built, never a free prompt, and never stores packet or reply text.</p>
|
|
103
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 = ""; }}>
|
|
104
104
|
<input name="relay" placeholder="relay url (https)" value=${(s.credits && s.credits.relay_url) || ""} />
|
|
105
105
|
<input name="token" type="password" placeholder=${s.credits && s.credits.has_token ? `sign-in token ${s.credits.token} (blank keeps it)` : "sign-in token"} />
|
|
@@ -107,18 +107,18 @@ export function Settings({ live }) {
|
|
|
107
107
|
<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); } }} disabled=${!(s.credits && s.credits.has_token)}>check balance</button>
|
|
108
108
|
${s.credits && s.credits.has_token ? html`<button type="button" class="danger" onClick=${() => put({ credits: { id_token: null } })}>sign out</button>` : ""}
|
|
109
109
|
</form>`}
|
|
110
|
-
${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>` : ""}
|
|
110
|
+
${s.credits && s.credits.last_balance ? html`<p class="small"><b>${s.credits.last_balance.available} credits available</b>${s.credits.last_balance.reserved ? ` (${s.credits.last_balance.reserved} held for a run in progress)` : ""}, checked ${new Date(s.credits.last_balance.at).toLocaleString()}</p>` : ""}
|
|
111
111
|
</div>
|
|
112
112
|
<h2>Usage</h2>
|
|
113
113
|
<div class="card">
|
|
114
|
-
${usage && usage.by_op.length ? html`<table class="cats"><thead><tr><th>operation</th><th>result</th><th>runs</th><th>tokens in</th><th>tokens out</th><th>model</th></tr></thead><tbody>
|
|
114
|
+
${usage && usage.by_op.length ? html`<div class="tablewrap"><table class="cats"><thead><tr><th>operation</th><th>result</th><th>runs</th><th>tokens in</th><th>tokens out</th><th>model</th></tr></thead><tbody>
|
|
115
115
|
${usage.by_op.flatMap((u) => [
|
|
116
116
|
html`<tr key=${u.op + "p"}><td rowspan="2"><b>${u.op}</b><div class="small muted">${(u.avg_ms / 1000).toFixed(0)} s avg</div></td><td><span class="tag passed">passed</span></td><td>${u.passed}</td><td>${u.in_passed.toLocaleString()}</td><td>${u.out_passed.toLocaleString()}</td><td rowspan="2" class="small muted">${u.model || ""}</td></tr>`,
|
|
117
117
|
html`<tr key=${u.op + "f"}><td><span class="tag blocked">failed</span></td><td>${u.failed}</td><td>${u.in_failed.toLocaleString()}</td><td>${u.out_failed.toLocaleString()}</td></tr>`,
|
|
118
118
|
])}
|
|
119
119
|
<tr><td><b>total</b></td><td></td><td>${usage.by_op.reduce((n, u) => n + u.attempts, 0)}</td><td><b>${usage.by_op.reduce((n, u) => n + u.tokens_in, 0).toLocaleString()}</b></td><td><b>${usage.by_op.reduce((n, u) => n + u.tokens_out, 0).toLocaleString()}</b></td><td></td></tr>
|
|
120
120
|
<tr><td colspan="2"><b>of which wasted on failures</b></td><td>${usage.by_op.reduce((n, u) => n + u.failed, 0)}</td><td>${usage.by_op.reduce((n, u) => n + u.in_failed, 0).toLocaleString()}</td><td>${usage.by_op.reduce((n, u) => n + u.out_failed, 0).toLocaleString()}</td><td></td></tr>
|
|
121
|
-
</tbody></table><p class="small muted">Counted from every run through your own provider or credits since ${usage.since ? new Date(usage.since).toLocaleDateString() : "the start"}. "Failed" is a reply that came back but did not pass the gate. ${usage.unrecorded ? `${usage.unrecorded} call${usage.unrecorded === 1 ? "" : "s"} never returned (a timeout or a dropped connection): no tokens recorded here, but the provider still billed the input.` : ""} Paste runs are not counted. Multiply by your provider's price per million tokens for the cost.</p>`
|
|
121
|
+
</tbody></table></div><p class="small muted">Counted from every run through your own provider or credits since ${usage.since ? new Date(usage.since).toLocaleDateString() : "the start"}. "Failed" is a reply that came back but did not pass the gate. ${usage.unrecorded ? `${usage.unrecorded} call${usage.unrecorded === 1 ? "" : "s"} never returned (a timeout or a dropped connection): no tokens recorded here, but the provider still billed the input.` : ""} Paste runs are not counted. Multiply by your provider's price per million tokens for the cost.</p>`
|
|
122
122
|
: html`<p class="small muted">Nothing counted yet. Runs through your own provider key or credits record their token counts here.</p>`}
|
|
123
123
|
</div>
|
|
124
124
|
<h2>Bullet register</h2>
|
|
@@ -129,9 +129,9 @@ export function Settings({ live }) {
|
|
|
129
129
|
<h2>House rules</h2>
|
|
130
130
|
<div class="card">
|
|
131
131
|
<p class="small muted">Style rules only. Integrity rules (provenance, metrics, tenure, verbs, history) have no switch.</p>
|
|
132
|
-
<table class="cats"><thead><tr><th>rule</th>${KINDS.map((k) => html`<th key=${k}>${k}</th>`)}</tr></thead><tbody>
|
|
132
|
+
<div class="tablewrap"><table class="cats"><thead><tr><th>rule</th>${KINDS.map((k) => html`<th key=${k}>${k}</th>`)}</tr></thead><tbody>
|
|
133
133
|
${["no_dashes", "style_config"].map((rule) => html`<tr key=${rule}><td>${rule}</td>${KINDS.map((k) => html`<td key=${k}><select onChange=${(e) => setSev(rule, k, e.target.value)}>${["block", "warn", "off"].map((sev) => html`<option key=${sev} value=${sev} selected=${(h[rule][k] || "warn") === sev}>${sev}</option>`)}</select></td>`)}</tr>`)}
|
|
134
|
-
</tbody></table>
|
|
134
|
+
</tbody></table></div>
|
|
135
135
|
<form class="inline" onSubmit=${(e) => { e.preventDefault(); const v = e.target.elements.allow.value.trim(); if (v) hr({ hyphen_allow: [...h.hyphen_allow, v] }); e.target.reset(); }}>
|
|
136
136
|
<span>allowed hyphen compounds: ${h.hyphen_allow.map((a) => html`<span class="tag" key=${a}>${a} <a href="#/settings" onClick=${(e) => { e.preventDefault(); hr({ hyphen_allow: h.hyphen_allow.filter((x) => x !== a) }); }}>×</a></span> `)}</span>
|
|
137
137
|
<input name="allow" placeholder="cross-functional" /><button>allow</button></form>
|
package/ui/screens/welcome.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { html, useState, useEffect } from "../vendor/preact.mjs";
|
|
2
2
|
import { api, cleanPosting, guessPosting } from "../lib.js";
|
|
3
|
-
import { Onboarding, DraftForm, fromDraft, Positions, ContactForm } from "./kb.js";
|
|
3
|
+
import { Onboarding, DraftForm, fromDraft, Positions, ContactForm, AnswerBox } from "./kb.js";
|
|
4
4
|
import { PacketModal } from "./packet.js";
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -10,6 +10,9 @@ import { PacketModal } from "./packet.js";
|
|
|
10
10
|
* step flips when the store says so; "skip for now" opens the app anyway.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
/** While the wizard shows its final result, the app's gate waits for "Open the app". */
|
|
14
|
+
export const wizardHold = { on: false };
|
|
15
|
+
|
|
13
16
|
const KIND_LABEL = { anthropic: "Anthropic (Claude)", openai_compatible: "OpenAI, or any OpenAI-compatible server", gemini: "Google Gemini" };
|
|
14
17
|
|
|
15
18
|
function StepAi({ s, reload, hosted }) {
|
|
@@ -58,13 +61,31 @@ function StepAi({ s, reload, hosted }) {
|
|
|
58
61
|
</div>`;
|
|
59
62
|
}
|
|
60
63
|
|
|
61
|
-
|
|
64
|
+
/** Hosted only: the included AI runs on credits, so the first step is having some. */
|
|
65
|
+
function StepCredits({ reload, setErr }) {
|
|
66
|
+
const [bal, setBal] = useState(null);
|
|
67
|
+
const [busy, setBusy] = useState("");
|
|
68
|
+
const check = async () => { setBusy("check"); try { setErr(""); const r = await api.get("/api/credits/balance"); setBal(r); await reload(); } catch (e) { setErr(e.message); } setBusy(""); };
|
|
69
|
+
useEffect(() => { check(); }, []);
|
|
70
|
+
const buy = async (pack) => { setBusy(pack); try { setErr(""); const r = await api.post("/api/credits/checkout", { pack }); location.href = r.url; } catch (e) { setErr(e.message); setBusy(""); } };
|
|
71
|
+
return html`<div>
|
|
72
|
+
<p>Reading your resumes, asking what is missing, and every document you make run on the included AI and cost credits: a few per resume read, a few per document. Buy a pack once; nothing recurs and credits never expire. Stripe takes the card; GAF-J never sees it.</p>
|
|
73
|
+
<div class="ai-options">
|
|
74
|
+
${[["starter", "Starter", "25 credits", "$9", "enough to read your resumes and make a few documents"], ["campaign", "Campaign", "100 credits", "$29", "a full search: many postings, resumes, and prep"], ["season", "Season", "300 credits", "$69", "a long search, or a career change with many versions"]].map(([k, name, n, price, why]) => html`<button key=${k} disabled=${!!busy} onClick=${() => buy(k)}><b>${name}: ${n}, ${price}</b><span class="small muted">${why}</span></button>`)}
|
|
75
|
+
</div>
|
|
76
|
+
<div class="row" style="margin-top:12px"><span class="small">${bal ? html`<b>${bal.available} credits available</b>` : busy === "check" ? "checking" : "balance unknown"}</span><button class="small" disabled=${!!busy} onClick=${check}>check again</button>
|
|
77
|
+
<span class="small muted">Prefer to use a chat you already pay for? Settings, AI route, Paste; then this step can be skipped.</span></div>
|
|
78
|
+
</div>`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function StepPosting({ reload, direct, onResult }) {
|
|
62
82
|
const [text, setText] = useState("");
|
|
63
83
|
const [company, setCompany] = useState("");
|
|
64
84
|
const [title, setTitle] = useState("");
|
|
65
85
|
const [g, setG] = useState({ company: true, title: true });
|
|
66
86
|
const [err, setErr] = useState("");
|
|
67
87
|
const [busy, setBusy] = useState(false);
|
|
88
|
+
const [result, setResult] = useState(null);
|
|
68
89
|
const onPaste = (raw) => {
|
|
69
90
|
const t = cleanPosting(raw);
|
|
70
91
|
setText(t);
|
|
@@ -78,16 +99,26 @@ function StepPosting({ reload, direct }) {
|
|
|
78
99
|
try {
|
|
79
100
|
const r = await api.post("/api/postings", { company, title, raw_text: text });
|
|
80
101
|
await api.post(`/api/postings/${r.posting_id}/scan`, {});
|
|
81
|
-
|
|
102
|
+
let note = "";
|
|
103
|
+
if (direct) { setBusy("ai"); try { const c = await api.post(`/api/run/categories?posting_id=${encodeURIComponent(r.posting_id)}`, {}); if (c.status !== "passed") note = "your AI's categories were refused (" + (c.errors || []).slice(0, 1).join("") + "); the local scan's draft is scored instead"; } catch (e3) { note = "your AI could not propose categories (" + e3.message + "); the local scan's draft is scored instead"; } }
|
|
104
|
+
setBusy("score");
|
|
105
|
+
if (onResult) onResult();
|
|
106
|
+
try { const f = await api.post(`/api/postings/${r.posting_id}/freeze`, {}); setResult({ ...f, posting_id: r.posting_id, note }); } catch (e4) { setResult({ posting_id: r.posting_id, note: (note ? note + ". " : "") + "Not scored yet: " + e4.message }); }
|
|
82
107
|
await reload();
|
|
83
108
|
} catch (e2) { setErr(e2.message); }
|
|
84
109
|
setBusy(false);
|
|
85
110
|
};
|
|
111
|
+
if (result) return html`<div>
|
|
112
|
+
${result.fit_score != null ? html`<p><b>Fit ${result.fit_score} of 10 · ${String(result.verdict).toUpperCase()}</b> <span class="muted small">${result.verdict_reason}</span></p>
|
|
113
|
+
<ul class="list">${(result.categories || []).map((c, i) => html`<li key=${i}><span class="mono small">weight ${c.weight} · score ${c.score}</span> ${c.category} ${c.is_hard_gate ? html`<span class="tag warn">must have</span>` : ""}</li>`)}</ul>` : ""}
|
|
114
|
+
${result.note ? html`<p class="small error">${result.note}</p>` : ""}
|
|
115
|
+
<p class="small muted">Weight is how much the employer stressed it; score is how well your confirmed records meet it. Open <a href=${"#/postings/" + result.posting_id}>the posting</a> to edit either and re-score, or to log an application and make the resume.</p>
|
|
116
|
+
</div>`;
|
|
86
117
|
return html`<form onSubmit=${store}>
|
|
87
118
|
<p>Paste the text of a job you want, exactly as the site shows it. GAF-J scores it against your record and shows what the posting asks for beside what you have. Everything else (the resume, the prep) starts from there.</p>
|
|
88
119
|
<textarea required placeholder="the whole posting" value=${text} onInput=${(e) => onPaste(e.target.value)}></textarea>
|
|
89
|
-
<div class="row"><input required placeholder="company (guessed; fix if wrong)" value=${company} onInput=${(e) => { setG({ ...g, company: false }); setCompany(e.target.value); }} /><input required placeholder="title (guessed; fix if wrong)" value=${title} onInput=${(e) => { setG({ ...g, title: false }); setTitle(e.target.value); }} /></div>
|
|
90
|
-
<div class="row"><button class="primary" disabled=${busy}>${busy
|
|
120
|
+
<div class="row wide"><input required placeholder="company (guessed; fix if wrong)" value=${company} onInput=${(e) => { setG({ ...g, company: false }); setCompany(e.target.value); }} /><input required placeholder="title (guessed; fix if wrong)" value=${title} onInput=${(e) => { setG({ ...g, title: false }); setTitle(e.target.value); }} /></div>
|
|
121
|
+
<div class="row"><button class="primary" disabled=${!!busy}>${busy === "ai" ? "asking your AI what the employer wants" : busy === "score" ? "scoring your record against it" : busy ? "storing" : "Store and score"}</button>${err ? html`<span class="error small">${err}</span>` : ""}</div>
|
|
91
122
|
</form>`;
|
|
92
123
|
}
|
|
93
124
|
|
|
@@ -101,20 +132,33 @@ export function Welcome({ live, onDone }) {
|
|
|
101
132
|
const [answering, setAnswering] = useState(null);
|
|
102
133
|
const [busy, setBusy] = useState("");
|
|
103
134
|
const [view, setView] = useState(null); // a step the user went back to; null follows the next undone step
|
|
104
|
-
const
|
|
135
|
+
const hold = () => { wizardHold.on = true; };
|
|
136
|
+
const release = () => { wizardHold.on = false; onDone(); };
|
|
137
|
+
const load = () => Promise.all([api.get("/api/setup"), api.get("/api/onboarding"), api.get("/api/kb"), api.get("/api/me")]).then(([s, o, k, m]) => { setSetup(s); setOb(o); setKb(k); setMe(m); if (s.complete && !wizardHold.on) onDone(); }).catch((e) => setErr(e.message));
|
|
105
138
|
useEffect(() => { load(); }, [live]);
|
|
106
139
|
const post = async (path, body) => { try { setErr(""); await api.post(path, body || {}); await load(); } catch (e) { setErr(e.message); } };
|
|
107
140
|
if (!setup || !ob || !kb || !me) return html`<div class="welcome"><p class="muted">loading</p></div>`;
|
|
108
141
|
const direct = me.route === "byo_key" || me.route === "credits";
|
|
109
|
-
|
|
110
|
-
const
|
|
111
|
-
const
|
|
142
|
+
// with every step done there is no next; the last step stays on screen with its result
|
|
143
|
+
const last = setup.steps.length - 1;
|
|
144
|
+
const nextIdx = setup.next ? setup.steps.findIndex((x) => x.key === setup.next) : setup.steps.length;
|
|
145
|
+
const idx = view !== null && view <= Math.min(nextIdx, last) ? view : Math.min(nextIdx, last);
|
|
146
|
+
// when the step on screen completes (an extraction lands, a record is confirmed), stay on it so the result is
|
|
147
|
+
// readable; Continue moves on. The pin is by step key, so a reload does not lose it.
|
|
148
|
+
const [pinned, setPinned] = useState(null);
|
|
149
|
+
const pinnedIdx = pinned ? setup.steps.findIndex((x) => x.key === pinned) : -1;
|
|
150
|
+
const stay = view === null && pinnedIdx >= 0 && pinnedIdx < nextIdx && setup.steps[pinnedIdx].done && setup.steps[pinnedIdx].key !== "ai" && setup.steps[pinnedIdx].key !== "credits";
|
|
151
|
+
const at = stay ? pinnedIdx : idx;
|
|
152
|
+
const shown = setup.steps[at];
|
|
153
|
+
useEffect(() => { if (shown && !shown.done) setPinned(shown.key); }, [shown && !shown.done ? shown.key : null]);
|
|
154
|
+
const step = setup.steps[at].key;
|
|
112
155
|
const discover = async () => {
|
|
113
156
|
if (!direct) { setModal("discover"); return; }
|
|
114
157
|
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("");
|
|
115
158
|
};
|
|
116
159
|
const body = {
|
|
117
160
|
ai: html`<${StepAi} s=${setup} reload=${load} hosted=${!!me.hosted} />`,
|
|
161
|
+
credits: html`<${StepCredits} reload=${load} setErr=${setErr} />`,
|
|
118
162
|
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>`,
|
|
119
163
|
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>`,
|
|
120
164
|
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>
|
|
@@ -129,17 +173,19 @@ export function Welcome({ live, onDone }) {
|
|
|
129
173
|
${kb.pending.length > 1 ? html`<div class="row"><button onClick=${async () => { for (const p of kb.pending) await api.post(`/api/kb/pending/${p.pending_id}/confirm`, {}); await load(); }}>Confirm all ${kb.pending.length}</button><span class="small muted">only if you have read them</span></div>` : ""}</div>`,
|
|
130
174
|
discover: html`<div><p>Your AI reads the confirmed records and asks what a coach would: the figure a record lacks, the size of a team, the story behind a bare bullet. Each answer lands on the record it is about.</p>
|
|
131
175
|
${kb.questions.length ? html`<ul class="list">${kb.questions.map((q) => html`<li key=${q.question_id}><div><span class="tag">${q.kind}</span> <b>${q.title}</b> <span class="muted">${q.company}</span><br />${q.question}
|
|
132
|
-
|
|
176
|
+
<${AnswerBox} q=${q} onSubmit=${(draft) => post(`/api/kb/questions/${q.question_id}/answer`, { draft })} onSkip=${() => post(`/api/kb/questions/${q.question_id}/dismiss`, {})} /></div></li>`)}</ul>`
|
|
133
177
|
: html`<div class="row"><button class="primary" disabled=${!!busy} onClick=${discover}>${busy ? "asking" : "Ask what's missing"}</button><button onClick=${() => post("/api/setup/dismiss", { dismissed: true })}>Later</button></div>`}</div>`,
|
|
134
|
-
posting: html`<${StepPosting} reload=${load} direct=${direct} />`,
|
|
178
|
+
posting: html`<${StepPosting} reload=${load} direct=${direct} onResult=${hold} />`,
|
|
135
179
|
};
|
|
136
180
|
return html`<div class="welcome">
|
|
137
181
|
<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">${["", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight"][setup.steps.length] || setup.steps.length} short steps, then the app is yours.</div></div>
|
|
138
|
-
<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", {});
|
|
139
|
-
<
|
|
182
|
+
<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", {}); release(); }}>skip for now</a></span></div>
|
|
183
|
+
<div class="stepper-compact"><span>Step ${at + 1} of ${setup.steps.length}</span><span class="bar"><span style=${`width:${Math.round((setup.steps.filter((x) => x.done).length / setup.steps.length) * 100)}%`}></span></span></div>
|
|
184
|
+
<ol class="stepper">${setup.steps.map((st, i) => html`<li key=${st.key} class=${(st.done ? "done" : "") + (i === at ? " 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>
|
|
140
185
|
${err ? html`<p class="error">${err}</p>` : ""}
|
|
141
|
-
<div class="card welcome-body"
|
|
142
|
-
<div class="row welcome-nav">${
|
|
186
|
+
<div class="card welcome-body">
|
|
187
|
+
<div class="row between welcome-nav"><h3>${at + 1}. ${setup.steps[at].title}</h3><div class="row">${at > 0 ? html`<button class="small" onClick=${() => setView(at - 1)}>← Back</button>` : ""}${setup.complete && at === last ? html`<button class="primary small" onClick=${release}>Open the app →</button>` : at < nextIdx ? html`<button class=${stay ? "primary small" : "small"} onClick=${() => { setPinned(null); setView(null); }}>Continue →</button>` : ""}</div></div>
|
|
188
|
+
${body[step]}</div>
|
|
143
189
|
${modal === "discover" ? html`<${PacketModal} op="discover" label="Discovery questions" target=${{}} onClose=${() => setModal(null)} onSaved=${load} />` : ""}
|
|
144
190
|
</div>`;
|
|
145
191
|
}
|