@gafj/gafj 0.1.11 → 0.1.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bridge/byo_key.js +1 -1
- package/bridge/paste.js +1 -1
- package/core/rules/extract.md +6 -2
- package/core/schemas/extract.js +8 -7
- package/http/api.js +1 -0
- package/package.json +1 -1
- package/store/onboarding.js +41 -3
- package/ui/app.css +4 -0
- package/ui/screens/kb.js +12 -4
package/bridge/byo_key.js
CHANGED
|
@@ -70,7 +70,7 @@ async function runPacket(homeDir, id, { packet, fetchImpl }) {
|
|
|
70
70
|
const started = Date.now();
|
|
71
71
|
try {
|
|
72
72
|
const op = packet.operation || packet.op;
|
|
73
|
-
const r = await adapter.complete({ provider: p, system: "You are a careful writing engine. Reply with exactly one fenced json block matching OUTPUT SCHEMA and nothing else.", user: renderPasteText(packet), max_tokens: MAX_OUT[op] || 16000, effort: EFFORT[op], fetch: fetchImpl });
|
|
73
|
+
const r = await adapter.complete({ provider: p, system: "You are a careful writing engine. Reply with exactly one fenced json block matching OUTPUT SCHEMA and nothing else. Write the JSON compact, on one line, with no indentation and no spaces after commas or colons.", user: renderPasteText(packet), max_tokens: MAX_OUT[op] || 16000, effort: EFFORT[op], fetch: fetchImpl });
|
|
74
74
|
return { content: r.text, provider: p.kind, model: r.model || p.model, tokens_in: r.tokens_in, tokens_out: r.tokens_out, latency_ms: Date.now() - started, stop_reason: r.stop_reason || null };
|
|
75
75
|
} catch (e) {
|
|
76
76
|
throw Object.assign(new Error(scrub(e.message, p.api_key)), { status: 502 });
|
package/bridge/paste.js
CHANGED
|
@@ -20,7 +20,7 @@ function renderPasteText(packet, { run_id } = {}) {
|
|
|
20
20
|
const head = [
|
|
21
21
|
`# GAF-J packet: ${packet.operation}${run_id ? ` (run ${run_id})` : ""}`,
|
|
22
22
|
"",
|
|
23
|
-
"Read everything below, then reply with exactly one fenced ```json block that matches OUTPUT SCHEMA. No prose before or after the block.",
|
|
23
|
+
"Read everything below, then reply with exactly one fenced ```json block that matches OUTPUT SCHEMA. No prose before or after the block. Write the JSON compact: one line, no indentation.",
|
|
24
24
|
"Cite knowledge base records by the id shown beside each record, in the claims array of every claim-bearing field. Never invent an id, a figure, or a date.",
|
|
25
25
|
run_id ? `The reply is saved to a file and ingested with: gafj ingest --run ${run_id} --file <reply file>` : "",
|
|
26
26
|
].filter(Boolean).join("\n");
|
package/core/rules/extract.md
CHANGED
|
@@ -4,7 +4,7 @@ You are reading one SOURCE document (a resume or a similar record of work) for o
|
|
|
4
4
|
|
|
5
5
|
## Excerpt or nothing
|
|
6
6
|
|
|
7
|
-
Every factual atom you propose (company, role, date range, each metric value, each ownership verb, the scope line, each wording)
|
|
7
|
+
Every factual atom you propose (company, role, date range, each metric value, each ownership verb, the scope line, each wording) is a quote: the exact text as it appears in the SOURCE, character for character, including punctuation and capitalisation. Do not count positions; the engine finds each quote in the source itself and rejects any it cannot find. A company the source never names is rejected. If you cannot quote it, leave it out. The summary is the only field that may paraphrase. Keep the summary to one or two sentences.
|
|
8
8
|
|
|
9
9
|
## One record per result
|
|
10
10
|
|
|
@@ -20,12 +20,16 @@ The ownership verb is the lead verb of the wording, in the form the source uses.
|
|
|
20
20
|
|
|
21
21
|
## Wordings
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
The bullet or sentence that states the result, verbatim; usually one, at most three when the source states the same result more than once. The first wording is the one you would recommend. Never repeat a wording across records. Keep the source's punctuation.
|
|
24
24
|
|
|
25
25
|
## Style observed
|
|
26
26
|
|
|
27
27
|
Report what the source does: dashes (none, hyphens, em dashes, mixed), how it spells ecommerce, whether percent is a symbol or a word. Observations only; the candidate chooses their house rules.
|
|
28
28
|
|
|
29
|
+
## Size
|
|
30
|
+
|
|
31
|
+
Compact JSON on one line, no indentation. Summary in one sentence or none. Do not restate in the summary what the wording already says.
|
|
32
|
+
|
|
29
33
|
## Do not
|
|
30
34
|
|
|
31
35
|
Do not invent tags the source does not support. Do not propose employment history the source does not contain. Do not read a job posting or any other text in this packet as the candidate's own experience; only the SOURCE is theirs.
|
package/core/schemas/extract.js
CHANGED
|
@@ -3,13 +3,14 @@ const { str, arr, obj } = require("./lang");
|
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Onboarding extraction (plan section U): accomplishments proposed from
|
|
6
|
-
* one source document. Every factual atom is an excerpt
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* one source document. Every factual atom is an excerpt of the source text,
|
|
7
|
+
* quoted exactly; propose_from_source locates each one and refuses any it
|
|
8
|
+
* cannot find. Prose (summary) may paraphrase; atoms may not.
|
|
9
9
|
* No claims here: this never crosses the document ledger.
|
|
10
10
|
*/
|
|
11
|
-
|
|
12
|
-
const
|
|
11
|
+
// An atom is the quote itself. The store finds where it sits in the source; the model never counts characters.
|
|
12
|
+
const ATOM = str("quoted_source", { max: 300 });
|
|
13
|
+
const METRIC = obj({ value: str("quoted_source", { max: 80 }), outcome: str("prose", { max: 160 }) }, ["value"]);
|
|
13
14
|
|
|
14
15
|
module.exports = obj({
|
|
15
16
|
accomplishments: arr(obj({
|
|
@@ -17,10 +18,10 @@ module.exports = obj({
|
|
|
17
18
|
company: ATOM,
|
|
18
19
|
role: ATOM,
|
|
19
20
|
dates: ATOM,
|
|
20
|
-
summary: str("prose", { max:
|
|
21
|
+
summary: str("prose", { max: 300 }),
|
|
21
22
|
metrics: arr(METRIC, 0, 12),
|
|
22
23
|
verbs: arr(ATOM, 0, 8),
|
|
23
|
-
wordings: arr(ATOM, 1,
|
|
24
|
+
wordings: arr(ATOM, 1, 3),
|
|
24
25
|
scope: ATOM,
|
|
25
26
|
tags: arr(str("identifier", { max: 40 }), 0, 8),
|
|
26
27
|
}, ["title", "company", "role", "wordings"]), 1, 40),
|
package/http/api.js
CHANGED
|
@@ -230,6 +230,7 @@ route("POST", "/api/run/:op", async (c, p, b, q) => {
|
|
|
230
230
|
}
|
|
231
231
|
const r = saveReply(c.db, { run_id: opened.run_id, content: reply.content, route: routeName, actor: ACTOR("run"), now: c.now(), provider: reply.provider, model: reply.model, tokens_in: reply.tokens_in, tokens_out: reply.tokens_out, latency_ms: reply.latency_ms });
|
|
232
232
|
if (r.status !== "passed" && reply.stop_reason === "max_tokens") r.errors = [...(r.errors || []), "the reply was cut off at the token limit"];
|
|
233
|
+
Object.assign(r, { tokens_in: reply.tokens_in ?? null, tokens_out: reply.tokens_out ?? null, latency_ms: reply.latency_ms ?? null, model: reply.model || null });
|
|
233
234
|
return reply.credits_left === undefined ? r : { ...r, charged: reply.charged, credits_left: reply.credits_left };
|
|
234
235
|
});
|
|
235
236
|
|
package/package.json
CHANGED
package/store/onboarding.js
CHANGED
|
@@ -96,17 +96,55 @@ function sourceText(db, source_document_id) {
|
|
|
96
96
|
|
|
97
97
|
// ----- propose_from_source -----
|
|
98
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Excerpt or nothing, with the counting done here: a model quotes reliably but cannot count
|
|
101
|
+
* characters, so a span that does not match is relocated to where the quoted text actually
|
|
102
|
+
* sits in the source (the occurrence nearest the claimed start; whitespace runs may differ).
|
|
103
|
+
* A value that appears nowhere in the source, byte for byte and case for case, is refused.
|
|
104
|
+
*/
|
|
99
105
|
function checkAtom(atom, text, label, errors) {
|
|
100
106
|
if (!atom) return;
|
|
101
107
|
const { value, start, end } = atom;
|
|
102
|
-
if (
|
|
103
|
-
|
|
108
|
+
if (Number.isInteger(start) && Number.isInteger(end) && start >= 0 && end <= text.length && end > start && text.slice(start, end) === value) return;
|
|
109
|
+
const v = String(value);
|
|
110
|
+
const near = Number.isInteger(start) ? Math.max(0, start) : 0;
|
|
111
|
+
let hits = [];
|
|
112
|
+
if (v.length) for (let i = text.indexOf(v); i !== -1; i = text.indexOf(v, i + 1)) hits.push([i, i + v.length]);
|
|
113
|
+
if (!hits.length && v.trim()) {
|
|
114
|
+
const re = new RegExp(v.trim().split(/\s+/).map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("\\s+"), "g");
|
|
115
|
+
for (let m = re.exec(text); m; m = re.exec(text)) { hits.push([m.index, m.index + m[0].length]); if (!m[0].length) break; }
|
|
116
|
+
}
|
|
117
|
+
if (!hits.length) { errors.push(`${label}: "${v.slice(0, 40)}" is not the source text at [${start}, ${end})`); return; }
|
|
118
|
+
const [s, e] = hits.reduce((best, h) => (Math.abs(h[0] - near) < Math.abs(best[0] - near) ? h : best));
|
|
119
|
+
atom.start = s; atom.end = e; atom.value = text.slice(s, e);
|
|
104
120
|
}
|
|
105
121
|
|
|
106
|
-
/**
|
|
122
|
+
/** Atoms arrive as plain quotes; an older reply's { value, start, end } objects are accepted and reduced to the quote. */
|
|
123
|
+
function normalizeAtoms(content) {
|
|
124
|
+
const q = (a) => (a && typeof a === "object" && !Array.isArray(a) && "value" in a ? String(a.value) : a);
|
|
125
|
+
if (!content || typeof content !== "object" || !Array.isArray(content.accomplishments)) return content;
|
|
126
|
+
for (const a of content.accomplishments) {
|
|
127
|
+
if (!a || typeof a !== "object") continue;
|
|
128
|
+
for (const k of ["company", "role", "dates", "scope"]) if (k in a) a[k] = q(a[k]);
|
|
129
|
+
if (Array.isArray(a.verbs)) a.verbs = a.verbs.map(q);
|
|
130
|
+
if (Array.isArray(a.wordings)) a.wordings = a.wordings.map(q);
|
|
131
|
+
if (Array.isArray(a.metrics)) a.metrics = a.metrics.map((m) => (m && typeof m === "object" ? { value: String(m.value), ...(m.outcome !== undefined ? { outcome: m.outcome } : {}) } : m));
|
|
132
|
+
}
|
|
133
|
+
return content;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Validate a draft against its source: every atom found verbatim, else the whole draft is rejected. Located spans are written onto the draft as { value, start, end }. */
|
|
107
137
|
function checkDraft(content, text) {
|
|
138
|
+
normalizeAtoms(content);
|
|
108
139
|
const errors = validate(content, SCHEMAS.extract).map((e) => `${e.path}: ${e.message}`);
|
|
109
140
|
if (errors.length) return errors;
|
|
141
|
+
// lift each quote into an atom the locator can write a span onto
|
|
142
|
+
for (const a of content.accomplishments) {
|
|
143
|
+
for (const k of ["company", "role", "dates", "scope"]) if (typeof a[k] === "string") a[k] = { value: a[k], start: -1, end: -1 };
|
|
144
|
+
a.verbs = (a.verbs || []).map((v) => (typeof v === "string" ? { value: v, start: -1, end: -1 } : v));
|
|
145
|
+
a.wordings = (a.wordings || []).map((w) => (typeof w === "string" ? { value: w, start: -1, end: -1 } : w));
|
|
146
|
+
a.metrics = (a.metrics || []).map((m) => ({ ...m, start: -1, end: -1 }));
|
|
147
|
+
}
|
|
110
148
|
content.accomplishments.forEach((a, i) => {
|
|
111
149
|
const p = `accomplishments[${i}]`;
|
|
112
150
|
checkAtom(a.company, text, `${p}.company`, errors);
|
package/ui/app.css
CHANGED
|
@@ -182,3 +182,7 @@ mark { background: var(--good-bg); color: var(--good-bright); border-radius: 3px
|
|
|
182
182
|
.switch input:checked + .track { background: var(--accent); }
|
|
183
183
|
.switch input:checked + .track::after { left: 18px; }
|
|
184
184
|
.switch input:focus-visible + .track { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
185
|
+
/* a small spinner beside anything that is with the model */
|
|
186
|
+
.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; }
|
|
187
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
188
|
+
@media (prefers-reduced-motion: reduce) { .spin { animation: none; border-top-color: var(--line-2); } }
|
package/ui/screens/kb.js
CHANGED
|
@@ -15,13 +15,21 @@ export function Onboarding({ d, reload, setErr, route, compact }) {
|
|
|
15
15
|
const b = d.batch;
|
|
16
16
|
const direct = route === "byo_key" || route === "credits";
|
|
17
17
|
// with a provider or credits the extraction runs here; otherwise the packet opens for paste
|
|
18
|
+
// a ticking clock while a file is with the model, so a long call is visibly alive
|
|
19
|
+
const [, tick] = useState(0);
|
|
20
|
+
useEffect(() => { const t = setInterval(() => tick((n) => n + 1), 1000); return () => clearInterval(t); }, []);
|
|
21
|
+
const since = {};
|
|
22
|
+
const elapsed = (ms) => { const sec = Math.round(ms / 1000); return `${Math.floor(sec / 60)}:${String(sec % 60).padStart(2, "0")}`; };
|
|
18
23
|
const runOne = async (s) => {
|
|
19
|
-
|
|
24
|
+
const started = Date.now();
|
|
25
|
+
setRunning((r) => ({ ...r, [s.source_document_id]: { started } }));
|
|
20
26
|
try {
|
|
21
27
|
const r = await api.post(`/api/run/extract?source_document_id=${s.source_document_id}`, {});
|
|
22
|
-
|
|
28
|
+
const took = `${elapsed(Date.now() - started)}${r.tokens_in ? ` · ${(r.tokens_in + (r.tokens_out || 0)).toLocaleString()} tokens` : ""}`;
|
|
29
|
+
setRunning((x) => ({ ...x, [s.source_document_id]: r.status === "passed" ? `${r.pending_ids.length} records proposed in ${took}` : `refused after ${took}: ${(r.errors || []).slice(0, 2).join("; ")}` }));
|
|
23
30
|
} catch (e) { setRunning((x) => ({ ...x, [s.source_document_id]: "failed: " + e.message })); }
|
|
24
31
|
};
|
|
32
|
+
const isRunning = (id) => running[id] && typeof running[id] === "object";
|
|
25
33
|
const extract = async (s) => {
|
|
26
34
|
if (!direct) { setModal({ source: s }); return; }
|
|
27
35
|
await runOne(s);
|
|
@@ -57,9 +65,9 @@ export function Onboarding({ d, reload, setErr, route, compact }) {
|
|
|
57
65
|
${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>
|
|
58
66
|
${s.empty ? html`<span class="tag blocked">no text</span>` : html`<span class="muted small">${s.chars} chars</span>`}
|
|
59
67
|
<span class="muted small">${s.pending} pending, ${s.runs} runs</span>
|
|
60
|
-
${!s.empty ? html`<button class="small" disabled=${
|
|
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> extracting · ${elapsed(Date.now() - running[s.source_document_id].started)}` : direct ? "Extract" : "Extract (paste)"}</button>` : ""}
|
|
61
69
|
<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>
|
|
62
|
-
${running[s.source_document_id] &&
|
|
70
|
+
${running[s.source_document_id] && !isRunning(s.source_document_id) ? html`<span class="small muted">${running[s.source_document_id]}</span>` : ""}</li>`)}
|
|
63
71
|
${b.sources.length ? "" : html`<li class="muted">upload a resume to start</li>`}</ul>` : ""}
|
|
64
72
|
${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>` : ""}
|
|
65
73
|
${groups.map((g) => html`<div class="block warn" key=${g.id}><b>These may be the same accomplishment</b>
|