diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 39ef625d..82a74fe5 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -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:00–03:59`;`unknown` 使用 `00:00–23:59`。缺少出生日期、地点、时区,或选择 `period_only` 却没有合法时段等真正不完整组合,仍在调用创建 RPC 前 fail closed。 +- 验证:回归测试先证明旧实现对 period/unknown 稳定抛出 `profile_incomplete`,修复后锁定 morning `08:00–11:59`、late-night `23:00–03:59`、unknown `00:00–23:59` 均能传入 `open_agentic_rectification_case_v2`;非法缺时段/缺具体时间组合仍不调用 RPC。既有引擎范围判断支持跨午夜,工具合同已覆盖全天宽范围不伪造中午分钟。 +- 防复发:资料完整性、Case 可创建性和是否可以立即执行分钟级扫描必须分层;宽范围应先通过事件问题逐步缩小,不得以 `profile_incomplete` 阻止用户进入,也不得生成虚假具体出生时间。 +- 相关记录:BUG-127、BUG-196、BUG-197 +- 修复版本:本次 staging 修复提交(精确 SHA 以远端分支与 staging health 验收结果为准) diff --git a/frontend/src/lib/rectification-agentic/v9/case-service.ts b/frontend/src/lib/rectification-agentic/v9/case-service.ts index 0b5eb9e8..d56e5b18 100644 --- a/frontend/src/lib/rectification-agentic/v9/case-service.ts +++ b/frontend/src/lib/rectification-agentic/v9/case-service.ts @@ -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 }), }; } diff --git a/frontend/tests/rectification-v9-case-service.test.ts b/frontend/tests/rectification-v9-case-service.test.ts index adbe064d..821b7053 100644 --- a/frontend/tests/rectification-v9-case-service.test.ts +++ b/frontend/tests/rectification-v9-case-service.test.ts @@ -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 | 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).reported_birth_time, + null, + ); + } +}); + +test("unknown-time profiles open with a full-day server-owned range", async () => { + const capturedArgs: { value: Record | 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", );