@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,24 @@
|
|
|
1
|
+
// Exact-term lookup: the understanding names the term; clause chunks
|
|
2
|
+
// whose head IS the term get a decisive nudge (publication headers
|
|
3
|
+
// contain the title words, so the reranker alone is unreliable here).
|
|
4
|
+
import { THRESHOLDS } from "../config.ts";
|
|
5
|
+
import type { Stage } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
export const termNudge: Stage = {
|
|
8
|
+
name: "term-nudge",
|
|
9
|
+
when: (c) => !!c.u?.term,
|
|
10
|
+
run: (c) => {
|
|
11
|
+
const scored = c.hits.map((h) => h.rerank_score ?? h.score);
|
|
12
|
+
const spread = Math.max(...scored) - Math.min(...scored);
|
|
13
|
+
if (spread > 0) {
|
|
14
|
+
const esc = c.u!.term!.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
15
|
+
const termRe = new RegExp(`(^|[^a-z])${esc}([^a-z]|$)`, "i");
|
|
16
|
+
for (const h of c.hits) {
|
|
17
|
+
const body = h.text.split("\n").slice(1).join(" ").slice(0, 200);
|
|
18
|
+
const hay = `${h.metadata.clause_title || ""} ${body}`.toLowerCase();
|
|
19
|
+
if (termRe.test(hay)) h.rerank_score = (h.rerank_score ?? h.score) + spread * THRESHOLDS.termNudgeSpread;
|
|
20
|
+
}
|
|
21
|
+
c.hits.sort((a, b) => (b.rerank_score ?? -Infinity) - (a.rerank_score ?? -Infinity));
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
};
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Answer contract v2 — typed-chunk pin (FINAL position): doc-scoped
|
|
2
|
+
// queries get ONE typed unit chunk (table first) guaranteed a slot.
|
|
3
|
+
// Prose outranks serialized tables under the cross-encoder AND the
|
|
4
|
+
// per-doc diversity cap counts typed chunks against the same doc key —
|
|
5
|
+
// without this guarantee the model never sees a unit id to reference.
|
|
6
|
+
// Includes the small-to-big parent fetch: an embedded object answers
|
|
7
|
+
// WITH its clause — if the parent clause's prose passage is not already
|
|
8
|
+
// among the finals, one metadata-filtered fetch adds it. The typed unit
|
|
9
|
+
// cites; the clause grounds.
|
|
10
|
+
import { LIMITS, THRESHOLDS } from "../config.ts";
|
|
11
|
+
import type { Hit } from "../../../shared/chunk";
|
|
12
|
+
import type { Stage } from "./types.ts";
|
|
13
|
+
|
|
14
|
+
/** Typed-chunk selection for the pin: among a doc's typed units pick the
|
|
15
|
+
* one whose text best overlaps the QUERY (the first candidate is wrong as
|
|
16
|
+
* often as right — annex example tables outrank nothing). Lexical-overlap
|
|
17
|
+
* heuristic over title + serialized rows; tables, figures and formulas
|
|
18
|
+
* compete on the same score so a figure question can pin the figure
|
|
19
|
+
* (which then feeds multimodal generation), while table-value questions
|
|
20
|
+
* still pin their table on overlap. When the query NAMES an artifact type
|
|
21
|
+
* ("figure", "table", "formula"/"equation"), units of that type get a
|
|
22
|
+
* dominating bonus — without it, clause units of the § tie with the
|
|
23
|
+
* figure under rerank variance and the multimodal answer flips on luck. */
|
|
24
|
+
function pickTypedChunk(query: string, candidates: Hit[], ranked: Hit[]): Hit | null {
|
|
25
|
+
if (!candidates.length) return null;
|
|
26
|
+
const pool = candidates;
|
|
27
|
+
const q = query.toLowerCase();
|
|
28
|
+
const terms = q.replace(/[^\p{L}\p{N}\s]/gu, " ").split(/\s+/).filter((t) => t.length > 2);
|
|
29
|
+
const typeBonus: Record<string, number> = {};
|
|
30
|
+
if (/\bfig(ure)?s?\b/.test(q)) typeBonus.figure = 1;
|
|
31
|
+
if (/\btables?\b/.test(q)) typeBonus.table = 1;
|
|
32
|
+
if (/\b(formulas?|equations?)\b/.test(q)) typeBonus.formula = 1;
|
|
33
|
+
// the top-ranked PROSE passage usually sits in the answer clause: a
|
|
34
|
+
// typed chunk from that same clause is the answering object, not a
|
|
35
|
+
// same-topic example from an annex
|
|
36
|
+
const topProse = ranked.find((h) => !h.metadata.unit_id);
|
|
37
|
+
const topAnchor = topProse?.metadata.clause_anchor ?? "";
|
|
38
|
+
let best: Hit | null = null;
|
|
39
|
+
let bestScore = -1;
|
|
40
|
+
for (const h of pool) {
|
|
41
|
+
const hay = `${h.metadata.clause_title ?? ""} ${h.text}`.toLowerCase();
|
|
42
|
+
let score = 0;
|
|
43
|
+
for (const t of terms) if (hay.includes(t)) score++;
|
|
44
|
+
if (typeBonus[h.metadata.block ?? ""]) score += terms.length * 2; // dominates
|
|
45
|
+
else if (topAnchor && h.metadata.clause_anchor === topAnchor) score += terms.length;
|
|
46
|
+
// blank annex FORMS (empty value cells) are not answer tables
|
|
47
|
+
const cells = h.text.split("|").map((x) => x.trim());
|
|
48
|
+
const filled = cells.filter((x) => x.length > 0).length;
|
|
49
|
+
const density = cells.length ? filled / cells.length : 0;
|
|
50
|
+
score += density * 2;
|
|
51
|
+
if (score > bestScore) {
|
|
52
|
+
bestScore = score;
|
|
53
|
+
best = h;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return best ?? pool[0];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const typedPin: Stage = {
|
|
60
|
+
name: "typed-pin",
|
|
61
|
+
when: (c) => {
|
|
62
|
+
// family scope: the hard doc filter, else the understanding's
|
|
63
|
+
// family, else the UNION of the vocabulary link's candidate
|
|
64
|
+
// families — a value question can straddle families that define
|
|
65
|
+
// near-identical tables (n_LC class B = 5 000 exists in R 60-1 AND
|
|
66
|
+
// R 76-2); pickTypedChunk's overlap + top-prose-anchor scoring then
|
|
67
|
+
// picks the right table among them instead of the family choice
|
|
68
|
+
// deciding in advance
|
|
69
|
+
const glossaryFamilies = new Set<string>();
|
|
70
|
+
for (const g of c.glossary) if (g.doc_number) glossaryFamilies.add(g.doc_number.split("-")[0]);
|
|
71
|
+
const pinFamily = c.filters?.doc_number ?? c.u?.doc_number ?? null;
|
|
72
|
+
return new Set<string>(pinFamily ? [pinFamily.split("-")[0]] : [...glossaryFamilies]).size > 0;
|
|
73
|
+
},
|
|
74
|
+
run: async (c) => {
|
|
75
|
+
const { query, filters, u, glossary, hits, env, vector } = c;
|
|
76
|
+
const glossaryFamilies = new Set<string>();
|
|
77
|
+
for (const g of glossary) if (g.doc_number) glossaryFamilies.add(g.doc_number.split("-")[0]);
|
|
78
|
+
const pinFamily = filters?.doc_number ?? u?.doc_number ?? null;
|
|
79
|
+
const pinFamilies = new Set<string>(pinFamily ? [pinFamily.split("-")[0]] : [...glossaryFamilies]);
|
|
80
|
+
const base = (dn?: string) => String(dn ?? "").split("-")[0];
|
|
81
|
+
const sameDocTyped = (h: Hit) =>
|
|
82
|
+
!!h.metadata.unit_id && !!h.metadata.block && pinFamilies.has(base(h.metadata.doc_number));
|
|
83
|
+
{
|
|
84
|
+
// the pin guarantees the BEST query-overlap typed unit a slot — not
|
|
85
|
+
// merely "some" typed unit. Hard doc scope pins unconditionally
|
|
86
|
+
// (existing behavior); the glossary-family union (no hard scope)
|
|
87
|
+
// pins only on real query overlap — the picker always returns
|
|
88
|
+
// SOMETHING, and a near-zero-overlap table riding the window on
|
|
89
|
+
// every vocabulary-linked query would be pollution.
|
|
90
|
+
const typed = pickTypedChunk(query, hits.filter(sameDocTyped), hits);
|
|
91
|
+
const hardScope = !!pinFamily;
|
|
92
|
+
const overlap = (() => {
|
|
93
|
+
if (!typed || hardScope) return Infinity;
|
|
94
|
+
const terms = query.toLowerCase().replace(/[^\p{L}\p{N}\s]/gu, " ").split(/\s+/).filter((t) => t.length > 2);
|
|
95
|
+
const hay = `${typed.metadata.clause_title ?? ""} ${typed.text}`.toLowerCase();
|
|
96
|
+
return terms.filter((t) => hay.includes(t)).length;
|
|
97
|
+
})();
|
|
98
|
+
// tables are exempt from the overlap gate: a table in the window
|
|
99
|
+
// renders as a block and summarizes harmlessly — the pollution
|
|
100
|
+
// concern applies to other typed units, not to the one artifact
|
|
101
|
+
// the answer contract most needs to reach the user
|
|
102
|
+
const tableExempt = typed?.metadata.block === "table";
|
|
103
|
+
if (typed && (overlap >= 3 || tableExempt) && !c.finalHits.some((h) => h.id === typed.id)) {
|
|
104
|
+
c.finalHits = [...c.finalHits.slice(0, LIMITS.rerankKeep - 1), typed];
|
|
105
|
+
console.log("typed pin:", typed.metadata.docidentifier, "§", typed.metadata.clause_anchor, `(${typed.metadata.block})${hardScope ? "" : " [glossary families]"}`);
|
|
106
|
+
|
|
107
|
+
const anchor = typed.metadata.clause_anchor;
|
|
108
|
+
const docId = typed.metadata.doc_id;
|
|
109
|
+
const parentPresent = c.finalHits.some(
|
|
110
|
+
(h) => h.metadata.doc_id === docId && h.metadata.clause_anchor === anchor && !h.metadata.unit_id,
|
|
111
|
+
);
|
|
112
|
+
if (anchor && docId && !parentPresent) {
|
|
113
|
+
try {
|
|
114
|
+
const pv = await env.VECTORIZE.query(vector, {
|
|
115
|
+
topK: 4,
|
|
116
|
+
returnMetadata: "all",
|
|
117
|
+
filter: { $and: [{ doc_id: { $eq: docId } }, { clause_anchor: { $eq: anchor } }] },
|
|
118
|
+
});
|
|
119
|
+
const parent = (pv.matches ?? []).map((m: any) => ({ id: m.id, score: m.score, metadata: m.metadata, text: m.metadata?.chunk_text ?? "" })).find((h: any) => !h.metadata?.unit_id);
|
|
120
|
+
if (parent && !c.finalHits.some((h) => h.id === parent.id)) {
|
|
121
|
+
c.finalHits = [...c.finalHits, { ...parent, score: parent.score * THRESHOLDS.smallToBigDiscount }];
|
|
122
|
+
console.log("small-to-big: parent §", anchor, "of", typed.metadata.docidentifier, "added");
|
|
123
|
+
}
|
|
124
|
+
} catch {
|
|
125
|
+
// additive lane; primary results stand
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// The pipeline stage contract (TODO.impl/02): every retrieval mechanism
|
|
2
|
+
// is a self-contained stage implementing this interface, composed by the
|
|
3
|
+
// registry in stages/index.ts. Adding a mechanism = adding a stage file
|
|
4
|
+
// and one registry entry — no edits to existing stages, the runner, or
|
|
5
|
+
// retrieve() (OCP). Contracts per stage: docs/spec-pipeline.md.
|
|
6
|
+
|
|
7
|
+
import type { Hit } from "../../../shared/chunk";
|
|
8
|
+
import type { QueryFilters } from "../selfquery.ts";
|
|
9
|
+
import type { QueryUnderstanding } from "../understand";
|
|
10
|
+
|
|
11
|
+
export interface RetrieveOptions {
|
|
12
|
+
prev?: string;
|
|
13
|
+
understanding?: QueryUnderstanding | null;
|
|
14
|
+
queryOverride?: string;
|
|
15
|
+
federate?: (query: string) => Promise<Hit[]>;
|
|
16
|
+
warmEmbed?: Promise<number[] | null>;
|
|
17
|
+
graphDocNumbers?: string[];
|
|
18
|
+
/** The declared context's HARD seal (TODO.ai-platform/02): when the
|
|
19
|
+
* panel's chip declares a document scope, the CANDIDATE POOL is cut
|
|
20
|
+
* to the publication family before rerank + the top-N cut — the
|
|
21
|
+
* soft-steer widenings below (the full-corpus lexical union, the
|
|
22
|
+
* sparse-filter widen, the sub-query lanes) can otherwise outscore
|
|
23
|
+
* the filtered dense lane under the cross-encoder and push every
|
|
24
|
+
* in-family passage out of the final hits, sealing the answer to
|
|
25
|
+
* zero despite a healthy in-family pool. */
|
|
26
|
+
sealScope?: { doc_number: string; edition?: string } | null;
|
|
27
|
+
/** Option C: dense-lane results computed concurrently with
|
|
28
|
+
* understanding (same folded-query vector, retrieve's exact query
|
|
29
|
+
* parameters). With no filter they REPLACE the primary dense query;
|
|
30
|
+
* with a filter they union in as discounted filter-miss cover. */
|
|
31
|
+
optimisticHits?: Hit[];
|
|
32
|
+
optimisticVec?: number[] | null;
|
|
33
|
+
/** Dataset scope (the sidebar toggles): the set of CORPUS values the
|
|
34
|
+
* request allows. Present only when NARROWER than the default (all
|
|
35
|
+
* permitted datasets) — a null scope means no filtering. The ask path
|
|
36
|
+
* intersects the requested ids with session permissions before
|
|
37
|
+
* building this set. */
|
|
38
|
+
datasetScope?: Set<string> | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface GlossaryEntry {
|
|
42
|
+
term: string;
|
|
43
|
+
definition: string;
|
|
44
|
+
docidentifier: string;
|
|
45
|
+
doc_number: string;
|
|
46
|
+
score: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The mutable state every stage reads and writes. Field ownership per
|
|
50
|
+
* stage is specified in docs/spec-pipeline.md; in general: candidate
|
|
51
|
+
* lanes append to `matches`, refinement stages rewrite `hits`, and only
|
|
52
|
+
* the window-assembly stages touch `finalHits`. */
|
|
53
|
+
export interface PipelineContext {
|
|
54
|
+
env: any;
|
|
55
|
+
query: string; // the user's original wording (rerank + pin score on it)
|
|
56
|
+
rq: string; // the retrieval query (folded / standalone / expanded)
|
|
57
|
+
folded: string; // the pre-understanding fold (optimistic-lane identity)
|
|
58
|
+
u: QueryUnderstanding | null;
|
|
59
|
+
filters: QueryFilters | null; // dense-stage may drop a guessed edition pin
|
|
60
|
+
filter: Record<string, string> | null | undefined; // toVectorizeFilter's output
|
|
61
|
+
vector: number[]; // embedding of rq
|
|
62
|
+
lexicalHits: Hit[]; // full-corpus BM25 ranking (already seal-filtered)
|
|
63
|
+
matches: any[]; // the candidate pool (Vectorize match shape, pre-Hit)
|
|
64
|
+
hits: Hit[]; // the ranked pool from poolOpen onward
|
|
65
|
+
finalHits: Hit[]; // the answer window
|
|
66
|
+
glossary: GlossaryEntry[]; // the vocabulary link (glossary stage owns)
|
|
67
|
+
opts: RetrieveOptions;
|
|
68
|
+
/** prefetch bag: stage-name → that stage's in-flight I/O promise (the
|
|
69
|
+
* stage owns its key; see Stage.prefetch) */
|
|
70
|
+
lane: Record<string, Promise<unknown>>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface Stage {
|
|
74
|
+
name: string;
|
|
75
|
+
/** Absent = always runs. Guards are pure reads of the context. */
|
|
76
|
+
when?: (c: PipelineContext) => boolean;
|
|
77
|
+
/** "additive": a throw is logged and the pipeline continues with the
|
|
78
|
+
* context as the previous stage left it (the lane's results were not
|
|
79
|
+
* written). "blocking" (default): the throw propagates to the caller. */
|
|
80
|
+
failure?: "additive" | "blocking";
|
|
81
|
+
/** Kick this stage's INDEPENDENT I/O off early (the runner invokes
|
|
82
|
+
* every stage's prefetch before running any stage). Only for stages
|
|
83
|
+
* whose I/O depends on pre-pipeline state (u, vector, opts) — never
|
|
84
|
+
* on prior stages' output. The promise lands in c.lane[name]; run()
|
|
85
|
+
* awaits it and merges. Merges stay in registry order — concurrency
|
|
86
|
+
* changes when I/O completes, never the merge order (determinism). */
|
|
87
|
+
prefetch?: (c: PipelineContext) => void;
|
|
88
|
+
run: (c: PipelineContext) => Promise<void> | void;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Run the registry in order. Additive stages swallow their own throws —
|
|
92
|
+
* replicating the per-lane try/catch the monolith carried inline. */
|
|
93
|
+
export async function runStages(stages: Stage[], c: PipelineContext): Promise<void> {
|
|
94
|
+
for (const stage of stages) {
|
|
95
|
+
if (stage.prefetch && (!stage.when || stage.when(c))) stage.prefetch(c);
|
|
96
|
+
}
|
|
97
|
+
for (const stage of stages) {
|
|
98
|
+
if (stage.when && !stage.when(c)) continue;
|
|
99
|
+
if (stage.failure === "additive") {
|
|
100
|
+
try {
|
|
101
|
+
await stage.run(c);
|
|
102
|
+
} catch (e) {
|
|
103
|
+
console.log(`stage ${stage.name}: additive lane failed — primary results stand (${String(e).slice(0, 120)})`);
|
|
104
|
+
}
|
|
105
|
+
} else {
|
|
106
|
+
await stage.run(c);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// the shared match→Hit conversion (lib/hit.ts owns it)
|
|
112
|
+
export { toHits } from "../lib/hit.ts";
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Relevance-floored window (the evidence-budget principle): a full
|
|
2
|
+
// window of near-miss passages costs tokens and attention while good
|
|
3
|
+
// answers cite only what they need (measured: median 10 served, 1–2
|
|
4
|
+
// cited). Serve what can matter: passages within a fraction of the top
|
|
5
|
+
// score, plus every structurally-guaranteed unit — typed pins and the
|
|
6
|
+
// passages appended after ranking (their scores are not cross-encoder
|
|
7
|
+
// comparable). Never fewer than two.
|
|
8
|
+
import { THRESHOLDS } from "../config.ts";
|
|
9
|
+
import type { Stage } from "./types.ts";
|
|
10
|
+
|
|
11
|
+
export const windowFloor: Stage = {
|
|
12
|
+
name: "window-floor",
|
|
13
|
+
run: (c) => {
|
|
14
|
+
const top = Math.max(...c.finalHits.map((h) => h.rerank_score ?? h.score));
|
|
15
|
+
const floored = c.finalHits.filter(
|
|
16
|
+
(h) => h.rerank_score === undefined || (h.rerank_score ?? h.score) >= THRESHOLDS.windowFloorFraction * top || !!h.metadata.unit_id || h.metadata.clause_anchor === "family",
|
|
17
|
+
);
|
|
18
|
+
if (floored.length >= 2) {
|
|
19
|
+
if (floored.length < c.finalHits.length) console.log("window floor:", c.finalHits.length, "→", floored.length, "passages");
|
|
20
|
+
c.finalHits = floored;
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
};
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// Structural retrieval over the producer-native clause tree — serving-path
|
|
2
|
+
// adaptations of FABLE/BEAR (arXiv:2601.18116) to a corpus that already HAS
|
|
3
|
+
// the hierarchy: Metanorma clause anchors ARE the tree, so unlike FABLE no
|
|
4
|
+
// LLM tree-builder runs at index time. Three techniques:
|
|
5
|
+
//
|
|
6
|
+
// - structuralPropagation: TreeExpansion's relevance propagation — a
|
|
7
|
+
// clause blends its own score with its ancestors' (topic continuity)
|
|
8
|
+
// and descendants' (subtopic heat): sections whose clauses are
|
|
9
|
+
// collectively hot rise, hot sections lift their clauses.
|
|
10
|
+
// - positionOrder: NodeFusion's position-preserving ordering — evidence
|
|
11
|
+
// is presented in document reading order (per publication, groups by
|
|
12
|
+
// selection priority), because synthesis quality depends on
|
|
13
|
+
// arrangement, not just set membership.
|
|
14
|
+
// - ancestorDescendantDedup: near-duplicate chunks of the same clause
|
|
15
|
+
// chain (parent §3.1 vs child §3.1.2 repeating its heading + text)
|
|
16
|
+
// collapse to the stronger one before the window is cut.
|
|
17
|
+
import type { Hit } from "./pipeline";
|
|
18
|
+
|
|
19
|
+
/** "3.1.2" → [3,1,2]; null for everything else (annex labels, producer
|
|
20
|
+
* UUIDs, overview/family, empty). */
|
|
21
|
+
export function parseAnchor(anchor: string | undefined | null): number[] | null {
|
|
22
|
+
if (!anchor) return null;
|
|
23
|
+
const a = anchor.trim().replace(/\.$/, "");
|
|
24
|
+
if (!/^\d+(\.\d+)*$/.test(a)) return null;
|
|
25
|
+
return a.split(".").map(Number);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** a is a PROPER ancestor of b ("3.1" ⊳ "3.1.2"). */
|
|
29
|
+
export function isAncestorOf(a: number[], b: number[]): boolean {
|
|
30
|
+
return a.length < b.length && b.slice(0, a.length).every((s, i) => s === a[i]);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Document order for dotted numeric anchors ("3" < "3.1" < "3.1.2" < "3.2"). */
|
|
34
|
+
export function anchorCompare(a: number[], b: number[]): number {
|
|
35
|
+
const n = Math.min(a.length, b.length);
|
|
36
|
+
for (let i = 0; i < n; i++) if (a[i] !== b[i]) return a[i] - b[i];
|
|
37
|
+
return a.length - b.length;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const scoreOf = (h: Hit) => h.rerank_score ?? h.score;
|
|
41
|
+
|
|
42
|
+
/** TreeExpansion-style structural propagation (Eq. 7 of the paper):
|
|
43
|
+
* S(v) = (self + inherited + childAgg)/3, blended into the live score as
|
|
44
|
+
* a spread-scaled adjustment — same idiom as edition steering, so the
|
|
45
|
+
* adjustment can never outrank the cross-encoder's own signal. */
|
|
46
|
+
export function structuralPropagation(hits: Hit[]): Hit[] {
|
|
47
|
+
if (hits.length < 3) return hits;
|
|
48
|
+
const scored = hits.map(scoreOf);
|
|
49
|
+
const min = Math.min(...scored);
|
|
50
|
+
const max = Math.max(...scored);
|
|
51
|
+
const spread = max - min;
|
|
52
|
+
if (spread <= 0) return hits;
|
|
53
|
+
|
|
54
|
+
const byDoc = new Map<string, { h: Hit; a: number[]; n: number }[]>();
|
|
55
|
+
for (const h of hits) {
|
|
56
|
+
const a = parseAnchor(h.metadata.clause_anchor);
|
|
57
|
+
if (!a) continue;
|
|
58
|
+
const k = h.metadata.doc_id;
|
|
59
|
+
if (!byDoc.has(k)) byDoc.set(k, []);
|
|
60
|
+
byDoc.get(k)!.push({ h, a, n: (scoreOf(h) - min) / spread });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let adjusted = 0;
|
|
64
|
+
for (const nodes of byDoc.values()) {
|
|
65
|
+
if (nodes.length < 2) continue;
|
|
66
|
+
for (const nd of nodes) {
|
|
67
|
+
let inherited: number | null = null;
|
|
68
|
+
let childSum = 0;
|
|
69
|
+
let childN = 0;
|
|
70
|
+
for (const other of nodes) {
|
|
71
|
+
if (other === nd) continue;
|
|
72
|
+
if (isAncestorOf(other.a, nd.a)) inherited = Math.max(inherited ?? 0, other.n);
|
|
73
|
+
else if (isAncestorOf(nd.a, other.a)) {
|
|
74
|
+
childSum += other.n;
|
|
75
|
+
childN++;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (inherited === null && childN === 0) continue;
|
|
79
|
+
const s = (nd.n + (inherited ?? nd.n) + (childN ? childSum / childN : nd.n)) / 3;
|
|
80
|
+
const adj = spread * 0.2 * (s - nd.n);
|
|
81
|
+
if (Math.abs(adj) < 1e-9) continue;
|
|
82
|
+
if (nd.h.rerank_score !== undefined) nd.h.rerank_score += adj;
|
|
83
|
+
else nd.h.score += adj;
|
|
84
|
+
adjusted++;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (adjusted) {
|
|
88
|
+
console.log("structural propagation:", adjusted, "hits re-scored across the clause tree");
|
|
89
|
+
hits.sort((a, b) => scoreOf(b) - scoreOf(a));
|
|
90
|
+
}
|
|
91
|
+
return hits;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Position-preserving evidence order (NodeFusion, Algorithm 2): passages
|
|
95
|
+
* of the same publication are fed in document order, publications ordered
|
|
96
|
+
* by their best-ranked member. Document order is the PRODUCER'S ordinal
|
|
97
|
+
* when the metadata carries one (metanorma-document#56) — a sort, never
|
|
98
|
+
* an anchor parse; the anchor compare is the fallback for chunks whose
|
|
99
|
+
* producer doesn't emit ordinals. Structural chunks (overview/family)
|
|
100
|
+
* lead their doc; unnumbered passages follow the numbered ones. */
|
|
101
|
+
export function positionOrder(hits: Hit[]): Hit[] {
|
|
102
|
+
if (hits.length < 3) return hits;
|
|
103
|
+
const idx = new Map(hits.map((h, i) => [h, i]));
|
|
104
|
+
const groups = new Map<string, Hit[]>();
|
|
105
|
+
for (const h of hits) {
|
|
106
|
+
const k = h.metadata.doc_id || h.id;
|
|
107
|
+
if (!groups.has(k)) groups.set(k, []);
|
|
108
|
+
groups.get(k)!.push(h);
|
|
109
|
+
}
|
|
110
|
+
const rank = (g: Hit[]) => Math.min(...g.map((h) => idx.get(h)!));
|
|
111
|
+
const structural = (h: Hit) => h.metadata.clause_anchor === "overview" || h.metadata.clause_anchor === "family";
|
|
112
|
+
const byOrig = (a: Hit, b: Hit) => idx.get(a)! - idx.get(b)!;
|
|
113
|
+
const byDocOrder = (a: Hit, b: Hit) => {
|
|
114
|
+
const oa = (a.metadata as any).ordinal;
|
|
115
|
+
const ob = (b.metadata as any).ordinal;
|
|
116
|
+
if (typeof oa === "number" && typeof ob === "number" && oa !== ob) return oa - ob;
|
|
117
|
+
const pa = parseAnchor(a.metadata.clause_anchor);
|
|
118
|
+
const pb = parseAnchor(b.metadata.clause_anchor);
|
|
119
|
+
if (pa && pb) return anchorCompare(pa, pb) || byOrig(a, b);
|
|
120
|
+
if (pa && !pb) return -1;
|
|
121
|
+
if (!pa && pb) return 1;
|
|
122
|
+
return byOrig(a, b);
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const out: Hit[] = [];
|
|
126
|
+
for (const g of [...groups.values()].sort((a, b) => rank(a) - rank(b))) {
|
|
127
|
+
const head = g.filter(structural).sort(byOrig);
|
|
128
|
+
const ordered = g.filter((h) => !structural(h)).sort(byDocOrder);
|
|
129
|
+
out.push(...head, ...ordered);
|
|
130
|
+
}
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const headText = (h: Hit) => h.text.replace(/\s+/g, " ").toLowerCase().slice(0, 600);
|
|
135
|
+
|
|
136
|
+
function overlap(a: string, b: string): number {
|
|
137
|
+
const A = new Set(a.split(/[^a-z0-9°%]+/).filter((t) => t.length > 3));
|
|
138
|
+
const B = new Set(b.split(/[^a-z0-9°%]+/).filter((t) => t.length > 3));
|
|
139
|
+
if (!A.size || !B.size) return 0;
|
|
140
|
+
let inter = 0;
|
|
141
|
+
for (const t of A) if (B.has(t)) inter++;
|
|
142
|
+
return inter / (A.size + B.size - inter);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Same-chain near-duplicate collapse: when an ancestor chunk and a
|
|
146
|
+
* descendant chunk of one clause chain carry substantially the same text,
|
|
147
|
+
* the weaker one leaves the window (FABLE keeps the subtree, drops the
|
|
148
|
+
* redundant node). Different-text relatives both stay — a parent clause
|
|
149
|
+
* and a deep sub-clause are usually different content. */
|
|
150
|
+
export function ancestorDescendantDedup(hits: Hit[]): Hit[] {
|
|
151
|
+
if (hits.length < 2) return hits;
|
|
152
|
+
const anchors = hits.map((h) => parseAnchor(h.metadata.clause_anchor));
|
|
153
|
+
const drop = new Set<Hit>();
|
|
154
|
+
for (let i = 0; i < hits.length; i++) {
|
|
155
|
+
if (!anchors[i] || drop.has(hits[i])) continue;
|
|
156
|
+
for (let j = i + 1; j < hits.length; j++) {
|
|
157
|
+
if (!anchors[j] || drop.has(hits[j])) continue;
|
|
158
|
+
if (hits[i].metadata.doc_id !== hits[j].metadata.doc_id) continue;
|
|
159
|
+
const chained = isAncestorOf(anchors[i]!, anchors[j]!) || isAncestorOf(anchors[j]!, anchors[i]!);
|
|
160
|
+
if (!chained) continue;
|
|
161
|
+
if (overlap(headText(hits[i]), headText(hits[j])) >= 0.5) {
|
|
162
|
+
drop.add(scoreOf(hits[i]) >= scoreOf(hits[j]) ? hits[j] : hits[i]);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (drop.size) {
|
|
167
|
+
console.log("structural dedup:", drop.size, "same-chain near-duplicate(s) dropped");
|
|
168
|
+
return hits.filter((h) => !drop.has(h));
|
|
169
|
+
}
|
|
170
|
+
return hits;
|
|
171
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** Schema-aware table context composition (TableRAG-class cell
|
|
2
|
+
* selection) over the producer's typed table payload. Pure — the
|
|
3
|
+
* interface is the test surface. */
|
|
4
|
+
/** Schema-aware table context (TableRAG-class cell selection): the
|
|
5
|
+
* producer payload (metadata.table) carries caption/columns/rows; the
|
|
6
|
+
* consumer composes the model-facing serialization — columns whose
|
|
7
|
+
* labels overlap the query, rows whose cells overlap the query or the
|
|
8
|
+
* selected column labels. Full table stays available for rendering;
|
|
9
|
+
* this only shapes the prompt context, and falls back to the stored
|
|
10
|
+
* text when pruning matches nothing (never worse than baseline). */
|
|
11
|
+
export function tableContext(meta: any, query: string): string | null {
|
|
12
|
+
const t: any = meta?.table;
|
|
13
|
+
if (!t || !Array.isArray(t.columns) || !Array.isArray(t.rows) || !t.rows.length) return null;
|
|
14
|
+
const terms = new Set(
|
|
15
|
+
query.toLowerCase().replace(/[^\p{L}\p{N}\s]/gu, " ").split(/\s+/).filter((w: string) => w.length > 2),
|
|
16
|
+
);
|
|
17
|
+
const termList = [...terms];
|
|
18
|
+
const label = (c: any): string => `${c?.label ?? ""} ${c?.unit ?? ""}`.toLowerCase();
|
|
19
|
+
const keepCols: number[] = [];
|
|
20
|
+
t.columns.forEach((c: any, i: number) => {
|
|
21
|
+
if (termList.some((term) => label(c).includes(term))) keepCols.push(i);
|
|
22
|
+
});
|
|
23
|
+
const colKeep: number[] = keepCols.length ? keepCols : t.columns.map((_: any, i: number) => i);
|
|
24
|
+
const rowHits: string[] = [];
|
|
25
|
+
for (const row of t.rows as string[]) {
|
|
26
|
+
const cells = String(row).split("|").map((c: string) => c.trim().toLowerCase());
|
|
27
|
+
const cellHit = cells.some((c: string) => c && termList.some((term) => c.includes(term)));
|
|
28
|
+
const colHit = keepCols.length > 0 && colKeep.some((i: number) => cells[i] && termList.some((term) => label(t.columns[i]).includes(term) && cells[i].length > 0));
|
|
29
|
+
if (cellHit || colHit) rowHits.push(row);
|
|
30
|
+
}
|
|
31
|
+
if (!rowHits.length) return null;
|
|
32
|
+
const CAP = 10;
|
|
33
|
+
const shown = rowHits.slice(0, CAP);
|
|
34
|
+
const header = `Table: ${t.caption ?? ""}\ncolumns: ${colKeep.map((i: number) => `${t.columns[i]?.label ?? ""}${t.columns[i]?.unit ? ` [${t.columns[i].unit}]` : ""}`).join(" | ")}`;
|
|
35
|
+
const lines = shown.map((r: string) => `row: ${r}`);
|
|
36
|
+
const elided =
|
|
37
|
+
rowHits.length > CAP || rowHits.length < t.rows.length
|
|
38
|
+
? `\n(${shown.length} of ${t.rows.length} rows shown; ${t.rows.length - rowHits.length} rows did not match the question terms)`
|
|
39
|
+
: "";
|
|
40
|
+
return `${header}\n${lines.join("\n")}${elided}`;
|
|
41
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { ModelRunner } from "./ports/model.ts";
|
|
2
|
+
import { extractJson, type QueryUnderstanding } from "./understandContract.ts";
|
|
3
|
+
export type { QueryUnderstanding };
|
|
4
|
+
|
|
5
|
+
// Query understanding (TODO.impl/15 audit finding #1): a small, fast LLM
|
|
6
|
+
// call replaces the regex heuristics as the decision-maker for what
|
|
7
|
+
// retrieval should see. The regex path (selfquery.ts) remains as a
|
|
8
|
+
// fallback when this call fails, errors, or times out — degraded mode,
|
|
9
|
+
// never a hard failure.
|
|
10
|
+
|
|
11
|
+
// The prompt is data (prompts/understanding.md), bundled as text.
|
|
12
|
+
import SYSTEM from "../prompts/understanding.md";
|
|
13
|
+
|
|
14
|
+
/** Understand the query with the cheap model. Null = use the regex fallback. */
|
|
15
|
+
export async function understandQuery(
|
|
16
|
+
ai: ModelRunner,
|
|
17
|
+
model: string,
|
|
18
|
+
query: string,
|
|
19
|
+
history: Array<{ role: string; content: string }>,
|
|
20
|
+
entities: Array<{ entity: string; kind: string }> = [],
|
|
21
|
+
): Promise<QueryUnderstanding | null> {
|
|
22
|
+
const convo = history
|
|
23
|
+
.slice(-6)
|
|
24
|
+
.map((h) => `${h.role === "user" ? "User" : "Assistant"}: ${h.content.slice(0, 600)}`)
|
|
25
|
+
.join("\n");
|
|
26
|
+
const entityLine = entities.length
|
|
27
|
+
? `Entities already established in this conversation: ${entities.map((e) => e.entity).join("; ")}. Resolve pronouns and shorthand against these.\n\n`
|
|
28
|
+
: "";
|
|
29
|
+
const user = `${convo ? "Conversation so far:\n" + convo + "\n\n" : ""}${entityLine}Question: ${query}`;
|
|
30
|
+
const body = {
|
|
31
|
+
messages: [
|
|
32
|
+
{ role: "system", content: SYSTEM },
|
|
33
|
+
{ role: "user", content: user },
|
|
34
|
+
],
|
|
35
|
+
// the model always reasons; reasoning tokens share this budget — too
|
|
36
|
+
// small and the JSON is never reached (understanding silently degrades).
|
|
37
|
+
// GLM-5 family defaults to reasoning_effort "max" when the parameter is
|
|
38
|
+
// not honored, so GLM needs headroom or reasoning starves the JSON.
|
|
39
|
+
max_tokens: model.includes("glm") ? 3072 : 1500,
|
|
40
|
+
reasoning_effort: "low",
|
|
41
|
+
// Qwen3 thinking-mode sampling (model card): greedy/1.0 sampling
|
|
42
|
+
// degrades into repetition loops — the 10s/5s timeout nulls were the
|
|
43
|
+
// budget being eaten by loops, not by reasoning
|
|
44
|
+
temperature: 0.6,
|
|
45
|
+
top_p: 0.95,
|
|
46
|
+
top_k: 20,
|
|
47
|
+
};
|
|
48
|
+
// each attempt issues a FRESH call — re-racing a timed-out promise would
|
|
49
|
+
// retry nothing. Generous first attempt: reasoning + the full JSON must
|
|
50
|
+
// fit inside the timeout or understanding silently degrades to vanilla
|
|
51
|
+
// retrieval (which refuses conversational turns).
|
|
52
|
+
const ATTEMPT_TIMEOUTS = [10000, 5000];
|
|
53
|
+
for (let attempt = 0; attempt < ATTEMPT_TIMEOUTS.length; attempt++) {
|
|
54
|
+
const call = (async () => {
|
|
55
|
+
const res = await ai.run({ model, messages: (body as any).messages, effort: (body as any).reasoning_effort, maxTokens: (body as any).max_tokens, temperature: (body as any).temperature, topP: (body as any).top_p, topK: (body as any).top_k });
|
|
56
|
+
const text = res?.text ?? null;
|
|
57
|
+
return typeof text === "string" ? extractJson(text) : null;
|
|
58
|
+
})();
|
|
59
|
+
const timeout = new Promise<null>((r) => setTimeout(() => r(null), ATTEMPT_TIMEOUTS[attempt]));
|
|
60
|
+
try {
|
|
61
|
+
const got = await Promise.race([call, timeout]);
|
|
62
|
+
if (got) return got;
|
|
63
|
+
} catch (e) {
|
|
64
|
+
// account rate-limited: a retry in the same minute will also fail —
|
|
65
|
+
// degrade to vanilla retrieval immediately instead of burning the
|
|
66
|
+
// second attempt (TTFT surgery)
|
|
67
|
+
if (String(e).includes("3021") || String(e).includes("rate")) return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
console.warn("query understanding unavailable — vanilla retrieval");
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// The understanding contract (TODO.impl/29): QueryUnderstanding and the
|
|
2
|
+
// pure JSON extractor — no imports (the prompt file and the model call
|
|
3
|
+
// stay in understand.ts; this module is the testable contract).
|
|
4
|
+
|
|
5
|
+
export interface QueryUnderstanding {
|
|
6
|
+
/** conversational turn (greeting, identity, small talk) vs knowledge seek */
|
|
7
|
+
intent: "conversational" | "knowledge";
|
|
8
|
+
/** normalized document reference, e.g. "OIML R 76-2" — null when none */
|
|
9
|
+
docidentifier: string | null;
|
|
10
|
+
/** base document number for the Vectorize filter, e.g. "60" */
|
|
11
|
+
doc_number: string | null;
|
|
12
|
+
edition?: string | null;
|
|
13
|
+
language?: string | null;
|
|
14
|
+
/** the question is about a process around publications (certify, apply…) */
|
|
15
|
+
process_intent: boolean;
|
|
16
|
+
/** definition-style question whose subject is `term` */
|
|
17
|
+
term: string | null;
|
|
18
|
+
/** corpus-terminology mapping of everyday wording (drift→creep) */
|
|
19
|
+
defined_terms: string[];
|
|
20
|
+
/** self-contained retrieval query: follow-ups folded with context */
|
|
21
|
+
standalone_query: string;
|
|
22
|
+
complexity: "simple" | "complex";
|
|
23
|
+
query_variants: string[];
|
|
24
|
+
sub_queries: string[];
|
|
25
|
+
hypothetical_answer: string;
|
|
26
|
+
/** plausible next questions (conversational UX), in the user's language */
|
|
27
|
+
follow_ups: string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
/** Model output text
|
|
32
|
+
* → QueryUnderstanding (or null). Every silent coercion is pinned by
|
|
33
|
+
* tests/understand.test.ts — change the prompt's JSON shape and the
|
|
34
|
+
* test names what moved. */
|
|
35
|
+
export function extractJson(text: string): QueryUnderstanding | null {
|
|
36
|
+
const m = text.match(/\{[\s\S]*\}/);
|
|
37
|
+
if (!m) return null;
|
|
38
|
+
try {
|
|
39
|
+
const raw = JSON.parse(m[0]);
|
|
40
|
+
const u: QueryUnderstanding = {
|
|
41
|
+
intent: raw.intent === "conversational" ? ("conversational" as const) : ("knowledge" as const),
|
|
42
|
+
docidentifier: typeof raw.docidentifier === "string" && raw.docidentifier.trim() ? raw.docidentifier.trim().slice(0, 60) : null,
|
|
43
|
+
doc_number: typeof raw.docnumber === "string" && /^\d{1,3}$/.test(raw.docnumber) ? raw.docnumber : null,
|
|
44
|
+
edition: typeof raw.edition === "string" && /^\d{4}$/.test(raw.edition) ? raw.edition : null,
|
|
45
|
+
language: typeof raw.language === "string" && /^[a-z]{2}$/.test(raw.language) ? raw.language : null,
|
|
46
|
+
process_intent: raw.process_intent === true,
|
|
47
|
+
term: typeof raw.term === "string" && raw.term.trim() ? raw.term.trim().slice(0, 60) : null,
|
|
48
|
+
defined_terms: Array.isArray(raw.defined_terms)
|
|
49
|
+
? raw.defined_terms.filter((t: unknown) => typeof t === "string" && (t as string).trim()).map((t: string) => t.trim().slice(0, 60)).slice(0, 4)
|
|
50
|
+
: [],
|
|
51
|
+
standalone_query: typeof raw.standalone_query === "string" && raw.standalone_query.trim() ? raw.standalone_query.trim().slice(0, 400) : "",
|
|
52
|
+
complexity: raw.complexity === 'complex' ? ('complex' as const) : ('simple' as const),
|
|
53
|
+
query_variants: Array.isArray(raw.query_variants)
|
|
54
|
+
? raw.query_variants.filter((q: unknown) => typeof q === 'string' && (q as string).trim()).map((q: string) => q.trim().slice(0, 300)).slice(0, 4)
|
|
55
|
+
: [],
|
|
56
|
+
hypothetical_answer: typeof raw.hypothetical_answer === "string" ? raw.hypothetical_answer.trim().slice(0, 300) : "",
|
|
57
|
+
sub_queries: Array.isArray(raw.sub_queries)
|
|
58
|
+
? raw.sub_queries.filter((q: unknown) => typeof q === 'string' && (q as string).trim()).map((q: string) => q.trim().slice(0, 300)).slice(0, 5)
|
|
59
|
+
: [],
|
|
60
|
+
follow_ups: Array.isArray(raw.follow_ups)
|
|
61
|
+
? raw.follow_ups.filter((q: unknown) => typeof q === 'string' && (q as string).trim()).map((q: string) => q.trim().slice(0, 200)).slice(0, 2)
|
|
62
|
+
: [], };
|
|
63
|
+
return u;
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|