@gafj/gafj 0.1.13 → 0.1.16

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 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], schema: packet.output_schema, 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");
@@ -9,12 +9,31 @@ const DEFAULT_MODEL = "claude-sonnet-5";
9
9
  * passes effort ("low" for extraction) and it goes out as output_config.effort; the thinking
10
10
  * parameter itself is never sent, which is the setting every current model accepts.
11
11
  */
12
- async function complete({ provider, system, user, max_tokens = 4000, effort, fetch }) {
12
+ /**
13
+ * The API's structured-output mode accepts a subset of JSON Schema: no length, range, or count
14
+ * limits, no vendor keys. This strips those so the schema the store validates against can be
15
+ * handed to the model as the shape it must produce. Enums, required, and additionalProperties
16
+ * stay, which is what makes the reply valid by construction.
17
+ */
18
+ const DROP = new Set(["maxLength", "minLength", "minimum", "maximum", "minItems", "maxItems", "multipleOf", "pattern", "format"]);
19
+ function strictSchema(node) {
20
+ if (Array.isArray(node)) return node.map(strictSchema);
21
+ if (!node || typeof node !== "object") return node;
22
+ const out = {};
23
+ for (const [k, v] of Object.entries(node)) {
24
+ if (DROP.has(k) || k.startsWith("x-")) continue;
25
+ out[k] = k === "properties" ? Object.fromEntries(Object.entries(v).map(([pk, pv]) => [pk, strictSchema(pv)])) : strictSchema(v);
26
+ }
27
+ return out;
28
+ }
29
+
30
+ async function complete({ provider, system, user, max_tokens = 4000, effort, schema, fetch }) {
13
31
  const { post } = require("./index");
14
32
  const base = (provider.base_url || DEFAULT_URL).replace(/\/$/, "");
15
33
  const model = provider.model || DEFAULT_MODEL;
16
34
  const body = { model, max_tokens, system, messages: [{ role: "user", content: user }] };
17
- if (effort) body.output_config = { effort };
35
+ const usable = schema && typeof schema === "object" && schema.type === "object" && schema.properties && Object.keys(schema.properties).length;
36
+ if (effort || usable) body.output_config = { ...(effort ? { effort } : {}), ...(usable ? { format: { type: "json_schema", schema: strictSchema(schema) } } : {}) };
18
37
  const data = await post(fetch, `${base}/v1/messages`, { "x-api-key": provider.api_key || "", "anthropic-version": "2023-06-01" }, body);
19
38
  if (data.stop_reason === "refusal") throw new Error(`the model declined this request${data.stop_details && data.stop_details.category ? ` (${data.stop_details.category})` : ""}`);
20
39
  const text = (data.content || []).filter((c) => c.type === "text").map((c) => c.text).join("\n");
@@ -29,4 +48,4 @@ async function listModels({ provider, fetch }) {
29
48
  return (data.data || []).map((m) => ({ id: m.id, name: m.display_name || m.id, created: m.created_at || null }));
30
49
  }
31
50
 
32
- module.exports = { complete, listModels, DEFAULT_URL, DEFAULT_MODEL };
51
+ module.exports = { complete, listModels, strictSchema, DEFAULT_URL, DEFAULT_MODEL };
@@ -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) carries the exact value and the character span [start, end) in the SOURCE text where that value appears. The value must equal the source text at that span, character for character. A metric with no span is rejected. A company the source never names is rejected. If you cannot point at it, leave it out. The summary is the only field that may paraphrase.
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
- Every bullet or sentence that states the result, verbatim, each with its span. The first wording is the one you would recommend. Keep the source's punctuation.
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.
@@ -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: the value plus
7
- * the [start, end) span of the source text that contains it, checked by
8
- * propose_from_source. Prose (summary) may paraphrase; atoms may not.
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
- const ATOM = obj({ value: str("quoted_source", { max: 300 }), start: { t: "number", min: 0 }, end: { t: "number", min: 0 } });
12
- const METRIC = obj({ value: str("quoted_source", { max: 80 }), start: { t: "number", min: 0 }, end: { t: "number", min: 0 }, outcome: str("prose", { max: 160 }) }, ["value", "start", "end"]);
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: 1200 }),
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, 6),
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gafj/gafj",
3
- "version": "0.1.13",
3
+ "version": "0.1.16",
4
4
  "description": "GAF-J: a local campaign engine for job search. One SQLite file, one ingest door, your own AI subscription.",
