Compare commits

...

4 Commits

Author SHA1 Message Date
Jesse_Chen c7dfe1c166 fix(rectification): bind evidence to current server turn
Staging Backend Quality Gate / validate (pull_request) Successful in 16m51s
Staging Backend Quality Gate / publish (pull_request) Has been skipped
2026-08-12 12:12:10 +08:00
jesse 8beed38951 Merge pull request #32: fix(rectification): hydrate persisted case turns
Staging Backend Quality Gate / validate (push) Successful in 11m16s
Staging Backend Quality Gate / publish (push) Successful in 7m33s
Restore asynchronously loaded persisted rectification turns and preserve receipt methods.
2026-08-12 11:03:09 +08:00
Jesse_Chen 220828d6f0 fix(rectification): hydrate persisted case turns
Staging Backend Quality Gate / validate (pull_request) Successful in 15m6s
Staging Backend Quality Gate / publish (pull_request) Has been skipped
2026-08-12 10:45:49 +08:00
jesse 31fa6aaa50 Merge pull request #31 from codex/rectification-activity-birth-context-20260812
Staging Backend Quality Gate / validate (push) Successful in 10m45s
Staging Backend Quality Gate / publish (push) Successful in 7m31s
fix(rectification): surface real activity context
2026-08-12 10:03:13 +08:00
10 changed files with 124 additions and 52 deletions
+30
View File
@@ -2960,3 +2960,33 @@
- 防复发:明确区分“服务端内部 Agent 工具结果”和“公开 UI/API projection”;Activity 不得从泛化 phase 文案推导技法,不得公开思维链、Prompt、工具参数、出生资料、原始分数、权重、规则 ID 或 Provider metadata。
- 相关记录:BUG-163、BUG-170、BUG-171
- 修复版本:待提交
## BUG-173 | 恢复生时校正会话时历史消息未随异步 Case GET 渲染
- 状态:resolved(本地候选)
- 首次发现:2026-08-12
- 最近更新:2026-08-12
- 影响面:V9 生时校正恢复入口、历史 turns 和持久化 Activity 技法展示。
- 用户现象:点击“继续上次校正”后只看到页面壳层,Case GET 已返回 assistant turn,但消息区为空。
- 触发条件:恢复已有 Case;聊天组件先以空 `initialTurns` 挂载,Case GET 稍后返回历史 turns。
- 根因:`RectificationAgenticChat` 仅在 `useState` 初始化时投影 `initialTurns`,未在异步 props 更新后同步;同时 `page.tsx` 映射持久化 receipt 时漏掉 `methods`
- 修复:复用同一个 turns→messages 投影函数,并让父层在持久化 turns 到达时用最后一个 Turn ID 重挂载该局部聊天组件;补齐 receipt `methods` 的客户端类型和安全映射。
- 验证:新增静态回归检查覆盖异步 hydration 和 methods 映射;部署后以真实登录浏览器确认历史 Agent 消息及 Activity 渲染。
- 防复发:任何异步加载后传入的初始化数据不能只依赖子组件首次 state 初始化;公开 receipt 新字段必须贯通 API projection、page mapping 与组件类型。
- 相关记录:BUG-172
- 修复版本:待提交
## BUG-174 | V9 当前用户事件无法绑定服务端 Turn,Agent 要求重复发送
- 状态:resolved(本地候选)
- 首次发现:2026-08-12
- 最近更新:2026-08-12
- 影响面:V9 生时校正事件证据提出与最终出生时间确认的用户原话绑定。
- 用户现象:用户输入“2016 年 9 月上大学”后,Agent 能识别事件并调用“整理事件证据”,但声称当前轮锚点没有接通,要求用户重复发送同一句话。
- 触发条件:Agent 在当前消息轮调用 `rectification-propose-evidence`;工具 schema 要求模型回传 `sourceTurnId`,但消息上下文和 `rectification-read-case` 安全投影都不公开当前 Turn UUID。
- 根因:服务端 runner 已在 Agent 执行前创建并持有可信 `turnId`,工具仍错误地把该内部引用交给模型提供,形成模型无法满足的参数合同;重复发送不会修复这一合同缺口。
- 修复:`rectification-propose-evidence``rectification-confirm-birth-time` 不再接受模型提供的 `sourceTurnId`,统一使用工具上下文中的服务端当前 `turnId`;保留数据库 Case ownership、Turn existence 与 quote/consent 原文匹配校验。
- 验证:回归断言 propose/confirm schema 拒绝模型传入 Turn ID,并确认事件证据与最终确认 RPC 的 `p_source_turn_id` 均等于服务端当前 Turn;聚焦测试通过,staging 部署证据见本轮发布记录。
- 防复发:当前请求已经由服务器掌握的内部 ID 不得再要求模型猜测或回传;原文真实性继续在数据库信任边界验证,不能以放宽 quote 校验规避绑定问题。
- 相关记录:BUG-170、BUG-172、BUG-173
- 修复版本:本次提交(staging 精确 SHA 以发布记录为准)
+2 -1
View File
@@ -2372,6 +2372,7 @@ export default function Home() {
status: String((turn.receipt as { status?: unknown }).status ?? ""),
phases: Array.isArray((turn.receipt as { phases?: unknown }).phases) ? (turn.receipt as { phases: unknown[] }).phases.map(String) : [],
tools: Array.isArray((turn.receipt as { tools?: unknown }).tools) ? (turn.receipt as { tools: unknown[] }).tools.map(String) : [],
methods: Array.isArray((turn.receipt as { methods?: unknown }).methods) ? (turn.receipt as { methods: unknown[] }).methods.map(String) : [],
skill_name: typeof (turn.receipt as { skill_name?: unknown }).skill_name === "string" ? (turn.receipt as { skill_name: string }).skill_name : undefined,
skill_version: typeof (turn.receipt as { skill_version?: unknown }).skill_version === "string" ? (turn.receipt as { skill_version: string }).skill_version : undefined,
} : null,
@@ -3480,7 +3481,7 @@ export default function Home() {
{rectificationSurfaceOpen && rectificationCaseId && (
<ConversationalBirthTimeRectification
key={`${rectificationSessionId}-${rectificationCaseId}`}
key={`${rectificationSessionId}-${rectificationCaseId}-${rectificationTurns.at(-1)?.id ?? "loading"}`}
caseId={rectificationCaseId}
sessionId={rectificationSessionId ?? ""}
readonly={rectificationReadonly}
@@ -13,6 +13,7 @@ export type PersistedRectificationTurn = Readonly<{
status: string;
phases: readonly string[];
tools: readonly string[];
methods?: readonly string[];
skill_name?: string;
skill_version?: string;
}> | null;
@@ -112,6 +112,28 @@ function activityFromReceipt(receipt: PersistedTurn["receipt"]): ReceiptActivity
};
}
function messagesFromTurns(initialTurns: readonly PersistedTurn[]): RenderMessage[] {
return initialTurns.flatMap((turn, index): RenderMessage[] => {
const key = `persisted-${turn.id}-${index}`;
if (turn.role === "assistant") {
return [{
role: "assistant",
text: turn.text ?? "",
renderKey: key,
state: turn.status === "completed" ? "settled" : "thinking",
receiptActivity: activityFromReceipt(turn.receipt),
receiptStatus: turn.receipt?.status,
}];
}
return [{
role: "user",
text: turn.text ?? "",
renderKey: key,
state: "settled",
}];
});
}
export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const {
caseId,
@@ -131,27 +153,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
onRestart,
} = props;
const [messages, setMessages] = useState<RenderMessage[]>(() =>
initialTurns.flatMap((turn, index): RenderMessage[] => {
const key = `persisted-${turn.id}-${index}`;
if (turn.role === "assistant") {
return [{
role: "assistant",
text: turn.text ?? "",
renderKey: key,
state: turn.status === "completed" ? "settled" : "thinking",
receiptActivity: activityFromReceipt(turn.receipt),
receiptStatus: turn.receipt?.status,
}];
}
return [{
role: "user",
text: turn.text ?? "",
renderKey: key,
state: "settled",
}];
}),
);
const [messages, setMessages] = useState<RenderMessage[]>(() => messagesFromTurns(initialTurns));
const [draft, setDraft] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
+1 -1
View File
@@ -55,7 +55,7 @@ const agenticRectificationInstructions = `你是生时校正 Agent,只服务
硬性边界(必须服从):
1. 先调用 skill 工具加载 jyotish-birth-time-rectification(本 Case 固定版本),再调用 rectification-read-case 读取服务端 Case/Dossier 后才能行动。
2. 事件事实只能来自用户原话:不得虚构事件、日期、候选、分盘数据、评分或出生分钟;计算与持久化只能通过工具。
3. 工具 input 只传最小引用(caseId、sourceTurnId、evidenceId、resultId、candidateId、quote、proposedKind、日期精度等)。绝不传 userId、出生资料、候选范围、events 数组、分数或权限开关。
3. 工具 input 只传最小引用(caseId、evidenceId、resultId、candidateId、quote、proposedKind、日期精度等)。绝不传 userId、出生资料、候选范围、events 数组、分数或权限开关。
4. 日期精度如实保留:用户只说年份就按 year 处理,不得诱导编造月份。
5. 三层语义严格分开:candidate=当前候选比较结果;accepted=用户明确采用的排盘时间;confirmed=通过服务器确认门且用户明确同意。accepted 不等于 confirmed。
6. “是/对”只能确认当前 pending draft;用户更正事实用 revise(生成 revision,不覆盖历史)。
+5 -16
View File
@@ -1,8 +1,8 @@
/**
* V9 rectification tools (Case-ref only).
*
* The model may only pass minimal references: caseId, sourceTurnId, quote,
* proposedKind, evidenceId, resultId, candidateId (plus optional date fields
* The model may only pass minimal references: caseId, quote, proposedKind,
* evidenceId, resultId, candidateId (plus optional date fields
* the model grounded in the user's own words). userId, birth data, candidate
* ranges, event arrays, scores and permission booleans never appear in tool
* inputs. Every tool authenticates through the Case row, validates ownership
@@ -214,13 +214,6 @@ function assertEvidenceRef(input: { evidenceId?: unknown }): string {
return input.evidenceId;
}
function assertTurnRef(input: { sourceTurnId?: unknown }): string {
if (typeof input.sourceTurnId !== "string" || !uuidPattern.test(input.sourceTurnId)) {
throw new RectificationToolServiceError("invalid_source_turn_id");
}
return input.sourceTurnId;
}
function assertCandidateRef(input: { resultId?: unknown; candidateId?: unknown }): { resultId: string; candidateId: string } {
if (
typeof input.resultId !== "string" || !uuidPattern.test(input.resultId)
@@ -304,7 +297,6 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
"提出一条事件证据草稿。quote 必须能规范化匹配当前用户轮次原文;日期只保留用户说过的精度(只说年份则 year);subject 只能 self/family/othereventKind 使用受控枚举;summary 只复述用户原话,不得补充月份、原因、主动被动或人物关系。返回 evidenceId(服务器生成)。",
inputSchema: z.object({
caseId: z.string().uuid(),
sourceTurnId: z.string().uuid(),
quote: z.string().trim().min(2).max(400),
proposedKind: z.string().trim().min(1).max(48),
subject: z.enum(["self", "family", "other"]).default("self"),
@@ -316,13 +308,12 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
}).strict(),
execute: async (input) => {
assertCaseRef(input);
const sourceTurnId = assertTurnRef(input);
if (!isEvidenceKind(input.proposedKind)) throw new RectificationToolServiceError("invalid_event_kind");
if (!isEvidenceDomain(input.domain)) throw new RectificationToolServiceError("invalid_domain");
if (!isDatePrecision(input.datePrecision)) throw new RectificationToolServiceError("invalid_date_precision");
const inputFingerprint = canonicalToolInputFingerprint("rectification-propose-evidence", {
caseId: input.caseId,
sourceTurnId,
sourceTurnId: turnId,
quote: input.quote,
proposedKind: input.proposedKind,
});
@@ -331,7 +322,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
const occurredFrom = input.occurredFrom ? normalizeDatePart(input.occurredFrom) : null;
const occurredTo = input.occurredTo ? normalizeDatePart(input.occurredTo) : null;
const result = await proposeV9Evidence(accounting, userId, input.caseId, {
sourceTurnId,
sourceTurnId: turnId,
quote: input.quote,
subject: input.subject,
eventKind: input.proposedKind,
@@ -651,13 +642,11 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
caseId: z.string().uuid(),
resultId: z.string().uuid(),
candidateId: z.string().regex(timePattern),
sourceTurnId: z.string().uuid(),
consentQuote: z.string().trim().min(2).max(400),
}).strict(),
execute: async (input) => {
assertCaseRef(input);
const { resultId } = assertCandidateRef({ resultId: input.resultId, candidateId: input.candidateId });
const sourceTurnId = assertTurnRef(input);
const inputFingerprint = canonicalToolInputFingerprint("rectification-confirm-birth-time", {
caseId: input.caseId,
resultId,
@@ -669,7 +658,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
resultId,
time: input.candidateId,
consentQuote: input.consentQuote,
sourceTurnId,
sourceTurnId: turnId,
});
const projection = {
saved_time: result.savedTime,
@@ -29,6 +29,14 @@ test("birth-time rectification entry mounts the V9 case-ref chat", () => {
assert.match(component, /initialTurns:/);
});
test("persisted rectification turns hydrate after the async Case refresh", () => {
assert.match(chat, /function messagesFromTurns\(initialTurns:/);
assert.match(chat, /useState<RenderMessage\[\]>\(\(\) => messagesFromTurns\(initialTurns\)\)/);
assert.match(page, /key=\{`\$\{rectificationSessionId\}-\$\{rectificationCaseId\}-\$\{rectificationTurns\.at\(-1\)\?\.id \?\? "loading"\}`\}/);
assert.match(page, /methods: Array\.isArray\(\(turn\.receipt as \{ methods\?: unknown \}\)\.methods\)/);
assert.match(component, /methods\?: readonly string\[\]/);
});
test("opening is server-owned: shouldStartOpening drives the first turn, never client history", () => {
assert.doesNotMatch(chat, /initialMessages\.length > 0 \|\| openingStarted/);
assert.doesNotMatch(chat, /agenticOpeningInstruction|用户刚进入生时校正会话/);
@@ -100,7 +108,7 @@ test("agent tool calls never end silently; the runner owns completion and failur
test("persisted turns survive remounts; duplicate openings are suppressed by the server", () => {
assert.match(chat, /initialTurns/);
assert.match(chat, /const openingStarted = useRef\(false\)/);
assert.match(page, /key=\{`\$\{rectificationSessionId\}-\$\{rectificationCaseId\}`\}/);
assert.match(page, /key=\{`\$\{rectificationSessionId\}-\$\{rectificationCaseId\}-\$\{rectificationTurns\.at\(-1\)\?\.id \?\? "loading"\}`\}/);
assert.match(page, /initialTurns=\{rectificationTurns\}/);
assert.match(page, /onMessagesChange=\{handleRectificationMessagesChange\}/);
});
@@ -22,7 +22,6 @@ import {
receiptHandlers,
} from "./rectification-v9-test-support.ts";
const SOURCE_TURN_ID = "77777777-7777-4777-8777-777777777777";
function toolContext(overrides: {
accounting?: ReturnType<typeof fakeAccounting>;
@@ -47,14 +46,13 @@ function toolContext(overrides: {
};
}
test("propose-evidence schema rejects model-provided ids, birth data and ranges", async () => {
test("propose-evidence binds the server-owned current turn and rejects model-provided ids, birth data and ranges", async () => {
const { tools } = toolContext();
const schema = (tools as Record<string, { inputSchema?: { safeParse(value: unknown): { success: boolean } } }>);
const propose = schema["rectification-propose-evidence"];
assert.ok(propose?.inputSchema);
const valid = propose.inputSchema!.safeParse({
caseId: CASE_ID,
sourceTurnId: SOURCE_TURN_ID,
quote: "2016年9月离开家去北京工作",
proposedKind: "career_entry",
subject: "self",
@@ -65,9 +63,21 @@ test("propose-evidence schema rejects model-provided ids, birth data and ranges"
});
assert.equal(valid.success, true);
const withTurnId = propose.inputSchema!.safeParse({
caseId: CASE_ID,
sourceTurnId: "77777777-7777-4777-8777-777777777777",
quote: "2016年9月离开家去北京工作",
proposedKind: "career_entry",
subject: "self",
domain: "career",
datePrecision: "month",
occurredFrom: "2016-09",
summary: "2016年9月离家去北京工作",
});
assert.equal(withTurnId.success, false);
const withModelId = propose.inputSchema!.safeParse({
caseId: CASE_ID,
sourceTurnId: SOURCE_TURN_ID,
quote: "2016年9月离开家去北京工作",
proposedKind: "career_entry",
datePrecision: "month",
@@ -78,7 +88,6 @@ test("propose-evidence schema rejects model-provided ids, birth data and ranges"
const withBirthData = propose.inputSchema!.safeParse({
caseId: CASE_ID,
sourceTurnId: SOURCE_TURN_ID,
quote: "2016年9月离开家去北京工作",
proposedKind: "career_entry",
datePrecision: "month",
@@ -89,7 +98,6 @@ test("propose-evidence schema rejects model-provided ids, birth data and ranges"
const withRange = propose.inputSchema!.safeParse({
caseId: CASE_ID,
sourceTurnId: SOURCE_TURN_ID,
quote: "2016年9月离开家去北京工作",
proposedKind: "career_entry",
datePrecision: "month",
@@ -113,7 +121,6 @@ test("quote must be grounded in the source turn's own message", async () => {
execute(input: unknown): Promise<unknown>;
}).execute({
caseId: CASE_ID,
sourceTurnId: SOURCE_TURN_ID,
quote: "这段话根本不在用户消息里",
proposedKind: "career_entry",
subject: "self",
@@ -133,7 +140,6 @@ test("year-only evidence keeps year precision and normalizes to a year start", a
execute(input: unknown): Promise<unknown>;
}).execute({
caseId: CASE_ID,
sourceTurnId: SOURCE_TURN_ID,
quote: "2016年离开家去北京开始工作",
proposedKind: "career_entry",
subject: "self",
@@ -144,6 +150,7 @@ test("year-only evidence keeps year precision and normalizes to a year start", a
});
const proposeCall = accounting.calls.find((call) => call.fn === "propose_agentic_rectification_evidence");
assert.ok(proposeCall);
assert.equal(proposeCall.args.p_source_turn_id, TURN_ID);
assert.equal(proposeCall.args.p_date_precision, "year");
assert.equal(proposeCall.args.p_occurred_from, "2016-01-01");
// The model cannot supply an evidence id; the server generates it.
@@ -227,7 +234,6 @@ test("propose is idempotent: replay returns the existing draft without a second
execute(input: unknown): Promise<{ idempotent: boolean }>;
}).execute({
caseId: CASE_ID,
sourceTurnId: SOURCE_TURN_ID,
quote: "2016年9月离开家去北京工作",
proposedKind: "career_entry",
subject: "self",
@@ -262,7 +268,6 @@ test("terminal cases reject evidence writes", async () => {
execute(input: unknown): Promise<unknown>;
}).execute({
caseId: CASE_ID,
sourceTurnId: SOURCE_TURN_ID,
quote: "2016年9月离开家去北京工作",
proposedKind: "career_entry",
subject: "self",
@@ -94,6 +94,43 @@ test("confirmed requires the engine gate plus explicit grounded consent", async
);
});
test("confirm-birth-time binds consent to the server-owned current turn", async () => {
const accounting = fakeAccounting({
...receiptHandlers,
confirm_agentic_rectification_birth_time: () => ({
success: true,
saved_time: "05:02",
status: "confirmed",
result_id: RESULT_ID,
case_status: "confirmed",
idempotent: false,
}),
});
const tools = createRectificationV9Tools({
userId: USER_ID,
caseId: CASE_ID,
turnId: TURN_ID,
accounting: accounting.client as never,
});
const confirm = tools["rectification-confirm-birth-time"] as unknown as {
inputSchema: { safeParse(value: unknown): { success: boolean } };
execute(input: unknown): Promise<unknown>;
};
const input = {
caseId: CASE_ID,
resultId: RESULT_ID,
candidateId: "05:02",
consentQuote: "就用05:02",
};
assert.equal(confirm.inputSchema.safeParse(input).success, true);
assert.equal(confirm.inputSchema.safeParse({ ...input, sourceTurnId: "77777777-7777-4777-8777-777777777777" }).success, false);
await confirm.execute(input);
const call = accounting.calls.find((item) => item.fn === "confirm_agentic_rectification_birth_time");
assert.ok(call);
assert.equal(call.args.p_source_turn_id, TURN_ID);
});
test("candidate ownership is case-scoped: the RPC always receives the case id", async () => {
const accounting = fakeAccounting({
accept_agentic_rectification_candidate_for_case: () => ({
@@ -152,7 +189,6 @@ test("terminal cases reject evidence writes and candidate actions", async () =>
await assert.rejects(
(tools["rectification-propose-evidence"] as unknown as { execute(input: unknown): Promise<unknown> }).execute({
caseId: CASE_ID,
sourceTurnId: TURN_ID,
quote: "2016年9月离开家去北京工作",
proposedKind: "career_entry",
subject: "self",
@@ -42,7 +42,7 @@ description: "生时校正专用 Skill(V9)。以用户原话事件 + 服务
## 4. 可调用工具与边界
只调用服务器提供的 `rectification-*` 工具(read-case / propose-evidence / confirm-evidence / revise-evidence / compare-candidates / read-diagnostics / offer-candidates / accept-candidate / confirm-birth-time / close-case)。工具 input 只含最小引用(caseId、evidenceId、turnId、quote、proposedKind 等),**绝不**传:
只调用服务器提供的 `rectification-*` 工具(read-case / propose-evidence / confirm-evidence / revise-evidence / compare-candidates / read-diagnostics / offer-candidates / accept-candidate / confirm-birth-time / close-case)。工具 input 只含最小引用(caseId、evidenceId、quote、proposedKind 等),**绝不**传:
- userId、出生日期/时间/地点/时区、candidate range、完整 events 数组、分数与阈值、confirmationAllowed/selectionAllowed、profile 写入目标。