@gafj/gafj 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/README.md +99 -0
  2. package/bin/cli.js +628 -0
  3. package/bridge/byo_key.js +50 -0
  4. package/bridge/credits.js +72 -0
  5. package/bridge/paste.js +44 -0
  6. package/bridge/providers/anthropic.js +16 -0
  7. package/bridge/providers/gemini.js +15 -0
  8. package/bridge/providers/index.js +39 -0
  9. package/bridge/providers/openai_compatible.js +17 -0
  10. package/core/extract.js +55 -0
  11. package/core/generators/excerpt.js +64 -0
  12. package/core/generators/packet.js +197 -0
  13. package/core/generators/serialize.js +56 -0
  14. package/core/generators/slice.js +61 -0
  15. package/core/house_rules.js +49 -0
  16. package/core/kb_import.js +257 -0
  17. package/core/migrations/002-profile.sql +3 -0
  18. package/core/migrations/003-discover.sql +49 -0
  19. package/core/parsers.js +79 -0
  20. package/core/practice.js +75 -0
  21. package/core/provenance.js +90 -0
  22. package/core/qr.js +185 -0
  23. package/core/rules/categories.md +5 -0
  24. package/core/rules/deep_answers.md +7 -0
  25. package/core/rules/discover.md +30 -0
  26. package/core/rules/extract.md +31 -0
  27. package/core/rules/followup.md +9 -0
  28. package/core/rules/index.js +32 -0
  29. package/core/rules/onepager.md +15 -0
  30. package/core/rules/practice.md +10 -0
  31. package/core/rules/prep.md +47 -0
  32. package/core/rules/resume.md +49 -0
  33. package/core/rules/rubric.md +120 -0
  34. package/core/schema.sql +342 -0
  35. package/core/schemas/categories.js +16 -0
  36. package/core/schemas/cover.js +10 -0
  37. package/core/schemas/deep_answers.js +16 -0
  38. package/core/schemas/discover.js +19 -0
  39. package/core/schemas/extract.js +32 -0
  40. package/core/schemas/followup.js +10 -0
  41. package/core/schemas/index.js +32 -0
  42. package/core/schemas/lang.js +111 -0
  43. package/core/schemas/onepager.js +26 -0
  44. package/core/schemas/practice.js +18 -0
  45. package/core/schemas/prep.js +15 -0
  46. package/core/schemas/resume.js +25 -0
  47. package/core/scoring.js +586 -0
  48. package/core/text.js +98 -0
  49. package/core/tracker_map.js +94 -0
  50. package/core/ulid.js +38 -0
  51. package/core/validators/highlight_body_match.js +49 -0
  52. package/core/validators/index.js +43 -0
  53. package/core/validators/metric_pairing.js +52 -0
  54. package/core/validators/no_confabulated_history.js +63 -0
  55. package/core/validators/no_dashes.js +31 -0
  56. package/core/validators/no_derived_tenure.js +60 -0
  57. package/core/validators/style_config.js +18 -0
  58. package/core/validators/verb_ladder.js +51 -0
  59. package/core/verbs.js +43 -0
  60. package/http/api.js +260 -0
  61. package/http/guard.js +106 -0
  62. package/http/headers.js +17 -0
  63. package/http/hosted.js +273 -0
  64. package/http/identity.js +70 -0
  65. package/http/server.js +136 -0
  66. package/http/sse.js +41 -0
  67. package/mcp/server.js +25 -0
  68. package/mcp/tools.js +237 -0
  69. package/package.json +59 -0
  70. package/store/backup.js +63 -0
  71. package/store/backup_dir.js +60 -0
  72. package/store/config.js +32 -0
  73. package/store/context.js +74 -0
  74. package/store/db.js +37 -0
  75. package/store/discover.js +120 -0
  76. package/store/events.js +28 -0
  77. package/store/export_xlsx.js +62 -0
  78. package/store/home.js +36 -0
  79. package/store/import_kb.js +88 -0
  80. package/store/import_tracker.js +195 -0
  81. package/store/ingest.js +140 -0
  82. package/store/kb.js +202 -0
  83. package/store/log.js +88 -0
  84. package/store/migrate.js +55 -0
  85. package/store/onboarding.js +267 -0
  86. package/store/packets.js +110 -0
  87. package/store/practice.js +52 -0
  88. package/store/proposals.js +119 -0
  89. package/store/reads.js +110 -0
  90. package/store/render.js +111 -0
  91. package/store/reply.js +19 -0
  92. package/store/review.js +64 -0
  93. package/store/scoring.js +134 -0
  94. package/store/settings.js +142 -0
  95. package/store/snapshot.js +88 -0
  96. package/store/transitions.js +391 -0
  97. package/store/tx.js +71 -0
  98. package/store/tz.js +24 -0
  99. package/ui/app.css +137 -0
  100. package/ui/app.js +75 -0
  101. package/ui/fonts/plexmono-400.woff2 +0 -0
  102. package/ui/fonts/plexmono-500.woff2 +0 -0
  103. package/ui/fonts/plexmono-600.woff2 +0 -0
  104. package/ui/fonts/spacegrotesk-var.woff2 +0 -0
  105. package/ui/hosted/signin.css +8 -0
  106. package/ui/hosted/signin.html +30 -0
  107. package/ui/hosted/signin.js +28 -0
  108. package/ui/icon-192.png +0 -0
  109. package/ui/icon-512.png +0 -0
  110. package/ui/index.html +20 -0
  111. package/ui/lib.js +92 -0
  112. package/ui/manifest.webmanifest +18 -0
  113. package/ui/screens/application.js +73 -0
  114. package/ui/screens/dashboard.js +37 -0
  115. package/ui/screens/document.js +64 -0
  116. package/ui/screens/interview.js +85 -0
  117. package/ui/screens/kb.js +142 -0
  118. package/ui/screens/packet.js +122 -0
  119. package/ui/screens/postings.js +92 -0
  120. package/ui/screens/settings.js +115 -0
  121. package/ui/vendor/preact.mjs +1 -0
