fix(consult): allow reported-time precision and persist receipts
Independent Staging Quality Gate / validate (push) Successful in 12m1s
Independent Staging Quality Gate / publish (push) Successful in 9m50s

This commit is contained in:
Jesse_Chen
2026-08-16 16:57:12 +08:00
parent a8279623aa
commit f12a43ad1d
12 changed files with 87 additions and 45 deletions
+31
View File
@@ -3509,6 +3509,7 @@
- 防复发:服务器已经确定的 Skill 身份、指令和首个事实读取步骤不得再依赖模型自动选工具;所有 provider 调用前必须完成可审计的 Skill 绑定,首步工具面保持最小化,并继续以最终 `run.completed`、持久化 Turn 和计费不变量作为部署后验收标准。
- 相关记录:BUG-177、BUG-198、BUG-206
- 修复版本:本次 staging 发布候选(精确 SHA 以远端 staging 与健康检查验收为准)
## BUG-209 | 用户选择准确出生时间后仍停留 reported,个人报告固定返回 birth_time_not_usable
- 状态:resolved(本地候选,待 staging 迁移、精确 SHA 发布与登录态报告验收)
@@ -3523,3 +3524,33 @@
- 防复发:`reported` 表示用户声明但尚未采用,`accepted` 表示用户明确采用为当前排盘输入,`confirmed` 只表示引擎或校正流程确认;任何初始化来源语义变更必须同时覆盖 Profile 持久化、历史回填、报告服务端门槛和客户端入口,不得通过放宽报告接口读取未采用的 `reported_birth_time` 绕过事实边界。
- 相关记录:BUG-125、BUG-196、BUG-197
- 修复版本:本地未提交候选
## BUG-210 | 用户填报具体出生分钟被生时校正状态错误阻断精确应期
- 状态:resolved(本次 staging 发布候选)
- 首次发现:2026-08-16
- 最近更新:2026-08-16
- 影响面:普通咨询 `unverified_birth_time` 模式的 Consultation Plan、Python workflow 精度边界、Agent/legacy 回答 receipt 与确定性日期输出过滤。
- 用户现象:用户已经明确填报到具体分钟并使用个人星盘咨询,回答仍以生时未校正为由拒绝精确应期,真实大运或阶段日期被替换成 `[具体时间已省略]`;生时校正因此被错误实现成查看精确日期的付费前置条件。
- 触发条件:服务端 Profile 含合法 `reported_birth_time`,咨询模式解析为 `unverified_birth_time`,且 workflow 原始证据本可允许精确应期。
- 根因:TypeScript Consultation Plan 把除 `verified_chart` 外的所有模式统一投影为 `precise_timing_blocked`workflow 返回后 `applyBirthTimeModeToWorkflowContext()` 又无条件把 `can_answer_precise_timing` 改为 `false`。最终输出 guard 根据被强制阻断的 receipt 删除年月日,而不是根据计算证据是否完整决定。
- 修复:有具体分钟的 `verified_chart``unverified_birth_time` 统一使用 `server_evidence_required`,精确应期权限由服务器计算证据决定;未校正模式继续保留 `birth_time_confidence=unverified_reported_time``candidate_is_confirmed=false`,但不再覆盖 workflow 的精度许可。无出生分钟的 `general_no_birth_time` 继续使用 `precise_timing_blocked`,证据确实不足时仍保留原确定性 guard。输出事件、receipt 字段和回答结构未改变。
- 验证:回归测试先稳定复现 unverified plan/receipt 被强制 blocked 和日期脱敏,修复后确认具体填报分钟投影为 `server_evidence_required`、证据允许时 receipt 为 `allowed`、真实起止日期保持原文,同时证据阻断和无分钟模式仍继续过滤不允许的精确日期。
- 防复发:生时校正状态只能作为出生时间来源与置信度元数据,不得充当普通咨询功能 entitlement;精确应期许可必须由服务端证据完整性决定。Plan、workflow context、receipt 与输出 guard 的回归必须同时覆盖 verified、reported-minute 和 no-minute 三种模式。
- 相关记录:BUG-194、BUG-198、BUG-200
- 修复版本:本次 staging 发布候选(精确 SHA 以远端 staging 为准)
## BUG-211 | 多领域咨询 receipt 可生成但无法写入聊天记录
- 状态:resolved(本次 staging 发布候选)
- 首次发现:2026-08-16
- 最近更新:2026-08-16
- 影响面:普通咨询首轮完成后的整段聊天记录 PATCH、立即追问流程及 `workflowReceipt.domains` 持久化。
- 用户现象:首轮多领域回答正常完成,用户立即追问时返回 HTTP 400 `聊天记录格式不正确`,问题被放回输入框,第二次 `/api/consult` 未开始。
- 触发条件:assistant message 同时包含公开 `workflowReceipt.domains``agentExecutionReceipt.workflow.domains`,前端在下一轮生成前 PATCH 完整消息数组。
- 根因:公开 Agent 事件使用的 canonical `workflowReceiptSchema` 已允许可选 `domains`,聊天写入合同却重复维护了一套 `.strict()` 旧 schema,导致 `messages[].workflowReceipt.domains` 被 Zod 判定为 `unrecognized_keys`;嵌套 execution receipt 使用新版 schema,因此同一业务对象在两个位置具有不同合法字段。
- 修复:聊天写入合同直接复用 `consultation-agent-events.ts` 导出的 `workflowReceiptSchema``WorkflowReceipt` 类型,删除重复字段定义;API、NDJSON、消息和 receipt 输出结构不变。
- 验证:新增与真实失败 payload 同形的回归,assistant message 同时携带顶层与 execution receipt 的 `domains=[general,timing]``chatSessionWriteSchema` 成功解析;既有 same-origin PATCH、错误重试和所有权边界测试继续通过。
- 防复发:跨响应、UI 状态和持久化边界共享的 receipt 必须只有一个 canonical schema;禁止在写入合同中复制 `.strict()` 子结构。新增字段必须以包含完整真实消息形状的 round-trip 回归验证。
- 相关记录:BUG-186、BUG-189
- 修复版本:本次 staging 发布候选(精确 SHA 以远端 staging 为准)
+3 -2
View File
@@ -825,8 +825,9 @@ export async function POST(request: Request) {
if (!prepared.serverChart) throw new Error("server_chart_truth_missing");
const toolInput = consultationInputSchema.parse({
...prepared.serverChart.toolInput,
// Unverified use is still a normal chart calculation with a hard answer
// boundary. It must never reactivate the retired rectification questionnaire.
// A user-reported concrete minute is a normal chart calculation. Keep its
// provenance, but let server evidence—not rectification purchase state—own
// precise-timing permission. Never reactivate the retired questionnaire.
entryMode: "direct_chart",
question: resolvedQuestion.modelQuestion,
theme: parsed.data.theme,
+2 -7
View File
@@ -1,4 +1,4 @@
import type { AgentExecutionReceipt, PublicActivityPhase } from "./consultation-agent-events.ts";
import type { AgentExecutionReceipt, PublicActivityPhase, WorkflowReceipt } from "./consultation-agent-events.ts";
export type AgentActivityView = Readonly<{
phase: PublicActivityPhase;
@@ -11,12 +11,7 @@ export type ChatMessage = {
readonly suggestions?: readonly string[];
readonly techniqueTruth?: string;
readonly agentExecutionReceipt?: AgentExecutionReceipt;
readonly workflowReceipt?: {
readonly route: string;
readonly status: string;
readonly preciseTiming: string;
readonly missingLayers: readonly string[];
};
readonly workflowReceipt?: WorkflowReceipt;
};
export type ChatMessageView = ChatMessage & {
@@ -1,5 +1,10 @@
import { z } from "zod";
import { agentExecutionReceiptSchema, type AgentExecutionReceipt } from "./consultation-agent-events.ts";
import {
agentExecutionReceiptSchema,
workflowReceiptSchema,
type AgentExecutionReceipt,
type WorkflowReceipt,
} from "./consultation-agent-events.ts";
import { consultationDomainSchema, type ConsultationDomain } from "./consultation-domain-registry.ts";
const chatMessageSchema = z.object({
@@ -8,12 +13,7 @@ const chatMessageSchema = z.object({
suggestions: z.array(z.string().max(200)).max(3).optional(),
techniqueTruth: z.string().max(120).optional(),
agentExecutionReceipt: agentExecutionReceiptSchema.optional(),
workflowReceipt: z.object({
route: z.string().max(120),
status: z.string().max(120),
preciseTiming: z.string().max(120),
missingLayers: z.array(z.string().max(120)).max(30),
}).strict().optional(),
workflowReceipt: workflowReceiptSchema.optional(),
}).strict();
export const chatSessionWriteSchema = z.object({
@@ -44,12 +44,7 @@ export type ChatSessionWrite = Readonly<{
suggestions?: readonly string[];
techniqueTruth?: string;
agentExecutionReceipt?: AgentExecutionReceipt;
workflowReceipt?: Readonly<{
route: string;
status: string;
preciseTiming: string;
missingLayers: readonly string[];
}>;
workflowReceipt?: WorkflowReceipt;
}>[];
session_type: "consultation" | "birth_time_rectification";
rectification_case_id: string | null;
+10 -3
View File
@@ -1,5 +1,5 @@
import { z } from "zod";
import { consultationDomainSchema } from "./consultation-domain-registry.ts";
import { consultationDomainSchema, type ConsultationDomain } from "./consultation-domain-registry.ts";
export const publicActivityPhaseSchema = z.enum([
"loading-method",
@@ -9,14 +9,21 @@ export const publicActivityPhaseSchema = z.enum([
]);
export type PublicActivityPhase = z.infer<typeof publicActivityPhaseSchema>;
export const workflowReceiptSchema = z.object({
export type WorkflowReceipt = Readonly<{
route: string;
status: string;
preciseTiming: string;
missingLayers: readonly string[];
domains?: readonly ConsultationDomain[];
}>;
export const workflowReceiptSchema: z.ZodType<WorkflowReceipt> = z.object({
route: z.string().max(120),
status: z.string().max(120),
preciseTiming: z.string().max(120),
missingLayers: z.array(z.string().max(120)).max(30),
domains: z.array(consultationDomainSchema).min(1).max(6).optional(),
}).strict();
export type WorkflowReceipt = z.infer<typeof workflowReceiptSchema>;
const executionStepSchema = z.object({
sequence: z.number().int().min(1).max(32),
@@ -34,7 +34,6 @@ export function applyBirthTimeModeToWorkflowContext<
...context.consumer_context,
answer_policy: {
...context.consumer_context.answer_policy,
can_answer_precise_timing: false,
birth_time_confidence: "unverified_reported_time",
candidate_is_confirmed: false,
},
+1 -1
View File
@@ -73,7 +73,7 @@ function deepFreeze<T>(value: T): DeepReadonly<T> {
}
function precisionBoundaryFor(mode: ConsultationBirthTimeMode) {
return mode === "verified_chart" ? "server_evidence_required" as const : "precise_timing_blocked" as const;
return mode === "general_no_birth_time" ? "precise_timing_blocked" as const : "server_evidence_required" as const;
}
export function validateConsultationPlan(
+10 -2
View File
@@ -21,13 +21,21 @@ test("chat session schema preserves the safe agent execution receipt", () => {
runtime: "mastra-agentic" as const,
skill: { name: "jyotish-vedic-astrology" as const, loaded: true },
steps: [{ sequence: 1, kind: "skill" as const, name: "jyotish-vedic-astrology", status: "completed" as const }],
workflow: { route: "career", status: "ready", preciseTiming: "blocked", missingLayers: [] },
workflow: { route: "multi-domain", status: "ready", preciseTiming: "allowed", missingLayers: [], domains: ["general", "timing"] },
techniqueTruth: "verified",
};
const workflowReceipt = {
route: "multi-domain",
status: "ready",
preciseTiming: "allowed",
missingLayers: [],
domains: ["general", "timing"] as const,
};
const parsed = chatSessionWriteSchema.parse({
...values,
messages: [{ role: "assistant", text: "回答", agentExecutionReceipt: receipt }],
messages: [{ role: "assistant", text: "回答", workflowReceipt, agentExecutionReceipt: receipt }],
});
assert.deepEqual(parsed.messages[0]?.workflowReceipt, workflowReceipt);
assert.deepEqual(parsed.messages[0]?.agentExecutionReceipt, receipt);
});
@@ -72,7 +72,7 @@ test("context-bound tool keeps legacy single theme compatibility and calculates
assert.deepEqual(captured, { ...serverChart.toolInput, entryMode: "direct_chart", question: "事业如何", theme: "career" });
assert.strictEqual(capturedPlan, plan);
assert.equal(state.consultationToolCallCount, 1);
assert.equal(state.workflowReceipt?.preciseTiming, "blocked");
assert.equal(state.workflowReceipt?.preciseTiming, "allowed");
assert.deepEqual(state.workflowReceipt?.domains, ["career"]);
assert.deepEqual((first as { domains?: string[] }).domains, ["career"]);
});
@@ -19,7 +19,7 @@ test("general-no-birth-time is an explicit server mode that never runs a chart w
assert.equal(shouldRunBirthChartWorkflow("verified_chart"), true);
});
test("unverified chart context can never become confirmed or retain precise timing permission", () => {
test("unverified chart context preserves evidence-owned precise timing permission while retaining provenance", () => {
const original = {
success: true,
consumer_context: {
@@ -35,21 +35,27 @@ test("unverified chart context can never become confirmed or retain precise timi
assert.equal(original.consumer_context.answer_policy.can_answer_precise_timing, true);
assert.deepEqual(guarded.consumer_context.answer_policy, {
can_answer_direction: true,
can_answer_precise_timing: false,
can_answer_precise_timing: true,
birth_time_confidence: "unverified_reported_time",
candidate_is_confirmed: false,
});
});
test("unverified answers keep the timing guard without a repetitive rectification warning", () => {
const transform = createBirthTimeModeOutputGuard("unverified_birth_time", false);
const first = transform("2026年8月适合观察方向。");
const second = transform("你一定会升职。");
test("unverified answers preserve exact dates when server evidence allows precise timing", () => {
const transform = createBirthTimeModeOutputGuard("unverified_birth_time", true);
const answer = transform("Rahu 大运为 2013年11月21日 至 2031年11月22日。");
assert.doesNotMatch(first, new RegExp(UNVERIFIED_BIRTH_TIME_NOTICE));
assert.match(first, /具体时间已省略/);
assert.doesNotMatch(second, /一定会升职/);
assert.doesNotMatch(second, new RegExp(UNVERIFIED_BIRTH_TIME_NOTICE));
assert.equal(answer, "Rahu 大运为 2013年11月21日 至 2031年11月22日。");
assert.doesNotMatch(answer, new RegExp(UNVERIFIED_BIRTH_TIME_NOTICE));
assert.doesNotMatch(answer, /具体时间已省略/);
});
test("evidence-blocked unverified answers still use the deterministic timing guard", () => {
const transform = createBirthTimeModeOutputGuard("unverified_birth_time", false);
const answer = transform("2026年8月适合观察方向。");
assert.match(answer, /具体时间已省略/);
assert.doesNotMatch(answer, new RegExp(UNVERIFIED_BIRTH_TIME_NOTICE));
});
test("general mode deterministically rejects personal chart claims while preserving general knowledge", () => {
+3 -3
View File
@@ -35,12 +35,12 @@ test("rejects a theme outside the consultation allowlist", () => {
);
});
test("server birth-time mode fixes the precision boundary", () => {
test("reported concrete birth time uses the same evidence-owned precision boundary as a verified chart", () => {
assert.equal(createConsultationPlan({
userIntent: "未来什么时候适合行动",
theme: "timing",
consultationMode: "unverified_birth_time",
}).precisionBoundary, "precise_timing_blocked");
}).precisionBoundary, "server_evidence_required");
assert.equal(createConsultationPlan({
userIntent: "什么是上升星座",
theme: "general",
@@ -55,7 +55,7 @@ test("rejects mode or precision tampering", () => {
consultationMode: "unverified_birth_time",
});
assert.throws(
() => validateConsultationPlan({ ...plan, precisionBoundary: "server_evidence_required" }, {
() => validateConsultationPlan({ ...plan, precisionBoundary: "precise_timing_blocked" }, {
consultationMode: "unverified_birth_time",
modelCreditCost: 1,
}),
@@ -25,7 +25,7 @@ test("timing questions use a legal report theme and preserve a timing route hint
assert.ok(request.requiredLayers.includes("negative holdout gate"));
assert.equal(request.claimBoundary, "candidate_day_month_window_only_until_holdout_passes");
assert.equal(request.plan_version, "consultation-plan-v2");
assert.equal(request.precision_boundary, "precise_timing_blocked");
assert.equal(request.precision_boundary, "server_evidence_required");
assert.equal(request.timing_horizon, "next_12_months");
});
@@ -98,7 +98,7 @@ test("timing input projects only legal private workflow fields", async () => {
assert.equal(body.strict_workflow_route, "timing");
assert.ok(body.required_layers.includes("negative holdout gate"));
assert.equal(body.claim_boundary, "candidate_day_month_window_only_until_holdout_passes");
assert.equal(body.precision_boundary, "precise_timing_blocked");
assert.equal(body.precision_boundary, "server_evidence_required");
assert.deepEqual(body.requested_domains, ["timing"]);
assert.deepEqual(body.required_evidence_categories, ["natal_foundation", "timing", "validation"]);
assert.equal(input.question, "未来哪些阶段值得把握?");