@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,252 @@
1
+ // Auth routes: OIDC authorization-code + PKCE against the estate OP
2
+ // (id.oimlsmart.org), per docs/identity-service.md. The OP declares WHO
3
+ // the user is; quota tiers (what they may do here) are ours.
4
+
5
+ import {
6
+ buildAuthorizationUrl,
7
+ buildEndSessionUrl,
8
+ discoverIssuer,
9
+ exchangeCode,
10
+ generatePkce,
11
+ randomToken,
12
+ validateIdToken,
13
+ fetchUserinfo,
14
+ OidcError,
15
+ } from "./oidc";
16
+ import { clearSessionCookie, mintSessionCookie, mintSessionToken, rawSessionToken, readSession, sessionCookieFromToken, SessionClaims } from "./session";
17
+ import { bubbleConfirmPage, isAllowedBubbleOrigin } from "./bubble";
18
+ import { dropOpAccessToken, retainOpAccessToken } from "./livedata";
19
+
20
+ const PLAIN_LANGUAGE: Record<string, string> = {
21
+ not_configured: "Sign-in is not configured for this service yet.",
22
+ discovery: "The sign-in service could not be reached. Please try again shortly.",
23
+ issuer_mismatch: "The sign-in service answered from an unexpected address. Sign-in was refused.",
24
+ exchange: "The sign-in service refused the sign-in. Please try again.",
25
+ state: "That sign-in link has expired. Please start again.",
26
+ token_malformed: "The sign-in service returned an unreadable token. Please try again.",
27
+ token_alg: "The sign-in service returned a token in an unsupported format.",
28
+ token_signature: "The sign-in token could not be verified. Sign-in was refused.",
29
+ token_issuer: "The sign-in token was issued by an unexpected party. Sign-in was refused.",
30
+ token_audience: "The sign-in token was not issued for this service. Sign-in was refused.",
31
+ token_expired: "The sign-in window expired. Please sign in again.",
32
+ token_nonce: "The sign-in response failed its replay check. Please sign in again.",
33
+ origin_not_allowed: "That site may not connect the assistant to your account.",
34
+ };
35
+
36
+ function authErrorText(reason: string): string {
37
+ return PLAIN_LANGUAGE[reason] ?? "Sign-in failed. Please try again.";
38
+ }
39
+
40
+ // Two tiers only: anonymous (public index) and member (both indexes).
41
+ // Estate roles (mc_member, etc.) are identity claims, NOT access gates
42
+ // here — any authenticated user is a member.
43
+ export interface AuthConfig {
44
+ issuer: string;
45
+ clientId: string;
46
+ redirectUri: string;
47
+ sessionSecret: string;
48
+ }
49
+
50
+ function authConfig(env: any): AuthConfig | null {
51
+ const issuer = env.OIDC_ISSUER ?? "https://id.oimlsmart.org";
52
+ const clientId = env.OIDC_CLIENT_ID;
53
+ const redirectUri = env.OIDC_REDIRECT_URI ?? "https://ai.oimlsmart.org/auth/callback";
54
+ const sessionSecret = env.SESSION_SECRET;
55
+ if (!clientId || !sessionSecret) return null;
56
+ return { issuer, clientId, redirectUri, sessionSecret };
57
+ }
58
+
59
+ const redirectWithError = (reason: string) =>
60
+ new Response(null, {
61
+ status: 302,
62
+ headers: { location: `/?auth_error=${reason}&auth_msg=${encodeURIComponent(authErrorText(reason))}` },
63
+ });
64
+
65
+ export async function handleLogin(env: any, req: Request): Promise<Response> {
66
+ const cfg = authConfig(env);
67
+ if (!cfg) return redirectWithError("not_configured");
68
+ // Bubble bridge (bubble.ts): the embedded panel's sign-in. The origin
69
+ // is validated NOW, at flow start, and bound to the state — the
70
+ // callback hands the session token to exactly that origin, never to
71
+ // wherever the request happens to come from later.
72
+ const url0 = new URL(req.url);
73
+ const bubbleMode = url0.searchParams.get("mode") === "bubble";
74
+ const bubbleOrigin = url0.searchParams.get("origin") ?? "";
75
+ if (bubbleMode && !isAllowedBubbleOrigin(bubbleOrigin)) return redirectWithError("origin_not_allowed");
76
+ try {
77
+ const meta = await discoverIssuer(cfg.issuer);
78
+ const state = randomToken();
79
+ const nonce = randomToken();
80
+ const pkce = await generatePkce();
81
+ await env.CACHE.put(
82
+ `oa:${state}`,
83
+ JSON.stringify({ nonce, verifier: pkce.verifier, ...(bubbleMode ? { mode: "bubble", origin: bubbleOrigin } : {}) }),
84
+ {
85
+ expirationTtl: 600,
86
+ },
87
+ );
88
+ const url = buildAuthorizationUrl(meta, {
89
+ clientId: cfg.clientId,
90
+ redirectUri: cfg.redirectUri,
91
+ scopes: "openid profile email roles",
92
+ state,
93
+ nonce,
94
+ codeChallenge: pkce.challenge,
95
+ });
96
+ return new Response(null, { status: 302, headers: { location: url } });
97
+ } catch (e) {
98
+ return redirectWithError(e instanceof OidcError ? e.reason : "discovery");
99
+ }
100
+ }
101
+
102
+ export async function handleCallback(env: any, req: Request): Promise<Response> {
103
+ const cfg = authConfig(env);
104
+ if (!cfg) return redirectWithError("not_configured");
105
+ const url = new URL(req.url);
106
+ const opError = url.searchParams.get("error");
107
+ if (opError) {
108
+ // the OP redirected back with its own failure (user denied consent,
109
+ // session expired at the OP, …) — fail closed, in plain language
110
+ const msg =
111
+ opError === "access_denied"
112
+ ? "Sign-in was cancelled."
113
+ : opError === "temporarily_unavailable"
114
+ ? "The sign-in service is busy. Please try again in a moment."
115
+ : "The sign-in service reported a problem. Please try again.";
116
+ return new Response(null, {
117
+ status: 302,
118
+ headers: { location: `/?auth_error=${encodeURIComponent(opError)}&auth_msg=${encodeURIComponent(msg)}` },
119
+ });
120
+ }
121
+ const code = url.searchParams.get("code") ?? "";
122
+ const state = url.searchParams.get("state") ?? "";
123
+ if (!code || !state) return redirectWithError("state");
124
+
125
+ const stored = await env.CACHE.get(`oa:${state}`, "json");
126
+ if (!stored) return redirectWithError("state");
127
+ // one-time: the state is consumed whether or not the exchange succeeds
128
+ await env.CACHE.delete(`oa:${state}`);
129
+
130
+ try {
131
+ const meta = await discoverIssuer(cfg.issuer);
132
+ const token = await exchangeCode(meta, {
133
+ clientId: cfg.clientId,
134
+ clientSecret: env.OIDC_CLIENT_SECRET,
135
+ code,
136
+ redirectUri: cfg.redirectUri,
137
+ codeVerifier: stored.verifier,
138
+ });
139
+ const claims = await validateIdToken(token.id_token, {
140
+ issuer: cfg.issuer,
141
+ clientId: cfg.clientId,
142
+ nonce: stored.nonce,
143
+ jwksUri: meta.jwks_uri,
144
+ });
145
+ const roles = Array.isArray(claims.roles) ? claims.roles.map(String) : [];
146
+ // `picture` is a userinfo claim on this OP (the ID token carries the
147
+ // profile contract only) — fall back to userinfo, sub-checked, when
148
+ // the token happens to omit it
149
+ let picture = typeof claims.picture === "string" ? claims.picture : undefined;
150
+ if (!picture && typeof token.access_token === "string") {
151
+ const ui = await fetchUserinfo(meta, token.access_token);
152
+ if (ui.sub === claims.sub && typeof ui.picture === "string" && ui.picture) picture = ui.picture;
153
+ }
154
+ const sessionClaims = {
155
+ sub: claims.sub,
156
+ name: typeof claims.name === "string" ? claims.name : undefined,
157
+ email: typeof claims.email === "string" ? claims.email : undefined,
158
+ picture,
159
+ roles,
160
+ };
161
+ // Mint ONCE — the cookie and the bubble's Bearer carry the same
162
+ // session token, so the live-data window (TODO.ai-platform/03) keyed
163
+ // off it holds for both presentations.
164
+ const session = await mintSessionToken(cfg.sessionSecret, sessionClaims);
165
+ const cookie = sessionCookieFromToken(session.token);
166
+ // TODO.ai-platform/03: retain the OP access token for the session's
167
+ // exchange window (the "my account" live-data delegation exchanges
168
+ // it per the identity service's RFC 8693 §9b) — KV only, TTL = the
169
+ // OP token's own life, never D1, never past the window.
170
+ if (typeof token.access_token === "string" && typeof token.expires_in === "number") {
171
+ await retainOpAccessToken(env, session.token, token.access_token, token.expires_in);
172
+ }
173
+ // Bubble bridge: hand the session to the embedded panel as a Bearer
174
+ // token via the confirm page — postMessage to the validated origin
175
+ // ONLY, and only on the user's explicit click (bubble.ts). The
176
+ // cookie still sets, so ai.oimlsmart.org itself is signed in too.
177
+ if (stored.mode === "bubble" && typeof stored.origin === "string" && isAllowedBubbleOrigin(stored.origin)) {
178
+ return new Response(
179
+ bubbleConfirmPage({
180
+ name: sessionClaims.name ?? sessionClaims.email ?? "member",
181
+ origin: stored.origin,
182
+ token: session.token,
183
+ expiresAt: session.expiresAt,
184
+ }),
185
+ { status: 200, headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "set-cookie": cookie } },
186
+ );
187
+ }
188
+ return new Response(null, { status: 302, headers: { location: "/", "set-cookie": cookie } });
189
+ } catch (e) {
190
+ if (e instanceof OidcError) console.error("auth callback:", e.reason, "—", e.message.slice(0, 200));
191
+ return redirectWithError(e instanceof OidcError ? e.reason : "exchange");
192
+ }
193
+ }
194
+
195
+ export async function sessionFrom(req: Request, env: any): Promise<SessionClaims | null> {
196
+ return readSession(req, authConfig(env)?.sessionSecret);
197
+ }
198
+
199
+ export async function handleMe(env: any, req: Request): Promise<Response> {
200
+ const cfg = authConfig(env);
201
+ const session = cfg ? await readSession(req, cfg.sessionSecret) : null;
202
+ const headers: Record<string, string> = { "content-type": "application/json" };
203
+ // sliding renewal: a session older than a day re-mints on sight, so
204
+ // active users never hit the 7-day wall mid-conversation
205
+ if (session && cfg && Date.now() - session.iat > 24 * 3600 * 1000) {
206
+ headers["set-cookie"] = await mintSessionCookie(cfg.sessionSecret, {
207
+ sub: session.sub,
208
+ name: session.name,
209
+ email: session.email,
210
+ picture: session.picture,
211
+ roles: session.roles,
212
+ });
213
+ }
214
+ return new Response(
215
+ JSON.stringify({
216
+ authenticated: !!session,
217
+ name: session?.name ?? null,
218
+ email: session?.email ?? null,
219
+ picture: session?.picture ?? null,
220
+ roles: session?.roles ?? [],
221
+ tier: session ? "member" : "anon",
222
+ sign_in_available: !!cfg,
223
+ }),
224
+ { headers },
225
+ );
226
+ }
227
+
228
+ export async function handleLogout(env: any, req: Request): Promise<Response> {
229
+ const cfg = authConfig(env);
230
+ const headers: Record<string, string> = { "set-cookie": clearSessionCookie() };
231
+ // The live-data window closes WITH the session (TODO.ai-platform/03 —
232
+ // deliberately, never by the TTL alone).
233
+ const presented = rawSessionToken(req);
234
+ if (presented) await dropOpAccessToken(env, presented);
235
+ if (cfg) {
236
+ try {
237
+ const meta = await discoverIssuer(cfg.issuer);
238
+ const end = buildEndSessionUrl(meta, {
239
+ clientId: cfg.clientId,
240
+ postLogoutRedirectUri: env.OIDC_REDIRECT_URI ?? "https://ai.oimlsmart.org/",
241
+ });
242
+ if (end) {
243
+ headers.location = end;
244
+ return new Response(null, { status: 302, headers });
245
+ }
246
+ } catch {
247
+ // OP-side logout is best-effort; the local session is already cleared
248
+ }
249
+ }
250
+ headers.location = "/";
251
+ return new Response(null, { status: 302, headers });
252
+ }
@@ -0,0 +1,111 @@
1
+ // The bubble bridge (TODO.ai-platform/01 in the smart repo): the
2
+ // assistant panel the shared chrome (@oimlsmart/site-shell's AiBubble)
3
+ // embeds in every estate property cannot ride the rag_session cookie —
4
+ // SameSite=Lax never crosses origins on fetch, and the estate bans
5
+ // shared-domain cookies (the SSO doctrine, identity guide §8a: every
6
+ // property keeps its OWN session, established by its own OP round-trip).
7
+ //
8
+ // The bridge: the panel opens /auth/login?mode=bubble&origin=<its own
9
+ // origin> in a popup. The standard OIDC code+PKCE dance runs unchanged
10
+ // (the OP session makes it one click); the callback then renders a
11
+ // CONFIRM page — never auto-sends — that postMessages the session token
12
+ // (the same HMAC-signed payload the cookie carries) to the requesting
13
+ // origin only. The panel holds the token in memory (sessionStorage
14
+ // survives reloads) and calls the API with `Authorization: Bearer`.
15
+ // The token is the service's OWN session artifact — it can act on this
16
+ // service only, never on the OP or other properties.
17
+
18
+ /** The origins a bubble may ride from: the estate pattern (the same rule
19
+ * corsHeaders applies) plus localhost for the local dev posture. */
20
+ export function isAllowedBubbleOrigin(origin: string): boolean {
21
+ if (origin === "https://oimlsmart.org") return true;
22
+ if (/^https:\/\/[a-z0-9-]+(\.[a-z0-9-]+)*\.oimlsmart\.org$/.test(origin)) return true;
23
+ if (/^http:\/\/localhost(:\d{1,5})?$/.test(origin)) return true;
24
+ if (/^http:\/\/127\.0\.0\.1(:\d{1,5})?$/.test(origin)) return true;
25
+ return false;
26
+ }
27
+
28
+ function escapeHtml(s: string): string {
29
+ return s
30
+ .replace(/&/g, "&amp;")
31
+ .replace(/</g, "&lt;")
32
+ .replace(/>/g, "&gt;")
33
+ .replace(/"/g, "&quot;");
34
+ }
35
+
36
+ /** The confirm page the callback renders in bubble mode. Static inline
37
+ * HTML+JS, everything escaped, targetOrigin = the validated origin
38
+ * (never "*"). The token leaves ONLY on the user's explicit Continue —
39
+ * a popup the user never asked for (a page opening it under the OP's
40
+ * SSO session) shows the request and stops here. */
41
+ export function bubbleConfirmPage(opts: { name: string; origin: string; token: string; expiresAt: number }): string {
42
+ const who = escapeHtml(opts.name);
43
+ const host = escapeHtml(new URL(opts.origin).host);
44
+ // JSON embedded in a <script> block: escape `<` so a claim value can
45
+ // never close the element early (</script> breakout).
46
+ const jsSafe = (v: unknown) => JSON.stringify(v).replace(/</g, "\\u003c");
47
+ const payload = jsSafe({
48
+ type: "oimlsmart-ai-session",
49
+ token: opts.token,
50
+ name: opts.name,
51
+ expiresAt: opts.expiresAt,
52
+ });
53
+ return `<!DOCTYPE html>
54
+ <html lang="en">
55
+ <head>
56
+ <meta charset="utf-8" />
57
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
58
+ <meta name="robots" content="noindex" />
59
+ <title>OIML SMART AI — sign in</title>
60
+ <style>
61
+ :root { color-scheme: light dark; }
62
+ body { font-family: ui-sans-serif, system-ui, sans-serif; margin: 0; padding: 2rem 1.25rem;
63
+ background: #faf6ee; color: #0a1628; line-height: 1.5; }
64
+ main { max-width: 26rem; margin: 0 auto; }
65
+ h1 { font-size: 1.15rem; margin: 0 0 0.75rem; }
66
+ p { margin: 0 0 1rem; font-size: 0.925rem; }
67
+ .who { font-weight: 600; }
68
+ .row { display: flex; gap: 0.75rem; margin-top: 1.25rem; }
69
+ button { flex: 1; min-height: 44px; font: inherit; font-weight: 600; border-radius: 6px; cursor: pointer; }
70
+ .go { background: #004996; color: #fff; border: 1px solid #004996; }
71
+ .no { background: transparent; color: inherit; border: 1px solid #ddd2bd; }
72
+ @media (prefers-color-scheme: dark) {
73
+ body { background: #0a1628; color: #f5efe4; }
74
+ .go { background: #89b4ef; color: #001230; border-color: #89b4ef; }
75
+ .no { border-color: #1f3357; }
76
+ }
77
+ </style>
78
+ </head>
79
+ <body>
80
+ <main>
81
+ <h1>Continue to the OIML SMART AI assistant?</h1>
82
+ <p>Signed in as <span class="who">${who}</span>. The page at <span class="who">${host}</span>
83
+ asked to connect the assistant to your account, so your conversations sync there.</p>
84
+ <p>The assistant can read the public OIML corpus and your own assistant conversations — nothing else.</p>
85
+ <div class="row">
86
+ <button type="button" class="no" id="cancel">Cancel</button>
87
+ <button type="button" class="go" id="go">Continue</button>
88
+ </div>
89
+ <p id="done" hidden>You can close this window.</p>
90
+ </main>
91
+ <script>
92
+ var TARGET = ${jsSafe(opts.origin)};
93
+ var PAYLOAD = ${payload};
94
+ var done = document.getElementById("done");
95
+ document.getElementById("cancel").addEventListener("click", function () { window.close(); done.hidden = false; });
96
+ document.getElementById("go").addEventListener("click", function () {
97
+ if (window.opener) {
98
+ window.opener.postMessage(PAYLOAD, TARGET);
99
+ window.close();
100
+ }
101
+ done.hidden = false;
102
+ });
103
+ if (!window.opener) {
104
+ document.querySelector(".row").hidden = true;
105
+ document.querySelector("h1").textContent = "Signed in.";
106
+ done.hidden = false;
107
+ }
108
+ </script>
109
+ </body>
110
+ </html>`;
111
+ }
@@ -0,0 +1,75 @@
1
+ // Contract completion (MECE: ask.ts orchestrates, this module owns the
2
+ // two post-generation completions). Both heal the same gap — the answer
3
+ // references normative objects that contractV2 did not (or could not)
4
+ // resolve into blocks — using the producer's own payloads:
5
+ //
6
+ // table completion: the answer presents a numeric value from a table
7
+ // in the answer's document family → resolve and attach that table
8
+ // figure completion (#172): the model names figure units in PROSE as
9
+ // often as in [[u:…]] tokens, and token refs whose unit wasn't in
10
+ // the used passages drop — either way the answer shows no figure
11
+ // while the payload and asset exist; every u:fig mention in the
12
+ // final text (tokenized or not) resolves from D1
13
+ //
14
+ // Additive throughout: any failure returns what it has.
15
+ import type { StoreQuery } from "./ports/store.ts";
16
+ import { resolveBlocks, type ResolvedBlock } from "./refs.ts";
17
+ import type { Hit } from "./pipeline.ts";
18
+
19
+ export async function completeTables(
20
+ db: StoreQuery,
21
+ answer: string,
22
+ used: Hit[],
23
+ ): Promise<ResolvedBlock[]> {
24
+ const blocks: ResolvedBlock[] = [];
25
+ try {
26
+ const answerNums = new Set((answer.match(/\d[\d ,.]{1,8}\d/g) ?? []).map((x) => x.replace(/[ ,.]/g, "")));
27
+ if (!answerNums.size) return blocks;
28
+ // match by docidentifier (the chunks and unit_payloads use different
29
+ // doc_id schemes — 'dirty:r60-1-2006-eng' vs 'mko:oiml-r-60-1' — but
30
+ // both carry the publication name)
31
+ const fams = [...new Set(used.map((h) => h.metadata.docidentifier).filter(Boolean))].slice(0, 3);
32
+ for (const fam of fams) {
33
+ const base = String(fam).replace(/\s*\([A-Z]\)\s*$/, "").split(":")[0].trim();
34
+ const rows = await db
35
+ .prepare("SELECT unit_id, payload FROM unit_payloads WHERE type = 'table' AND docidentifier LIKE ?1 LIMIT 8")
36
+ .bind(`%${base}%`)
37
+ .all<{ unit_id: string; payload: string }>();
38
+ for (const r of rows.results ?? []) {
39
+ const tableNums = new Set((String(r.payload).match(/\d[\d ,.]{1,8}\d/g) ?? []).map((x) => x.replace(/[ ,.]/g, "")));
40
+ let hits = 0;
41
+ for (const n of answerNums) if (tableNums.has(n)) hits++;
42
+ if (hits >= 1) {
43
+ const resolved = await resolveBlocks(db, [r.unit_id]);
44
+ blocks.push(...resolved);
45
+ console.log("contract D1 completion: table", r.unit_id, "in", base, "—", hits, "matching values");
46
+ break;
47
+ }
48
+ }
49
+ if (blocks.length) break;
50
+ }
51
+ } catch {
52
+ // additive; primary results stand
53
+ }
54
+ return blocks;
55
+ }
56
+
57
+ export async function completeFigures(
58
+ db: StoreQuery,
59
+ answer: string,
60
+ alreadyAttached: ResolvedBlock[],
61
+ ): Promise<ResolvedBlock[]> {
62
+ // the model names figures BOTH ways: unit ids (u:fig-3) and bare
63
+ // producer anchors (fig-2a of D 36) — collect both forms; bare
64
+ // anchors get the unit prefix and D1 resolution drops every candidate
65
+ // that is not a real unit (a bare mention can never fabricate a block)
66
+ const unitForm = (answer.match(/u:fig[\w.-]*/g) ?? []).map((x) => x.replace(/[.,;:)]+$/, ""));
67
+ const bareForm = (answer.match(/\bfig-[\w.-]+\b/g) ?? []).map((x) => x.replace(/[.,;:)]+$/, ""));
68
+ const mentioned = [...new Set([...unitForm, ...bareForm.map((x) => (x.startsWith("u:") ? x : "u:" + x))])].slice(0, 6);
69
+ const have = new Set(alreadyAttached.map((b) => b.unit_id));
70
+ const missing = mentioned.filter((id) => !have.has(id));
71
+ if (!missing.length) return [];
72
+ const figs = await resolveBlocks(db, missing);
73
+ if (figs.length) console.log("figure completion:", figs.map((b) => b.unit_id).join(", "), "attached from D1");
74
+ return figs;
75
+ }
@@ -0,0 +1,238 @@
1
+ import { P } from "./profile.ts";
2
+
3
+ export const MODELS = {
4
+ embed: "@cf/qwen/qwen3-embedding-0.6b", rerank: "@cf/baai/bge-reranker-base",
5
+ // ANSWER MODEL (all tiers, user decision 2026-08-29): glm-5.3-flash —
6
+ // natively multimodal (vision-unified contract), GLM family, flash tier.
7
+ // Vision image-parts land with answer contract v2; text answers work now.
8
+ anon: "@cf/zai-org/glm-5.3-flash",
9
+ member: "@cf/zai-org/glm-5.3-flash",
10
+ // hot-path understanding/summarize stays on the cheap Qwen (cost-first lane)
11
+ understand: "@cf/qwen/qwen3-30b-a3b-fp8",
12
+ // generation fallback when the answer model is unavailable
13
+ fallback: "@cf/qwen/qwen3-30b-a3b-fp8",
14
+ grader: "@cf/deepseek-ai/deepseek-v4-flash-0731", // CRAG grader + judges (unsuffixed slug was retired → silent 5018s)
15
+ // contextual enrichment is the QUALITY-FIRST lane (one-time spend over
16
+ // the corpus, its quality persists into every future retrieval)
17
+ enrich: "@cf/deepseek-ai/deepseek-v4-pro-0813",
18
+ // final-tier listwise reranker for hard/member queries (cascade:
19
+ // cross-encoder prunes, listwise orders jointly)
20
+ listwise: "@cf/zai-org/glm-4.7-flash",
21
+ // deep-research loop (G10): bounded agentic iterations, members-only
22
+ research: "@cf/zai-org/glm-5.3-flash",
23
+ } as const;
24
+
25
+ export const LIMITS = {
26
+ // input sizes: generous — real questions can be long (pasted scenarios,
27
+ // multi-part asks). The context BUDGET is the real governor of what the
28
+ // model sees; these caps only bound abuse.
29
+ maxInputChars: 8000,
30
+ maxOutputTokens: 3072, // qwen3-30b-a3b always reasons; 768 starved the answer entirely
31
+ retrieveK: 50, // Vectorize caps topK at 50 when returnMetadata=all
32
+ rerankKeep: 10, // 8 crowded out dirty-lane goldens once 3k enriched MKO clean chunks entered the pool
33
+ cacheTtlSec: 6 * 3600,
34
+ // context-window budget (estimated tokens) for the assembled prompt —
35
+ // system + summary + history slice + passages must fit or the model
36
+ // request fails. 16k is conservative for the qwen3 tier (pre-budget
37
+ // traffic at ~12k+ never hit a length error); raise via INPUT_TOKEN_BUDGET
38
+ // after watching logs for length rejections
39
+ inputTokenBudget: 16000,
40
+ maxPassageTokens: 900, // per-passage cap (clause chunks with tables can be huge)
41
+ } as const;
42
+
43
+ /** Tuned retrieval thresholds — every number the pipeline steers by.
44
+ * Each carries the measurement that justifies it; changing one without
45
+ * re-running the gates (golden ×3 + annealment ×6) is a guess. */
46
+ export const THRESHOLDS = {
47
+ /** HyDE candidate score discount — hypothetical-answer vectors match
48
+ * differently than question vectors; 0.7 keeps them competitive
49
+ * without letting a bad hypothetical outrank the real query. */
50
+ hydeDiscount: 0.7,
51
+ /** Graph-lane candidate discount — graph-filtered chunks enter the
52
+ * pool below the primary dense lane; they must earn their window
53
+ * slot under the cross-encoder, not by graph membership alone. */
54
+ graphLaneDiscount: 0.75,
55
+ /** Concept-graph candidate discount — same rationale as the graph
56
+ * lane: definitional content enters discounted. */
57
+ conceptGraphDiscount: 0.75,
58
+ /** Multi-hop sub-query discount — sub-question hits are unioned, not
59
+ * RRF-fused; the discount keeps them from dominating the primary
60
+ * ranking on their first appearance. */
61
+ subQueryDiscount: 0.8,
62
+ /** Federated-ISO discount — the public corpus answers by default;
63
+ * internal ISO passages compete but don't preempt. */
64
+ federateDiscount: 0.95,
65
+ /** Standard-reference nudge (L5): when the question asks about an
66
+ * invoked ISO/IEC standard, chunks that CARRY such a citation (their
67
+ * text contains an ISO/IEC identifier) get a spread-scaled boost —
68
+ * the citing clause is the answer, and generic family prose otherwise
69
+ * fills the window (measured: l5a-iso-humidity flips ~1/6 runs). */
70
+ stdRefNudgeSpread: 0.35,
71
+ /** Edition cover — the score multiplier for current-edition chunks the
72
+ * cover stage fetches when a pool holds ONLY stale editions of a
73
+ * document. Just under the top: the point is REPRESENTATION (so
74
+ * edition steering can demote the stale siblings and diversity keeps
75
+ * the current overview), not free ranking. */
76
+ editionCoverDiscount: 0.9,
77
+ /** Overview-chunk demotion — overview chunks repeat title/doctype
78
+ * boilerplate and embed strongly for name-like queries, crowding
79
+ * clause chunks out of the rerank window. */
80
+ overviewDemotion: 0.85,
81
+ /** Glossary-link cosine floor — below this the dense match to a
82
+ * defined term is noise, not a candidate. Calibrated on the
83
+ * drifting→durability probe (durability-def 0.542, noise ~0.3). */
84
+ glossaryCosineFloor: 0.5,
85
+ /** Exact-term nudge (spread multiplier) — clause chunks whose head IS
86
+ * the asked-for term get a decisive nudge because publication headers
87
+ * contain the title words and the reranker alone is unreliable there;
88
+ * >1 so it dominates the spread, unlike the additive steering boosts. */
89
+ termNudgeSpread: 1.5,
90
+ /** Concept-steering rerank boost (spread fraction) — the vocabulary
91
+ * link's defining families get the edition-steering boost idiom.
92
+ * 0.15 is enough to lift in-family content past the cross-encoder's
93
+ * vocabulary bias without overriding genuine relevance. */
94
+ conceptSteerSpread: 0.15,
95
+ /** Cross-publication recency boost (spread fraction) — a
96
+ * current-edition publication ranks over stale ones; composed with
97
+ * the family-relative demotion below. */
98
+ crossPubRecencySpread: 0.1,
99
+ /** Family-relative edition demotion (spread fraction) — superseded
100
+ * editions are demoted when a newer edition of the same publication
101
+ * is in the pool; a sibling one revision back still competes. */
102
+ familyDemoteSpread: 0.4,
103
+ /** Relevance-floored window — passages below this fraction of the
104
+ * top rerank score leave the window (the evidence-budget principle;
105
+ * structural units are exempt). */
106
+ windowFloorFraction: 0.25,
107
+ /** Small-to-big parent fetch discount — the parent clause enters at
108
+ * a discount because it's supplementary grounding, not the answer. */
109
+ smallToBigDiscount: 0.7,
110
+ /** Section-descent child discount — children fetched from a ranked
111
+ * depth-1 summary enter discounted. */
112
+ sectionDescentDiscount: 0.8,
113
+ /** History budget share — the fraction of the context budget the
114
+ * conversation slice may consume; older turns overflow into the
115
+ * compacted summary instead of starving the passages. */
116
+ historyBudgetShare: 0.3,
117
+ } as const;
118
+
119
+ /** Process-expansion — appended to process-intent queries; publisher
120
+ * vocabulary from the profile (read at request time — see profile.ts). */
121
+ export function processExpansion(): string {
122
+ return P().retrieval.process_expansion;
123
+ }
124
+
125
+ /** Per-deployment model override: <ROLE>_MODEL (e.g. UNDERSTAND_MODEL)
126
+ * replaces the pinned default for that role. Ops lever for A/B-ing a
127
+ * role's model without a code change; absent/invalid = the default. */
128
+ export function roleModel(env: any, role: keyof typeof MODELS): string {
129
+ const ov = env[`${role.toUpperCase()}_MODEL`];
130
+ return typeof ov === "string" && ov.startsWith("@cf/") ? ov : MODELS[role];
131
+ }
132
+
133
+ const EFFORTS = new Set(["low", "medium", "high", "max"]);
134
+
135
+ /** Per-deployment effort override for the answer lane (ANSWER_EFFORT).
136
+ * The effort–accuracy curve is front-loaded (DeepSeek-V4.1-Flash report,
137
+ * Fig. 9): serving pins "low" for latency, and any move up must be
138
+ * measured on the golden set — this lever makes that measurable live,
139
+ * same pattern as roleModel. Absent/invalid = "low". */
140
+ export function answerEffort(env: any): string {
141
+ const v = env?.ANSWER_EFFORT;
142
+ return typeof v === "string" && EFFORTS.has(v) ? v : "low";
143
+ }
144
+
145
+ /** Per-request effort for the answer lane (the user-facing depth toggle).
146
+ * The vocabulary is validated; elevated efforts (medium+) are a member
147
+ * lane — same cost rule as research — and degrade to the deployment
148
+ * lever for anonymous or silent requests. */
149
+ /** Elevated effort reasons longer — the output budget must grow with it
150
+ * or reasoning starves the content (the GLM-5 rule; measured
151
+ * 2026-09-12: medium at the flat 3072 budget scored 5/38 on the golden
152
+ * set — empty answers — versus 37–38/38 at low). */
153
+ export function effortBudget(effort: string): number {
154
+ if (effort === "medium") return LIMITS.maxOutputTokens * 2;
155
+ if (effort === "high" || effort === "max") return LIMITS.maxOutputTokens * 4;
156
+ return LIMITS.maxOutputTokens;
157
+ }
158
+
159
+ export function requestEffort(env: any, session: unknown, requested: unknown): string {
160
+ if (typeof requested !== "string" || !EFFORTS.has(requested)) return answerEffort(env);
161
+ if (requested === "low") return requested;
162
+ return session ? requested : answerEffort(env);
163
+ }
164
+
165
+ /** The corpus catalog — single source for /api/datasets, the assistant's
166
+ * self-description, and per-corpus model guidance. `session: true`
167
+ * datasets are enabled for signed-in members (federated via the internal
168
+ * service binding). `note` (optional) is injected into the system prompt
169
+ * when passages from this corpus are present — corpus behavior travels
170
+ * with the dataset, not with pipeline code. */
171
+ export interface Dataset {
172
+ id: string;
173
+ label: string;
174
+ description: string;
175
+ /** member-session gated (federated via the internal service binding) */
176
+ session?: boolean;
177
+ /** the estate permission (a role code set in the publisher's
178
+ * identity provider) the session must carry for a session-gated
179
+ * dataset — membership alone is not the bar */
180
+ permission?: string;
181
+ /** the corpora this dataset searches (profile datasets.yaml) — the
182
+ * engine maps no publisher names */
183
+ corpora?: string[];
184
+ note?: string;
185
+ }
186
+
187
+ // The catalog is publisher data (profile/datasets.yaml, codegen into
188
+ // profile.gen.ts) — the engine ships no publisher facts
189
+ export function DATASETS(): readonly Dataset[] {
190
+ return P().datasets;
191
+ }
192
+
193
+ /** The estate permission gate: a session-gated dataset requires BOTH
194
+ * membership and the named permission (a role code the account carries
195
+ * in id.oimlsmart.org). Enforcement is server-side — the UI's lock is
196
+ * cosmetic; /api/ask federation and the scope intersect here too. */
197
+ export function hasPermission(session: unknown, code: string): boolean {
198
+ const roles = (session as { roles?: unknown } | null)?.roles;
199
+ return Array.isArray(roles) && roles.map(String).includes(code);
200
+ }
201
+
202
+ export function datasetAllowed(d: Dataset, session: unknown): boolean {
203
+ if (!d.session) return true;
204
+ if (!session) return false;
205
+ return hasPermission(session, d.permission ?? "ai-preview");
206
+ }
207
+
208
+ export function datasetsFor(session: unknown): unknown[] {
209
+ return DATASETS().map((d) => ({
210
+ id: d.id,
211
+ label: d.label,
212
+ description: d.description,
213
+ enabled: datasetAllowed(d, session),
214
+ ...(d.session
215
+ ? { requires: `the ${d.permission ?? "ai-preview"} permission (id.oimlsmart.org)`, authenticated: !!session }
216
+ : {}),
217
+ }));
218
+ }
219
+
220
+ /** Empty-state starter questions, served by /api/datasets — UI content
221
+ * comes from the API, never hardcoded in the client. */
222
+ export function SUGGESTIONS(): string[] {
223
+ return [...P().ui.suggestions];
224
+ }
225
+
226
+ export function num(env: Record<string, unknown>, key: string, fallback: number): number {
227
+ const v = Number(env[key]);
228
+ return Number.isFinite(v) && v > 0 ? v : fallback;
229
+ }
230
+
231
+ export function today(): string {
232
+ return new Date().toISOString().slice(0, 10);
233
+ }
234
+
235
+ export async function sha256Hex(s: string): Promise<string> {
236
+ const d = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(s));
237
+ return [...new Uint8Array(d)].map((b) => b.toString(16).padStart(2, "0")).join("");
238
+ }