@konneal/engine 0.2.104 → 0.2.109

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 (40) hide show
  1. package/dist/ablate.d.ts +31 -0
  2. package/dist/ask-EAKLN77G.js +15 -0
  3. package/dist/{chunk-25ECO5M7.js → chunk-2R7OVYAO.js} +377 -185
  4. package/dist/{chunk-U4ERFUOK.js → chunk-4NXH5ZI6.js} +1 -1
  5. package/dist/{chunk-DTW4UW2E.js → chunk-DJ65EF5G.js} +1 -1
  6. package/dist/{chunk-NCZHN6QW.js → chunk-G4KBK5XZ.js} +175 -22
  7. package/dist/{chunk-ANUHRJ4Z.js → chunk-G56TYXNN.js} +3 -3
  8. package/dist/{chunk-TFGJ6NYS.js → chunk-HAU5INEJ.js} +5 -5
  9. package/dist/{chunk-KNCRYAX3.js → chunk-TH5XGPD5.js} +1 -1
  10. package/dist/{chunk-BOAITSVV.js → chunk-VT7DR6NQ.js} +11 -1
  11. package/dist/config.d.ts +6 -0
  12. package/dist/openapi-types.d.ts +24 -0
  13. package/dist/prompts/doc-summary.md +1 -0
  14. package/dist/prompts/speculative-verify.md +9 -0
  15. package/dist/route.d.ts +29 -0
  16. package/dist/search-HKGN7F3O.js +13 -0
  17. package/dist/speculative.d.ts +9 -0
  18. package/dist/{tools-EXDADNWX.js → tools-WSWEGJVQ.js} +4 -4
  19. package/dist/worker_mcp/src/index.js +2 -2
  20. package/dist/worker_public/src/config.js +1 -1
  21. package/dist/worker_public/src/index.js +20 -16
  22. package/dist/worker_public/src/requestScope.js +2 -2
  23. package/docs/spec-pipeline.md +12 -1
  24. package/package.json +1 -1
  25. package/workers/worker_public/openapi.yaml +53 -0
  26. package/workers/worker_public/prompts/doc-summary.md +1 -0
  27. package/workers/worker_public/prompts/speculative-verify.md +9 -0
  28. package/workers/worker_public/src/ablate.ts +97 -0
  29. package/workers/worker_public/src/admin.ts +14 -6
  30. package/workers/worker_public/src/ask.ts +120 -16
  31. package/workers/worker_public/src/config.ts +10 -0
  32. package/workers/worker_public/src/pipeline.ts +5 -2
  33. package/workers/worker_public/src/route.ts +77 -0
  34. package/workers/worker_public/src/speculative.ts +39 -0
  35. package/workers/worker_public/src/stages/graphPpr.ts +189 -0
  36. package/workers/worker_public/src/stages/index.ts +19 -0
  37. package/workers/worker_public/src/stages/sectionDescent.ts +91 -7
  38. package/workers/worker_public/src/stages/types.ts +6 -0
  39. package/dist/ask-M3VRAIJB.js +0 -15
  40. package/dist/search-5SNP5CHL.js +0 -13
@@ -4,15 +4,15 @@ import {
4
4
  handleMemories,
5
5
  scoreFaithfulness,
6
6
  scoreJudge
7
- } from "../../chunk-NCZHN6QW.js";
7
+ } from "../../chunk-G4KBK5XZ.js";
8
8
  import {
9
9
  TOOLS_REGISTRY,
10
10
  bindModelNode,
11
11
  modelGroundingBlock
12
- } from "../../chunk-ANUHRJ4Z.js";
12
+ } from "../../chunk-G56TYXNN.js";
13
13
  import {
14
14
  handleSearch
15
- } from "../../chunk-TFGJ6NYS.js";
15
+ } from "../../chunk-HAU5INEJ.js";
16
16
  import {
17
17
  buildMessages,
18
18
  citations,
@@ -34,13 +34,13 @@ import {
34
34
  sessionFrom,
35
35
  telemetry,
36
36
  understandQuery
37
- } from "../../chunk-25ECO5M7.js";
37
+ } from "../../chunk-2R7OVYAO.js";
38
38
  import {
39
39
  cfBlobs,
40
40
  embed,
41
41
  generateOnce,
42
42
  portModelRunner
43
- } from "../../chunk-KNCRYAX3.js";
43
+ } from "../../chunk-TH5XGPD5.js";
44
44
  import {
45
45
  authenticate,
46
46
  corsHeaders,
@@ -49,14 +49,14 @@ import {
49
49
  readJson,
50
50
  validateQuery,
51
51
  withCors
52
- } from "../../chunk-DTW4UW2E.js";
52
+ } from "../../chunk-DJ65EF5G.js";
53
53
  import {
54
54
  canonicalRefusal
55
55
  } from "../../chunk-A3QHHUN5.js";
