Skip to main content

Phase 2B (KB 线) — KB 策展资产 + golden 优先检索 + coverage report Implementation Plan

⚠️ STATUS UPDATE (2026-07-08, 执行中变更 — 覆盖下方原始 Task 5/6): 执行时 golden 检索方向被纠正:原计划(Task 5 KbCurationRetrievalService 关键词打分 + Task 6 方案 B 注入)假设「配置侧自造关键词匹配」——否决。关键词匹配不了语义(用户「能退吗」命中不了 golden 的「退款政策」),而 golden 的价值就是被可靠命中;静默失效的优先层比没有更糟。正确方向:真相源留配置底座,复用 Haystack 语义检索(golden 发布时同步进 Haystack 派生索引,命中置顶)。这是 M→L、跨 NestJS+rag 服务、碰生产检索安全路径的改动,已独立设计:docs/superpowers/specs/2026-07-08-p2.4-golden-answer-retrieval-design.md拆为独立 PR本 PR 实际交付(不含 P2.4 golden 检索):Task 1-4(KB 策展 schema)✅ · Task 7-8(前端编辑器)✅ · Task 9-10(coverage report,含 fail-closed authz 加固)✅ · Task 11(文档)✅。Task 5 keyword service 已删除(作废)。Task 6(golden 注入)不在本 PR——见 P2.4 设计,独立 PR 实现。下方 Task 5/6 原文保留仅作历史,勿照其实现。

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 在统一配置资产底座上接入两种 KB 策展 asset_type(kb_golden_answer + kb_domain_rule),让 /chat 检索时优先命中 published golden answers(否则新 schema 是死数据),并新增一个 KB coverage report(kb_retrieval_event × messages join)喂 AM 的策展 to-do。

Architecture: 复用底座「插件公式」——每个 KB 策展类型 = 一个 Zod schema 文件 + CONTENT_SCHEMAS 一行注册 + 类型化前端编辑器;版本/发布/审计/回滚/隔离/materialize 全部复用底座,零新增底座能力,零 controller 改动config-assets.controller.ts 已按 assetType 泛化,kb_golden_answer/kb_domain_rule 已在枚举 + 迁移 CHECK + DTO @IsIn)。唯一碰生产检索路径的是 golden 优先检索——在 conversations.service.ts 混合检索块之前插一层「先查本 (org,specialist) 的 published golden answers,命中则前置注入」,严格遵守 ADR-020 双过滤。KB 策展资产存在 specialist_config_versions.content(jsonb),与 KB 文档本体(kb_documents + Haystack)是两套完全隔离的存储——本计划不碰 ADR-008 文档管道。

Tech Stack: NestJS 11 + TypeORM(PG16) · Zod · Next.js 16 + React 19 · BullMQ · Jest(service spec 用 better-sqlite3 :memory:;schema spec 纯函数 safeParse


基线事实(实现时精确对齐,均已核实 @ 分支基点 abfb30ec = Phase 2A merge):

底座(config-assets)

  • CONTENT_SCHEMASapi/src/config-assets/schemas/index.ts:11-14)当前只有 { soul: SoulContent, skill: SkillContent }加类型 = 加 schemas/<type>.schema.ts + 一行注册。未注册的 type 在 validateContentconfig-assets.service.ts:578-596)抛 400 asset type "<x>" is not yet supportedcreateAsset(:110) 与 saveDraft(:201) 都过它。
  • kb_golden_answer / kb_domain_rule 已在枚举config-asset.entity.ts:10-17 CONFIG_ASSET_TYPES)、已在迁移 CHECKapi/migrations/1806700000000-CreateSpecialistConfigAssets.ts:31)、已在 DTO @IsIn(CONFIG_ASSET_TYPES)dto.ts:59-60)。缺的只是 schema 注册。
  • NFR5 helper 每 schema 文件本地各自定义(不从公共处 import):const opStr = (max) => z.string().max(max).superRefine(noInfraDisclosure)(范本 schemas/skill.schema.ts:8)。noInfraDisclosure../no-infra-disclosure import(no-infra-disclosure.ts:12,复用 common/scrub-filesystem-paths 的检测做校验,命中 ctx.addIssue 拒绝、不改写)。每个 operator 可见自由文本字段必须用 opStr;仅 regex 约束的标识符字段用裸 z.string().regex(...)
  • 预算常量已就位:config-asset-budgets.ts:10 KB_BUDGET_CHARS = 16_000(design §6 layer ⑦)——直接 import,不新增。顶层 .superRefineJSON.stringify(val).length > KB_BUDGET_CHARS 报错(照 skill.schema.ts:49-57)。
  • schema 文件导出约定:export const XxxContent = z.object({...}) + export type XxxContentT = z.infer<typeof XxxContent>skill.schema.ts:13,59)。
  • controller 不需改:create/getDetail/saveDraft/publish/rollback/discard 全按 assetType 泛化;contentRecord<string,unknown> 透传,校验完全交给 CONTENT_SCHEMAS
  • 测试 harness 中心化实体清单 api/test/test-db.config.ts withConfigAssetEntities 已含 SpecialistConfigAsset/SpecialistConfigVersion——本计划不新增实体(策展资产复用底座两表),无需改此文件(P2.6 例外见 Task 10:读既有 KbRetrievalEvent + Message)。
  • 测试风格:service/检索 spec 直接 new DataSource(better-sqlite3 :memory:synchronize:true)+ mock 依赖,不用 Test.createTestingModule;schema spec 纯函数 safeParse

KB 检索管道(P2.4 golden 优先要接入的地方)

  • 两个检索引擎,务必区分KbRetrievalService.retrieve()api/src/kb/kb-retrieval.service.ts:40,关键词、K1/K2 哨兵、agent outage fallback)vs HaystackRetrievalService.search(orgId, query, options?)api/src/haystack/haystack-retrieval.service.ts:110,BM25+vector+RRF+rerank)。draft 主路径用混合引擎
  • 哨兵HP_GLOBAL_ORG_UUID = "9363f7d3-2652-5f05-86d9-d44c9f486ddf"api/src/kb/kb.constants.ts:3)。golden answers 是 config-asset(org_instance,双 scope),不涉及 K2 哨兵——它们只存本 (org, specialist),无跨 org fan-in。
  • 检索注入点ConversationsService.runAgentPipeline(起点 conversations.service.ts:2581),KB 检索+注入块 :2993-3059亲验 @ abfb30ec)。关键行:
    • :3006 const ctx = await this.haystackRetrieval.search(retrievalOrgId, dto.content ?? "", { limit: Number(process.env.HAYSTACK_TOP_K ?? 5), source: "kb", failOpen: true, specialistId: conv.specialistId })
    • :3012 retrievedKbContext = ctx.results;retrievedKbContextContextResult[],不是字符串
    • :3019 kbContextProvided = ctx.paused !== true && ctx.degraded !== true(#3166 注释:real 0-results 也算 provided;只有 outage(paused/degraded) 才 false)
    • :3020-3027 if (retrievedKbContext.length > 0) history.unshift({ role: "user", content: this.formatKbContext(retrievedKbContext) })(#3190:user role 而非 system)
    • 整块用 feature-flag kb_retrieval_enabled(默认 ON)+ HAYSTACK_ENABLED !== "false" 双 gate(:2994-3003),且整个 search 已包在 try/catch(:3040-3044 warn "proceeding without")。
  • formatKbContext(results: ContextResult[]) 是单一注入 choke point(亲验 :4074-4102results.map(c => [${c.citation.filename ?? c.document_id}] ${c.content}).join("\n\n")stripControlChars截断到 MAX_KB_CONTEXT_CHARS(#3190 P2-1,:4085-4089)→ <kb_context> 包裹 + wrapUntrustedInput(body, {tag:"kb_context"})(#3190 P1-2 prompt-injection 防护,:4096-4101)。任何 KB 内容都必须走这个包裹,不能绕过。
  • ContextResult 形状api/src/haystack/haystack.types.ts:65):{ source:"kb"|..., content, document_id, dataset_id, similarity, citation:{filename?,...}, metadata, ... }formatKbContext 只读 citation.filename/document_id/content
  • MAX_KB_CONTEXT_CHARS = 16_000api/src/common/sanitize-llm.ts:43不在 conversations 目录,import 之)。
  • golden 优先的正确设计(方案 B,最 DRY):把 published golden answers 映射成 ContextResultsource:"kb"content = 格式化的 Q/A/adaptation/sources,similarity:1citation.filename:"golden:<slug>"),unshiftretrievedKbContext 数组最前,再走既有 formatKbContext。golden 自然排最前(design §6 ⑦「golden 优先 → hybrid」;ADR-033 L1)+ 复用截断 + 复用 <kb_context> 包裹,零新注入路径、零绕过防护。不在 formatKbContext 加参数。
  • ConfigAssetsModule 已被 conversations.module.ts import(published-soul persona, Task 11——见 conversations/CLAUDE.md)。所以 Task 6 的 module 接线已就位,只需 ConfigAssetsModule exports 新增的 KbCurationRetrievalService(Task 5),conversations 即可直接注入,无需改 conversations.module.ts 的 imports

KB coverage report(P2.6)

  • 表已存在KbRetrievalEventapi/src/common/entities.ts:3154-3208@Entity("kb_retrieval_event"))。字段:id, org_id, specialist_id, conversation_id, message_id(nullable), engine(text), query(text), top_k(int), result_count(int), latency_ms(int?), retrieved(jsonb), created_at
  • 写入点已存在conversations.service.ts:3403-3433(draft 生成后入队),engine 硬编码 "hybrid":3415)。持久化 KbRetrievalEventService.persist()api/src/kb/kb-retrieval-event.service.ts:16.insert().orIgnore() 幂等)。
  • join messageskb_retrieval_event.message_id → messages.id。coverage report = 按 (org, specialist) 聚合 result_count = 0(或低命中)的 retrieval events,作为「KB 缺口」to-do。
  • KbRetrievalEvent 实体注册在 config/typeorm-data-source.config.ts:40-41, 248-249(autoLoadEntities 关闭,已列出)。

