@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,126 @@
1
+ // Deep-research dossier loop (G10): bounded agentic iterations,
2
+ // members-only — the workflow-shaped research spend (TODO.impl/23).
3
+ import { err, json, corsHeaders, readJson, validateQuery } from "./lib/http";
4
+ import { portModelRunner } from "./env.ts";
5
+ import { telemetry } from "./quota";
6
+ import { LIMITS, MODELS, sha256Hex } from "./config";
7
+ import { retrieve, buildMessages, citations } from "./pipeline";
8
+ import { understandQuery } from "./understand";
9
+ import { generateOnce } from "./ai";
10
+ import { canonicalRefusal } from "./refusal";
11
+ import { checkQuoteAnchors } from "./anchors";
12
+ import { graphExpand, editionNote } from "./graph";
13
+ import researchPromptText from "../prompts/research.md";
14
+ import type { Env } from "./env";
15
+ import type { Hit } from "../../shared/chunk.ts";
16
+
17
+ /** Deep-research mode (G10 v1): bounded agentic loop for members —
18
+ * retrieve → sufficiency judge → re-retrieve targeting the gap → answer
19
+ * from the ACCUMULATED evidence. ≤ max_iterations rounds; every
20
+ * iteration's retrieval goes through the same gated pipeline as a
21
+ * normal ask. Workflows (durable, resumable) is the documented upgrade
22
+ * path when runs outgrow a single request. */
23
+ export async function handleResearch(env: Env, ctx: ExecutionContext, req: Request, session: any): Promise<Response> {
24
+ if (!session) {
25
+ return err(403, "forbidden", "Deep research is a member feature — sign in with your OIML SMART account.");
26
+ }
27
+ const body = await readJson(req);
28
+ const q = validateQuery(body);
29
+ if (!q) return err(400, "invalid_input", `query is required (1-${LIMITS.maxInputChars} chars)`);
30
+ const maxIters = Math.min(Math.max(Number(body?.max_iterations) || 3, 1), 3);
31
+
32
+ const started = Date.now();
33
+ const queryHash = await sha256Hex(q.query);
34
+ const understanding = await understandQuery(portModelRunner(env), MODELS.understand, q.query, [], []);
35
+ const graphDocNumbers = await graphExpand(env, understanding);
36
+ const eNote = await editionNote(env, understanding);
37
+
38
+ const accumulated = new Map<string, Hit>();
39
+ let iterations = 0;
40
+ let focus = understanding?.standalone_query?.trim() || q.query;
41
+ let judge: { sufficient: boolean; missing: string } | null = null;
42
+
43
+ for (let i = 0; i < maxIters; i++) {
44
+ iterations = i + 1;
45
+ let retrieved: { hits: Hit[] };
46
+ try {
47
+ retrieved = await retrieve(env, q.query, {
48
+ understanding: i === 0 ? understanding : ({ ...understanding, standalone_query: focus, query_variants: [], hypothetical_answer: undefined } as any),
49
+ graphDocNumbers,
50
+ });
51
+ } catch {
52
+ break;
53
+ }
54
+ for (const h of retrieved.hits.slice(0, LIMITS.rerankKeep)) {
55
+ if (!accumulated.has(h.id)) accumulated.set(h.id, h);
56
+ }
57
+ const passages = [...accumulated.values()];
58
+ // Hierarchical context management (GLM-5 report, their search agents):
59
+ // the judge re-reads the full evidence every round and its context
60
+ // grows without bound. Keep-recent-k: the k most recent findings at
61
+ // full length, everything older as one-line digests. The final ANSWER
62
+ // generation below still sees the full set within the token budget —
63
+ // folding is judge-context only.
64
+ const KEEP_RECENT = 10;
65
+ const older = passages.slice(0, Math.max(0, passages.length - KEEP_RECENT));
66
+ const recent = passages.slice(-KEEP_RECENT);
67
+ const digest = older.length
68
+ ? `Earlier evidence (digest, ${older.length} passages):\n${older.map((h) => `- ${h.metadata.docidentifier ?? ""} §${h.metadata.clause_anchor ?? ""}: ${h.text.replace(/\s+/g, " ").slice(0, 160)}`).join("\n")}\n\n`
69
+ : "";
70
+ judge = await (async () => {
71
+ try {
72
+ const res: any = await env.AI.run(MODELS.grader, {
73
+ messages: [
74
+ { role: "system", content: researchPromptText.trimEnd() },
75
+ { role: "user", content: `Research question: ${q.query}\n\n${digest}Collected passages (${recent.length}):\n${recent.map((h, n) => `[${n + 1}] ${h.metadata.docidentifier ?? ""} §${h.metadata.clause_anchor ?? ""}: ${h.text.slice(0, 700)}`).join("\n")}` },
76
+ ],
77
+ max_tokens: 3072,
78
+ reasoning_effort: "low",
79
+ temperature: 1.0,
80
+ top_p: 1.0,
81
+ });
82
+ const text = typeof res?.response === "string" ? res.response : res?.choices?.[0]?.message?.content;
83
+ let parsed: any = null;
84
+ for (const m of (text ?? "").matchAll(/\{[^{}]*\}/g)) {
85
+ try {
86
+ const obj = JSON.parse(m[0]);
87
+ if (typeof obj.sufficient === "boolean") parsed = obj;
88
+ } catch { /* keep scanning */ }
89
+ }
90
+ return parsed ? { sufficient: parsed.sufficient, missing: String(parsed.missing ?? "") } : null;
91
+ } catch {
92
+ return null;
93
+ }
94
+ })();
95
+ console.log("research iter", iterations, "passages", passages.length, "sufficient:", judge?.sufficient);
96
+ if (!judge || judge.sufficient || !judge.missing) break;
97
+ // fold, don't accumulate: appending every round's `missing` compounds
98
+ // stale wants; the next retrieval focuses on the ORIGINAL question plus
99
+ // what is still missing now
100
+ focus = `${understanding?.standalone_query?.trim() || q.query} ${judge.missing}`.slice(0, LIMITS.maxInputChars);
101
+ }
102
+
103
+ const used = [...accumulated.values()];
104
+ if (!used.length) {
105
+ return err(503, "retrieval_unavailable", "Search is briefly busy — please retry in a moment.");
106
+ }
107
+ const { messages, usedHits } = buildMessages(q.query, used, q.lang, [], eNote || undefined, undefined, LIMITS.inputTokenBudget);
108
+ let answer = await generateOnce(env, MODELS.research, messages);
109
+ if (answer === null) answer = await generateOnce(env, MODELS.fallback, messages);
110
+ if (answer === null) {
111
+ telemetry(env, ctx, "member", "research", MODELS.research, false, 0, queryHash, q.lang);
112
+ return err(502, "generation_failed", "The generation model is unavailable; please retry.");
113
+ }
114
+ answer = canonicalRefusal(answer);
115
+ const anchors = checkQuoteAnchors(answer, used.map((h: Hit) => h.text));
116
+ if (anchors.violations.length) console.log("research anchors:", anchors.violations.length, "unverified");
117
+ const out = {
118
+ answer,
119
+ citations: citations(usedHits),
120
+ model: MODELS.research,
121
+ query_hash: queryHash,
122
+ research: { iterations, passages: used.length, elapsed_ms: Date.now() - started, sufficient: judge?.sufficient ?? null },
123
+ };
124
+ telemetry(env, ctx, "member", "research", MODELS.research, true, answer.length, queryHash, q.lang);
125
+ return json({ ...out, ...corsHeaders(req) });
126
+ }
@@ -0,0 +1,56 @@
1
+ // Passage search: retrieval without generation (TODO.impl/23).
2
+ import { LIMITS, MODELS, num, sha256Hex } from "./config";
3
+ import { portModelRunner } from "./env.ts";
4
+ import type { Background } from "./ports/runtime.ts";
5
+ import { retrieve } from "./pipeline";
6
+ import { understandQuery } from "./understand";
7
+ import { sessionFrom } from "./auth";
8
+ import { err, json, corsHeaders, readJson, validateQuery, type ApiKey } from "./lib/http";
9
+ import { checkQuota, clientIp, telemetry } from "./quota";
10
+ import { graphExpand } from "./graph";
11
+ import type { Env } from "./env";
12
+ import type { Hit } from "../../shared/chunk.ts";
13
+
14
+ export async function handleSearch(
15
+ env: Env,
16
+ ctx: Background,
17
+ req: Request,
18
+ tier: "anon" | "key" | "member",
19
+ key: ApiKey | null,
20
+ ): Promise<Response> {
21
+ const body = await readJson(req);
22
+ const q = validateQuery(body);
23
+ if (!q) return err(400, "invalid_input", `query is required (1-${LIMITS.maxInputChars} chars)`);
24
+
25
+ const member = tier === "member" ? await sessionFrom(req, env as any) : null;
26
+ const limit = tier === "key" || member ? Number.MAX_SAFE_INTEGER : num(env as any, "ANON_DAY_SEARCH", 50);
27
+ const bucketId = tier === "key" ? `key:${key!.id}` : member ? `sub:${member.sub}` : clientIp(req);
28
+ const quota = await checkQuota(env, "search", bucketId, limit);
29
+ if (!quota.ok) {
30
+ return err(429, "quota_exceeded", `Daily search limit reached (${quota.limit}). Try again tomorrow.`);
31
+ }
32
+
33
+ const understanding = await understandQuery(portModelRunner(env), MODELS.understand, q.query, []);
34
+ const graphDocNumbers = await graphExpand(env, understanding);
35
+ let retrieved;
36
+ try {
37
+ retrieved = await retrieve(env, q.query, { understanding, graphDocNumbers });
38
+ } catch {
39
+ return err(503, "retrieval_unavailable", "Search is briefly busy — please retry in a moment.");
40
+ }
41
+ const { hits, filters } = retrieved;
42
+ const results = hits.map((h: Hit) => ({
43
+ doc_id: h.metadata.doc_id,
44
+ docidentifier: h.metadata.docidentifier,
45
+ edition: h.metadata.edition,
46
+ language: h.metadata.language,
47
+ clause_anchor: h.metadata.clause_anchor,
48
+ clause_title: h.metadata.clause_title,
49
+ status: h.metadata.status ?? "unknown",
50
+ superseded_by: h.metadata.superseded_by || undefined,
51
+ text: h.text,
52
+ score: h.rerank_score ?? h.score,
53
+ }));
54
+ telemetry(env, ctx, tier, "search", MODELS.embed, true, 0, await sha256Hex(q.query), q.lang);
55
+ return json({ results, filters, quota, ...corsHeaders(req) });
56
+ }
@@ -0,0 +1,25 @@
1
+ // Filter types + Vectorize filter conversion for the LLM understanding
2
+ // output. The regex filter floor that used to live here was removed:
3
+ // doc-number/edition/process-intent are SEMANTIC judgments about the
4
+ // user's query, and semantics are decided by query understanding
5
+ // (understanding.ts) — never by string matching. When understanding is
6
+ // unavailable, retrieval runs unfiltered rather than regex-filtered.
7
+
8
+ export interface QueryFilters {
9
+ doctype?: string;
10
+ doc_number?: string;
11
+ edition?: string;
12
+ language?: string;
13
+ }
14
+
15
+ export function toVectorizeFilter(f: QueryFilters): Record<string, string> | undefined {
16
+ // a pinned doc number is near-selective on its own; including doctype
17
+ // hides the dirty-corpus docs whose identifiers lost the series letter
18
+ // ("OIML 106"), and the reranker resolves R/D number collisions
19
+ if (f.doc_number) {
20
+ const out: Record<string, string> = { doc_number: f.doc_number };
21
+ if (f.edition) out.edition = f.edition;
22
+ return out; // language filter omitted: the index is English-only
23
+ }
24
+ return undefined;
25
+ }
@@ -0,0 +1,4 @@
1
+ // Compat re-export: the session implementation lives in
2
+ // workers/shared/session.ts (TODO.impl/16) — the import surface of this
3
+ // path is unchanged for worker_public's modules.
4
+ export * from "../../shared/session.ts";
@@ -0,0 +1,53 @@
1
+ // Public read-only shared conversations (TODO.rag/09).
2
+
3
+ import { LIMITS } from "./config";
4
+ import { json, err } from "./lib/http";
5
+
6
+ const SLUG_RE = /^[a-z0-9]{10}$/;
7
+
8
+ function makeSlug(): string {
9
+ const bytes = crypto.getRandomValues(new Uint8Array(8));
10
+ return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("").slice(0, 10);
11
+ }
12
+
13
+ export async function handleShareConversation(
14
+ env: any,
15
+ ownerSub: string,
16
+ title: string,
17
+ messages: any[],
18
+ ): Promise<Response> {
19
+ if (messages.length === 0 || messages.length > 100) {
20
+ return err(400, "invalid_input", "Cannot share an empty or oversized conversation");
21
+ }
22
+ // rate limit: 10 shares/day per user
23
+ const day = new Date().toISOString().slice(0, 10);
24
+ const count = Number((await env.CACHE.get(`sh:${day}:${ownerSub.slice(0, 20)}`)) ?? "0");
25
+ if (count >= 10) return err(429, "rate_limited", "Daily share limit reached");
26
+ await env.CACHE.put(`sh:${day}:${ownerSub.slice(0, 20)}`, String(count + 1), { expirationTtl: 90000 });
27
+
28
+ const slug = makeSlug();
29
+ const cleanMessages = messages.slice(0, 50).map((m: any) => ({
30
+ role: m.role === "user" ? "user" : "assistant",
31
+ content: (m.content ?? "").slice(0, LIMITS.maxOutputTokens * 2),
32
+ citations: m.citations ? JSON.parse(m.citations) : null,
33
+ }));
34
+ await env.DB.prepare(
35
+ "INSERT INTO shared_conversations (slug, owner_sub, title, messages, created_at) VALUES (?1,?2,?3,?4,?5)",
36
+ )
37
+ .bind(slug, ownerSub, title.slice(0, 120), JSON.stringify(cleanMessages), new Date().toISOString())
38
+ .run();
39
+ return json({ slug, url: `/c/${slug}` }, 201);
40
+ }
41
+
42
+ export async function handleGetShared(env: any, slug: string): Promise<Response> {
43
+ if (!SLUG_RE.test(slug)) return err(400, "invalid_input", "Invalid link");
44
+ const row = await env.DB.prepare("SELECT title, messages, created_at FROM shared_conversations WHERE slug = ?1")
45
+ .bind(slug)
46
+ .first();
47
+ if (!row) return err(404, "not_found", "This shared link has expired or was removed");
48
+ return json({
49
+ title: (row as any).title,
50
+ created_at: (row as any).created_at,
51
+ messages: JSON.parse((row as any).messages),
52
+ });
53
+ }
@@ -0,0 +1,68 @@
1
+ // Concept-graph lane (additive). The D1 projection (relaton structure +
2
+ // Glossarist defines edges) resolves what the query's WORDS map to in
3
+ // the corpus's own structure: a defined term → the documents that
4
+ // define it; a named publication → its family/successors. Same query
5
+ // vector, graph-filtered candidates — vocabulary mismatch stops
6
+ // mattering when the graph carries the link. Path A (rag#137): the
7
+ // concept link drives the SAME resolver — linked TERM → defining
8
+ // documents. Unlike the reverted candidate-family routing (every chunk
9
+ // of every candidate's family, which flooded the pool with the wrong
10
+ // domain), this is narrow: the definitional clauses of the linked
11
+ // concepts. "actual quantity" (defined by R 87) brings the prepackage
12
+ // domain in without the weigh-labeler families.
13
+ //
14
+ // NOTE: candidate-family pool routing was measured and REVERTED (rag#137
15
+ // experiment, 2026-09-05): the wrong-domain candidates' families (the
16
+ // labeler concepts, R 51/R 45) merged at the same discount and DILUTED
17
+ // the pool — canary fell 3/6 → 1/6. Candidate-level families cannot
18
+ // adjudicate domains; only the answer model can (it does, when the
19
+ // family's content reaches it).
20
+ import { THRESHOLDS } from "../config.ts";
21
+ import type { Stage } from "./types.ts";
22
+
23
+ export const conceptGraph: Stage = {
24
+ name: "concept-graph",
25
+ failure: "additive",
26
+ when: (c) => c.glossary.length > 0 && !!c.env.DB && c.vector.length > 0,
27
+ run: async (c) => {
28
+ const numbers = new Set<string>();
29
+ // the terms are independent — resolve them in PARALLEL; a serial loop
30
+ // paid up to 3 sequential D1 round trips on the hot path
31
+ const termRows = await Promise.all(
32
+ c.glossary
33
+ .slice(0, 3)
34
+ .filter((gl) => gl.term.length >= 3)
35
+ .map((gl) =>
36
+ c.env.DB.prepare(
37
+ "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",
38
+ )
39
+ .bind(gl.term, `%${gl.term}%`)
40
+ .all()
41
+ .catch(() => ({ results: [] })) as Promise<any>,
42
+ ),
43
+ );
44
+ for (const rows of termRows) {
45
+ for (const r of rows.results ?? []) {
46
+ const m = String(r.doc ?? "").match(/^doc:OIML-[A-Z]-(\d+)-/);
47
+ if (m) numbers.add(m[1]!);
48
+ }
49
+ }
50
+ if (numbers.size) {
51
+ const gc = await c.env.VECTORIZE.query(c.vector, {
52
+ topK: 12,
53
+ returnMetadata: "all",
54
+ filter: { doc_number: { $in: [...numbers] } },
55
+ });
56
+ const seenIds0 = new Set(c.matches.map((m: any) => m.id));
57
+ let merged0 = 0;
58
+ for (const m of (gc.matches ?? []).slice(0, 6)) {
59
+ if (!seenIds0.has(m.id)) {
60
+ c.matches.push({ id: m.id, score: m.score * THRESHOLDS.conceptGraphDiscount, metadata: m.metadata });
61
+ seenIds0.add(m.id);
62
+ merged0++;
63
+ }
64
+ }
65
+ if (merged0) console.log("concept graph:", [...numbers].join(","), "— merged", merged0);
66
+ }
67
+ },
68
+ };
@@ -0,0 +1,39 @@
1
+ // Concept-anchored steering (rag#137): the vocabulary link names the
2
+ // corpus's own concepts for the question's subject; their DEFINING
3
+ // publications get a small rerank boost — the same spread-scaled
4
+ // steering idiom as edition steering. Measured motivation: for
5
+ // everyday-goods phrasing the right family's chunks sit at dense rank 3
6
+ // and are then BURIED by the cross-encoder under instrument-markings
7
+ // vocabulary (R 111/R 76 "label" clauses outrank R 79's overview for
8
+ // "rules for the label on a bag of flour"). The concept link knows the
9
+ // subject's family ("actual quantity" → R 87, "Prepackage" → R 79); the
10
+ // boost is what lets that knowledge survive the cross-encoder. The
11
+ // answer model still adjudicates among the boosted families' passages.
12
+ import { THRESHOLDS } from "../config.ts";
13
+ import type { Stage } from "./types.ts";
14
+
15
+ export const conceptSteer: Stage = {
16
+ name: "concept-steer",
17
+ when: (c) => c.glossary.length > 0 && c.hits.length > 1,
18
+ run: (c) => {
19
+ const fams = new Set(c.glossary.map((g) => g.doc_number.split("-")[0]).filter(Boolean));
20
+ if (fams.size) {
21
+ const scored = c.hits.map((h) => h.rerank_score ?? h.score);
22
+ const spread = Math.max(...scored) - Math.min(...scored);
23
+ if (spread > 0) {
24
+ let boosted = 0;
25
+ for (const h of c.hits) {
26
+ const base = String(h.metadata.doc_number ?? "").split("-")[0];
27
+ if (fams.has(base)) {
28
+ h.rerank_score = (h.rerank_score ?? h.score) + spread * THRESHOLDS.conceptSteerSpread;
29
+ boosted++;
30
+ }
31
+ }
32
+ if (boosted) {
33
+ console.log("concept steering: +", boosted, "hits in", [...fams].join(","));
34
+ c.hits.sort((a, b) => (b.rerank_score ?? -Infinity) - (a.rerank_score ?? -Infinity));
35
+ }
36
+ }
37
+ }
38
+ },
39
+ };
@@ -0,0 +1,25 @@
1
+ // Dataset scope (the sidebar toggles): hits whose corpus lies outside
2
+ // the request's enabled datasets drop from the pool before the window
3
+ // is cut — disabling a database must actually disable it, not just hide
4
+ // its UI row. Runs LAST of the pool-assembly stages: after lexicalRrf,
5
+ // which unions the lexical lane's separate hit list into the pool AFTER
6
+ // rerank (filtering earlier let lexical hits back in — measured). Corpora
7
+ // the toggle model doesn't name (serving lanes, unknown values) pass
8
+ // untouched: the toggles name DATASETS, not lanes.
9
+ import type { Stage } from "./types.ts";
10
+
11
+ const DATASET_CORPORA = new Set(["oiml", "dirty", "clean", "synthetic", "smart-model", "iso-internal"]);
12
+
13
+ export const corpusScope: Stage = {
14
+ name: "corpus-scope",
15
+ when: (c) => !!c.opts.datasetScope && c.opts.datasetScope.size > 0,
16
+ run: (c) => {
17
+ const before = c.hits.length;
18
+ c.hits = c.hits.filter((h) => {
19
+ const corpus = h.metadata.corpus;
20
+ if (!corpus || !DATASET_CORPORA.has(corpus)) return true;
21
+ return c.opts.datasetScope!.has(corpus);
22
+ });
23
+ if (c.hits.length !== before) console.log("corpus scope:", before, "→", c.hits.length, "candidates");
24
+ },
25
+ };
@@ -0,0 +1,10 @@
1
+ // Same-chain near-duplicate collapse (FABLE ancestor-descendant dedup).
2
+ import { ancestorDescendantDedup } from "../structural.ts";
3
+ import type { Stage } from "./types.ts";
4
+
5
+ export const dedup: Stage = {
6
+ name: "dedup",
7
+ run: (c) => {
8
+ c.finalHits = ancestorDescendantDedup(c.finalHits);
9
+ },
10
+ };
@@ -0,0 +1,73 @@
1
+ // Dense lane (blocking): the primary Vectorize query over the folded
2
+ // retrieval query. Three shapes: Option C optimistic reuse (identical
3
+ // query, no filter — bit-for-bit same candidates, one serial round-trip
4
+ // saved), the doc/edition-filtered query with its guessed-pin drop and
5
+ // sparse-filter widen, and the plain unfiltered query.
6
+ import { LIMITS } from "../config.ts";
7
+ import { toVectorizeFilter } from "../selfquery.ts";
8
+ import type { Stage } from "./types.ts";
9
+
10
+ export const dense: Stage = {
11
+ name: "dense",
12
+ run: async (c) => {
13
+ const { env, filter, filters, vector, opts, rq, folded } = c;
14
+ const q: any = { topK: LIMITS.retrieveK, returnMetadata: "all" };
15
+ if (filter) q.filter = filter;
16
+ const optimistic = opts.optimisticHits ?? [];
17
+ const sameLane = rq === folded; // optimistic vector === this lane's vector
18
+ if (!filter && sameLane && optimistic.length) {
19
+ // Option C fast path: the unfiltered dense query already ran
20
+ // concurrently with understanding — same vector, same topK, no
21
+ // filter. Reusing it skips one serial Vectorize round-trip with a
22
+ // bit-for-bit identical candidate set.
23
+ c.matches = optimistic.map((h) => ({ id: h.id, score: h.score, metadata: h.metadata }));
24
+ console.log("optimistic lane: reused", c.matches.length, "dense hits (no re-query)");
25
+ // NOTE: when rq diverged (standalone_query / override) the
26
+ // optimistic hits are deliberately NOT unioned. Measured
27
+ // 2026-08-30: injecting the raw question's top-50 into a
28
+ // rewritten query's pool let topically close but wrong documents
29
+ // outscore the correct ones under the cross-encoder — recall@5
30
+ // fell 94.3% → 89.7% (golden ×3). The optimistic lane may only
31
+ // REPLACE an identical query, never dilute a better one.
32
+ return;
33
+ }
34
+ if (filter) {
35
+ let matches: any[] = (await env.VECTORIZE.query(vector, q)).matches ?? [];
36
+ // an edition pin corroborated by (almost) nothing means the pin
37
+ // was a guess — understanding emits editions for families it
38
+ // mixes up (observed: R 76 pinned @2021, an R 60 year; the corpus
39
+ // holds 1988/1992/2006). The index is the ground truth for which
40
+ // editions EXIST: a wrong pin starves the doc filter and the
41
+ // widen then floods the pool with superseded editions. Drop to
42
+ // the doc-only filter and let family-relative steering rank
43
+ // editions downstream. A pin the user actually asked for
44
+ // survives — its edition exists in the corpus.
45
+ if (filters && filters.edition && matches.length < 3) {
46
+ const docOnly = await env.VECTORIZE.query(vector, {
47
+ topK: LIMITS.retrieveK,
48
+ returnMetadata: "all",
49
+ filter: toVectorizeFilter({ doc_number: filters.doc_number }),
50
+ });
51
+ if ((docOnly.matches ?? []).length > matches.length) {
52
+ console.log("edition pin dropped:", filters.doc_number, "@", filters.edition, "→", docOnly.matches?.length ?? 0, "doc-scoped hits (edition not in corpus)");
53
+ matches = docOnly.matches ?? [];
54
+ filters.edition = undefined;
55
+ }
56
+ }
57
+ if (matches.length < LIMITS.rerankKeep) {
58
+ // sparse doc filter → widen with the unfiltered ranking. Same
59
+ // lane: the optimistic results ARE that ranking (identical
60
+ // vector, no filter) — reuse them; otherwise re-query with this
61
+ // lane's vector.
62
+ const unfiltered = sameLane && optimistic.length
63
+ ? optimistic.map((h) => ({ id: h.id, score: h.score, metadata: h.metadata }))
64
+ : (await env.VECTORIZE.query(vector, { topK: LIMITS.retrieveK, returnMetadata: "all" })).matches ?? [];
65
+ const seen = new Set(matches.map((m: any) => m.id));
66
+ matches = [...matches, ...unfiltered.filter((m: any) => !seen.has(m.id))];
67
+ }
68
+ c.matches = matches;
69
+ return;
70
+ }
71
+ c.matches = (await env.VECTORIZE.query(vector, q)).matches ?? [];
72
+ },
73
+ };
@@ -0,0 +1,33 @@
1
+ // Per-publication diversity, keyed by normalized identity: overview
2
+ // chunks are near-duplicates across editions — at most ONE per
3
+ // publication; clause chunks get a higher cap so content can fill slots.
4
+ import { LIMITS } from "../config.ts";
5
+ import type { Stage } from "./types.ts";
6
+
7
+ export const diversity: Stage = {
8
+ name: "diversity",
9
+ run: (c) => {
10
+ const filters = c.filters;
11
+ const perDoc = new Map<string, number>();
12
+ let overviews = 0;
13
+ const diversified = [];
14
+ for (const h of c.hits) {
15
+ const isOverview = h.metadata.clause_anchor === "overview";
16
+ // a doc-number query matches every part (R 60-1/-2/Annexe A) —
17
+ // without a global overview cap their near-identical overviews
18
+ // crowd out the definition and clause chunks the answer needs
19
+ const ovCap = filters?.doc_number ? 6 : 2; // part overviews of the queried family are signal, not noise
20
+ if (isOverview && overviews >= ovCap) continue;
21
+ const key = `${h.metadata.docidentifier}|${h.metadata.language}`;
22
+ const n = perDoc.get(key) ?? 0;
23
+ const cap = isOverview ? 1 : filters?.doc_number ? 3 : 2;
24
+ if (n < cap) {
25
+ diversified.push(h);
26
+ perDoc.set(key, n + 1);
27
+ if (isOverview) overviews += 1;
28
+ }
29
+ if (diversified.length >= LIMITS.rerankKeep + 2) break;
30
+ }
31
+ c.finalHits = diversified.slice(0, LIMITS.rerankKeep);
32
+ },
33
+ };
@@ -0,0 +1,63 @@
1
+ // Edition cover (the l6a guarantee): when a pool holds ONLY stale
2
+ // editions of a document whose ACTIVE edition the registry knows, fetch
3
+ // the current edition's chunks and add them at a discount — the current
4
+ // edition must be ABLE to win: edition steering demotes stale siblings
5
+ // only when the successor is in the pool ("R 60:2017 answers 'which
6
+ // edition applies'" was the observed failure under rerank draws that
7
+ // left every 2021 chunk out). One registry read per query; at most
8
+ // `maxDocs` documents covered; additive — any failure skips.
9
+ import { THRESHOLDS } from "../config.ts";
10
+ import { toHits, type Stage } from "./types.ts";
11
+
12
+ const maxDocs = 2;
13
+
14
+ const familyOf = (di: string): string | null => {
15
+ const m = /^(?:OIML\s+)?([A-Z])\s?(\d{1,3})(?:[-–]([0-9A-Za-z]+))?/.exec(di);
16
+ return m ? `${m[1]}-${m[2]}` : null;
17
+ };
18
+
19
+ export const editionCover: Stage = {
20
+ name: "edition-cover",
21
+ failure: "additive",
22
+ when: (c) => !c.filters?.edition && c.hits.length > 1,
23
+ run: async (c) => {
24
+ const poolDocs = new Map<string, Set<string>>();
25
+ for (const h of c.hits) {
26
+ const di = h.metadata.docidentifier;
27
+ const ed = h.metadata.edition;
28
+ if (!di || !ed) continue;
29
+ if (!poolDocs.has(di)) poolDocs.set(di, new Set());
30
+ poolDocs.get(di)!.add(ed);
31
+ }
32
+ const families = [...new Set([...poolDocs.keys()].map(familyOf).filter(Boolean))] as string[];
33
+ if (!families.length) return;
34
+ const ph = families.map(() => "?").join(",");
35
+ const rows = (await c.env.DB.prepare(`SELECT docidentifier, edition FROM documents WHERE family IN (${ph}) AND active = 1`).bind(...families).all()).results ?? [];
36
+ // registry identifiers may carry the edition suffix; chunks never do
37
+ const want: Array<{ di: string; edition: string }> = [];
38
+ for (const r of rows as any[]) {
39
+ const di = String(r.docidentifier ?? "").replace(/:\d{4}$/, "");
40
+ const ed = String(r.edition ?? "");
41
+ if (di && /^\d{4}$/.test(ed) && poolDocs.has(di) && !poolDocs.get(di)!.has(ed)) want.push({ di, edition: ed });
42
+ }
43
+ if (!want.length) return;
44
+ const top = Math.max(...c.hits.map((h) => h.score));
45
+ let added = 0;
46
+ for (const w of want.slice(0, maxDocs)) {
47
+ try {
48
+ const q = await c.env.VECTORIZE.query(c.vector, {
49
+ topK: 3,
50
+ returnMetadata: "all",
51
+ filter: { $and: [{ docidentifier: { $eq: w.di } }, { edition: { $eq: w.edition } }] },
52
+ });
53
+ const hits = toHits((q as any).matches ?? []).map((h) => ({ ...h, score: top * THRESHOLDS.editionCoverDiscount }));
54
+ c.hits.push(...hits);
55
+ added += hits.length;
56
+ console.log("edition cover:", w.di, w.edition, `+${hits.length}`);
57
+ } catch {
58
+ // additive — retrieval stands without the cover
59
+ }
60
+ }
61
+ if (added) c.hits.sort((a, b) => b.score - a.score);
62
+ },
63
+ };