@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.
- package/dist/admin.d.ts +1 -0
- package/dist/ask-NAWBPZPU.js +12 -0
- package/dist/chunk-6HCFW5PM.js +2780 -0
- package/dist/{chunk-EHJEELVB.js → chunk-LNSDBEKS.js} +1 -1
- package/dist/chunk-LNUSF3UK.js +1852 -0
- package/dist/{chunk-35ODH64W.js → chunk-Q327B27J.js} +33 -0
- package/dist/{chunk-OCNLV7Q7.js → chunk-Q6LI4T7M.js} +6 -1
- package/dist/chunk-RFG3QKVY.js +64 -0
- package/dist/{chunk-ROF3Q7UC.js → chunk-SN3ANQ3Y.js} +2 -2
- package/dist/{chunk-CAEHIVG5.js → chunk-WGXATDXY.js} +1 -1
- package/dist/codecs.d.ts +3 -3
- package/dist/completion.d.ts +1 -1
- package/dist/config.d.ts +9 -0
- package/dist/faithfulness.d.ts +1 -0
- package/dist/mcp-proto.d.ts +25 -0
- package/dist/mcp.d.ts +4 -0
- package/dist/openapi-surface.gen.d.ts +9 -0
- package/dist/openapi-types.d.ts +2141 -0
- package/dist/pipeline.d.ts +3 -0
- package/dist/profile.gen.d.ts +1 -0
- package/dist/prompts/system.md +1 -0
- package/dist/prompts/understanding.md +1 -0
- package/dist/quota.d.ts +4 -1
- package/dist/search-7XMYAV34.js +11 -0
- package/dist/tablecontext.d.ts +7 -0
- package/dist/verdict-parse.d.ts +5 -0
- package/dist/worker_mcp/src/index.js +3 -3
- package/dist/worker_public/src/config.js +4 -2
- package/dist/worker_public/src/index.js +1190 -4834
- package/dist/worker_public/src/profile.js +1 -1
- package/dist/worker_public/src/refusal.js +2 -2
- package/dist/worker_public/src/requestScope.js +3 -3
- package/docs/INGEST-ARCHITECTURE.md +1 -0
- package/docs/projects-design.md +7 -0
- package/docs/sota-mechanisms.md +15 -0
- package/docs/spec-api.md +27 -13
- package/docs/spec-pipeline.md +20 -13
- package/package.json +12 -3
- package/profile/prompts.yaml +3 -0
- package/workers/shared/router.ts +23 -16
- package/workers/worker_public/migrations/0014_usage_cache.sql +5 -0
- package/workers/worker_public/openapi.yaml +1169 -0
- package/workers/worker_public/prompts/system.md +1 -0
- package/workers/worker_public/prompts/understanding.md +1 -0
- package/workers/worker_public/schema.sql +3 -1
- package/workers/worker_public/src/admin.ts +51 -7
- package/workers/worker_public/src/ask.ts +97 -24
- package/workers/worker_public/src/codecs.ts +35 -10
- package/workers/worker_public/src/completion.ts +24 -1
- package/workers/worker_public/src/config.ts +10 -0
- package/workers/worker_public/src/conversations.ts +1 -1
- package/workers/worker_public/src/faithfulness.ts +10 -17
- package/workers/worker_public/src/grader.ts +2 -2
- package/workers/worker_public/src/index.ts +106 -58
- package/workers/worker_public/src/lib/router.ts +1 -1
- package/workers/worker_public/src/mcp-proto.ts +71 -0
- package/workers/worker_public/src/mcp.ts +47 -0
- package/workers/worker_public/src/openapi-surface.gen.ts +318 -0
- package/workers/worker_public/src/pipeline.ts +9 -3
- package/workers/worker_public/src/profile.gen.ts +1 -0
- package/workers/worker_public/src/projects.ts +4 -2
- package/workers/worker_public/src/quota.ts +4 -2
- package/workers/worker_public/src/research.ts +55 -3
- package/workers/worker_public/src/stages/citationProbe.ts +145 -0
- package/workers/worker_public/src/stages/index.ts +2 -0
- package/workers/worker_public/src/stages/types.ts +4 -0
- package/workers/worker_public/src/tablecontext.ts +13 -2
- 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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|