预存在失败核对规则(给 implementer): 任何「这个测试本来就挂」的判断必须对分支基点 abfb30ec 验证(干净 checkout 跑或 git stash),不是 HEAD~1。config-assets 模块在基点全绿;该模块内任何红都是本次引入。整仓 npm test(api)本地有 ~21 个预存在失败(见记忆 verification-env-gotchas),只关心你触碰的 suite。

无本机绝对路径入库(NFR5 + no-machine-paths-in-repo-docs): 所有代码/文档/测试 fixture 中的路径必须 repo-relative;提交前 grep /Users/|~/ 自检。


File Structure

P2.3 KB 策展资产(后端 schema):

  • Create api/src/config-assets/schemas/kb-golden-answer.schema.tsKbGoldenAnswerContent Zod schema + 共享 Applicability
  • Create api/src/config-assets/schemas/kb-golden-answer.schema.spec.ts
  • Create api/src/config-assets/schemas/kb-domain-rule.schema.tsKbDomainRuleContent(复用 Applicability
  • Create api/src/config-assets/schemas/kb-domain-rule.schema.spec.ts
  • Create api/src/config-assets/schemas/applicability.schema.ts — 抽出 Applicability(两 schema 共用,DRY)
  • Modify api/src/config-assets/schemas/index.ts — 注册 kb_golden_answer + kb_domain_rule

P2.4 golden 优先检索(后端):

  • Create api/src/config-assets/kb-curation-retrieval.service.tsKbCurationRetrievalService.retrievePublishedGoldenAnswers(orgId, specialistId, query)(读底座 published golden answer 版本,关键词打分,双 scope,published-only)
  • Create api/src/config-assets/kb-curation-retrieval.service.spec.ts
  • Modify api/src/config-assets/config-assets.module.ts — provider + export KbCurationRetrievalService
  • Create api/src/conversations/golden-answer-context.ts — 纯函数 goldenAnswersToContextResults(hits): ContextResult[]
  • Create api/test/golden-answer-priority.spec.ts
  • Modify api/src/conversations/conversations.service.ts(KB 检索块 :2993-3059 + constructor @Optional() inject)— golden→ContextResult unshift 到 hybrid 前,复用 formatKbContext
  • 不改 api/src/conversations/conversations.module.ts——已 import ConfigAssetsModule(published-soul persona),只要 Task 5 让该 module export KbCurationRetrievalService 即可注入

P2.3 KB 策展资产(前端编辑器):

  • Create frontend/src/app/ops/config-assets/KbGoldenAnswerEditor.tsx
  • Create frontend/src/app/ops/config-assets/KbDomainRuleEditor.tsx
  • Create frontend/src/app/ops/config-assets/__tests__/kb-golden-answer-editor.test.tsx
  • Create frontend/src/app/ops/config-assets/__tests__/kb-domain-rule-editor.test.tsx
  • Modify frontend/src/app/ops/config-assets/[id]/page.tsxassetType==="kb_golden_answer"|"kb_domain_rule" 渲染对应 Editor
  • Modify frontend/src/app/ops/config-assets/page.tsx — 列表页支持新类型(若有类型过滤/建资产入口)

P2.6 KB coverage report(后端 + 前端):

  • Create api/src/kb/kb-coverage.service.tsKbCoverageService.getCoverageGaps(orgId, specialistId, opts)(聚合 kb_retrieval_event 零命中/低命中)
  • Create api/src/kb/kb-coverage.service.spec.ts
  • Create api/src/kb/kb-coverage.controller.tsGET /kb/coverage(AM/superadmin,双 scope)
  • Modify api/src/kb/kb.module.ts — provide KbCoverageService + controller
  • Modify frontend/src/lib/api.tsgetKbCoverageGaps client + 类型
  • Create frontend/src/app/ops/config-assets/KbCoveragePanel.tsx(或挂在既有 KB Ops 页)

收尾:

  • Modify api/src/config-assets/CLAUDE.md — 新增 KB 策展类型段 + golden 优先检索段
  • Modify api/src/kb/CLAUDE.md — coverage report 段 + golden-vs-文档边界重申
  • Modify docs/superpowers/specs/2026-07-07-unified-config-asset-model.md — §3 kb schema 从初稿改「as-built」标注(如有偏差)

Task 1: Applicability 共享 schema

Files:

  • Create: api/src/config-assets/schemas/applicability.schema.ts

  • Test: api/src/config-assets/schemas/applicability.schema.spec.ts

  • Step 1: Write the failing test

api/src/config-assets/schemas/applicability.schema.spec.ts:

import { Applicability } from "./applicability.schema";

describe("Applicability", () => {
it("accepts a fully-specified applicability", () => {
const r = Applicability.safeParse({
effectiveFrom: "2026-01-01",
effectiveTo: "2026-12-31",
audience: ["retail", "vip"],
jurisdiction: ["US", "GB"],
});
expect(r.success).toBe(true);
});

it("defaults to an empty object", () => {
const r = Applicability.safeParse({});
expect(r.success).toBe(true);
});

it("rejects a malformed date", () => {
expect(Applicability.safeParse({ effectiveFrom: "01/01/2026" }).success).toBe(false);
});

it("rejects an over-long jurisdiction code", () => {
expect(Applicability.safeParse({ jurisdiction: ["UNITED-STATES"] }).success).toBe(false);
});

it("rejects a filesystem-path infra disclosure in an audience tag (NFR5)", () => {
expect(Applicability.safeParse({ audience: ["/opt/data/secret"] }).success).toBe(false);
});
});
  • Step 2: Run test to verify it fails

Run (from .worktrees/specialist-value-output-phase2b-kb/api): npx jest src/config-assets/schemas/applicability.schema.spec.ts Expected: FAIL — Cannot find module './applicability.schema'

  • Step 3: Write minimal implementation

api/src/config-assets/schemas/applicability.schema.ts:

import { z } from "zod";
import { noInfraDisclosure } from "../no-infra-disclosure";

// NFR5: operator-visible free-text fields run the infra-disclosure validator.
// Same helper shape as skill.schema.ts:8 — do NOT use bare z.string() for an
// operator-visible field.
const opStr = (max: number) => z.string().max(max).superRefine(noInfraDisclosure);

// Shared by kb_golden_answer + kb_domain_rule (design §3 Applicability).
// effective dates ISO YYYY-MM-DD; jurisdiction ISO 3166 (≤8 chars).
export const Applicability = z
.object({
effectiveFrom: z.string().date().optional(),
effectiveTo: z.string().date().optional(),
audience: z.array(opStr(64)).max(16).optional(),
jurisdiction: z.array(opStr(8)).max(16).optional(),
})
.default({});

export type ApplicabilityT = z.infer<typeof Applicability>;
  • Step 4: Run test to verify it passes

Run: npx jest src/config-assets/schemas/applicability.schema.spec.ts Expected: PASS (5 tests)

  • Step 5: Commit
git add api/src/config-assets/schemas/applicability.schema.ts api/src/config-assets/schemas/applicability.schema.spec.ts
git commit -m "feat(config-assets): shared Applicability schema for KB curation assets"

Task 2: kb_golden_answer content schema

Files:

  • Create: api/src/config-assets/schemas/kb-golden-answer.schema.ts

  • Test: api/src/config-assets/schemas/kb-golden-answer.schema.spec.ts

  • Step 1: Write the failing test

api/src/config-assets/schemas/kb-golden-answer.schema.spec.ts:

import { KbGoldenAnswerContent } from "./kb-golden-answer.schema";
import { KB_BUDGET_CHARS } from "../config-asset-budgets";

const valid = () => ({
question: "What is the refund window for retail orders?",
answer: "Retail orders can be refunded within 30 days of delivery, no questions asked.",
sources: ["https://acme.example.com/policy/refunds"],
applicability: { audience: ["retail"], jurisdiction: ["US"] },
adaptationNotes: "May soften the tone but must keep the 30-day figure exact.",
});

describe("KbGoldenAnswerContent", () => {
it("accepts a well-formed golden answer", () => {
expect(KbGoldenAnswerContent.safeParse(valid()).success).toBe(true);
});

it("defaults applicability to {} and sources/notes optional", () => {
const r = KbGoldenAnswerContent.safeParse({
question: "Q?",
answer: "A.",
});
expect(r.success).toBe(true);
if (r.success) expect(r.data.applicability).toEqual({});
});

it("requires a non-empty question and answer", () => {
expect(KbGoldenAnswerContent.safeParse({ ...valid(), question: "" }).success).toBe(false);
expect(KbGoldenAnswerContent.safeParse({ ...valid(), answer: "" }).success).toBe(false);
});

it("rejects a non-URL source", () => {
expect(KbGoldenAnswerContent.safeParse({ ...valid(), sources: ["not a url"] }).success).toBe(false);
});

it("rejects a filesystem-path infra disclosure in the answer (NFR5)", () => {
const bad = { ...valid(), answer: "See /opt/data/policy.md for the rule." };
expect(KbGoldenAnswerContent.safeParse(bad).success).toBe(false);
});

it("rejects content over the KB budget", () => {
const bad = { ...valid(), answer: "x".repeat(KB_BUDGET_CHARS + 1) };
expect(KbGoldenAnswerContent.safeParse(bad).success).toBe(false);
});
});
  • Step 2: Run test to verify it fails

Run: npx jest src/config-assets/schemas/kb-golden-answer.schema.spec.ts Expected: FAIL — Cannot find module './kb-golden-answer.schema'

  • Step 3: Write minimal implementation

api/src/config-assets/schemas/kb-golden-answer.schema.ts:

import { z } from "zod";
import { noInfraDisclosure } from "../no-infra-disclosure";
import { Applicability } from "./applicability.schema";
import { KB_BUDGET_CHARS } from "../config-asset-budgets";

// NFR5 helper — see skill.schema.ts:8.
const opStr = (max: number) => z.string().max(max).superRefine(noInfraDisclosure);

// R3.1/R3.2: verbatim-with-adaptation 母本 (ADR-033 L1). Retrieved first,
// before hybrid KB (P2.4). Sources are external URLs (evidence), not infra.
export const KbGoldenAnswerContent = z
.object({
question: opStr(1_000).min(1),
answer: opStr(6_000).min(1),
sources: z.array(z.string().url()).max(8).default([]),
applicability: Applicability,
adaptationNotes: opStr(1_000).optional(),
})
.superRefine((val, ctx) => {
// §6 layer-⑦ budget: reject at save; runtime never truncates (NFR3).
if (JSON.stringify(val).length > KB_BUDGET_CHARS) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Golden answer exceeds the ${KB_BUDGET_CHARS}-char KB budget — trim it.`,
});
}
});

export type KbGoldenAnswerContentT = z.infer<typeof KbGoldenAnswerContent>;
  • Step 4: Run test to verify it passes

Run: npx jest src/config-assets/schemas/kb-golden-answer.schema.spec.ts Expected: PASS (6 tests)

  • Step 5: Commit
git add api/src/config-assets/schemas/kb-golden-answer.schema.ts api/src/config-assets/schemas/kb-golden-answer.schema.spec.ts
git commit -m "feat(config-assets): kb_golden_answer content schema"

Task 3: kb_domain_rule content schema

Files:

  • Create: api/src/config-assets/schemas/kb-domain-rule.schema.ts

  • Test: api/src/config-assets/schemas/kb-domain-rule.schema.spec.ts

  • Step 1: Write the failing test

api/src/config-assets/schemas/kb-domain-rule.schema.spec.ts:

import { KbDomainRuleContent } from "./kb-domain-rule.schema";
import { KB_BUDGET_CHARS } from "../config-asset-budgets";

const valid = () => ({
statement: "Customers under 18 are not eligible for margin trading accounts.",
category: "eligibility" as const,
applicability: { jurisdiction: ["US"] },
references: ["Internal policy POL-114 §3"],
});

describe("KbDomainRuleContent", () => {
it("accepts a well-formed domain rule", () => {
expect(KbDomainRuleContent.safeParse(valid()).success).toBe(true);
});

it("requires a non-empty statement", () => {
expect(KbDomainRuleContent.safeParse({ ...valid(), statement: "" }).success).toBe(false);
});

it("rejects an unknown category", () => {
expect(KbDomainRuleContent.safeParse({ ...valid(), category: "misc" }).success).toBe(false);
});

it("accepts each valid category", () => {
for (const category of ["policy", "eligibility", "process", "fact"]) {
expect(KbDomainRuleContent.safeParse({ ...valid(), category }).success).toBe(true);
}
});

it("defaults references to [] and applicability to {}", () => {
const r = KbDomainRuleContent.safeParse({ statement: "S.", category: "fact" });
expect(r.success).toBe(true);
if (r.success) {
expect(r.data.references).toEqual([]);
expect(r.data.applicability).toEqual({});
}
});

it("rejects a filesystem-path infra disclosure in the statement (NFR5)", () => {
const bad = { ...valid(), statement: "Follow the file at /etc/rules.conf." };
expect(KbDomainRuleContent.safeParse(bad).success).toBe(false);
});

it("rejects content over the KB budget", () => {
const bad = { ...valid(), statement: "x".repeat(KB_BUDGET_CHARS + 1) };
// statement max is 2000, so the field-level max fires first — assert failure either way.
expect(KbDomainRuleContent.safeParse(bad).success).toBe(false);
});
});
  • Step 2: Run test to verify it fails

Run: npx jest src/config-assets/schemas/kb-domain-rule.schema.spec.ts Expected: FAIL — Cannot find module './kb-domain-rule.schema'

  • Step 3: Write minimal implementation

api/src/config-assets/schemas/kb-domain-rule.schema.ts:

import { z } from "zod";
import { noInfraDisclosure } from "../no-infra-disclosure";
import { Applicability } from "./applicability.schema";
import { KB_BUDGET_CHARS } from "../config-asset-budgets";

// NFR5 helper — see skill.schema.ts:8.
const opStr = (max: number) => z.string().max(max).superRefine(noInfraDisclosure);

// R3.2: structured domain rule (policy / eligibility / process / fact). A
// hard constraint the assembler injects; NOT a verbatim answer body.
export const KbDomainRuleContent = z
.object({
statement: opStr(2_000).min(1),
category: z.enum(["policy", "eligibility", "process", "fact"]),
applicability: Applicability,
references: z.array(opStr(300)).max(8).default([]),
})
.superRefine((val, ctx) => {
if (JSON.stringify(val).length > KB_BUDGET_CHARS) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Domain rule exceeds the ${KB_BUDGET_CHARS}-char KB budget — trim it.`,
});
}
});

