@konneal/engine 0.1.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 (285) hide show
  1. package/LICENSE +29 -0
  2. package/README.md +13 -0
  3. package/dist/admin.d.ts +26 -0
  4. package/dist/ai.d.ts +6 -0
  5. package/dist/anchors.d.ts +6 -0
  6. package/dist/answercache.d.ts +22 -0
  7. package/dist/ask.d.ts +5 -0
  8. package/dist/auth.d.ts +12 -0
  9. package/dist/bubble.d.ts +14 -0
  10. package/dist/chunk-LLWPT2XV.js +49 -0
  11. package/dist/chunk-MB74PTRM.js +114 -0
  12. package/dist/chunk-WOGQM7DJ.js +197 -0
  13. package/dist/chunk-WWNCWKKC.js +42 -0
  14. package/dist/completion.d.ts +5 -0
  15. package/dist/config.d.ts +154 -0
  16. package/dist/config.js +37 -0
  17. package/dist/context.d.ts +115 -0
  18. package/dist/conversations.d.ts +5 -0
  19. package/dist/drafts.d.ts +129 -0
  20. package/dist/env.d.ts +57 -0
  21. package/dist/faithfulness.d.ts +5 -0
  22. package/dist/grader.d.ts +3 -0
  23. package/dist/graph.d.ts +13 -0
  24. package/dist/hybrid.d.ts +7 -0
  25. package/dist/index.d.ts +9 -0
  26. package/dist/index.js +5373 -0
  27. package/dist/internal_gateway.d.ts +14 -0
  28. package/dist/lexical.d.ts +7 -0
  29. package/dist/livedata.d.ts +77 -0
  30. package/dist/memories.d.ts +10 -0
  31. package/dist/modelplane.d.ts +61 -0
  32. package/dist/oidc.d.ts +73 -0
  33. package/dist/pipeline.d.ts +57 -0
  34. package/dist/profile.d.ts +2 -0
  35. package/dist/profile.gen.d.ts +70 -0
  36. package/dist/profile.js +8 -0
  37. package/dist/projects.d.ts +8 -0
  38. package/dist/prompts/conversational.md +8 -0
  39. package/dist/prompts/enrichment.md +3 -0
  40. package/dist/prompts/faithfulness.md +1 -0
  41. package/dist/prompts/grader.md +5 -0
  42. package/dist/prompts/listwise.md +3 -0
  43. package/dist/prompts/precision.md +1 -0
  44. package/dist/prompts/reflect.md +1 -0
  45. package/dist/prompts/relevancy.md +1 -0
  46. package/dist/prompts/research.md +10 -0
  47. package/dist/prompts/section-summary.md +5 -0
  48. package/dist/prompts/summarize.md +1 -0
  49. package/dist/prompts/system.md +18 -0
  50. package/dist/prompts/understanding.md +17 -0
  51. package/dist/quota.d.ts +13 -0
  52. package/dist/reflect.d.ts +5 -0
  53. package/dist/refs.d.ts +40 -0
  54. package/dist/refusal.d.ts +9 -0
  55. package/dist/refusal.js +9 -0
  56. package/dist/requestScope.d.ts +26 -0
  57. package/dist/requestScope.js +10 -0
  58. package/dist/research.d.ts +8 -0
  59. package/dist/search.d.ts +4 -0
  60. package/dist/selfquery.d.ts +7 -0
  61. package/dist/session.d.ts +1 -0
  62. package/dist/share.d.ts +2 -0
  63. package/dist/structural.d.ts +27 -0
  64. package/dist/tablecontext.d.ts +11 -0
  65. package/dist/understand.d.ts +11 -0
  66. package/dist/understandContract.d.ts +29 -0
  67. package/dist/verdict.d.ts +24 -0
  68. package/docs/API.md +451 -0
  69. package/docs/ARCHITECTURE.md +302 -0
  70. package/docs/AUDIT-2026-08-24.md +71 -0
  71. package/docs/CONTRIBUTOR-AUDIT-2026-08-25.md +147 -0
  72. package/docs/INGEST-ARCHITECTURE.md +158 -0
  73. package/docs/MCP.md +92 -0
  74. package/docs/METANORMA-AI-SERIALIZATION.md +247 -0
  75. package/docs/MKO-EXPORT-PIPELINE.md +147 -0
  76. package/docs/REDESIGN-NORMATIVE-RAG-ETSI.md +485 -0
  77. package/docs/RESEARCH-SOTA-2026.md +243 -0
  78. package/docs/ROADMAP-SOTA.md +130 -0
  79. package/docs/SOTA-STAGE-SPECS.md +509 -0
  80. package/docs/annealment/F1-verdict.md +27 -0
  81. package/docs/annealment/F10-notes.md +19 -0
  82. package/docs/annealment/F11-composition.md +17 -0
  83. package/docs/annealment/F12-passport.md +17 -0
  84. package/docs/annealment/F2-counterfactual.md +20 -0
  85. package/docs/annealment/F3-absence.md +21 -0
  86. package/docs/annealment/F4-instance.md +18 -0
  87. package/docs/annealment/F5-workflow.md +21 -0
  88. package/docs/annealment/F6-impact.md +21 -0
  89. package/docs/annealment/F7-editions.md +18 -0
  90. package/docs/annealment/F8-selfverify.md +19 -0
  91. package/docs/annealment/F9-projection-qa.md +17 -0
  92. package/docs/annealment/L0-locate.md +19 -0
  93. package/docs/annealment/L1-extract.md +18 -0
  94. package/docs/annealment/L2-nomenclature.md +22 -0
  95. package/docs/annealment/L3-geometry.md +23 -0
  96. package/docs/annealment/L4-composition.md +21 -0
  97. package/docs/annealment/L5-cross-standard.md +20 -0
  98. package/docs/annealment/L6-diachrony.md +21 -0
  99. package/docs/annealment/L7-perception.md +20 -0
  100. package/docs/annealment/L8-computation.md +22 -0
  101. package/docs/annealment/L9-instance-process.md +23 -0
  102. package/docs/annealment/README.md +10 -0
  103. package/docs/guidelines-metanorma-ai-programme.md +279 -0
  104. package/docs/identity-onboarding-rag.md +65 -0
  105. package/docs/identity-service.md +219 -0
  106. package/docs/knowledge-annealment.md +273 -0
  107. package/docs/konneal-extraction-plan.md +481 -0
  108. package/docs/metanorma-for-ai.md +270 -0
  109. package/docs/mirror-plan.md +36 -0
  110. package/docs/multi-sdo-architecture.md +191 -0
  111. package/docs/paper-annealment-comparison.md +259 -0
  112. package/docs/paper-assets/architecture.svg +94 -0
  113. package/docs/paper-assets/contract-v2.svg +94 -0
  114. package/docs/paper-assets/mko-ingest.svg +91 -0
  115. package/docs/paper-oiml-bulletin.md +402 -0
  116. package/docs/paper-oiml-bulletin.mdx +419 -0
  117. package/docs/product-branding-options.md +172 -0
  118. package/docs/projects-design.md +88 -0
  119. package/docs/sota-mechanisms.md +184 -0
  120. package/docs/spec-api.md +77 -0
  121. package/docs/spec-pipeline.md +126 -0
  122. package/docs/vector-adapter.md +88 -0
  123. package/package.json +70 -0
  124. package/profile/corpora.yaml +5 -0
  125. package/profile/datasets.yaml +14 -0
  126. package/profile/prompts.yaml +5 -0
  127. package/profile/publisher.yaml +17 -0
  128. package/profile/retrieval.yaml +1 -0
  129. package/profile/sources.yaml +5 -0
  130. package/profile/ui.yaml +7 -0
  131. package/scripts/gen_profile.mjs +33 -0
  132. package/workers/shared/ai.ts +21 -0
  133. package/workers/shared/auth.ts +16 -0
  134. package/workers/shared/chunk.ts +108 -0
  135. package/workers/shared/oidc.ts +312 -0
  136. package/workers/shared/router.ts +45 -0
  137. package/workers/shared/session.ts +104 -0
  138. package/workers/worker_internal/src/index.ts +157 -0
  139. package/workers/worker_internal/tsconfig.json +15 -0
  140. package/workers/worker_internal/wrangler.toml +32 -0
  141. package/workers/worker_mcp/src/index.ts +175 -0
  142. package/workers/worker_mcp/tsconfig.json +13 -0
  143. package/workers/worker_mcp/wrangler.toml +18 -0
  144. package/workers/worker_public/migrations/0002_conversations.sql +22 -0
  145. package/workers/worker_public/migrations/0003_shared_conversations.sql +9 -0
  146. package/workers/worker_public/migrations/0004_graph.sql +16 -0
  147. package/workers/worker_public/migrations/0005_documents.sql +19 -0
  148. package/workers/worker_public/migrations/0006_conversation_entities.sql +11 -0
  149. package/workers/worker_public/migrations/0007_chunks_fts.sql +43 -0
  150. package/workers/worker_public/migrations/0008_unit_payloads.sql +15 -0
  151. package/workers/worker_public/migrations/0009_chunks_unit.sql +8 -0
  152. package/workers/worker_public/migrations/0009_message_context.sql +7 -0
  153. package/workers/worker_public/migrations/0010_model_nodes.sql +33 -0
  154. package/workers/worker_public/migrations/0011_schema_union.sql +38 -0
  155. package/workers/worker_public/migrations/0012_memories.sql +15 -0
  156. package/workers/worker_public/migrations/0013_projects.sql +21 -0
  157. package/workers/worker_public/node_modules/@cloudflare/workers-types/2021-11-03/index.d.ts +16306 -0
  158. package/workers/worker_public/node_modules/@cloudflare/workers-types/2021-11-03/index.ts +16261 -0
  159. package/workers/worker_public/node_modules/@cloudflare/workers-types/2022-01-31/index.d.ts +16373 -0
  160. package/workers/worker_public/node_modules/@cloudflare/workers-types/2022-01-31/index.ts +16328 -0
  161. package/workers/worker_public/node_modules/@cloudflare/workers-types/2022-03-21/index.d.ts +16382 -0
  162. package/workers/worker_public/node_modules/@cloudflare/workers-types/2022-03-21/index.ts +16337 -0
  163. package/workers/worker_public/node_modules/@cloudflare/workers-types/2022-08-04/index.d.ts +16383 -0
  164. package/workers/worker_public/node_modules/@cloudflare/workers-types/2022-08-04/index.ts +16338 -0
  165. package/workers/worker_public/node_modules/@cloudflare/workers-types/2022-10-31/index.d.ts +16403 -0
  166. package/workers/worker_public/node_modules/@cloudflare/workers-types/2022-10-31/index.ts +16358 -0
  167. package/workers/worker_public/node_modules/@cloudflare/workers-types/2022-11-30/index.d.ts +16408 -0
  168. package/workers/worker_public/node_modules/@cloudflare/workers-types/2022-11-30/index.ts +16363 -0
  169. package/workers/worker_public/node_modules/@cloudflare/workers-types/2023-03-01/index.d.ts +16414 -0
  170. package/workers/worker_public/node_modules/@cloudflare/workers-types/2023-03-01/index.ts +16369 -0
  171. package/workers/worker_public/node_modules/@cloudflare/workers-types/2023-07-01/index.d.ts +16414 -0
  172. package/workers/worker_public/node_modules/@cloudflare/workers-types/2023-07-01/index.ts +16369 -0
  173. package/workers/worker_public/node_modules/@cloudflare/workers-types/README.md +135 -0
  174. package/workers/worker_public/node_modules/@cloudflare/workers-types/entrypoints.svg +53 -0
  175. package/workers/worker_public/node_modules/@cloudflare/workers-types/experimental/index.d.ts +17095 -0
  176. package/workers/worker_public/node_modules/@cloudflare/workers-types/experimental/index.ts +17050 -0
  177. package/workers/worker_public/node_modules/@cloudflare/workers-types/index.d.ts +16306 -0
  178. package/workers/worker_public/node_modules/@cloudflare/workers-types/index.ts +16261 -0
  179. package/workers/worker_public/node_modules/@cloudflare/workers-types/latest/index.d.ts +16447 -0
  180. package/workers/worker_public/node_modules/@cloudflare/workers-types/latest/index.ts +16402 -0
  181. package/workers/worker_public/node_modules/@cloudflare/workers-types/oldest/index.d.ts +16306 -0
  182. package/workers/worker_public/node_modules/@cloudflare/workers-types/oldest/index.ts +16261 -0
  183. package/workers/worker_public/node_modules/@cloudflare/workers-types/package.json +11 -0
  184. package/workers/worker_public/package.json +13 -0
  185. package/workers/worker_public/prompts/conversational.md +8 -0
  186. package/workers/worker_public/prompts/enrichment.md +3 -0
  187. package/workers/worker_public/prompts/faithfulness.md +1 -0
  188. package/workers/worker_public/prompts/grader.md +5 -0
  189. package/workers/worker_public/prompts/listwise.md +3 -0
  190. package/workers/worker_public/prompts/precision.md +1 -0
  191. package/workers/worker_public/prompts/reflect.md +1 -0
  192. package/workers/worker_public/prompts/relevancy.md +1 -0
  193. package/workers/worker_public/prompts/research.md +10 -0
  194. package/workers/worker_public/prompts/section-summary.md +5 -0
  195. package/workers/worker_public/prompts/summarize.md +1 -0
  196. package/workers/worker_public/prompts/system.md +18 -0
  197. package/workers/worker_public/prompts/understanding.md +17 -0
  198. package/workers/worker_public/public/app.js +166 -0
  199. package/workers/worker_public/public/index.html +48 -0
  200. package/workers/worker_public/public/style.css +147 -0
  201. package/workers/worker_public/schema.sql +248 -0
  202. package/workers/worker_public/src/admin.ts +358 -0
  203. package/workers/worker_public/src/ai.ts +71 -0
  204. package/workers/worker_public/src/anchors.ts +41 -0
  205. package/workers/worker_public/src/answercache.ts +72 -0
  206. package/workers/worker_public/src/ask.ts +1094 -0
  207. package/workers/worker_public/src/auth.ts +252 -0
  208. package/workers/worker_public/src/bubble.ts +111 -0
  209. package/workers/worker_public/src/completion.ts +75 -0
  210. package/workers/worker_public/src/config.ts +238 -0
  211. package/workers/worker_public/src/context.ts +238 -0
  212. package/workers/worker_public/src/conversations.ts +162 -0
  213. package/workers/worker_public/src/drafts.ts +497 -0
  214. package/workers/worker_public/src/env.ts +90 -0
  215. package/workers/worker_public/src/faithfulness.ts +63 -0
  216. package/workers/worker_public/src/grader.ts +89 -0
  217. package/workers/worker_public/src/graph.ts +63 -0
  218. package/workers/worker_public/src/hybrid.ts +77 -0
  219. package/workers/worker_public/src/index.ts +441 -0
  220. package/workers/worker_public/src/internal_gateway.ts +41 -0
  221. package/workers/worker_public/src/lexical.ts +86 -0
  222. package/workers/worker_public/src/lib/hit.ts +4 -0
  223. package/workers/worker_public/src/lib/http.ts +83 -0
  224. package/workers/worker_public/src/lib/router.ts +4 -0
  225. package/workers/worker_public/src/livedata.ts +334 -0
  226. package/workers/worker_public/src/memories.ts +81 -0
  227. package/workers/worker_public/src/modelplane.ts +213 -0
  228. package/workers/worker_public/src/oidc.ts +333 -0
  229. package/workers/worker_public/src/pipeline.ts +377 -0
  230. package/workers/worker_public/src/ports/blobs.ts +7 -0
  231. package/workers/worker_public/src/ports/cloudflare/adapters.ts +177 -0
  232. package/workers/worker_public/src/ports/kv.ts +8 -0
  233. package/workers/worker_public/src/ports/model.ts +28 -0
  234. package/workers/worker_public/src/ports/runtime.ts +13 -0
  235. package/workers/worker_public/src/ports/store.ts +20 -0
  236. package/workers/worker_public/src/ports/vector.ts +26 -0
  237. package/workers/worker_public/src/profile.gen.ts +101 -0
  238. package/workers/worker_public/src/profile.ts +16 -0
  239. package/workers/worker_public/src/projects.ts +108 -0
  240. package/workers/worker_public/src/prompts.d.ts +6 -0
  241. package/workers/worker_public/src/quota.ts +54 -0
  242. package/workers/worker_public/src/reflect.ts +67 -0
  243. package/workers/worker_public/src/refs.ts +107 -0
  244. package/workers/worker_public/src/refusal.ts +65 -0
  245. package/workers/worker_public/src/requestScope.ts +71 -0
  246. package/workers/worker_public/src/research.ts +126 -0
  247. package/workers/worker_public/src/search.ts +56 -0
  248. package/workers/worker_public/src/selfquery.ts +25 -0
  249. package/workers/worker_public/src/session.ts +4 -0
  250. package/workers/worker_public/src/share.ts +53 -0
  251. package/workers/worker_public/src/stages/conceptGraph.ts +68 -0
  252. package/workers/worker_public/src/stages/conceptSteer.ts +39 -0
  253. package/workers/worker_public/src/stages/corpusScope.ts +25 -0
  254. package/workers/worker_public/src/stages/dedup.ts +10 -0
  255. package/workers/worker_public/src/stages/dense.ts +73 -0
  256. package/workers/worker_public/src/stages/diversity.ts +33 -0
  257. package/workers/worker_public/src/stages/editionCover.ts +63 -0
  258. package/workers/worker_public/src/stages/editionSteer.ts +88 -0
  259. package/workers/worker_public/src/stages/familyBoost.ts +22 -0
  260. package/workers/worker_public/src/stages/federate.ts +22 -0
  261. package/workers/worker_public/src/stages/glossary.ts +65 -0
  262. package/workers/worker_public/src/stages/graphLane.ts +31 -0
  263. package/workers/worker_public/src/stages/hyde.ts +29 -0
  264. package/workers/worker_public/src/stages/index.ts +69 -0
  265. package/workers/worker_public/src/stages/lexicalUnion.ts +21 -0
  266. package/workers/worker_public/src/stages/multiQuery.ts +57 -0
  267. package/workers/worker_public/src/stages/overviewDemote.ts +14 -0
  268. package/workers/worker_public/src/stages/poolOpen.ts +10 -0
  269. package/workers/worker_public/src/stages/propagate.ts +15 -0
  270. package/workers/worker_public/src/stages/rerank.ts +47 -0
  271. package/workers/worker_public/src/stages/seal.ts +16 -0
  272. package/workers/worker_public/src/stages/sectionDescent.ts +61 -0
  273. package/workers/worker_public/src/stages/stdRefNudge.ts +35 -0
  274. package/workers/worker_public/src/stages/subQuery.ts +42 -0
  275. package/workers/worker_public/src/stages/termNudge.ts +24 -0
  276. package/workers/worker_public/src/stages/typedPin.ts +131 -0
  277. package/workers/worker_public/src/stages/types.ts +112 -0
  278. package/workers/worker_public/src/stages/windowFloor.ts +23 -0
  279. package/workers/worker_public/src/structural.ts +171 -0
  280. package/workers/worker_public/src/tablecontext.ts +41 -0
  281. package/workers/worker_public/src/understand.ts +72 -0
  282. package/workers/worker_public/src/understandContract.ts +67 -0
  283. package/workers/worker_public/src/verdict.ts +255 -0
  284. package/workers/worker_public/tsconfig.json +18 -0
  285. package/workers/worker_public/wrangler.toml +104 -0
