@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.
- package/LICENSE +29 -0
- package/README.md +13 -0
- package/dist/admin.d.ts +26 -0
- package/dist/ai.d.ts +6 -0
- package/dist/anchors.d.ts +6 -0
- package/dist/answercache.d.ts +22 -0
- package/dist/ask.d.ts +5 -0
- package/dist/auth.d.ts +12 -0
- package/dist/bubble.d.ts +14 -0
- package/dist/chunk-LLWPT2XV.js +49 -0
- package/dist/chunk-MB74PTRM.js +114 -0
- package/dist/chunk-WOGQM7DJ.js +197 -0
- package/dist/chunk-WWNCWKKC.js +42 -0
- package/dist/completion.d.ts +5 -0
- package/dist/config.d.ts +154 -0
- package/dist/config.js +37 -0
- package/dist/context.d.ts +115 -0
- package/dist/conversations.d.ts +5 -0
- package/dist/drafts.d.ts +129 -0
- package/dist/env.d.ts +57 -0
- package/dist/faithfulness.d.ts +5 -0
- package/dist/grader.d.ts +3 -0
- package/dist/graph.d.ts +13 -0
- package/dist/hybrid.d.ts +7 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +5373 -0
- package/dist/internal_gateway.d.ts +14 -0
- package/dist/lexical.d.ts +7 -0
- package/dist/livedata.d.ts +77 -0
- package/dist/memories.d.ts +10 -0
- package/dist/modelplane.d.ts +61 -0
- package/dist/oidc.d.ts +73 -0
- package/dist/pipeline.d.ts +57 -0
- package/dist/profile.d.ts +2 -0
- package/dist/profile.gen.d.ts +70 -0
- package/dist/profile.js +8 -0
- package/dist/projects.d.ts +8 -0
- package/dist/prompts/conversational.md +8 -0
- package/dist/prompts/enrichment.md +3 -0
- package/dist/prompts/faithfulness.md +1 -0
- package/dist/prompts/grader.md +5 -0
- package/dist/prompts/listwise.md +3 -0
- package/dist/prompts/precision.md +1 -0
- package/dist/prompts/reflect.md +1 -0
- package/dist/prompts/relevancy.md +1 -0
- package/dist/prompts/research.md +10 -0
- package/dist/prompts/section-summary.md +5 -0
- package/dist/prompts/summarize.md +1 -0
- package/dist/prompts/system.md +18 -0
- package/dist/prompts/understanding.md +17 -0
- package/dist/quota.d.ts +13 -0
- package/dist/reflect.d.ts +5 -0
- package/dist/refs.d.ts +40 -0
- package/dist/refusal.d.ts +9 -0
- package/dist/refusal.js +9 -0
- package/dist/requestScope.d.ts +26 -0
- package/dist/requestScope.js +10 -0
- package/dist/research.d.ts +8 -0
- package/dist/search.d.ts +4 -0
- package/dist/selfquery.d.ts +7 -0
- package/dist/session.d.ts +1 -0
- package/dist/share.d.ts +2 -0
- package/dist/structural.d.ts +27 -0
- package/dist/tablecontext.d.ts +11 -0
- package/dist/understand.d.ts +11 -0
- package/dist/understandContract.d.ts +29 -0
- package/dist/verdict.d.ts +24 -0
- package/docs/API.md +451 -0
- package/docs/ARCHITECTURE.md +302 -0
- package/docs/AUDIT-2026-08-24.md +71 -0
- package/docs/CONTRIBUTOR-AUDIT-2026-08-25.md +147 -0
- package/docs/INGEST-ARCHITECTURE.md +158 -0
- package/docs/MCP.md +92 -0
- package/docs/METANORMA-AI-SERIALIZATION.md +247 -0
- package/docs/MKO-EXPORT-PIPELINE.md +147 -0
- package/docs/REDESIGN-NORMATIVE-RAG-ETSI.md +485 -0
- package/docs/RESEARCH-SOTA-2026.md +243 -0
- package/docs/ROADMAP-SOTA.md +130 -0
- package/docs/SOTA-STAGE-SPECS.md +509 -0
- package/docs/annealment/F1-verdict.md +27 -0
- package/docs/annealment/F10-notes.md +19 -0
- package/docs/annealment/F11-composition.md +17 -0
- package/docs/annealment/F12-passport.md +17 -0
- package/docs/annealment/F2-counterfactual.md +20 -0
- package/docs/annealment/F3-absence.md +21 -0
- package/docs/annealment/F4-instance.md +18 -0
- package/docs/annealment/F5-workflow.md +21 -0
- package/docs/annealment/F6-impact.md +21 -0
- package/docs/annealment/F7-editions.md +18 -0
- package/docs/annealment/F8-selfverify.md +19 -0
- package/docs/annealment/F9-projection-qa.md +17 -0
- package/docs/annealment/L0-locate.md +19 -0
- package/docs/annealment/L1-extract.md +18 -0
- package/docs/annealment/L2-nomenclature.md +22 -0
- package/docs/annealment/L3-geometry.md +23 -0
- package/docs/annealment/L4-composition.md +21 -0
- package/docs/annealment/L5-cross-standard.md +20 -0
- package/docs/annealment/L6-diachrony.md +21 -0
- package/docs/annealment/L7-perception.md +20 -0
- package/docs/annealment/L8-computation.md +22 -0
- package/docs/annealment/L9-instance-process.md +23 -0
- package/docs/annealment/README.md +10 -0
- package/docs/guidelines-metanorma-ai-programme.md +279 -0
- package/docs/identity-onboarding-rag.md +65 -0
- package/docs/identity-service.md +219 -0
- package/docs/knowledge-annealment.md +273 -0
- package/docs/konneal-extraction-plan.md +481 -0
- package/docs/metanorma-for-ai.md +270 -0
- package/docs/mirror-plan.md +36 -0
- package/docs/multi-sdo-architecture.md +191 -0
- package/docs/paper-annealment-comparison.md +259 -0
- package/docs/paper-assets/architecture.svg +94 -0
- package/docs/paper-assets/contract-v2.svg +94 -0
- package/docs/paper-assets/mko-ingest.svg +91 -0
- package/docs/paper-oiml-bulletin.md +402 -0
- package/docs/paper-oiml-bulletin.mdx +419 -0
- package/docs/product-branding-options.md +172 -0
- package/docs/projects-design.md +88 -0
- package/docs/sota-mechanisms.md +184 -0
- package/docs/spec-api.md +77 -0
- package/docs/spec-pipeline.md +126 -0
- package/docs/vector-adapter.md +88 -0
- package/package.json +70 -0
- package/profile/corpora.yaml +5 -0
- package/profile/datasets.yaml +14 -0
- package/profile/prompts.yaml +5 -0
- package/profile/publisher.yaml +17 -0
- package/profile/retrieval.yaml +1 -0
- package/profile/sources.yaml +5 -0
- package/profile/ui.yaml +7 -0
- package/scripts/gen_profile.mjs +33 -0
- package/workers/shared/ai.ts +21 -0
- package/workers/shared/auth.ts +16 -0
- package/workers/shared/chunk.ts +108 -0
- package/workers/shared/oidc.ts +312 -0
- package/workers/shared/router.ts +45 -0
- package/workers/shared/session.ts +104 -0
- package/workers/worker_internal/src/index.ts +157 -0
- package/workers/worker_internal/tsconfig.json +15 -0
- package/workers/worker_internal/wrangler.toml +32 -0
- package/workers/worker_mcp/src/index.ts +175 -0
- package/workers/worker_mcp/tsconfig.json +13 -0
- package/workers/worker_mcp/wrangler.toml +18 -0
- package/workers/worker_public/migrations/0002_conversations.sql +22 -0
- package/workers/worker_public/migrations/0003_shared_conversations.sql +9 -0
- package/workers/worker_public/migrations/0004_graph.sql +16 -0
- package/workers/worker_public/migrations/0005_documents.sql +19 -0
- package/workers/worker_public/migrations/0006_conversation_entities.sql +11 -0
- package/workers/worker_public/migrations/0007_chunks_fts.sql +43 -0
- package/workers/worker_public/migrations/0008_unit_payloads.sql +15 -0
- package/workers/worker_public/migrations/0009_chunks_unit.sql +8 -0
- package/workers/worker_public/migrations/0009_message_context.sql +7 -0
- package/workers/worker_public/migrations/0010_model_nodes.sql +33 -0
- package/workers/worker_public/migrations/0011_schema_union.sql +38 -0
- package/workers/worker_public/migrations/0012_memories.sql +15 -0
- package/workers/worker_public/migrations/0013_projects.sql +21 -0
- package/workers/worker_public/node_modules/@cloudflare/workers-types/2021-11-03/index.d.ts +16306 -0
- package/workers/worker_public/node_modules/@cloudflare/workers-types/2021-11-03/index.ts +16261 -0
- package/workers/worker_public/node_modules/@cloudflare/workers-types/2022-01-31/index.d.ts +16373 -0
- package/workers/worker_public/node_modules/@cloudflare/workers-types/2022-01-31/index.ts +16328 -0
- package/workers/worker_public/node_modules/@cloudflare/workers-types/2022-03-21/index.d.ts +16382 -0
- package/workers/worker_public/node_modules/@cloudflare/workers-types/2022-03-21/index.ts +16337 -0
- package/workers/worker_public/node_modules/@cloudflare/workers-types/2022-08-04/index.d.ts +16383 -0
- package/workers/worker_public/node_modules/@cloudflare/workers-types/2022-08-04/index.ts +16338 -0
- package/workers/worker_public/node_modules/@cloudflare/workers-types/2022-10-31/index.d.ts +16403 -0
- package/workers/worker_public/node_modules/@cloudflare/workers-types/2022-10-31/index.ts +16358 -0
- package/workers/worker_public/node_modules/@cloudflare/workers-types/2022-11-30/index.d.ts +16408 -0
- package/workers/worker_public/node_modules/@cloudflare/workers-types/2022-11-30/index.ts +16363 -0
- package/workers/worker_public/node_modules/@cloudflare/workers-types/2023-03-01/index.d.ts +16414 -0
- package/workers/worker_public/node_modules/@cloudflare/workers-types/2023-03-01/index.ts +16369 -0
- package/workers/worker_public/node_modules/@cloudflare/workers-types/2023-07-01/index.d.ts +16414 -0
- package/workers/worker_public/node_modules/@cloudflare/workers-types/2023-07-01/index.ts +16369 -0
- package/workers/worker_public/node_modules/@cloudflare/workers-types/README.md +135 -0
- package/workers/worker_public/node_modules/@cloudflare/workers-types/entrypoints.svg +53 -0
- package/workers/worker_public/node_modules/@cloudflare/workers-types/experimental/index.d.ts +17095 -0
- package/workers/worker_public/node_modules/@cloudflare/workers-types/experimental/index.ts +17050 -0
- package/workers/worker_public/node_modules/@cloudflare/workers-types/index.d.ts +16306 -0
- package/workers/worker_public/node_modules/@cloudflare/workers-types/index.ts +16261 -0
- package/workers/worker_public/node_modules/@cloudflare/workers-types/latest/index.d.ts +16447 -0
- package/workers/worker_public/node_modules/@cloudflare/workers-types/latest/index.ts +16402 -0
- package/workers/worker_public/node_modules/@cloudflare/workers-types/oldest/index.d.ts +16306 -0
- package/workers/worker_public/node_modules/@cloudflare/workers-types/oldest/index.ts +16261 -0
- package/workers/worker_public/node_modules/@cloudflare/workers-types/package.json +11 -0
- package/workers/worker_public/package.json +13 -0
- package/workers/worker_public/prompts/conversational.md +8 -0
- package/workers/worker_public/prompts/enrichment.md +3 -0
- package/workers/worker_public/prompts/faithfulness.md +1 -0
- package/workers/worker_public/prompts/grader.md +5 -0
- package/workers/worker_public/prompts/listwise.md +3 -0
- package/workers/worker_public/prompts/precision.md +1 -0
- package/workers/worker_public/prompts/reflect.md +1 -0
- package/workers/worker_public/prompts/relevancy.md +1 -0
- package/workers/worker_public/prompts/research.md +10 -0
- package/workers/worker_public/prompts/section-summary.md +5 -0
- package/workers/worker_public/prompts/summarize.md +1 -0
- package/workers/worker_public/prompts/system.md +18 -0
- package/workers/worker_public/prompts/understanding.md +17 -0
- package/workers/worker_public/public/app.js +166 -0
- package/workers/worker_public/public/index.html +48 -0
- package/workers/worker_public/public/style.css +147 -0
- package/workers/worker_public/schema.sql +248 -0
- package/workers/worker_public/src/admin.ts +358 -0
- package/workers/worker_public/src/ai.ts +71 -0
- package/workers/worker_public/src/anchors.ts +41 -0
- package/workers/worker_public/src/answercache.ts +72 -0
- package/workers/worker_public/src/ask.ts +1094 -0
- package/workers/worker_public/src/auth.ts +252 -0
- package/workers/worker_public/src/bubble.ts +111 -0
- package/workers/worker_public/src/completion.ts +75 -0
- package/workers/worker_public/src/config.ts +238 -0
- package/workers/worker_public/src/context.ts +238 -0
- package/workers/worker_public/src/conversations.ts +162 -0
- package/workers/worker_public/src/drafts.ts +497 -0
- package/workers/worker_public/src/env.ts +90 -0
- package/workers/worker_public/src/faithfulness.ts +63 -0
- package/workers/worker_public/src/grader.ts +89 -0
- package/workers/worker_public/src/graph.ts +63 -0
- package/workers/worker_public/src/hybrid.ts +77 -0
- package/workers/worker_public/src/index.ts +441 -0
- package/workers/worker_public/src/internal_gateway.ts +41 -0
- package/workers/worker_public/src/lexical.ts +86 -0
- package/workers/worker_public/src/lib/hit.ts +4 -0
- package/workers/worker_public/src/lib/http.ts +83 -0
- package/workers/worker_public/src/lib/router.ts +4 -0
- package/workers/worker_public/src/livedata.ts +334 -0
- package/workers/worker_public/src/memories.ts +81 -0
- package/workers/worker_public/src/modelplane.ts +213 -0
- package/workers/worker_public/src/oidc.ts +333 -0
- package/workers/worker_public/src/pipeline.ts +377 -0
- package/workers/worker_public/src/ports/blobs.ts +7 -0
- package/workers/worker_public/src/ports/cloudflare/adapters.ts +177 -0
- package/workers/worker_public/src/ports/kv.ts +8 -0
- package/workers/worker_public/src/ports/model.ts +28 -0
- package/workers/worker_public/src/ports/runtime.ts +13 -0
- package/workers/worker_public/src/ports/store.ts +20 -0
- package/workers/worker_public/src/ports/vector.ts +26 -0
- package/workers/worker_public/src/profile.gen.ts +101 -0
- package/workers/worker_public/src/profile.ts +16 -0
- package/workers/worker_public/src/projects.ts +108 -0
- package/workers/worker_public/src/prompts.d.ts +6 -0
- package/workers/worker_public/src/quota.ts +54 -0
- package/workers/worker_public/src/reflect.ts +67 -0
- package/workers/worker_public/src/refs.ts +107 -0
- package/workers/worker_public/src/refusal.ts +65 -0
- package/workers/worker_public/src/requestScope.ts +71 -0
- package/workers/worker_public/src/research.ts +126 -0
- package/workers/worker_public/src/search.ts +56 -0
- package/workers/worker_public/src/selfquery.ts +25 -0
- package/workers/worker_public/src/session.ts +4 -0
- package/workers/worker_public/src/share.ts +53 -0
- package/workers/worker_public/src/stages/conceptGraph.ts +68 -0
- package/workers/worker_public/src/stages/conceptSteer.ts +39 -0
- package/workers/worker_public/src/stages/corpusScope.ts +25 -0
- package/workers/worker_public/src/stages/dedup.ts +10 -0
- package/workers/worker_public/src/stages/dense.ts +73 -0
- package/workers/worker_public/src/stages/diversity.ts +33 -0
- package/workers/worker_public/src/stages/editionCover.ts +63 -0
- package/workers/worker_public/src/stages/editionSteer.ts +88 -0
- package/workers/worker_public/src/stages/familyBoost.ts +22 -0
- package/workers/worker_public/src/stages/federate.ts +22 -0
- package/workers/worker_public/src/stages/glossary.ts +65 -0
- package/workers/worker_public/src/stages/graphLane.ts +31 -0
- package/workers/worker_public/src/stages/hyde.ts +29 -0
- package/workers/worker_public/src/stages/index.ts +69 -0
- package/workers/worker_public/src/stages/lexicalUnion.ts +21 -0
- package/workers/worker_public/src/stages/multiQuery.ts +57 -0
- package/workers/worker_public/src/stages/overviewDemote.ts +14 -0
- package/workers/worker_public/src/stages/poolOpen.ts +10 -0
- package/workers/worker_public/src/stages/propagate.ts +15 -0
- package/workers/worker_public/src/stages/rerank.ts +47 -0
- package/workers/worker_public/src/stages/seal.ts +16 -0
- package/workers/worker_public/src/stages/sectionDescent.ts +61 -0
- package/workers/worker_public/src/stages/stdRefNudge.ts +35 -0
- package/workers/worker_public/src/stages/subQuery.ts +42 -0
- package/workers/worker_public/src/stages/termNudge.ts +24 -0
- package/workers/worker_public/src/stages/typedPin.ts +131 -0
- package/workers/worker_public/src/stages/types.ts +112 -0
- package/workers/worker_public/src/stages/windowFloor.ts +23 -0
- package/workers/worker_public/src/structural.ts +171 -0
- package/workers/worker_public/src/tablecontext.ts +41 -0
- package/workers/worker_public/src/understand.ts +72 -0
- package/workers/worker_public/src/understandContract.ts +67 -0
- package/workers/worker_public/src/verdict.ts +255 -0
- package/workers/worker_public/tsconfig.json +18 -0
- package/workers/worker_public/wrangler.toml +104 -0
|
@@ -0,0 +1,1094 @@
|
|
|
1
|
+
// The ask path (TODO.impl/30): answer generation, streaming, the answer
|
|
2
|
+
// cache, contract completion, verdicts, drafts, live-data and the context
|
|
3
|
+
// echo — everything between "request validated" and "response written".
|
|
4
|
+
// index.ts routes here; this module owns the answer contract.
|
|
5
|
+
|
|
6
|
+
import { LIMITS, MODELS, num, sha256Hex, roleModel, answerEffort, requestEffort, effortBudget } from "./config";
|
|
7
|
+
import { portModelRunner } from "./env.ts";
|
|
8
|
+
import { buildMessages, citations, retrieve, retrievalQuery, identityNote, splitHistory, listwiseRerank, refusalAnswer, Hit } from "./pipeline";
|
|
9
|
+
import { sessionFrom } from "./auth";
|
|
10
|
+
import { retrieveInternal } from "./internal_gateway";
|
|
11
|
+
import { understandQuery } from "./understand";
|
|
12
|
+
import { gradeRetrieval } from "./grader";
|
|
13
|
+
import summarizePrompt from "../prompts/summarize.md";
|
|
14
|
+
import { embed, generateOnce } from "./ai";
|
|
15
|
+
import { reflect } from "./reflect";
|
|
16
|
+
import { checkQuoteAnchors, ANCHOR_CORRECTION_NOTE } from "./anchors";
|
|
17
|
+
import { canonicalRefusal } from "./refusal";
|
|
18
|
+
import { contractV2, tableRetyped } from "./refs";
|
|
19
|
+
import { completeTables, completeFigures } from "./completion";
|
|
20
|
+
import { NO_CONTEXT, appliedContext, contextNote, namedDocumentIn, parseContext, resolveDocScope, syntheticUnderstanding } from "./context";
|
|
21
|
+
import { exchangeForLiveToken, liveDataConfig, resolveLiveAccount, type LiveRecord } from "./livedata";
|
|
22
|
+
import { bindModelNode, modelCitation, modelEcho, modelGroundingBlock, modelNodeRefIn, standardForDocNumber } from "./modelplane";
|
|
23
|
+
import { evaluate as machineEvaluate, verdictNote } from "./verdict";
|
|
24
|
+
import { detectDraftIntent, prepareDraft } from "./drafts";
|
|
25
|
+
import { memoryNote } from "./memories";
|
|
26
|
+
import { resolveRequestScope, requestSalt } from "./requestScope";
|
|
27
|
+
import { rawSessionToken } from "./session";
|
|
28
|
+
import { cacheKeyMaterial, corpusGen, exactCacheKey, freshRequested, semanticCacheKey } from "./answercache";
|
|
29
|
+
import type { Env } from "./env";
|
|
30
|
+
export type { Env };
|
|
31
|
+
import { json, err, corsHeaders, readJson, validateQuery, type ApiKey } from "./lib/http";
|
|
32
|
+
import { clientIp, checkQuota, telemetry } from "./quota";
|
|
33
|
+
import { graphExpand, editionNote } from "./graph";
|
|
34
|
+
|
|
35
|
+
/** User-uploaded image for multimodal questions: a data URL
|
|
36
|
+
* (data:image/(png|jpeg|webp|gif);base64,…) up to 6 MB of payload. The
|
|
37
|
+
* question text still drives retrieval; the image is CONTEXT for the
|
|
38
|
+
* answer model (photo of a nameplate, a schematic, a scale dial). Null =
|
|
39
|
+
* no image; undefined-but-present-invalid throws at the boundary. */
|
|
40
|
+
function userImageDataUrl(body: any): string | null {
|
|
41
|
+
const img = body?.image;
|
|
42
|
+
if (img == null) return null;
|
|
43
|
+
if (typeof img !== "string" || img.length > 6_000_000) return null;
|
|
44
|
+
const m = img.match(/^data:image\/(png|jpe?g|webp|gif);base64,([A-Za-z0-9+/=]+)$/);
|
|
45
|
+
if (!m || !m[2]) return null;
|
|
46
|
+
return img;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function cacheGet(env: Env, gen: string, ns: string, query: string, lang?: string, salt?: string | null) {
|
|
50
|
+
const key = exactCacheKey(env.INDEX_VERSION, gen, ns, await sha256Hex(cacheKeyMaterial(query, lang, salt)));
|
|
51
|
+
const hit = await env.CACHE.get(key, "json");
|
|
52
|
+
return hit ? { key, value: hit as any } : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// the refusal canonicalizer lives in ./refusal (the pinned sentence, the
|
|
56
|
+
// start-anchored variant, and the rag#88 drift family — the same shapes
|
|
57
|
+
// the harnesses accept, canonicalized, never more)
|
|
58
|
+
|
|
59
|
+
/** Start an embed call without awaiting failures — null result means the
|
|
60
|
+
* caller simply embeds fresh. */
|
|
61
|
+
function embedWarm(env: Env, text: string): Promise<number[] | null> {
|
|
62
|
+
return embed(portModelRunner(env), MODELS.embed, text).catch(() => null);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** GLM-5.3-Flash is natively multimodal: when the used passages contain
|
|
66
|
+
* figure units with uploaded assets, attach the actual pixels to the
|
|
67
|
+
* generation call so the model interprets the producer's figure, not
|
|
68
|
+
* just its stored caption. Additive — failures simply send no images.
|
|
69
|
+
* Two hard-won shape rules (probed live, 2026-09-09): images ride their
|
|
70
|
+
* OWN short trailing user message, never the passages message (long
|
|
71
|
+
* text + image parts in one message triggers nondeterministic Workers
|
|
72
|
+
* AI 8005s that scale with payload size), and only the ONE pinned
|
|
73
|
+
* figure attaches (the type-intent pin already chose the answering
|
|
74
|
+
* object; a second base64 blob doubles the flake surface for nothing). */
|
|
75
|
+
async function attachFigureImages(env: Env, messages: { role: string; content: string }[], usedHits: Hit[], query: string): Promise<void> {
|
|
76
|
+
// Attach only when the question WANTS the drawing (names a figure-ish
|
|
77
|
+
// artifact) or the pinned figure sits in the top prose passage's own
|
|
78
|
+
// clause (it IS the answering object) — a plain definition question
|
|
79
|
+
// gains nothing from pixels and pays the multimodal flake surface
|
|
80
|
+
const figIntent = /\b(fig(ure)?s?|diagram|drawing|graph|chart)\b/i.test(query);
|
|
81
|
+
const topProseAnchor = usedHits.find((h) => !h.metadata.unit_id)?.metadata.clause_anchor;
|
|
82
|
+
const figures = usedHits
|
|
83
|
+
.filter((h) => h.metadata.unit_id && h.metadata.block === "figure")
|
|
84
|
+
.filter((h) => figIntent || (!!h.metadata.clause_anchor && h.metadata.clause_anchor === topProseAnchor))
|
|
85
|
+
.slice(0, 1);
|
|
86
|
+
if (!figures.length) return;
|
|
87
|
+
const parts: unknown[] = [];
|
|
88
|
+
const names: string[] = [];
|
|
89
|
+
for (const h of figures) {
|
|
90
|
+
try {
|
|
91
|
+
const row = await env.DB.prepare("SELECT payload FROM unit_payloads WHERE unit_id = ?1").bind(h.metadata.unit_id!).first<any>();
|
|
92
|
+
const uri = row ? (JSON.parse(String(row.payload)).uri ?? "") : "";
|
|
93
|
+
const m = typeof uri === "string" ? uri.match(/^\/assets\/(.+)/) : null;
|
|
94
|
+
if (!m) continue;
|
|
95
|
+
const obj = await env.UNIT_ASSETS.get(m[1]);
|
|
96
|
+
if (!obj) continue;
|
|
97
|
+
const buf = new Uint8Array(await obj.arrayBuffer());
|
|
98
|
+
const ext = m[1].split(".").pop()?.toLowerCase() ?? "png";
|
|
99
|
+
const mime = ext === "svg" ? "image/svg+xml" : `image/${ext === "jpg" ? "jpeg" : ext}`;
|
|
100
|
+
let binary = "";
|
|
101
|
+
for (let i = 0; i < buf.length; i += 8192) binary += String.fromCharCode(...buf.subarray(i, i + 8192));
|
|
102
|
+
parts.push({ type: "image_url", image_url: { url: `data:${mime};base64,${btoa(binary)}` } });
|
|
103
|
+
names.push(h.metadata.unit_id!);
|
|
104
|
+
} catch {
|
|
105
|
+
// additive — one unreadable asset never blocks the answer
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (!parts.length) return;
|
|
109
|
+
messages.push({
|
|
110
|
+
role: "user",
|
|
111
|
+
content: [
|
|
112
|
+
{ type: "text", text: `The original image of figure unit ${names.join(", ")} is attached; interpret it directly when answering about this figure.` },
|
|
113
|
+
...parts,
|
|
114
|
+
] as unknown as string,
|
|
115
|
+
});
|
|
116
|
+
console.log("figure images attached:", names.join(", "));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function generateStream(env: Env, model: string, messages: any[], effort?: string): Promise<ReadableStream<Uint8Array> | null> {
|
|
120
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
121
|
+
try {
|
|
122
|
+
const res: any = await env.AI.run(model, {
|
|
123
|
+
messages,
|
|
124
|
+
stream: true,
|
|
125
|
+
max_tokens: effortBudget(effort ?? answerEffort(env)),
|
|
126
|
+
reasoning_effort: effort ?? answerEffort(env),
|
|
127
|
+
temperature: 0.6,
|
|
128
|
+
top_p: 0.95,
|
|
129
|
+
});
|
|
130
|
+
if (res && typeof res.getReader === "function") return res as ReadableStream<Uint8Array>;
|
|
131
|
+
if (res && res.body && typeof res.body.getReader === "function") return res.body;
|
|
132
|
+
} catch (e) {
|
|
133
|
+
console.error("stream failed:", model, String(e).slice(0, 120));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Compact overflow history into a short continuity summary. Null = keep
|
|
140
|
+
* nothing (degrades to plain truncation, never to failure). */
|
|
141
|
+
async function summarizeHistory(
|
|
142
|
+
env: Env,
|
|
143
|
+
model: string,
|
|
144
|
+
turns: Array<{ role: string; content: string }>,
|
|
145
|
+
): Promise<string | null> {
|
|
146
|
+
try {
|
|
147
|
+
const convo = turns
|
|
148
|
+
.map((t) => `${t.role === "user" ? "User" : "Assistant"}: ${t.content.slice(0, 1200)}`)
|
|
149
|
+
.join("\n")
|
|
150
|
+
.slice(0, 24000);
|
|
151
|
+
const res: any = await env.AI.run(model, {
|
|
152
|
+
messages: [
|
|
153
|
+
{
|
|
154
|
+
role: "system",
|
|
155
|
+
content: summarizePrompt.trimEnd(),
|
|
156
|
+
},
|
|
157
|
+
{ role: "user", content: convo },
|
|
158
|
+
],
|
|
159
|
+
max_tokens: 2048,
|
|
160
|
+
reasoning_effort: "low",
|
|
161
|
+
// Qwen3 thinking-mode sampling (model card) — prevents the
|
|
162
|
+
// repetition loops that eat the budget before the summary lands
|
|
163
|
+
temperature: 0.6,
|
|
164
|
+
top_p: 0.95,
|
|
165
|
+
top_k: 20,
|
|
166
|
+
});
|
|
167
|
+
const text = typeof res?.response === "string" ? res.response : res?.choices?.[0]?.message?.content;
|
|
168
|
+
return typeof text === "string" && text.trim() ? text.trim().slice(0, 1200) : null;
|
|
169
|
+
} catch {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function* sseTokens(stream: ReadableStream<Uint8Array>): AsyncGenerator<string> {
|
|
175
|
+
const reader = stream.getReader();
|
|
176
|
+
const decoder = new TextDecoder();
|
|
177
|
+
let buf = "";
|
|
178
|
+
while (true) {
|
|
179
|
+
const { done, value } = await reader.read();
|
|
180
|
+
if (done) break;
|
|
181
|
+
buf += decoder.decode(value, { stream: true });
|
|
182
|
+
const lines = buf.split("\n");
|
|
183
|
+
buf = lines.pop() ?? "";
|
|
184
|
+
for (const line of lines) {
|
|
185
|
+
const trimmed = line.trim();
|
|
186
|
+
if (!trimmed.startsWith("data:")) continue;
|
|
187
|
+
const payload = trimmed.slice(5).trim();
|
|
188
|
+
if (!payload || payload === "[DONE]") continue;
|
|
189
|
+
try {
|
|
190
|
+
const evt = JSON.parse(payload);
|
|
191
|
+
const tok =
|
|
192
|
+
typeof evt?.response === "string"
|
|
193
|
+
? evt.response
|
|
194
|
+
: evt?.choices?.[0]?.delta?.content;
|
|
195
|
+
if (tok) yield tok;
|
|
196
|
+
} catch {
|
|
197
|
+
// partial JSON in line splitting — ignore
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function handleAsk(
|
|
204
|
+
env: Env,
|
|
205
|
+
ctx: ExecutionContext,
|
|
206
|
+
req: Request,
|
|
207
|
+
tier: "anon" | "key" | "member",
|
|
208
|
+
key: ApiKey | null,
|
|
209
|
+
): Promise<Response> {
|
|
210
|
+
const body = await readJson(req);
|
|
211
|
+
const q = validateQuery(body);
|
|
212
|
+
if (!q) return err(400, "invalid_input", `query is required (1-${LIMITS.maxInputChars} chars)`);
|
|
213
|
+
|
|
214
|
+
// The declared context (TODO.ai-platform/02): the panel's opt-in chips.
|
|
215
|
+
// A declared context makes the answer depend on MORE than the query, so
|
|
216
|
+
// it bypasses both answer caches (read AND write) exactly as a
|
|
217
|
+
// contextual (history-carrying) turn does.
|
|
218
|
+
const declaredCtx = parseContext(body);
|
|
219
|
+
|
|
220
|
+
// The draft act (TODO.ai-platform/04): the user asks the assistant to
|
|
221
|
+
// PREPARE an act (the application prefill is the pilot) — never to
|
|
222
|
+
// perform it. The answer depends on the conversation, the account's
|
|
223
|
+
// live standing and the registry, never on the query alone, so a draft
|
|
224
|
+
// ask bypasses both answer caches (read AND write) exactly as a
|
|
225
|
+
// declared-context ask does.
|
|
226
|
+
const draftAct = detectDraftIntent(q.query);
|
|
227
|
+
|
|
228
|
+
const member = tier === "member" ? await sessionFrom(req, env as any) : null;
|
|
229
|
+
// resolved before the quota check: the effort choice prices the ask
|
|
230
|
+
const effort = requestEffort(env, member, (body as any)?.effort);
|
|
231
|
+
|
|
232
|
+
const limit =
|
|
233
|
+
tier === "key" ? key!.day_limit : tier === "member" || member ? num(env as any, "MEMBER_DAY_ASK", 300) : num(env as any, "ANON_DAY_ASK", 20);
|
|
234
|
+
const bucketId = tier === "key" ? `key:${key!.id}` : member ? `sub:${member.sub}` : clientIp(req);
|
|
235
|
+
const quota = await checkQuota(env, "ask", bucketId, limit, effort === "low" ? 1 : 2);
|
|
236
|
+
if (!quota.ok) {
|
|
237
|
+
return err(429, "quota_exceeded", `Daily question limit reached (${quota.limit}). Try again tomorrow.`);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const hardCap = num(env as any, "ANON_DAY_HARD_CAP", 5000);
|
|
241
|
+
if (tier === "anon" && quota.used > hardCap) {
|
|
242
|
+
return err(503, "generation_disabled", "Generation is temporarily paused; search remains available.");
|
|
243
|
+
}
|
|
244
|
+
if ((await env.CACHE.get("sys:generation")) === "off") {
|
|
245
|
+
return err(503, "generation_disabled", "Generation is temporarily paused; search remains available.");
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Members get federated retrieval (OIML + ISO/IEC) merged into the same
|
|
249
|
+
// pipeline via the service binding; rag-public never touches the
|
|
250
|
+
// internal index itself, and generation/rerank stay in ONE pipeline.
|
|
251
|
+
const service = env.INTERNAL_SERVICE;
|
|
252
|
+
const fedAuth = {
|
|
253
|
+
cookie: req.headers.get("cookie") ?? "",
|
|
254
|
+
authorization: req.headers.get("authorization") ?? "",
|
|
255
|
+
};
|
|
256
|
+
// Dataset scope + memory selection (MECE: the derivation lives in
|
|
257
|
+
// ./requestScope; this path only wires it). A request that explicitly
|
|
258
|
+
// disables every dataset is a user error.
|
|
259
|
+
const scope = resolveRequestScope(body, member);
|
|
260
|
+
if ("error" in scope) return err(400, "invalid_input", "datasets: at least one dataset must stay enabled");
|
|
261
|
+
const { corpora, narrowed, isoOn } = scope;
|
|
262
|
+
// Personalized memory files (#171): member-scoped, selected per ask;
|
|
263
|
+
// the note rides buildMessages as a trusted-user-facts preamble, and
|
|
264
|
+
// the selections SALT the answer cache (requestScope.requestSalt).
|
|
265
|
+
const [memNote, memoryUsed] = member && scope.memoryIds.length ? await memoryNote(env, member.sub, scope.memoryIds) : [null, []];
|
|
266
|
+
const requestSaltStr = requestSalt(scope, memoryUsed);
|
|
267
|
+
// per-request depth toggle (⚡ fast / 🧠 thorough): effort changes the
|
|
268
|
+
// answer, so it salts the cache with the selections above
|
|
269
|
+
const salt = requestSaltStr ? `${requestSaltStr}|effort:${effort}` : `effort:${effort}`;
|
|
270
|
+
const federate = member && service && isoOn
|
|
271
|
+
? (q2: string) => retrieveInternal(service, fedAuth, q2)
|
|
272
|
+
: undefined;
|
|
273
|
+
|
|
274
|
+
const ns = tier === "key" ? `k:${key!.id}` : member ? `m:${member.sub}` : "anon";
|
|
275
|
+
const model = member ? MODELS.member : MODELS.anon;
|
|
276
|
+
const prev = typeof body?.prev === "string" ? body.prev.slice(0, 800) : undefined;
|
|
277
|
+
const rawHistory = Array.isArray(body?.history) ? body.history : [];
|
|
278
|
+
const history = rawHistory
|
|
279
|
+
.filter((h: any) => (h?.role === "user" || h?.role === "assistant") && typeof h?.content === "string" && h.content.trim())
|
|
280
|
+
.slice(-24)
|
|
281
|
+
.map((h: any) => ({ role: h.role, content: h.content.slice(0, 4000) }));
|
|
282
|
+
const contextual = history.length > 0;
|
|
283
|
+
// user-uploaded image: present-but-invalid is a boundary error (silent
|
|
284
|
+
// drop would answer a DIFFERENT question than the one the user asked)
|
|
285
|
+
const userImage = body?.image != null ? userImageDataUrl(body) : null;
|
|
286
|
+
if (body?.image != null && !userImage) {
|
|
287
|
+
return err(400, "invalid_image", "image must be a data URL (data:image/png|jpeg|webp|gif;base64,…) up to 6 MB");
|
|
288
|
+
}
|
|
289
|
+
// history compaction: turns beyond the budget slice are summarized into a
|
|
290
|
+
// continuity block (below) instead of silently dropped
|
|
291
|
+
const budget = num(env as any, "INPUT_TOKEN_BUDGET", LIMITS.inputTokenBudget);
|
|
292
|
+
const { kept: keptHistory, overflow } = splitHistory(history, budget);
|
|
293
|
+
const summary = overflow.length >= 2 ? ((await summarizeHistory(env, MODELS.understand, overflow)) ?? undefined) : undefined;
|
|
294
|
+
let retrieved;
|
|
295
|
+
// fresh (regenerate) skips the cache read; contextual follow-ups,
|
|
296
|
+
// declared-context asks, draft asks and image asks skip the cache
|
|
297
|
+
// entirely — the answer depends on the conversation / declared context
|
|
298
|
+
// / image, not the query text alone. The fresh parse is answercache's
|
|
299
|
+
// single freshRequested, honored at every answer-cache read below.
|
|
300
|
+
const fresh = freshRequested(body);
|
|
301
|
+
// the corpus-generation stamp (KV sys:corpus_gen) namespaces both
|
|
302
|
+
// answer caches; corpus surgery bumps it (scripts/invalidate_answer_
|
|
303
|
+
// cache.py) and old-generation entries miss (oimlsmart/rag#72)
|
|
304
|
+
const gen = await corpusGen(env.CACHE);
|
|
305
|
+
const cached = fresh || contextual || declaredCtx || draftAct || userImage ? null : await cacheGet(env, gen, ns, q.query, q.lang, salt);
|
|
306
|
+
const wantsStream = body?.stream === true || (tier === "anon" && body?.stream !== false);
|
|
307
|
+
|
|
308
|
+
if (cached) {
|
|
309
|
+
telemetry(env, ctx, tier, "ask", null, true, (cached.value.answer ?? "").length, cached.value.query_hash, q.lang);
|
|
310
|
+
// echo the context the CACHED answer was computed under — the payload
|
|
311
|
+
// stores it (cacheable excludes declared-context answers, but a model
|
|
312
|
+
// node named in the question binds WITHOUT a chip and its echo must
|
|
313
|
+
// survive the cache, not silently flatten to "none")
|
|
314
|
+
const cctx = cached.value.context_applied ?? NO_CONTEXT;
|
|
315
|
+
if (wantsStream) {
|
|
316
|
+
// a cache hit must still speak SSE — the chat client parses a stream
|
|
317
|
+
return sseResponse([{ type: "citations", citations: cached.value.citations ?? [], quota, context_applied: cctx }, { type: "token", v: cached.value.answer ?? "" }, { type: "done", model: cached.value.model ?? MODELS.member, query_hash: cached.value.query_hash, context_applied: cctx }], corsHeaders(req));
|
|
318
|
+
}
|
|
319
|
+
return json({ ...cached.value, cached: true, quota, context_applied: cctx });
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// warm the folded-query embedding concurrently with understanding —
|
|
323
|
+
// retrieval reuses it when the understanding leaves the query as-is
|
|
324
|
+
const warmQuery = retrievalQuery(q.query, prev);
|
|
325
|
+
const warmEmbed = embedWarm(env, warmQuery);
|
|
326
|
+
// cross-turn entity memory: resolved entities for this conversation
|
|
327
|
+
// (present when the client passes conversation_id) make pronoun
|
|
328
|
+
// follow-ups O(1) — "it / the 2017 one" resolve against the map
|
|
329
|
+
const conversationId = typeof body?.conversation_id === "string" && /^[A-Za-z0-9_-]{8,64}$/.test(body.conversation_id) ? body.conversation_id : null;
|
|
330
|
+
let convEntities: Array<{ entity: string; kind: string }> = [];
|
|
331
|
+
if (conversationId) {
|
|
332
|
+
try {
|
|
333
|
+
const rows = await env.DB.prepare("SELECT entity, kind FROM conversation_entities WHERE conversation_id = ?1 LIMIT 12").bind(conversationId).all();
|
|
334
|
+
convEntities = (rows.results ?? []) as any;
|
|
335
|
+
if (convEntities.length) console.log("entity map:", convEntities.length, "entries");
|
|
336
|
+
} catch {
|
|
337
|
+
/* memory is additive */
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
// ── fast path: standalone (non-contextual) near-duplicate of a recently
|
|
341
|
+
// answered question — serve from the semantic cache WITHOUT paying the
|
|
342
|
+
// understanding call. Contextual turns and declared-context asks never
|
|
343
|
+
// take this path (they always run understanding + live retrieval);
|
|
344
|
+
// fresh already bypassed the exact cache above and bypasses this one.
|
|
345
|
+
let understanding: any = null;
|
|
346
|
+
// a query naming a model node (/req/…, /term/…) is node-SCOPED: its
|
|
347
|
+
// embedding sits near every other node-scoped ask about the same
|
|
348
|
+
// standard, and the single-entry semantic bucket then serves one
|
|
349
|
+
// node's answer for another (observed run-to-run across the golden
|
|
350
|
+
// model legs). Node-scoped queries use the exact cache only.
|
|
351
|
+
const nodeScoped = !!modelNodeRefIn(q.query) || !!modelNodeRefIn(declaredCtx?.label);
|
|
352
|
+
if (!cached && !nodeScoped && !contextual && !declaredCtx && !draftAct && !q.lang && !userImage && !fresh) {
|
|
353
|
+
const wv0 = (await warmEmbed) ?? null;
|
|
354
|
+
if (wv0) {
|
|
355
|
+
const sc0 = await semanticCacheGet(env, gen, wv0, salt);
|
|
356
|
+
if (sc0) {
|
|
357
|
+
console.log("semantic cache hit (pre-understanding)");
|
|
358
|
+
telemetry(env, ctx, tier, "ask", null, true, sc0.answer.length, sc0.query_hash, q.lang);
|
|
359
|
+
const cctx0 = sc0.context_applied ?? NO_CONTEXT;
|
|
360
|
+
if (wantsStream) {
|
|
361
|
+
return sseResponse([{ type: "citations", citations: sc0.citations ?? [], context_applied: cctx0 }, { type: "token", v: sc0.answer }, { type: "done", model: sc0.model, query_hash: sc0.query_hash, similar: true, context_applied: cctx0 }], corsHeaders(req));
|
|
362
|
+
}
|
|
363
|
+
return json({ ...sc0, similar: true, context_applied: cctx0, quota, });
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
// ── Option C: optimistic parallel retrieval ──
|
|
368
|
+
// The dense lane (folded-query embed + unfiltered Vectorize query) runs
|
|
369
|
+
// CONCURRENTLY with understanding instead of after it — the serial
|
|
370
|
+
// understand→retrieve chain collapses to max(understand, dense). The
|
|
371
|
+
// warm embedding IS the folded-query vector retrieve() would compute,
|
|
372
|
+
// and the query is issued with retrieve's exact parameters (topK,
|
|
373
|
+
// no filter), so when understanding emits no filter the optimistic
|
|
374
|
+
// results replace the primary dense query bit-for-bit; with a filter
|
|
375
|
+
// they union in as discounted candidates covering filter misses.
|
|
376
|
+
let optimisticVec: number[] | null = null;
|
|
377
|
+
let optimisticHits: Hit[] = [];
|
|
378
|
+
const t0 = Date.now();
|
|
379
|
+
if (!cached) {
|
|
380
|
+
const understandingP = understandQuery(portModelRunner(env), roleModel(env, "understand"), q.query, history, convEntities);
|
|
381
|
+
try {
|
|
382
|
+
optimisticVec = (await warmEmbed) ?? null;
|
|
383
|
+
if (optimisticVec) {
|
|
384
|
+
const ores = await env.VECTORIZE.query(optimisticVec, { topK: LIMITS.retrieveK, returnMetadata: "all" });
|
|
385
|
+
optimisticHits = (ores.matches ?? []).map((m: any) => ({
|
|
386
|
+
id: m.id, score: m.score, metadata: m.metadata, text: (m.metadata?.chunk_text ?? ""),
|
|
387
|
+
})) as Hit[];
|
|
388
|
+
}
|
|
389
|
+
} catch {
|
|
390
|
+
// optimistic path is additive; retrieve() runs its own dense lane
|
|
391
|
+
}
|
|
392
|
+
understanding = await understandingP;
|
|
393
|
+
console.log("stage: understand+optimistic", Date.now() - t0, "ms");
|
|
394
|
+
}
|
|
395
|
+
// ── The declared context's document scope (TODO.ai-platform/02) ──
|
|
396
|
+
// The entity/document chip's corpus reference pins retrieval to that
|
|
397
|
+
// publication FAMILY by writing the same understanding fields a named
|
|
398
|
+
// document in the query would — the whole doc-scoped machinery (the
|
|
399
|
+
// Vectorize filter, the family boost, the typed pin, the grade skip)
|
|
400
|
+
// keys off them. A document named IN THE QUESTION wins over the chip:
|
|
401
|
+
// the context informs, it never overrides the user's explicit words —
|
|
402
|
+
// and context_applied's note says which way it went, never silently.
|
|
403
|
+
// "Named" is read from the question TEXT (namedDocumentIn), never from
|
|
404
|
+
// understand's extraction alone: the LLM also fires on domain priors
|
|
405
|
+
// ("maximum permissible errors" → R 76 with no document named — an
|
|
406
|
+
// inference must never steal the user's explicit chip) and can miss a
|
|
407
|
+
// naming the text plainly carries (the win must not depend on that
|
|
408
|
+
// flake either).
|
|
409
|
+
const docScope = declaredCtx && declaredCtx.kind !== "account" ? await resolveDocScope(env, declaredCtx) : null;
|
|
410
|
+
const named = declaredCtx && declaredCtx.kind !== "account" ? namedDocumentIn(q.query) : null;
|
|
411
|
+
let ctxApplied;
|
|
412
|
+
let declaredScoped = false;
|
|
413
|
+
if (!declaredCtx) {
|
|
414
|
+
ctxApplied = NO_CONTEXT;
|
|
415
|
+
// chip-less but the question TEXT names a publication: the same
|
|
416
|
+
// deterministic naming the chip path uses must scope retrieval here
|
|
417
|
+
// too — leaving it to the understanding model's extraction made
|
|
418
|
+
// "What is OIML D 29?" a coin flip (nodoc → unscoped pool loses D 29
|
|
419
|
+
// to R 29 and D-family neighbors; doc#29 → scoped pool answers). The
|
|
420
|
+
// text is the user's own words; the LLM understanding augments but
|
|
421
|
+
// never gates an explicit naming.
|
|
422
|
+
const bare = understanding?.process_intent ? null : namedDocumentIn(q.query);
|
|
423
|
+
if (bare && understanding?.doc_number !== bare.doc_number) {
|
|
424
|
+
understanding = {
|
|
425
|
+
...(understanding ?? syntheticUnderstanding(bare)),
|
|
426
|
+
docidentifier: bare.label,
|
|
427
|
+
doc_number: bare.doc_number,
|
|
428
|
+
edition: bare.edition ?? understanding?.edition ?? null,
|
|
429
|
+
};
|
|
430
|
+
console.log("question names", bare.label, "— scoping retrieval from the text");
|
|
431
|
+
}
|
|
432
|
+
} else if (declaredCtx.kind === "account") {
|
|
433
|
+
// Provisional echo (TODO.ai-platform/03): the live read's outcome
|
|
434
|
+
// refines it after the conversational branch — a conversational turn
|
|
435
|
+
// never reads the account. The account context NEVER scopes corpus
|
|
436
|
+
// retrieval (scoped_to stays null; the corpus answers the regulatory
|
|
437
|
+
// half, the records answer the account half).
|
|
438
|
+
ctxApplied = appliedContext(declaredCtx, null);
|
|
439
|
+
} else if (docScope && (!named || named.doc_number === docScope.doc_number)) {
|
|
440
|
+
// the chip scopes; a same-family document named in the question
|
|
441
|
+
// agrees with it. An understand extraction the text does not name is
|
|
442
|
+
// an inference — the chip overrides it.
|
|
443
|
+
if (understanding?.doc_number && understanding.doc_number !== docScope.doc_number) {
|
|
444
|
+
console.log("context scope: understand's doc#" + understanding.doc_number, "is inferred, not named in the question — the declared", docScope.label, "scopes");
|
|
445
|
+
}
|
|
446
|
+
understanding = {
|
|
447
|
+
...(understanding ?? syntheticUnderstanding(docScope)),
|
|
448
|
+
docidentifier: docScope.label,
|
|
449
|
+
doc_number: docScope.doc_number,
|
|
450
|
+
edition: docScope.edition ?? understanding?.edition ?? null,
|
|
451
|
+
};
|
|
452
|
+
ctxApplied = appliedContext(declaredCtx, docScope);
|
|
453
|
+
declaredScoped = true;
|
|
454
|
+
console.log("context scope:", docScope.label, `(${declaredCtx.kind})`);
|
|
455
|
+
} else if (docScope && named) {
|
|
456
|
+
// the question names a DIFFERENT publication — the user's explicit
|
|
457
|
+
// words win over the chip, and retrieval follows the named document.
|
|
458
|
+
// When understand extracted the same document its fields stay (they
|
|
459
|
+
// can carry a phrased edition pin the text parse does not read).
|
|
460
|
+
if (understanding?.doc_number !== named.doc_number) {
|
|
461
|
+
understanding = {
|
|
462
|
+
...(understanding ?? syntheticUnderstanding(named)),
|
|
463
|
+
docidentifier: named.label,
|
|
464
|
+
doc_number: named.doc_number,
|
|
465
|
+
edition: named.edition ?? null,
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
ctxApplied = appliedContext(declaredCtx, null, "question-document-wins");
|
|
469
|
+
console.log("context scope: the question names", named.label, "— it wins over the declared", docScope.label);
|
|
470
|
+
} else if (declaredCtx.doc) {
|
|
471
|
+
ctxApplied = appliedContext(declaredCtx, null, "document-not-in-corpus");
|
|
472
|
+
console.log("context scope:", declaredCtx.doc, "not in the corpus — the general corpus answers");
|
|
473
|
+
} else {
|
|
474
|
+
ctxApplied = appliedContext(declaredCtx, null);
|
|
475
|
+
}
|
|
476
|
+
if (conversationId && understanding) {
|
|
477
|
+
const now = Date.now();
|
|
478
|
+
const ents: Array<[string, string]> = [];
|
|
479
|
+
if (understanding.docidentifier) ents.push([understanding.docidentifier, "document"]);
|
|
480
|
+
for (const t of understanding.defined_terms) ents.push([t, "term"]);
|
|
481
|
+
if (ents.length) {
|
|
482
|
+
const upsert = (e: string, k: string) => env.DB.prepare("INSERT OR REPLACE INTO conversation_entities (conversation_id, entity, kind, ts) VALUES (?1, ?2, ?3, ?4)").bind(conversationId, e, k, now).run();
|
|
483
|
+
ctx.waitUntil(Promise.allSettled(ents.map(([e, k]) => upsert(e, k))));
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
console.log("understand:", understanding?.intent ?? "null", understanding?.doc_number ? `doc#${understanding.doc_number}${understanding.edition ? "@" + understanding.edition : ""}` : "nodoc", "|", q.query.slice(0, 50));
|
|
487
|
+
const graphDocNumbers = await graphExpand(env, understanding);
|
|
488
|
+
const eNote = await editionNote(env, understanding);
|
|
489
|
+
|
|
490
|
+
// semantic cache: near-duplicate of a recently answered question —
|
|
491
|
+
// serves the stored answer with a `similar: true` marker (checked only
|
|
492
|
+
// for standalone knowledge questions; contextual turns, declared-context
|
|
493
|
+
// asks and image asks always run live; fresh regenerates, bypassing
|
|
494
|
+
// this cache too)
|
|
495
|
+
if (understanding?.intent !== "conversational" && !nodeScoped && !contextual && !declaredCtx && !draftAct && !userImage && !fresh) {
|
|
496
|
+
const warmVec = (await warmEmbed) ?? null;
|
|
497
|
+
if (warmVec) {
|
|
498
|
+
const sc = await semanticCacheGet(env, gen, warmVec, salt);
|
|
499
|
+
if (sc) {
|
|
500
|
+
console.log("semantic cache hit");
|
|
501
|
+
telemetry(env, ctx, tier, "ask", null, true, sc.answer.length, sc.query_hash, q.lang);
|
|
502
|
+
const cctx = sc.context_applied ?? NO_CONTEXT;
|
|
503
|
+
if (wantsStream) {
|
|
504
|
+
return sseResponse([{ type: "citations", citations: sc.citations ?? [], context_applied: cctx }, { type: "token", v: sc.answer }, { type: "done", model: sc.model, query_hash: sc.query_hash, similar: true, context_applied: cctx }], corsHeaders(req));
|
|
505
|
+
}
|
|
506
|
+
return json({ ...sc, similar: true, context_applied: cctx, quota, });
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Conversational route, decided by query UNDERSTANDING (any language, any
|
|
512
|
+
// phrasing) — not string matching. No retrieval: nothing in the corpus
|
|
513
|
+
// answers "who are you". The model speaks for itself from the service
|
|
514
|
+
// facts in identityNote (composed from the DATASETS catalog). A declared
|
|
515
|
+
// context is honestly NOT applied here (nothing grounds a conversational
|
|
516
|
+
// turn) — the echo reports none.
|
|
517
|
+
if (understanding?.intent === "conversational") {
|
|
518
|
+
const queryHash = await sha256Hex(q.query);
|
|
519
|
+
const messages = [
|
|
520
|
+
{ role: "system", content: identityNote(!!member) },
|
|
521
|
+
...(summary ? [{ role: "system", content: `Earlier in this conversation (summarized for continuity):\n${summary}` }] : []),
|
|
522
|
+
...keptHistory.slice(-6),
|
|
523
|
+
{ role: "user", content: q.query },
|
|
524
|
+
];
|
|
525
|
+
if (wantsStream) {
|
|
526
|
+
const stream = await generateStream(env, model, messages, effort);
|
|
527
|
+
if (stream) {
|
|
528
|
+
const encoder = new TextEncoder();
|
|
529
|
+
const sse = new ReadableStream({
|
|
530
|
+
async start(controller) {
|
|
531
|
+
const send = (obj: unknown) => controller.enqueue(encoder.encode(`data: ${JSON.stringify(obj)}\n\n`));
|
|
532
|
+
send({ type: "citations", citations: [], context_applied: NO_CONTEXT, quota, });
|
|
533
|
+
let full = "";
|
|
534
|
+
try {
|
|
535
|
+
for await (const tok of sseTokens(stream)) {
|
|
536
|
+
full += tok;
|
|
537
|
+
send({ type: "token", v: tok });
|
|
538
|
+
}
|
|
539
|
+
} catch {
|
|
540
|
+
// stream ended prematurely — deliver what we have
|
|
541
|
+
}
|
|
542
|
+
send({ type: "done", model, query_hash: queryHash, context_applied: NO_CONTEXT });
|
|
543
|
+
telemetry(env, ctx, tier, "ask", model, true, full.length, queryHash, q.lang);
|
|
544
|
+
controller.close();
|
|
545
|
+
},
|
|
546
|
+
});
|
|
547
|
+
return new Response(sse, {
|
|
548
|
+
headers: { "content-type": "text/event-stream", "cache-control": "no-cache", "x-accel-buffering": "no", ...corsHeaders(req) },
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
let answer = await generateOnce(env, model, messages, effort);
|
|
553
|
+
if (answer === null) answer = await generateOnce(env, MODELS.fallback, messages, effort);
|
|
554
|
+
if (answer === null) {
|
|
555
|
+
telemetry(env, ctx, tier, "ask", model, false, 0, queryHash, q.lang);
|
|
556
|
+
return err(502, "generation_failed", "The generation model is unavailable; please retry.");
|
|
557
|
+
}
|
|
558
|
+
telemetry(env, ctx, tier, "ask", model, true, answer.length, queryHash, q.lang);
|
|
559
|
+
return json({ answer, citations: [], model, query_hash: queryHash, follow_ups: [], context_applied: NO_CONTEXT, quota, });
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// ── The draft act (TODO.ai-platform/04) — the assistant PREPARES, the
|
|
563
|
+
// user commits in the platform's real UI. Branched after the
|
|
564
|
+
// conversational route (a draft ask is not one) and before retrieval
|
|
565
|
+
// (the draft grounds in the conversation + the registry anchor, not
|
|
566
|
+
// the corpus passages). THE SERVICE NEVER WRITES: the only credential
|
|
567
|
+
// in play is the read-scoped delegation (the RFC 8693 exchange, the
|
|
568
|
+
// same one the "my account" reads ride), and it only ever feeds the
|
|
569
|
+
// ROLE check — the refusal speaks the platform's own vocabulary. The
|
|
570
|
+
// draft rides the response's `draft` field to the panel, which hands
|
|
571
|
+
// it to the platform's real form; the commit is the user's own click.
|
|
572
|
+
if (draftAct) {
|
|
573
|
+
const draftCtxApplied = declaredCtx ? appliedContext(declaredCtx, null) : NO_CONTEXT;
|
|
574
|
+
const queryHash = await sha256Hex(q.query);
|
|
575
|
+
// The delegation's honest states, computed exactly as the live-data
|
|
576
|
+
// path computes them (livedata.ts): the member's session → the
|
|
577
|
+
// exchange → the read-scoped token whose roles the draft reads.
|
|
578
|
+
const liveCfg = liveDataConfig(env);
|
|
579
|
+
const sessionRaw = rawSessionToken(req);
|
|
580
|
+
let delegation;
|
|
581
|
+
if (!member || !sessionRaw) delegation = { status: "unsigned" as const };
|
|
582
|
+
else if (!liveCfg) delegation = { status: "not_configured" as const };
|
|
583
|
+
else {
|
|
584
|
+
const exchanged = await exchangeForLiveToken(env, sessionRaw);
|
|
585
|
+
delegation = exchanged.ok
|
|
586
|
+
? { status: "ok" as const, token: exchanged.token }
|
|
587
|
+
: { status: exchanged.reason };
|
|
588
|
+
}
|
|
589
|
+
const verdict = await prepareDraft(env, {
|
|
590
|
+
act: draftAct,
|
|
591
|
+
query: q.query,
|
|
592
|
+
history: keptHistory,
|
|
593
|
+
member,
|
|
594
|
+
delegation,
|
|
595
|
+
platformClientId: liveCfg?.platformClientId,
|
|
596
|
+
model: roleModel(env, "understand"),
|
|
597
|
+
});
|
|
598
|
+
console.log("draft act:", draftAct, "→", verdict.status === "draft" ? `draft (${Object.keys(verdict.draft.fields).length} fields)` : `refused (${verdict.reason})`);
|
|
599
|
+
const citations = verdict.citation ? [{ ...verdict.citation, corpus: "oiml" }] : [];
|
|
600
|
+
const draftPayload = verdict.status === "draft" ? verdict.draft : undefined;
|
|
601
|
+
telemetry(env, ctx, tier, "ask", model, true, verdict.answer.length, queryHash, q.lang);
|
|
602
|
+
if (wantsStream) {
|
|
603
|
+
return sseResponse(
|
|
604
|
+
[
|
|
605
|
+
{ type: "citations", citations, context_applied: draftCtxApplied, ...(draftPayload ? { draft: draftPayload } : {}), quota, },
|
|
606
|
+
{ type: "token", v: verdict.answer },
|
|
607
|
+
{ type: "done", model, query_hash: queryHash, context_applied: draftCtxApplied },
|
|
608
|
+
],
|
|
609
|
+
corsHeaders(req),
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
return json({ answer: verdict.answer, citations, model, query_hash: queryHash, follow_ups: [], context_applied: draftCtxApplied, ...(draftPayload ? { draft: draftPayload } : {}), quota, });
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// (declared before the retrieval try: the account block, the refusal
|
|
616
|
+
// gate, the prompt and the response all read them)
|
|
617
|
+
let liveRecords: LiveRecord[] | undefined;
|
|
618
|
+
// set by the contract check when the answer presents a served table's
|
|
619
|
+
// data without its reference and the corrected retry still omitted the
|
|
620
|
+
// token — the worker then attaches the table block itself
|
|
621
|
+
let accountNote: string | undefined;
|
|
622
|
+
// ── The model plane's node binding (TODO.ai-platform/05) ──
|
|
623
|
+
// "this requirement" on a model surface grounds in the model NODE
|
|
624
|
+
// itself (its constraint, its provenance, its tests): the declared
|
|
625
|
+
// entity label leads with the canonical node id (the platform's
|
|
626
|
+
// publish contract); a question may name one too. The standard comes
|
|
627
|
+
// from the DECLARED or question-NAMED publication only — understand's
|
|
628
|
+
// LLM extraction is an inference and never narrows the bind (the
|
|
629
|
+
// wave-02 lesson); scope-less binds hold only when the node id is
|
|
630
|
+
// unambiguous across the indexed standards.
|
|
631
|
+
const modelDocHint = named ?? docScope ?? namedDocumentIn(q.query);
|
|
632
|
+
const boundModel = await bindModelNode(env, {
|
|
633
|
+
label: declaredCtx?.label,
|
|
634
|
+
query: q.query,
|
|
635
|
+
standard: standardForDocNumber(modelDocHint?.doc_number),
|
|
636
|
+
});
|
|
637
|
+
if (boundModel) {
|
|
638
|
+
ctxApplied = { ...ctxApplied, model: modelEcho(boundModel) };
|
|
639
|
+
console.log("model plane: bound", boundModel.node_id, `[${boundModel.standard}]`, boundModel.clause?.urn ?? "no-clause");
|
|
640
|
+
}
|
|
641
|
+
const modelNote = boundModel ? modelGroundingBlock(boundModel) : undefined;
|
|
642
|
+
// ── the verdict engine (TODO.era3/01) ──
|
|
643
|
+
// the worker EXECUTES the bound node's machine checks against the
|
|
644
|
+
// question's stated values; the model narrates the computed verdict
|
|
645
|
+
// and the verdict BLOCK is server-built — data, never generated prose
|
|
646
|
+
const machineVerdict = boundModel ? machineEvaluate(boundModel.content, q.query) : null;
|
|
647
|
+
const machineNote = machineVerdict && boundModel ? verdictNote(machineVerdict, boundModel) : undefined;
|
|
648
|
+
const verdictBlock = machineVerdict
|
|
649
|
+
? {
|
|
650
|
+
unit_id: boundModel!.node_id,
|
|
651
|
+
type: "verdict",
|
|
652
|
+
docidentifier: `OIML SMART model (${boundModel!.standard})`,
|
|
653
|
+
payload: {
|
|
654
|
+
verdict: machineVerdict.verdict,
|
|
655
|
+
on_violation: machineVerdict.on_violation,
|
|
656
|
+
violation_meaning: machineVerdict.violation_meaning,
|
|
657
|
+
missing: machineVerdict.missing,
|
|
658
|
+
checks: machineVerdict.checks,
|
|
659
|
+
},
|
|
660
|
+
}
|
|
661
|
+
: null;
|
|
662
|
+
if (machineVerdict) console.log("verdict engine:", boundModel!.node_id, "→", machineVerdict.verdict.toUpperCase(), machineVerdict.missing.length ? `(missing ${machineVerdict.missing.join(",")})` : "");
|
|
663
|
+
try {
|
|
664
|
+
const tR = Date.now();
|
|
665
|
+
// ── The "my account" live read (TODO.ai-platform/03) — resolved
|
|
666
|
+
// HERE, after the conversational branch (a conversational turn never
|
|
667
|
+
// reads the account) and before retrieval (the records join the
|
|
668
|
+
// prompt beside the corpus passages). The cones bind exactly as for
|
|
669
|
+
// the user's own browser: the exchange (the identity service's RFC
|
|
670
|
+
// 8693 session delegation) re-judges the standing live, and the
|
|
671
|
+
// platform's API enforces the visibility — this service only ever
|
|
672
|
+
// maps what the platform answered. Every failure degrades honestly:
|
|
673
|
+
// the answer runs on the corpus and the context line says WHY the
|
|
674
|
+
// live data was not read.
|
|
675
|
+
if (declaredCtx?.kind === "account") {
|
|
676
|
+
const live = await resolveLiveAccount(env, rawSessionToken(req), member);
|
|
677
|
+
if (live.status === "ok") {
|
|
678
|
+
liveRecords = live.records;
|
|
679
|
+
ctxApplied = appliedContext(declaredCtx, null, undefined, {
|
|
680
|
+
read_at: live.readAt,
|
|
681
|
+
stores: live.stores,
|
|
682
|
+
records: live.records.length,
|
|
683
|
+
});
|
|
684
|
+
const lines = live.records.map(
|
|
685
|
+
(r) => `- ${r.label} [${[r.status, r.detail].filter(Boolean).join("; ")}] ${r.url}`,
|
|
686
|
+
);
|
|
687
|
+
accountNote =
|
|
688
|
+
`Live account data (read ${live.readAt} from the user's own OIML SMART account — exactly what they may see, never more):\n` +
|
|
689
|
+
(lines.length ? lines.join("\n") : "(the account surfaces answered empty)") +
|
|
690
|
+
`\nAnswer account questions from these records ONLY: name the record when you use it, never invent one, and say honestly when they do not hold the answer. The corpus passages still ground the regulatory claims (the requirements, the procedures); the records are the user's own work.`;
|
|
691
|
+
console.log("live data:", live.records.length, "records from", live.stores.join("+") || "none");
|
|
692
|
+
} else {
|
|
693
|
+
const note =
|
|
694
|
+
live.reason === "sign_in_required" ? "sign-in-required"
|
|
695
|
+
: live.reason === "window_expired" ? "live-window-expired"
|
|
696
|
+
: "live-unavailable";
|
|
697
|
+
ctxApplied = appliedContext(declaredCtx, null, note);
|
|
698
|
+
accountNote =
|
|
699
|
+
live.reason === "sign_in_required"
|
|
700
|
+
? "Context note: the user asked with the 'my account' context but is not signed in — the account data was NOT read; answer from the corpus and say so."
|
|
701
|
+
: live.reason === "window_expired"
|
|
702
|
+
? "Context note: the user's live access window lapsed — the account data was NOT read; answer from the corpus, say the live read did not happen, and suggest signing in again to refresh it."
|
|
703
|
+
: "Context note: the live account read was refused or unreachable — the account data was NOT read; answer from the corpus and say so honestly.";
|
|
704
|
+
console.log("live data: not read —", live.reason);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
// The DECLARED context's scope is a HARD seal (TODO.ai-platform/02):
|
|
708
|
+
// the panel's context line claims the grounding, so no passage from
|
|
709
|
+
// outside the declared publication may reach the answer. The seal is
|
|
710
|
+
// applied to the CANDIDATE POOL inside retrieve — the soft-steer
|
|
711
|
+
// widenings (the sparse-filter union, the full-corpus lexical union,
|
|
712
|
+
// the sub-query lanes) can otherwise outscore the filtered dense lane
|
|
713
|
+
// under the cross-encoder and push every in-family passage out of the
|
|
714
|
+
// top-N before a post-hoc seal ever sees one. A document named IN THE
|
|
715
|
+
// QUESTION keeps the soft steer by design (the widen covers sparse
|
|
716
|
+
// publications there).
|
|
717
|
+
retrieved = await retrieve(env, q.query, { prev, understanding, federate, warmEmbed, graphDocNumbers,
|
|
718
|
+
sealScope: declaredScoped ? docScope : null, optimisticHits, optimisticVec,
|
|
719
|
+
datasetScope: narrowed ? corpora : null });
|
|
720
|
+
console.log("stage: retrieve", Date.now() - tR, "ms");
|
|
721
|
+
// ── TTFT surgery: the two post-retrieval LLM calls run IN PARALLEL —
|
|
722
|
+
// they consume the same candidate list (grade is coarse: good/weak;
|
|
723
|
+
// listwise reorders survivors). Doc-scoped queries skip the grade
|
|
724
|
+
// entirely (the filter already pins the corpus; grading adds only latency).
|
|
725
|
+
const docScoped = !!(understanding?.doc_number);
|
|
726
|
+
const gradePromise = docScoped
|
|
727
|
+
? Promise.resolve("skipped-doc-scoped" as const)
|
|
728
|
+
: gradeRetrieval(env.AI, MODELS.grader, q.query, retrieved.hits.map((h: Hit) => h.text)).catch(() => null);
|
|
729
|
+
if (retrieved.hits.length >= 4 && (member || understanding?.complexity === "complex")) {
|
|
730
|
+
const reordered = await listwiseRerank(env, MODELS.listwise, understanding?.standalone_query || q.query, retrieved.hits);
|
|
731
|
+
if (reordered) {
|
|
732
|
+
console.log("listwise: reordered", reordered[0]?.metadata?.docidentifier ?? "?", "to top");
|
|
733
|
+
retrieved = { hits: reordered, filters: retrieved.filters };
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
const grade = await gradePromise;
|
|
737
|
+
console.log("stage: grade+listwise", Date.now() - tR, "ms since retrieve start | grade:", grade);
|
|
738
|
+
if (grade === "weak" && understanding?.docidentifier) {
|
|
739
|
+
const broaden = `${understanding.standalone_query || q.query} ${understanding.docidentifier}`.trim();
|
|
740
|
+
const second = await retrieve(env, q.query, { prev, understanding, queryOverride: broaden, federate, datasetScope: narrowed ? corpora : null });
|
|
741
|
+
const grade2 = await gradeRetrieval(env.AI, MODELS.grader, q.query, second.hits.map((h: Hit) => h.text));
|
|
742
|
+
if (grade2 === "good") retrieved = second; // corrective retry must be strictly better
|
|
743
|
+
}
|
|
744
|
+
} catch (e) {
|
|
745
|
+
console.log("ask: retrieval failed:", String(e).slice(0, 300));
|
|
746
|
+
telemetry(env, ctx, tier, "ask", MODELS.embed, false, 0, await sha256Hex(q.query), q.lang);
|
|
747
|
+
return err(503, "retrieval_unavailable", "Search is briefly busy — please retry in a moment.");
|
|
748
|
+
}
|
|
749
|
+
const { hits } = retrieved;
|
|
750
|
+
if (hits.length === 0 && !liveRecords?.length && !boundModel) {
|
|
751
|
+
const answer = refusalAnswer();
|
|
752
|
+
const out = { answer, citations: [], model, query_hash: await sha256Hex(q.query), context_applied: ctxApplied };
|
|
753
|
+
telemetry(env, ctx, tier, "ask", model, true, answer.length, out.query_hash, q.lang);
|
|
754
|
+
return json({ ...out, quota, });
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
const processNote = understanding?.process_intent
|
|
758
|
+
? "Retrieval note: these passages come from the OIML Certification System documents because they govern certification/application procedures for OIML publications."
|
|
759
|
+
: undefined;
|
|
760
|
+
// the vocabulary binding (L2): the corpus's defined-term candidates for
|
|
761
|
+
// the question's subject — the model adjudicates among them and uses
|
|
762
|
+
// the corpus term (with its defining publication) when it names the
|
|
763
|
+
// subject; everyday words stop hiding the defined term
|
|
764
|
+
// filter the note by the understanding's own defined_terms when it
|
|
765
|
+
// identified them — the note bridges everyday words to the corpus
|
|
766
|
+
// term; when the understanding already named the right term, offering
|
|
767
|
+
// wrong alternatives (creep when the question is about months of use)
|
|
768
|
+
// gives the answer model a way to pick the wrong one
|
|
769
|
+
const glossaryForNote = (() => {
|
|
770
|
+
const g = retrieved.glossary ?? [];
|
|
771
|
+
if (!g.length) return g;
|
|
772
|
+
const dt = (understanding?.defined_terms ?? []).map((s: string) => s.toLowerCase());
|
|
773
|
+
if (!dt.length) return g;
|
|
774
|
+
const matched = g.filter((x) => dt.some((d: string) => x.term.toLowerCase().includes(d.split(" ")[0]) || d.includes(x.term.toLowerCase().split(" ")[0])));
|
|
775
|
+
return matched.length ? matched : g; // understanding named terms the glossary didn't carry — keep all
|
|
776
|
+
})();
|
|
777
|
+
const vocabNote = glossaryForNote.length
|
|
778
|
+
? "Vocabulary binding — defined terms in the indexed corpus that may name this question's subject:\n" +
|
|
779
|
+
glossaryForNote.map((g) => `- ${g.term} (${g.docidentifier}): ${g.definition}`).join("\n") +
|
|
780
|
+
"\nIf the question describes a symptom or behavior in everyday words, OPEN the answer by naming the matching defined term, quote its definition, and cite its defining publication; keep using that term throughout. Match TIME SCALE carefully: change under a constant load over minutes/hours is creep; change over months/years of use is span stability or durability — do not call long-term drift creep."
|
|
781
|
+
: undefined;
|
|
782
|
+
const { messages, usedHits } = buildMessages(
|
|
783
|
+
q.query,
|
|
784
|
+
hits,
|
|
785
|
+
q.lang,
|
|
786
|
+
keptHistory,
|
|
787
|
+
[processNote, eNote, contextNote(declaredCtx, docScope), accountNote, modelNote, vocabNote, memNote, machineNote].filter(Boolean).join("\n") || undefined,
|
|
788
|
+
summary,
|
|
789
|
+
budget,
|
|
790
|
+
);
|
|
791
|
+
await attachFigureImages(env, messages, usedHits, q.query);
|
|
792
|
+
if (userImage) {
|
|
793
|
+
// the user's own image rides on the question message — retrieval stays
|
|
794
|
+
// text-driven; the answer model reads the image as question context
|
|
795
|
+
const last = messages[messages.length - 1];
|
|
796
|
+
const note = "\n\n(The user attached an image with this question; interpret it directly when answering.)";
|
|
797
|
+
if (Array.isArray(last.content)) {
|
|
798
|
+
const textPart = last.content.find((p: any) => p.type === "text");
|
|
799
|
+
if (textPart) textPart.text += note;
|
|
800
|
+
last.content = [...last.content, { type: "image_url", image_url: { url: userImage } }] as unknown as string;
|
|
801
|
+
} else {
|
|
802
|
+
last.content = [
|
|
803
|
+
{ type: "text", text: last.content + note },
|
|
804
|
+
{ type: "image_url", image_url: { url: userImage } },
|
|
805
|
+
] as unknown as string;
|
|
806
|
+
}
|
|
807
|
+
console.log("user image attached to generation");
|
|
808
|
+
}
|
|
809
|
+
const queryHash = await sha256Hex(q.query);
|
|
810
|
+
// The bound model node leads the citations (TODO.ai-platform/05): the
|
|
811
|
+
// panel's first citation card IS the model node — its constraint, its
|
|
812
|
+
// provenance — ahead of the prose passages.
|
|
813
|
+
const cites = boundModel ? [modelCitation(boundModel), ...citations(usedHits)] : citations(usedHits);
|
|
814
|
+
|
|
815
|
+
if (wantsStream) {
|
|
816
|
+
const stream = await generateStream(env, model, messages, effort);
|
|
817
|
+
if (stream) {
|
|
818
|
+
const encoder = new TextEncoder();
|
|
819
|
+
const sse = new ReadableStream({
|
|
820
|
+
async start(controller) {
|
|
821
|
+
const send = (obj: unknown) => controller.enqueue(encoder.encode(`data: ${JSON.stringify(obj)}\n\n`));
|
|
822
|
+
send({ type: "citations", citations: cites, context_applied: ctxApplied, ...(liveRecords ? { records: liveRecords } : {}), quota, });
|
|
823
|
+
let full = "";
|
|
824
|
+
try {
|
|
825
|
+
for await (const tok of sseTokens(stream)) {
|
|
826
|
+
full += tok;
|
|
827
|
+
send({ type: "token", v: tok });
|
|
828
|
+
}
|
|
829
|
+
} catch {
|
|
830
|
+
// stream ended prematurely — deliver what we have
|
|
831
|
+
}
|
|
832
|
+
const canonical0 = canonicalRefusal(full);
|
|
833
|
+
// answer contract v2: validate [[u:]] refs, resolve typed blocks
|
|
834
|
+
const c2 = canonical0.includes(refusalAnswer())
|
|
835
|
+
? { text: canonical0, blocks: [], dropped: [] as string[] }
|
|
836
|
+
: await contractV2(env.DB, canonical0, usedHits);
|
|
837
|
+
send({ type: "done", model, query_hash: queryHash, follow_ups: understanding?.follow_ups ?? [], blocks: verdictBlock ? [...c2.blocks, verdictBlock] : c2.blocks, context_applied: ctxApplied });
|
|
838
|
+
telemetry(env, ctx, tier, "ask", model, true, c2.text.length, queryHash, q.lang);
|
|
839
|
+
const canonical = c2.text;
|
|
840
|
+
// streamed answers can't be regenerated mid-flight; enforcement
|
|
841
|
+
// is that an unverified answer is never served from cache again
|
|
842
|
+
const streamedAnchors = checkQuoteAnchors(canonical, usedHits.map((h: Hit) => h.text));
|
|
843
|
+
const streamedRetyped = tableRetyped(canonical, usedHits.some((h: Hit) => h.metadata.unit_id && h.metadata.block === "table"));
|
|
844
|
+
const streamed = { total: streamedAnchors.total, violations: streamedRetyped ? ["table-retyped"] : streamedAnchors.violations };
|
|
845
|
+
if (streamed.violations.length > 0) {
|
|
846
|
+
console.log("anchors:", streamed.violations.length, "of", streamed.total, "unverified — not caching");
|
|
847
|
+
}
|
|
848
|
+
if (streamed.violations.length === 0 && canonical.length > 0 && !contextual && !declaredCtx && !canonical.includes(refusalAnswer())) {
|
|
849
|
+
const wv = (await warmEmbed) ?? null;
|
|
850
|
+
if (wv) semanticCachePut(env, ctx, gen, wv, salt, { answer: canonical, citations: cites, model, query_hash: queryHash });
|
|
851
|
+
ctx.waitUntil(
|
|
852
|
+
env.CACHE.put(exactCacheKey(env.INDEX_VERSION, gen, ns, await sha256Hex(cacheKeyMaterial(q.query, q.lang, salt))), JSON.stringify({ answer: canonical, citations: cites, model, query_hash: queryHash }), { expirationTtl: LIMITS.cacheTtlSec }),
|
|
853
|
+
);
|
|
854
|
+
}
|
|
855
|
+
controller.close();
|
|
856
|
+
},
|
|
857
|
+
});
|
|
858
|
+
return new Response(sse, {
|
|
859
|
+
headers: {
|
|
860
|
+
"content-type": "text/event-stream",
|
|
861
|
+
"cache-control": "no-cache",
|
|
862
|
+
"x-accel-buffering": "no",
|
|
863
|
+
...corsHeaders(req),
|
|
864
|
+
},
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
let answer = await generateOnce(env, model, messages, effort);
|
|
870
|
+
if (answer === null) {
|
|
871
|
+
// the fallback is a text-only model: image parts must be flattened
|
|
872
|
+
// out first or it errors on (or silently ignores) the pixels the
|
|
873
|
+
// primary was carrying — and the figure-attach NOTE with them: a
|
|
874
|
+
// message saying "the image is attached" to a model that cannot see
|
|
875
|
+
// images gets ANSWERED ("I don't have access to the original
|
|
876
|
+
// image…") instead of the question (observed in the wild). The
|
|
877
|
+
// user-image message keeps its text (the question) minus its note.
|
|
878
|
+
const isFigureAttachMessage = (m: any) =>
|
|
879
|
+
Array.isArray(m.content) &&
|
|
880
|
+
m.content.some((part: any) => part?.type === "text" && /^The original image of figure unit /.test(part.text ?? ""));
|
|
881
|
+
const flat = messages
|
|
882
|
+
.filter((m: any) => !isFigureAttachMessage(m))
|
|
883
|
+
.map((m: any) =>
|
|
884
|
+
typeof m.content === "string"
|
|
885
|
+
? m
|
|
886
|
+
: { ...m, content: m.content.filter((p: any) => p?.type === "text").map((p: any) => (p?.text ?? "").replace(/\n?\(The user attached an image with this question; interpret it directly when answering\.\)/, "")).join("\n") },
|
|
887
|
+
);
|
|
888
|
+
answer = await generateOnce(env, MODELS.fallback, flat, effort);
|
|
889
|
+
}
|
|
890
|
+
if (answer) answer = canonicalRefusal(answer);
|
|
891
|
+
|
|
892
|
+
// ── Deterministic quote-anchor + table-retyping check ──
|
|
893
|
+
// One corrective regeneration when an anchor quotes text absent from
|
|
894
|
+
// the passages or a typed table was retyped as markdown; the retry
|
|
895
|
+
// wins only if it verifies better.
|
|
896
|
+
let used = usedHits;
|
|
897
|
+
if (answer && !answer.includes(refusalAnswer())) {
|
|
898
|
+
const anchors = checkQuoteAnchors(answer, used.map((h: Hit) => h.text));
|
|
899
|
+
const hasTableUnit = used.some((h: Hit) => h.metadata.unit_id && h.metadata.block === "table");
|
|
900
|
+
const retyped = tableRetyped(answer, hasTableUnit);
|
|
901
|
+
// presenting a served table's DATA without its unit reference is the
|
|
902
|
+
// same contract violation as retyping it — the HARD RULE wants the
|
|
903
|
+
// token wherever the table's values carry the answer
|
|
904
|
+
const unreferenced = (() => {
|
|
905
|
+
if (!hasTableUnit || answer.includes("[[u:")) return false;
|
|
906
|
+
const norm = (s: string) => (s.match(/\d[\d ,.]{1,8}\d/g) ?? []).map((x) => x.replace(/[ ,.]/g, ""));
|
|
907
|
+
const nums = norm(answer);
|
|
908
|
+
if (nums.length < 2) return false;
|
|
909
|
+
const tableNums = new Set(
|
|
910
|
+
norm(used.filter((h: Hit) => h.metadata.unit_id && h.metadata.block === "table").map((h: Hit) => h.text).join(" ")),
|
|
911
|
+
);
|
|
912
|
+
return nums.filter((n) => tableNums.has(n)).length >= 2;
|
|
913
|
+
})();
|
|
914
|
+
if (anchors.violations.length > 0 || retyped || unreferenced) {
|
|
915
|
+
console.log("contract check:", anchors.violations.length, "anchor violations; tableRetyped:", retyped, "; tableDataUnreferenced:", unreferenced, "— regenerating");
|
|
916
|
+
// name the EXACT unit the token must reference — a generic note
|
|
917
|
+
// leaves the model guessing which id to write
|
|
918
|
+
const tableUnitId = unreferenced
|
|
919
|
+
? used.find((h: Hit) => h.metadata.unit_id && h.metadata.block === "table")?.metadata.unit_id
|
|
920
|
+
: undefined;
|
|
921
|
+
const note = retyped || unreferenced
|
|
922
|
+
? `Correction notice: your draft reproduced a table as markdown or presented a served table's data without its reference. Rewrite the answer: describe the table in prose, cite the clause, and write the reference token [[u:${tableUnitId ?? "<unit id>"}]] exactly where the table belongs. Do not render any table as markdown.`
|
|
923
|
+
: ANCHOR_CORRECTION_NOTE;
|
|
924
|
+
const corrected = await generateOnce(env, model, [...messages, { role: "system", content: note }], effort);
|
|
925
|
+
if (corrected) {
|
|
926
|
+
const correctedAnswer = canonicalRefusal(corrected);
|
|
927
|
+
const retryAnchors = checkQuoteAnchors(correctedAnswer, used.map((h: Hit) => h.text));
|
|
928
|
+
const retryRetyped = tableRetyped(correctedAnswer, hasTableUnit);
|
|
929
|
+
if (retryAnchors.violations.length < anchors.violations.length || (!retryRetyped && retyped) || (unreferenced && correctedAnswer.includes("[[u:"))) {
|
|
930
|
+
answer = correctedAnswer;
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
// contract COMPLETION (the verdict-block philosophy): if the answer
|
|
935
|
+
// presents a served table's data and the model still did not write
|
|
936
|
+
// the reference after the corrected retry, the worker attaches the
|
|
937
|
+
// block itself — the renderer draws from the blocks array, so the
|
|
938
|
+
// table reaches the user exactly from the producer's payload with or
|
|
939
|
+
// without the model's inline token. The contract is mechanical, not
|
|
940
|
+
// a hope: two generation samples failing no longer ships a violation.
|
|
941
|
+
// ALWAYS complete the contract when a table unit was served and the
|
|
942
|
+
// model didn't reference it — the block is additive (the renderer
|
|
943
|
+
// shows it from the producer's payload regardless of the inline
|
|
944
|
+
// token), so there is no reason to condition on number-matching
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// ── Self-RAG reflection loop ──
|
|
948
|
+
// The model critiques its own answer; if claims are ungrounded, retry
|
|
949
|
+
// retrieval with the missing-info hint (max one retry).
|
|
950
|
+
// Ref: selfrag.github.io; arXiv 2606.05658 bounded reflection
|
|
951
|
+
if (answer && !answer.includes(refusalAnswer())) {
|
|
952
|
+
const reflection = await reflect(env.AI, MODELS.grader, q.query, answer, hits.map((h: Hit) => h.text));
|
|
953
|
+
console.log("reflection:", reflection ? (reflection.grounded ? "grounded" : "ungrounded") : "null");
|
|
954
|
+
if (reflection && !reflection.grounded && reflection.missing_info) {
|
|
955
|
+
// re-retrieve targeting what was missing — the declared context's
|
|
956
|
+
// hard seal binds the retry exactly as the first pass
|
|
957
|
+
// (TODO.ai-platform/02)
|
|
958
|
+
const retryRetrieve = await retrieve(env, q.query, {
|
|
959
|
+
prev,
|
|
960
|
+
understanding: { ...understanding, standalone_query: `${understanding?.standalone_query || q.query} ${reflection.missing_info}` } as any,
|
|
961
|
+
sealScope: declaredScoped ? docScope : null,
|
|
962
|
+
datasetScope: narrowed ? corpora : null,
|
|
963
|
+
});
|
|
964
|
+
if (retryRetrieve.hits.length > 0) {
|
|
965
|
+
const { messages: retryMessages, usedHits: retryUsed } = buildMessages(q.query, retryRetrieve.hits, q.lang, keptHistory, undefined, summary, budget);
|
|
966
|
+
const retryAnswer = await generateOnce(env, model, retryMessages, effort);
|
|
967
|
+
// the answer now comes from the retry passages — citations must follow
|
|
968
|
+
if (retryAnswer) {
|
|
969
|
+
answer = canonicalRefusal(retryAnswer);
|
|
970
|
+
used = retryUsed;
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
if (answer === null) {
|
|
977
|
+
telemetry(env, ctx, tier, "ask", model, false, 0, queryHash, q.lang);
|
|
978
|
+
return err(502, "generation_failed", "The generation model is unavailable; please retry.");
|
|
979
|
+
}
|
|
980
|
+
const finalCites = boundModel ? [modelCitation(boundModel), ...citations(used)] : citations(used);
|
|
981
|
+
const c2ns = answer.includes(refusalAnswer())
|
|
982
|
+
? { text: answer, blocks: [] as Awaited<ReturnType<typeof contractV2>>["blocks"], dropped: [] as string[] }
|
|
983
|
+
: await contractV2(env.DB, answer, used);
|
|
984
|
+
answer = c2ns.text;
|
|
985
|
+
const finalAnchors = answer.includes(refusalAnswer())
|
|
986
|
+
? { total: 0, violations: [] as string[] }
|
|
987
|
+
: checkQuoteAnchors(answer, used.map((h: Hit) => h.text));
|
|
988
|
+
if (finalAnchors.violations.length > 0) {
|
|
989
|
+
console.log("anchors:", finalAnchors.violations.length, "of", finalAnchors.total, "unverified — not caching");
|
|
990
|
+
}
|
|
991
|
+
// contract completion — POST-c2ns: if the final blocks array carries
|
|
992
|
+
// no table but the answer presents a numeric value from a table in the
|
|
993
|
+
// answer's document family, the worker resolves and attaches it from
|
|
994
|
+
// D1 directly. Running AFTER contractV2 closes the gap where the model
|
|
995
|
+
// wrote [[u:…]] in the retry (completion check saw it, skipped the
|
|
996
|
+
// fallback) but contractV2 then dropped the reference because the unit
|
|
997
|
+
// wasn't in the used passages — leaving no block and no token.
|
|
998
|
+
let completionBlocks: Awaited<ReturnType<typeof completeTables>> = [];
|
|
999
|
+
if (!answer.includes(refusalAnswer()) && !c2ns.blocks.some((b: any) => b.type === "table")) {
|
|
1000
|
+
completionBlocks = await completeTables(env.DB, answer, used);
|
|
1001
|
+
if (completionBlocks.length) console.log("contract completion:", completionBlocks.length, "table block(s) attached server-side");
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
// figure completion (#172) — see ./completion for the rationale
|
|
1005
|
+
completionBlocks.push(...(await completeFigures(env.DB, answer, [...c2ns.blocks, ...completionBlocks])));
|
|
1006
|
+
|
|
1007
|
+
const out = { answer, citations: finalCites, model: MODELS.member, query_hash: queryHash, follow_ups: understanding?.follow_ups ?? [], blocks: [...c2ns.blocks, ...(verdictBlock ? [verdictBlock] : []), ...completionBlocks], context_applied: ctxApplied, ...(liveRecords ? { records: liveRecords } : {}) };
|
|
1008
|
+
const cacheable = !contextual && !declaredCtx && !answer.includes(refusalAnswer()) && finalAnchors.violations.length === 0;
|
|
1009
|
+
if (cacheable) {
|
|
1010
|
+
const warmVec = (await warmEmbed) ?? null;
|
|
1011
|
+
if (warmVec) semanticCachePut(env, ctx, gen, warmVec, salt, out);
|
|
1012
|
+
}
|
|
1013
|
+
if (cacheable) {
|
|
1014
|
+
const ck = exactCacheKey(env.INDEX_VERSION, gen, ns, await sha256Hex(cacheKeyMaterial(q.query, q.lang, salt)));
|
|
1015
|
+
ctx.waitUntil(env.CACHE.put(ck, JSON.stringify(out), { expirationTtl: LIMITS.cacheTtlSec }));
|
|
1016
|
+
}
|
|
1017
|
+
telemetry(env, ctx, tier, "ask", model, true, answer.length, queryHash, q.lang);
|
|
1018
|
+
// grounding transparency for integrators (and the eval battery): the
|
|
1019
|
+
// passages the answer was actually built from — response-only, never
|
|
1020
|
+
// stored in the answer cache
|
|
1021
|
+
const contextOut = used.map((h: Hit) => ({
|
|
1022
|
+
doc_id: h.metadata.doc_id,
|
|
1023
|
+
clause_anchor: h.metadata.clause_anchor,
|
|
1024
|
+
text: h.text.slice(0, 1200),
|
|
1025
|
+
}));
|
|
1026
|
+
return json({ ...out, context: contextOut, quota, ...corsHeaders(req) });
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
function sseResponse(events: unknown[], cors: Record<string, string>): Response {
|
|
1030
|
+
const encoder = new TextEncoder();
|
|
1031
|
+
const body = events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join("");
|
|
1032
|
+
return new Response(encoder.encode(body), {
|
|
1033
|
+
headers: {
|
|
1034
|
+
"content-type": "text/event-stream",
|
|
1035
|
+
"cache-control": "no-cache",
|
|
1036
|
+
"x-accel-buffering": "no",
|
|
1037
|
+
...cors,
|
|
1038
|
+
},
|
|
1039
|
+
});
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
/** RAGAS-style metric battery (G13): judge an (question, answer, passages)
|
|
1045
|
+
* triple — faithfulness, answer relevancy, context precision. Driven by
|
|
1046
|
+
* tests/eval-suite.mjs; prompts are data; refuses nothing, judges only. */
|
|
1047
|
+
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
|
|
1051
|
+
|
|
1052
|
+
// ── Semantic answer cache (G6) ──
|
|
1053
|
+
// Near-duplicate queries re-pay the whole pipeline. Bucket KV by a
|
|
1054
|
+
// leading-dimension signature of the query embedding; confirm with full
|
|
1055
|
+
// cosine >= 0.97 before serving. Same INDEX_VERSION + corpus-generation
|
|
1056
|
+
// namespace as the answer cache (deploys and corpus surgery invalidate
|
|
1057
|
+
// both). Single entry per bucket (v1): collisions overwrite, never mix.
|
|
1058
|
+
function cosine(a: number[], b: number[]): number {
|
|
1059
|
+
let dot = 0;
|
|
1060
|
+
let na = 0;
|
|
1061
|
+
let nb = 0;
|
|
1062
|
+
for (let i = 0; i < a.length; i++) {
|
|
1063
|
+
dot += a[i] * b[i];
|
|
1064
|
+
na += a[i] * a[i];
|
|
1065
|
+
nb += b[i] * b[i];
|
|
1066
|
+
}
|
|
1067
|
+
return dot / (Math.sqrt(na) * Math.sqrt(nb) || 1);
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
function scSignature(v: number[], salt?: string | null): string {
|
|
1071
|
+
return v.slice(0, 16).map((x) => x.toFixed(2)).join(",") + (salt ? `|s:${salt.length}:${salt.slice(0, 64)}` : "");
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
async function semanticCacheGet(env: Env, gen: string, vec: number[], salt?: string | null): Promise<{ answer: string; citations: unknown[]; model: string; query_hash: string; context_applied?: unknown } | null> {
|
|
1075
|
+
try {
|
|
1076
|
+
const raw = await env.CACHE.get(semanticCacheKey(env.INDEX_VERSION, gen, scSignature(vec, salt)), "json") as any;
|
|
1077
|
+
if (!raw?.v || !Array.isArray(raw.v) || raw.v.length !== vec.length) return null;
|
|
1078
|
+
if (cosine(raw.v, vec) < 0.97) return null;
|
|
1079
|
+
return raw;
|
|
1080
|
+
} catch {
|
|
1081
|
+
return null;
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
function semanticCachePut(env: Env, ctx: ExecutionContext, gen: string, vec: number[], salt: string | null | undefined, payload: { answer: string; citations: unknown[]; model: string; query_hash: string }): void {
|
|
1086
|
+
const v = vec.map((x) => Number(x.toFixed(3)));
|
|
1087
|
+
ctx.waitUntil(
|
|
1088
|
+
env.CACHE.put(semanticCacheKey(env.INDEX_VERSION, gen, scSignature(vec, salt)), JSON.stringify({ v, ...payload }), { expirationTtl: LIMITS.cacheTtlSec }),
|
|
1089
|
+
);
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
// ── the route table (TODO.impl/03) ───────────────────────────────────────
|
|
1093
|
+
|
|
1094
|
+
export { handleAsk };
|