export type KbDomainRuleContentT = z.infer<typeof KbDomainRuleContent>;
  • Step 4: Run test to verify it passes

Run: npx jest src/config-assets/schemas/kb-domain-rule.schema.spec.ts Expected: PASS (7 tests)

  • Step 5: Commit
git add api/src/config-assets/schemas/kb-domain-rule.schema.ts api/src/config-assets/schemas/kb-domain-rule.schema.spec.ts
git commit -m "feat(config-assets): kb_domain_rule content schema"

Task 4: 注册两个 KB 策展类型到 CONTENT_SCHEMAS

Files:

  • Modify: api/src/config-assets/schemas/index.ts

  • Test: api/src/config-assets/schemas/index.spec.ts (create if absent)

  • Step 1: Write the failing test

api/src/config-assets/schemas/index.spec.ts:

import { CONTENT_SCHEMAS } from "./index";

describe("CONTENT_SCHEMAS registry", () => {
it("registers all four shipped asset types", () => {
expect(Object.keys(CONTENT_SCHEMAS).sort()).toEqual(
["kb_domain_rule", "kb_golden_answer", "skill", "soul"].sort(),
);
});

it("kb_golden_answer validates a minimal golden answer", () => {
const schema = CONTENT_SCHEMAS.kb_golden_answer!;
expect(schema.safeParse({ question: "Q?", answer: "A." }).success).toBe(true);
});

it("kb_domain_rule validates a minimal domain rule", () => {
const schema = CONTENT_SCHEMAS.kb_domain_rule!;
expect(schema.safeParse({ statement: "S.", category: "fact" }).success).toBe(true);
});
});
  • Step 2: Run test to verify it fails

