@konneal/engine 0.1.3 → 0.2.0

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 (68) hide show
  1. package/dist/admin.d.ts +1 -0
  2. package/dist/ask-NAWBPZPU.js +12 -0
  3. package/dist/chunk-6HCFW5PM.js +2780 -0
  4. package/dist/{chunk-EHJEELVB.js → chunk-LNSDBEKS.js} +1 -1
  5. package/dist/chunk-LNUSF3UK.js +1852 -0
  6. package/dist/{chunk-35ODH64W.js → chunk-Q327B27J.js} +33 -0
  7. package/dist/{chunk-OCNLV7Q7.js → chunk-Q6LI4T7M.js} +6 -1
  8. package/dist/chunk-RFG3QKVY.js +64 -0
  9. package/dist/{chunk-ROF3Q7UC.js → chunk-SN3ANQ3Y.js} +2 -2
  10. package/dist/{chunk-CAEHIVG5.js → chunk-WGXATDXY.js} +1 -1
  11. package/dist/codecs.d.ts +3 -3
  12. package/dist/completion.d.ts +1 -1
  13. package/dist/config.d.ts +9 -0
  14. package/dist/faithfulness.d.ts +1 -0
  15. package/dist/mcp-proto.d.ts +25 -0
  16. package/dist/mcp.d.ts +4 -0
  17. package/dist/openapi-surface.gen.d.ts +9 -0
  18. package/dist/openapi-types.d.ts +2141 -0
  19. package/dist/pipeline.d.ts +3 -0
  20. package/dist/profile.gen.d.ts +1 -0
  21. package/dist/prompts/system.md +1 -0
  22. package/dist/prompts/understanding.md +1 -0
  23. package/dist/quota.d.ts +4 -1
  24. package/dist/search-7XMYAV34.js +11 -0
  25. package/dist/tablecontext.d.ts +7 -0
  26. package/dist/verdict-parse.d.ts +5 -0
  27. package/dist/worker_mcp/src/index.js +3 -3
  28. package/dist/worker_public/src/config.js +4 -2
  29. package/dist/worker_public/src/index.js +1190 -4834
  30. package/dist/worker_public/src/profile.js +1 -1
  31. package/dist/worker_public/src/refusal.js +2 -2
  32. package/dist/worker_public/src/requestScope.js +3 -3
  33. package/docs/INGEST-ARCHITECTURE.md +1 -0
  34. package/docs/projects-design.md +7 -0
  35. package/docs/sota-mechanisms.md +15 -0
  36. package/docs/spec-api.md +27 -13
  37. package/docs/spec-pipeline.md +20 -13
  38. package/package.json +12 -3
  39. package/profile/prompts.yaml +3 -0
  40. package/workers/shared/router.ts +23 -16
  41. package/workers/worker_public/migrations/0014_usage_cache.sql +5 -0
  42. package/workers/worker_public/openapi.yaml +1169 -0
  43. package/workers/worker_public/prompts/system.md +1 -0
  44. package/workers/worker_public/prompts/understanding.md +1 -0
  45. package/workers/worker_public/schema.sql +3 -1
  46. package/workers/worker_public/src/admin.ts +51 -7
  47. package/workers/worker_public/src/ask.ts +97 -24
  48. package/workers/worker_public/src/codecs.ts +35 -10
  49. package/workers/worker_public/src/completion.ts +24 -1
  50. package/workers/worker_public/src/config.ts +10 -0
  51. package/workers/worker_public/src/conversations.ts +1 -1
  52. package/workers/worker_public/src/faithfulness.ts +10 -17
  53. package/workers/worker_public/src/grader.ts +2 -2
  54. package/workers/worker_public/src/index.ts +106 -58
  55. package/workers/worker_public/src/lib/router.ts +1 -1
  56. package/workers/worker_public/src/mcp-proto.ts +71 -0
  57. package/workers/worker_public/src/mcp.ts +47 -0
  58. package/workers/worker_public/src/openapi-surface.gen.ts +318 -0
  59. package/workers/worker_public/src/pipeline.ts +9 -3
  60. package/workers/worker_public/src/profile.gen.ts +1 -0
  61. package/workers/worker_public/src/projects.ts +4 -2
  62. package/workers/worker_public/src/quota.ts +4 -2
  63. package/workers/worker_public/src/research.ts +55 -3
  64. package/workers/worker_public/src/stages/citationProbe.ts +145 -0
  65. package/workers/worker_public/src/stages/index.ts +2 -0
  66. package/workers/worker_public/src/stages/types.ts +4 -0
  67. package/workers/worker_public/src/tablecontext.ts +13 -2
  68. package/workers/worker_public/src/verdict-parse.ts +60 -0
