Phase 0 — 度量基线 + 设计定案 + 止血 · 实施计划
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: 落地 specialist-value-output master plan 的 Phase 0:edit-signal 指标基线、OQ-205 止血、promptTemplateKey 主路径修复、minimal 装配 trace、统一配置资产模型设计文档、PRD 更正。
Architecture: 全部为 expand-only 改动(新 flag / 新索引 / 新增响应字段 / metadata 增键),不改任何既有行为契约;processor 止血经 feature flag 控制;trace 走"agent 响应 audit 块扩展 + draft metadata 持久化"的最小路径,不建新表。
Tech Stack: NestJS 11 + TypeORM(jsonb) · Jest · FastAPI + pytest · feature-flags(org scope) · TypeORM migration(expand-contract 规则见根 CLAUDE.md)
上游文档: master plan v2 · PRD docs/specs/prd-specialist-value-output.md
基线事实(已核查,2026-07-07 @ 714516a6):
- 两条 Expert 发送路径都已捕获 editSignal:
respond()(expert-queue.service.ts:1156)、releaseDraft()(:1813),连同correctionCategory写入Message.metadata(jsonb,entities.ts:526)。 respond()的 metadata 存queueItemId(:1251)但不存releasedDraftId;releaseDraft()存releasedDraftId(:1846)。- 主派发点
conversations.service.ts:2583-2591只 selectsystemPrompt,.chat()调用(:3092-3110)不传specialistPromptTemplateKey→ wire 上prompt_template_key落到req.rolefallback(agent.client.ts:384)。 correction-refinement.processor.ts无 flag 控制,_processInContext(:78)扫processedAt IS NULL后ingestionService.upload(:174)直接写 KB。- agent 已回传
audit.prompt_version,agent.client.ts:529解析为promptVersion(接口字段:127),但 conversations.service 不持久化(grep 0 命中)。 - persona-override 启发式在
agent/prompts_loader.py:250-279(is_full_template),当前不上报是否触发。
Task 1: P0.5 — 主派发路径传 specialistPromptTemplateKey(BUG 修复)
Files:
-
Modify:
api/src/conversations/conversations.service.ts:2583-2591(select + 变量)、:3092-3110(chat 调用) -
Modify: 同文件 regenerate-draft 的第二个
.chat({调用点(用 grep 定位,同样修法) -
Test:
api/src/conversations/specialist-template-key.spec.ts(新建) -
Step 1: 定位全部
.chat({调用点
Run: grep -n "orgAgentClient.chat({\|agentClient.chat({" api/src/conversations/conversations.service.ts
Expected: 2 个生产调用点(主派发 ~3092、regenerateDraft 路径)。记录两处行号。
- Step 2: 写失败测试
新建 api/src/conversations/specialist-template-key.spec.ts。测试策略:不实例化完整 ConversationsService(依赖过重),复用仓库已有的 runAgentPipeline 测试基建 —— grep -ln "runAgentPipeline\|agentClient" api/test/*.spec.ts api/src/conversations/*.spec.ts 找到现有 harness(api/test/auto-reply-mode.spec.ts 已 mock AgentClient 驱动 pipeline),在该 harness 中新增断言块:
// 加入现有 runAgentPipeline harness(specialist fixture 需带 promptTemplateKey)
it("passes specialist promptTemplateKey to the agent (#ADR-034 wire fix)", async () => {
// arrange: specialistRepo.findOne mock 返回
// { systemPrompt: "You are Amy.", promptTemplateKey: "kyc_ops" }
// act: 触发 runAgentPipeline(沿用该 spec 现有的触发方式)
// assert:
const chatArgs = mockAgentClient.chat.mock.calls[0][0];
expect(chatArgs.specialistPromptTemplateKey).toBe("kyc_ops");
});
it("omits promptTemplateKey when specialist has none (falls back to role)", async () => {
// specialistRepo mock 返回 { systemPrompt: "...", promptTemplateKey: null }
const chatArgs = mockAgentClient.chat.mock.calls[0][0];
expect(chatArgs.specialistPromptTemplateKey ?? null).toBeNull();
});
注意:现有 harness 里 specialistRepo.findOne 的 mock 如果按 select 参数裁剪返回,需同步允许 promptTemplateKey。
- Step 3: 跑测试确认失败
Run: cd api && npx jest specialist-template-key --no-coverage
Expected: FAIL — chatArgs.specialistPromptTemplateKey 为 undefined。
- Step 4: 实现
conversations.service.ts:2583-2591 改为:
// Fetch Specialist systemPrompt + template key if the conversation has a specialistId
let specialistSystemPrompt: string | null = null;
let specialistPromptTemplateKey: string | null = null;
if (conv.specialistId && this.specialistRepo) {
const specialist = await this.specialistRepo.findOne({
where: { id: conv.specialistId },
select: ["systemPrompt", "promptTemplateKey"],
});
specialistSystemPrompt = specialist?.systemPrompt
? stripControlChars(specialist.systemPrompt)
: null;
// ADR-034: the specialist's template key must reach the wire — without
// this the agent resolves prompt_template_key from `role` fallback and
// the specialists.prompt_template_key column silently does nothing.
specialistPromptTemplateKey = specialist?.promptTemplateKey ?? null;
}
.chat({...}) 调用(:3092 处)在 specialistSystemPrompt, 之后加一行:
specialistSystemPrompt,
specialistPromptTemplateKey,
对 regenerateDraft 的第二个调用点做同样的两处修改(该路径同样 fetch specialist —— 若它复用了别的 prompt 解析函数,把 key 一并穿透)。
- Step 5: 跑测试确认通过 + 回归
Run: cd api && npx jest specialist-template-key agent.client --no-coverage && npx tsc --noEmit
Expected: PASS;tsc clean。
- Step 6: Commit
git add api/src/conversations/conversations.service.ts api/src/conversations/specialist-template-key.spec.ts
git commit -m "fix(api): thread specialist promptTemplateKey through agent dispatch (ADR-034 wire gap)"
Task 2: P0.4 — correction 自动写 KB 止血(flag)+ OQ-205 ADR
Files:
-
Modify:
api/src/feature-flags/feature-flags.constants.ts -
Modify:
api/src/haystack/jobs/correction-refinement.processor.ts -
Test:
api/src/haystack/jobs/correction-refinement.flag.spec.ts(新建;若已有 processor spec 则并入) -
Create:
docs/decisions/037-runtime-readonly-and-human-approved-promotion.md -
Step 1: 写失败测试
// api/src/haystack/jobs/correction-refinement.flag.spec.ts
import { CorrectionRefinementProcessor } from "./correction-refinement.processor";
describe("CorrectionRefinementProcessor — correction_auto_ingest_enabled gate (OQ-205)", () => {
function makeProcessor(flagValue: boolean) {
const correctionRepo = {
find: jest.fn().mockResolvedValue([
{
id: "c1",
originalResponse: "a",
correctedResponse: "b",
expertQueueItem: { conversation: { orgId: "org-1" } },
},
]),
};
const ingestionService = { upload: jest.fn() };
const featureFlags = { isEnabled: jest.fn().mockResolvedValue(flagValue) };
const rlsContext = {
withInternalContext: jest.fn((fn: any) => fn(undefined)),
};
const processor = new CorrectionRefinementProcessor(
correctionRepo as any,
{ } as any, // orgDocumentRepo
ingestionService as any,
{ tryLog: jest.fn(), log: jest.fn() } as any, // auditService
{ isIntegrationPausedSafe: jest.fn().mockResolvedValue(false) } as any,
rlsContext as any,
{ generateObject: jest.fn() } as any, // llm
featureFlags as any, // NEW dep
);
return { processor, ingestionService, featureFlags, correctionRepo };
}
it("does NOT ingest nor markProcessed when flag disabled for the org", async () => {
const { processor, ingestionService, featureFlags } = makeProcessor(false);
const markProcessed = jest
.spyOn(processor as any, "markProcessed")
.mockResolvedValue(undefined);
await processor.process();
expect(featureFlags.isEnabled).toHaveBeenCalledWith(
"correction_auto_ingest_enabled",
expect.objectContaining({ orgId: "org-1" }),
);
expect(ingestionService.upload).not.toHaveBeenCalled();
expect(markProcessed).not.toHaveBeenCalled(); // 留给未来 proposal 流消费
});
it("proceeds when flag enabled", async () => {
const { processor, featureFlags } = makeProcessor(true);
// categorize 会走 LLM mock 并抛错 → 被外层 catch 吞掉;只断言 gate 已放行
await processor.process();
expect(featureFlags.isEnabled).toHaveBeenCalled();
});
});
- Step 2: 跑测试确认失败
Run: cd api && npx jest correction-refinement.flag --no-coverage
Expected: FAIL — 构造函数不接受第 8 个参数 / isEnabled 未被调用。
- Step 3: 实现 flag key
feature-flags.constants.ts 的 FLAG_KEYS 数组(ai_reply_attachments_enabled 之后)加:
// OQ-205 (ADR-037) — corrections auto-ingesting into the KB without human
// approval contradicts the read-only-runtime default. OFF: corrections stay
// unprocessed (processedAt NULL) until the human-approved proposal flow
// (master plan P4.2) consumes them. Enable per-org ONLY as an explicit,
// documented exception while P4.2 is not yet shipped.
"correction_auto_ingest_enabled",
DEFAULT_FLAG_VALUES 加:
// OQ-205: default OFF — nothing self-applies. See ADR-037.
correction_auto_ingest_enabled: false,
- Step 4: 实现 processor gate
correction-refinement.processor.ts:constructor 追加依赖(FeatureFlagService 从 ../../feature-flags/feature-flag.service import;haystack.module 需 import FeatureFlagsModule —— 若其为 @Global() 则只加构造参数):
private readonly llm: LlmService,
private readonly featureFlags: FeatureFlagService,
processOneCorrection 在 orgId 解析之后、isIntegrationPausedSafe 之前插入:
// OQ-205 / ADR-037 — human-approved promotion is the default. When the
// auto-ingest flag is OFF for this org, leave the correction UNprocessed
// (processedAt stays NULL) so the future proposal flow can consume it.
const autoIngestEnabled = await this.featureFlags.isEnabled(
"correction_auto_ingest_enabled",
{ orgId },
);
if (!autoIngestEnabled) {
this.logger.debug(
`Correction ${correction.id} left unprocessed: correction_auto_ingest_enabled OFF for org ${orgId}`,
);
return;
}
- Step 5: 跑测试 + 既有回归
Run: cd api && npx jest correction-refinement feature-flag --no-coverage && npx tsc --noEmit
Expected: 全 PASS(feature-flag.service.spec.ts 有 FLAG_KEYS 快照类断言的话需同步更新)。
- Step 6: 写 ADR-037
docs/decisions/037-runtime-readonly-and-human-approved-promotion.md,结构:
# ADR-037: Runtime is read-only toward Specialist assets; improvement loop proposes, humans approve
Status: Proposed → (评审后) Accepted
Date: 2026-07-XX
Closes: OQ-205 (OPEN_DECISIONS.md §OQ-205) · PRD prd-specialist-value-output Q1/T0.4
## Decision
1. Option A:runtime 对 Soul/Skill/KB 等 Specialist 资产永远只读;
2. 改进循环(corrections、eval 建议)只产出 proposal,人工(AM/Expert)approve 后生效;
3. 过渡期:correction→KB 自动 ingest 由 `correction_auto_ingest_enabled` flag 控制,默认 OFF;
P4.2 proposal 审批流上线后删除该 flag 与 processor 的直接 ingest 分支(contract 阶段)。
## Context …(引 PRD §2、ADR-033 L6、现状 processor 行为)
## Consequences …(corrections 积压至 P4.2 是接受的;紧急放行路径 = per-org flag ON + 审计)
## Alternatives considered …(B: PR-promotion;C: audited write-back — 为何否决)
- Step 7: Commit
git add api/src/feature-flags/feature-flags.constants.ts api/src/haystack/jobs/ docs/decisions/037-*.md
git commit -m "feat(api): gate correction auto-ingest behind correction_auto_ingest_enabled (OQ-205, ADR-037)"
Task 3: P0.1 — edit-signal 补全(统一 draft 链接键 + 指标索引)
设计决定(对 PRD R4.1 AC 的收窄,写进 PR 描述):不建新表、不冗余 diff 文本 —— 完整 diff 由 expert message ↔ 被释放 draft join 派生(两侧 content 都已在 messages 表)。本任务只补两个真实缺口:respond() 路径缺 releasedDraftId(join 键不统一);缺分析索引。
Files:
-
Modify:
api/src/expert-queue/expert-queue.service.ts(respond() metadata 块:1249-1260) -
Create:
api/migrations/<timestamp>-AddReleaseSignalIndex.ts -
Test: 扩展
api/src/expert-queue/release-draft-attachments.spec.ts同级新建api/src/expert-queue/release-signal-link.spec.ts -
Step 1: 写失败测试
// api/src/expert-queue/release-signal-link.spec.ts
// 复用 release-draft-attachments.spec.ts 的 service 构造 harness(同目录、
// 同 mock 形状)——断言 respond() 保存的 expert message metadata 带
// releasedDraftId = item.messageId(有 aiDraft 时)。
it("respond() stamps releasedDraftId alongside editSignal", async () => {
// arrange: pending item with messageId "draft-1"; msgRepo.findOne 返回
// { id: "draft-1", content: "AI text", metadata: { confidence: 0.9 } }
// act: await service.respond(itemId, "edited text", caller)
const saved = mockEm.save.mock.calls.find(
([entity]: any[]) => entity.role === "expert",
)![0];
expect(saved.metadata.releasedDraftId).toBe("draft-1");
expect(saved.metadata.wasEdited).toBe(true);
});
- Step 2: 跑测试确认失败
Run: cd api && npx jest release-signal-link --no-coverage
Expected: FAIL — releasedDraftId undefined。
- Step 3: 实现
expert-queue.service.ts respond() 的 metadata 块(:1249)中,在 ...editSignal, 后加:
// #3323 uniform draft-link key: releaseDraft() already stamps
// releasedDraftId; respond() must too so the edit-diff join
// (expert msg ↔ released draft) is one key on both send paths.
...(item.messageId && editSignal.wasEdited !== undefined
? { releasedDraftId: item.messageId }
: {}),
(条件 editSignal.wasEdited !== undefined = 只有确实找到 aiDraft 并算了 signal 才 stamp,follow-up 回复不带。注意 editSignal 声明处若类型是 Partial<EditSignal>,此判断成立。)
- Step 4: 指标索引 migration
先看仓库 CONCURRENTLY 先例:grep -rln "CONCURRENTLY" api/migrations/ | head -3,镜像其事务处理方式。新建(时间戳取当前,命名沿用仓库风格):
// api/migrations/<ts>-AddReleaseSignalIndex.ts
import { MigrationInterface, QueryRunner } from "typeorm";
/** Expand-only. Serves the draft-quality metrics rollup (master plan P0.2):
* released-draft expert messages filtered by osa/time. Partial: only rows
* that actually carry the edit signal. */
export class AddReleaseSignalIndex<ts> implements MigrationInterface {
public async up(q: QueryRunner): Promise<void> {
await q.query(
`CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_messages_release_signal
ON messages (osa_id, created_at)
WHERE role = 'expert' AND metadata ? 'wasEdited'`,
);
}
public async down(q: QueryRunner): Promise<void> {
await q.query(`DROP INDEX CONCURRENTLY IF EXISTS idx_messages_release_signal`);
}
}
- Step 5: 验证
Run: cd api && npx jest release-signal-link release-draft-attachments expert-queue --no-coverage && npx tsc --noEmit
Expected: PASS(migration 由 CI api-postgres-migrations job 验证前向应用)。
- Step 6: Commit
git add api/src/expert-queue/ api/migrations/
git commit -m "feat(api): uniform releasedDraftId link on respond() + release-signal metrics index (P0.1)"
Task 4: P0.2 — draft-quality 指标端点 + /ops 卡片
Files:
-
Create:
api/src/analytics/draft-quality.service.ts+draft-quality.controller.ts(挂进analytics模块;若该模块结构不同则并入现有 controller) -
Test:
api/src/analytics/draft-quality.service.spec.ts -
Modify:
frontend/src/app/ops/page.tsx(或 ops 首页对应组件,Step 5 先定位) -
Step 1: 写失败测试(服务层,Postgres-only 跳过 sqlite)
// api/src/analytics/draft-quality.service.spec.ts
import { DraftQualityService } from "./draft-quality.service";
describe("DraftQualityService.rollup", () => {
it("aggregates acceptance rate and mean edit ratio by day", async () => {
const rows = [
{ day: "2026-07-01", releases: "10", sent_unedited: "7", mean_edit_ratio: "0.21" },
];
const dataSource = { query: jest.fn().mockResolvedValue(rows) };
const svc = new DraftQualityService(dataSource as any);
const out = await svc.rollup({ orgId: "o1", specialistId: "s1", days: 14 });
expect(dataSource.query).toHaveBeenCalledWith(
expect.stringContaining("wasEdited"),
expect.arrayContaining(["o1", "s1"]),
);
expect(out[0]).toEqual({
day: "2026-07-01",
releases: 10,
acceptanceRate: 0.7,
meanEditRatio: 0.21,
});
});
});
- Step 2: 跑测试确认失败
Run: cd api && npx jest draft-quality --no-coverage
Expected: FAIL — 模块不存在。
- Step 3: 实现服务
// api/src/analytics/draft-quality.service.ts
import { Injectable } from "@nestjs/common";
import { InjectDataSource } from "@nestjs/typeorm";
import { DataSource } from "typeorm";
export interface DraftQualityDay {
day: string;
releases: number;
acceptanceRate: number; // sent-unedited %
meanEditRatio: number | null;
}
/** Draft-quality rollup (master plan P0.2 / PRD R4.2, §6 metrics).
* Reads Message.metadata edit signals (#3323) — ADR-020: caller must be an
* HP-staff surface; query filters org AND specialist via conversations join. */
@Injectable()
export class DraftQualityService {
constructor(@InjectDataSource() private readonly ds: DataSource) {}
async rollup(opts: {
orgId?: string;
specialistId?: string;
days: number;
}): Promise<DraftQualityDay[]> {
const params: unknown[] = [];
let where = `m.role = 'expert' AND m.metadata ? 'wasEdited'
AND m.created_at >= now() - ($${params.push(opts.days)}::int * interval '1 day')`;
if (opts.orgId) where += ` AND c.org_id = $${params.push(opts.orgId)}`;
if (opts.specialistId) where += ` AND c.specialist_id = $${params.push(opts.specialistId)}`;
const rows: Array<Record<string, string>> = await this.ds.query(
`SELECT to_char(date_trunc('day', m.created_at), 'YYYY-MM-DD') AS day,
count(*)::text AS releases,
count(*) FILTER (WHERE m.metadata->>'wasEdited' = 'false')::text AS sent_unedited,
avg((m.metadata->>'editRatio')::float)
FILTER (WHERE m.metadata->>'wasEdited' = 'true')::text AS mean_edit_ratio
FROM messages m
JOIN conversations c ON c.id = m.conversation_id
WHERE ${where}
GROUP BY 1 ORDER BY 1`,
params,
);
return rows.map((r) => ({
day: r.day,
releases: Number(r.releases),
acceptanceRate: Number(r.releases)
? Number(r.sent_unedited) / Number(r.releases)
: 0,
meanEditRatio: r.mean_edit_ratio === null ? null : Number(r.mean_edit_ratio),
}));
}
}
Controller:GET /analytics/draft-quality?orgId&specialistId&days=14,roles superadmin | account_manager(沿用 analytics 模块现有 guard 组合;query 参数走 DTO + @IsUUID()/@IsInt() 校验)。注册进 analytics 模块的 providers/controllers。
- Step 4: 跑测试确认通过
Run: cd api && npx jest draft-quality --no-coverage && npx tsc --noEmit
Expected: PASS。
- Step 5: /ops 卡片
定位 ops 首页:ls frontend/src/app/ops/(首页文件 page.tsx)。新增组件 frontend/src/components/ops/DraftQualityCard.tsx:14 天 acceptance-rate 折线(复用 ops 现有图表依赖;若无图表库则渲染两个数值 tile:本周 acceptance rate、mean edit ratio + 周环比箭头),数据取 GET /analytics/draft-quality?days=14(走 frontend/src/lib/api.ts 新增 fetchDraftQuality(),沿用现有鉴权 fetch 封装)。挂到 ops 首页卡片区。
- Step 6: 前端验证 + Commit
Run: cd frontend && npx tsc --noEmit && npm run build 2>&1 | tail -5
Expected: build 通过。
git add api/src/analytics/ frontend/src/components/ops/ frontend/src/lib/api.ts frontend/src/app/ops/
git commit -m "feat: draft-quality metrics endpoint + ops dashboard card (P0.2, PRD R4.2)"
Task 5: P0.6 — minimal per-turn assembly trace
设计:不建新表。AG 侧把装配元数据加进已有 audit 响应块;BE 侧解析并持久化到 draft message 的 metadata.assembly(jsonb,天然 join 到消息/edit-signal)。全量 trace(version ids、独立表、90 天保留)在 Phase 3 P3.4。
Files:
-
Modify:
agent/prompts_loader.py(返回装配元数据) -
Modify:
agent/models/shared.py(AuditMetadata 增字段) -
Modify:
agent/main.py(audit payload 增字段;4 个构造点:727,752,1021,1060附近,以 grep 定位) -
Modify:
agent/chat_helpers.py(穿透 meta) -
Test:
agent/evals/test_prompt_assembly_meta.py(新建) -
Modify:
api/src/common/agent.client.ts(:127接口 +:529解析) -
Modify:
api/src/conversations/conversations.service.ts(draft 持久化点 metadata 增assembly键) -
Test:
api/src/common/agent.client.spec.ts(扩展) -
Step 1: AG 失败测试
# agent/evals/test_prompt_assembly_meta.py
"""P0.6 minimal assembly trace — build_system_prompt must report which path fired."""
from prompts_loader import build_system_prompt_with_meta
def test_short_persona_reports_header_injection():
prompt, meta = build_system_prompt_with_meta(
role="kyc_ops", agent_context={"specialist_system_prompt": "Be warm."}
)
assert meta["template_used"] == "kyc_ops"
assert meta["persona_override_applied"] is False
assert "SPECIALIST PERSONA" in prompt
def test_long_persona_reports_override_fired():
long_persona = "x" * 400 # > 300 chars triggers the full-template heuristic
prompt, meta = build_system_prompt_with_meta(
role="kyc_ops", agent_context={"specialist_system_prompt": long_persona}
)
assert meta["persona_override_applied"] is True
assert meta["template_used"] is None # role template was displaced
def test_no_persona():
_, meta = build_system_prompt_with_meta(role="finance_ops", agent_context={})
assert meta["persona_override_applied"] is False
assert meta["template_used"] == "finance_ops"
Run: cd agent && python -m pytest evals/test_prompt_assembly_meta.py -v
Expected: FAIL — build_system_prompt_with_meta 不存在。
(若 build_system_prompt 实际签名的 persona 入参与上面不符,以 prompts_loader.py:236 真实签名为准调整测试入参,断言不变。)
- Step 2: AG 实现
prompts_loader.py:把 build_system_prompt 主体改名为 build_system_prompt_with_meta,返回 (prompt, meta);原名函数保留为薄包装(return build_system_prompt_with_meta(...)[0]),调用方零改动。meta 在 is_full_template 分支处构造:
meta = {
# which role template body was loaded; None when the persona override
# displaced it entirely (the ADR-033 anti-pattern we are measuring)
"template_used": None if is_full_template else role,
"persona_override_applied": is_full_template,
"persona_chars": len(specialist_override or ""),
}
chat_helpers.py 的 prompt 构建处改调 _with_meta 变体并把 meta 与既有 compute_prompt_version 结果一起向上返回;main.py 的 4 个 audit payload 构造点(grep -n "prompt_version" agent/main.py)在 prompt_version 旁增加:
"prompt_template_used": assembly_meta.get("template_used"),
"persona_override_applied": assembly_meta.get("persona_override_applied"),
models/shared.py AuditMetadata 增字段:
prompt_template_used: Optional[str] = None
persona_override_applied: Optional[bool] = None
Run: cd agent && python -m pytest evals/test_prompt_assembly_meta.py evals/test_hermes_parser.py -v
Expected: PASS(含既有 parser 回归)。
- Step 3: BE 失败测试
api/src/common/agent.client.spec.ts 增用例(沿用该文件现有 fetch-mock 形状):
it("parses assembly trace fields from the audit block (P0.6)", async () => {
// fetch mock 返回体 audit 块增加:
// audit: { prompt_version: "abc", prompt_template_used: "kyc_ops",
// persona_override_applied: false, ... }
const resp = await client.chat(baseRequest);
expect(resp.promptVersion).toBe("abc");
expect(resp.promptTemplateUsed).toBe("kyc_ops");
expect(resp.personaOverrideApplied).toBe(false);
});
Run: cd api && npx jest agent.client --no-coverage → Expected: FAIL。
- Step 4: BE 实现
agent.client.ts AgentResponse 接口(promptVersion?: string; 之后,:127):
promptTemplateUsed?: string | null;
personaOverrideApplied?: boolean;
解析处(:529 同一块):
promptTemplateUsed: (data["audit"] as Record<string, unknown> | undefined)?.["prompt_template_used"] as string | null | undefined,
personaOverrideApplied: (data["audit"] as Record<string, unknown> | undefined)?.["persona_override_applied"] as boolean | undefined,
conversations.service.ts draft 持久化点(grep -n 'draftStatus: "active"' api/src/conversations/conversations.service.ts 定位;agent 草稿 metadata 构造块)增加:
// P0.6 minimal assembly trace — joinable to edit signals by this
// very message row; full versioned trace lands with the assembler
// (master plan P3.4). Keys absent when the agent didn't report.
...(agentResp.promptVersion
? {
assembly: {
promptVersion: agentResp.promptVersion,
templateKeySent: specialistPromptTemplateKey ?? conv.role ?? null,
templateUsed: agentResp.promptTemplateUsed ?? null,
personaOverrideApplied: agentResp.personaOverrideApplied ?? null,
kbContextProvided,
activeDirectivesCount: activeDirectives?.length ?? 0,
},
}
: {}),
- Step 5: 验证 + Commit
Run: cd api && npx jest agent.client conversations --no-coverage && npx tsc --noEmit
Expected: PASS。
git add agent/ api/src/common/agent.client.ts api/src/conversations/conversations.service.ts api/src/common/agent.client.spec.ts
git commit -m "feat: minimal per-turn assembly trace — agent audit block + draft metadata (P0.6, PRD R5.4-minimal)"
上线后一周检查点:override 命中率 = count(*) FILTER (WHERE (metadata->'assembly'->>'personaOverrideApplied') = 'true')::float / NULLIF(count(*) FILTER (WHERE metadata->'assembly'->>'personaOverrideApplied' IS NOT NULL), 0) —— 分母必须过滤 IS NOT NULL:混布期老 agent 产生的 draft 行没有 assembly 字段(值为 null),不进分母,否则命中率被稀释。这是 Phase 1 T1.4/P1.6 移除启发式的风险面数据。