Run: npx jest src/config-assets/schemas/index.spec.ts Expected: FAIL — registry only has soul, skill

  • Step 3: Write minimal implementation

api/src/config-assets/schemas/index.ts — add imports + two registry entries:

import { ZodType } from "zod";
import { ConfigAssetType } from "../config-asset.entity";
import { SoulContent } from "./soul.schema";
import { SkillContent } from "./skill.schema";
import { KbGoldenAnswerContent } from "./kb-golden-answer.schema";
import { KbDomainRuleContent } from "./kb-domain-rule.schema";

/**
* asset_type → content schema registry. Shipped: soul (Phase 1) + skill
* (Phase 2A) + kb_golden_answer / kb_domain_rule (Phase 2B). Adding a type =
* add its schema file + one entry here (design §1 non-goal: no generic-platform
* extension points beyond the five known types). tool_binding is the only
* remaining unimplemented type.
*/
export const CONTENT_SCHEMAS: Partial<Record<ConfigAssetType, ZodType>> = {
soul: SoulContent,
skill: SkillContent,
kb_golden_answer: KbGoldenAnswerContent,
kb_domain_rule: KbDomainRuleContent,
};
  • Step 4: Run test + full config-assets suite

Run: npx jest src/config-assets/schemas/index.spec.ts && npx jest src/config-assets Expected: PASS. The whole config-assets suite must stay green (baseline was green @ abfb30ec).

  • Step 5: Commit
git add api/src/config-assets/schemas/index.ts api/src/config-assets/schemas/index.spec.ts
git commit -m "feat(config-assets): register kb_golden_answer + kb_domain_rule in CONTENT_SCHEMAS"

Task 5: KbCurationRetrievalService — published golden answer 检索(纯服务,无 conversations 依赖)

Files:

  • Create: api/src/config-assets/kb-curation-retrieval.service.ts
  • Test: api/src/config-assets/kb-curation-retrieval.service.spec.ts
  • Modify: api/src/config-assets/config-assets.module.ts

设计说明: golden answers 是 config-asset(org_instance),存 specialist_config_versions.content。检索 = 取本 (org, specialist) 全部 publishedkb_golden_answer 资产的 published version content,对 question 做关键词打分(照 KbRetrievalService.scoreDocument 的朴素 token overlap,检索规模小——单 specialist 的 golden answers 数量级是几十条,不需要向量),返回 topK 命中。ADR-020 双过滤:org_id AND specialist_id 同时约束。 只读 published,不读 draft。

  • Step 1: Write the failing test

api/src/config-assets/kb-curation-retrieval.service.spec.ts:

import { DataSource } from "typeorm";
import { SpecialistConfigAsset } from "./config-asset.entity";
import { SpecialistConfigVersion } from "./config-asset-version.entity";
import { KbCurationRetrievalService } from "./kb-curation-retrieval.service";

const ORG = "11111111-1111-1111-1111-111111111111";
const OTHER_ORG = "22222222-2222-2222-2222-222222222222";
const SPEC = "33333333-3333-3333-3333-333333333333";
const OTHER_SPEC = "44444444-4444-4444-4444-444444444444";

async function makeDs() {
const ds = new DataSource({
type: "better-sqlite3",
database: ":memory:",
entities: [SpecialistConfigAsset, SpecialistConfigVersion],
synchronize: true,
});
await ds.initialize();
return ds;
}

// Seed one published golden answer asset. Returns the asset id.
async function seedGolden(
ds: DataSource,
opts: { orgId: string; specialistId: string; slug: string; question: string; answer: string },
) {
const assetRepo = ds.getRepository(SpecialistConfigAsset);
const verRepo = ds.getRepository(SpecialistConfigVersion);
const asset = await assetRepo.save(
assetRepo.create({
assetType: "kb_golden_answer",
scope: "org_instance",
orgId: opts.orgId,
specialistId: opts.specialistId,
slug: opts.slug,
displayName: opts.question.slice(0, 40),
source: "manual",
createdBy: "seed",
}),
);
const ver = await verRepo.save(
verRepo.create({
assetId: asset.id,
versionNo: 1,
content: { question: opts.question, answer: opts.answer, applicability: {} },
contentHash: "hash-" + opts.slug,
publishStatus: "published",
authorId: "seed",
}),
);
asset.publishedVersionId = ver.id;
await assetRepo.save(asset);
return asset.id;
}

describe("KbCurationRetrievalService.retrievePublishedGoldenAnswers", () => {
let ds: DataSource;
let svc: KbCurationRetrievalService;

beforeEach(async () => {
ds = await makeDs();
svc = new KbCurationRetrievalService(
ds.getRepository(SpecialistConfigAsset),
ds.getRepository(SpecialistConfigVersion),
);
});
afterEach(async () => ds.destroy());

it("returns a published golden answer matching the query", async () => {
await seedGolden(ds, {
orgId: ORG, specialistId: SPEC, slug: "refund-window",
question: "What is the refund window for retail orders?",
answer: "30 days.",
});
const hits = await svc.retrievePublishedGoldenAnswers(ORG, SPEC, "refund window retail");
expect(hits).toHaveLength(1);
expect(hits[0].answer).toBe("30 days.");
});

it("does NOT return another org's golden answer (ADR-020 org filter)", async () => {
await seedGolden(ds, {
orgId: OTHER_ORG, specialistId: SPEC, slug: "refund-window",
question: "What is the refund window?", answer: "leak",
});
const hits = await svc.retrievePublishedGoldenAnswers(ORG, SPEC, "refund window");
expect(hits).toHaveLength(0);
});

it("does NOT return another specialist's golden answer (ADR-020 specialist filter)", async () => {
await seedGolden(ds, {
orgId: ORG, specialistId: OTHER_SPEC, slug: "refund-window",
question: "What is the refund window?", answer: "leak",
});
const hits = await svc.retrievePublishedGoldenAnswers(ORG, SPEC, "refund window");
expect(hits).toHaveLength(0);
});

it("does NOT return a draft-only golden answer (published-only)", async () => {
const assetRepo = ds.getRepository(SpecialistConfigAsset);
const verRepo = ds.getRepository(SpecialistConfigVersion);
const asset = await assetRepo.save(
assetRepo.create({
assetType: "kb_golden_answer", scope: "org_instance", orgId: ORG,
specialistId: SPEC, slug: "draft-only", displayName: "d", source: "manual", createdBy: "s",
}),
);
const ver = await verRepo.save(
verRepo.create({
assetId: asset.id, versionNo: 1,
content: { question: "refund window", answer: "draft", applicability: {} },
contentHash: "h", publishStatus: "draft", authorId: "s",
}),
);
asset.draftVersionId = ver.id;
await assetRepo.save(asset);
const hits = await svc.retrievePublishedGoldenAnswers(ORG, SPEC, "refund window");
expect(hits).toHaveLength(0);
});

it("ignores non-golden-answer asset types", async () => {
const assetRepo = ds.getRepository(SpecialistConfigAsset);
const verRepo = ds.getRepository(SpecialistConfigVersion);
const asset = await assetRepo.save(
assetRepo.create({
assetType: "soul", scope: "org_instance", orgId: ORG, specialistId: SPEC,
slug: "soul", displayName: "s", source: "manual", createdBy: "s",
}),
);
const ver = await verRepo.save(
verRepo.create({
assetId: asset.id, versionNo: 1, content: { refund: "window" },
contentHash: "h", publishStatus: "published", authorId: "s",
}),
);
asset.publishedVersionId = ver.id;
await assetRepo.save(asset);
const hits = await svc.retrievePublishedGoldenAnswers(ORG, SPEC, "refund window");
expect(hits).toHaveLength(0);
});

it("returns [] on empty query without throwing", async () => {
expect(await svc.retrievePublishedGoldenAnswers(ORG, SPEC, "")).toEqual([]);
});
});
  • Step 2: Run test to verify it fails

Run: npx jest src/config-assets/kb-curation-retrieval.service.spec.ts Expected: FAIL — Cannot find module './kb-curation-retrieval.service'

  • Step 3: Write minimal implementation

api/src/config-assets/kb-curation-retrieval.service.ts:

import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { SpecialistConfigAsset } from "./config-asset.entity";
import { SpecialistConfigVersion } from "./config-asset-version.entity";
import { KbGoldenAnswerContentT } from "./schemas/kb-golden-answer.schema";