@@ -4,7 +4,7 @@ import { LIMITS, MODELS, DATASETS, THRESHOLDS, processExpansion } from "./config
4
4
  import systemPromptText from "../prompts/system.md";
5
5
  import conversationalPromptText from "../prompts/conversational.md";
6
6
  import listwisePromptText from "../prompts/listwise.md";
7
- import { tableContext } from "./tablecontext";
7
+ import { tableSelection } from "./tablecontext";
8
8
 
9
9
  // the pinned refusal sentence lives with the canonicalizer in ./refusal
10
10
  // (refusals are never cached: a refusal says "retrieval found nothing",
@@ -52,6 +52,9 @@ export interface Retrieved {
52
52
  * among them (dense retrieval alone binds everyday words to the wrong
53
53
  * term: measured "keeps drifting" → creep 0.69 vs durability 0.54) */
54
54
  glossary?: GlossaryEntry[];
55
+ /** structured facts stages extracted from the graph (GraphRAG) —
56
+ * merged into the answer prompt's retrieval note */
57
+ notes?: string[];
55
58
  }
56
59
 
57
60
  // Short follow-ups are usually elliptical ("and the limits?") — fold the
@@ -130,13 +133,14 @@ export async function retrieve(
130
133
 
131
134
  const ctx: PipelineContext = {
132
135
  env, query, rq, folded, u, filters, filter, vector, lexicalHits,
133
- matches: [], hits: [], finalHits: [], glossary: [], opts, lane: {},
136
+ matches: [], hits: [], finalHits: [], glossary: [], notes: [], opts, lane: {},
134
137
  };
135
138
  await runStages(STAGES, ctx);
136
139
  return {
137
140
  hits: ctx.finalHits,
138
141
  filters: ctx.filters ?? {},
139
142
  ...(ctx.glossary?.length ? { glossary: ctx.glossary } : {}),
143
+ ...(ctx.notes?.length ? { notes: ctx.notes } : {}),
140
144
  };
141
145
  }
142
146
 