56
56
  import {
57
57
  entitlementScope,
58
58
  standardKeysFrom
59
- } from "../../chunk-U4ERFUOK.js";
59
+ } from "../../chunk-4NXH5ZI6.js";
60
60
  import {
61
61
  LIMITS,
62
62
  MODELS,
@@ -67,7 +67,7 @@ import {
67
67
  roleModel,
68
68
  sha256Hex,
69
69
  today
70
- } from "../../chunk-BOAITSVV.js";
70
+ } from "../../chunk-VT7DR6NQ.js";
71
71
  import "../../chunk-JZFXSTYX.js";
72
72
  import {
73
73
  P,
@@ -347,6 +347,9 @@ async function handleGetShared(env, slug) {
347
347
  // workers/worker_public/prompts/enrichment.md
348
348
  var enrichment_default = "You write a retrieval context for a passage from {{CORPUS_KIND}}. The context is prepended to the passage before embedding so a semantic search can locate the passage even when the query uses different vocabulary than the passage itself.\n\nWrite ONE concise sentence (at most 40 words) that situates the passage: name the publication by its exact {{PUBLISHER_NAME}} identifier (including part or annex when applicable) and what the passage covers \u2014 paraphrasing the topic in words DIFFERENT from the passage's own. Do not copy the passage verbatim, do not add facts that are not derivable from the passage or its header, do not answer or explain the content. Reply with the context sentence only \u2014 no quotes, no preamble.\n";
349
349
 
350
+ // workers/worker_public/prompts/doc-summary.md
351
+ var doc_summary_default = "You write the document-level summary for a legal-metrology publication in {{PUBLISHER_NAME}}'s retrieval index. The section summaries below cover the publication clause by clause. Write one compact abstract, three to five sentences: what the publication regulates or provides, its scope and applicability, and the structure a reader should know (parts, annexes, where the normative core sits). Use the publication's own terms exactly. No preamble, no headings.\n";
352
+
350
353
  // workers/worker_public/prompts/section-summary.md
351
354
  var section_summary_default = "You summarize one numbered clause of a metrology publication for a retrieval index. You are given the publication, the clause number, and excerpts of its sub-clauses.\n\nWrite a dense summary of 3 to 5 sentences stating what the clause governs and how its sub-clauses divide the subject. Name each sub-clause number together with its topic, in document order.\n\nPlain factual prose. No preamble, no headings, no bullet list, no quotation marks around the whole text. Write in the same language as the excerpts.\n";
352
355
 
@@ -451,18 +454,19 @@ async function handleSectionUnit(env, ctx, req) {
451
454
  return { id: u?.id ?? null, ok: false, error: "invalid unit (id, metadata.doc_id, metadata.clause_anchor, children required)" };
452
455
  }
453
456
  try {
457
+ const level = u?.level === 2 ? 2 : 1;
454
458
  const cacheKey = `s:${u.id}`;
455
459
  let summary = body?.force === true ? null : await env.CACHE.get(cacheKey);
456
460
  const cached = !!summary;
457
461
  if (!summary) {
458
- const head = `${m.docidentifier ?? m.doc_id} \xA7${m.clause_anchor}${m.clause_title ? " \u2014 " + m.clause_title : ""}`;
459
- const listing = u.children.slice(0, 12).map((c) => `\xA7${c.anchor ?? ""}${c.title ? " " + c.title : ""} \u2014 ${String(c.excerpt ?? "").slice(0, 260)}`).join("\n");
462
+ const head = level === 2 ? `${m.docidentifier ?? m.doc_id} \u2014 document summary` : `${m.docidentifier ?? m.doc_id} \xA7${m.clause_anchor}${m.clause_title ? " \u2014 " + m.clause_title : ""}`;
463
+ const listing = u.children.slice(0, 12).map((c) => level === 2 ? `- ${c.title ?? c.anchor ?? ""} \u2014 ${String(c.excerpt ?? "").slice(0, 300)}` : `\xA7${c.anchor ?? ""}${c.title ? " " + c.title : ""} \u2014 ${String(c.excerpt ?? "").slice(0, 260)}`).join("\n");
460
464
  const res = await env.AI.run(model, {
461
465
  messages: [
462
- { role: "system", content: section_summary_default.trimEnd() },
466
+ { role: "system", content: (level === 2 ? doc_summary_default : section_summary_default).trimEnd() },
463
467
  { role: "user", content: `${head}
464
468
 
465
- Sub-clauses:
469
+ ${level === 2 ? "Section summaries:" : "Sub-clauses:"}
466
470
  ${listing}` }
467
471
  ],
468
472
  max_tokens: 1600,
@@ -487,9 +491,9 @@ Covers: ${childAnchors}`;
487
491
  const vectorText = `${m.docidentifier ?? m.doc_id} \xA7${m.clause_anchor} ${text}`.slice(0, 2e3);
488
492
  const vector = await embed(portModelRunner(env), MODELS.embed, vectorText);
489
493
  await env.VECTORIZE.upsert([
490
- { id: u.id, values: vector, metadata: { ...m, chunk_text: text, section_summary: "1", child_anchors: childAnchors, ctx: "1" } }
494
+ { id: u.id, values: vector, metadata: { ...m, chunk_text: text, section_summary: "1", ...level === 2 ? { summary_level: "2" } : {}, child_anchors: childAnchors, ctx: "1" } }
491
495
  ]);
492
- return { id: u.id, ok: true, cached, children: u.children.length };
496
+ return { id: u.id, ok: true, cached, level, children: u.children.length };
493
497
  } catch (e) {
494
498
  return { id: u.id, ok: false, error: String(e?.message ?? e).slice(0, 200) };
495
499
  }
@@ -990,13 +994,13 @@ async function callToolOnce(env, ctx, _req, tier, key, name, args, t0) {
990
994
  // stream:false forces the JSON lane (anon defaults to SSE)
991
995
  body: JSON.stringify({ ...args, stream: false })
992
996
  });
993
- const { runTool, TOOLS_REGISTRY: TOOLS_REGISTRY2 } = await import("../../tools-EXDADNWX.js");
997
+ const { runTool, TOOLS_REGISTRY: TOOLS_REGISTRY2 } = await import("../../tools-WSWEGJVQ.js");
994
998
  let payload;
995
999
  if (TOOLS_REGISTRY2.some((t) => t.name === name && t.audiences.includes("mcp"))) {
996
1000
  const r = await runTool({ ...env, DB: env.DB }, { name, args }, "mcp");
997
1001
  payload = r ? { tool: r.name, query: r.query, result: r.output } : { error: { message: `tool ${name} returned nothing for the given arguments` } };
998
1002
  } else {
999
- const res = name === "ask" ? await (await import("../../ask-M3VRAIJB.js")).handleAsk(env, ctx, inner, tier, key) : await (await import("../../search-5SNP5CHL.js")).handleSearch(env, ctx, inner, tier, key);
1003
+ const res = name === "ask" ? await (await import("../../ask-EAKLN77G.js")).handleAsk(env, ctx, inner, tier, key) : await (await import("../../search-HKGN7F3O.js")).handleSearch(env, ctx, inner, tier, key);
1000
1004
  payload = await res.json().catch(() => ({ error: { message: "tool transport failed", status: res.status } }));
1001
1005
  }
1002
1006
  telemetry(env, ctx, tier, `mcp:${name}`, null, !payload?.error, JSON.stringify(payload).length, await sha256Hex(`${name}:${JSON.stringify(args ?? {})}`), void 0, void 0, { durationMs: Date.now() - t0 });
@@ -4,8 +4,8 @@ import {
4
4
  requestSalt,
5
5
  resolveRequestScope,
6
6
  standardKeysFrom
7
- } from "../../chunk-U4ERFUOK.js";
8
- import "../../chunk-BOAITSVV.js";
7
+ } from "../../chunk-4NXH5ZI6.js";
8
+ import "../../chunk-VT7DR6NQ.js";
9
9
  import "../../chunk-3FYJM7LH.js";
10
10
  export {
11
11
  entitlementScope,
@@ -26,6 +26,16 @@ edits to existing stages, the runner, or `retrieve()`. A stage that
26
26
  needs a new context field declares it on `PipelineContext` (the field's
27
27
  owner is the stage that writes it).
28
28
 
29
+ The registry is also the ablation switch (TODO.sota/09):
30
+ `projectStages(names)` projects it to a named subset — registry order
31
+ preserved, unknown names thrown — and `RetrieveOptions.stageSubset`
32
+ carries the projection. The ask boundary (`src/ablate.ts`) gates the
33
+ `ablate_stages` / `ablate_no_verdict` body fields behind the admin
34
+ credential (`x-admin-token`); an ablated ask bypasses every answer-cache
35
+ read and write and echoes its configuration in the response. A new
36
+ stage updates the pinned vocabulary in `tests/ablate.test.ts`
37
+ consciously — that list is the runner's contract.
38
+
29
39
  ## Registry order and invariants
30
40
 
31
41
  Order is load-bearing. The phase structure:
@@ -41,6 +51,7 @@ candidate lanes → pool open → pool-level merges → refinement → window as
41
51
  | 3 | `glossary` | `env.GLOSSARY && vector` | additive | `glossary` | Vocabulary link: dense glossary candidates + cross-encoder rerank; top-3 distinct concepts. MUST run before the concept-graph and steering stages — they consume `glossary`. |
42
52
  | 4 | `concept-graph` | `glossary.length && env.DB && vector` | additive | appends `matches` | Linked terms → defining doc numbers (D1, parallel per-term) → metadata-filtered dense merge at `conceptGraphDiscount`. |
43
53
  | 5 | `graph-lane` | `opts.graphDocNumbers && vector` | additive | appends `matches` | Caller-resolved family/successor doc numbers merged at `graphLaneDiscount`. |
54
+ | 5b | `graph-ppr` | `(glossary.length \|\| u.docidentifier) && env.DB && vector` | additive | appends `matches` | HippoRAG-style multi-hop memory (TODO.sota/06): Personalized-PageRank over the D1 graph (defines/successor/part_of/variant_of/amends/cites, undirected spread) seeded by the vocabulary link's concepts and the understood publication's family node; documents ranked by stationary mass merge BM25-within-set candidates at `pprDiscount` (the narrowed-lane law). Two bounded hops, deterministic iteration — same seeds, same ranks. |
44
55
  | 6 | `multi-query` | `u.query_variants` | blocking (per-variant catches inside) | REPLACES `matches` | RRF-fuses the primary ranking with each variant's (k=60, top `retrieveK`). |
45
56
  | 7 | `sub-query` | `u.complexity === "complex" && u.sub_queries` | blocking (per-sub catches inside) | appends `matches` | Complementary-perspective union at `subQueryDiscount` (no RRF). |
46
57
  | 8 | `pool-open` | — | blocking | `hits` | Converts the candidate pool to the `Hit` shape. Last writer of `matches`; every later stage operates on `hits`. |
@@ -62,7 +73,7 @@ candidate lanes → pool open → pool-level merges → refinement → window as
62
73
  | 23 | `structural-propagate` | — | blocking | REPLACES `hits` | FABLE TreeExpansion: score blends along the clause tree. |
63
74
  | 24 | `diversity` | — | blocking | `finalHits` (from `hits`) | Per-publication caps (1 overview / 2–3 clauses; global overview cap 2/6); window cut to `rerankKeep`. FIRST writer of `finalHits`. |
64
75
  | 25 | `typed-pin` | pin families resolvable | blocking (inner parent-fetch additive) | `finalHits` | Answer-contract v2: one typed unit guaranteed a slot (+ small-to-big parent fetch at `smallToBigDiscount`). |
65
- | 26 | `section-descent` | a ranked depth-1 summary has children | additive | `finalHits` | Summary node → top child clauses at `sectionDescentDiscount`; the summary retires when children answer. |
76
+ | 26 | `section-descent` | a ranked summary has children | additive | `finalHits` | Summary node → child clauses at `sectionDescentDiscount`; the summary retires when children answer. Two levels (the RAPTOR extension, TODO.sota/06): a level-1 section summary's children are CLAUSE anchors found by the same-query filtered search; a level-2 DOCUMENT summary's children are LEVEL-1 UNIT ids — read by id (the ≤20 getByIds law), their clauses from the D1 corpus directly (the l6a law — the index metadata filters are dead), the two leading sections (reading order) consumed, the synthetic nodes retired. |
66
77
  | 27 | `dedup` | — | blocking | `finalHits` | FABLE ancestor-descendant same-chain collapse (≥0.5 text overlap). |
67
78
  | 28 | `window-floor` | — | blocking | filters `finalHits` | Evidence-budget cut at `windowFloorFraction` of top; typed/family/unscored exempt; never fewer than two. |
68
79
 
package/package.json CHANGED
@@ -27,7 +27,7 @@
27
27
  "playwright": "^1.62.1",
28
28
  "yaml": "^2.9.1"
29
29
  },
30
- "version": "0.2.104",
30
+ "version": "0.2.109",
31
31
  "description": "The Konneal engine: the publisher-agnostic build pipeline and API plane for standards intelligence (retrieval, answer contract, verdicts, evaluation).",
32
32
  "license": "BSD-3-Clause",
33
33
  "type": "module",
@@ -1154,6 +1154,46 @@ components:
1154
1154
  response also carries the grounding passages in context.
1155
1155
  Evaluation callers use it to observe what the answer was
1156
1156
  built from.
1157
+ ablate_stages:
1158
+ type: array
1159
+ items:
1160
+ type: string
1161
+ description: >-
1162
+ Research-only (TODO.sota/09): run the retrieval registry
1163
+ projected to these stage names (registry order preserved;
1164
+ unknown names are a 400). Requires the x-admin-token header —
1165
+ a request naming a configuration without it is a 403.
1166
+ Ablated answers bypass every answer cache read and write and
1167
+ echo the active configuration in the response's ablate field.
1168
+ ablate_no_verdict:
1169
+ type: boolean
1170
+ default: false
1171
+ description: >-
1172
+ Research-only: skip the verdict engine (machine verdicts and
1173
+ condition-set evaluation); the answer model narrates from
1174
+ passages alone. Same admin credential and cache law as
1175
+ ablate_stages.
1176
+ ablate_speculative:
1177
+ type: boolean
1178
+ default: false
1179
+ description: >-
1180
+ Research-only (TODO.sota/02 row 2): arm speculative
1181
+ draft-verify — the cheap model drafts an answer per
1182
+ diversified subset of the window in parallel, one strong
1183
+ call verifies every candidate against the full passages and
1184
+ produces the final answer. Same admin credential and cache
1185
+ law as ablate_stages.
1186
+ ablate_route:
1187
+ type: string
1188
+ enum: [fast, deep, adaptive]
1189
+ default: adaptive
1190
+ description: >-
1191
+ Research-only: force the route (TODO.sota/05). fast drops the
1192
+ candidate-expansion lanes (hyde, multi-query, sub-query) and
1193
+ floors generation effort at low; deep keeps the full
1194
+ registry. adaptive (the default) lets the router decide from
1195
+ understanding's features. Same admin credential and cache
1196
+ law as ablate_stages.
1157
1197
  lang:
1158
1198
  type: string
1159
1199
  description: Forces the answer language, for example en or fr.
@@ -1224,6 +1264,19 @@ components:
1224
1264
  intent: { type: string }
1225
1265
  doc: { type: string, nullable: true }
1226
1266
  edition: { type: string, nullable: true }
1267
+ route:
1268
+ type: object
1269
+ description: >-
1270
+ The adaptive route the ask rode (TODO.sota/05): fast (the
1271
+ registry minus the candidate-expansion lanes, generation
1272
+ effort floored at low) or deep (the full registry), with the
1273
+ features that fired — the telemetry the routing-accuracy
1274
+ analysis reads.
1275
+ properties:
1276
+ route: { type: string, enum: [fast, deep] }
1277
+ features:
1278
+ type: array
1279
+ items: { type: string }
1227
1280
  term: { type: string, nullable: true }
1228
1281
  terms:
1229
1282
  type: array
@@ -0,0 +1 @@
1
+ You write the document-level summary for a legal-metrology publication in {{PUBLISHER_NAME}}'s retrieval index. The section summaries below cover the publication clause by clause. Write one compact abstract, three to five sentences: what the publication regulates or provides, its scope and applicability, and the structure a reader should know (parts, annexes, where the normative core sits). Use the publication's own terms exactly. No preamble, no headings.
@@ -0,0 +1,9 @@
1
+ You answer questions about legal-metrology publications for {{PUBLISHER_NAME}}. Candidate draft answers were prepared in parallel, each from a PARTIAL view of the evidence (a subset of the passages); you see every candidate beside the FULL passages. Produce the final answer.
2
+
3
+ Rules:
4
+ 1. Compare the candidates against the full passages. Adopt the candidate best supported by the evidence as your starting point; correct every claim the full passages contradict or fail to support, and fold in anything the other candidates got right that the adopted one missed.
5
+ 2. Ground every factual claim in the numbered passages and cite them inline as [1], [2], … exactly as the passages are numbered below. Never cite a passage that is not there.
6
+ 3. Quote verbatim — with quotation marks — when a clause's exact wording matters (definitions, thresholds, requirements); a quotation must appear word-for-word in the passages.
7
+ 4. Numeric and table values come from the passages exactly; never retype a table as markdown — refer to the table and cite it.
8
+ 5. If the passages do not answer the question, say so plainly instead of guessing.
9
+ 6. Be complete but compact: answer the question, cite, stop.
@@ -0,0 +1,97 @@
1
+ // ── The ablation request boundary (TODO.sota/09) ──
2
+ //
3
+ // A runner (scripts/ablate.mjs in the deployment repo) scores pipeline
4
+ // configurations — stage lists, verdict-engine off — over the dev half
5
+ // of the benchmark. The configuration rides the /v1/ask body:
6
+ //
7
+ // ablate_stages: ["dense", "poolOpen", ...] // registry projection
8
+ // ablate_no_verdict: true // verdict engine off
9
+ //
10
+ // gated by the ADMIN credential in the x-admin-token header (the same
11
+ // secret the /admin routes carry; the ask bearer stays a normal tier
12
+ // key, so quota and telemetry behave exactly as for a real ask).
13
+ //
14
+ // The cache law: an ablated answer is request-scoped context — it must
15
+ // never be served to a later caller nor written for one. ask.ts honors
16
+ // that by bypassing every answer-cache read and write when a
17
+ // configuration is active.
18
+ //
19
+ // Self-contained (no imports) so the unit tests run on plain node type
20
+ // stripping, like anchors/refusal/verdict.
21
+
22
+ export interface AblateConfig {
23
+ /** Registry projection (names; projectStages applies them). Null/absent
24
+ * = the serving default (adaptive routing decides). */
25
+ stages: string[] | null;
26
+ /** Skip the verdict engine (machineVerdict + condition sets): the
27
+ * answer model narrates from passages alone. */
28
+ noVerdict: boolean;
29
+ /** Force a route: "fast" drops the expansion lanes, "deep" keeps the
30
+ * full registry, "adaptive" (the default) lets the router decide
31
+ * (TODO.sota/05). The grid measures the routes by forcing them. */
32
+ route: "fast" | "deep" | "adaptive";
33
+ /** Speculative draft-verify (TODO.sota/02 row 2): cheap drafts per
34
+ * diversified subset + one strong verifier. Off in serving until the
35
+ * gate promotes it; the grid arms it per ask. */
36
+ speculative: boolean;
37
+ }
38
+
39
+ export type AblateParse =
40
+ | { ok: true; config: AblateConfig | null }
41
+ | { ok: false; status: number; code: string; message: string };
42
+
43
+ /** Parse and authorize an ablation configuration. Non-null config
44
+ * requires the admin token; a request that NAMES a configuration
45
+ * without the credential is 403 — never silently ignored, or a
46
+ * misconfigured runner would measure the full pipeline believing it
47
+ * measured its ablation. */
48
+ export function parseAblate(
49
+ body: any,
50
+ adminToken: string | undefined,
51
+ presentedToken: string | null,
52
+ knownStages: readonly string[],
53
+ ): AblateParse {
54
+ const rawStages = body?.ablate_stages;
55
+ const noVerdict = body?.ablate_no_verdict === true;
56
+ const rawRoute = body?.ablate_route;
57
+ const speculative = body?.ablate_speculative === true;
58
+ const namesAblation = rawStages !== undefined && rawStages !== null;
59
+ const routeAblation = rawRoute !== undefined && rawRoute !== null && rawRoute !== "adaptive";
60
+
61
+ if (!namesAblation && !noVerdict && !routeAblation && !speculative) return { ok: true, config: null };
62
+
63
+ if (!adminToken || presentedToken !== adminToken) {
64
+ return {
65
+ ok: false,
66
+ status: 403,
67
+ code: "ablation_forbidden",
68
+ message: "ablation fields require the admin credential (x-admin-token)",
69
+ };
70
+ }
71
+
72
+ let route: AblateConfig["route"] = "adaptive";
73
+ if (routeAblation) {
74
+ if (rawRoute !== "fast" && rawRoute !== "deep") {
75
+ return { ok: false, status: 400, code: "invalid_ablation", message: "ablate_route must be \"fast\", \"deep\" or \"adaptive\"" };
76
+ }
77
+ route = rawRoute;
78
+ }
79
+
80
+ let stages: string[] | null = null;
81
+ if (namesAblation) {
82
+ if (!Array.isArray(rawStages) || rawStages.length === 0) {
83
+ return { ok: false, status: 400, code: "invalid_ablation", message: "ablate_stages must be a non-empty array of stage names" };
84
+ }
85
+ if (rawStages.some((n: unknown) => typeof n !== "string")) {
86
+ return { ok: false, status: 400, code: "invalid_ablation", message: "ablate_stages must contain only stage-name strings" };
87
+ }
88
+ const known = new Set(knownStages);
89
+ const unknown = (rawStages as string[]).filter((n) => !known.has(n));
90
+ if (unknown.length) {
91
+ return { ok: false, status: 400, code: "invalid_ablation", message: `unknown stage(s): ${unknown.join(", ")}` };
92
+ }
93
+ stages = [...new Set(rawStages as string[])];
94
+ }
95
+
96
+ return { ok: true, config: { stages, noVerdict, route, speculative } };
97
+ }
@@ -6,6 +6,7 @@ import { embed } from "./ai";
6
6
  import { err, json, corsHeaders, readJson, authenticate } from "./lib/http";
7
7
  import type { Env } from "./env";
8
8
  import enrichmentPrompt from "../prompts/enrichment.md";
9
+ import docSummaryPrompt from "../prompts/doc-summary.md";
9
10
  import sectionSummaryPrompt from "../prompts/section-summary.md";
10
11
  import relevancyPrompt from "../prompts/relevancy.md";
11
12
  import precisionPrompt from "../prompts/precision.md";
@@ -138,19 +139,26 @@ export async function handleSectionUnit(env: Env, ctx: ExecutionContext, req: Re
138
139
  return { id: u?.id ?? null, ok: false, error: "invalid unit (id, metadata.doc_id, metadata.clause_anchor, children required)" };
139
140
  }
140
141
  try {
142
+ // RAPTOR depth (TODO.sota/06, the catalog's row 3): level 2
143
+ // summarizes the LEVEL-1 summary units of one document into a
144
+ // document-level navigation node — the children are unit ids,
145
+ // and the stamp says so the descent can walk another level.
146
+ const level = u?.level === 2 ? 2 : 1;
141
147
  const cacheKey = `s:${u.id}`;
142
148
  let summary = body?.force === true ? null : await env.CACHE.get(cacheKey);
143
149
  const cached = !!summary;
144
150
  if (!summary) {
145
- const head = `${m.docidentifier ?? m.doc_id} §${m.clause_anchor}${m.clause_title ? " — " + m.clause_title : ""}`;
151
+ const head = level === 2
152
+ ? `${m.docidentifier ?? m.doc_id} — document summary`
153
+ : `${m.docidentifier ?? m.doc_id} §${m.clause_anchor}${m.clause_title ? " — " + m.clause_title : ""}`;
146
154
  const listing = u.children
147
155
  .slice(0, 12)
148
- .map((c: any) => `§${c.anchor ?? ""}${c.title ? " " + c.title : ""} — ${String(c.excerpt ?? "").slice(0, 260)}`)
156
+ .map((c: any) => (level === 2 ? `- ${c.title ?? c.anchor ?? ""} — ${String(c.excerpt ?? "").slice(0, 300)}` : `§${c.anchor ?? ""}${c.title ? " " + c.title : ""} — ${String(c.excerpt ?? "").slice(0, 260)}`))
149
157
  .join("\n");
150
158
  const res: any = await env.AI.run(model, {
151
159
  messages: [
152
- { role: "system", content: sectionSummaryPrompt.trimEnd() },
153
- { role: "user", content: `${head}\n\nSub-clauses:\n${listing}` },
160
+ { role: "system", content: (level === 2 ? docSummaryPrompt : sectionSummaryPrompt).trimEnd() },
161
+ { role: "user", content: `${head}\n\n${level === 2 ? "Section summaries:" : "Sub-clauses:"}\n${listing}` },
154
162
  ],
155
163
  max_tokens: 1600, // parity with the chunk-enrichment call — 900 starved ~40% of section summaries (model-card budget rule)
156
164
  reasoning_effort: "low",
@@ -172,9 +180,9 @@ export async function handleSectionUnit(env: Env, ctx: ExecutionContext, req: Re
172
180
  const vectorText = `${m.docidentifier ?? m.doc_id} §${m.clause_anchor} ${text}`.slice(0, 2000);
173
181
  const vector = await embed(portModelRunner(env), MODELS.embed, vectorText);
174
182
  await env.VECTORIZE.upsert([
175
- { id: u.id, values: vector, metadata: { ...m, chunk_text: text, section_summary: "1", child_anchors: childAnchors, ctx: "1" } },
183
+ { id: u.id, values: vector, metadata: { ...m, chunk_text: text, section_summary: "1", ...(level === 2 ? { summary_level: "2" } : {}), child_anchors: childAnchors, ctx: "1" } },
176
184
  ]);
177
- return { id: u.id, ok: true, cached, children: u.children.length };
185
+ return { id: u.id, ok: true, cached, level, children: u.children.length };
178
186
  } catch (e: any) {
179
187
  return { id: u.id, ok: false, error: String(e?.message ?? e).slice(0, 200) };
180
188
  }