export interface GoldenAnswerHit {
assetId: string;
question: string;
answer: string;
adaptationNotes?: string;
sources: string[];
score: number;
}

// Naive token-overlap scoring — golden answers per (org, specialist) are a
// small set (tens), so keyword scoring is sufficient; no vector index needed.
function tokenize(s: string): string[] {
return s.toLowerCase().match(/[a-z0-9]+/g) ?? [];
}

@Injectable()
export class KbCurationRetrievalService {
constructor(
@InjectRepository(SpecialistConfigAsset)
private readonly assetRepo: Repository<SpecialistConfigAsset>,
@InjectRepository(SpecialistConfigVersion)
private readonly versionRepo: Repository<SpecialistConfigVersion>,
) {}

/**
* Retrieve published kb_golden_answer assets for this (org, specialist) whose
* question overlaps the query. ADR-020 row-1: filters org_id AND specialist_id.
* Published-only — drafts are never surfaced to /chat.
*/
async retrievePublishedGoldenAnswers(
orgId: string,
specialistId: string,
query: string,
topK = 3,
): Promise<GoldenAnswerHit[]> {
const queryTokens = tokenize(query);
if (queryTokens.length === 0) return [];

// ADR-020 dual filter + published-only (published_version_id NOT NULL).
const assets = await this.assetRepo
.createQueryBuilder("a")
.where("a.asset_type = :t", { t: "kb_golden_answer" })
.andWhere("a.scope = :s", { s: "org_instance" })
.andWhere("a.org_id = :orgId", { orgId })
.andWhere("a.specialist_id = :specialistId", { specialistId })
.andWhere("a.archived_at IS NULL")
.andWhere("a.published_version_id IS NOT NULL")
.getMany();

if (assets.length === 0) return [];

const versionIds = assets.map((a) => a.publishedVersionId!).filter(Boolean);
const versions = await this.versionRepo
.createQueryBuilder("v")
.where("v.id IN (:...ids)", { ids: versionIds })
.getMany();
const versionById = new Map(versions.map((v) => [v.id, v]));

const queryTokenSet = new Set(queryTokens);
const hits: GoldenAnswerHit[] = [];
for (const asset of assets) {
const version = versionById.get(asset.publishedVersionId!);
if (!version) continue;
const content = version.content as unknown as KbGoldenAnswerContentT;
if (!content?.question || !content?.answer) continue;
const qTokens = tokenize(content.question);
const overlap = qTokens.filter((t) => queryTokenSet.has(t)).length;
if (overlap === 0) continue;
hits.push({
assetId: asset.id,
question: content.question,
answer: content.answer,
adaptationNotes: content.adaptationNotes,
sources: content.sources ?? [],
score: overlap / qTokens.length,
});
}

hits.sort((a, b) => b.score - a.score);
return hits.slice(0, topK);
}
}
  • Step 4: Wire into module + run test

api/src/config-assets/config-assets.module.ts — add KbCurationRetrievalService to providers and exports (so ConversationsModule can inject it). The TypeOrmModule.forFeature([SpecialistConfigAsset, SpecialistConfigVersion]) is already imported by this module (verify — if not, add it to the existing forFeature array).

Run: npx jest src/config-assets/kb-curation-retrieval.service.spec.ts Expected: PASS (6 tests)

  • Step 5: Commit
git add api/src/config-assets/kb-curation-retrieval.service.ts api/src/config-assets/kb-curation-retrieval.service.spec.ts api/src/config-assets/config-assets.module.ts
git commit -m "feat(config-assets): published golden-answer retrieval service (ADR-020 dual scope, published-only)"

Task 6: golden 优先注入 /chat 检索路径(P2.4 核心 — 唯一改生产检索)

