@konneal/engine 0.2.118 → 0.2.119

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/dist/admin.d.ts CHANGED
@@ -13,6 +13,14 @@ export declare function handleEnrich(env: Env, ctx: ExecutionContext, req: Reque
13
13
  * descends from it to quotable leaf clauses (pipeline.ts section
14
14
  * descent). Credential, batching and ledger mirror /admin/enrich. */
15
15
  export declare function handleSectionUnit(env: Env, ctx: ExecutionContext, req: Request): Promise<Response>;
16
+ /** Family community summaries (TODO.sota/06 item 2): one precomputed
17
+ * answer per publication family to "what is this family and what
18
+ * changed between editions" — generated from the registry rows and
19
+ * the successor graph (the family IS the corpus's natural community),
20
+ * stored in KV under famsum:<family>, served by editionNote beside
21
+ * the deterministic registry line. Admin-gated; idempotent (force
22
+ * regenerates); the quality-first lane pays once. */
23
+ export declare function handleFamilySummary(env: Env, req: Request): Promise<Response>;
16
24
  /** Ops access to the Vectorize binding (get/upsert by id) for offline
17
25
  * passes like embedding smoothing (G-ETSI-4) — the binding is the
18
26
  * credential, admin-token gated exactly like /admin/enrich. */
@@ -1,8 +1,8 @@
1
1
  import {
2
2
  handleAsk
3
- } from "./chunk-UBQCQ4UQ.js";
3
+ } from "./chunk-EGFTNES5.js";
4
4
  import "./chunk-32UDKBA2.js";
5
- import "./chunk-NUHZMAII.js";
5
+ import "./chunk-IIQWESHC.js";
6
6
  import "./chunk-TH5XGPD5.js";
7
7
  import "./chunk-DJ65EF5G.js";
8
8
  import "./chunk-A3QHHUN5.js";
@@ -6,7 +6,7 @@ import {
6
6
  sessionFrom,
7
7
  telemetry,
8
8
  understandQuery
9
- } from "./chunk-NUHZMAII.js";
9
+ } from "./chunk-IIQWESHC.js";
10
10
  import {
11
11
  portModelRunner
12
12
  } from "./chunk-TH5XGPD5.js";
@@ -60,7 +60,7 @@ import {
60
60
  tokenBudget,
61
61
  understandQuery,
62
62
  usageTotal
63
- } from "./chunk-NUHZMAII.js";
63
+ } from "./chunk-IIQWESHC.js";
64
64
  import {
65
65
  embed,
66
66
  generateOnce,
@@ -24756,7 +24756,14 @@ async function editionNote(env, u) {
24756
24756
  ).bind(`% ${u.doc_number}:%`).all();
24757
24757
  const actives = (rows.results ?? []).map((r) => r.docidentifier);
24758
24758
  if (!actives.length) return void 0;
24759
- return `Publication registry (authoritative): the ACTIVE edition(s) for this publication are ${actives.join(", ")}. Passages from other editions are superseded \u2014 use them only for historical comparison and say so.`;
24759
+ const famRow = await env.DB.prepare("SELECT family FROM documents WHERE docidentifier LIKE ?1 || '%:%' LIMIT 1").bind(`% ${u.doc_number}:%`).first().catch(() => null);
24760
+ let summary = "";
24761
+ if (famRow?.family && env.CACHE) {
24762
+ summary = await env.CACHE.get(`famsum:${famRow.family}`).catch(() => "") ?? "";
24763
+ }
24764
+ const registry = `Publication registry (authoritative): the ACTIVE edition(s) for this publication are ${actives.join(", ")}. Passages from other editions are superseded \u2014 use them only for historical comparison and say so.`;
24765
+ return summary ? `${registry}
24766
+ Family summary (precomputed from the registry and the successor graph): ${summary}` : registry;
24760
24767
  } catch {
24761
24768
  return void 0;
24762
24769
  }
@@ -3,7 +3,7 @@ export interface OpenApiRoute {
3
3
  pattern: string;
4
4
  operationId: string;
5
5
  }
