@gafj/gafj 0.1.2 → 0.1.3
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/http/api.js +6 -0
- package/package.json +1 -1
- package/store/setup.js +54 -0
- package/ui/app.css +16 -0
- package/ui/screens/dashboard.js +41 -1
- package/ui/screens/postings.js +24 -4
package/http/api.js
CHANGED
|
@@ -151,6 +151,12 @@ route("GET", "/api/kb/questions", (c, p, b, q) => discover.listQuestions(c.db, c
|
|
|
151
151
|
route("POST", "/api/kb/questions/:id/answer", (c, p, b) => discover.answerQuestion(c.db, { candidate_id: c.candidate_id, question_id: p.id, draft: need(b, "draft"), now: c.now(), actor: ACTOR("answer_question") }));
|
|
152
152
|
route("POST", "/api/kb/questions/:id/dismiss", (c, p) => discover.dismissQuestion(c.db, { candidate_id: c.candidate_id, question_id: p.id, now: c.now(), actor: ACTOR("dismiss_question") }));
|
|
153
153
|
|
|
154
|
+
// guided setup (first run)
|
|
155
|
+
const setup = require("../store/setup");
|
|
156
|
+
route("GET", "/api/setup", (c) => setup.setupView(c.db, c.home, c.candidate_id));
|
|
157
|
+
route("POST", "/api/setup/ai", (c, p, b) => { const r = setup.chooseAi(c.db, c.home, c.candidate_id, { route: need(b, "route"), now: c.now(), actor: ACTOR("setup") }); if (c.reload) c.reload(); return r; });
|
|
158
|
+
route("POST", "/api/setup/dismiss", (c, p, b) => setup.dismiss(c.db, c.home, c.candidate_id, { dismissed: b.dismissed !== false }));
|
|
159
|
+
|
|
154
160
|
// settings (screen 6)
|
|
155
161
|
const settings = require("../store/settings");
|
|
156
162
|
route("GET", "/api/settings", (c) => settings.getSettings(c.db, c.home, c.candidate_id));
|
package/package.json
CHANGED
package/store/setup.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The guided setup (first run): six steps in the order they must happen,
|
|
5
|
+
* each computed from the store, so the checklist is never out of date and
|
|
6
|
+
* nothing is stored except two flags in config: which AI route the user
|
|
7
|
+
* chose on purpose (the default is paste, which says nothing), and whether
|
|
8
|
+
* they dismissed the card. The card leaves on its own when every step is done.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const { readConfig, writeConfig } = require("./config");
|
|
12
|
+
|
|
13
|
+
const ROUTE_LABEL = { paste: "Paste", mcp: "Claude Desktop", byo_key: "My providers", credits: "Credits" };
|
|
14
|
+
|
|
15
|
+
function setupView(db, homeDir, candidate_id) {
|
|
16
|
+
const cfg = readConfig(homeDir);
|
|
17
|
+
const one = (sql, ...a) => db.prepare(sql).get(...a).c;
|
|
18
|
+
const sources = one("SELECT count(*) c FROM source_document WHERE candidate_id = ?", candidate_id);
|
|
19
|
+
const extracted = one("SELECT count(*) c FROM ai_run WHERE candidate_id = ? AND operation = 'extract' AND final_result = 'passed'", candidate_id);
|
|
20
|
+
const pending = one("SELECT count(*) c FROM pending_accomplishment WHERE candidate_id = ?", candidate_id);
|
|
21
|
+
const confirmed = one("SELECT count(*) c FROM accomplishment WHERE candidate_id = ?", candidate_id);
|
|
22
|
+
const questions = one("SELECT count(*) c FROM kb_question WHERE candidate_id = ?", candidate_id);
|
|
23
|
+
const postings = one("SELECT count(*) c FROM posting WHERE candidate_id = ?", candidate_id);
|
|
24
|
+
const aiChosen = !!(cfg.setup && cfg.setup.ai_chosen);
|
|
25
|
+
const steps = [
|
|
26
|
+
{ key: "ai", title: "Tell GAF-J which AI you use", done: aiChosen, detail: aiChosen ? `Using ${ROUTE_LABEL[cfg.route] || cfg.route}` : null },
|
|
27
|
+
{ key: "upload", title: "Upload your resumes", done: sources > 0, detail: sources ? `${sources} file${sources === 1 ? "" : "s"} uploaded` : null },
|
|
28
|
+
{ key: "extract", title: "Extract records from them", done: extracted > 0 || pending > 0 || confirmed > 0, detail: extracted ? `${extracted} extraction${extracted === 1 ? "" : "s"} saved` : null },
|
|
29
|
+
{ 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) },
|
|
30
|
+
{ key: "discover", title: "Answer what's missing", done: questions > 0, detail: questions ? `${questions} question${questions === 1 ? "" : "s"} asked` : null },
|
|
31
|
+
{ key: "posting", title: "Paste your first job posting", done: postings > 0, detail: postings ? `${postings} posting${postings === 1 ? "" : "s"}` : null },
|
|
32
|
+
];
|
|
33
|
+
const next = steps.find((s) => !s.done);
|
|
34
|
+
return { steps, next: next ? next.key : null, complete: !next, dismissed: !!(cfg.setup && cfg.setup.dismissed), route: cfg.route || "paste" };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The user picked how they will use AI; the route is set and the step is done. */
|
|
38
|
+
function chooseAi(db, homeDir, candidate_id, { route, now, actor }) {
|
|
39
|
+
const settings = require("./settings");
|
|
40
|
+
settings.putSettings(db, homeDir, candidate_id, { route }, { now, actor });
|
|
41
|
+
const cfg = readConfig(homeDir);
|
|
42
|
+
cfg.setup = { ...(cfg.setup || {}), ai_chosen: true };
|
|
43
|
+
writeConfig(homeDir, cfg);
|
|
44
|
+
return setupView(db, homeDir, candidate_id);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function dismiss(db, homeDir, candidate_id, { dismissed = true } = {}) {
|
|
48
|
+
const cfg = readConfig(homeDir);
|
|
49
|
+
cfg.setup = { ...(cfg.setup || {}), dismissed: !!dismissed };
|
|
50
|
+
writeConfig(homeDir, cfg);
|
|
51
|
+
return setupView(db, homeDir, candidate_id);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
module.exports = { setupView, chooseAi, dismiss };
|
package/ui/app.css
CHANGED
|
@@ -135,3 +135,19 @@ input.narrow { width: 76px; }
|
|
|
135
135
|
pre.wrap { white-space: pre-wrap; }
|
|
136
136
|
/* KB "receipt" moment: the confirmed span highlighted inline against its muted source sentence. */
|
|
137
137
|
mark { background: var(--good-bg); color: var(--good-bright); border-radius: 3px; padding: 0 3px; font-weight: 500; }
|
|
138
|
+
|
|
139
|
+
/* guided setup on the dashboard: the next step is open with its words and its button; done steps collapse to a line */
|
|
140
|
+
.setup { margin-top: 10px; border-color: var(--accent); }
|
|
141
|
+
.setup-steps { list-style: none; margin: 10px 0 0; padding: 0; }
|
|
142
|
+
.setup-steps li { display: grid; grid-template-columns: 28px 1fr; gap: 10px; padding: 9px 0; border-top: 1px solid var(--line); align-items: start; }
|
|
143
|
+
.setup-steps li:first-child { border-top: 0; }
|
|
144
|
+
.setup-steps .n { width: 24px; height: 24px; border-radius: 50%; border: 1px solid var(--line-2); display: inline-flex; align-items: center; justify-content: center; font-size: 11px; color: var(--muted); }
|
|
145
|
+
.setup-steps li.done .n { background: var(--good-bg); color: var(--good-bright); border-color: var(--good-bg); }
|
|
146
|
+
.setup-steps li.next .n { border-color: var(--accent); color: var(--accent); font-weight: 600; }
|
|
147
|
+
.setup-steps li.done .t { color: var(--muted); }
|
|
148
|
+
.setup-steps li.later .t { color: var(--dim); }
|
|
149
|
+
.setup-steps li.next .t { font-weight: 600; }
|
|
150
|
+
.setup-steps p { margin: 4px 0 8px; color: var(--muted); max-width: 66ch; }
|
|
151
|
+
.ai-options { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 8px; margin-top: 4px; }
|
|
152
|
+
.ai-options button { text-align: left; display: flex; flex-direction: column; gap: 4px; padding: 10px 12px; height: 100%; }
|
|
153
|
+
.ai-options button:hover { border-color: var(--accent); }
|
package/ui/screens/dashboard.js
CHANGED
|
@@ -1,11 +1,49 @@
|
|
|
1
1
|
import { html, useState, useEffect } from "../vendor/preact.mjs";
|
|
2
2
|
import { api, fmtWhen } from "../lib.js";
|
|
3
3
|
|
|
4
|
+
/** The six steps a new user takes, in order, with the words for each and where the button goes. */
|
|
5
|
+
const STEPS = {
|
|
6
|
+
ai: { why: "GAF-J never writes text itself. It hands a packet of your confirmed facts to an AI you already have, then checks every figure in the reply. Pick how you will connect one; you can change it later in Settings.", cta: null },
|
|
7
|
+
upload: { why: "Every resume you have, old ones too. Word, PDF, Markdown, or plain text. The text is read on this PC; nothing is uploaded anywhere.", cta: ["#/kb", "Open Knowledge base"] },
|
|
8
|
+
extract: { why: "Beside each uploaded file, click Extract. A packet opens: copy it into your AI, paste its JSON reply back. Your AI proposes records where every number and phrase is quoted from the file; anything it cannot quote is refused.", cta: ["#/kb", "Extract on Knowledge base"] },
|
|
9
|
+
confirm: { why: "Under Pending, read each proposed record. 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.", cta: ["#/kb", "Review Pending"] },
|
|
10
|
+
discover: { why: "Click Ask what's missing. Your AI reads the confirmed records and asks the questions a coach would: the figure a record lacks, the size of a team, the story behind a bare bullet. Each answer strengthens a record.", cta: ["#/kb", "Ask what's missing"] },
|
|
11
|
+
posting: { why: "Paste the text of a job you want. GAF-J scores it against your record, shows what the posting asks for and what you have, and from there builds the resume, the interview prep, and the rest.", cta: ["#/postings", "Paste a posting"] },
|
|
12
|
+
};
|
|
13
|
+
const AI_OPTIONS = [
|
|
14
|
+
["paste", "Paste", "Works with any chat you already pay for: ChatGPT, Claude, Gemini. Copy the packet, paste the reply. No keys, no setup."],
|
|
15
|
+
["mcp", "Claude Desktop", "Claude talks to the app directly through a local connector. Print the config in Settings and add it to Claude Desktop."],
|
|
16
|
+
["byo_key", "My own API key", "The app calls the provider itself with a key you add in Settings. The key never leaves this PC."],
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
function Setup({ s, reload }) {
|
|
20
|
+
const [busy, setBusy] = useState(false);
|
|
21
|
+
const choose = async (route) => { setBusy(true); try { await api.post("/api/setup/ai", { route }); await reload(); } finally { setBusy(false); } };
|
|
22
|
+
const done = s.steps.filter((x) => x.done).length;
|
|
23
|
+
return html`<div class="card setup">
|
|
24
|
+
<div class="row between"><b>Getting started: ${done} of ${s.steps.length}</b><button class="small" onClick=${async () => { await api.post("/api/setup/dismiss", {}); reload(); }}>hide</button></div>
|
|
25
|
+
<ol class="setup-steps">${s.steps.map((st, i) => {
|
|
26
|
+
const meta = STEPS[st.key];
|
|
27
|
+
const isNext = s.next === st.key;
|
|
28
|
+
return html`<li key=${st.key} class=${st.done ? "done" : isNext ? "next" : "later"}>
|
|
29
|
+
<span class="n mono">${st.done ? "✓" : i + 1}</span>
|
|
30
|
+
<div>
|
|
31
|
+
<div class="t">${st.title}${st.detail ? html` <span class="muted small">· ${st.detail}</span>` : ""}</div>
|
|
32
|
+
${isNext ? html`<p class="small">${meta.why}</p>
|
|
33
|
+
${st.key === "ai" ? html`<div class="ai-options">${AI_OPTIONS.map(([r, label, text]) => html`<button key=${r} disabled=${busy} onClick=${() => choose(r)}><b>${label}</b><span class="small muted">${text}</span></button>`)}</div>`
|
|
34
|
+
: html`<div class="row"><a href=${meta.cta[0]}><button class="primary small">${meta.cta[1]}</button></a></div>`}` : ""}
|
|
35
|
+
</div></li>`;
|
|
36
|
+
})}</ol>
|
|
37
|
+
</div>`;
|
|
38
|
+
}
|
|
39
|
+
|
|
4
40
|
export function Dashboard({ live }) {
|
|
5
41
|
const [snap, setSnap] = useState(null);
|
|
42
|
+
const [setup, setSetup] = useState(null);
|
|
6
43
|
const [msg, setMsg] = useState("");
|
|
7
44
|
const [err, setErr] = useState("");
|
|
8
|
-
|
|
45
|
+
const loadSetup = () => api.get("/api/setup").then(setSetup).catch(() => setSetup(null));
|
|
46
|
+
useEffect(() => { api.get("/api/snapshot").then(setSnap).catch((e) => setErr(e.message)); loadSetup(); }, [live]);
|
|
9
47
|
const act = async (path, label) => { setMsg(""); setErr(""); try { const r = await api.post(path); setMsg(`${label}: ${r.file}`); } catch (e) { setErr(e.message); } };
|
|
10
48
|
if (err && !snap) return html`<p class="error">${err}</p>`;
|
|
11
49
|
if (!snap) return html`<p class="muted">loading</p>`;
|
|
@@ -13,6 +51,8 @@ export function Dashboard({ live }) {
|
|
|
13
51
|
return html`<div>
|
|
14
52
|
<h1>Dashboard</h1>
|
|
15
53
|
<div class="sub">${new Date(snap.now).toLocaleString()}</div>
|
|
54
|
+
${setup && !setup.complete && !setup.dismissed ? html`<${Setup} s=${setup} reload=${loadSetup} />` : ""}
|
|
55
|
+
${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>` : ""}
|
|
16
56
|
<h2>Needs you now</h2>
|
|
17
57
|
<div class="card">${snap.needs_you.length ? html`<ul class="list">${snap.needs_you.map((n, i) => html`<li key=${i}>
|
|
18
58
|
<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/postings.js
CHANGED
|
@@ -37,6 +37,25 @@ export function Postings({ live, id }) {
|
|
|
37
37
|
const [rows, setRows] = useState([]);
|
|
38
38
|
const [sel, setSel] = useState(null);
|
|
39
39
|
const [form, setForm] = useState({ company: "", title: "", source_url: "", location: "", comp_text: "", raw_text: "" });
|
|
40
|
+
const [guessed, setGuessed] = useState({ company: true, title: true });
|
|
41
|
+
// the paste is all a person should have to give; company and title are guessed from its first lines and can be corrected
|
|
42
|
+
const onPaste = (text) => {
|
|
43
|
+
const lines = text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean).slice(0, 12);
|
|
44
|
+
const next = { ...form, raw_text: text };
|
|
45
|
+
if (guessed.title) {
|
|
46
|
+
const t = lines.find((l) => l.length <= 90 && !/^https?:/i.test(l) && !/^(at|company|location|posted|apply|share)\b/i.test(l));
|
|
47
|
+
next.title = t || "";
|
|
48
|
+
}
|
|
49
|
+
if (guessed.company) {
|
|
50
|
+
let c = "";
|
|
51
|
+
for (const l of lines) { const m = l.match(/^(?:at|company[:\s]+|employer[:\s]+)\s*([^|·,]{2,60})/i); if (m) { c = m[1].trim(); break; } }
|
|
52
|
+
if (!c) { const i = lines.findIndex((l) => l === next.title); const cand = lines[i + 1] || ""; if (cand && cand.length <= 50 && !/^https?:/i.test(cand) && !/\d{4}/.test(cand)) c = cand.replace(/^[·|\-\s]+/, ""); }
|
|
53
|
+
next.company = c;
|
|
54
|
+
}
|
|
55
|
+
const url = lines.find((l) => /^https?:\/\//i.test(l));
|
|
56
|
+
if (url && !form.source_url) next.source_url = url;
|
|
57
|
+
setForm(next);
|
|
58
|
+
};
|
|
40
59
|
const [err, setErr] = useState("");
|
|
41
60
|
const [msg, setMsg] = useState("");
|
|
42
61
|
const load = async () => { setRows(await api.get("/api/postings")); if (id) setSel(await api.get(`/api/postings/${id}`)); };
|
|
@@ -47,6 +66,7 @@ export function Postings({ live, id }) {
|
|
|
47
66
|
const r = await api.post("/api/postings", form);
|
|
48
67
|
await api.post(`/api/postings/${r.posting_id}/scan`);
|
|
49
68
|
setForm({ company: "", title: "", source_url: "", location: "", comp_text: "", raw_text: "" });
|
|
69
|
+
setGuessed({ company: true, title: true });
|
|
50
70
|
location.hash = "#/postings/" + r.posting_id;
|
|
51
71
|
setMsg(r.created ? "stored and scanned" : "already stored; scanned again");
|
|
52
72
|
} catch (e2) { setErr(e2.message); }
|
|
@@ -61,10 +81,10 @@ export function Postings({ live, id }) {
|
|
|
61
81
|
<div>
|
|
62
82
|
<h2>Paste posting</h2>
|
|
63
83
|
<form class="card" onSubmit=${store}>
|
|
64
|
-
<
|
|
65
|
-
<div class="row"><input placeholder="
|
|
66
|
-
<
|
|
67
|
-
<div class="row"><button class="primary">Store + scan</button><span class="small muted">
|
|
84
|
+
<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>
|
|
85
|
+
<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>
|
|
86
|
+
<div class="row"><input placeholder="link to the posting (optional)" value=${form.source_url} onInput=${(e) => setForm({ ...form, source_url: e.target.value })} /></div>
|
|
87
|
+
<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>
|
|
68
88
|
</form>
|
|
69
89
|
<h2>Stored</h2>
|
|
70
90
|
<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>
|