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_SCHEMAS(api/src/config-assets/schemas/index.ts:11-14)当前只有{ soul: SoulContent, skill: SkillContent }。加类型 = 加schemas/<type>.schema.ts+ 一行注册。未注册的 type 在validateContent(config-assets.service.ts:578-596)抛 400asset type "<x>" is not yet supported。createAsset(:110) 与saveDraft(:201) 都过它。kb_golden_answer/kb_domain_rule已在枚举(config-asset.entity.ts:10-17CONFIG_ASSET_TYPES)、已在迁移 CHECK(api/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-disclosureimport(no-infra-disclosure.ts:12,复用common/scrub-filesystem-paths的检测做校验,命中ctx.addIssue拒绝、不改写)。每个 operator 可见自由文本字段必须用opStr;仅 regex 约束的标识符字段用裸z.string().regex(...)。 - 预算常量已就位:
config-asset-budgets.ts:10KB_BUDGET_CHARS = 16_000(design §6 layer ⑦)——直接 import,不新增。顶层.superRefine对JSON.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泛化;content是Record<string,unknown>透传,校验完全交给CONTENT_SCHEMAS。 - 测试 harness 中心化实体清单
api/test/test-db.config.tswithConfigAssetEntities已含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)vsHaystackRetrievalService.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)。关键行::3006const ctx = await this.haystackRetrieval.search(retrievalOrgId, dto.content ?? "", { limit: Number(process.env.HAYSTACK_TOP_K ?? 5), source: "kb", failOpen: true, specialistId: conv.specialistId }):3012retrievedKbContext = ctx.results;(retrievedKbContext是ContextResult[],不是字符串):3019kbContextProvided = ctx.paused !== true && ctx.degraded !== true(#3166 注释:real 0-results 也算 provided;只有 outage(paused/degraded) 才 false):3020-3027if (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-3044warn "proceeding without")。
formatKbContext(results: ContextResult[])是单一注入 choke point(亲验:4074-4102):results.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_000(api/src/common/sanitize-llm.ts:43,不在 conversations 目录,import 之)。- golden 优先的正确设计(方案 B,最 DRY):把 published golden answers 映射成
ContextResult(source:"kb",content= 格式化的 Q/A/adaptation/sources,similarity:1,citation.filename:"golden:<slug>"),unshift到retrievedKbContext数组最前,再走既有formatKbContext。golden 自然排最前(design §6 ⑦「golden 优先 → hybrid」;ADR-033 L1)+ 复用截断 + 复用<kb_context>包裹,零新注入路径、零绕过防护。不在formatKbContext加参数。 ConfigAssetsModule已被conversations.module.tsimport(published-soul persona, Task 11——见 conversations/CLAUDE.md)。所以 Task 6 的 module 接线已就位,只需ConfigAssetsModuleexports新增的KbCurationRetrievalService(Task 5),conversations 即可直接注入,无需改 conversations.module.ts 的 imports。
KB coverage report(P2.6)
- 表已存在:
KbRetrievalEvent(api/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 messages:
kb_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.ts—KbGoldenAnswerContentZod schema + 共享Applicability - Create
api/src/config-assets/schemas/kb-golden-answer.schema.spec.ts - Create
api/src/config-assets/schemas/kb-domain-rule.schema.ts—KbDomainRuleContent(复用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.ts—KbCurationRetrievalService.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 + exportKbCurationRetrievalService - 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——已 importConfigAssetsModule(published-soul persona),只要 Task 5 让该 module exportKbCurationRetrievalService即可注入
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.tsx—assetType==="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.ts—KbCoverageService.getCoverageGaps(orgId, specialistId, opts)(聚合kb_retrieval_event零命中/低命中) - Create
api/src/kb/kb-coverage.service.spec.ts - Create
api/src/kb/kb-coverage.controller.ts—GET /kb/coverage(AM/superadmin,双 scope) - Modify
api/src/kb/kb.module.ts— provideKbCoverageService+ controller - Modify
frontend/src/lib/api.ts—getKbCoverageGapsclient + 类型 - 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) 全部 published 的 kb_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>包裹。 现状:3012retrievedKbContext = ctx.results(ContextResult[])→:3020-3027if (len>0) history.unshift(formatKbContext(retrievedKbContext))。formatKbContext内含stripControlChars+MAX_KB_CONTEXT_CHARS截断 +<kb_context>+wrapUntrustedInput(#3190 防护,:4085-4101)。 -
做法:把 published golden answers 映射成
ContextResult(source:"kb",similarity:1,citation.filename:"golden:<assetId>",content= 格式化 Q/A/adaptation/sources),unshift到retrievedKbContext数组最前(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:
- Imports:
import { goldenAnswersToContextResults } from "./golden-answer-context";andimport { KbCurationRetrievalService } from "../config-assets/kb-curation-retrieval.service";. - 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-importedConfigAssetsModule). - 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 ofHAYSTACK_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";.)
- Where hybrid results are assembled (
:3012retrievedKbContext = 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)」:
KbCurationRetrievalServicepublished-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)」:
KbCoverageService从kb_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)。