@konneal/engine 0.2.37 → 0.2.39

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.
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  handleAsk
3
- } from "./chunk-SEOQWW77.js";
4
- import "./chunk-37FSCXBQ.js";
3
+ } from "./chunk-OHYAHQQV.js";
4
+ import "./chunk-NBE7BCQ7.js";
5
5
  import "./chunk-R2V3X6SQ.js";
6
6
  import "./chunk-A3QHHUN5.js";
7
7
  import "./chunk-4GJGBGJK.js";
@@ -7,7 +7,7 @@ import {
7
7
  sessionFrom,
8
8
  telemetry,
9
9
  understandQuery
10
- } from "./chunk-37FSCXBQ.js";
10
+ } from "./chunk-NBE7BCQ7.js";
11
11
  import {
12
12
  corsHeaders,
13
13
  err,
@@ -61,6 +61,27 @@ async function generateOnce(env, model, messages, effort) {
61
61
  return null;
62
62
  }
63
63
 
64
+ // workers/worker_public/src/quality.ts
65
+ var ORDER = { verified: 0, curated: 1, ocr: 2 };
66
+ function hitQuality(m) {
67
+ if (m.model_node || m.model_version || m.producer === "primmel") return "verified";
68
+ if (m.tier === "curated") return "curated";
69
+ return "ocr";
70
+ }
71
+ function answerQuality(qualities) {
72
+ let worst = null;
73
+ for (const q of qualities) {
74
+ if (!q) continue;
75
+ if (!worst || ORDER[q] > ORDER[worst]) worst = q;
76
+ }
77
+ return worst;
78
+ }
79
+ function qualityNote(q) {
80
+ if (q === "verified") return "Grounded in verified sources: this publication's requirements ride a machine-checkable data model.";
81
+ if (q === "curated") return "Grounded in the edited corpus: Metanorma-authored documents, chunked at clause boundaries.";
82
+ return "Partly grounded in experimental OCR text: verify quotations and table values against the official publication.";
83
+ }
84
+
64
85
  // workers/worker_public/src/lexical.ts
65
86
  var LEXICAL_K = 40;
66
87
  function ftsMatchQuery(query) {
@@ -23515,6 +23536,7 @@ function citations(hits) {
23515
23536
  status: h.metadata.status ?? "unknown",
23516
23537
  superseded_by: h.metadata.superseded_by || void 0,
23517
23538
  corpus: h.metadata.corpus || P().publisher.id,
23539
+ quality: hitQuality(h.metadata),
23518
23540
  url: publicationUrl(h.metadata),
23519
23541
  snippet: h.text.slice(0, 400),
23520
23542
  score: h.rerank_score ?? h.score
@@ -23814,13 +23836,23 @@ function opMemberCanWrite(member) {
23814
23836
  if (member.via !== "op-token") return true;
23815
23837
  return /\bwrite\b/.test(member.scope ?? "");
23816
23838
  }
23817
- function opMemberFromIntrospection(answer, ids) {
23839
+ function opMemberFromIntrospection(answer, ids, patServices = []) {
23818
23840
  if (!answer?.active) return null;
23819
- const clientId = String(answer.client_id ?? "");
23820
- if (!ids.includes(clientId)) return null;
23821
23841
  const sub = String(answer.sub ?? "");
23822
23842
  if (!sub) return null;
23823
- return { sub, scope: String(answer.scope ?? ""), via: "op-token" };
23843
+ const clientId = String(answer.client_id ?? "");
23844
+ if (clientId) {
23845
+ if (!ids.includes(clientId)) return null;
23846
+ return { sub, scope: String(answer.scope ?? ""), via: "op-token" };
23847
+ }
23848
+ if (!patServices.length) return null;
23849
+ const scope = String(answer.scope ?? "");
23850
+ const services = scope.split(/\s+/).filter(Boolean).map((s8) => s8.split(":")[0]);
23851
+ if (!services.some((s8) => patServices.includes(s8))) return null;
23852
+ return { sub, scope, via: "op-token" };
23853
+ }
23854
+ function patServiceIds(env) {
23855
+ return String(env.OIDC_PAT_SERVICES ?? "").split(/[\s,]+/).filter(Boolean);
23824
23856
  }
23825
23857
  async function opTokenMember(env, cfg, req) {
23826
23858
  const m = /^Bearer\s+(.+)$/i.exec((req.headers.get("Authorization") ?? "").trim());
@@ -23850,7 +23882,7 @@ async function opTokenMember(env, cfg, req) {
23850
23882
  } catch {
23851
23883
  }
23852
23884
  }
23853
- return opMemberFromIntrospection(answer, ids);
23885
+ return opMemberFromIntrospection(answer, ids, patServiceIds(env));
23854
23886
  }
23855
23887
 
23856
23888
  // workers/worker_public/src/oidc.ts
@@ -24560,6 +24592,8 @@ async function editionNote(env, u) {
24560
24592
  export {
24561
24593
  embed,
24562
24594
  generateOnce,
24595
+ answerQuality,
24596
+ qualityNote,
24563
24597
  ftsMatchQuery,
24564
24598
  refCodec,
24565
24599
  NO_CONTEXT,
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  NO_CONTEXT,
3
+ answerQuality,
3
4
  appliedContext,
4
5
  buildMessages,
5
6
  checkQuota,
@@ -21,6 +22,7 @@ import {
21
22
  parseContext,
22
23
  portModelRunner,
23
24
  portStore,
25
+ qualityNote,
24
26
  rawSessionToken,
25
27
  refCodec,
26
28
  resolveDocScope,
@@ -32,7 +34,7 @@ import {
32
34
  syntheticUnderstanding,
33
35
  telemetry,
34
36
  understandQuery
35
- } from "./chunk-37FSCXBQ.js";
37
+ } from "./chunk-NBE7BCQ7.js";
36
38
  import {
37
39
  corsHeaders,
38
40
  err,
@@ -497,6 +499,7 @@ function modelCitation(node) {
497
499
  clause_title: `${node.kind.replace(/_/g, " ")} \u2014 ${node.name} (${node.node_id})`,
498
500
  status: "in-force",
499
501
  corpus: "smart-model",
502
+ quality: "verified",
500
503
  url: void 0,
501
504
  snippet: `${node.node_id}${node.clause ? ` \xB7 ${node.clause.urn}` : ""}${node.content?.statement ? ` \u2014 ${clip(node.content.statement, 240)}` : ""}`,
502
505
  score: 1
@@ -2471,7 +2474,8 @@ Answer account questions from these records ONLY: name the record when you use i
2471
2474
 
2472
2475
  `));
2473
2476
  send({ type: "read", read: readAs() });
2474
- send({ type: "citations", citations: cites, context_applied: ctxApplied, ...liveRecords ? { records: liveRecords } : {}, quota });
2477
+ const sourceQuality = answerQuality(cites.map((c) => c.quality));
2478
+ send({ type: "citations", citations: cites, context_applied: ctxApplied, ...sourceQuality ? { source_quality: sourceQuality, quality_note: qualityNote(sourceQuality) } : {}, ...liveRecords ? { records: liveRecords } : {}, quota });
2475
2479
  let full = "";
2476
2480
  try {
2477
2481
  for await (const tok of sseTokens(stream)) {
@@ -2604,7 +2608,8 @@ Answer account questions from these records ONLY: name the record when you use i
2604
2608
  if (completionBlocks.length) console.log("contract completion:", completionBlocks.length, "table block(s) attached server-side");
2605
2609
  }
2606
2610
  completionBlocks.push(...await completeFigures(env.DB, answer, [...c2ns.blocks, ...completionBlocks], used));
2607
- const out = { answer, citations: finalCites, model: MODELS.member, query_hash: queryHash, follow_ups: understanding?.follow_ups ?? [], blocks: [...c2ns.blocks, ...verdictBlock ? [verdictBlock] : [], ...conditionBlock ? [conditionBlock] : [], ...aggregationBlock ? [aggregationBlock] : [], ...completionBlocks], context_applied: ctxApplied, ...liveRecords ? { records: liveRecords } : {} };
2611
+ const jsonQuality = answerQuality(finalCites.map((c) => c.quality));
2612
+ const out = { answer, citations: finalCites, ...jsonQuality ? { source_quality: jsonQuality, quality_note: qualityNote(jsonQuality) } : {}, model: MODELS.member, query_hash: queryHash, follow_ups: understanding?.follow_ups ?? [], blocks: [...c2ns.blocks, ...verdictBlock ? [verdictBlock] : [], ...conditionBlock ? [conditionBlock] : [], ...aggregationBlock ? [aggregationBlock] : [], ...completionBlocks], context_applied: ctxApplied, ...liveRecords ? { records: liveRecords } : {} };
2608
2613
  const cacheable = !contextual && !declaredCtx && !answer.includes(refusalAnswer()) && finalAnchors.violations.length === 0;
2609
2614
  if (cacheable) {
2610
2615
  const warmVec = await warmEmbed ?? null;
package/dist/env.d.ts CHANGED
@@ -36,6 +36,10 @@ export interface Env {
36
36
  * deployment admits as member-read credentials via introspection.
37
37
  * Empty/absent = the tier is off. */
38
38
  OIDC_DEVICE_CLIENT_IDS?: string;
39
+ /** the services whose OP-minted personal access tokens (the PAT form
40
+ * of introspection — no client_id on the wire) this deployment admits
41
+ * as member-read credentials. Empty/absent = PATs stay anonymous. */
42
+ OIDC_PAT_SERVICES?: string;
39
43
  SESSION_SECRET?: string;
40
44
  /** TODO.ai-platform/03 — the "my account" live-data delegation: the
41
45
  * platform instance's API base + its client id at the OP (the
@@ -111,9 +111,17 @@ export declare function opMemberCanWrite(member: {
111
111
  scope?: string;
112
112
  } | null): boolean;
113
113
  /** The introspection answer → the member credential, judged against the
114
- * deployment's allowlist. Inactive, foreign-client or subject-less
115
- * answers all resolve to null — never to a widened guess. */
116
- export declare function opMemberFromIntrospection(answer: any, ids: string[]): OpMember | null;
114
+ * deployment's two allowlists. The OP answers client-bearing tokens
115
+ * WITH client_id (the access-token table, the machine JWTs) and its
116
+ * PAT form WITHOUT one — a PAT's identity on the wire is its granted
117
+ * SERVICES (the PAT grammar's whole vocabulary). So the deployment
118
+ * names device-grant clients (OIDC_DEVICE_CLIENT_IDS) and, separately,
119
+ * the services whose personal access tokens it admits
120
+ * (OIDC_PAT_SERVICES). Both lists empty = the tier stays off; a token
121
+ * matching neither list resolves to null — never a widened guess. */
122
+ export declare function opMemberFromIntrospection(answer: any, ids: string[], patServices?: string[]): OpMember | null;
123
+ /** The PAT-services allowlist (see opMemberFromIntrospection). */
124
+ export declare function patServiceIds(env: any): string[];
117
125
  /** The Bearer read + introspection, KV-cached 45s keyed by the token's
118
126
  * hash (an ask burst costs one introspection; a deactivation lands
119
127
  * within a minute). The cached object is the OP's RAW answer — the
@@ -87,6 +87,7 @@ export declare function modelCitation(node: BoundModelNode): {
87
87
  clause_title: string;
88
88
  status: string;
89
89
  corpus: string;
90
+ quality: string;
90
91
  url: undefined;
91
92
  snippet: string;
92
93
  score: number;
@@ -61,6 +61,7 @@ export declare function citations(hits: Hit[]): {
61
61
  status: string;
62
62
  superseded_by: string | undefined;
63
63
  corpus: any;
64
+ quality: import("./quality").SourceQuality;
64
65
  url: string | undefined;
65
66
  snippet: string;
66
67
  score: number;
@@ -0,0 +1,6 @@
1
+ import type { ChunkMeta } from "../../shared/chunk.ts";
2
+ export type SourceQuality = "verified" | "curated" | "ocr";
3
+ export declare function hitQuality(m: ChunkMeta): SourceQuality;
4
+ export declare function answerQuality(qualities: (SourceQuality | undefined)[]): SourceQuality | null;
5
+ /** The one-line posture the interface renders under the answer. */
6
+ export declare function qualityNote(q: SourceQuality): string;
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  handleSearch
3
- } from "./chunk-YEAAGFHB.js";
4
- import "./chunk-37FSCXBQ.js";
3
+ } from "./chunk-EVCERBIH.js";
4
+ import "./chunk-NBE7BCQ7.js";
5
5
  import "./chunk-R2V3X6SQ.js";
6
6
  import "./chunk-A3QHHUN5.js";
7
7
  import "./chunk-4GJGBGJK.js";
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  handleSearch
3
- } from "../../chunk-YEAAGFHB.js";
3
+ } from "../../chunk-EVCERBIH.js";
4
4
  import {
5
5
  bindModelNode,
6
6
  checkQuoteAnchors,
@@ -9,7 +9,7 @@ import {
9
9
  modelGroundingBlock,
10
10
  scoreJudge,
11
11
  standardForDocNumber
12
- } from "../../chunk-SEOQWW77.js";
12
+ } from "../../chunk-OHYAHQQV.js";
13
13
  import {
14
14
  buildMessages,
15
15
  citations,
@@ -35,7 +35,7 @@ import {
35
35
  sessionFrom,
36
36
  telemetry,
37
37
  understandQuery
38
- } from "../../chunk-37FSCXBQ.js";
38
+ } from "../../chunk-NBE7BCQ7.js";
39
39
  import {
40
40
  authenticate,
41
41
  corsHeaders,
@@ -999,7 +999,7 @@ async function handleMcp(env, ctx, req, tier, key) {
999
999
  // stream:false forces the JSON lane (anon defaults to SSE)
1000
1000
  body: JSON.stringify({ ...args, stream: false })
1001
1001
  });
1002
- const res = name === "ask" ? await (await import("../../ask-J6NGXPOD.js")).handleAsk(env, ctx, inner, tier, key) : await (await import("../../search-V6M4KASL.js")).handleSearch(env, ctx, inner, tier, key);
1002
+ const res = name === "ask" ? await (await import("../../ask-UGTXNPPY.js")).handleAsk(env, ctx, inner, tier, key) : await (await import("../../search-G23CY5I6.js")).handleSearch(env, ctx, inner, tier, key);
1003
1003
  return res.json().catch(() => ({ error: { message: "tool transport failed", status: res.status } }));
1004
1004
  });
1005
1005
  if (out.ok && "accepted" in out) return new Response(null, { status: 202 });
package/package.json CHANGED
@@ -27,7 +27,7 @@
27
27
  "playwright": "^1.62.1",
28
28
  "yaml": "^2.9.1"
29
29
  },
30
- "version": "0.2.37",
30
+ "version": "0.2.39",
31
31
  "description": "The Konneal engine: the publisher-agnostic build pipeline and API plane for standards intelligence (retrieval, answer contract, verdicts, evaluation).",
32
32
  "license": "BSD-3-Clause",
33
33
  "type": "module",
@@ -25,6 +25,7 @@ import { evaluate as machineEvaluate, verdictNote } from "./verdict";
25
25
  import { evaluateConditionSets, quantitiesIn, type ConditionVerdict } from "./conditions";
26
26
  import { evaluateAggregation, type AggregationVerdict } from "./aggregation";
27
27
  import { matchLicensedTopic, boundaryNoteText } from "./boundary";
28
+ import { answerQuality, qualityNote } from "./quality";
28
29
  import { detectDraftIntent, prepareDraft } from "./drafts";
29
30
  import { detectApiCallIntent, prepareApiCall } from "./apicalls";
30
31
  import { memoryNote } from "./memories";
@@ -1145,7 +1146,8 @@ async function handleAsk(
1145
1146
  // the reading arrives first: the interpretation that steered
1146
1147
  // retrieval, before a single token of the answer
1147
1148
  send({ type: "read", read: readAs() });
1148
- send({ type: "citations", citations: cites, context_applied: ctxApplied, ...(liveRecords ? { records: liveRecords } : {}), quota, });
1149
+ const sourceQuality = answerQuality(cites.map((c: any) => c.quality));
1150
+ send({ type: "citations", citations: cites, context_applied: ctxApplied, ...(sourceQuality ? { source_quality: sourceQuality, quality_note: qualityNote(sourceQuality) } : {}), ...(liveRecords ? { records: liveRecords } : {}), quota, });
1149
1151
  let full = "";
1150
1152
  try {
1151
1153
  for await (const tok of sseTokens(stream)) {
@@ -1339,7 +1341,8 @@ async function handleAsk(
1339
1341
  // figure completion (#172) — see ./completion for the rationale
1340
1342
  completionBlocks.push(...(await completeFigures(env.DB, answer, [...c2ns.blocks, ...completionBlocks], used)));
1341
1343
 
1342
- const out = { answer, citations: finalCites, model: MODELS.member, query_hash: queryHash, follow_ups: understanding?.follow_ups ?? [], blocks: [...c2ns.blocks, ...(verdictBlock ? [verdictBlock] : []), ...(conditionBlock ? [conditionBlock] : []), ...(aggregationBlock ? [aggregationBlock] : []), ...completionBlocks], context_applied: ctxApplied, ...(liveRecords ? { records: liveRecords } : {}) };
1344
+ const jsonQuality = answerQuality(finalCites.map((c: any) => c.quality));
1345
+ const out = { answer, citations: finalCites, ...(jsonQuality ? { source_quality: jsonQuality, quality_note: qualityNote(jsonQuality) } : {}), model: MODELS.member, query_hash: queryHash, follow_ups: understanding?.follow_ups ?? [], blocks: [...c2ns.blocks, ...(verdictBlock ? [verdictBlock] : []), ...(conditionBlock ? [conditionBlock] : []), ...(aggregationBlock ? [aggregationBlock] : []), ...completionBlocks], context_applied: ctxApplied, ...(liveRecords ? { records: liveRecords } : {}) };
1343
1346
  const cacheable = !contextual && !declaredCtx && !answer.includes(refusalAnswer()) && finalAnchors.violations.length === 0;
1344
1347
  if (cacheable) {
1345
1348
  const warmVec = (await warmEmbed) ?? null;
@@ -38,6 +38,10 @@ export interface Env {
38
38
  * deployment admits as member-read credentials via introspection.
39
39
  * Empty/absent = the tier is off. */
40
40
  OIDC_DEVICE_CLIENT_IDS?: string;
41
+ /** the services whose OP-minted personal access tokens (the PAT form
42
+ * of introspection — no client_id on the wire) this deployment admits
43
+ * as member-read credentials. Empty/absent = PATs stay anonymous. */
44
+ OIDC_PAT_SERVICES?: string;
41
45
  SESSION_SECRET?: string;
42
46
  /** TODO.ai-platform/03 — the "my account" live-data delegation: the
43
47
  * platform instance's API base + its client id at the OP (the
@@ -431,15 +431,35 @@ export function opMemberCanWrite(member: { via?: string; scope?: string } | null
431
431
  }
432
432
 
433
433
  /** The introspection answer → the member credential, judged against the
434
- * deployment's allowlist. Inactive, foreign-client or subject-less
435
- * answers all resolve to null — never to a widened guess. */
436
- export function opMemberFromIntrospection(answer: any, ids: string[]): OpMember | null {
434
+ * deployment's two allowlists. The OP answers client-bearing tokens
435
+ * WITH client_id (the access-token table, the machine JWTs) and its
436
+ * PAT form WITHOUT one — a PAT's identity on the wire is its granted
437
+ * SERVICES (the PAT grammar's whole vocabulary). So the deployment
438
+ * names device-grant clients (OIDC_DEVICE_CLIENT_IDS) and, separately,
439
+ * the services whose personal access tokens it admits
440
+ * (OIDC_PAT_SERVICES). Both lists empty = the tier stays off; a token
441
+ * matching neither list resolves to null — never a widened guess. */
442
+ export function opMemberFromIntrospection(answer: any, ids: string[], patServices: string[] = []): OpMember | null {
437
443
  if (!answer?.active) return null;
438
- const clientId = String(answer.client_id ?? "");
439
- if (!ids.includes(clientId)) return null;
440
444
  const sub = String(answer.sub ?? "");
441
445
  if (!sub) return null;
442
- return { sub, scope: String(answer.scope ?? ""), via: "op-token" };
446
+ const clientId = String(answer.client_id ?? "");
447
+ if (clientId) {
448
+ if (!ids.includes(clientId)) return null;
449
+ return { sub, scope: String(answer.scope ?? ""), via: "op-token" };
450
+ }
451
+ if (!patServices.length) return null;
452
+ const scope = String(answer.scope ?? "");
453
+ const services = scope.split(/\s+/).filter(Boolean).map((s) => s.split(":")[0]);
454
+ if (!services.some((s) => patServices.includes(s))) return null;
455
+ return { sub, scope, via: "op-token" };
456
+ }
457
+
458
+ /** The PAT-services allowlist (see opMemberFromIntrospection). */
459
+ export function patServiceIds(env: any): string[] {
460
+ return String(env.OIDC_PAT_SERVICES ?? "")
461
+ .split(/[\s,]+/)
462
+ .filter(Boolean);
443
463
  }
444
464
 
445
465
  /** The Bearer read + introspection, KV-cached 45s keyed by the token's
@@ -482,5 +502,5 @@ export async function opTokenMember(
482
502
  /* cacheless = introspect per ask */
483
503
  }
484
504
  }
485
- return opMemberFromIntrospection(answer, ids);
505
+ return opMemberFromIntrospection(answer, ids, patServiceIds(env));
486
506
  }
@@ -287,6 +287,7 @@ export function modelCitation(node: BoundModelNode) {
287
287
  clause_title: `${node.kind.replace(/_/g, " ")} — ${node.name} (${node.node_id})`,
288
288
  status: "in-force",
289
289
  corpus: "smart-model",
290
+ quality: "verified",
290
291
  url: undefined,
291
292
  snippet: `${node.node_id}${node.clause ? ` · ${node.clause.urn}` : ""}${node.content?.statement ? ` — ${clip(node.content.statement, 240)}` : ""}`,
292
293
  score: 1,
@@ -33,6 +33,7 @@ export function fill(template: string, vars: Record<string, string>): string {
33
33
  return template.replace(/\{\{(\w+)\}\}/g, (_m, k: string) => (k in vars ? vars[k] : ""));
34
34
  }
35
35
  import { QueryFilters, toVectorizeFilter, standardKeyAllowed } from "./selfquery";
36
+ import { hitQuality } from "./quality";
36
37
  import { lexicalPrefilter } from "./lexical";
37
38
  import { positionOrder } from "./structural";
38
39
  import { STAGES, runStages } from "./stages";
@@ -404,6 +405,7 @@ export function citations(hits: Hit[]) {
404
405
  status: h.metadata.status ?? "unknown",
405
406
  superseded_by: h.metadata.superseded_by || undefined,
406
407
  corpus: h.metadata.corpus || P().publisher.id,
408
+ quality: hitQuality(h.metadata),
407
409
  url: publicationUrl(h.metadata),
408
410
  snippet: h.text.slice(0, 400),
409
411
  score: h.rerank_score ?? h.score,
@@ -0,0 +1,48 @@
1
+ // The answer-quality ladder (TODO.rag/13): every citation states what
2
+ // kind of text grounds it, so the answer states how far to trust it.
3
+ // Three rungs, derived from provenance the chunk wire already carries —
4
+ // no new ingest state:
5
+ //
6
+ // verified — the model plane: the chunk rides a primmel data model
7
+ // (model_node/model_version), the Recommendation's semantics are
8
+ // machine-checkable and the golden set measures them (R 60 is the
9
+ // gauge). The answer can be WRONG, but it is wrong against a
10
+ // contract we can see.
11
+ // curated — the edited corpus: Metanorma-authored documents
12
+ // (mn-samples-oiml and the editors' own), clause-boundary chunks,
13
+ // enrichment applied.
14
+ // ocr — the experimental OCR corpus: the broad coverage lane,
15
+ // text recovered from scans; quote anchors may wobble, tables can
16
+ // be mangled. Useful, and marked as experimental on purpose.
17
+ //
18
+ // The answer's tier is the WORST rung its citations stand on: one OCR
19
+ // citation makes the whole answer experimental, because the reader
20
+ // cannot tell which sentence leaned on which chunk.
21
+
22
+ import type { ChunkMeta } from "../../shared/chunk.ts";
23
+
24
+ export type SourceQuality = "verified" | "curated" | "ocr";
25
+
26
+ const ORDER: Record<SourceQuality, number> = { verified: 0, curated: 1, ocr: 2 };
27
+
28
+ export function hitQuality(m: ChunkMeta): SourceQuality {
29
+ if (m.model_node || m.model_version || m.producer === "primmel") return "verified";
30
+ if (m.tier === "curated") return "curated";
31
+ return "ocr";
32
+ }
33
+
34
+ export function answerQuality(qualities: (SourceQuality | undefined)[]): SourceQuality | null {
35
+ let worst: SourceQuality | null = null;
36
+ for (const q of qualities) {
37
+ if (!q) continue;
38
+ if (!worst || ORDER[q] > ORDER[worst]) worst = q;
39
+ }
40
+ return worst;
41
+ }
42
+
43
+ /** The one-line posture the interface renders under the answer. */
44
+ export function qualityNote(q: SourceQuality): string {
45
+ if (q === "verified") return "Grounded in verified sources: this publication's requirements ride a machine-checkable data model.";
46
+ if (q === "curated") return "Grounded in the edited corpus: Metanorma-authored documents, chunked at clause boundaries.";
47
+ return "Partly grounded in experimental OCR text: verify quotations and table values against the official publication.";
48
+ }