@@ -0,0 +1,24 @@
1
+ // Exact-term lookup: the understanding names the term; clause chunks
2
+ // whose head IS the term get a decisive nudge (publication headers
3
+ // contain the title words, so the reranker alone is unreliable here).
4
+ import { THRESHOLDS } from "../config.ts";
5
+ import type { Stage } from "./types.ts";
6
+
7
+ export const termNudge: Stage = {
8
+ name: "term-nudge",
9
+ when: (c) => !!c.u?.term,
10
+ run: (c) => {
11
+ const scored = c.hits.map((h) => h.rerank_score ?? h.score);
12
+ const spread = Math.max(...scored) - Math.min(...scored);
13
+ if (spread > 0) {
14
+ const esc = c.u!.term!.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
15
+ const termRe = new RegExp(`(^|[^a-z])${esc}([^a-z]|$)`, "i");
16
+ for (const h of c.hits) {
17
+ const body = h.text.split("\n").slice(1).join(" ").slice(0, 200);
18
+ const hay = `${h.metadata.clause_title || ""} ${body}`.toLowerCase();
19
+ if (termRe.test(hay)) h.rerank_score = (h.rerank_score ?? h.score) + spread * THRESHOLDS.termNudgeSpread;
20
+ }
21
+ c.hits.sort((a, b) => (b.rerank_score ?? -Infinity) - (a.rerank_score ?? -Infinity));
22
+ }
23
+ },
24
+ };
@@ -0,0 +1,131 @@
1
+ // Answer contract v2 — typed-chunk pin (FINAL position): doc-scoped
2
+ // queries get ONE typed unit chunk (table first) guaranteed a slot.
3
+ // Prose outranks serialized tables under the cross-encoder AND the
4
+ // per-doc diversity cap counts typed chunks against the same doc key —
5
+ // without this guarantee the model never sees a unit id to reference.
6
+ // Includes the small-to-big parent fetch: an embedded object answers
7
+ // WITH its clause — if the parent clause's prose passage is not already
8
+ // among the finals, one metadata-filtered fetch adds it. The typed unit
9
+ // cites; the clause grounds.
10
+ import { LIMITS, THRESHOLDS } from "../config.ts";
11
+ import type { Hit } from "../../../shared/chunk";
12
+ import type { Stage } from "./types.ts";
13
+
14
+ /** Typed-chunk selection for the pin: among a doc's typed units pick the
15
+ * one whose text best overlaps the QUERY (the first candidate is wrong as
16
+ * often as right — annex example tables outrank nothing). Lexical-overlap
17
+ * heuristic over title + serialized rows; tables, figures and formulas
18
+ * compete on the same score so a figure question can pin the figure
19
+ * (which then feeds multimodal generation), while table-value questions
20
+ * still pin their table on overlap. When the query NAMES an artifact type
21
+ * ("figure", "table", "formula"/"equation"), units of that type get a
22
+ * dominating bonus — without it, clause units of the § tie with the
23
+ * figure under rerank variance and the multimodal answer flips on luck. */
24
+ function pickTypedChunk(query: string, candidates: Hit[], ranked: Hit[]): Hit | null {
25
+ if (!candidates.length) return null;
26
+ const pool = candidates;
27
+ const q = query.toLowerCase();
28
+ const terms = q.replace(/[^\p{L}\p{N}\s]/gu, " ").split(/\s+/).filter((t) => t.length > 2);
29
+ const typeBonus: Record<string, number> = {};
30
+ if (/\bfig(ure)?s?\b/.test(q)) typeBonus.figure = 1;
31
+ if (/\btables?\b/.test(q)) typeBonus.table = 1;
32
+ if (/\b(formulas?|equations?)\b/.test(q)) typeBonus.formula = 1;
33
+ // the top-ranked PROSE passage usually sits in the answer clause: a
34
+ // typed chunk from that same clause is the answering object, not a
35
+ // same-topic example from an annex
36
+ const topProse = ranked.find((h) => !h.metadata.unit_id);
37
+ const topAnchor = topProse?.metadata.clause_anchor ?? "";
38
+ let best: Hit | null = null;
39
+ let bestScore = -1;
40
+ for (const h of pool) {
41
+ const hay = `${h.metadata.clause_title ?? ""} ${h.text}`.toLowerCase();
42
+ let score = 0;
43
+ for (const t of terms) if (hay.includes(t)) score++;
44
+ if (typeBonus[h.metadata.block ?? ""]) score += terms.length * 2; // dominates
45
+ else if (topAnchor && h.metadata.clause_anchor === topAnchor) score += terms.length;
46
+ // blank annex FORMS (empty value cells) are not answer tables
47
+ const cells = h.text.split("|").map((x) => x.trim());
48
+ const filled = cells.filter((x) => x.length > 0).length;
49
+ const density = cells.length ? filled / cells.length : 0;
50
+ score += density * 2;
51
+ if (score > bestScore) {
52
+ bestScore = score;
53
+ best = h;
54
+ }
55
+ }
56
+ return best ?? pool[0];
57
+ }
58
+
59
+ export const typedPin: Stage = {
60
+ name: "typed-pin",
61
+ when: (c) => {
62
+ // family scope: the hard doc filter, else the understanding's
63
+ // family, else the UNION of the vocabulary link's candidate
64
+ // families — a value question can straddle families that define
65
+ // near-identical tables (n_LC class B = 5 000 exists in R 60-1 AND
66
+ // R 76-2); pickTypedChunk's overlap + top-prose-anchor scoring then
67
+ // picks the right table among them instead of the family choice
68
+ // deciding in advance
69
+ const glossaryFamilies = new Set<string>();
70
+ for (const g of c.glossary) if (g.doc_number) glossaryFamilies.add(g.doc_number.split("-")[0]);
71
+ const pinFamily = c.filters?.doc_number ?? c.u?.doc_number ?? null;
72
+ return new Set<string>(pinFamily ? [pinFamily.split("-")[0]] : [...glossaryFamilies]).size > 0;
73
+ },
74
+ run: async (c) => {
75
+ const { query, filters, u, glossary, hits, env, vector } = c;
76
+ const glossaryFamilies = new Set<string>();
77
+ for (const g of glossary) if (g.doc_number) glossaryFamilies.add(g.doc_number.split("-")[0]);
78
+ const pinFamily = filters?.doc_number ?? u?.doc_number ?? null;
79
+ const pinFamilies = new Set<string>(pinFamily ? [pinFamily.split("-")[0]] : [...glossaryFamilies]);
80
+ const base = (dn?: string) => String(dn ?? "").split("-")[0];
81
+ const sameDocTyped = (h: Hit) =>
82
+ !!h.metadata.unit_id && !!h.metadata.block && pinFamilies.has(base(h.metadata.doc_number));
83
+ {
84
+ // the pin guarantees the BEST query-overlap typed unit a slot — not
85
+ // merely "some" typed unit. Hard doc scope pins unconditionally
86
+ // (existing behavior); the glossary-family union (no hard scope)
87
+ // pins only on real query overlap — the picker always returns
88
+ // SOMETHING, and a near-zero-overlap table riding the window on
89
+ // every vocabulary-linked query would be pollution.
90
+ const typed = pickTypedChunk(query, hits.filter(sameDocTyped), hits);
91
+ const hardScope = !!pinFamily;
92
+ const overlap = (() => {
93
+ if (!typed || hardScope) return Infinity;
94
+ const terms = query.toLowerCase().replace(/[^\p{L}\p{N}\s]/gu, " ").split(/\s+/).filter((t) => t.length > 2);
95
+ const hay = `${typed.metadata.clause_title ?? ""} ${typed.text}`.toLowerCase();
96
+ return terms.filter((t) => hay.includes(t)).length;
97
+ })();
98
+ // tables are exempt from the overlap gate: a table in the window
99
+ // renders as a block and summarizes harmlessly — the pollution
100
+ // concern applies to other typed units, not to the one artifact
101
+ // the answer contract most needs to reach the user
102
+ const tableExempt = typed?.metadata.block === "table";
103
+ if (typed && (overlap >= 3 || tableExempt) && !c.finalHits.some((h) => h.id === typed.id)) {
104
+ c.finalHits = [...c.finalHits.slice(0, LIMITS.rerankKeep - 1), typed];
105
+ console.log("typed pin:", typed.metadata.docidentifier, "§", typed.metadata.clause_anchor, `(${typed.metadata.block})${hardScope ? "" : " [glossary families]"}`);
106
+
107
+ const anchor = typed.metadata.clause_anchor;
108
+ const docId = typed.metadata.doc_id;
109
+ const parentPresent = c.finalHits.some(
110
+ (h) => h.metadata.doc_id === docId && h.metadata.clause_anchor === anchor && !h.metadata.unit_id,
111
+ );
112
+ if (anchor && docId && !parentPresent) {
113
+ try {
114
+ const pv = await env.VECTORIZE.query(vector, {
115
+ topK: 4,
116
+ returnMetadata: "all",
117
+ filter: { $and: [{ doc_id: { $eq: docId } }, { clause_anchor: { $eq: anchor } }] },
118
+ });
119
+ const parent = (pv.matches ?? []).map((m: any) => ({ id: m.id, score: m.score, metadata: m.metadata, text: m.metadata?.chunk_text ?? "" })).find((h: any) => !h.metadata?.unit_id);
120
+ if (parent && !c.finalHits.some((h) => h.id === parent.id)) {
121
+ c.finalHits = [...c.finalHits, { ...parent, score: parent.score * THRESHOLDS.smallToBigDiscount }];
122
+ console.log("small-to-big: parent §", anchor, "of", typed.metadata.docidentifier, "added");
123
+ }
124
+ } catch {
125
+ // additive lane; primary results stand
126
+ }
127
+ }
128
+ }
129
+ }
130
+ },
131
+ };
@@ -0,0 +1,112 @@
1
+ // The pipeline stage contract (TODO.impl/02): every retrieval mechanism
2
+ // is a self-contained stage implementing this interface, composed by the
3
+ // registry in stages/index.ts. Adding a mechanism = adding a stage file
4
+ // and one registry entry — no edits to existing stages, the runner, or
5
+ // retrieve() (OCP). Contracts per stage: docs/spec-pipeline.md.
6
+
7
+ import type { Hit } from "../../../shared/chunk";
8
+ import type { QueryFilters } from "../selfquery.ts";
9
+ import type { QueryUnderstanding } from "../understand";
10
+
11
+ export interface RetrieveOptions {
12
+ prev?: string;
13
+ understanding?: QueryUnderstanding | null;
14
+ queryOverride?: string;
15
+ federate?: (query: string) => Promise<Hit[]>;
16
+ warmEmbed?: Promise<number[] | null>;
17
+ graphDocNumbers?: string[];
18
+ /** The declared context's HARD seal (TODO.ai-platform/02): when the
19
+ * panel's chip declares a document scope, the CANDIDATE POOL is cut
20
+ * to the publication family before rerank + the top-N cut — the
21
+ * soft-steer widenings below (the full-corpus lexical union, the
22
+ * sparse-filter widen, the sub-query lanes) can otherwise outscore
23
+ * the filtered dense lane under the cross-encoder and push every
24
+ * in-family passage out of the final hits, sealing the answer to
25
+ * zero despite a healthy in-family pool. */
26
+ sealScope?: { doc_number: string; edition?: string } | null;
27
+ /** Option C: dense-lane results computed concurrently with
28
+ * understanding (same folded-query vector, retrieve's exact query
29
+ * parameters). With no filter they REPLACE the primary dense query;
30
+ * with a filter they union in as discounted filter-miss cover. */
31
+ optimisticHits?: Hit[];
32
+ optimisticVec?: number[] | null;
33
+ /** Dataset scope (the sidebar toggles): the set of CORPUS values the
34
+ * request allows. Present only when NARROWER than the default (all
35
+ * permitted datasets) — a null scope means no filtering. The ask path
36
+ * intersects the requested ids with session permissions before
37
+ * building this set. */
38
+ datasetScope?: Set<string> | null;
39
+ }
40
+
41
+ export interface GlossaryEntry {
42
+ term: string;
43
+ definition: string;
44
+ docidentifier: string;
45
+ doc_number: string;
46
+ score: number;
47
+ }
48
+
49
+ /** The mutable state every stage reads and writes. Field ownership per
50
+ * stage is specified in docs/spec-pipeline.md; in general: candidate
51
+ * lanes append to `matches`, refinement stages rewrite `hits`, and only
52
+ * the window-assembly stages touch `finalHits`. */
53
+ export interface PipelineContext {
54
+ env: any;
55
+ query: string; // the user's original wording (rerank + pin score on it)
56
+ rq: string; // the retrieval query (folded / standalone / expanded)
57
+ folded: string; // the pre-understanding fold (optimistic-lane identity)
58
+ u: QueryUnderstanding | null;
59
+ filters: QueryFilters | null; // dense-stage may drop a guessed edition pin
60
+ filter: Record<string, string> | null | undefined; // toVectorizeFilter's output
61
+ vector: number[]; // embedding of rq
62
+ lexicalHits: Hit[]; // full-corpus BM25 ranking (already seal-filtered)
63
+ matches: any[]; // the candidate pool (Vectorize match shape, pre-Hit)
64
+ hits: Hit[]; // the ranked pool from poolOpen onward
65
+ finalHits: Hit[]; // the answer window
66
+ glossary: GlossaryEntry[]; // the vocabulary link (glossary stage owns)
67
+ opts: RetrieveOptions;
68
+ /** prefetch bag: stage-name → that stage's in-flight I/O promise (the
69
+ * stage owns its key; see Stage.prefetch) */
70
+ lane: Record<string, Promise<unknown>>;
71
+ }
72
+
73
+ export interface Stage {
74
+ name: string;
75
+ /** Absent = always runs. Guards are pure reads of the context. */
76
+ when?: (c: PipelineContext) => boolean;
77
+ /** "additive": a throw is logged and the pipeline continues with the
78
+ * context as the previous stage left it (the lane's results were not
79
+ * written). "blocking" (default): the throw propagates to the caller. */
80
+ failure?: "additive" | "blocking";
81
+ /** Kick this stage's INDEPENDENT I/O off early (the runner invokes
82
+ * every stage's prefetch before running any stage). Only for stages
83
+ * whose I/O depends on pre-pipeline state (u, vector, opts) — never
84
+ * on prior stages' output. The promise lands in c.lane[name]; run()
85
+ * awaits it and merges. Merges stay in registry order — concurrency
86
+ * changes when I/O completes, never the merge order (determinism). */
87
+ prefetch?: (c: PipelineContext) => void;
88
+ run: (c: PipelineContext) => Promise<void> | void;
89
+ }
90
+
91
+ /** Run the registry in order. Additive stages swallow their own throws —
92
+ * replicating the per-lane try/catch the monolith carried inline. */
93
+ export async function runStages(stages: Stage[], c: PipelineContext): Promise<void> {
94
+ for (const stage of stages) {
95
+ if (stage.prefetch && (!stage.when || stage.when(c))) stage.prefetch(c);
96
+ }
97
+ for (const stage of stages) {
98
+ if (stage.when && !stage.when(c)) continue;
99
+ if (stage.failure === "additive") {
100
+ try {
101
+ await stage.run(c);
102
+ } catch (e) {
103
+ console.log(`stage ${stage.name}: additive lane failed — primary results stand (${String(e).slice(0, 120)})`);
104
+ }
105
+ } else {
106
+ await stage.run(c);
107
+ }
108
+ }
109
+ }
110
+
111
+ // the shared match→Hit conversion (lib/hit.ts owns it)
112
+ export { toHits } from "../lib/hit.ts";
@@ -0,0 +1,23 @@
1
+ // Relevance-floored window (the evidence-budget principle): a full
2
+ // window of near-miss passages costs tokens and attention while good
3
+ // answers cite only what they need (measured: median 10 served, 1–2
4
+ // cited). Serve what can matter: passages within a fraction of the top
5
+ // score, plus every structurally-guaranteed unit — typed pins and the
6
+ // passages appended after ranking (their scores are not cross-encoder
7
+ // comparable). Never fewer than two.
8
+ import { THRESHOLDS } from "../config.ts";
9
+ import type { Stage } from "./types.ts";
10
+
11
+ export const windowFloor: Stage = {
12
+ name: "window-floor",
13
+ run: (c) => {
14
+ const top = Math.max(...c.finalHits.map((h) => h.rerank_score ?? h.score));
15
+ const floored = c.finalHits.filter(
16
+ (h) => h.rerank_score === undefined || (h.rerank_score ?? h.score) >= THRESHOLDS.windowFloorFraction * top || !!h.metadata.unit_id || h.metadata.clause_anchor === "family",
17
+ );
18
+ if (floored.length >= 2) {
19
+ if (floored.length < c.finalHits.length) console.log("window floor:", c.finalHits.length, "→", floored.length, "passages");
20
+ c.finalHits = floored;
21
+ }
22
+ },
23
+ };
@@ -0,0 +1,171 @@
1
+ // Structural retrieval over the producer-native clause tree — serving-path
2
+ // adaptations of FABLE/BEAR (arXiv:2601.18116) to a corpus that already HAS
3
+ // the hierarchy: Metanorma clause anchors ARE the tree, so unlike FABLE no
4
+ // LLM tree-builder runs at index time. Three techniques:
5
+ //
6
+ // - structuralPropagation: TreeExpansion's relevance propagation — a
7
+ // clause blends its own score with its ancestors' (topic continuity)
8
+ // and descendants' (subtopic heat): sections whose clauses are
9
+ // collectively hot rise, hot sections lift their clauses.
10
+ // - positionOrder: NodeFusion's position-preserving ordering — evidence
11
+ // is presented in document reading order (per publication, groups by
12
+ // selection priority), because synthesis quality depends on
13
+ // arrangement, not just set membership.
14
+ // - ancestorDescendantDedup: near-duplicate chunks of the same clause
15
+ // chain (parent §3.1 vs child §3.1.2 repeating its heading + text)
16
+ // collapse to the stronger one before the window is cut.
17
+ import type { Hit } from "./pipeline";
18
+
19
+ /** "3.1.2" → [3,1,2]; null for everything else (annex labels, producer
20
+ * UUIDs, overview/family, empty). */
21
+ export function parseAnchor(anchor: string | undefined | null): number[] | null {
22
+ if (!anchor) return null;
23
+ const a = anchor.trim().replace(/\.$/, "");
24
+ if (!/^\d+(\.\d+)*$/.test(a)) return null;
25
+ return a.split(".").map(Number);
26
+ }
27
+
28
+ /** a is a PROPER ancestor of b ("3.1" ⊳ "3.1.2"). */
29
+ export function isAncestorOf(a: number[], b: number[]): boolean {
30
+ return a.length < b.length && b.slice(0, a.length).every((s, i) => s === a[i]);
31
+ }
32
+
33
+ /** Document order for dotted numeric anchors ("3" < "3.1" < "3.1.2" < "3.2"). */
34
+ export function anchorCompare(a: number[], b: number[]): number {
35
+ const n = Math.min(a.length, b.length);
36
+ for (let i = 0; i < n; i++) if (a[i] !== b[i]) return a[i] - b[i];
37
+ return a.length - b.length;
38
+ }
39
+
40
+ const scoreOf = (h: Hit) => h.rerank_score ?? h.score;
41
+
42
+ /** TreeExpansion-style structural propagation (Eq. 7 of the paper):
43
+ * S(v) = (self + inherited + childAgg)/3, blended into the live score as
44
+ * a spread-scaled adjustment — same idiom as edition steering, so the
45
+ * adjustment can never outrank the cross-encoder's own signal. */
46
+ export function structuralPropagation(hits: Hit[]): Hit[] {
47
+ if (hits.length < 3) return hits;
48
+ const scored = hits.map(scoreOf);
49
+ const min = Math.min(...scored);
50
+ const max = Math.max(...scored);
51
+ const spread = max - min;
52
+ if (spread <= 0) return hits;
53
+
54
+ const byDoc = new Map<string, { h: Hit; a: number[]; n: number }[]>();
55
+ for (const h of hits) {
56
+ const a = parseAnchor(h.metadata.clause_anchor);
57
+ if (!a) continue;
58
+ const k = h.metadata.doc_id;
59
+ if (!byDoc.has(k)) byDoc.set(k, []);
60
+ byDoc.get(k)!.push({ h, a, n: (scoreOf(h) - min) / spread });
61
+ }
62
+
63
+ let adjusted = 0;
64
+ for (const nodes of byDoc.values()) {
65
+ if (nodes.length < 2) continue;
66
+ for (const nd of nodes) {
67
+ let inherited: number | null = null;
68
+ let childSum = 0;
69
+ let childN = 0;
70
+ for (const other of nodes) {
71
+ if (other === nd) continue;
72
+ if (isAncestorOf(other.a, nd.a)) inherited = Math.max(inherited ?? 0, other.n);
73
+ else if (isAncestorOf(nd.a, other.a)) {
74
+ childSum += other.n;
75
+ childN++;
76
+ }
77
+ }
78
+ if (inherited === null && childN === 0) continue;
79
+ const s = (nd.n + (inherited ?? nd.n) + (childN ? childSum / childN : nd.n)) / 3;
80
+ const adj = spread * 0.2 * (s - nd.n);
81
+ if (Math.abs(adj) < 1e-9) continue;
82
+ if (nd.h.rerank_score !== undefined) nd.h.rerank_score += adj;
83
+ else nd.h.score += adj;
84
+ adjusted++;
85
+ }
86
+ }
87
+ if (adjusted) {
88
+ console.log("structural propagation:", adjusted, "hits re-scored across the clause tree");
89
+ hits.sort((a, b) => scoreOf(b) - scoreOf(a));
90
+ }
91
+ return hits;
92
+ }
93
+
94
+ /** Position-preserving evidence order (NodeFusion, Algorithm 2): passages
95
+ * of the same publication are fed in document order, publications ordered
96
+ * by their best-ranked member. Document order is the PRODUCER'S ordinal
97
+ * when the metadata carries one (metanorma-document#56) — a sort, never
98
+ * an anchor parse; the anchor compare is the fallback for chunks whose
99
+ * producer doesn't emit ordinals. Structural chunks (overview/family)
100
+ * lead their doc; unnumbered passages follow the numbered ones. */
101
+ export function positionOrder(hits: Hit[]): Hit[] {
102
+ if (hits.length < 3) return hits;
103
+ const idx = new Map(hits.map((h, i) => [h, i]));
104
+ const groups = new Map<string, Hit[]>();
105
+ for (const h of hits) {
106
+ const k = h.metadata.doc_id || h.id;
107
+ if (!groups.has(k)) groups.set(k, []);
108
+ groups.get(k)!.push(h);
109
+ }
110
+ const rank = (g: Hit[]) => Math.min(...g.map((h) => idx.get(h)!));
111
+ const structural = (h: Hit) => h.metadata.clause_anchor === "overview" || h.metadata.clause_anchor === "family";
112
+ const byOrig = (a: Hit, b: Hit) => idx.get(a)! - idx.get(b)!;
113
+ const byDocOrder = (a: Hit, b: Hit) => {
114
+ const oa = (a.metadata as any).ordinal;
115
+ const ob = (b.metadata as any).ordinal;
116
+ if (typeof oa === "number" && typeof ob === "number" && oa !== ob) return oa - ob;
117
+ const pa = parseAnchor(a.metadata.clause_anchor);
118
+ const pb = parseAnchor(b.metadata.clause_anchor);
119
+ if (pa && pb) return anchorCompare(pa, pb) || byOrig(a, b);
120
+ if (pa && !pb) return -1;
121
+ if (!pa && pb) return 1;
122
+ return byOrig(a, b);
123
+ };
124
+
125
+ const out: Hit[] = [];
126
+ for (const g of [...groups.values()].sort((a, b) => rank(a) - rank(b))) {
127
+ const head = g.filter(structural).sort(byOrig);
128
+ const ordered = g.filter((h) => !structural(h)).sort(byDocOrder);
129
+ out.push(...head, ...ordered);
130
+ }
131
+ return out;
132
+ }
133
+
134
+ const headText = (h: Hit) => h.text.replace(/\s+/g, " ").toLowerCase().slice(0, 600);
135
+
136
+ function overlap(a: string, b: string): number {
137
+ const A = new Set(a.split(/[^a-z0-9°%]+/).filter((t) => t.length > 3));
138
+ const B = new Set(b.split(/[^a-z0-9°%]+/).filter((t) => t.length > 3));
139
+ if (!A.size || !B.size) return 0;
140
+ let inter = 0;
141
+ for (const t of A) if (B.has(t)) inter++;
142
+ return inter / (A.size + B.size - inter);
143
+ }
144
+
145
+ /** Same-chain near-duplicate collapse: when an ancestor chunk and a
146
+ * descendant chunk of one clause chain carry substantially the same text,
147
+ * the weaker one leaves the window (FABLE keeps the subtree, drops the
148
+ * redundant node). Different-text relatives both stay — a parent clause
149
+ * and a deep sub-clause are usually different content. */
150
+ export function ancestorDescendantDedup(hits: Hit[]): Hit[] {
151
+ if (hits.length < 2) return hits;
152
+ const anchors = hits.map((h) => parseAnchor(h.metadata.clause_anchor));
153
+ const drop = new Set<Hit>();
154
+ for (let i = 0; i < hits.length; i++) {
155
+ if (!anchors[i] || drop.has(hits[i])) continue;
156
+ for (let j = i + 1; j < hits.length; j++) {
157
+ if (!anchors[j] || drop.has(hits[j])) continue;
158
+ if (hits[i].metadata.doc_id !== hits[j].metadata.doc_id) continue;
159
+ const chained = isAncestorOf(anchors[i]!, anchors[j]!) || isAncestorOf(anchors[j]!, anchors[i]!);
160
+ if (!chained) continue;
161
+ if (overlap(headText(hits[i]), headText(hits[j])) >= 0.5) {
162
+ drop.add(scoreOf(hits[i]) >= scoreOf(hits[j]) ? hits[j] : hits[i]);
163
+ }
164
+ }
165
+ }
166
+ if (drop.size) {
167
+ console.log("structural dedup:", drop.size, "same-chain near-duplicate(s) dropped");
168
+ return hits.filter((h) => !drop.has(h));
169
+ }
170
+ return hits;
171
+ }
@@ -0,0 +1,41 @@
1
+ /** Schema-aware table context composition (TableRAG-class cell
2
+ * selection) over the producer's typed table payload. Pure — the
3
+ * interface is the test surface. */
4
+ /** Schema-aware table context (TableRAG-class cell selection): the
5
+ * producer payload (metadata.table) carries caption/columns/rows; the
6
+ * consumer composes the model-facing serialization — columns whose
7
+ * labels overlap the query, rows whose cells overlap the query or the
8
+ * selected column labels. Full table stays available for rendering;
9
+ * this only shapes the prompt context, and falls back to the stored
10
+ * text when pruning matches nothing (never worse than baseline). */
11
+ export function tableContext(meta: any, query: string): string | null {
12
+ const t: any = meta?.table;
13
+ if (!t || !Array.isArray(t.columns) || !Array.isArray(t.rows) || !t.rows.length) return null;
14
+ const terms = new Set(
15
+ query.toLowerCase().replace(/[^\p{L}\p{N}\s]/gu, " ").split(/\s+/).filter((w: string) => w.length > 2),
16
+ );
17
+ const termList = [...terms];
18
+ const label = (c: any): string => `${c?.label ?? ""} ${c?.unit ?? ""}`.toLowerCase();
19
+ const keepCols: number[] = [];
20
+ t.columns.forEach((c: any, i: number) => {
21
+ if (termList.some((term) => label(c).includes(term))) keepCols.push(i);
22
+ });
23
+ const colKeep: number[] = keepCols.length ? keepCols : t.columns.map((_: any, i: number) => i);
24
+ const rowHits: string[] = [];
25
+ for (const row of t.rows as string[]) {
26
+ const cells = String(row).split("|").map((c: string) => c.trim().toLowerCase());
27
+ const cellHit = cells.some((c: string) => c && termList.some((term) => c.includes(term)));
28
+ const colHit = keepCols.length > 0 && colKeep.some((i: number) => cells[i] && termList.some((term) => label(t.columns[i]).includes(term) && cells[i].length > 0));
29
+ if (cellHit || colHit) rowHits.push(row);
30
+ }
31
+ if (!rowHits.length) return null;
32
+ const CAP = 10;
33
+ const shown = rowHits.slice(0, CAP);
34
+ const header = `Table: ${t.caption ?? ""}\ncolumns: ${colKeep.map((i: number) => `${t.columns[i]?.label ?? ""}${t.columns[i]?.unit ? ` [${t.columns[i].unit}]` : ""}`).join(" | ")}`;
35
+ const lines = shown.map((r: string) => `row: ${r}`);
36
+ const elided =
37
+ rowHits.length > CAP || rowHits.length < t.rows.length
38
+ ? `\n(${shown.length} of ${t.rows.length} rows shown; ${t.rows.length - rowHits.length} rows did not match the question terms)`
39
+ : "";
40
+ return `${header}\n${lines.join("\n")}${elided}`;
41
+ }
@@ -0,0 +1,72 @@
1
+ import type { ModelRunner } from "./ports/model.ts";
2
+ import { extractJson, type QueryUnderstanding } from "./understandContract.ts";
3
+ export type { QueryUnderstanding };
4
+
5
+ // Query understanding (TODO.impl/15 audit finding #1): a small, fast LLM
6
+ // call replaces the regex heuristics as the decision-maker for what
7
+ // retrieval should see. The regex path (selfquery.ts) remains as a
8
+ // fallback when this call fails, errors, or times out — degraded mode,
9
+ // never a hard failure.
10
+
11
+ // The prompt is data (prompts/understanding.md), bundled as text.
12
+ import SYSTEM from "../prompts/understanding.md";
13
+
14
+ /** Understand the query with the cheap model. Null = use the regex fallback. */
15
+ export async function understandQuery(
16
+ ai: ModelRunner,
17
+ model: string,
18
+ query: string,
19
+ history: Array<{ role: string; content: string }>,
20
+ entities: Array<{ entity: string; kind: string }> = [],
21
+ ): Promise<QueryUnderstanding | null> {
22
+ const convo = history
23
+ .slice(-6)
24
+ .map((h) => `${h.role === "user" ? "User" : "Assistant"}: ${h.content.slice(0, 600)}`)
25
+ .join("\n");
26
+ const entityLine = entities.length
27
+ ? `Entities already established in this conversation: ${entities.map((e) => e.entity).join("; ")}. Resolve pronouns and shorthand against these.\n\n`
28
+ : "";
29
+ const user = `${convo ? "Conversation so far:\n" + convo + "\n\n" : ""}${entityLine}Question: ${query}`;
30
+ const body = {
31
+ messages: [
32
+ { role: "system", content: SYSTEM },
33
+ { role: "user", content: user },
34
+ ],
35
+ // the model always reasons; reasoning tokens share this budget — too
36
+ // small and the JSON is never reached (understanding silently degrades).
37
+ // GLM-5 family defaults to reasoning_effort "max" when the parameter is
38
+ // not honored, so GLM needs headroom or reasoning starves the JSON.
39
+ max_tokens: model.includes("glm") ? 3072 : 1500,
40
+ reasoning_effort: "low",
41
+ // Qwen3 thinking-mode sampling (model card): greedy/1.0 sampling
42
+ // degrades into repetition loops — the 10s/5s timeout nulls were the
43
+ // budget being eaten by loops, not by reasoning
44
+ temperature: 0.6,
45
+ top_p: 0.95,
46
+ top_k: 20,
47
+ };
48
+ // each attempt issues a FRESH call — re-racing a timed-out promise would
49
+ // retry nothing. Generous first attempt: reasoning + the full JSON must
50
+ // fit inside the timeout or understanding silently degrades to vanilla
51
+ // retrieval (which refuses conversational turns).
52
+ const ATTEMPT_TIMEOUTS = [10000, 5000];
53
+ for (let attempt = 0; attempt < ATTEMPT_TIMEOUTS.length; attempt++) {
54
+ const call = (async () => {
55
+ const res = await ai.run({ model, messages: (body as any).messages, effort: (body as any).reasoning_effort, maxTokens: (body as any).max_tokens, temperature: (body as any).temperature, topP: (body as any).top_p, topK: (body as any).top_k });
56
+ const text = res?.text ?? null;
57
+ return typeof text === "string" ? extractJson(text) : null;
58
+ })();
59
+ const timeout = new Promise<null>((r) => setTimeout(() => r(null), ATTEMPT_TIMEOUTS[attempt]));
60
+ try {
61
+ const got = await Promise.race([call, timeout]);
62
+ if (got) return got;
63
+ } catch (e) {
64
+ // account rate-limited: a retry in the same minute will also fail —
65
+ // degrade to vanilla retrieval immediately instead of burning the
66
+ // second attempt (TTFT surgery)
67
+ if (String(e).includes("3021") || String(e).includes("rate")) return null;
68
+ }
69
+ }
70
+ console.warn("query understanding unavailable — vanilla retrieval");
71
+ return null;
72
+ }
@@ -0,0 +1,67 @@
1
+ // The understanding contract (TODO.impl/29): QueryUnderstanding and the
2
+ // pure JSON extractor — no imports (the prompt file and the model call
3
+ // stay in understand.ts; this module is the testable contract).
4
+
5
+ export interface QueryUnderstanding {
6
+ /** conversational turn (greeting, identity, small talk) vs knowledge seek */
7
+ intent: "conversational" | "knowledge";
8
+ /** normalized document reference, e.g. "OIML R 76-2" — null when none */
9
+ docidentifier: string | null;
10
+ /** base document number for the Vectorize filter, e.g. "60" */
11
+ doc_number: string | null;
12
+ edition?: string | null;
13
+ language?: string | null;
14
+ /** the question is about a process around publications (certify, apply…) */
15
+ process_intent: boolean;
16
+ /** definition-style question whose subject is `term` */
17
+ term: string | null;
18
+ /** corpus-terminology mapping of everyday wording (drift→creep) */
19
+ defined_terms: string[];
20
+ /** self-contained retrieval query: follow-ups folded with context */
21
+ standalone_query: string;
22
+ complexity: "simple" | "complex";
23
+ query_variants: string[];
24
+ sub_queries: string[];
25
+ hypothetical_answer: string;
26
+ /** plausible next questions (conversational UX), in the user's language */
27
+ follow_ups: string[];
28
+ }
29
+
30
+
31
+ /** Model output text
32
+ * → QueryUnderstanding (or null). Every silent coercion is pinned by
33
+ * tests/understand.test.ts — change the prompt's JSON shape and the
34
+ * test names what moved. */
35
+ export function extractJson(text: string): QueryUnderstanding | null {
36
+ const m = text.match(/\{[\s\S]*\}/);
37
+ if (!m) return null;
38
+ try {
39
+ const raw = JSON.parse(m[0]);
40
+ const u: QueryUnderstanding = {
41
+ intent: raw.intent === "conversational" ? ("conversational" as const) : ("knowledge" as const),
42
+ docidentifier: typeof raw.docidentifier === "string" && raw.docidentifier.trim() ? raw.docidentifier.trim().slice(0, 60) : null,
43
+ doc_number: typeof raw.docnumber === "string" && /^\d{1,3}$/.test(raw.docnumber) ? raw.docnumber : null,
44
+ edition: typeof raw.edition === "string" && /^\d{4}$/.test(raw.edition) ? raw.edition : null,
45
+ language: typeof raw.language === "string" && /^[a-z]{2}$/.test(raw.language) ? raw.language : null,
46
+ process_intent: raw.process_intent === true,
47
+ term: typeof raw.term === "string" && raw.term.trim() ? raw.term.trim().slice(0, 60) : null,
48
+ defined_terms: Array.isArray(raw.defined_terms)
49
+ ? raw.defined_terms.filter((t: unknown) => typeof t === "string" && (t as string).trim()).map((t: string) => t.trim().slice(0, 60)).slice(0, 4)
50
+ : [],
51
+ standalone_query: typeof raw.standalone_query === "string" && raw.standalone_query.trim() ? raw.standalone_query.trim().slice(0, 400) : "",
52
+ complexity: raw.complexity === 'complex' ? ('complex' as const) : ('simple' as const),
53
+ query_variants: Array.isArray(raw.query_variants)
54
+ ? raw.query_variants.filter((q: unknown) => typeof q === 'string' && (q as string).trim()).map((q: string) => q.trim().slice(0, 300)).slice(0, 4)
55
+ : [],
56
+ hypothetical_answer: typeof raw.hypothetical_answer === "string" ? raw.hypothetical_answer.trim().slice(0, 300) : "",
57
+ sub_queries: Array.isArray(raw.sub_queries)
58
+ ? raw.sub_queries.filter((q: unknown) => typeof q === 'string' && (q as string).trim()).map((q: string) => q.trim().slice(0, 300)).slice(0, 5)
59
+ : [],
60
+ follow_ups: Array.isArray(raw.follow_ups)
61
+ ? raw.follow_ups.filter((q: unknown) => typeof q === 'string' && (q as string).trim()).map((q: string) => q.trim().slice(0, 200)).slice(0, 2)
62
+ : [], };
63
+ return u;
64
+ } catch {
65
+ return null;
66
+ }
67
+ }