@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,89 @@
1
+ // CRAG-style retrieval grader (audit finding; the corrective layer of
2
+ // 2025 SOTA RAG architectures): grade the retrieved passages against the
3
+ // question BEFORE generating. "weak" triggers one corrective re-retrieval;
4
+ // "bad" flows through to the strict generation prompt, which refuses
5
+ // honestly — the grader never invents a refusal of its own.
6
+
7
+ // The prompt is data (prompts/grader.md), bundled as text.
8
+ import SYSTEM from "../prompts/grader.md";
9
+
10
+ export type RetrievalGrade = "good" | "weak" | "bad";
11
+
12
+
13
+
14
+ export async function gradeRetrieval(
15
+ ai: any,
16
+ model: string,
17
+ query: string,
18
+ passages: string[],
19
+ ): Promise<RetrievalGrade> {
20
+ if (!passages.length) return "bad";
21
+ const summary = passages
22
+ .slice(0, 8)
23
+ .map((p, i) => `[${i + 1}] ${p.replace(/\s+/g, " ").slice(0, 220)}`)
24
+ .join("\n");
25
+ const timeout = new Promise<null>((r) => setTimeout(() => r(null), 6000));
26
+ const call = (async () => {
27
+ const res: any = await ai.run(model, {
28
+ messages: [
29
+ { role: "system", content: SYSTEM },
30
+ { role: "user", content: `Question: ${query}\n\nPassages:\n${summary}` },
31
+ ],
32
+ // reasoning shares this budget — starved budgets silently disable
33
+ // the CRAG corrective layer (default "good" fires). DeepSeek-V4's
34
+ // non-think mode is severely degraded (model card: HLE 8.1 vs 34.8),
35
+ // so the grader keeps reasoning on with real headroom plus the
36
+ // card's recommended sampling.
37
+ max_tokens: 3072,
38
+ reasoning_effort: "low",
39
+ temperature: 1.0,
40
+ top_p: 1.0,
41
+ });
42
+ const text = typeof res?.response === "string" ? res.response : res?.choices?.[0]?.message?.content;
43
+ const m = (text ?? "").match(/"grade"\s*:\s*"(good|weak|bad)"/);
44
+ return m ? (m[1] as RetrievalGrade) : null;
45
+ })();
46
+ try {
47
+ const got = await Promise.race([call, timeout]);
48
+ return got ?? "good"; // grader unavailable → do not block the pipeline
49
+ } catch {
50
+ return "good";
51
+ }
52
+ }
53
+
54
+ export async function scoreJudge(
55
+ ai: any,
56
+ model: string,
57
+ systemPrompt: string,
58
+ userPrompt: string,
59
+ ): Promise<number | null> {
60
+ try {
61
+ const timeout = new Promise<null>((r) => setTimeout(() => r(null), 15000));
62
+ const call = (async () => {
63
+ const res: any = await ai.run(model, {
64
+ messages: [
65
+ { role: "system", content: systemPrompt.trimEnd() },
66
+ { role: "user", content: userPrompt },
67
+ ],
68
+ max_tokens: 3072,
69
+ reasoning_effort: "low",
70
+ });
71
+ const text = typeof res?.response === "string" ? res.response : res?.choices?.[0]?.message?.content;
72
+ // reasoning models can emit {...} fragments before the verdict — the
73
+ // LAST flat object with a numeric score wins
74
+ let score: number | null = null;
75
+ for (const m of (text ?? "").matchAll(/\{[^{}]*\}/g)) {
76
+ try {
77
+ const obj = JSON.parse(m[0]);
78
+ if (typeof obj.score === "number") score = obj.score;
79
+ } catch {
80
+ // keep scanning
81
+ }
82
+ }
83
+ return score === null ? null : Math.max(0, Math.min(1, score));
84
+ })();
85
+ return await Promise.race([call, timeout]);
86
+ } catch {
87
+ return null;
88
+ }
89
+ }
@@ -0,0 +1,63 @@
1
+ // Graph expansion (G8 query lane): map understanding's term onto the
2
+ // D1 projection (graph_nodes / graph_edges) and return the doc_numbers
3
+ // the graph says are relevant. Mirrors graph.py's node-id format
4
+ // (doc:OIML-R-60-1-2017, concept:<id>).
5
+ import type { Env } from "./env";
6
+
7
+ /** Graph expansion (G8 query lane): map understanding's term / named
8
+ * document onto the D1 projection (graph_nodes / graph_edges) and return
9
+ * the doc_numbers the graph says are relevant. Mirrors graph.py's node-id
10
+ * format (doc:OIML-R-60-1-2017, concept:<id>). */
11
+ function docNumberOf(nodeId: string): string | null {
12
+ // doc:OIML-R-60-1-2017 → "60" | doc:OIML-B-18-2025 → "18"
13
+ const m = nodeId.match(/^doc:OIML-[A-Z]-(\d+)-/);
14
+ return m ? m[1] : null;
15
+ }
16
+
17
+ export async function graphExpand(env: Env, u: { term?: string | null; defined_terms?: string[]; docidentifier?: string | null } | null): Promise<string[] | undefined> {
18
+ if (!env.DB || !u) return undefined;
19
+ const numbers = new Set<string>();
20
+ const terms = [...(u.defined_terms ?? []), ...(u.term ? [u.term] : [])].filter((t) => t.length >= 3);
21
+ try {
22
+ for (const term of terms.slice(0, 4)) {
23
+ const rows = await env.DB.prepare(
24
+ "SELECT e.src AS doc FROM graph_edges e JOIN graph_nodes c ON e.dst = c.id WHERE e.kind = 'defines' AND c.kind = 'concept' AND (c.label = ?1 OR c.label LIKE ?2) LIMIT 12",
25
+ )
26
+ .bind(term, `%${term}%`)
27
+ .all<{ doc: string }>();
28
+ for (const r of rows.results ?? []) {
29
+ const n = docNumberOf(r.doc);
30
+ if (n) numbers.add(n);
31
+ }
32
+ }
33
+ // NOTE: the docidentifier branch is deliberately absent — a named
34
+ // document already gets the exact doc_number filter; merging its
35
+ // annex/variant neighbors only pollutes doc-level queries. The graph
36
+ // lane exists for VOCABULARY MISMATCH (everyday words → defined term
37
+ // → defining documents), which no filter can express.
38
+ } catch {
39
+ return numbers.size ? [...numbers] : undefined;
40
+ }
41
+ console.log("graphExpand: terms", JSON.stringify(terms), "→", JSON.stringify([...numbers]));
42
+ return numbers.size ? [...numbers].slice(0, 6) : undefined;
43
+ }
44
+
45
+ /** Edition registry note (documents table): when the query names a
46
+ * publication, tell the model which editions are ACTIVE so superseded
47
+ * passages are treated as such — derived status from successor edges,
48
+ * not the fallible relaton status field. */
49
+ export async function editionNote(env: Env, u: { doc_number?: string | null } | null): Promise<string | undefined> {
50
+ if (!env.DB || !u?.doc_number) return undefined;
51
+ try {
52
+ const rows = await env.DB.prepare(
53
+ "SELECT docidentifier FROM documents WHERE family = (SELECT family FROM documents WHERE docidentifier LIKE ?1 || '%:%' LIMIT 1) AND active = 1",
54
+ )
55
+ .bind(`% ${u.doc_number}:%`)
56
+ .all<{ docidentifier: string }>();
57
+ const actives = (rows.results ?? []).map((r) => r.docidentifier);
58
+ if (!actives.length) return undefined;
59
+ return `Publication registry (authoritative): the ACTIVE edition(s) for this publication are ${actives.join(", ")}. Passages from other editions are superseded — use them only for historical comparison and say so.`;
60
+ } catch {
61
+ return undefined;
62
+ }
63
+ }
@@ -0,0 +1,77 @@
1
+ // Hybrid search: BM25 keyword scoring fused with dense retrieval via
2
+ // Reciprocal Rank Fusion (RRF, k=60). Vectorize has no sparse vectors,
3
+ // so exact-term queries (part numbers, "n_LC", "R 60-3", defined terms)
4
+ // fail on pure vector similarity. BM25 catches them; RRF combines both
5
+ // rankings without needing calibrated scores.
6
+ // Ref: arXiv 2604.01733 — hybrid + RRF consistently outperforms either
7
+ // method alone; RRF(k=60) is the best-practice default (Denser.ai).
8
+
9
+ import type { Hit } from "./pipeline";
10
+
11
+ const RRF_K = 60;
12
+
13
+ /** Simple BM25-ish keyword scorer over the chunk corpus in memory.
14
+ * Not a full BM25 (no IDF — we don't have corpus statistics in the
15
+ * worker); this is a lexical overlap score that catches exact terms
16
+ * that dense embeddings miss. */
17
+ function keywordScore(query: string, text: string): number {
18
+ const terms = query
19
+ .toLowerCase()
20
+ .replace(/[^\w\s-]/g, " ")
21
+ .split(/\s+/)
22
+ .filter((t) => t.length > 1);
23
+ if (!terms.length) return 0;
24
+ const lower = text.toLowerCase();
25
+ let score = 0;
26
+ for (const term of terms) {
27
+ let count = 0;
28
+ let idx = lower.indexOf(term);
29
+ while (idx !== -1 && count < 10) {
30
+ count++;
31
+ idx = lower.indexOf(term, idx + 1);
32
+ }
33
+ if (count > 0) {
34
+ // saturating: the first few hits matter most
35
+ score += 1 + Math.log(count) * 0.5;
36
+ }
37
+ }
38
+ return score / terms.length;
39
+ }
40
+
41
+ /** Rank all candidates by keyword overlap against the query. */
42
+ export function keywordRank(query: string, hits: Hit[]): Hit[] {
43
+ return hits
44
+ .map((h) => ({ h, s: keywordScore(query, h.text) }))
45
+ .filter((x) => x.s > 0)
46
+ .sort((a, b) => b.s - a.s)
47
+ .map((x) => x.h);
48
+ }
49
+
50
+ /** Fuse two rankings with Reciprocal Rank Fusion.
51
+ * score(d) = Σ 1/(k + rank_i(d)) — rank-agnostic, robust across
52
+ * heterogeneous scorers (dense + sparse). */
53
+ export function rrfFuse(
54
+ dense: Hit[],
55
+ keyword: Hit[],
56
+ keep: number,
57
+ ): Hit[] {
58
+ const scores = new Map<string, number>();
59
+ const byId = new Map<string, Hit>();
60
+
61
+ dense.forEach((h, i) => {
62
+ const rank = i + 1;
63
+ scores.set(h.id, (scores.get(h.id) ?? 0) + 1 / (RRF_K + rank));
64
+ byId.set(h.id, h);
65
+ });
66
+ keyword.forEach((h, i) => {
67
+ const rank = i + 1;
68
+ scores.set(h.id, (scores.get(h.id) ?? 0) + 1 / (RRF_K + rank));
69
+ byId.set(h.id, h);
70
+ });
71
+
72
+ return [...scores.entries()]
73
+ .sort((a, b) => b[1] - a[1])
74
+ .slice(0, keep)
75
+ .map(([id]) => byId.get(id)!)
76
+ .filter(Boolean);
77
+ }
@@ -0,0 +1,441 @@
1
+ import { MODELS, datasetsFor, SUGGESTIONS, roleModel } from "./config";
2
+ export { setProfile } from "./profile.ts";
3
+ import { retrieve } from "./pipeline";
4
+ import type { Hit } from "./pipeline";
5
+ import { handleCallback, handleLogin, handleLogout, handleMe, sessionFrom } from "./auth";
6
+ import { handleAppendMessage, handleConversations } from "./conversations";
7
+ import { handleMemories } from "./memories";
8
+ import { handleProjects, handleProjectFiles } from "./projects";
9
+ import { handleShareConversation, handleGetShared } from "./share";
10
+ import { understandQuery } from "./understand";
11
+ import { embed } from "./ai";
12
+ import { ftsMatchQuery } from "./lexical";
13
+ import { scoreFaithfulness } from "./faithfulness";
14
+ import { checkQuoteAnchors } from "./anchors";
15
+ import { namedDocumentIn } from "./context";
16
+ import { standardForDocNumber } from "./modelplane";
17
+ import type { Env } from "./env";
18
+ export type { Env };
19
+ import { json, err, corsHeaders, withCors, readJson, authenticate, type ApiKey } from "./lib/http";
20
+
21
+ import { handleSearch } from "./search";
22
+ import { handleEnrich, handleSectionUnit, handleCaption, handleVectors, handleJudge, handleCreateKey, handleListKeys } from "./admin";
23
+ import { handleResearch } from "./research";
24
+ import { handleAsk } from "./ask";
25
+
26
+ // One named handler per HTTP route, declared in ROUTES below and
27
+ // dispatched by lib/router.ts's matchRoute. Adding a route = one entry +
28
+ // its handler; entry order is irrelevant (every pattern is
29
+ // segment-exact). Dual-published routes (/api for the browser, /v1 for
30
+ // keyed integrators) point at the SAME handler — the tier split is the
31
+ // handler's, derived from the path. Mirrored in docs/spec-api.md.
32
+
33
+ import { matchRoute, type RouteContext, type Route } from "./lib/router";
34
+
35
+ async function serveIndexPage(c: RouteContext): Promise<Response> {
36
+ // HTML pages are served through the worker with must-revalidate so a
37
+ // deploy can never leave the edge serving HTML that references
38
+ // deleted fingerprinted assets.
39
+ const target = new URL(c.path === "/index.html" ? "/" : c.path, c.url);
40
+ const asset = await c.env.ASSETS.fetch(new Request(target, { method: "GET" }));
41
+ if (asset.status === 200) {
42
+ return new Response(asset.body, {
43
+ headers: {
44
+ "content-type": "text/html; charset=utf-8",
45
+ "cache-control": "public, max-age=0, must-revalidate",
46
+ ...corsHeaders(c.req),
47
+ },
48
+ });
49
+ }
50
+ return err(404, "not_found", "Page not found");
51
+ }
52
+
53
+ async function memoriesRoute(c: RouteContext): Promise<Response> {
54
+ const session = await sessionFrom(c.req, c.env as any);
55
+ if (!session) return withCors(err(401, "unauthorized", "Sign in to use memory files"), corsHeaders(c.req));
56
+ return withCors(await handleMemories(c.env, session.sub, c.req, { method: c.req.method, id: c.params.id }), corsHeaders(c.req));
57
+ }
58
+
59
+ async function projectsRoute(c: RouteContext): Promise<Response> {
60
+ const session = await sessionFrom(c.req, c.env as any);
61
+ if (!session) return withCors(err(401, "unauthorized", "Sign in to use projects"), corsHeaders(c.req));
62
+ return withCors(await handleProjects(c.env, session.sub, c.req, { method: c.req.method, id: c.params.id }), corsHeaders(c.req));
63
+ }
64
+
65
+ async function projectFilesRoute(c: RouteContext): Promise<Response> {
66
+ const session = await sessionFrom(c.req, c.env as any);
67
+ if (!session) return withCors(err(401, "unauthorized", "Sign in to use projects"), corsHeaders(c.req));
68
+ return withCors(await handleProjectFiles(c.env, session.sub, c.req, { method: c.req.method, id: c.params.id }), corsHeaders(c.req));
69
+ }
70
+
71
+ async function conversationsRoute(c: RouteContext): Promise<Response> {
72
+ const session = await sessionFrom(c.req, c.env as any);
73
+ if (!session) return withCors(err(401, "unauthorized", "Sign in to sync your conversations across devices"), corsHeaders(c.req));
74
+ return withCors(await handleConversations(c.env, session.sub, c.req, { method: c.req.method, id: c.params.id }), corsHeaders(c.req));
75
+ }
76
+
77
+ async function appendMessageRoute(c: RouteContext): Promise<Response> {
78
+ const session = await sessionFrom(c.req, c.env as any);
79
+ if (!session) return withCors(err(401, "unauthorized", "Sign in to sync your conversations across devices"), corsHeaders(c.req));
80
+ return withCors(await handleAppendMessage(c.env, session.sub, c.req, c.params.id!), corsHeaders(c.req));
81
+ }
82
+
83
+ async function shareRoute(c: RouteContext): Promise<Response> {
84
+ const session = await sessionFrom(c.req, c.env as any);
85
+ if (!session) return err(401, "unauthorized", "Sign in to share conversations");
86
+ const convId = c.params.id;
87
+ const conv = await c.env.DB.prepare("SELECT id, sub, title FROM conversations WHERE id = ?1 AND sub = ?2").bind(convId, session.sub).first();
88
+ if (!conv) return err(404, "not_found", "No such conversation");
89
+ const msgs = await c.env.DB.prepare("SELECT role, content, citations, model FROM messages WHERE conversation_id = ?1 ORDER BY created_at ASC").bind(convId).all();
90
+ return handleShareConversation(c.env, session.sub, (conv as any).title, msgs.results ?? []);
91
+ }
92
+
93
+ async function getSharedRoute(c: RouteContext): Promise<Response> {
94
+ return handleGetShared(c.env, c.params.slug ?? "");
95
+ }
96
+
97
+ async function datasetsRoute(c: RouteContext): Promise<Response> {
98
+ const session = await sessionFrom(c.req, c.env as any);
99
+ return json({ datasets: datasetsFor(session), suggestions: SUGGESTIONS() }, 200, corsHeaders(c.req));
100
+ }
101
+
102
+ async function healthRoute(c: RouteContext): Promise<Response> {
103
+ return json({ ok: true, service: "rag-public", index_version: c.env.INDEX_VERSION, ...corsHeaders(c.req) });
104
+ }
105
+
106
+ /** The /v1 vs /api tier split: /v1 requires an API key; /api is the
107
+ * browser surface (anonymous, or member via a valid session cookie or
108
+ * the bubble bridge's Bearer token). Returns the 401 itself on a bad
109
+ * key. */
110
+ async function tierFor(c: RouteContext): Promise<{ tier: "anon" | "key" | "member"; key: ApiKey | null } | Response> {
111
+ const isApi = c.path.startsWith("/v1/");
112
+ let key: ApiKey | null = null;
113
+ if (isApi) {
114
+ key = await authenticate(c.env, c.req);
115
+ if (!key) return err(401, "unauthorized", "Provide a valid API key: Authorization: Bearer oiml_...");
116
+ }
117
+ let tier: "anon" | "key" | "member" = isApi ? "key" : "anon";
118
+ if (!isApi && c.env.SESSION_SECRET && (await sessionFrom(c.req, c.env as any))) tier = "member";
119
+ return { tier, key };
120
+ }
121
+
122
+ async function askRoute(c: RouteContext): Promise<Response> {
123
+ const t = await tierFor(c);
124
+ if (t instanceof Response) return t;
125
+ return withCors(await handleAsk(c.env, c.ctx, c.req, t.tier, t.key), corsHeaders(c.req));
126
+ }
127
+
128
+ async function searchRoute(c: RouteContext): Promise<Response> {
129
+ const t = await tierFor(c);
130
+ if (t instanceof Response) return t;
131
+ return withCors(await handleSearch(c.env, c.ctx, c.req, t.tier, t.key), corsHeaders(c.req));
132
+ }
133
+
134
+ async function adminStatsRoute(c: RouteContext): Promise<Response> {
135
+ const { env, req, ctx } = c;
136
+ if (!env.ADMIN_TOKEN) return err(501, "admin_disabled", "ADMIN_TOKEN secret is not configured");
137
+ const auth = req.headers.get("authorization") ?? "";
138
+ if (auth !== `Bearer ${env.ADMIN_TOKEN}`) return err(401, "unauthorized", "Invalid admin token");
139
+ const [byDay, byModel, feedback, convCount] = await Promise.all([
140
+ env.DB.prepare("SELECT day, tier, COUNT(*) as n, SUM(ok) as ok FROM queries WHERE day >= date('now','-7 days') GROUP BY day, tier ORDER BY day DESC").all(),
141
+ env.DB.prepare("SELECT model, SUM(requests) as requests FROM spend WHERE day >= date('now','-7 days') GROUP BY model ORDER BY requests DESC").all(),
142
+ env.DB.prepare("SELECT rating, COUNT(*) as n FROM feedback GROUP BY rating").all(),
143
+ env.DB.prepare("SELECT COUNT(*) as n FROM conversations").first(),
144
+ ]);
145
+ const totalQueries = (byDay.results as any[]).reduce((a, r) => a + (r.n || 0), 0) || 0;
146
+ const totalOk = (byDay.results as any[]).reduce((a, r) => a + (r.ok_count || 0), 0) || 0;
147
+ const errorRate = totalQueries > 0 ? (((totalQueries - totalOk) / totalQueries) * 100).toFixed(1) : "0";
148
+ ctx.waitUntil(env.DB.batch([
149
+ env.DB.prepare("DELETE FROM queries WHERE day < date('now','-90 days')"),
150
+ env.DB.prepare("DELETE FROM spend WHERE day < date('now','-90 days')"),
151
+ env.DB.prepare("DELETE FROM feedback WHERE ts < datetime('now','-90 days')"),
152
+ ]));
153
+ return json({
154
+ window: "7 days",
155
+ queries_by_day: byDay.results,
156
+ spend_by_model: byModel.results,
157
+ feedback: feedback.results,
158
+ conversations: (convCount as any)?.n ?? 0,
159
+ error_rate_pct: errorRate,
160
+ index_version: env.INDEX_VERSION,
161
+ pruned: "telemetry >90d",
162
+ }, 200, corsHeaders(req));
163
+ }
164
+
165
+ // ── Provable absence (TODO.era3/02): deterministic enumeration proof ──
166
+ async function absenceRoute(c: RouteContext): Promise<Response> {
167
+ const { env, req } = c;
168
+ const t = await tierFor(c);
169
+ if (t instanceof Response) return t;
170
+ const body = await readJson(req);
171
+ const namedStd = namedDocumentIn(String(body?.standard ?? ""));
172
+ const standard = standardForDocNumber(namedStd?.doc_number ?? String(body?.standard ?? "").trim());
173
+ const topic = String(body?.topic ?? "").trim().toLowerCase();
174
+ if (!standard || !topic) return err(400, "invalid_input", "standard (e.g. \"R 60\") and topic are required");
175
+ try {
176
+ const nodes = (await env.DB.prepare("SELECT node_id, kind, name, content FROM model_nodes WHERE standard = ?1").bind(standard).all()).results ?? [];
177
+ const tokens = topic.split(/\s+/).filter((t: string) => t.length > 2);
178
+ const matches: unknown[] = [];
179
+ for (const n of nodes as any[]) {
180
+ const hay = `${n.name ?? ""} ${n.content ?? ""}`.toLowerCase();
181
+ if (tokens.some((tok: string) => hay.includes(tok))) {
182
+ matches.push({ node_id: n.node_id, kind: n.kind });
183
+ }
184
+ }
185
+ const chunks = (await env.DB.prepare("SELECT COUNT(*) AS n FROM chunks WHERE corpus = 'smart-model' AND (docidentifier LIKE ?1 OR doc_id LIKE ?2)").bind(`%${body?.standard}%`, `%${body?.standard}%`).first()) as any;
186
+ return json({
187
+ standard,
188
+ topic,
189
+ enumerated: { model_nodes: nodes.length, smart_model_chunks: chunks?.n ?? 0 },
190
+ matches: matches.slice(0, 20),
191
+ verdict: matches.length === 0 ? "absent" : "present",
192
+ scope: `the model plane of ${standard} (all model nodes) — the enumeration is exhaustive over that scope; prose outside the modeled families is not claimed`,
193
+ });
194
+ } catch (e) {
195
+ return err(502, "absence_failed", String(e).slice(0, 200));
196
+ }
197
+ }
198
+
199
+ // ── Self-verification (TODO.era3/03): the deterministic battery, exposed ──
200
+ async function verifyRoute(c: RouteContext): Promise<Response> {
201
+ const { env, req } = c;
202
+ const t = await tierFor(c);
203
+ if (t instanceof Response) return t;
204
+ const body = await readJson(req);
205
+ const answer = typeof body?.answer === "string" ? body.answer : "";
206
+ const query = typeof body?.query === "string" ? body.query.trim() : "";
207
+ if (!answer || !query) return err(400, "invalid_input", "answer and query are required");
208
+ try {
209
+ const u = await understandQuery(env.AI, roleModel(env, "understand"), query, [], []);
210
+ const retrieved = await retrieve(env, query, { understanding: u });
211
+ const passages = retrieved.hits.map((h: Hit) => h.text);
212
+ const anchors = checkQuoteAnchors(answer, passages);
213
+ const refs = [...answer.matchAll(/\[\[u:([^\]]+)\]\]/g)].map((m) => m[1]);
214
+ const validRefs = refs.filter((r) => retrieved.hits.some((h: Hit) => h.metadata.unit_id === r));
215
+ const checks = [
216
+ { name: "quote_anchors", deterministic: true, pass: anchors.violations.length === 0, detail: `${anchors.violations.length} of ${anchors.total} quoted spans absent from the retrieved passages` },
217
+ { name: "unit_references", deterministic: true, pass: refs.length === validRefs.length, detail: refs.length ? `${validRefs.length}/${refs.length} unit references resolve to served units` : "no unit references" },
218
+ { name: "citations_present", deterministic: true, pass: /\[[^\]]*(OIML|ISO)[^\]]*\]/.test(answer), detail: "normative claims should carry a passage citation" },
219
+ ];
220
+ const faith = await scoreFaithfulness(env.AI, roleModel(env, "grader"), answer, retrieved.hits.map((h: Hit) => h.text));
221
+ return json({
222
+ checks,
223
+ judged: faith ? { name: "faithfulness", deterministic: false, score: faith.score, ungrounded_claims: faith.ungrounded_claims.slice(0, 5) } : null,
224
+ passages_used: retrieved.hits.length,
225
+ });
226
+ } catch (e) {
227
+ return err(502, "verify_failed", String(e).slice(0, 200));
228
+ }
229
+ }
230
+
231
+ // comparison lane query (TODO.model-rag): direct retrieval against a
232
+ // comparison index, bypassing the full ask pipeline — for the annealment
233
+ // runner and the /compare demo
234
+ async function laneRoute(c: RouteContext): Promise<Response> {
235
+ const { env, req } = c;
236
+ const t = await tierFor(c);
237
+ if (t instanceof Response) return t;
238
+ const body = await readJson(req);
239
+ const laneName = String(body?.lane ?? "");
240
+ const query = String(body?.query ?? "").trim();
241
+ const laneBindings: Record<string, any> = {
242
+ primmel: env.EXP_PRIMMEL,
243
+ composed: env.EXP_COMPOSED,
244
+ plain: env.EXP_PLAIN,
245
+ adoc: env.EXP_ADC,
246
+ mko: env.EXP_MKO,
247
+ primmel_flat: env.EXP_PFLAT,
248
+ };
249
+ const laneTables: Record<string, string> = {
250
+ primmel: "chunks_primmel",
251
+ composed: "chunks_composed",
252
+ plain: "chunks_plain",
253
+ adoc: "chunks_adoc",
254
+ mko: "chunks_mko",
255
+ primmel_flat: "chunks_primmel_flat",
256
+ };
257
+ const binding = laneBindings[laneName];
258
+ const table = laneTables[laneName];
259
+ if (!binding || !table) {
260
+ return err(400, "invalid_lane", `lane must be one of: ${Object.keys(laneBindings).join(", ")}`);
261
+ }
262
+ if (!query || query.length > 2000) return err(400, "invalid_input", "query required (1-2000 chars)");
263
+
264
+ try {
265
+ // embed the query
266
+ const vector = await embed(env.AI, MODELS.embed, query);
267
+ // dense retrieval from the comparison index
268
+ const dense = await binding.query(vector, { topK: 20, returnMetadata: "all" });
269
+ const hits = (dense.matches ?? []).map((m: any) => ({
270
+ id: m.id,
271
+ score: m.score,
272
+ metadata: m.metadata ?? {},
273
+ text: m.metadata?.chunk_text ?? "",
274
+ }));
275
+ // lexical retrieval from the comparison D1
276
+ let lexical: any[] = [];
277
+ try {
278
+ // the FTS MATCH construction is the LEXICAL module's (one stopword
279
+ // list, one term policy — the lane's SQL stays lane-specific)
280
+ const match = ftsMatchQuery(query);
281
+ if (match) {
282
+ const res = await env.EXP_DB.prepare(
283
+ `SELECT c.id, c.docidentifier, c.clause_anchor, c.clause_title, c.unit_id, c.block,
284
+ c.text, c.source_lane, c.linked_clause, bm25(${table}_fts) AS rank
285
+ FROM ${table}_fts
286
+ JOIN ${table} c ON c.rowid = ${table}_fts.rowid
287
+ WHERE ${table}_fts MATCH ?1
288
+ ORDER BY rank LIMIT ?2`
289
+ ).bind(match, 10).all();
290
+ lexical = (res.results ?? []).map((r: any) => ({
291
+ id: r.id,
292
+ score: 1 / (1 + Math.max(0, r.rank)),
293
+ metadata: {
294
+ docidentifier: r.docidentifier,
295
+ clause_anchor: r.clause_anchor,
296
+ clause_title: r.clause_title,
297
+ unit_id: r.unit_id,
298
+ block: r.block,
299
+ source_lane: r.source_lane,
300
+ linked_clause: r.linked_clause,
301
+ },
302
+ text: r.text,
303
+ }));
304
+ }
305
+ } catch (e) {
306
+ console.log("lane lexical failed:", String(e).slice(0, 100));
307
+ }
308
+
309
+ // fuse: dedupe by id, dense first, lexical appended
310
+ const seen = new Set<string>();
311
+ const fused = [...hits, ...lexical.filter((h: any) => !seen.has(h.id) && !hits.some((d: any) => d.id === h.id))];
312
+ hits.forEach((h: any) => seen.add(h.id));
313
+ lexical.forEach((h: any) => { if (!seen.has(h.id)) { fused.push(h); seen.add(h.id); } });
314
+
315
+ return json({
316
+ lane: laneName,
317
+ query,
318
+ hits: fused.slice(0, 10).map((h: any) => ({
319
+ id: h.id,
320
+ score: h.score,
321
+ docidentifier: h.metadata?.docidentifier ?? "",
322
+ clause_anchor: h.metadata?.clause_anchor ?? "",
323
+ clause_title: h.metadata?.clause_title ?? "",
324
+ unit_id: h.metadata?.unit_id ?? "",
325
+ block: h.metadata?.block ?? "",
326
+ source_lane: h.metadata?.source_lane ?? "",
327
+ linked_clause: h.metadata?.linked_clause ?? "",
328
+ text: String(h.text ?? "").slice(0, 400),
329
+ })),
330
+ });
331
+ } catch (e) {
332
+ return err(502, "lane_query_failed", String(e).slice(0, 200));
333
+ }
334
+ }
335
+
336
+ async function feedbackRoute(c: RouteContext): Promise<Response> {
337
+ const body = await readJson(c.req);
338
+ const queryHash = typeof body?.query_hash === "string" ? body.query_hash : "";
339
+ const rating = Number(body?.rating);
340
+ if (!/^[a-f0-9]{64}$/.test(queryHash) || ![1, -1].includes(rating)) {
341
+ return withCors(err(400, "invalid_input", "query_hash and rating (1 or -1) are required"), corsHeaders(c.req));
342
+ }
343
+ await c.env.DB.prepare("INSERT INTO feedback (query_hash, rating, ts) VALUES (?1,?2,?3)")
344
+ .bind(queryHash, rating, new Date().toISOString())
345
+ .run();
346
+ return json({ ok: true, ...corsHeaders(c.req) });
347
+ }
348
+
349
+ // unit assets (answer contract v2): immutable, unit-keyed figure images
350
+ async function unitAssetRoute(c: RouteContext): Promise<Response> {
351
+ const m = c.path.match(/^\/assets\/(u:[A-Za-z0-9_-]+)\.(png|jpe?g|gif|svg|webp)$/);
352
+ if (!m) return err(404, "not_found", "Unknown asset");
353
+ const obj = await c.env.UNIT_ASSETS.get(m[1] + "." + m[2]);
354
+ if (!obj) return new Response("not found", { status: 404 });
355
+ const types: Record<string, string> = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", svg: "image/svg+xml", webp: "image/webp" };
356
+ return new Response(obj.body, { headers: { "content-type": types[m[2]] ?? "application/octet-stream", "cache-control": "public, max-age=31536000, immutable", ...corsHeaders(c.req) } });
357
+ }
358
+
359
+ // rendered publication documents (metanorma-mirror layer 1/2): the
360
+ // clean corpus's own HTML renderings under docs/<slug>.{html,anchors.json},
361
+ // immutable — a citation becomes a door into the original document
362
+ async function docsRoute(c: RouteContext): Promise<Response> {
363
+ const m = c.path.match(/^\/docs\/([a-z0-9-]+)\.(html|anchors\.json)$/);
364
+ if (!m) return err(404, "not_found", "Unknown document");
365
+ const obj = await c.env.UNIT_ASSETS.get(`docs/${m[1]}.${m[2]}`);
366
+ if (!obj) return new Response("not found", { status: 404 });
367
+ const type = m[2] === "html" ? "text/html; charset=utf-8" : "application/json; charset=utf-8";
368
+ return new Response(obj.body, { headers: { "content-type": type, "cache-control": "public, max-age=31536000, immutable", ...corsHeaders(c.req) } });
369
+ }
370
+
371
+ async function researchRoute(c: RouteContext): Promise<Response> {
372
+ // member-only: a valid RAG session cookie is required (research spend stays with humans)
373
+ const session = c.env.SESSION_SECRET ? await sessionFrom(c.req, c.env as any) : null;
374
+ return handleResearch(c.env, c.ctx, c.req, session);
375
+ }
376
+
377
+ export const ROUTES: Route[] = [
378
+ { method: "GET", pattern: "/", handler: serveIndexPage },
379
+ { method: "GET", pattern: "/api/", handler: serveIndexPage },
380
+ { method: "GET", pattern: "/index.html", handler: serveIndexPage },
381
+ { method: "GET", pattern: "/auth/login", handler: (c) => handleLogin(c.env as any, c.req) },
382
+ { method: "GET", pattern: "/auth/callback", handler: (c) => handleCallback(c.env as any, c.req) },
383
+ { method: "GET", pattern: "/auth/me", handler: async (c) => withCors(await handleMe(c.env as any, c.req), corsHeaders(c.req)) },
384
+ { method: "GET", pattern: "/auth/logout", handler: (c) => handleLogout(c.env as any, c.req) },
385
+ { method: "POST", pattern: "/auth/logout", handler: (c) => handleLogout(c.env as any, c.req) },
386
+ { method: "*", pattern: "/api/conversations", handler: conversationsRoute },
387
+ { method: "*", pattern: "/api/memories", handler: memoriesRoute },
388
+ { method: "*", pattern: "/api/projects", handler: projectsRoute },
389
+ { method: "*", pattern: "/api/projects/:id/files", handler: projectFilesRoute },
390
+ { method: "DELETE", pattern: "/api/project-files/:id", handler: projectFilesRoute },
391
+ { method: "*", pattern: "/api/memories/:id", handler: memoriesRoute },
392
+ { method: "*", pattern: "/api/conversations/:id", handler: conversationsRoute },
393
+ { method: "POST", pattern: "/api/conversations/:id/messages", handler: appendMessageRoute },
394
+ { method: "POST", pattern: "/api/conversations/:id/share", handler: shareRoute },
395
+ { method: "GET", pattern: "/api/shared/:slug", handler: getSharedRoute },
396
+ { method: "GET", pattern: "/api/datasets", handler: datasetsRoute },
397
+ { method: "GET", pattern: "/health", handler: healthRoute },
398
+ { method: "GET", pattern: "/v1/admin/stats", handler: adminStatsRoute },
399
+ { method: "POST", pattern: "/api/ask", handler: askRoute },
400
+ { method: "POST", pattern: "/v1/ask", handler: askRoute },
401
+ { method: "POST", pattern: "/api/absence", handler: absenceRoute },
402
+ { method: "POST", pattern: "/v1/absence", handler: absenceRoute },
403
+ { method: "POST", pattern: "/api/verify", handler: verifyRoute },
404
+ { method: "POST", pattern: "/v1/verify", handler: verifyRoute },
405
+ { method: "POST", pattern: "/api/lane", handler: laneRoute },
406
+ { method: "POST", pattern: "/v1/lane", handler: laneRoute },
407
+ { method: "POST", pattern: "/api/search", handler: searchRoute },
408
+ { method: "POST", pattern: "/v1/search", handler: searchRoute },
409
+ { method: "POST", pattern: "/api/feedback", handler: feedbackRoute },
410
+ { method: "POST", pattern: "/admin/enrich", handler: (c) => handleEnrich(c.env, c.ctx, c.req) },
411
+ { method: "POST", pattern: "/v1/admin/enrich", handler: (c) => handleEnrich(c.env, c.ctx, c.req) },
412
+ { method: "POST", pattern: "/admin/section", handler: (c) => handleSectionUnit(c.env, c.ctx, c.req) },
413
+ { method: "POST", pattern: "/v1/admin/section", handler: (c) => handleSectionUnit(c.env, c.ctx, c.req) },
414
+ { method: "POST", pattern: "/admin/vectors", handler: (c) => handleVectors(c.env, c.req) },
415
+ { method: "POST", pattern: "/admin/caption", handler: (c) => handleCaption(c.env, c.req) },
416
+ { method: "GET", pattern: "/assets/*", handler: unitAssetRoute },
417
+ { method: "GET", pattern: "/docs/*", handler: docsRoute },
418
+ { method: "POST", pattern: "/api/research", handler: researchRoute },
419
+ { method: "POST", pattern: "/v1/research", handler: researchRoute },
420
+ { method: "POST", pattern: "/admin/judge", handler: (c) => handleJudge(c.env, c.req) },
421
+ { method: "POST", pattern: "/v1/admin/judge", handler: (c) => handleJudge(c.env, c.req) },
422
+ { method: "POST", pattern: "/v1/admin/keys", handler: (c) => handleCreateKey(c.env, c.req) },
423
+ { method: "GET", pattern: "/v1/admin/keys", handler: (c) => handleListKeys(c.env, c.req) },
424
+ ];
425
+
426
+ export default {
427
+ async fetch(req: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
428
+ const url = new URL(req.url);
429
+ const path = url.pathname;
430
+ const cors = corsHeaders(req);
431
+
432
+ if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: cors });
433
+
434
+ const matched = matchRoute(ROUTES, req.method, path);
435
+ if (matched) {
436
+ return matched.route.handler({ env, req, ctx, url, path, params: matched.params });
437
+ }
438
+ return err(404, "not_found", "Unknown route");
439
+ },
440
+ };
441
+