@@ -0,0 +1,64 @@
1
+ import { html, useState, useEffect } from "../vendor/preact.mjs";
2
+ import { api, fmtWhen } from "../lib.js";
3
+
4
+ const label = (k) => k.replace(/_/g, " ").replace(/([a-z])([A-Z])/g, "$1 $2").replace(/^\w/, (c) => c.toUpperCase());
5
+ const txt = (v) => (v && typeof v === "object" && "text" in v ? v.text : v);
6
+
7
+ function Generic({ c }) {
8
+ return html`${Object.entries(c).map(([k, v]) => {
9
+ if (v == null || v === "" || (Array.isArray(v) && !v.length)) return "";
10
+ if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") return html`<p key=${k}><b>${label(k)}:</b> ${String(v)}</p>`;
11
+ if (v && typeof v === "object" && "text" in v && "claims" in v) return html`<p key=${k}><b>${label(k)}:</b> ${v.text}</p>`;
12
+ if (Array.isArray(v)) return html`<div key=${k}><h2>${label(k)}</h2>${typeof v[0] === "string" ? html`<ul>${v.map((x, i) => html`<li key=${i}>${x}</li>`)}</ul>`
13
+ : v[0] && "text" in v[0] && "claims" in v[0] ? html`<ul>${v.map((x, i) => html`<li key=${i}>${x.text}</li>`)}</ul>`
14
+ : html`<ol>${v.map((x, i) => html`<li key=${i}>${Object.entries(x).map(([kk, vv]) => vv == null || vv === "" ? "" : html`<div key=${kk}><b>${label(kk)}:</b> ${typeof vv === "object" && !Array.isArray(vv) ? txt(vv) : Array.isArray(vv) ? vv.map(txt).join("; ") : String(vv)}</div>`)}</li>`)}</ol>`}</div>`;
15
+ if (typeof v === "object") return html`<div key=${k}><h2>${label(k)}</h2><${Generic} c=${v} /></div>`;
16
+ return "";
17
+ })}`;
18
+ }
19
+
20
+ function OnePager({ c, header }) {
21
+ return html`<div>
22
+ <p><b>${c.posture}</b>${c.hard_stop ? html` · <span class="error">${c.hard_stop}</span>` : ""}</p>
23
+ <h2>Stories</h2><ul>${c.stories.map((s, i) => html`<li key=${i}><span class="lead">${s.lead}:</span> ${s.line.text}</li>`)}</ul>
24
+ <h2>Amber gaps</h2><ul>${c.amber_gaps.map((g, i) => html`<li key=${i}>${g.text}</li>`)}</ul>
25
+ <h2>Answer keys</h2><ul>${c.answer_keys.map((k, i) => html`<li key=${i}><b>${k.trigger}</b>: ${k.key}</li>`)}</ul>
26
+ <h2>Questions to ask</h2><ol>${c.questions.map((q, i) => html`<li key=${i}>${q}</li>`)}</ol>
27
+ <h2>Do nots</h2><ul>${c.do_nots.map((q, i) => html`<li key=${i}>${q}</li>`)}</ul>
28
+ ${c.technical_page && Object.keys(c.technical_page).length ? html`<div class="pagebreak"><h2>Technical page</h2><${Generic} c=${c.technical_page} /></div>` : ""}
29
+ </div>`;
30
+ }
31
+
32
+ function Resume({ c }) {
33
+ return html`<div>
34
+ <h1>${c.headline}</h1><p>${(c.specialties || []).join(" · ")}</p>${c.headerLinks && c.headerLinks.length ? html`<p class="small">${c.headerLinks.join(" | ")}</p>` : ""}
35
+ <h2>Career Highlights</h2><ul>${c.careerHighlights.map((h, i) => html`<li key=${i}><b>${h.leadIn}</b> ${h.proof.text}</li>`)}</ul>
36
+ <h2>${c.experienceSectionTitle || "Experience"}</h2>
37
+ ${c.experience.map((e, i) => html`<div key=${i}><p><b>${e.role}</b>, ${e.company} <span class="muted">${e.dates}</span>${e.section === "concurrent" ? html` <span class="tag">concurrent</span>` : ""}</p>${e.scope ? html`<p><i>${e.scope}</i></p>` : ""}<ul>${(e.bullets || []).map((b, j) => html`<li key=${j}>${b.text.text}</li>`)}</ul></div>`)}
38
+ ${c.skills && c.skills.length ? html`<h2>Skills</h2><p>${c.skills.join(" · ")}</p>` : ""}
39
+ </div>`;
40
+ }
41
+
42
+ export function Document({ id, live }) {
43
+ const [d, setD] = useState(null);
44
+ const [err, setErr] = useState("");
45
+ const [msg, setMsg] = useState("");
46
+ useEffect(() => { api.get(`/api/documents/${id}`).then(setD).catch((e) => setErr(e.message)); }, [id, live]);
47
+ if (err) return html`<p class="error">${err}</p>`;
48
+ if (!d) return html`<p class="muted">loading</p>`;
49
+ const h = d.header || {};
50
+ const back = d.interview_id ? "#/interviews/" + d.interview_id : d.application_id ? "#/applications/" + d.application_id : "#/applications";
51
+ const exportMd = async () => { try { const r = await api.post(`/api/documents/${id}/export`, { format: "md" }); setMsg("written: " + r.file); } catch (e) { setMsg(e.message); } };
52
+ return html`<div>
53
+ <div class="row between noprint"><div><a href=${back}>← back</a> <span class=${"tag " + d.status}>${d.status.toUpperCase()}</span> <span class="muted small">${d.kind} · ${d.route}</span></div>
54
+ <div class="row"><button onClick=${() => print()}>Print</button>${d.status === "passed" ? html`<button onClick=${exportMd}>Export Markdown</button>` : ""}</div></div>
55
+ ${msg ? html`<p class="small mono noprint">${msg}</p>` : ""}
56
+ ${d.blocks.length ? html`<div class="card noprint"><b class="error">Blocked</b><ul>${d.blocks.map((b, i) => html`<li key=${i}><b>${b.rule}</b> <span class="mono">${b.path}</span> "${b.span}" ${b.hint}</li>`)}</ul></div>` : ""}
57
+ ${d.warnings.length ? html`<details class="noprint"><summary>${d.warnings.length} warnings</summary><ul>${d.warnings.map((w, i) => html`<li key=${i}><b>${w.rule}</b> <span class="mono">${w.path}</span> "${w.span}" ${w.hint}</li>`)}</ul></details>` : ""}
58
+ <div class=${"doc " + d.kind}>
59
+ ${d.kind !== "resume" ? html`<div class="row between"><div><b>${h.company || ""}</b>${h.title ? " · " + h.title : ""}</div><div class="small">${h.round_label ? `${h.round_label} ${h.format} · ${fmtWhen(h.scheduled_at, h.tz)}` : ""}</div></div>
60
+ ${h.people && h.people.length ? html`<p class="small">${h.people.map((p) => p.title ? `${p.name} (${p.title})` : p.name).join(", ")}</p>` : ""}` : ""}
61
+ ${d.kind === "onepager" ? html`<${OnePager} c=${d.content} header=${h} />` : d.kind === "resume" ? html`<${Resume} c=${d.content} />` : d.kind === "email" ? html`<div><p><b>Subject:</b> ${d.content.subject}</p><p style="white-space:pre-wrap">${d.content.body.text}</p></div>` : html`<${Generic} c=${d.content} />`}
62
+ </div>
63
+ </div>`;
64
+ }
@@ -0,0 +1,85 @@
1
+ import { html, useState, useEffect } from "../vendor/preact.mjs";
2
+ import { api, fmtWhen, OPS, toIso, localZone } from "../lib.js";
3
+ import { PacketModal } from "./packet.js";
4
+
5
+ function Person({ p }) {
6
+ return html`<li><b>${p.name}</b> <span class="muted">${[p.title, p.org].filter(Boolean).join(", ")}</span>
7
+ ${p.intel_text ? html`<details><summary>intel</summary><div class="intel">${p.intel_text}</div></details>` : html`<span class="muted small">no intel yet</span>`}</li>`;
8
+ }
9
+
10
+ export function Interview({ id, live }) {
11
+ const [d, setD] = useState(null);
12
+ const [err, setErr] = useState("");
13
+ const [modal, setModal] = useState(null);
14
+ const [debrief, setDebrief] = useState("");
15
+ const [pinQ, setPinQ] = useState("");
16
+ const [pinHits, setPinHits] = useState([]);
17
+ const [resched, setResched] = useState(null);
18
+ const [personName, setPersonName] = useState("");
19
+ const load = () => api.get(`/api/interviews/${id}`).then((x) => { setD(x); setErr(""); }).catch((e) => setErr(e.message));
20
+ useEffect(() => { load(); }, [id, live]);
21
+ const post = async (path, body) => { try { await api.post(path, body); await load(); } catch (e) { setErr(e.message + (e.allowed ? " (allowed: " + e.allowed.join(", ") + ")" : "")); } };
22
+ if (err && !d) return html`<p class="error">${err}</p>`;
23
+ if (!d) return html`<p class="muted">loading</p>`;
24
+ const searchPins = async () => setPinHits(await api.get(`/api/accomplishments?q=${encodeURIComponent(pinQ)}&limit=8`));
25
+ const pin = (rid) => post(`/api/interviews/${id}/pins`, { ids: [...d.pinned_ids.filter((x) => x !== rid), rid] });
26
+ const unpin = (rid) => post(`/api/interviews/${id}/pins`, { ids: d.pinned_ids.filter((x) => x !== rid) });
27
+ const target = { interview_id: id, name: `${d.company} ${d.round_label}` };
28
+ const docsByKind = {};
29
+ for (const doc of d.documents) (docsByKind[doc.kind] = docsByKind[doc.kind] || []).push(doc);
30
+ return html`<div>
31
+ <div class="sub"><a href=${"#/applications/" + d.application_id}>${d.company}: ${d.title}</a></div>
32
+ <h1>${d.company} · ${d.round_label} · ${fmtWhen(d.scheduled_at, d.tz)} · ${d.format} <span class="tag">${d.status}</span></h1>
33
+ ${err ? html`<p class="error">${err}</p>` : ""}
34
+ <div class="grid2">
35
+ <div>
36
+ <h2>People</h2>
37
+ <div class="card"><ul class="list">${d.people.map((p) => html`<${Person} key=${p.person_id} p=${p} />`)}${d.people.length ? "" : html`<li class="muted">no one attached yet</li>`}</ul>
38
+ <form class="inline noprint" onSubmit=${(e) => { e.preventDefault(); if (personName.trim()) { post(`/api/interviews/${id}/people`, { name: personName.trim() }); setPersonName(""); } }}>
39
+ <input placeholder="add a person (first name is fine)" value=${personName} onInput=${(e) => setPersonName(e.target.value)} /><button>add</button></form></div>
40
+ <h2>Prior rounds</h2>
41
+ <div class="card">${d.prior_rounds.length ? d.prior_rounds.map((r) => html`<div key=${r.interview_id}><a href=${"#/interviews/" + r.interview_id}>${r.round_label} ${r.format}</a> <span class="muted">${fmtWhen(r.scheduled_at, r.tz)}</span> <span class="tag">${r.status}${r.outcome !== "pending" ? ", " + r.outcome : ""}</span>
42
+ ${r.debrief_text ? html`<details><summary>debrief</summary><div class="intel">${r.debrief_text}</div></details>` : html`<span class="muted small"> no debrief</span>`}</div>`) : html`<span class="muted">first round</span>`}</div>
43
+ <h2>Pinned</h2>
44
+ <div class="card"><ul class="list">${d.pinned.map((p) => html`<li key=${p.id}><span class="tag pin">${p.id}</span> ${p.company}: ${p.title} <button class="small noprint" onClick=${() => unpin(p.id)}>unpin</button></li>`)}${d.pinned.length ? "" : html`<li class="muted">nothing pinned; pinned records go first in every packet</li>`}</ul>
45
+ <form class="inline noprint" onSubmit=${(e) => { e.preventDefault(); searchPins(); }}><input placeholder="search the knowledge base" value=${pinQ} onInput=${(e) => setPinQ(e.target.value)} /><button>search</button></form>
46
+ ${pinHits.length ? html`<ul class="list">${pinHits.map((r) => html`<li key=${r.id}><span class="mono">${r.id}</span> ${r.company}: ${r.title} <button class="small" onClick=${() => pin(r.id)}>pin</button></li>`)}</ul>` : ""}</div>
47
+ </div>
48
+ <div>
49
+ <h2>Build</h2>
50
+ <div class="card noprint">
51
+ <div class="row">${OPS.map((o) => html`<button key=${o.op} onClick=${() => setModal({ op: o.op, label: o.label })}>${o.label}</button>`)}</div>
52
+ <p class="small muted">via: paste (default). With Claude Desktop connected, ask: "Prep me for my ${d.company} ${d.round_label}"; it calls get_prep_packet and save_document itself and this screen updates.</p>
53
+ </div>
54
+ <h2>Documents</h2>
55
+ <div class="card"><ul class="list">${d.documents.map((doc) => html`<li key=${doc.document_id}>
56
+ <a href=${"#/documents/" + doc.document_id}>${doc.kind}</a> <span class=${"tag " + doc.status}>${doc.status.toUpperCase()}</span>
57
+ ${doc.warnings.length ? html`<span class="tag warn">${doc.warnings.length} warning${doc.warnings.length > 1 ? "s" : ""}</span>` : ""}
58
+ ${doc.status === "blocked" ? html`<span class="small error">${doc.blocks[0] ? doc.blocks[0].rule + " " + (doc.blocks[0].path || "") : ""}</span> <button class="small noprint" onClick=${async () => setModal({ op: doc.kind === "email" ? "followup" : doc.kind, label: "Repair " + doc.kind, doc: await api.get(`/api/documents/${doc.document_id}`) })}>repair</button>` : ""}
59
+ ${doc.status === "passed" ? html`<button class="small noprint" onClick=${() => post(doc.kind === "practice" ? `/api/documents/${doc.document_id}/workbook` : `/api/documents/${doc.document_id}/export`, { format: "md" })}>${doc.kind === "practice" ? "workbook" : "export"}</button>` : ""}
60
+ ${doc.rendered_rel_path ? html`<span class="small mono muted">${doc.rendered_rel_path}</span>` : ""}
61
+ <span class="small muted">${doc.route} ${doc.created_at.slice(0, 16).replace("T", " ")}</span></li>`)}
62
+ ${d.documents.length ? "" : html`<li class="muted">nothing built yet</li>`}</ul></div>
63
+ <h2>After</h2>
64
+ <div class="card noprint">
65
+ ${d.status === "scheduled" ? html`<div class="row">
66
+ <button class="primary" onClick=${() => post(`/api/interviews/${id}/complete`, {})}>Mark completed</button>
67
+ ${!d.invite_received_at ? html`<button onClick=${() => post(`/api/interviews/${id}/invite-received`, {})}>Invite received</button>` : html`<span class="tag">invite received</span>`}
68
+ <button onClick=${() => setResched(resched ? null : { at: "", tz: d.tz || localZone() })}>Reschedule</button>
69
+ <button class="danger" onClick=${() => { if (confirm("Cancel this interview?")) post(`/api/interviews/${id}/cancel`, {}); }}>Cancel</button></div>
70
+ ${resched ? html`<form class="inline" onSubmit=${(e) => { e.preventDefault(); post(`/api/interviews/${id}/reschedule`, { scheduled_at: toIso(resched.at, resched.tz), tz: resched.tz }); setResched(null); }}>
71
+ <input type="datetime-local" required value=${resched.at} onInput=${(e) => setResched({ ...resched, at: e.target.value })} /><input value=${resched.tz} onInput=${(e) => setResched({ ...resched, tz: e.target.value })} /><button class="primary">save</button></form>` : ""}` : ""}
72
+ ${d.status === "completed" ? html`<div>
73
+ <div class="row">outcome: ${["advanced", "rejected", "ghosted"].map((o) => html`<button key=${o} class=${d.outcome === o ? "primary" : ""} onClick=${() => post(`/api/interviews/${id}/outcome`, { outcome: o })}>${o}</button>`)}</div>
74
+ <h3>Debrief</h3>
75
+ ${d.debrief_text ? html`<div class="intel">${d.debrief_text}</div><p class="small muted">captured ${d.debrief_captured_at}</p>` : ""}
76
+ <textarea placeholder="What happened, who said what, in their words where you can. Quotes in a thank-you must come from here." value=${debrief} onInput=${(e) => setDebrief(e.target.value)}></textarea>
77
+ <div class="row"><button class="primary" disabled=${!debrief.trim()} onClick=${() => { post(`/api/interviews/${id}/debrief`, { text: debrief }); setDebrief(""); }}>${d.debrief_text ? "Replace debrief" : "Save debrief"}</button></div>
78
+ </div>` : ""}
79
+ ${d.status === "cancelled" || d.status === "superseded" ? html`<span class="muted">${d.status}</span>` : ""}
80
+ </div>
81
+ </div>
82
+ </div>
83
+ ${modal ? html`<${PacketModal} op=${modal.op} label=${modal.label} target=${target} document=${modal.doc} onClose=${() => setModal(null)} onSaved=${() => load()} />` : ""}
84
+ </div>`;
85
+ }
@@ -0,0 +1,142 @@
1
+ import { html, useState, useEffect } from "../vendor/preact.mjs";
2
+ import { api } from "../lib.js";
3
+ import { PacketModal } from "./packet.js";
4
+
5
+ function readFileBase64(file) {
6
+ return new Promise((resolve, reject) => { const r = new FileReader(); r.onload = () => resolve(String(r.result).split(",")[1]); r.onerror = reject; r.readAsDataURL(file); });
7
+ }
8
+
9
+ /** Onboarding: upload resumes, build extraction packets, decide duplicate groups. */
10
+ function Onboarding({ d, reload, setErr }) {
11
+ const [modal, setModal] = useState(null);
12
+ const [busy, setBusy] = useState(false);
13
+ const [style, setStyle] = useState(null);
14
+ const b = d.batch;
15
+ const upload = async (e) => {
16
+ setBusy(true);
17
+ try {
18
+ for (const f of e.target.files) {
19
+ const r = await api.post("/api/sources", { batch_id: b ? b.batch_id : undefined, filename: f.name, data_base64: await readFileBase64(f) });
20
+ if (r.empty) setErr(`${f.name}: ${r.failure}`);
21
+ }
22
+ await reload();
23
+ } catch (e2) { setErr(e2.message); }
24
+ setBusy(false);
25
+ e.target.value = "";
26
+ };
27
+ const post = async (path, body) => { try { await api.post(path, body || {}); await reload(); } catch (e) { setErr(e.message); } };
28
+ const groups = b ? b.groups.map((g) => ({ id: g, rows: b.pending.filter((p) => p.dedup_group_id === g) })) : [];
29
+ return html`<div class="card">
30
+ <div class="row"><input type="file" multiple accept=".docx,.pdf,.md,.txt" disabled=${busy} onChange=${upload} /><span class="small muted">docx, pdf, md, txt; text is extracted locally, no OCR</span></div>
31
+ ${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>
32
+ ${s.empty ? html`<span class="tag blocked">no text</span>` : html`<span class="muted small">${s.chars} chars</span>`}
33
+ <span class="muted small">${s.pending} pending, ${s.runs} runs</span>
34
+ ${!s.empty ? html`<button class="small" onClick=${() => setModal({ source: s })}>Extract</button>` : ""}</li>`)}
35
+ ${b.sources.length ? "" : html`<li class="muted">upload a resume to start</li>`}</ul>` : ""}
36
+ ${b && b.pending.length ? html`<div class="row"><button onClick=${() => post(`/api/onboarding/batches/${b.batch_id}/dedup`)}>Suggest duplicates</button><span class="small muted">same employer, overlapping dates, a shared figure; nothing merges without you</span></div>` : ""}
37
+ ${groups.map((g) => html`<div class="block warn" key=${g.id}><b>These may be the same accomplishment</b>
38
+ <ul class="list">${g.rows.map((p) => html`<li key=${p.pending_id}><span class="mono small">${p.pending_id.slice(-6)}</span> ${p.draft.title} <span class="muted small">${p.draft.company} · ${p.draft.metrics.join(", ")}</span> <button class="small" onClick=${() => post(`/api/kb/groups/${g.id}/merge`, { keep_id: p.pending_id })}>merge into this one</button></li>`)}</ul>
39
+ <button class="small" onClick=${() => post(`/api/kb/groups/${g.id}/keep`)}>Keep separate</button></div>`)}
40
+ <div class="row"><button class="small" onClick=${() => setStyle(style ? null : d.style)}>${style ? "hide" : "Proposed house style from these sources"}</button></div>
41
+ ${style ? html`<pre class="mono small wrap">${JSON.stringify(style, null, 1)}</pre><p class="small muted">Set your choices on the settings screen; a new candidate defaults to warn.</p>` : ""}
42
+ ${modal ? html`<${PacketModal} op="extract" label=${"Extract " + modal.source.filename} target=${{ source_document_id: modal.source.source_document_id, name: modal.source.filename }} onClose=${() => setModal(null)} onSaved=${reload} />` : ""}
43
+ </div>`;
44
+ }
45
+
46
+ function Excerpt({ text, span }) {
47
+ if (!span || !text) return "";
48
+ const a = Math.max(0, span.start - 60), b = Math.min(text.length, span.end + 60);
49
+ return html`<span class="small muted">…${text.slice(a, span.start)}<mark>${text.slice(span.start, span.end)}</mark>${text.slice(span.end, b)}…</span>`;
50
+ }
51
+
52
+ /** Screen 5: gaps to answer, pending rows to confirm, imported dimensions to review, confirmed records to search. */
53
+
54
+ const EMPTY = { title: "", company: "", role: "", dates: "", summary: "", metrics: "", verbs: "", wordings: "", tenure: "" };
55
+ const toDraft = (f) => ({ title: f.title, company: f.company, role: f.role, dates: f.dates, summary: f.summary,
56
+ 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() } : {}) });
57
+ 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 || "" });
58
+
59
+ function DraftForm({ initial, onSubmit, label }) {
60
+ const [f, setF] = useState(initial || EMPTY);
61
+ const up = (k) => (e) => setF({ ...f, [k]: e.target.value });
62
+ return html`<form onSubmit=${(e) => { e.preventDefault(); onSubmit(toDraft(f)); }}>
63
+ <div class="row"><input required placeholder="title (what you did, with the figure)" value=${f.title} onInput=${up("title")} /></div>
64
+ <div class="row"><input required placeholder="company" value=${f.company} onInput=${up("company")} /><input required placeholder="role" value=${f.role} onInput=${up("role")} /><input placeholder="dates, e.g. 2019-03 to 2021-08" value=${f.dates} onInput=${up("dates")} /></div>
65
+ <textarea placeholder="summary in your words" value=${f.summary} onInput=${up("summary")}></textarea>
66
+ <div class="row"><input placeholder="metrics, comma separated: 18%, $430,000" value=${f.metrics} onInput=${up("metrics")} /><input placeholder="verbs you own: led, built" value=${f.verbs} onInput=${up("verbs")} /></div>
67
+ <textarea placeholder="resume wordings, one per line" value=${f.wordings} onInput=${up("wordings")}></textarea>
68
+ <div class="row"><input placeholder="tenure phrase you will stand behind, e.g. 7+ years in fulfillment (optional)" value=${f.tenure} onInput=${up("tenure")} /></div>
69
+ <div class="row"><button class="primary">${label}</button></div>
70
+ </form>`;
71
+ }
72
+
73
+ export function KB({ live }) {
74
+ const [d, setD] = useState(null);
75
+ const [err, setErr] = useState("");
76
+ const [q, setQ] = useState("");
77
+ const [hits, setHits] = useState([]);
78
+ const [answering, setAnswering] = useState(null);
79
+ const [editing, setEditing] = useState(null);
80
+ const [adding, setAdding] = useState(false);
81
+ const [ob, setOb] = useState(null);
82
+ const [modal, setModal] = useState(null);
83
+ const [texts, setTexts] = useState({});
84
+ const load = () => Promise.all([api.get("/api/kb"), api.get("/api/onboarding")]).then(([x, o]) => { setD(x); setOb(o); setErr(""); }).catch((e) => setErr(e.message));
85
+ const showSource = async (id) => { if (texts[id]) return; const s = await api.get(`/api/sources/${id}`); setTexts({ ...texts, [id]: s.text }); };
86
+ useEffect(() => { load(); }, [live]);
87
+ const post = async (path, body) => { try { await api.post(path, body); await load(); } catch (e) { setErr(e.message); } };
88
+ if (err && !d) return html`<p class="error">${err}</p>`;
89
+ if (!d) return html`<p class="muted">loading</p>`;
90
+ const byRecord = {};
91
+ for (const r of d.review) (byRecord[r.id] = byRecord[r.id] || { id: r.id, title: r.title, company: r.company, items: [] }).items.push(r);
92
+ return html`<div>
93
+ <h1>Knowledge base</h1>
94
+ <div class="sub">${d.confirmed} confirmed · ${d.gaps.length} gap${d.gaps.length === 1 ? "" : "s"} · ${(d.questions || []).length} question${(d.questions || []).length === 1 ? "" : "s"} · ${(d.holes || []).length} hole${(d.holes || []).length === 1 ? "" : "s"} · ${d.pending.length} pending · ${d.review.length} dimensions to review · revision ${d.kb_revision}</div>
95
+ ${err ? html`<p class="error">${err}</p>` : ""}
96
+ <h2>Onboarding (your resumes to records)</h2>
97
+ ${ob ? html`<${Onboarding} d=${ob} reload=${load} setErr=${setErr} />` : ""}
98
+ <h2>Gaps (questions from postings)</h2>
99
+ <div class="card"><ul class="list">${d.gaps.map((g) => html`<li key=${g.gap_id}><div><b>?</b> ${g.company} asks: <i>"${g.requirement_text}"</i>. ${g.question}
100
+ ${answering === g.gap_id ? html`<${DraftForm} label="Answer" onSubmit=${(draft) => { post(`/api/kb/gaps/${g.gap_id}/answer`, { draft }); setAnswering(null); }} />` : html`<button class="small" onClick=${() => setAnswering(g.gap_id)}>Answer</button>`}</div></li>`)}
101
+ ${d.gaps.length ? "" : html`<li class="muted">no open gaps</li>`}</ul></div>
102
+ <h2>Discovery (the AI asks, you answer)</h2>
103
+ <div class="card"><p class="small muted">The shakedown after upload: the AI reads your confirmed records and asks what a coach would ask, one atom per question. Your answer lands pending against that record; nothing changes until you confirm it. Ask again once these are answered or dismissed.</p>
104
+ <div class="row"><button disabled=${(d.questions || []).length > 0 || d.confirmed === 0} onClick=${() => setModal({ op: "discover" })}>Ask what's missing</button>
105
+ <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>
106
+ <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>` : ""}
107
+ ${answering === q.question_id ? html`<${DraftForm} label="Add answer as pending" initial=${fromDraft(q.draft)} onSubmit=${(draft) => { post(`/api/kb/questions/${q.question_id}/answer`, { draft }); setAnswering(null); }} />`
108
+ : 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>`)}
109
+ ${(d.questions || []).length ? "" : html`<li class="muted">no open questions</li>`}</ul></div>
110
+ ${modal && modal.op === "discover" ? html`<${PacketModal} op="discover" label="Discovery questions" target=${{}} onClose=${() => setModal(null)} onSaved=${load} />` : ""}
111
+ <h2>Holes (questions your own records raise)</h2>
112
+ <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}
113
+ ${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>`)}
114
+ ${(d.holes || []).length ? "" : html`<li class="muted">no holes: every record carries a confirmed figure and every ownership claim a scope</li>`}</ul></div>
115
+ <h2>Pending (written, not yet confirmed)</h2>
116
+ <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>` : ""}
117
+ <div class="small muted">metrics: ${(p.draft.metrics || []).join(", ") || "none"} · verbs: ${(p.draft.verbs || []).join(", ") || "none"}${p.draft.dates ? " · " + p.draft.dates : ""}</div>
118
+ ${p.source === "onboarding" && p.draft.excerpts ? html`<details onToggle=${(e) => { if (e.target.open) showSource(p.draft.source_document_id); }}><summary>source excerpts</summary>
119
+ ${texts[p.draft.source_document_id] ? html`<ul class="list small">
120
+ <li>company <${Excerpt} text=${texts[p.draft.source_document_id]} span=${p.draft.excerpts.company} /></li>
121
+ <li>role <${Excerpt} text=${texts[p.draft.source_document_id]} span=${p.draft.excerpts.role} /></li>
122
+ ${p.draft.excerpts.dates ? html`<li>dates <${Excerpt} text=${texts[p.draft.source_document_id]} span=${p.draft.excerpts.dates} /></li>` : ""}
123
+ ${(p.draft.metrics || []).map((m, i) => html`<li key=${"m" + i}>metric ${m} <${Excerpt} text=${texts[p.draft.source_document_id]} span=${p.draft.excerpts.metrics[i]} /></li>`)}
124
+ ${(p.draft.verbs || []).map((v, i) => html`<li key=${"v" + i}>verb ${v} <${Excerpt} text=${texts[p.draft.source_document_id]} span=${p.draft.excerpts.verbs[i]} /></li>`)}
125
+ ${(p.draft.wordings || []).map((w, i) => html`<li key=${"w" + i}>wording <${Excerpt} text=${texts[p.draft.source_document_id]} span=${p.draft.excerpts.wordings[i]} /></li>`)}
126
+ </ul>` : html`<span class="muted small">loading</span>`}</details>` : ""}
127
+ ${editing === p.pending_id ? html`<${DraftForm} initial=${fromDraft(p.draft)} label="Confirm with these edits" onSubmit=${(draft) => { post(`/api/kb/pending/${p.pending_id}/confirm`, { edits: draft }); setEditing(null); }} />`
128
+ : html`<div class="row"><button class="primary small" onClick=${() => post(`/api/kb/pending/${p.pending_id}/confirm`, {})}>Confirm</button><button class="small" onClick=${() => setEditing(p.pending_id)}>Edit</button><button class="small danger" onClick=${() => post(`/api/kb/pending/${p.pending_id}/dismiss`, {})}>Dismiss</button></div>`}</div></li>`)}
129
+ ${d.pending.length ? "" : html`<li class="muted">nothing pending</li>`}</ul>
130
+ ${adding ? html`<${DraftForm} label="Add as pending" onSubmit=${(draft) => { post("/api/kb/pending", { draft }); setAdding(false); }} />` : html`<button class="small" onClick=${() => setAdding(true)}>+ write one by hand</button>`}</div>
131
+ <h2>Review (imported, not yet evidence)</h2>
132
+ <div class="card">${Object.values(byRecord).slice(0, 40).map((r) => html`<div key=${r.id}><b>${r.company}</b> · ${r.title} <span class="mono small">${r.id}</span>
133
+ <ul class="list">${r.items.map((it, i) => html`<li key=${i}><span class="tag">${it.dimension}</span> ${it.value} <span class="muted small">${it.detail || ""}</span>
134
+ <button class="small" onClick=${() => post(`/api/kb/${r.id}/review`, { dimension: it.dimension, value: it.value, action: "confirm" })}>Confirm</button>
135
+ <button class="small danger" onClick=${() => post(`/api/kb/${r.id}/review`, { dimension: it.dimension, value: it.value, action: "drop" })}>Drop</button></li>`)}
136
+ <li><button class="small" onClick=${() => post(`/api/kb/${r.id}/review`, { dimension: "metric", value: "*", action: "confirm" })}>confirm all metrics</button> <button class="small" onClick=${() => post(`/api/kb/${r.id}/review`, { dimension: "verb", value: "*", action: "confirm" })}>confirm all verbs</button></li></ul></div>`)}
137
+ ${d.review.length ? "" : html`<span class="muted">everything imported is confirmed</span>`}${Object.keys(byRecord).length > 40 ? html`<p class="muted small">first 40 records shown</p>` : ""}</div>
138
+ <h2>Confirmed</h2>
139
+ <div class="card"><form class="inline" onSubmit=${async (e) => { e.preventDefault(); setHits(await api.get(`/api/accomplishments?q=${encodeURIComponent(q)}&limit=20`)); }}><input placeholder="search" value=${q} onInput=${(e) => setQ(e.target.value)} /><button>search</button></form>
140
+ <ul class="list">${hits.map((r) => html`<li key=${r.id}><div><span class="mono small">${r.id}</span> <b>${r.company}</b> · ${r.title}<div class="small muted">${r.metrics.map((m) => m.value).join(", ")}${r.verbs.length ? " · " + r.verbs.map((v) => v.verb).join(", ") : ""}</div></div></li>`)}</ul></div>
141
+ </div>`;
142
+ }
@@ -0,0 +1,122 @@
1
+ import { html, useState, useEffect } from "../vendor/preact.mjs";
2
+ import { api } from "../lib.js";
3
+
4
+ /**
5
+ * Screen 7: the packet modal and the form-based repair editor. Blocks
6
+ * render as rows with the field, the offending span, and the nearest
7
+ * records; you pick or delete, then re-ingest as route manual. Raw JSON
8
+ * stays behind an advanced toggle. Nothing is ever auto-substituted.
9
+ */
10
+
11
+ function parsePath(p) {
12
+ const s = String(p || "").replace(/^\$\.?/, "");
13
+ const out = [];
14
+ for (const m of s.matchAll(/([^.[\]]+)|\[(\d+)\]/g)) out.push(m[2] !== undefined ? Number(m[2]) : m[1]);
15
+ return out;
16
+ }
17
+ function getAt(obj, keys) { let v = obj; for (const k of keys) { if (v == null) return undefined; v = v[k]; } return v; }
18
+ function setAt(obj, keys, val) {
19
+ const copy = JSON.parse(JSON.stringify(obj));
20
+ let v = copy;
21
+ for (let i = 0; i < keys.length - 1; i++) v = v[keys[i]];
22
+ v[keys[keys.length - 1]] = val;
23
+ return copy;
24
+ }
25
+ function deleteAt(obj, keys) {
26
+ const copy = JSON.parse(JSON.stringify(obj));
27
+ let v = copy;
28
+ for (let i = 0; i < keys.length - 1; i++) v = v[keys[i]];
29
+ const last = keys[keys.length - 1];
30
+ if (Array.isArray(v)) v.splice(last, 1); else delete v[last];
31
+ return copy;
32
+ }
33
+ function dropSentence(text, span) {
34
+ if (!span) return text;
35
+ const parts = String(text).split(/(?<=[.!?])\s+/);
36
+ const kept = parts.filter((s) => !s.includes(span));
37
+ return kept.length === parts.length ? text.split(span).join("") : kept.join(" ");
38
+ }
39
+
40
+ function BlockRow({ b, content, onChange, kind }) {
41
+ const keys = parsePath(b.path);
42
+ const isClaim = keys.includes("claims");
43
+ const textKeys = isClaim ? keys.slice(0, keys.indexOf("claims")).concat("text") : keys;
44
+ const value = getAt(content, textKeys);
45
+ const isText = typeof value === "string";
46
+ const claimKeys = isClaim ? keys : null;
47
+ const claim = claimKeys ? getAt(content, claimKeys) : null;
48
+ const warn = kind === "warn";
49
+ return html`<div>
50
+ <div class=${"term " + (warn ? "warn" : "blocked")}>
51
+ <div class="headline">> VALIDATE ${b.path || "$"} … ${warn ? "WARN" : "BLOCKED"}</div>
52
+ <div class="kv"><b>rule:</b> ${b.rule}<br />
53
+ ${b.span ? html`<b>span:</b> <span class="hl">"${b.span}"</span><br />` : ""}
54
+ <b>reason:</b> ${b.hint}</div>
55
+ </div>
56
+ ${isText && !warn ? html`<div class="block"><textarea value=${value} onInput=${(e) => onChange(setAt(content, textKeys, e.target.value))}></textarea>
57
+ <div class="row small"><button onClick=${() => onChange(setAt(content, textKeys, dropSentence(value, b.span)))}>delete sentence</button></div></div>` : ""}
58
+ ${claim ? html`<div class="nearby">claim ${JSON.stringify(claim)}
59
+ ${b.nearby && b.nearby.length ? html`<div>nearby (diagnostic only): ${b.nearby.map((n) => html`<button key=${n.id} onClick=${() => onChange(setAt(content, claimKeys.concat("source_id"), n.id))}>pick ${n.id}</button><span class="muted">${n.company}: ${n.title}</span> `)}</div>` : ""}
60
+ <button onClick=${() => onChange(deleteAt(content, claimKeys))}>delete claim</button></div>` : ""}
61
+ </div>`;
62
+ }
63
+
64
+ export function PacketModal({ op, label, target, document: doc, onClose, onSaved }) {
65
+ const [state, setState] = useState({ loading: !doc, packet: null, run_id: doc ? null : null, paste: "", error: "" });
66
+ const [reply, setReply] = useState("");
67
+ const [result, setResult] = useState(null);
68
+ const [content, setContent] = useState(doc ? doc.content : null);
69
+ const [docId, setDocId] = useState(doc ? doc.document_id : null);
70
+ const [raw, setRaw] = useState(false);
71
+ const [busy, setBusy] = useState(false);
72
+ useEffect(() => {
73
+ if (doc) { setResult({ status: doc.status, blocks: doc.blocks, warnings: doc.warnings, attempt: null }); return; }
74
+ const q = target.interview_id ? `?interview_id=${target.interview_id}` : target.source_document_id ? `?source_document_id=${target.source_document_id}` : target.posting_id ? `?posting_id=${target.posting_id}` : "";
75
+ api.get(`/api/packets/${op}${q}`).then((r) => setState({ loading: false, packet: r.packet, run_id: r.run_id, paste: r.paste_text, error: "" }))
76
+ .catch((e) => setState({ loading: false, packet: null, run_id: null, paste: "", error: e.message + (e.detail ? " " + JSON.stringify(e.detail) : "") }));
77
+ }, [op, doc]);
78
+ const copy = async () => { try { await navigator.clipboard.writeText(state.paste); } catch (_) { /* select manually */ } };
79
+ const apply = (r) => {
80
+ setResult(r);
81
+ if (r.document_id) { setDocId(r.document_id); if (r.status === "blocked") api.get(`/api/documents/${r.document_id}`).then((d) => setContent(d.content)); }
82
+ if (r.status === "passed" && onSaved) onSaved(r);
83
+ };
84
+ const validate = async () => {
85
+ setBusy(true);
86
+ try { apply(await api.post(`/api/runs/${state.run_id}/ingest`, { content: reply, route: "paste" })); } catch (e) { setResult({ status: "error", blocks: [{ rule: "request", path: "", span: "", hint: e.message }], warnings: [] }); }
87
+ setBusy(false);
88
+ };
89
+ const repair = async () => {
90
+ setBusy(true);
91
+ try { apply(await api.post(`/api/documents/${docId}/repair`, { content })); } catch (e) { setResult({ status: "error", blocks: [{ rule: "request", path: "", span: "", hint: e.message }], warnings: [] }); }
92
+ setBusy(false);
93
+ };
94
+ const blocked = result && result.status === "blocked" && result.blocks;
95
+ return html`<div class="modal-back" onClick=${(e) => { if (e.target === e.currentTarget) onClose(); }}><div class="modal">
96
+ <div class="row between"><h1>${label}${target && target.name && !label.includes(target.name) ? " · " + target.name : ""}</h1><button onClick=${onClose}>close</button></div>
97
+ ${state.error ? html`<p class="error">${state.error}</p>` : ""}
98
+ ${state.loading ? html`<p class="muted">building the packet</p>` : ""}
99
+ ${state.packet ? html`<div>
100
+ <div class="row"><button class="primary" onClick=${copy}>Copy all</button><span class="small muted">${state.packet.kb_slice.length} records, run ${state.run_id}. Paste into any chat. Ask for one JSON block back.</span></div>
101
+ <details><summary>show packet text</summary><textarea readonly value=${state.paste}></textarea></details>
102
+ <h3>Paste the AI's JSON (fences are fine)</h3>
103
+ <textarea value=${reply} onInput=${(e) => setReply(e.target.value)} placeholder='{"posture": ...}'></textarea>
104
+ <div class="row"><button class="primary" disabled=${busy || !reply.trim()} onClick=${validate}>Validate + save</button>
105
+ ${result && result.attempt ? html`<span class="attempt">attempt ${result.attempt} of 3${result.stop ? ", stopped: hand this to yourself, open a fresh packet" : ""}</span>` : ""}</div>
106
+ </div>` : ""}
107
+ ${result ? html`<div>
108
+ ${result.status === "passed" && result.document_id ? html`<div class="term passed"><div class="headline">> VALIDATE ${op} … PASSED${(result.warnings || []).length ? ` (${result.warnings.length} warning${result.warnings.length === 1 ? "" : "s"})` : ""}</div></div><p>Saved as <a href=${"#/documents/" + result.document_id} onClick=${onClose}>document</a>.</p>` : ""}
109
+ ${result.status === "error" ? html`<div class="term blocked"><div class="headline">> VALIDATE … ERROR</div><div class="kv">${result.blocks[0].hint}</div></div>` : ""}
110
+ ${result.errors && result.errors.length ? html`<div><div class="term blocked"><div class="headline">> VALIDATE … REJECTED (${result.errors.length} problem${result.errors.length === 1 ? "" : "s"})</div></div>${result.errors.map((e, i) => html`<div class="block" key=${i}><span class="mono small">${e}</span></div>`)}<p class="small muted">Fix the JSON in the chat and paste it again; every atom must be a verbatim excerpt at its span.</p></div>` : ""}
111
+ ${result.pending_ids && result.pending_ids.length ? html`<div class="term passed"><div class="headline">> VALIDATE … PASSED (${result.pending_ids.length} pending row${result.pending_ids.length === 1 ? "" : "s"} proposed)</div></div>` : ""}
112
+ ${result.question_ids && result.question_ids.length ? html`<div class="term passed"><div class="headline">> VALIDATE … PASSED (${result.question_ids.length} question${result.question_ids.length === 1 ? "" : "s"} to answer)</div>${result.notes ? html`<div class="kv">${result.notes}</div>` : ""}</div><p class="small muted">Close this and answer them under Discovery; each answer lands pending against its record.</p>` : ""}
113
+ ${blocked ? html`<div class="term blocked"><div class="headline">> VALIDATE ${op} … BLOCKED (${result.blocks.length} ${result.blocks.length === 1 ? "problem" : "problems"})</div></div>` : ""}
114
+ ${blocked && content ? result.blocks.map((b, i) => html`<${BlockRow} key=${i} b=${b} content=${content} onChange=${setContent} kind="block" />`) : ""}
115
+ ${blocked && !content ? result.blocks.map((b, i) => html`<${BlockRow} key=${i} b=${b} content=${{}} onChange=${() => {}} kind="block" />`) : ""}
116
+ ${result.warnings && result.warnings.length ? html`<details><summary>${result.warnings.length} warnings</summary>${result.warnings.map((w, i) => html`<${BlockRow} key=${i} b=${w} content=${content || {}} onChange=${setContent} kind="warn" />`)}</details>` : ""}
117
+ ${blocked && content ? html`<div class="row"><button class="primary" disabled=${busy} onClick=${repair}>Validate + save repaired</button>
118
+ <button onClick=${() => setRaw(!raw)}>advanced: raw JSON</button></div>
119
+ ${raw ? html`<textarea value=${JSON.stringify(content, null, 1)} onInput=${(e) => { try { setContent(JSON.parse(e.target.value)); } catch (_) { /* keep typing */ } }}></textarea>` : ""}` : ""}
120
+ </div>` : ""}
121
+ </div></div>`;
122
+ }
@@ -0,0 +1,92 @@
1
+ import { html, useState, useEffect } from "../vendor/preact.mjs";
2
+ import { api } from "../lib.js";
3
+
4
+ /** Screen 2: paste a posting, store and scan locally, edit the proposed categories, freeze, log the application. */
5
+
6
+ function CategoryEditor({ posting, scoring, onDone }) {
7
+ const [cats, setCats] = useState(scoring ? scoring.categories.map((c) => ({ category: c.category, weight: c.weight, jd_evidence: c.jd_evidence, is_hard_gate: !!c.is_hard_gate, score: c.score })) : []);
8
+ const [err, setErr] = useState("");
9
+ const [busy, setBusy] = useState(false);
10
+ const set = (i, patch) => setCats(cats.map((c, j) => (j === i ? { ...c, ...patch } : c)));
11
+ const freeze = async () => {
12
+ setBusy(true); setErr("");
13
+ try {
14
+ const scores = cats.filter((c) => c.score != null && c.score !== "").map((c) => ({ category: c.category, score: Number(c.score) }));
15
+ await api.post(`/api/postings/${posting.posting_id}/freeze`, { categories: cats.map(({ category, weight, jd_evidence, is_hard_gate }) => ({ category, weight: Number(weight), jd_evidence, is_hard_gate })), scores });
16
+ onDone();
17
+ } catch (e) { setErr(e.message); }
18
+ setBusy(false);
19
+ };
20
+ return html`<div>
21
+ <table class="cats"><thead><tr><th>category</th><th>wt</th><th>score</th><th>gate</th><th>evidence line (verbatim)</th><th></th></tr></thead><tbody>
22
+ ${cats.map((c, i) => html`<tr key=${i}>
23
+ <td><input value=${c.category} onInput=${(e) => set(i, { category: e.target.value })} /></td>
24
+ <td><input type="number" min="1" max="10" value=${c.weight} onInput=${(e) => set(i, { weight: e.target.value })} class="narrow" /></td>
25
+ <td><input type="number" min="0" max="10" step="0.5" value=${c.score ?? ""} placeholder="auto" onInput=${(e) => set(i, { score: e.target.value })} class="narrow" /></td>
26
+ <td><input type="checkbox" checked=${c.is_hard_gate} onChange=${(e) => set(i, { is_hard_gate: e.target.checked })} /></td>
27
+ <td class="small mono">${c.jd_evidence.map((e, j) => html`<div key=${j}>"${e}"</div>`)}</td>
28
+ <td><button class="small" onClick=${() => setCats(cats.filter((_, j) => j !== i))}>drop</button></td></tr>`)}
29
+ </tbody></table>
30
+ <p class="small muted">Five to eight categories. Weights come from the employer's emphasis. A score left blank is filled mechanically from the evidence ranking; your own score wins when you type one. Every evidence line must be verbatim from the posting or the freeze is rejected.</p>
31
+ ${err ? html`<p class="error">${err}</p>` : ""}
32
+ <div class="row"><button class="primary" disabled=${busy || cats.length < 5} onClick=${freeze}>Freeze categories → score</button></div>
33
+ </div>`;
34
+ }
35
+
36
+ export function Postings({ live, id }) {
37
+ const [rows, setRows] = useState([]);
38
+ const [sel, setSel] = useState(null);
39
+ const [form, setForm] = useState({ company: "", title: "", source_url: "", location: "", comp_text: "", raw_text: "" });
40
+ const [err, setErr] = useState("");
41
+ const [msg, setMsg] = useState("");
42
+ const load = async () => { setRows(await api.get("/api/postings")); if (id) setSel(await api.get(`/api/postings/${id}`)); };
43
+ useEffect(() => { load().catch((e) => setErr(e.message)); }, [live, id]);
44
+ const store = async (e) => {
45
+ e.preventDefault(); setErr(""); setMsg("");
46
+ try {
47
+ const r = await api.post("/api/postings", form);
48
+ await api.post(`/api/postings/${r.posting_id}/scan`);
49
+ setForm({ company: "", title: "", source_url: "", location: "", comp_text: "", raw_text: "" });
50
+ location.hash = "#/postings/" + r.posting_id;
51
+ setMsg(r.created ? "stored and scanned" : "already stored; scanned again");
52
+ } catch (e2) { setErr(e2.message); }
53
+ };
54
+ const act = async (path, body) => { setErr(""); try { const r = await api.post(path, body || {}); await load(); return r; } catch (e) { setErr(e.message + (e.allowed ? " (allowed: " + e.allowed.join(", ") + ")" : "")); } };
55
+ const frozen = sel && sel.scoring.find((s) => s.status === "frozen");
56
+ const proposed = sel && sel.scoring.find((s) => s.status === "proposed");
57
+ return html`<div>
58
+ <h1>Postings</h1>
59
+ ${err ? html`<p class="error">${err}</p>` : ""}${msg ? html`<p class="small muted">${msg}</p>` : ""}
60
+ <div class="grid2">
61
+ <div>
62
+ <h2>Paste posting</h2>
63
+ <form class="card" onSubmit=${store}>
64
+ <div class="row"><input required placeholder="company" value=${form.company} onInput=${(e) => setForm({ ...form, company: e.target.value })} /><input required placeholder="title" value=${form.title} onInput=${(e) => setForm({ ...form, title: e.target.value })} /></div>
65
+ <div class="row"><input placeholder="url" value=${form.source_url} onInput=${(e) => setForm({ ...form, source_url: e.target.value })} /><input placeholder="location" value=${form.location} onInput=${(e) => setForm({ ...form, location: e.target.value })} /><input placeholder="comp text" value=${form.comp_text} onInput=${(e) => setForm({ ...form, comp_text: e.target.value })} /></div>
66
+ <textarea required placeholder="raw posting text, stored verbatim, now" value=${form.raw_text} onInput=${(e) => setForm({ ...form, raw_text: e.target.value })}></textarea>
67
+ <div class="row"><button class="primary">Store + scan</button><span class="small muted">local, no model</span></div>
68
+ </form>
69
+ <h2>Stored</h2>
70
+ <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>
71
+ </div>
72
+ <div>
73
+ ${sel ? html`<h2>${sel.company}: ${sel.title}</h2>
74
+ <div class="card">
75
+ <div class="row"><span class="tag">${sel.status}</span><span class="muted small">${[sel.location, sel.comp_text].filter(Boolean).join(" · ")}</span>
76
+ ${sel.status === "proposed" ? html`<button class="primary" onClick=${() => act(`/api/postings/${sel.posting_id}/accept`)}>Accept</button>` : ""}
77
+ ${sel.status === "proposed" ? html`<button onClick=${() => act(`/api/postings/${sel.posting_id}/archive`)}>Archive</button>` : ""}
78
+ <button onClick=${() => act(`/api/postings/${sel.posting_id}/scan`)}>Scan again</button></div>
79
+ <details><summary>raw text (${sel.raw_text.length} chars)</summary><pre class="mono wrap">${sel.raw_text}</pre></details>
80
+ </div>
81
+ ${frozen ? html`<div class="card"><b>fit ${frozen.fit_score} · ${frozen.verdict.toUpperCase()}</b> <span class="muted small">${frozen.verdict_reason}</span>
82
+ <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>
83
+ ${!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>` : ""}
84
+ </div>` : ""}
85
+ <h3>${frozen ? "Re-score" : "Proposed categories"} ${proposed ? html`<span class="tag">draft from ${proposed.scored_at.slice(0, 10)}</span>` : ""}</h3>
86
+ ${proposed || frozen ? html`<div class="card"><${CategoryEditor} key=${(proposed || frozen).scoring_id} posting=${sel} scoring=${proposed || frozen} onDone=${load} /></div>` : html`<p class="muted">no categories yet; scan the posting or let a model propose them through MCP</p>`}
87
+ ${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>` : ""}
88
+ ` : html`<p class="muted">pick a posting</p>`}
89
+ </div>
90
+ </div>
91
+ </div>`;
92
+ }