Files:

  • Create: api/src/conversations/golden-answer-context.ts — 纯函数 goldenAnswersToContextResults(hits): ContextResult[]
  • Test: api/test/golden-answer-priority.spec.ts(隔离测纯函数,避免全 ConversationsService 依赖图)
  • Modify: api/src/conversations/conversations.service.ts (KB retrieval block :2993-3059 + constructor inject)
  • Modify: api/src/config-assets/config-assets.module.ts(Task 5 已加 export;此处确认 conversations 可注入——conversations.module.ts 已 import ConfigAssetsModule,无需改

设计说明(方案 B — 复用既有 formatKbContext choke point,Chesterton's Fence + DRY):

  • 不新造注入路径、不给 formatKbContext 加参数、不绕过 <kb_context> 包裹。 现状 :3012 retrievedKbContext = ctx.resultsContextResult[])→ :3020-3027 if (len>0) history.unshift(formatKbContext(retrievedKbContext))formatKbContext 内含 stripControlChars + MAX_KB_CONTEXT_CHARS 截断 + <kb_context> + wrapUntrustedInput(#3190 防护,:4085-4101)。

  • 做法:把 published golden answers 映射成 ContextResultsource:"kb"similarity:1citation.filename:"golden:<assetId>"content = 格式化 Q/A/adaptation/sources),unshiftretrievedKbContext 数组最前(golden 优先于 hybrid,design §6 ⑦)。既有 formatKbContext + 截断 + 包裹全复用——golden 排最前意味着超预算时 hybrid 段先被截(正是 NFR3 golden-priority 意图)。

  • failOpen:golden 检索失败绝不 break /chat——独立 try/catch,失败降级 [] + warn(对齐 getPublishedSoulMd 的 exception-level fail-open)。放在既有 haystack try/catch 之外/之前,使 golden 即使在 haystack gate 关闭/失败时仍能注入。

  • kbContextProvided:golden 命中也算「提供了 KB」——kbContextProvided = goldenHits.length > 0 || (ctx.paused !== true && ctx.degraded !== true)(保留 #3166 语义,golden OR 在前)。注意 ctx 只在 haystack 分支内有定义;实现时把 golden 检索提到 gate 判断之前,goldenHits 在外层作用域,注入判断用 retrievedKbContext.length > 0(golden unshift 后天然 >0)。

  • 注入去重风险(Inversion):golden answer 的内容可能与某 hybrid chunk 高度重合。MVP 不做跨源去重(可接受少量重复;golden 在前,agent 优先采信);若 final review 认为重复严重,作为 follow-up 记录,不在本 task 加去重逻辑(YAGNI)。

  • Step 1: Write the failing test(纯函数:golden hit → ContextResult[])

api/test/golden-answer-priority.spec.ts:

import { goldenAnswersToContextResults } from "../src/conversations/golden-answer-context";
import type { GoldenAnswerHit } from "../src/config-assets/kb-curation-retrieval.service";

const hit: GoldenAnswerHit = {
assetId: "a1",
question: "What is the refund window?",
answer: "30 days.",
sources: ["https://x.example/p"],
score: 1,
};

describe("goldenAnswersToContextResults", () => {
it("maps a golden hit into a kb-source ContextResult", () => {
const [r] = goldenAnswersToContextResults([hit]);
expect(r.source).toBe("kb");
expect(r.content).toContain("30 days.");
expect(r.content).toContain("What is the refund window?");
expect(r.similarity).toBe(1);
});

it("tags the citation filename so the agent can see it is a golden answer", () => {
const [r] = goldenAnswersToContextResults([hit]);
expect(r.citation.filename).toContain("golden");
});

it("includes adaptationNotes when present (allowed rewrite latitude)", () => {
const [r] = goldenAnswersToContextResults([{ ...hit, adaptationNotes: "Keep 30-day figure exact." }]);
expect(r.content).toContain("Keep 30-day figure exact.");
});

it("includes sources when present", () => {
const [r] = goldenAnswersToContextResults([hit]);
expect(r.content).toContain("https://x.example/p");
});

it("returns [] for no hits", () => {
expect(goldenAnswersToContextResults([])).toEqual([]);
});

it("preserves order (already sorted by the retrieval service)", () => {
const results = goldenAnswersToContextResults([
{ ...hit, assetId: "a1", answer: "first" },
{ ...hit, assetId: "a2", answer: "second" },
]);
expect(results[0].content).toContain("first");
expect(results[1].content).toContain("second");
});
});
  • Step 2: Run test to verify it fails

Run (from .worktrees/specialist-value-output-phase2b-kb/api): npx jest test/golden-answer-priority.spec.ts Expected: FAIL — Cannot find module '../src/conversations/golden-answer-context'

  • Step 3: Write the pure mapping function

api/src/conversations/golden-answer-context.ts:

import type { ContextResult } from "../haystack/haystack.types";
import type { GoldenAnswerHit } from "../config-assets/kb-curation-retrieval.service";

/**
* Map published golden answers into ContextResult[] so they flow through the
* SAME formatKbContext choke point as hybrid results (design §6 layer ⑦
* "golden answers 优先 → hybrid"; ADR-033 L1). Caller unshifts these ahead of
* the hybrid results, so golden lands first in the <kb_context> block and is
* truncated last (NFR3 golden-priority). No separate injection path — reuses
* the #3190 <kb_context> wrapper + MAX_KB_CONTEXT_CHARS truncation verbatim.
*/
export function goldenAnswersToContextResults(hits: GoldenAnswerHit[]): ContextResult[] {
return hits.map((g) => {
const notes = g.adaptationNotes ? `\nAdaptation allowed: ${g.adaptationNotes}` : "";
const src = g.sources.length ? `\nSources: ${g.sources.join(", ")}` : "";
return {
source: "kb",
content: `Curated golden answer (use verbatim-with-adaptation)\nQ: ${g.question}\nA: ${g.answer}${notes}${src}`,
document_id: `golden:${g.assetId}`,
dataset_id: "golden_answers",
similarity: g.score,
citation: { filename: `golden:${g.assetId}` },
metadata: { curated: true, assetId: g.assetId },
};
});
}
  • Step 4: Run test to verify it passes

Run: npx jest test/golden-answer-priority.spec.ts Expected: PASS (6 tests)

  • Step 5: Wire into conversations.service.ts

In api/src/conversations/conversations.service.ts:

  1. Imports: import { goldenAnswersToContextResults } from "./golden-answer-context"; and import { KbCurationRetrievalService } from "../config-assets/kb-curation-retrieval.service";.
  2. Constructor: inject @Optional() private readonly kbCuration?: KbCurationRetrievalService (@Optional() per this module's sqlite-test wiring convention — see conversations/CLAUDE.md "@Optional() repo wiring"; production always provides it via the already-imported ConfigAssetsModule).
  3. In the retrieval block, at the START of the if (retrievalOrgId && this.haystackRetrieval && ...) body (or just before it if golden should run even when haystack is gated off — prefer before the gate so golden works independently of HAYSTACK_ENABLED), add fail-open golden retrieval:
// P2.4: published golden answers take priority over hybrid KB (design §6 ⑦).
// Independent + fail-open — a golden error (or haystack being gated off) must
// never break the /chat turn, and golden must still inject when hybrid is off.
let goldenHits: GoldenAnswerHit[] = [];
if (retrievalOrgId && this.kbCuration) {
try {
goldenHits = await this.kbCuration.retrievePublishedGoldenAnswers(
retrievalOrgId,
conv.specialistId,
dto.content ?? "",
);
} catch (err) {
this.logger.warn(`golden-answer retrieval failed — proceeding without: ${(err as Error).message}`);
}
}

(Add import { GoldenAnswerHit } from "../config-assets/kb-curation-retrieval.service";.)

  1. Where hybrid results are assembled (:3012 retrievedKbContext = ctx.results;), unshift golden ahead:
retrievedKbContext = [...goldenAnswersToContextResults(goldenHits), ...ctx.results];

And update the provided flag to count golden:

kbContextProvided =
goldenHits.length > 0 || (ctx.paused !== true && ctx.degraded !== true);

The existing if (retrievedKbContext.length > 0) history.unshift({ role: "user", content: this.formatKbContext(retrievedKbContext) }) then covers golden automatically — no new injection code.

If golden must also inject when the haystack gate is OFF (recommended): move the history.unshift(formatKbContext(...)) so that a golden-only retrievedKbContext (populated outside the haystack try) still gets injected. Read the block first; keep the single formatKbContext choke point. If this complicates the gate structure, it is acceptable for MVP to inject golden only inside the haystack-enabled branch (note the limitation in the PR) — but the fail-open (golden error never breaks the turn) is non-negotiable.

  • Step 6: Run affected suites + build

Run:

npx jest test/golden-answer-priority.spec.ts src/config-assets
npm run build

Expected: PASS + clean TS compile. If a conversations retrieval spec exists, run it — the golden layer is additive + fail-open, so no-golden behaviour must be byte-identical to the baseline (verify against abfb30ec).

  • Step 7: Commit
git add api/src/conversations/golden-answer-context.ts api/test/golden-answer-priority.spec.ts api/src/conversations/conversations.service.ts
git commit -m "feat(conversations): golden answers take priority over hybrid KB in /chat retrieval (P2.4, fail-open, reuses formatKbContext)"

Task 7: 前端 — KbGoldenAnswerEditor

Files:

  • Create: frontend/src/app/ops/config-assets/KbGoldenAnswerEditor.tsx
  • Test: frontend/src/app/ops/config-assets/__tests__/kb-golden-answer-editor.test.tsx
  • Modify: frontend/src/app/ops/config-assets/[id]/page.tsx

设计说明:SkillEditor.tsx 的形状——受控/半受控表单,props { initialContent, saving, issues, onSave }(见 [id]/page.tsx:236-244 的 SkillEditor 调用)。字段:question(textarea)、answer(textarea)、sources(每行一个 URL)、applicability(effectiveFrom/To DateField、audience/jurisdiction 逗号分隔)、adaptationNotes(textarea)。onSave(content) 提交 KbGoldenAnswerContent 形状的对象。UI 规范:DateField(禁 <input type=date>)、Button loading、toast 走 @/components/shared/Toast、图标 lucide。

  • Step 1: Write the failing test

frontend/src/app/ops/config-assets/__tests__/kb-golden-answer-editor.test.tsx:

import { render, screen, fireEvent } from "@testing-library/react";
import { KbGoldenAnswerEditor } from "../KbGoldenAnswerEditor";

describe("KbGoldenAnswerEditor", () => {
it("renders question and answer from initialContent", () => {
render(
<KbGoldenAnswerEditor
initialContent={{ question: "Refund?", answer: "30 days." }}
saving={false}
issues={null}
onSave={() => {}}
/>,
);
expect(screen.getByDisplayValue("Refund?")).toBeInTheDocument();
expect(screen.getByDisplayValue("30 days.")).toBeInTheDocument();
});

it("calls onSave with the golden-answer content shape", () => {
const onSave = jest.fn();
render(
<KbGoldenAnswerEditor
initialContent={{ question: "Q?", answer: "A." }}
saving={false}
issues={null}
onSave={onSave}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /save draft/i }));
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({ question: "Q?", answer: "A." }),
);
});

it("parses newline-separated sources into an array", () => {
const onSave = jest.fn();
render(
<KbGoldenAnswerEditor
initialContent={{ question: "Q?", answer: "A.", sources: ["https://a.example"] }}
saving={false}
issues={null}
onSave={onSave}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /save draft/i }));
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({ sources: ["https://a.example"] }),
);
});
});
  • Step 2: Run test to verify it fails

Run (from .worktrees/.../frontend): npx jest src/app/ops/config-assets/__tests__/kb-golden-answer-editor.test.tsx Expected: FAIL — Cannot find module '../KbGoldenAnswerEditor'

  • Step 3: Write the editor

Implement KbGoldenAnswerEditor.tsx mirroring SkillEditor.tsx structure (read it first for the exact prop types + issues rendering + Button usage). Fields: question/answer textareas, sources (newline-split → filtered non-empty), applicability (effectiveFrom/To via DateField from @/components/ui/date-field, audience/jurisdiction comma-split), adaptationNotes textarea. onSave assembles the content object; empty optional fields omitted.

  • Step 4: Run test to verify it passes

Run: npx jest src/app/ops/config-assets/__tests__/kb-golden-answer-editor.test.tsx Expected: PASS (3 tests)

  • Step 5: Wire into [id]/page.tsx

Add branch after the skill branch ([id]/page.tsx:236-244):

{detail.assetType === "kb_golden_answer" && (
<KbGoldenAnswerEditor
key={`${detail.draftVersionId ?? "none"}:${detail.publishedVersionId ?? "none"}`}
initialContent={detail.draftContent ?? detail.publishedContent}
saving={saving}
issues={issues}
onSave={(content) => void handleSave(content)}
/>
)}

And extend the "not available yet" guard (:245) to exclude kb_golden_answer. Import KbGoldenAnswerEditor.

  • Step 6: Commit
git add frontend/src/app/ops/config-assets/KbGoldenAnswerEditor.tsx frontend/src/app/ops/config-assets/__tests__/kb-golden-answer-editor.test.tsx frontend/src/app/ops/config-assets/[id]/page.tsx
git commit -m "feat(ops): KbGoldenAnswerEditor + wire into config-asset detail"

Task 8: 前端 — KbDomainRuleEditor

Files:

  • Create: frontend/src/app/ops/config-assets/KbDomainRuleEditor.tsx
  • Test: frontend/src/app/ops/config-assets/__tests__/kb-domain-rule-editor.test.tsx
  • Modify: frontend/src/app/ops/config-assets/[id]/page.tsx