6
- export type OpenApiOperationId = "askAnonymous" | "askKeyed" | "search" | "searchKeyed" | "verify" | "verifyKeyed" | "research" | "researchKeyed" | "mcp" | "datasets" | "keyUsage" | "health" | "listConversations" | "createConversation" | "getConversation" | "renameConversation" | "deleteConversation" | "appendMessage" | "shareConversation" | "getShared" | "uploadAttachment" | "listAttachments" | "getAttachment" | "deleteAttachment" | "listMemories" | "createMemory" | "deleteMemory" | "listProjects" | "createProject" | "deleteProject" | "listProjectFiles" | "attachProjectFile" | "detachProjectFile" | "laneQuery" | "laneKeyed" | "feedback" | "adminEnrich" | "adminEnrichAlias" | "adminSection" | "adminSectionAlias" | "adminVectors" | "adminCaption" | "adminJudge" | "adminJudgeAlias" | "adminRevokeKey" | "authMe" | "authLogin" | "authCallback" | "authLogout" | "authLogoutLink" | "adminStats" | "adminListKeys" | "adminCreateKey";
6
+ export type OpenApiOperationId = "askAnonymous" | "askKeyed" | "search" | "searchKeyed" | "verify" | "verifyKeyed" | "research" | "researchKeyed" | "mcp" | "datasets" | "keyUsage" | "health" | "listConversations" | "createConversation" | "getConversation" | "renameConversation" | "deleteConversation" | "appendMessage" | "shareConversation" | "getShared" | "uploadAttachment" | "listAttachments" | "getAttachment" | "deleteAttachment" | "listMemories" | "createMemory" | "deleteMemory" | "listProjects" | "createProject" | "deleteProject" | "listProjectFiles" | "attachProjectFile" | "detachProjectFile" | "laneQuery" | "laneKeyed" | "feedback" | "adminEnrich" | "adminEnrichAlias" | "adminSection" | "adminFamilySummary" | "adminSectionAlias" | "adminFamilySummaryAlias" | "adminVectors" | "adminCaption" | "adminJudge" | "adminJudgeAlias" | "adminRevokeKey" | "authMe" | "authLogin" | "authCallback" | "authLogout" | "authLogoutLink" | "adminStats" | "adminListKeys" | "adminCreateKey";
7
7
  export declare const OPENAPI_SURFACE: readonly (Omit<OpenApiRoute, "operationId"> & {
8
8
  operationId: OpenApiOperationId;
9
9
  })[];
@@ -613,6 +613,26 @@ export interface paths {
613
613
  patch?: never;
614
614
  trace?: never;
615
615
  };