5
5
  "homepage": "https://gaf-j.com",
6
6
  "repository": {
@@ -105,14 +105,15 @@ function sourceText(db, source_document_id) {
105
105
  function checkAtom(atom, text, label, errors) {
106
106
  if (!atom) return;
107
107
  const { value, start, end } = atom;
108
- if (!Number.isInteger(start) || !Number.isInteger(end)) { errors.push(`${label}: span [${start}, ${end}) is not inside the source`); return; }
109
- if (start >= 0 && end <= text.length && end > start && text.slice(start, end) === value) return;
108
+ if (Number.isInteger(start) && Number.isInteger(end) && start >= 0 && end <= text.length && end > start && text.slice(start, end) === value) return;
110
109
  const v = String(value);
111
110
  const near = Number.isInteger(start) ? Math.max(0, start) : 0;
112
111
  let hits = [];
113
112
  if (v.length) for (let i = text.indexOf(v); i !== -1; i = text.indexOf(v, i + 1)) hits.push([i, i + v.length]);
114
113
  if (!hits.length && v.trim()) {
115
- const re = new RegExp(v.trim().split(/\s+/).map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("\\s+"), "g");
114
+ // the model tends to straighten quotes and dashes; match either form in the source
115
+ const cls = (ch) => (/['\u2018\u2019]/.test(ch) ? "['\u2018\u2019]" : /["\u201c\u201d]/.test(ch) ? "[\"\u201c\u201d]" : /[-\u2013\u2014]/.test(ch) ? "[-\u2013\u2014]" : ch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
116
+ const re = new RegExp(v.trim().split(/\s+/).map((w) => Array.from(w).map(cls).join("")).join("\\s+"), "g");
116
117
  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; }
117
118
  }
118
119
  if (!hits.length) { errors.push(`${label}: "${v.slice(0, 40)}" is not the source text at [${start}, ${end})`); return; }
@@ -120,10 +121,32 @@ function checkAtom(atom, text, label, errors) {
120
121
  atom.start = s; atom.end = e; atom.value = text.slice(s, e);
121
122
  }
122
123
 
123
- /** Validate a draft against its source: every atom cited and verbatim, else the whole draft is rejected. */
124
+ /** Atoms arrive as plain quotes; an older reply's { value, start, end } objects are accepted and reduced to the quote. */
125
+ function normalizeAtoms(content) {
126
+ const q = (a) => (a && typeof a === "object" && !Array.isArray(a) && "value" in a ? String(a.value) : a);
127
+ if (!content || typeof content !== "object" || !Array.isArray(content.accomplishments)) return content;
128
+ for (const a of content.accomplishments) {
129
+ if (!a || typeof a !== "object") continue;
130
+ for (const k of ["company", "role", "dates", "scope"]) if (k in a) a[k] = q(a[k]);
131
+ if (Array.isArray(a.verbs)) a.verbs = a.verbs.map(q);
132
+ if (Array.isArray(a.wordings)) a.wordings = a.wordings.map(q);
133
+ if (Array.isArray(a.metrics)) a.metrics = a.metrics.map((m) => (m && typeof m === "object" ? { value: String(m.value), ...(m.outcome !== undefined ? { outcome: m.outcome } : {}) } : m));
134
+ }
135
+ return content;
136
+ }
137
+
138
+ /** 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 }. */
124
139
  function checkDraft(content, text) {
140
+ normalizeAtoms(content);
125
141
  const errors = validate(content, SCHEMAS.extract).map((e) => `${e.path}: ${e.message}`);
126
142
  if (errors.length) return errors;
143
+ // lift each quote into an atom the locator can write a span onto
144
+ for (const a of content.accomplishments) {
145
+ for (const k of ["company", "role", "dates", "scope"]) if (typeof a[k] === "string") a[k] = { value: a[k], start: -1, end: -1 };
146
+ a.verbs = (a.verbs || []).map((v) => (typeof v === "string" ? { value: v, start: -1, end: -1 } : v));
147
+ a.wordings = (a.wordings || []).map((w) => (typeof w === "string" ? { value: w, start: -1, end: -1 } : w));
148
+ a.metrics = (a.metrics || []).map((m) => ({ ...m, start: -1, end: -1 }));
149
+ }
127
150
  content.accomplishments.forEach((a, i) => {
128
151
  const p = `accomplishments[${i}]`;
129
152
  checkAtom(a.company, text, `${p}.company`, errors);