fix(rectification): open cases for uncertain birth times
Independent Staging Quality Gate / validate (push) Successful in 12m32s
Independent Staging Quality Gate / publish (push) Successful in 9m9s

This commit is contained in:
Jesse_Chen
2026-08-15 20:24:43 +08:00
parent 995da2d4b4
commit 5b023e9480
3 changed files with 138 additions and 14 deletions
+15
View File
@@ -3333,3 +3333,18 @@
- 防复发:资料声明完整性和精确分钟校正可启动性必须是两个独立条件;任何 fresh Case 前置条件调整不得删除 period/unknown 资料入口。UI 合同测试必须正向断言两个一级选择、时段选择和跳过路径存在,禁止再用负向断言把产品能力删除写成门禁。
- 相关记录:BUG-127、BUG-196
- 修复版本:本次 staging 修复提交(精确 SHA 以远端分支核验结果为准)
## BUG-198 | 不确定或未知出生时间被错误拒绝创建生时校正 Case
- 状态:resolved(本地候选,待 staging 发布与登录态业务验收)
- 首次发现:2026-08-15
- 最近更新:2026-08-15
- 影响面:`/api/rectification/cases/open`、首页和新建生时校正入口、只有大致时段或完全未知出生时间的用户。
- 用户现象:用户已选择“上午/下午/晚上/深夜”等出生时段,或明确选择“完全不清楚”,资料保存成功,但开始生时校正时仍返回 HTTP 422 `profile_incomplete`,无法进入 Case。
- 触发条件:Profile 的 `reported_birth_time` 为空,且 `birth_time_source``period_only``unknown` 时,以 `homepage/new` intent 创建 fresh Case。
- 根因:BUG-196 将“没有具体分钟不能直接使用 ±15 分钟精细扫描”错误实现为“没有具体分钟不能创建 Case”;`case-service.ts` 的 fresh 候选范围只接受 `reported_birth_time`,没有恢复资料模型已经支持的时段范围和全天范围。对应测试也把该错误边界锁定为预期行为。
- 修复:继续保持 fresh Case 不继承历史 `active_birth_time` 和 uncertainty;有合法 `reported_birth_time` 时仍使用服务器控制的前后 15 分钟范围。`period_only` 改为使用用户已选择的服务器映射时段,包含 `late_night` 的跨午夜 `23:0003:59``unknown` 使用 `00:0023:59`。缺少出生日期、地点、时区,或选择 `period_only` 却没有合法时段等真正不完整组合,仍在调用创建 RPC 前 fail closed。
- 验证:回归测试先证明旧实现对 period/unknown 稳定抛出 `profile_incomplete`,修复后锁定 morning `08:0011:59`、late-night `23:0003:59`、unknown `00:0023:59` 均能传入 `open_agentic_rectification_case_v2`;非法缺时段/缺具体时间组合仍不调用 RPC。既有引擎范围判断支持跨午夜,工具合同已覆盖全天宽范围不伪造中午分钟。
- 防复发:资料完整性、Case 可创建性和是否可以立即执行分钟级扫描必须分层;宽范围应先通过事件问题逐步缩小,不得以 `profile_incomplete` 阻止用户进入,也不得生成虚假具体出生时间。
- 相关记录:BUG-127、BUG-196、BUG-197
- 修复版本:本次 staging 修复提交(精确 SHA 以远端分支与 staging health 验收结果为准)
@@ -108,16 +108,45 @@ function shiftedTime(time: string, offsetMinutes: number): string {
return `${String(Math.floor(normalized / 60)).padStart(2, "0")}:${String(normalized % 60).padStart(2, "0")}`;
}
// This is an engine execution boundary, not a user-declared uncertainty. Fresh
// Cases always start from reported_birth_time with enough room to rectify.
// This is an engine execution boundary, not a user-declared uncertainty.
// Exact-time Cases receive a movable search radius; imprecise declarations keep
// the honest server-owned range instead of inventing a baseline minute.
const FRESH_CASE_SEARCH_RADIUS_MINUTES = 15;
const PERIOD_CANDIDATE_RANGES = {
early_morning: { start_time: "04:00", end_time: "07:59" },
morning: { start_time: "08:00", end_time: "11:59" },
afternoon: { start_time: "12:00", end_time: "17:59" },
evening: { start_time: "18:00", end_time: "22:59" },
late_night: { start_time: "23:00", end_time: "03:59" },
} as const;
function deriveCandidateRange(reportedTime: string | null): { start_time: string; end_time: string } {
if (!reportedTime) throw new RectificationCaseServiceError("profile_incomplete");
return {
start_time: shiftedTime(reportedTime, -FRESH_CASE_SEARCH_RADIUS_MINUTES),
end_time: shiftedTime(reportedTime, FRESH_CASE_SEARCH_RADIUS_MINUTES),
};
function deriveCandidateRange(input: {
reportedTime: string | null;
source: string;
period: string | null;
}): { start_time: string; end_time: string } {
if (input.reportedTime) {
return {
start_time: shiftedTime(input.reportedTime, -FRESH_CASE_SEARCH_RADIUS_MINUTES),
end_time: shiftedTime(input.reportedTime, FRESH_CASE_SEARCH_RADIUS_MINUTES),
};
}
if (input.source === "period_only" || input.source === "legacy_import") {
const periodRange = input.period && Object.hasOwn(PERIOD_CANDIDATE_RANGES, input.period)
? PERIOD_CANDIDATE_RANGES[input.period as keyof typeof PERIOD_CANDIDATE_RANGES]
: null;
if (periodRange) return periodRange;
if (input.source === "period_only") {
throw new RectificationCaseServiceError("profile_incomplete");
}
}
if (input.source === "unknown" || input.source === "legacy_import") {
return { start_time: "00:00", end_time: "23:59" };
}
throw new RectificationCaseServiceError("profile_incomplete");
}
function baselineFingerprint(baseline: V9BaselineSnapshot): string {
@@ -187,7 +216,7 @@ export async function loadV9RectificationProfile(
userId,
baseline,
baselineFingerprint: baselineFingerprint(baseline),
candidateRange: deriveCandidateRange(reportedTime),
candidateRange: deriveCandidateRange({ reportedTime, source, period }),
};
}
@@ -123,11 +123,91 @@ test("loadV9RectificationProfile rejects incomplete profiles without creating a
}
});
test("homepage and new fail closed when a fresh profile has no reported time", async () => {
test("period-only profiles open with their selected server-owned range", async () => {
const cases = [
{ intent: "homepage", source: "period_only", period: "morning" },
{ intent: "new", source: "unknown", period: null },
{ intent: "homepage", source: "legacy_import", period: "evening" },
{ intent: "homepage", period: "morning", range: { start_time: "08:00", end_time: "11:59" } },
{ intent: "new", period: "late_night", range: { start_time: "23:00", end_time: "03:59" } },
] as const;
for (const item of cases) {
const capturedArgs: { value: Record<string, unknown> | null } = { value: null };
const accounting = fakeAccounting({
profile: {
...completeProfile,
reported_birth_time: null,
birth_time_source: "period_only",
birth_time_period: item.period,
},
rpc: async (_fn, args) => {
capturedArgs.value = args;
return {
data: {
disposition: "created",
case_id: caseId,
session_id: sessionId,
status: "draft",
should_start_opening: true,
skill_version: "9.0.0",
},
error: null,
};
},
});
const response = await openRectificationCase(accounting, "user-1", {
intent: item.intent,
requestId,
});
assert.equal(response.disposition, "created");
assert.deepEqual(capturedArgs.value?.p_candidate_range, item.range);
assert.equal(
(capturedArgs.value?.p_baseline_birth_snapshot as Record<string, unknown>).reported_birth_time,
null,
);
}
});
test("unknown-time profiles open with a full-day server-owned range", async () => {
const capturedArgs: { value: Record<string, unknown> | null } = { value: null };
const accounting = fakeAccounting({
profile: {
...completeProfile,
reported_birth_time: null,
birth_time_source: "unknown",
birth_time_period: null,
},
rpc: async (_fn, args) => {
capturedArgs.value = args;
return {
data: {
disposition: "created",
case_id: caseId,
session_id: sessionId,
status: "draft",
should_start_opening: true,
skill_version: "9.0.0",
},
error: null,
};
},
});
const response = await openRectificationCase(accounting, "user-1", {
intent: "homepage",
requestId,
});
assert.equal(response.disposition, "created");
assert.deepEqual(capturedArgs.value?.p_candidate_range, { start_time: "00:00", end_time: "23:59" });
});
test("fresh profiles still fail closed when their birth-time declaration is incomplete", async () => {
const cases = [
{ source: "period_only", period: null },
{ source: "period_only", period: "not_a_period" },
{ source: "family_exact", period: null },
{ source: "approximate", period: null },
] as const;
for (const item of cases) {
@@ -146,7 +226,7 @@ test("homepage and new fail closed when a fresh profile has no reported time", a
});
await assert.rejects(
() => openRectificationCase(accounting, "user-1", { intent: item.intent, requestId }),
() => openRectificationCase(accounting, "user-1", { intent: "homepage", requestId }),
(error: unknown) =>
error instanceof RectificationCaseServiceError && error.code === "profile_incomplete",
);