@@ -333,7 +337,9 @@ export function buildMessages(
333
337
  const head = `[${usedHits.length + 1}] ${label}${unitTag} ${h.metadata.clause_title ? "— " + h.metadata.clause_title : ""}\n`;
334
338
  // tables: schema-aware pruning from the producer payload; the
335
339
  // stored text is the fallback (pruning never goes below baseline)
336
- const pruned = (h.metadata as any).block === "table" ? tableContext(h.metadata, query) : null;
340
+ const tableSel = (h.metadata as any).block === "table" ? tableSelection(h.metadata, query) : null;
341
+ const pruned = tableSel?.text ?? null;
342
+ if (tableSel) (h.metadata as any).table_selection = { cols: tableSel.cols, rowsShown: tableSel.rowsShown, rowsTotal: tableSel.rowsTotal };
337
343
  const body = clipToTokens(pruned ?? h.text, LIMITS.maxPassageTokens);
338
344
  const t = estTokens(head) + estTokens(body);
339
345
  if (t <= remain) {
@@ -104,6 +104,7 @@ export const PROFILE = {
104
104
  },
105
105
  "prompts": {
106
106
  "vars": {
107
+ "publisher_identity": "the Fixture Organization — a worldwide organization that publishes the fixture corpus",
107
108
  "assistant_identity": "the fixture assistant — a public service answering questions about the fixture publisher's documents",
108
109
  "refusal_sentence": "I don't have information on this in the indexed fixture documents.",
109
110
  "account_note_source": "the user's own fixture account",
@@ -30,8 +30,10 @@ export async function handleProjects(env: any, sub: string, req: Request, route:
30
30
  }
31
31
  if (method === "POST") {
32
32
  const body: any = await req.json().catch(() => null);
33
- if (typeof body?.project_id === "string") {
34
- // move a conversation in/out of a project (membership-as-move)
33
+ if (body && (typeof body.project_id === "string" || body.project_id === null)) {
34
+ // move a conversation in/out of a project (membership-as-move) —
35
+ // null UNFILES (the 2026-09-16 bug: the string-only guard let the
36
+ // null case fall through to create-project and 400)
35
37
  const convId = String(body.conversation_id ?? "");
36
38
  if (!/^[a-zA-Z0-9_-]{8,64}$/.test(convId)) return err(400, "invalid_input", "bad conversation id");
37
39
  const target = body.project_id === null ? null : String(body.project_id);
@@ -39,13 +39,15 @@ export function telemetry(
39
39
  answerChars: number,
40
40
  queryHash: string,
41
41
  lang?: string,
42
+ cache?: "exact" | "semantic",
43
+ meta?: { durationMs?: number; keyId?: string | null },
42
44
  ) {
43
45
  const day = today();
44
46
  ctx.waitUntil(
45
47
  env.DB.batch([
46
48
  env.DB.prepare(
47
- "INSERT INTO queries (ts, day, tier, route, model, ok, answer_chars, query_hash, lang) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)",
48
- ).bind(new Date().toISOString(), day, tier, route, model, ok ? 1 : 0, answerChars, queryHash, lang ?? null),
49
+ "INSERT INTO queries (ts, day, tier, route, model, ok, answer_chars, query_hash, lang, cache, duration_ms, key_id) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12)",
50
+ ).bind(new Date().toISOString(), day, tier, route, model, ok ? 1 : 0, answerChars, queryHash, lang ?? null, cache ?? null, meta?.durationMs ?? null, meta?.keyId ?? null),
49
51
  env.DB.prepare(
50
52
  "INSERT INTO spend (day, tier, model, requests) VALUES (?1,?2,?3,1) ON CONFLICT(day, tier, model) DO UPDATE SET requests = requests + 1",
51
53
  ).bind(day, tier, model ?? "none"),
@@ -31,6 +31,47 @@ export async function handleResearch(env: Env, ctx: ExecutionContext, req: Reque
31
31
  if (!q) return err(400, "invalid_input", `query is required (1-${LIMITS.maxInputChars} chars)`);
32
32
  const maxIters = Math.min(Math.max(Number(body?.max_iterations) || 3, 1), 3);
33
33
 
34
+ // the passes, streamed when the caller asks: each retrieval and each
35
+ // sufficiency judgement is an event, so the reader watches the loop
36
+ // work instead of watching a spinner — the final event carries the
37
+ // same result object the JSON path returns
38
+ if (body?.stream === true) {
39
+ const enc = new TextEncoder();
40
+ const started = Date.now();
41
+ const stream = new ReadableStream({
42
+ async start(ctrl) {
43
+ const send = (e: unknown) => ctrl.enqueue(enc.encode(`data: ${JSON.stringify(e)}\n\n`));
44
+ try {
45
+ const out = await run(env, ctx, q, maxIters, send);
46
+ send({ type: "done", ...out });
47
+ } catch (e: any) {
48
+ send({ type: "error", message: String(e?.message ?? e).slice(0, 200) });
49
+ } finally {
50
+ ctrl.close();
51
+ }
52
+ },
53
+ });
54
+ return new Response(stream, {
55
+ headers: {
56
+ "content-type": "text/event-stream; charset=utf-8",
57
+ "cache-control": "no-cache",
58
+ "x-accel-buffering": "no",
59
+ ...corsHeaders(req),
60
+ },
61
+ });
62
+ void started;
63
+ }
64
+ const out = await run(env, ctx, q, maxIters);
65
+ return json({ ...out, ...corsHeaders(req) });
66
+ }
67
+
68
+ async function run(
69
+ env: Env,
70
+ ctx: ExecutionContext,
71
+ q: { query: string; lang?: string },
72
+ maxIters: number,
73
+ emit?: (e: unknown) => void,
74
+ ): Promise<{ answer: string; citations: unknown[]; model: string; query_hash: string; research: { iterations: number; passages: number; elapsed_ms: number; sufficient: boolean | null } }> {
34
75
  const started = Date.now();
35
76
  const queryHash = await sha256Hex(q.query);
36
77
  const understanding = await understandQuery(portModelRunner(env), MODELS.understand, q.query, [], []);
@@ -44,6 +85,7 @@ export async function handleResearch(env: Env, ctx: ExecutionContext, req: Reque
44
85
 
45
86
  for (let i = 0; i < maxIters; i++) {
46
87
  iterations = i + 1;
88
+ emit?.({ type: "pass", n: iterations, of: maxIters, phase: "retrieving" });
47
89
  let retrieved: { hits: Hit[] };
48
90
  try {
49
91
  retrieved = await retrieve(env, q.query, {
@@ -95,6 +137,15 @@ export async function handleResearch(env: Env, ctx: ExecutionContext, req: Reque
95
137
  }
96
138
  })();
97
139
  console.log("research iter", iterations, "passages", passages.length, "sufficient:", judge?.sufficient);
140
+ emit?.({
141
+ type: "pass",
142
+ n: iterations,
143
+ of: maxIters,
144
+ phase: "judged",
145
+ passages: passages.length,
146
+ sufficient: judge?.sufficient ?? null,
147
+ ...(judge && !judge.sufficient && judge.missing ? { missing: judge.missing.slice(0, 300) } : {}),
148
+ });
98
149
  if (!judge || judge.sufficient || !judge.missing) break;
99
150
  // fold, don't accumulate: appending every round's `missing` compounds
100
151
  // stale wants; the next retrieval focuses on the ORIGINAL question plus
@@ -104,14 +155,14 @@ export async function handleResearch(env: Env, ctx: ExecutionContext, req: Reque
104
155
 
105
156
  const used = [...accumulated.values()];
106
157
  if (!used.length) {
107
- return err(503, "retrieval_unavailable", "Search is briefly busy — please retry in a moment.");
158
+ throw new Error("Search is briefly busy — please retry in a moment.");
108
159
  }
109
160
  const { messages, usedHits } = buildMessages(q.query, used, q.lang, [], eNote || undefined, undefined, LIMITS.inputTokenBudget);
110
161
  let answer = await generateOnce(env, MODELS.research, messages);
111
162
  if (answer === null) answer = await generateOnce(env, MODELS.fallback, messages);
112
163
  if (answer === null) {
113
164
  telemetry(env, ctx, "member", "research", MODELS.research, false, 0, queryHash, q.lang);
114
- return err(502, "generation_failed", "The generation model is unavailable; please retry.");
165
+ throw new Error("The generation model is unavailable; please retry.");
115
166
  }
116
167
  answer = canonicalRefusal(answer);
117
168
  const anchors = checkQuoteAnchors(answer, used.map((h: Hit) => h.text));
@@ -124,5 +175,6 @@ export async function handleResearch(env: Env, ctx: ExecutionContext, req: Reque
124
175
  research: { iterations, passages: used.length, elapsed_ms: Date.now() - started, sufficient: judge?.sufficient ?? null },
125
176
  };
126
177
  telemetry(env, ctx, "member", "research", MODELS.research, true, answer.length, queryHash, q.lang);
127
- return json({ ...out, ...corsHeaders(req) });
178
+ emit?.({ type: "pass", n: iterations, of: maxIters, phase: "writing", passages: used.length });
179
+ return out;
128
180
  }
@@ -0,0 +1,145 @@
1
+ // Citation probe (deterministic, GraphRAG): questions asking what a
2
+ // publication CITES/REFERENCES need the bibliography chunks in the
3
+ // pool, but the bibliography's embedding rarely matches the question's
4
+ // phrasing — and prior editions may carry references the current one
5
+ // dropped. This stage pattern-matches the citation question shape and
6
+ // does two things for the named document family (all editions):
7
+ // 1. surfaces the bibliography SECTIONS as passages (FTS over the
8
+ // chunk store — no embedding similarity involved), and
9
+ // 2. injects the graph's structured `cites` edges (built at ingest
10
+ // from the same bibliographies) as an authoritative note — the
11
+ // graph answers the STRUCTURE (the list of cited standards), the
12
+ // passages ground it verbatim.
13
+ // Additive: results join the pool; nothing is filtered.
14
+ import type { Stage } from "./types.ts";
15
+ import { namedDocumentIn } from "../context.ts";
16
+ import { refCodec } from "../codecs.ts";
17
+ import type { Hit } from "../../../shared/chunk.ts";
18
+
19
+ const CITE_PATTERN = /\b(?:cite[sd]?|citing|referenc(?:e|es|ed|ing)|list[s]?|quote[sd]?)\b/i;
20
+ const REFS_PATTERN = /\b(?:standard|publication|document|normative|bibliograph)/i;
21
+
22
+ export interface CiteRow {
23
+ docidentifier: string;
24
+ edition: string;
25
+ active: number;
26
+ label: string;
27
+ }
28
+
29
+ /** The authoritative citation note: per edition, the cited standards in
30
+ * bibliography order. docLabel is the codec-normalized identifier — a
31
+ * bare number never appears. */
32
+ export function citationGraphNote(docLabel: string, rows: CiteRow[], cap = 30): string {
33
+ const bySrc = new Map<string, { active: boolean; labels: string[] }>();
34
+ for (const r of rows) {
35
+ const key = r.edition && !r.docidentifier.includes(r.edition) ? `${r.docidentifier}:${r.edition}` : r.docidentifier;
36
+ let e = bySrc.get(key);
37
+ if (!e) bySrc.set(key, (e = { active: !!r.active, labels: [] }));
38
+ if (e.labels.length < cap && !e.labels.includes(r.label)) e.labels.push(r.label);
39
+ }
40
+ if (!bySrc.size) return "";
41
+ const lines = [...bySrc.entries()]
42
+ .sort((a, b) => Number(b[1].active) - Number(a[1].active))
43
+ .map(([k, v]) => `- ${k}${v.active ? " (active edition)" : ""} cites: ${v.labels.join(", ")}`);
44
+ return [
45
+ `Citation graph (authoritative — extracted from the indexed bibliographies of ${docLabel}):`,
46
+ ...lines,
47
+ `When the question asks what ${docLabel} cites or references, answer from this list, name each standard exactly as listed, and cite the bibliography passage(s) provided in the context.`,
48
+ ].join("\n");
49
+ }
50
+
51
+ export const citationProbe: Stage = {
52
+ name: "citation-probe",
53
+ failure: "additive",
54
+ when: (c) => {
55
+ if (!CITE_PATTERN.test(c.query) || !REFS_PATTERN.test(c.query)) return false;
56
+ // the TEXT-derived naming (namedDocumentIn), never the LLM's
57
+ // extraction — the understand model may omit doc_number for this
58
+ // query shape (measured: it did)
59
+ const named = namedDocumentIn(c.query);
60
+ if (!named) return false;
61
+ (c as any).__citeDocNum = named.doc_number;
62
+ // the family key is codec-derived (label → family), never a bare number
63
+ (c as any).__citeFamily = refCodec().familyOf(named.label);
64
+ (c as any).__citeLabel = named.label;
65
+ (c as any).__citeEdition = named.edition ?? null;
66
+ return true;
67
+ },
68
+ prefetch: (c) => {
69
+ // deterministic: the chunk store's own bibliography-titled chunks for
70
+ // this document family — no embedding similarity involved (three
71
+ // vector-probe iterations measured: the embedding never matched
72
+ // reliably)
73
+ const docNum = String((c as any).__citeDocNum ?? c.u?.doc_number ?? "");
74
+ const family = (c as any).__citeFamily as string | null;
75
+ c.lane["citation-probe"] = Promise.all([
76
+ (async () => {
77
+ if (!docNum) return [] as Hit[];
78
+ try {
79
+ // chunks_fts is a virtual FTS5 table over chunks.fts_text — it
80
+ // has NO metadata columns. The doc_number and clause_title live
81
+ // in the chunks content table; join them.
82
+ const rows = await c.env.DB.prepare(
83
+ "SELECT c.id FROM chunks_fts f JOIN chunks c ON c.rowid = f.rowid WHERE chunks_fts MATCH ?1 AND c.doc_number = ?2 AND (c.clause_title LIKE '%ibliograph%' OR c.clause_title LIKE '%ormative reference%') LIMIT 8",
84
+ )
85
+ .bind("bibliography OR references", docNum)
86
+ .all() as { results?: Array<{ id: string }> };
87
+ const ids = (rows.results ?? []).map((r: { id: string }) => r.id).slice(0, 8);
88
+ if (!ids.length) return [] as Hit[];
89
+ const got = await c.env.VECTORIZE.getByIds(ids);
90
+ if (!got?.length) return [] as Hit[];
91
+ // getByIds returns metadata WITHOUT the chunk text — the
92
+ // usedHits builder calls h.text.clipToTokens and crashes on
93
+ // undefined (the live 503). Fetch the text from D1 and merge.
94
+ const ph = ids.map((_: string, i: number) => `?${i + 1}`).join(",");
95
+ const texts = await c.env.DB.prepare(`SELECT id, text FROM chunks WHERE id IN (${ph})`)
96
+ .bind(...ids)
97
+ .all() as { results?: Array<{ id: string; text: string }> };
98
+ const textById = new Map((texts.results ?? []).map((r) => [r.id, r.text]));
99
+ return got
100
+ .filter((h: any) => textById.has(h.id))
101
+ .map((h: any) => ({ ...h, score: 10, text: textById.get(h.id)! }));
102
+ } catch {
103
+ return [] as Hit[];
104
+ }
105
+ })(),
106
+ // the graph's cites edges for the family — structured, edition-keyed
107
+ (async () => {
108
+ if (!family) return [] as CiteRow[];
109
+ try {
110
+ const rows = await c.env.DB.prepare(
111
+ "SELECT d.docidentifier, d.edition, d.active, n.label FROM graph_edges e JOIN documents d ON e.src = d.canonical_id JOIN graph_nodes n ON e.dst = n.id WHERE e.kind = 'cites' AND d.family = ?1 ORDER BY d.active DESC, d.edition DESC LIMIT 120",
112
+ )
113
+ .bind(family)
114
+ .all() as { results?: CiteRow[] };
115
+ return rows.results ?? [];
116
+ } catch {
117
+ return [] as CiteRow[];
118
+ }
119
+ })(),
120
+ ]);
121
+ },
122
+ run: async (c) => {
123
+ const [probes, citeRows] = (await c.lane["citation-probe"]) as [Hit[], CiteRow[]];
124
+ const seen = new Set(c.hits.map((m: any) => m.id));
125
+ let added = 0;
126
+ // only bibliography-shaped chunks (clause title or text mentions it)
127
+ for (const h of probes) {
128
+ if (seen.has(h.id as any)) continue;
129
+ const title = String((h.metadata as any)?.clause_title ?? "");
130
+ const text = String(h.text ?? "");
131
+ if (/bibliograph|normative reference/i.test(title + " " + text.slice(0, 300))) {
132
+ c.hits.push(h);
133
+ seen.add(h.id as any);
134
+ added++;
135
+ }
136
+ }
137
+ // the structured note: an edition-scoped question sees only that
138
+ // edition's citations; the family question sees all editions
139
+ const edition = (c as any).__citeEdition as string | null;
140
+ const scoped = edition ? citeRows.filter((r) => r.edition === edition) : citeRows;
141
+ const note = citationGraphNote((c as any).__citeLabel as string, scoped);
142
+ if (note) c.notes.push(note);
143
+ console.log("citation-probe:", added, "passages,", note ? "graph note on" : "graph note off", `(${citeRows.length} cite rows)`);
144
+ },
145
+ };
@@ -12,6 +12,7 @@ import type { Stage } from "./types.ts";
12
12
  export { runStages } from "./types.ts";
13
13
  export type { PipelineContext, RetrieveOptions, GlossaryEntry, Stage } from "./types.ts";
14
14
  import { dense } from "./dense.ts";
15
+ import { citationProbe } from "./citationProbe.ts";
15
16
  import { hyde } from "./hyde.ts";
16
17
  import { glossary } from "./glossary.ts";
17
18
  import { conceptGraph } from "./conceptGraph.ts";
@@ -54,6 +55,7 @@ export const STAGES: Stage[] = [
54
55
  familyBoost,
55
56
  rerankStage,
56
57
  lexicalRrf,
58
+ citationProbe,
57
59
  corpusScope,
58
60
  editionCover,
59
61
  stdRefNudge,
@@ -64,6 +64,10 @@ export interface PipelineContext {
64
64
  hits: Hit[]; // the ranked pool from poolOpen onward
65
65
  finalHits: Hit[]; // the answer window
66
66
  glossary: GlossaryEntry[]; // the vocabulary link (glossary stage owns)
67
+ /** structured facts stages contribute to the answer prompt (the
68
+ * GraphRAG seam: graph-derived notes ride the same channel the
69
+ * vocabulary link does — ask.ts merges them into the retrieval note) */
70
+ notes: string[];
67
71
  opts: RetrieveOptions;
68
72
  /** prefetch bag: stage-name → that stage's in-flight I/O promise (the
69
73
  * stage owns its key; see Stage.prefetch) */
@@ -8,7 +8,14 @@
8
8
  * selected column labels. Full table stays available for rendering;
9
9
  * this only shapes the prompt context, and falls back to the stored
10
10
  * text when pruning matches nothing (never worse than baseline). */
11
- export function tableContext(meta: any, query: string): string | null {
11
+ export interface TableSelection {
12
+ text: string;
13
+ cols: string[];
14
+ rowsShown: number;
15
+ rowsTotal: number;
16
+ }
17
+
18
+ export function tableSelection(meta: any, query: string): TableSelection | null {
12
19
  const t: any = meta?.table;
13
20
  if (!t || !Array.isArray(t.columns) || !Array.isArray(t.rows) || !t.rows.length) return null;
14
21
  const terms = new Set(
@@ -37,5 +44,9 @@ export function tableContext(meta: any, query: string): string | null {
37
44
  rowHits.length > CAP || rowHits.length < t.rows.length
38
45
  ? `\n(${shown.length} of ${t.rows.length} rows shown; ${t.rows.length - rowHits.length} rows did not match the question terms)`
39
46
  : "";
40
- return `${header}\n${lines.join("\n")}${elided}`;
47
+ return { text: `${header}\n${lines.join("\n")}${elided}`, cols: colKeep.map((i: number) => `${t.columns[i]?.label ?? ""}${t.columns[i]?.unit ? ` [${t.columns[i].unit}]` : ""}`), rowsShown: shown.length, rowsTotal: t.rows.length };
48
+ }
49
+
50
+ export function tableContext(meta: any, query: string): string | null {
51
+ return tableSelection(meta, query)?.text ?? null;
41
52
  }
@@ -0,0 +1,60 @@
1
+ // Judge-verdict extraction, kept free of the prompt import so plain
2
+ // node can load it for unit tests.
3
+ export interface Verdict {
4
+ score: number; // 0-1, clamped
5
+ ungrounded_claims: string[];
6
+ }
7
+
8
+ function coerceVerdict(obj: unknown): Verdict | null {
9
+ if (obj === null || typeof obj !== "object" || Array.isArray(obj)) return null;
10
+ const raw = (obj as { score?: unknown }).score;
11
+ const score = typeof raw === "number" ? raw : typeof raw === "string" && raw.trim() !== "" ? Number(raw) : NaN;
12
+ if (!Number.isFinite(score)) return null;
13
+ const claims = (obj as { ungrounded_claims?: unknown }).ungrounded_claims;
14
+ return {
15
+ score: Math.max(0, Math.min(1, score)),
16
+ ungrounded_claims: Array.isArray(claims) ? claims.map(String).slice(0, 5) : [],
17
+ };
18
+ }
19
+
20
+ // Reasoning models emit drafts, prose, fenced blocks, and several {...}
21
+ // fragments before the verdict; the verdict is the LAST well-formed
22
+ // object carrying a numeric score. Braces can also appear INSIDE claim
23
+ // strings, so the scan tracks string state instead of regexing flat
24
+ // objects (the flat scan silently missed those shapes live).
25
+ export function parseVerdict(text: string): Verdict | null {
26
+ const stripped = text.replace(/```[a-zA-Z]*\n?/g, "").replace(/```/g, "").trim();
27
+ try {
28
+ const whole = coerceVerdict(JSON.parse(stripped));
29
+ if (whole) return whole;
30
+ } catch {
31
+ // not one JSON document — scan candidates
32
+ }
33
+ let verdict: Verdict | null = null;
34
+ let depth = 0;
35
+ let start = -1;
36
+ let inString = false;
37
+ let escaped = false;
38
+ for (let i = 0; i < stripped.length; i++) {
39
+ const ch = stripped[i];
40
+ if (escaped) { escaped = false; continue; }
41
+ if (inString && ch === "\\") { escaped = true; continue; }
42
+ if (ch === '"') { inString = !inString; continue; }
43
+ if (inString) continue;
44
+ if (ch === "{") {
45
+ if (depth === 0) start = i;
46
+ depth++;
47
+ } else if (ch === "}" && depth > 0) {
48
+ depth--;
49
+ if (depth === 0 && start >= 0) {
50
+ try {
51
+ const v = coerceVerdict(JSON.parse(stripped.slice(start, i + 1)));
52
+ if (v) verdict = v;
53
+ } catch {
54
+ // malformed candidate — keep scanning
55
+ }
56
+ }
57
+ }
58
+ }
59
+ return verdict;
60
+ }