设计说明: 同 Task 7 形状。字段:statement(textarea)、category(select: policy/eligibility/process/fact)、applicability(同上)、references(每行一个)。

  • Step 1: Write the failing test

frontend/src/app/ops/config-assets/__tests__/kb-domain-rule-editor.test.tsx:

import { render, screen, fireEvent } from "@testing-library/react";
import { KbDomainRuleEditor } from "../KbDomainRuleEditor";

describe("KbDomainRuleEditor", () => {
it("renders statement and category from initialContent", () => {
render(
<KbDomainRuleEditor
initialContent={{ statement: "Under 18 ineligible.", category: "eligibility" }}
saving={false}
issues={null}
onSave={() => {}}
/>,
);
expect(screen.getByDisplayValue("Under 18 ineligible.")).toBeInTheDocument();
expect(screen.getByDisplayValue("eligibility")).toBeInTheDocument();
});

it("calls onSave with statement + category", () => {
const onSave = jest.fn();
render(
<KbDomainRuleEditor
initialContent={{ statement: "S.", category: "fact" }}
saving={false}
issues={null}
onSave={onSave}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /save draft/i }));
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({ statement: "S.", category: "fact" }),
);
});

it("defaults category to policy when initialContent is empty", () => {
const onSave = jest.fn();
render(
<KbDomainRuleEditor initialContent={undefined} saving={false} issues={null} onSave={onSave} />,
);
fireEvent.change(screen.getByLabelText(/statement/i), { target: { value: "X." } });
fireEvent.click(screen.getByRole("button", { name: /save draft/i }));
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({ category: "policy" }),
);
});
});
  • Step 2: Run test to verify it fails

Run: npx jest src/app/ops/config-assets/__tests__/kb-domain-rule-editor.test.tsx Expected: FAIL — module not found

  • Step 3: Write the editor

Implement KbDomainRuleEditor.tsx mirroring the golden-answer editor. category is a <select> (4 enum values). statement/references/applicability as described.

  • Step 4: Run test to verify it passes

Run: npx jest src/app/ops/config-assets/__tests__/kb-domain-rule-editor.test.tsx Expected: PASS (3 tests)

  • Step 5: Wire into [id]/page.tsx + commit

Add the kb_domain_rule branch (mirror Task 7 Step 5), extend the guard, import the editor.

git add frontend/src/app/ops/config-assets/KbDomainRuleEditor.tsx frontend/src/app/ops/config-assets/__tests__/kb-domain-rule-editor.test.tsx frontend/src/app/ops/config-assets/[id]/page.tsx
git commit -m "feat(ops): KbDomainRuleEditor + wire into config-asset detail"

Task 9: KB coverage report — 后端 service

Files:

  • Create: api/src/kb/kb-coverage.service.ts
  • Test: api/src/kb/kb-coverage.service.spec.ts
  • Modify: api/src/kb/kb.module.ts

设计说明: coverage report = 从 kb_retrieval_event 聚合本 (org, specialist) 的「KB 缺口」——result_count = 0 的 retrieval events(agent 检索了但一无所获,是策展 to-do 的信号)。返回按 query 分组的缺口计数 + 最近发生时间,供 AM 决定补哪些 golden answers。ADR-020 双过滤:org_id AND specialist_id join messages 可选(拿上下文),MVP 先聚合 event 本身。

  • Step 1: Write the failing test

api/src/kb/kb-coverage.service.spec.ts:

import { DataSource } from "typeorm";
import { KbRetrievalEvent } from "../common/entities";
import { KbCoverageService } from "./kb-coverage.service";

const ORG = "11111111-1111-1111-1111-111111111111";
const OTHER_ORG = "22222222-2222-2222-2222-222222222222";
const SPEC = "33333333-3333-3333-3333-333333333333";

async function makeDs() {
const ds = new DataSource({
type: "better-sqlite3", database: ":memory:",
entities: [KbRetrievalEvent], synchronize: true,
});
await ds.initialize();
return ds;
}

async function seedEvent(
ds: DataSource,
o: { orgId: string; specialistId: string; query: string; resultCount: number },
) {
const repo = ds.getRepository(KbRetrievalEvent);
await repo.save(
repo.create({
orgId: o.orgId, specialistId: o.specialistId, conversationId: "c",
messageId: null, engine: "hybrid", query: o.query, topK: 5,
resultCount: o.resultCount, retrieved: [],
}),
);
}

describe("KbCoverageService.getCoverageGaps", () => {
let ds: DataSource;
let svc: KbCoverageService;

beforeEach(async () => {
ds = await makeDs();
svc = new KbCoverageService(ds.getRepository(KbRetrievalEvent));
});
afterEach(async () => ds.destroy());

it("returns zero-result queries as gaps, grouped with a count", async () => {
await seedEvent(ds, { orgId: ORG, specialistId: SPEC, query: "refund window", resultCount: 0 });
await seedEvent(ds, { orgId: ORG, specialistId: SPEC, query: "refund window", resultCount: 0 });
const gaps = await svc.getCoverageGaps(ORG, SPEC);
expect(gaps).toHaveLength(1);
expect(gaps[0].query).toBe("refund window");
expect(gaps[0].missCount).toBe(2);
});

it("excludes queries that had results", async () => {
await seedEvent(ds, { orgId: ORG, specialistId: SPEC, query: "hit query", resultCount: 3 });
const gaps = await svc.getCoverageGaps(ORG, SPEC);
expect(gaps).toHaveLength(0);
});

it("does NOT leak another org's gaps (ADR-020)", async () => {
await seedEvent(ds, { orgId: OTHER_ORG, specialistId: SPEC, query: "leak", resultCount: 0 });
const gaps = await svc.getCoverageGaps(ORG, SPEC);
expect(gaps).toHaveLength(0);
});
});
  • Step 2: Run test to verify it fails

Run: npx jest src/kb/kb-coverage.service.spec.ts Expected: FAIL — module not found

  • Step 3: Write minimal implementation

api/src/kb/kb-coverage.service.ts:

import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { KbRetrievalEvent } from "../common/entities";

export interface KbCoverageGap {
query: string;
missCount: number;
lastSeenAt: Date | null;
}

@Injectable()
export class KbCoverageService {
constructor(
@InjectRepository(KbRetrievalEvent)
private readonly eventRepo: Repository<KbRetrievalEvent>,
) {}

/**
* Aggregate zero-result KB retrievals for this (org, specialist) into a
* curation to-do list (design R3.4). ADR-020 row-1: filters org_id AND
* specialist_id. Zero-result = the agent searched but the KB had nothing —
* the AM should add a golden answer / domain rule for it.
*/
async getCoverageGaps(
orgId: string,
specialistId: string,
opts: { limit?: number } = {},
): Promise<KbCoverageGap[]> {
const rows = await this.eventRepo
.createQueryBuilder("e")
.select("e.query", "query")
.addSelect("COUNT(*)", "miss_count")
.addSelect("MAX(e.created_at)", "last_seen_at")
.where("e.org_id = :orgId", { orgId })
.andWhere("e.specialist_id = :specialistId", { specialistId })
.andWhere("e.result_count = 0")
.groupBy("e.query")
.orderBy("miss_count", "DESC")
.limit(opts.limit ?? 50)
.getRawMany<{ query: string; miss_count: string; last_seen_at: string | null }>();

return rows.map((r) => ({
query: r.query,
missCount: Number(r.miss_count),
lastSeenAt: r.last_seen_at ? new Date(r.last_seen_at) : null,
}));
}
}
  • Step 4: Wire into module + run test

api/src/kb/kb.module.ts — ensure TypeOrmModule.forFeature([..., KbRetrievalEvent]) includes KbRetrievalEvent (it may already, since KbRetrievalEventService lives here); add KbCoverageService to providers/exports.

Run: npx jest src/kb/kb-coverage.service.spec.ts Expected: PASS (3 tests)

  • Step 5: Commit
git add api/src/kb/kb-coverage.service.ts api/src/kb/kb-coverage.service.spec.ts api/src/kb/kb.module.ts
git commit -m "feat(kb): coverage-gap aggregation from zero-result retrieval events (ADR-020 dual scope)"

Task 10: KB coverage report — controller + 前端

Files:

  • Create: api/src/kb/kb-coverage.controller.ts
  • Test: api/test/kb-coverage.e2e-spec.ts(若 e2e harness 太重,退化为 controller 单测,mock service)
  • Modify: api/src/kb/kb.module.ts (register controller)
  • Modify: frontend/src/lib/api.ts (client fn + type)
  • Create: frontend/src/app/ops/config-assets/KbCoveragePanel.tsx