616
+ "/v1/admin/family-summary": {
617
+ parameters: {
618
+ query?: never;
619
+ header?: never;
620
+ path?: never;
621
+ cookie?: never;
622
+ };
623
+ get?: never;
624
+ put?: never;
625
+ /**
626
+ * Write a family's community summary
627
+ * @description Generates the precomputed answer to "what is this family and what changed between editions" from the registry rows and the successor graph, and stores it in KV; editionNote serves it beside the deterministic registry line on doc-scoped answers. Families need at least two editions. force regenerates.
628
+ */
629
+ post: operations["adminFamilySummary"];
630
+ delete?: never;
631
+ options?: never;
632
+ head?: never;
633
+ patch?: never;
634
+ trace?: never;
635
+ };
616
636
  "/admin/section": {
617
637
  parameters: {
618
638
  query?: never;
@@ -633,6 +653,26 @@ export interface paths {
633
653
  patch?: never;
634
654
  trace?: never;
635
655
  };
656
+ "/admin/family-summary": {
657
+ parameters: {
658
+ query?: never;
659
+ header?: never;
660
+ path?: never;
661
+ cookie?: never;
662
+ };
663
+ get?: never;
664
+ put?: never;
665
+ /**
666
+ * Write a family's community summary (the operational path)
667
+ * @description The operational alias of /v1/admin/family-summary; the behaviour and the credential are identical.
668
+ */
669
+ post: operations["adminFamilySummaryAlias"];
670
+ delete?: never;
671
+ options?: never;
672
+ head?: never;
673
+ patch?: never;
674
+ trace?: never;
675
+ };
636
676
  "/admin/vectors": {
637
677
  parameters: {
638
678
  query?: never;
@@ -1932,6 +1972,33 @@ export interface operations {
1932
1972
  };
1933
1973
  };
1934
1974
  };
1975
+ adminFamilySummary: {
1976
+ parameters: {
1977
+ query?: never;
1978
+ header?: never;
1979
+ path?: never;
1980
+ cookie?: never;
1981
+ };
1982
+ requestBody: {
1983
+ content: {
1984
+ "application/json": {
1985
+ /** @description The family key, for example R-60. */
1986
+ family: string;
1987
+ /** @default false */
1988
+ force?: boolean;
1989
+ };
1990
+ };
1991
+ };
1992
+ responses: {
1993
+ /** @description The stored summary with its provenance counts. */
1994
+ 200: {
1995
+ headers: {
1996
+ [name: string]: unknown;
1997
+ };
1998
+ content?: never;
1999
+ };
2000
+ };
2001
+ };
1935
2002
  adminSectionAlias: {
1936
2003
  parameters: {
1937
2004
  query?: never;
@@ -1956,6 +2023,30 @@ export interface operations {
1956
2023
  };
1957
2024
  };
1958
2025
  };
2026
+ adminFamilySummaryAlias: {
2027
+ parameters: {
2028
+ query?: never;
2029
+ header?: never;
2030
+ path?: never;
2031
+ cookie?: never;
2032
+ };
2033
+ requestBody: {
2034
+ content: {
2035
+ "application/json": {
2036
+ family: string;
2037
+ };
2038
+ };
2039
+ };
2040
+ responses: {
2041
+ /** @description The stored summary. */
2042
+ 200: {
2043
+ headers: {
2044
+ [name: string]: unknown;
2045
+ };
2046
+ content?: never;
2047
+ };
2048
+ };
2049
+ };
1959
2050
  adminVectors: {
1960
2051
  parameters: {
1961
2052
  query?: never;
@@ -0,0 +1 @@
1
+ You write the community summary of one publication family for {{PUBLISHER_NAME}}'s retrieval index — the precomputed answer to "what is this family and what changed between editions". The registry rows and the successor graph below are your ONLY facts. Write three to five sentences: what the family regulates or provides, its structure (parts, annexes), and the edition lineage — which editions are active, which are superseded, and what the successor chain says changed. Never invent a change the lineage does not show; when the chain only says one edition replaced another, say exactly that. Name editions exactly as the registry does. No preamble.
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  handleSearch
3
- } from "./chunk-YNQ5BKQW.js";
4
- import "./chunk-NUHZMAII.js";
3
+ } from "./chunk-C5L55VI7.js";
4
+ import "./chunk-IIQWESHC.js";
5
5
  import "./chunk-TH5XGPD5.js";
6
6
  import "./chunk-DJ65EF5G.js";
7
7
  import "./chunk-A3QHHUN5.js";
@@ -4,7 +4,7 @@ import {
4
4
  handleMemories,
5
5
  scoreFaithfulness,
6
6
  scoreJudge
7
- } from "../../chunk-UBQCQ4UQ.js";
7
+ } from "../../chunk-EGFTNES5.js";
8
8
  import {
9
9
  TOOLS_REGISTRY,
10
10
  bindModelNode,
@@ -12,7 +12,7 @@ import {
12
12
  } from "../../chunk-32UDKBA2.js";
13
13
  import {
14
14
  handleSearch
15
- } from "../../chunk-YNQ5BKQW.js";
15
+ } from "../../chunk-C5L55VI7.js";
16
16
  import {
17
17
  buildMessages,
18
18
  citations,
@@ -34,7 +34,7 @@ import {
34
34
  sessionFrom,
35
35
  telemetry,
36
36
  understandQuery
37
- } from "../../chunk-NUHZMAII.js";
37
+ } from "../../chunk-IIQWESHC.js";
38
38
  import {
39
39
  cfBlobs,
40
40
  embed,
@@ -350,6 +350,10 @@ var enrichment_default = "You write a retrieval context for a passage from {{COR
350
350
  // workers/worker_public/prompts/doc-summary.md
351
351
  var doc_summary_default = "You write the document-level summary for a legal-metrology publication in {{PUBLISHER_NAME}}'s retrieval index. The section summaries below cover the publication clause by clause. Write one compact abstract, three to five sentences: what the publication regulates or provides, its scope and applicability, and the structure a reader should know (parts, annexes, where the normative core sits). Use the publication's own terms exactly. No preamble, no headings.\n";
352
352
 
353
+ // workers/worker_public/prompts/family-summary.md
354
+ var family_summary_default = `You write the community summary of one publication family for {{PUBLISHER_NAME}}'s retrieval index \u2014 the precomputed answer to "what is this family and what changed between editions". The registry rows and the successor graph below are your ONLY facts. Write three to five sentences: what the family regulates or provides, its structure (parts, annexes), and the edition lineage \u2014 which editions are active, which are superseded, and what the successor chain says changed. Never invent a change the lineage does not show; when the chain only says one edition replaced another, say exactly that. Name editions exactly as the registry does. No preamble.
355
+ `;
356
+
353
357
  // workers/worker_public/prompts/section-summary.md
354
358
  var section_summary_default = "You summarize one numbered clause of a metrology publication for a retrieval index. You are given the publication, the clause number, and excerpts of its sub-clauses.\n\nWrite a dense summary of 3 to 5 sentences stating what the clause governs and how its sub-clauses divide the subject. Name each sub-clause number together with its topic, in document order.\n\nPlain factual prose. No preamble, no headings, no bullet list, no quotation marks around the whole text. Write in the same language as the excerpts.\n";
355
359
 
@@ -510,6 +514,51 @@ Covers: ${childAnchors}`;
510
514
  }
511
515
  return json({ results, usage });
512
516
  }
517
+ async function handleFamilySummary(env, req) {
518
+ if (!env.ADMIN_TOKEN) return err(501, "admin_disabled", "ADMIN_TOKEN secret is not configured");
519
+ const auth = req.headers.get("authorization") ?? "";
520
+ if (auth !== `Bearer ${env.ADMIN_TOKEN}`) return err(401, "unauthorized", "Invalid admin token");
521
+ const body = await readJson(req);
522
+ const family = typeof body?.family === "string" ? body.family.trim().slice(0, 24) : "";
523
+ if (!/^[A-Z]+-\d+$/.test(family)) return err(400, "invalid_input", "family must look like R-60 (type-number)");
524
+ try {
525
+ const docs = await env.DB.prepare(
526
+ "SELECT docidentifier, edition, status, active, superseded_by, title FROM documents WHERE family = ?1 ORDER BY edition"
527
+ ).bind(family).all();
528
+ const rows = docs.results ?? [];
529
+ if (rows.length < 2) return json({ family, ok: false, note: "fewer than two editions \u2014 no lineage to summarize" });
530
+ const succ = await env.DB.prepare(
531
+ "SELECT e.src AS src, e.dst AS dst FROM graph_edges e WHERE e.kind = 'successor' AND (e.src IN (SELECT src FROM graph_edges WHERE kind = 'part_of' AND dst = ?2) OR e.dst IN (SELECT src FROM graph_edges WHERE kind = 'part_of' AND dst = ?2)) LIMIT 40"
532
+ ).bind(family, `family:${family}`).all();
533
+ const key = `famsum:${family}`;
534
+ const cached = body?.force === true ? null : await env.CACHE.get(key);
535
+ if (cached) return json({ family, ok: true, cached: true, summary: cached });
536
+ const listing = rows.map((r) => `- ${r.docidentifier} \u2014 ${r.status}${r.active ? " (ACTIVE)" : ""}${r.title ? ` \u2014 ${String(r.title).slice(0, 120)}` : ""}`).join("\n");
537
+ const chain = (succ.results ?? []).map((r) => `${String(r.src).replace("doc:", "")} \u2192 superseded by \u2192 ${String(r.dst).replace("doc:", "")}`).join("\n");
538
+ const model = typeof env.ENRICH_MODEL === "string" && env.ENRICH_MODEL ? env.ENRICH_MODEL : MODELS.enrich;
539
+ const res = await env.AI.run(model, {
540
+ messages: [
541
+ { role: "system", content: family_summary_default.trimEnd() },
542
+ { role: "user", content: `Family ${family}
543
+
544
+ Registry rows:
545
+ ${listing}
546
+
547
+ Successor chain:
548
+ ${chain || "(no successor edges recorded)"}` }
549
+ ],
550
+ max_tokens: 1600,
551
+ reasoning_effort: "low"
552
+ });
553
+ const raw = typeof res?.response === "string" && res.response.trim() ? res.response : res?.choices?.[0]?.message?.content;
554
+ const summary = typeof raw === "string" ? raw.trim().replace(/^["']|["']$/g, "").slice(0, 1200) : "";
555
+ if (!summary) return json({ family, ok: false, error: "empty summary" });
556
+ await env.CACHE.put(key, summary);
557
+ return json({ family, ok: true, cached: false, summary, editions: rows.length, successors: (succ.results ?? []).length });
558
+ } catch (e) {
559
+ return err(502, "family_summary_failed", String(e?.message ?? e).slice(0, 200));
560
+ }
561
+ }
513
562
  async function handleCaption(env, req) {
514
563
  if (!env.ADMIN_TOKEN) return err(501, "admin_disabled", "ADMIN_TOKEN secret is not configured");
515
564
  const auth = req.headers.get("authorization") ?? "";
@@ -1000,7 +1049,7 @@ async function callToolOnce(env, ctx, _req, tier, key, name, args, t0) {
1000
1049
  const r = await runTool({ ...env, DB: env.DB }, { name, args }, "mcp");
1001
1050
  payload = r ? { tool: r.name, query: r.query, result: r.output } : { error: { message: `tool ${name} returned nothing for the given arguments` } };
1002
1051
  } else {
1003
- const res = name === "ask" ? await (await import("../../ask-ZZT67FEB.js")).handleAsk(env, ctx, inner, tier, key) : await (await import("../../search-3PBQSMO4.js")).handleSearch(env, ctx, inner, tier, key);
1052
+ const res = name === "ask" ? await (await import("../../ask-YVBM6IEV.js")).handleAsk(env, ctx, inner, tier, key) : await (await import("../../search-JDSQPI3A.js")).handleSearch(env, ctx, inner, tier, key);
1004
1053
  payload = await res.json().catch(() => ({ error: { message: "tool transport failed", status: res.status } }));
1005
1054
  }
1006
1055
  telemetry(env, ctx, tier, `mcp:${name}`, null, !payload?.error, JSON.stringify(payload).length, await sha256Hex(`${name}:${JSON.stringify(args ?? {})}`), void 0, void 0, { durationMs: Date.now() - t0 });
@@ -1232,11 +1281,21 @@ var OPENAPI_SURFACE = [
1232
1281
  "pattern": "/v1/admin/section",
1233
1282
  "operationId": "adminSection"
1234
1283
  },
1284
+ {
1285
+ "method": "POST",
1286
+ "pattern": "/v1/admin/family-summary",
1287
+ "operationId": "adminFamilySummary"
1288
+ },
1235
1289
  {
1236
1290
  "method": "POST",
1237
1291
  "pattern": "/admin/section",
1238
1292
  "operationId": "adminSectionAlias"
1239
1293
  },
1294
+ {
1295
+ "method": "POST",
1296
+ "pattern": "/admin/family-summary",
1297
+ "operationId": "adminFamilySummaryAlias"
1298
+ },
1240
1299
  {
1241
1300
  "method": "POST",
1242
1301
  "pattern": "/admin/vectors",
@@ -1772,6 +1831,8 @@ var OPENAPI_HANDLERS = {
1772
1831
  adminEnrich: (c) => handleEnrich(c.env, c.ctx, c.req),
1773
1832
  adminEnrichAlias: (c) => handleEnrich(c.env, c.ctx, c.req),
1774
1833
  adminSection: (c) => handleSectionUnit(c.env, c.ctx, c.req),
1834
+ adminFamilySummary: (c) => handleFamilySummary(c.env, c.req),
1835
+ adminFamilySummaryAlias: (c) => handleFamilySummary(c.env, c.req),
1775
1836
  adminSectionAlias: (c) => handleSectionUnit(c.env, c.ctx, c.req),
1776
1837
  adminVectors: (c) => handleVectors(c.env, c.req),
1777
1838
  adminCaption: (c) => handleCaption(c.env, c.req),
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.118",
30
+ "version": "0.2.119",
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",
@@ -866,6 +866,35 @@ paths:
866
866
  responses:
867
867
  '200':
868
868
  description: The written units with their usage.
869
+ /v1/admin/family-summary:
870
+ post:
871
+ tags: [Administration]
872
+ summary: Write a family's community summary
873
+ description: >-
874
+ Generates the precomputed answer to "what is this family and
875
+ what changed between editions" from the registry rows and the
876
+ successor graph, and stores it in KV; editionNote serves it
877
+ beside the deterministic registry line on doc-scoped answers.
878
+ Families need at least two editions. force regenerates.
879
+ operationId: adminFamilySummary
880
+ security: [{ adminAuth: [] }]
881
+ requestBody:
882
+ required: true
883
+ content:
884
+ application/json:
885
+ schema:
886
+ type: object
887
+ required: [family]
888
+ properties:
889
+ family:
890
+ type: string
891
+ description: The family key, for example R-60.
892
+ force:
893
+ type: boolean
894
+ default: false
895
+ responses:
896
+ '200':
897
+ description: The stored summary with its provenance counts.
869
898
  /admin/section:
870
899
  post:
871
900
  tags: [Administration]
@@ -887,6 +916,27 @@ paths:
887
916
  responses:
888
917
  '200':
889
918
  description: The written units with their usage.
919
+ /admin/family-summary:
920
+ post:
921
+ tags: [Administration]
922
+ summary: Write a family's community summary (the operational path)
923
+ description: >-
924
+ The operational alias of /v1/admin/family-summary; the behaviour
925
+ and the credential are identical.
926
+ operationId: adminFamilySummaryAlias
927
+ security: [{ adminAuth: [] }]
928
+ requestBody:
929
+ required: true
930
+ content:
931
+ application/json:
932
+ schema:
933
+ type: object
934
+ required: [family]
935
+ properties:
936
+ family: { type: string }
937
+ responses:
938
+ '200':
939
+ description: The stored summary.
890
940
  /admin/vectors:
891
941
  post:
892
942
  tags: [Administration]
@@ -0,0 +1 @@
1
+ You write the community summary of one publication family for {{PUBLISHER_NAME}}'s retrieval index — the precomputed answer to "what is this family and what changed between editions". The registry rows and the successor graph below are your ONLY facts. Write three to five sentences: what the family regulates or provides, its structure (parts, annexes), and the edition lineage — which editions are active, which are superseded, and what the successor chain says changed. Never invent a change the lineage does not show; when the chain only says one edition replaced another, say exactly that. Name editions exactly as the registry does. No preamble.
@@ -7,6 +7,7 @@ import { err, json, corsHeaders, readJson, authenticate } from "./lib/http";
7
7
  import type { Env } from "./env";
8
8
  import enrichmentPrompt from "../prompts/enrichment.md";
9
9
  import docSummaryPrompt from "../prompts/doc-summary.md";
10
+ import familySummaryPrompt from "../prompts/family-summary.md";
10
11
  import sectionSummaryPrompt from "../prompts/section-summary.md";
11
12
  import relevancyPrompt from "../prompts/relevancy.md";
12
13
  import precisionPrompt from "../prompts/precision.md";
@@ -202,6 +203,55 @@ export async function handleSectionUnit(env: Env, ctx: ExecutionContext, req: Re
202
203
  return json({ results, usage });
203
204
  }
204
205
 
206
+ /** Family community summaries (TODO.sota/06 item 2): one precomputed
207
+ * answer per publication family to "what is this family and what
208
+ * changed between editions" — generated from the registry rows and
209
+ * the successor graph (the family IS the corpus's natural community),
210
+ * stored in KV under famsum:<family>, served by editionNote beside
211
+ * the deterministic registry line. Admin-gated; idempotent (force
212
+ * regenerates); the quality-first lane pays once. */
213
+ export async function handleFamilySummary(env: Env, req: Request): Promise<Response> {
214
+ if (!env.ADMIN_TOKEN) return err(501, "admin_disabled", "ADMIN_TOKEN secret is not configured");
215
+ const auth = req.headers.get("authorization") ?? "";
216
+ if (auth !== `Bearer ${env.ADMIN_TOKEN}`) return err(401, "unauthorized", "Invalid admin token");
217
+ const body = await readJson(req);
218
+ const family = typeof body?.family === "string" ? body.family.trim().slice(0, 24) : "";
219
+ if (!/^[A-Z]+-\d+$/.test(family)) return err(400, "invalid_input", "family must look like R-60 (type-number)");
220
+ try {
221
+ const docs = await env.DB.prepare(
222
+ "SELECT docidentifier, edition, status, active, superseded_by, title FROM documents WHERE family = ?1 ORDER BY edition",
223
+ ).bind(family).all<any>();
224
+ const rows = docs.results ?? [];
225
+ if (rows.length < 2) return json({ family, ok: false, note: "fewer than two editions — no lineage to summarize" });
226
+ // the family's docs through the part_of edges (publisher-pure: no
227
+ // identifier literal — the family node is the join key)
228
+ const succ = await env.DB.prepare(
229
+ "SELECT e.src AS src, e.dst AS dst FROM graph_edges e WHERE e.kind = 'successor' AND (e.src IN (SELECT src FROM graph_edges WHERE kind = 'part_of' AND dst = ?2) OR e.dst IN (SELECT src FROM graph_edges WHERE kind = 'part_of' AND dst = ?2)) LIMIT 40",
230
+ ).bind(family, `family:${family}`).all<any>();
231
+ const key = `famsum:${family}`;
232
+ const cached = body?.force === true ? null : await env.CACHE.get(key);
233
+ if (cached) return json({ family, ok: true, cached: true, summary: cached });
234
+ const listing = rows.map((r: any) => `- ${r.docidentifier} — ${r.status}${r.active ? " (ACTIVE)" : ""}${r.title ? ` — ${String(r.title).slice(0, 120)}` : ""}`).join("\n");
235
+ const chain = ((succ.results ?? []) as any[]).map((r: any) => `${String(r.src).replace("doc:", "")} → superseded by → ${String(r.dst).replace("doc:", "")}`).join("\n");
236
+ const model = typeof env.ENRICH_MODEL === "string" && env.ENRICH_MODEL ? env.ENRICH_MODEL : MODELS.enrich;
237
+ const res: any = await env.AI.run(model, {
238
+ messages: [
239
+ { role: "system", content: familySummaryPrompt.trimEnd() },
240
+ { role: "user", content: `Family ${family}\n\nRegistry rows:\n${listing}\n\nSuccessor chain:\n${chain || "(no successor edges recorded)"}` },
241
+ ],
242
+ max_tokens: 1600,
243
+ reasoning_effort: "low",
244
+ });
245
+ const raw = typeof res?.response === "string" && res.response.trim() ? res.response : res?.choices?.[0]?.message?.content;
246
+ const summary = typeof raw === "string" ? raw.trim().replace(/^["']|["']$/g, "").slice(0, 1200) : "";
247
+ if (!summary) return json({ family, ok: false, error: "empty summary" });
248
+ await env.CACHE.put(key, summary);
249
+ return json({ family, ok: true, cached: false, summary, editions: rows.length, successors: (succ.results ?? []).length });
250
+ } catch (e: any) {
251
+ return err(502, "family_summary_failed", String(e?.message ?? e).slice(0, 200));
252
+ }
253
+ }
254
+
205
255
  /** Ops access to the Vectorize binding (get/upsert by id) for offline
206
256
  * passes like embedding smoothing (G-ETSI-4) — the binding is the
207
257
  * credential, admin-token gated exactly like /admin/enrich. */
@@ -55,7 +55,20 @@ export async function editionNote(env: Env, u: { doc_number?: string | null } |
55
55
  .all<{ docidentifier: string }>();
56
56
  const actives = (rows.results ?? []).map((r) => r.docidentifier);
57
57
  if (!actives.length) return undefined;
58
- return `Publication registry (authoritative): the ACTIVE edition(s) for this publication are ${actives.join(", ")}. Passages from other editions are superseded — use them only for historical comparison and say so.`;
58
+ // the family's community summary (TODO.sota/06): the precomputed
59
+ // what-changed-between-editions answer rides beside the
60
+ // deterministic registry line — provenance-carrying (generated from
61
+ // the registry and the successor graph, never free narration)
62
+ const famRow = await env.DB.prepare("SELECT family FROM documents WHERE docidentifier LIKE ?1 || '%:%' LIMIT 1")
63
+ .bind(`% ${u.doc_number}:%`)
64
+ .first<{ family: string }>()
65
+ .catch(() => null);
66
+ let summary = "";
67
+ if (famRow?.family && env.CACHE) {
68
+ summary = ((await env.CACHE.get(`famsum:${famRow.family}`).catch(() => "")) ?? "");
69
+ }
70
+ const registry = `Publication registry (authoritative): the ACTIVE edition(s) for this publication are ${actives.join(", ")}. Passages from other editions are superseded — use them only for historical comparison and say so.`;
71
+ return summary ? `${registry}\nFamily summary (precomputed from the registry and the successor graph): ${summary}` : registry;
59
72
  } catch {
60
73
  return undefined;
61
74
  }
@@ -22,7 +22,7 @@ export type { Env };
22
22
  import { json, err, corsHeaders, withCors, readJson, authenticate, type ApiKey } from "./lib/http";
23
23
 
24
24
  import { handleSearch } from "./search";
25
- import { handleEnrich, handleSectionUnit, handleCaption, handleVectors, handleJudge, handleCreateKey, handleListKeys, handleRevokeKey, handleKeyUsage } from "./admin";
25
+ import { handleEnrich, handleSectionUnit, handleCaption, handleVectors, handleJudge, handleCreateKey, handleListKeys, handleRevokeKey, handleKeyUsage, handleFamilySummary } from "./admin";
26
26
  import { handleResearch } from "./research";
27
27
  import { uploadAttachment, readAttachment, deleteAttachment, type AttachmentDeps } from "./attachments";
28
28
  import { cfBlobs } from "./ports/cloudflare/adapters";
@@ -618,6 +618,8 @@ const OPENAPI_HANDLERS: Record<OpenApiOperationId, RouteHandler> = {
618
618
  adminEnrich: (c) => handleEnrich(c.env, c.ctx, c.req),
619
619
  adminEnrichAlias: (c) => handleEnrich(c.env, c.ctx, c.req),
620
620
  adminSection: (c) => handleSectionUnit(c.env, c.ctx, c.req),
621
+ adminFamilySummary: (c) => handleFamilySummary(c.env, c.req),
622
+ adminFamilySummaryAlias: (c) => handleFamilySummary(c.env, c.req),
621
623
  adminSectionAlias: (c) => handleSectionUnit(c.env, c.ctx, c.req),
622
624
  adminVectors: (c) => handleVectors(c.env, c.req),
623
625
  adminCaption: (c) => handleCaption(c.env, c.req),
@@ -46,7 +46,9 @@ export type OpenApiOperationId =
46
46
  | "adminEnrich"
47
47
  | "adminEnrichAlias"
48
48
  | "adminSection"
49
+ | "adminFamilySummary"
49
50
  | "adminSectionAlias"
51
+ | "adminFamilySummaryAlias"
50
52
  | "adminVectors"
51
53
  | "adminCaption"
52
54
  | "adminJudge"
@@ -257,11 +259,21 @@ export const OPENAPI_SURFACE: readonly (Omit<OpenApiRoute, "operationId"> & { op
257
259
  "pattern": "/v1/admin/section",
258
260
  "operationId": "adminSection"
259
261
  },
262
+ {
263
+ "method": "POST",
264
+ "pattern": "/v1/admin/family-summary",
265
+ "operationId": "adminFamilySummary"
266
+ },
260
267
  {
261
268
  "method": "POST",
262
269
  "pattern": "/admin/section",
263
270
  "operationId": "adminSectionAlias"
264
271
  },
272
+ {
273
+ "method": "POST",
274
+ "pattern": "/admin/family-summary",
275
+ "operationId": "adminFamilySummaryAlias"
276
+ },
265
277
  {
266
278
  "method": "POST",
267
279
  "pattern": "/admin/vectors",