设计说明: GET /kb/coverage?orgId=&specialistId=,Guard 照既有 config-assets controller 的组合(JwtAuthGuard + MfaEnforcementGuard + PlatformRolesGuard,AM+superadmin,双 scope 强制、缺 scope 400、foreign org 403/unknown 404)。读 KbCoverageService.getCoverageGaps。前端一个只读面板列出缺口 query + missCount,带「新建 golden answer」链接指向 config-assets 新建流。

  • Step 1: Write the failing test(controller 单测,mock service)

api/src/kb/kb-coverage.controller.spec.ts:

import { KbCoverageController } from "./kb-coverage.controller";
import { KbCoverageService } from "./kb-coverage.service";

describe("KbCoverageController", () => {
it("returns gaps for the requested scope", async () => {
const svc = {
getCoverageGaps: jest.fn().mockResolvedValue([
{ query: "refund window", missCount: 3, lastSeenAt: new Date("2026-07-01") },
]),
} as unknown as KbCoverageService;
const ctrl = new KbCoverageController(svc);
const res = await ctrl.getCoverage("org-1", "spec-1");
expect(svc.getCoverageGaps).toHaveBeenCalledWith("org-1", "spec-1");
expect(res.gaps[0].query).toBe("refund window");
});

it("rejects a missing specialistId (ADR-020 dual scope)", async () => {
const svc = { getCoverageGaps: jest.fn() } as unknown as KbCoverageService;
const ctrl = new KbCoverageController(svc);
await expect(ctrl.getCoverage("org-1", "")).rejects.toThrow();
expect(svc.getCoverageGaps).not.toHaveBeenCalled();
});
});
  • Step 2: Run test to verify it fails

Run: npx jest src/kb/kb-coverage.controller.spec.ts Expected: FAIL — module not found

  • Step 3: Write the controller

api/src/kb/kb-coverage.controller.ts — mirror the guard stack + scope-validation shape of config-assets.controller.ts (read it for the exact guard imports + BadRequestException on missing scope). Endpoint @Get("coverage") reading @Query("orgId") / @Query("specialistId"); throw BadRequestException if specialistId missing; return { gaps }.

  • Step 4: Run test + register controller in kb.module.ts

Run: npx jest src/kb/kb-coverage.controller.spec.ts Expected: PASS (2 tests)

  • Step 5: Frontend client + panel

frontend/src/lib/api.ts — add getKbCoverageGaps(params: { orgId: string; specialistId: string }) + KbCoverageGap type (mirror the existing admin*ConfigAsset client fns). Create KbCoveragePanel.tsx (read-only table via TanStackTable or the shared table; loading skeleton + Try-again per frontend conventions). Wire it where AMs manage a specialist's KB (a reasonable home is the config-assets list page filtered to a specialist, or a new tab — pick the lightest integration and note it in the PR).

  • Step 6: Run frontend build + commit
cd frontend && npx tsc --noEmit && cd ..
git add api/src/kb/kb-coverage.controller.ts api/src/kb/kb-coverage.controller.spec.ts api/src/kb/kb.module.ts frontend/src/lib/api.ts frontend/src/app/ops/config-assets/KbCoveragePanel.tsx
git commit -m "feat(kb): coverage-report endpoint + Ops panel (curation to-do)"

Task 11: 文档收尾

Files:

  • Modify: api/src/config-assets/CLAUDE.md

  • Modify: api/src/kb/CLAUDE.md

  • Modify: docs/superpowers/specs/2026-07-07-unified-config-asset-model.md

  • Step 1: Update api/src/config-assets/CLAUDE.md

  • 顶部「Shipped」行加 kb_golden_answer + kb_domain_rule

  • 新增段「KB curation assets (P2.3)」:两 schema 字段、Applicability 共用、KB_BUDGET_CHARS layer ⑦、与 KB 文档本体(kb_documents/Haystack)的存储隔离边界重申。

  • 新增段「Golden-answer priority retrieval (P2.4)」:KbCurationRetrievalService published-only + 双 scope、注入点 conversations.service.ts、fail-open、golden 前置于 hybrid 的 merge、mergeGoldenAndHybridKbContext 纯函数。

  • Pitfalls 加:golden 检索必须 fail-open(对齐 getPublishedSoulMd);golden 是 config-asset 不涉 K2 哨兵。

  • Step 2: Update api/src/kb/CLAUDE.md

  • 新增段「Coverage report (P2.6)」:KbCoverageServicekb_retrieval_event 聚合零命中、双 scope、GET /kb/coverage。

  • 重申边界:golden answers / domain rules 是 config-asset(specialist_config_versions.content),不是 kb_documents;检索管道现有两引擎 + 新增的 golden 前置层的关系。

  • Step 3: Update design spec §3 KB schema 为 as-built

2026-07-07-unified-config-asset-model.md §3 的 kb-golden-answer / kb-domain-rule schema 块后加 AS-BUILT 注记(照 skill schema 的 as-built 注记格式),记录实现偏差:opStr 每文件本地定义、Applicability 抽成共享文件、加 .default([])、顶层 KB_BUDGET_CHARS superRefine。

  • Step 4: grep 自检无本机路径 + commit
grep -rn "/Users/\|/home/\|~/" docs/superpowers/plans/2026-07-08-phase2b-kb-curation-implementation.md api/src/config-assets/CLAUDE.md api/src/kb/CLAUDE.md || echo "clean"
git add api/src/config-assets/CLAUDE.md api/src/kb/CLAUDE.md docs/superpowers/specs/2026-07-07-unified-config-asset-model.md
git commit -m "docs(config-assets,kb): KB curation assets + golden priority + coverage report contracts"

分支级 final review(全部 task 完成后)

  • 跨任务一致性:GoldenAnswerHit 类型在 service / merge / conversations 三处签名一致;KbCoverageGap 在 service / controller / 前端一致。
  • 隔离:所有新查询双 scope(org_id AND specialist_id)——golden 检索、coverage 聚合。grep 新增查询确认无 org-only。
  • 检索路径:golden 层是 additive + fail-open,无 golden 时 /chat 行为 byte-identical(对分支基点跑 conversations retrieval 相关 spec 确认)。
  • scope:只碰 config-assets / kb / conversations 检索块 / ops 前端;未越界改 ADR-008 文档管道、未碰 tool 线。
  • 文档漂移:三个 CLAUDE.md/spec 与实现一致。
  • 预存在失败:触碰的 suite 对基点 abfb30ec 验证;整仓 ~21 预存在失败与本 PR 无关。
  • 无本机绝对路径入库(全 diff grep /Users/|~/)。

Self-Review 记录(计划作者自查)

Spec coverage(master plan §四 Phase 2B KB 线):

  • P2.3 KB 策展资产(kb_golden_answer + kb_domain_rule + K2 curation UI)→ Task 1–4(schema)+ Task 7–8(编辑器 UI)。✅
  • P2.4 golden answers 优先检索(检索时 join published versions)→ Task 5(检索 service)+ Task 6(注入 /chat)。✅
  • P2.6 KB coverage report(kb_retrieval_event × messages)→ Task 9(聚合 service)+ Task 10(endpoint + 前端)。✅
  • (P2.5 tool_binding 是 Tool 线,另一 PR,不在本计划。)

Placeholder scan: 无 TBD/TODO;每个 code step 含完整代码或明确指向要 mirror 的既有文件 + 精确行号。前端编辑器 Step 3 指向「mirror SkillEditor.tsx」而非贴全 UI 代码——这是刻意的(编辑器是既有模式的机械复制,贴全反而引入不一致风险;implementer 必须先读 SkillEditor 对齐 props/issues 渲染)。

Type consistency: GoldenAnswerHit(Task 5 定义于 kb-curation-retrieval.service.ts)在 Task 6 的 goldenAnswersToContextResults + conversations 注入一致引用;ContextResult(Task 6 复用 haystack/haystack.types.ts:65);KbGoldenAnswerContentT/KbDomainRuleContentT 从各自 schema 文件导出;KbCoverageGap(Task 9)在 Task 10 controller/前端一致。Task 6 采用方案 B(golden→ContextResult unshift,复用 formatKbContext choke point),全程无 mergeGoldenAndHybridKbContext 这类新拼接函数——不给 formatKbContext 加参数、不绕过 <kb_context> 包裹。

已知弹性点(implementer 需按现读代码定夺): Task 6 Step 5 第 4 点——golden 是否要在 haystack gate OFF 时也注入。计划推荐「gate 外注入」但允许 MVP 退化为「仅 gate 内注入 + PR 注明限制」;无论哪种,golden 检索的 fail-open(错误不 break turn)+ 复用单一 formatKbContext choke point 是硬约束。以现读 :2993-3059 块结构为准(Map ≠ Territory)。