From 21742e2d3789294bcae71557abe382e89ffeeb0a Mon Sep 17 00:00:00 2001 From: jesse-ux Date: Sat, 14 Nov 2026 12:43:58 +0800 Subject: [PATCH] fix: make questionnaire comparison reversible and provisional --- CHANGELOG.md | 7 +- docs/BUG_HISTORY.md | 1 + docs/tasks/PROGRESS-rect-adaptive-20261114.md | 15 +++ docs/tasks/rect-adaptive-replay.md | 2 + frontend/DESIGN.md | 4 +- frontend/docs/VOICE.md | 5 + .../src/app/api/chart-choice/adopt/route.ts | 14 ++- .../chart-choice/chart-choice-page.tsx | 67 +++++++---- frontend/src/lib/chart-choice/contract.ts | 2 +- frontend/src/lib/chart-choice/server.ts | 68 +++++++++++- .../src/lib/personal-report-longform-birth.ts | 7 +- ...chart-choice-rounds-page-20261113.test.tsx | 105 ++++++++++++++++-- ...sonal-report-birth-window-20261113.test.ts | 14 +++ 13 files changed, 262 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9dbc5e99..a594862f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,8 @@ # 印度占星 Skill 更新日志 -## 2026-11-14 — 生时纠正只提供包含参照时刻的收窄建议 +## 2026-11-14 — 生时纠正的答题区间可比较、可撤销 -- 问卷结果只在答题段包含填写时刻或范围中点时展示答题提示与匹配依据,该段更窄时可以选择收窄。沿用当前 Skill 版本(BUG-1886)。 +- 问卷结果说明现有信息不足以确认更准确的出生时间。较窄的答题段包含参照时刻时可以选来比较,首页可一键改回原填写时间或范围。沿用当前 Skill 版本(BUG-1886)。 ## 2026-11-14 — 年运附录增加 KP 宫头和子主 @@ -24,8 +24,7 @@ ## 2026-11-14 — 生时纠正按回答调整下一题 - 每次作答后缩小候选时段,按仍能区分的生活领域选下一题。年龄选项附年份,进度显示已确认的盘数。 -- 当前记录以代表时刻和范围为主,结果保留对得上的事件和把握分级。占星 Skill 版本不变。 -- 默认保留填写时间与答题前的范围。答题区间只作参考,点「收窄到」或「改用」才会采用。 +- 当前记录显示代表时刻和范围,问卷结果保留用户提供的事件。占星 Skill 版本不变。 ## 2026-11-13 — 年运报告可导出 Markdown diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index e04b65ab..1860a24e 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -25473,5 +25473,6 @@ - 根因:FIX-4 把显式采纳作为保护措施,未按答题段是否包含参照时刻限制建议。同宽度回放中,不含填写时刻的问卷段仅命中 7 / 33。 - 修复:范围带显示原范围与参照时刻。答题段包含参照时刻时展示答题提示、区间高亮和匹配依据。答题段同时更窄时提供收窄。 - 验证及防复发:真实引擎夹具覆盖填写钟点和范围中点的两种关系。390 像素 Chromium 验证默认保存及收窄提交。见 [#260 进度](tasks/PROGRESS-rect-adaptive-20261114.md)。 +- 11-14 占星师裁定:问卷结果固定说明无法确认更准确的出生时间。首页和问卷结果隐藏把握分级与吻合判断,事件按日期及类别展示。符合原筛选条件的区间用于比较,可从首页恢复到填写时间或原范围。长报告回归验证任意宽度的比较区间都保持 `reading_minute_confirmed=false` 和 `birth_time_accuracy=provisional`。 - 关联记录:#260、BUG-1881。 - 修复版本:`codex/rect-adaptive-20261114`,待独立验收。 diff --git a/docs/tasks/PROGRESS-rect-adaptive-20261114.md b/docs/tasks/PROGRESS-rect-adaptive-20261114.md index d6a4406e..44dec61b 100644 --- a/docs/tasks/PROGRESS-rect-adaptive-20261114.md +++ b/docs/tasks/PROGRESS-rect-adaptive-20261114.md @@ -49,3 +49,18 @@ | --- | --- | --- | | 答题段排除填写时刻时显示「你的经历更像」和「改用」 | 隐藏答题提示、候选带高亮与匹配依据。操作区只有默认保存 | FIX-5 根据回放限制收窄建议 | | 答题段包含填写时刻时显示原提示和「收窄到」 | 保留两项,并校验时间带及匹配依据。增加范围中点内外两组引擎响应 | 验证缺钟点时采用范围中点的同一规则 | + +## FIX-6 返工(BUG-1886) + +- 基于返工任务书提交 `474caca7`,沿用 PR #265。问卷完成后显示固定说明和用户提供的事件。只有包含参照时刻且比原范围窄的答题段提供比较入口。比较态的首页显示原始申报仍在,并提供一键恢复。 +- 恢复只针对当前仍可复用的比较记录。服务端用原申报重新计算默认时刻和六盘,再经现有保存流程写入 `reported_time_default` 或 `window_midpoint_default`。选用比较范围时保留原申报的范围和来源。无钟点时使用申报范围的中点,跨两小时的范围由引擎计算完整范围的默认盘。比较来源在长报告中始终保持 provisional。 +- Chromium 390 像素用合成资料核对四个页面状态,并实际点击保存比较和恢复按钮。真实引擎独立验证了钟点、区间中点和接近全天的范围恢复,三种都返回原区间和六盘。 +- Python 选盘及隐私回归共 126 项通过。前端定向的两种恢复来源和过期记录拒绝均有覆盖。 +- 前端定向 37 项通过、3 项跳过。TypeScript 和生产构建通过,ESLint 0 错误、63 条既有警告。首页保持静态,首载 gzip JS 519350 字节、CSS 93367 字节,与 staging 相同。 +- 前端全量 3742 项,3659 通过、46 失败、37 跳过、0 取消。对照同机无 Docker 配置下的 staging,失败名称完全一致,新增 3 项。保留 FIX-5 曾改名的比较条件测试。 +- 预检的远端可见性、适配器和碎片扫描通过。定向套件遇到台账 #220 / #237 已记录的历史镜像路径断言。 + +| 既有断言原值 | 新值 | 原因 | +| --- | --- | --- | +| 问卷结果不出现「准确」;包含参照时刻时写答题吻合、大运吻合及「收窄到」 | 验证任务书固定句、事件列表、比较按钮和说明。排除准确率、把握及吻合判断,并补原宽区间不显示比较按钮的断言 | FIX-6 将问卷输出作为比较范围 | +| 首页默认态显示代表时刻、范围和未定分盘 | 保留这些断言,补把握分级和恢复入口只在相应状态显示的断言 | 区分默认保存与可撤销的比较状态 | diff --git a/docs/tasks/rect-adaptive-replay.md b/docs/tasks/rect-adaptive-replay.md index 3c7c0375..464f05cc 100644 --- a/docs/tasks/rect-adaptive-replay.md +++ b/docs/tasks/rect-adaptive-replay.md @@ -22,6 +22,8 @@ 两组均为倾向 77 / 77,档内命中 45 / 77。高、中、低各 0 例,无法估计这些档的命中率。启用健康题时,本次回放的选中率和平均区间宽度没有变化。 +low/medium/high 是旧算法内部标签,不代表准确率(占星师 11-14)。 + 两种模式的偏差分组相同。 | 填写偏差(分钟) | 案例数 | 问卷命中 | 同宽随机期望 | 同宽填写时间居中 | diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 3492d303..79283868 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -357,8 +357,8 @@ The self-profile editor in 星盘资料 (`ChartLibraryPanel`) owns the ayanamsa ### Rectification surface - **按盘选盘(#239,BUG-1873):** 范围、答题、结果和当前记录用平面布局,靠留白和细线分组。答题用 36px 时间带、22px 衬线标题和浅底选项,选项内的 8px 小带表示覆盖范围。事件选项原地展开年份和类别,展开 0.32 秒,遵循系统的减少动态效果设置。结果突出候选段,六盘用细带展示段内换座,并列出证据与冲突。首页保留星盘、重做和按未定盘续问。 -- **自适应生时纠正(#260,BUG-1881):** 题目随候选范围变化。细进度条对应已确认盘数,年龄旁附公历年份。选项以同色底、1.5px 内描边和强调色文字及细带表示选中。事件类别用细边框与实心选中态,年份加减器保持透明底,展开仍为 0.32 秒。深色模式的选项和细带使用反转表面 token。刻度标签接近时移到另一侧,腾出靠近的端点。当前记录用代表时刻作标题,紧随范围与把握分级。结果仅列出匹配的事件依据。 -- **#260 返工:** 结果首屏标题是「按你填的 HH:MM」或「按范围中点 HH:MM」。范围带显示原范围和参照时刻。答题段包含参照时刻时,显示答题提示和匹配依据,在时间带中标出答题段。该段比原范围窄时,全宽「用 HH:MM」主按钮下出现描边「收窄到」按钮。六盘按主按钮时刻显示,跨换座线的盘标「还没定」和换座时刻。保存后首页先显示采用时刻,再显示保存范围与六盘状态。 +- **自适应生时纠正(#260,BUG-1881):** 题目随候选范围变化。细进度条对应已确认盘数,年龄旁附公历年份。选项以同色底、1.5px 内描边和强调色文字及细带表示选中。事件类别用细边框与实心选中态,年份加减器保持透明底,展开仍为 0.32 秒。深色模式的选项和细带使用反转表面 token。刻度标签接近时移到另一侧,腾出靠近的端点。当前记录用代表时刻作标题,紧随范围和六盘状态。 +- **问卷结果(#260,BUG-1886):** 标题下依次显示原范围和「现有信息不足以确认更准确的出生时间。」答题段包含参照时刻时在时间带标出。该段更窄时,「用 HH:MM」主按钮下面显示描边「按 X 这段比较」和用途说明。事件列表只列用户提供的事件。比较态的首页显示「你选的比较范围」、原填写时间或范围,以及描边恢复按钮。 The birth-time rectification session is the consultation transcript plus a house board; questions live inside the assistant message that asked them. It shares every waiting vocabulary with the consultation surface; nothing here spins or says "loading" after the reveal. diff --git a/frontend/docs/VOICE.md b/frontend/docs/VOICE.md index 3d4adbc6..14c86520 100644 --- a/frontend/docs/VOICE.md +++ b/frontend/docs/VOICE.md @@ -606,3 +606,8 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 星盘档案里问别人和你的关系,标题只用「TA 是你的」。选项只有:伴侣、孩子、父母、朋友、其他。不写「客户」「家人」来代替这五项。没选过的,名字后面的小标签写「其他」。本人的标签仍是「本人」,不问这一句。 +## 生时纠正问卷(#260) + +- 结果页在范围后写「现有信息不足以确认更准确的出生时间。」可比较时用「按 X 这段比较」,下方写「只用于比较,不代表真实时间已被证实,随时可以改回。」事件列表只写日期和类别。 +- 首页比较态写「你选的比较范围」和「你填的 HH:MM 没有改」,按钮「改回填的时间」。只填过范围时写「你填的范围没有改」,按钮「改回原范围」。 + diff --git a/frontend/src/app/api/chart-choice/adopt/route.ts b/frontend/src/app/api/chart-choice/adopt/route.ts index 44044c61..e2076e27 100644 --- a/frontend/src/app/api/chart-choice/adopt/route.ts +++ b/frontend/src/app/api/chart-choice/adopt/route.ts @@ -2,13 +2,13 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { chartAnswersSchema, chartChoiceCaller, chartChoiceErrorResponse, evidenceInput, resolvedSchema, windowSchema } from "@/lib/chart-choice/route-support"; -import { adoptCertainTime, adoptChartChoice, previewAdaptiveChartChoice, previewChartChoice } from "@/lib/chart-choice/server"; +import { adoptCertainTime, adoptChartChoice, previewAdaptiveChartChoice, previewChartChoice, restoreChartChoiceDefault } from "@/lib/chart-choice/server"; import { createAdminSupabaseClient } from "@/lib/supabase/admin"; export const runtime = "nodejs"; const side = z.enum(["before", "after"]); -const bodySchema = z.object({ +const choiceSchema = z.object({ subjectId: z.string().max(64).optional(), window: windowSchema.optional(), resolved: resolvedSchema.optional(), @@ -25,6 +25,11 @@ const bodySchema = z.object({ rangeChosen: z.boolean().optional(), ...evidenceInput, }).strict().refine((body) => !body.useAnswerWindow || (body.adaptive && body.mode === "choice")); +const bodySchema = z.union([choiceSchema, z.object({ + mode: z.literal("restore_default"), + subjectId: z.string().max(64).optional(), + recordId: z.string().uuid(), +}).strict()]); /** * POST /api/chart-choice/adopt — preview (mode "preview") or save the chosen chart. The server @@ -36,6 +41,11 @@ export async function POST(request: Request) { const parsed = bodySchema.safeParse(await request.json().catch(() => null)); if (!parsed.success) return NextResponse.json({ error: "请求格式不对", code: "bad_request" }, { status: 400 }); try { + if (parsed.data.mode === "restore_default") { + return NextResponse.json(await restoreChartChoiceDefault( + caller.supabase, createAdminSupabaseClient(), caller.userId, parsed.data, + )); + } if (parsed.data.mode === "preview") { if (parsed.data.adaptive) { return NextResponse.json(await previewAdaptiveChartChoice(caller.supabase, caller.userId, parsed.data)); diff --git a/frontend/src/components/chart-choice/chart-choice-page.tsx b/frontend/src/components/chart-choice/chart-choice-page.tsx index 354c8f7c..cc3ed3ac 100644 --- a/frontend/src/components/chart-choice/chart-choice-page.tsx +++ b/frontend/src/components/chart-choice/chart-choice-page.tsx @@ -213,8 +213,8 @@ export function ChartChoiceFlow({ subjectId }: { readonly subjectId: string }) { void loadPacket({}); }, [loadPacket]); - const afterSave = useCallback((time: string) => { - notifySuccess(`已保存,之后按 ${time} 解读`); + const afterSave = useCallback((time: string, comparisonOnly = false) => { + notifySuccess(comparisonOnly ? "已保存你选的比较范围" : `已保存,之后按 ${time} 解读`); setStep({ kind: "loading" }); reloadStart(); }, [reloadStart]); @@ -301,7 +301,7 @@ export function ChartChoiceFlow({ subjectId }: { readonly subjectId: string }) { ...final.body, mode: "choice", ...(rangeChosen ? { rangeChosen: true } : {}), ...(useAnswerWindow ? { useAnswerWindow } : {}), }); - afterSave(clockLabel(decision.saved_at, final.packet.reported_at, !decision.saved_at.endsWith(":00") && decision.saved_at.length === 19)); + afterSave(clockLabel(decision.saved_at, final.packet.reported_at, !decision.saved_at.endsWith(":00") && decision.saved_at.length === 19), useAnswerWindow); } catch (error) { setStep({ kind: "failed", message: (error as Error).message, retry: { kind: "back", step: final } }); } finally { @@ -309,6 +309,18 @@ export function ChartChoiceFlow({ subjectId }: { readonly subjectId: string }) { } } + async function restoreDefault(recordId: string) { + setPending(true); + try { + const { saved_at } = await postJson<{ saved_at: string }>("/api/chart-choice/adopt", { subjectId, recordId, mode: "restore_default" }); + afterSave(clockLabel(saved_at)); + } catch (error) { + setStep({ kind: "failed", message: (error as Error).message, retry: { kind: "start" } }); + } finally { + setPending(false); + } + } + async function finishQuestions( currentStep: Extract, submitted: ChartChoiceChartAnswers, @@ -356,7 +368,8 @@ export function ChartChoiceFlow({ subjectId }: { readonly subjectId: string }) { ); case "home": - return ; + return void restoreDefault(id)} />; case "narrow": return ( @@ -965,10 +978,6 @@ function matchedEvidence(decision: FinalDecision): string | null { return `${labels.join("、")},和这段时间的大运对得上${count < 3 ? ",先作参考" : ""}。`; } -const CONFIDENCE_LABEL: Readonly> = { - high: "把握 高", medium: "把握 中", low: "把握 低", tendency_only: "倾向", -}; - export function FinalStep({ packet, decision, pending, onBack, onAccept, onUseAnswerWindow }: { readonly packet: FinalPacket; readonly decision: FinalDecision; @@ -977,7 +986,6 @@ export function FinalStep({ packet, decision, pending, onBack, onAccept, onUseAn readonly onAccept: () => void; readonly onUseAnswerWindow?: () => void; }) { - const evidence = matchedEvidence(decision); const seconds = decision.saved_at.length === 19 && !decision.saved_at.endsWith(":00"); const saved = clockLabel(decision.saved_at, packet.reported_at, seconds); if (packet.status === "questionnaire") { @@ -993,31 +1001,36 @@ export function FinalStep({ packet, decision, pending, onBack, onAccept, onUseAn const canNarrow = containsReference && ends - starts < defaultWidth; const clock = clockLabel(reference); const range = segmentLabel(decision.segment, reference); - const advisory = hasReportedTime ? "你的回答和填的时间对得上。" : "你的回答落在范围中间这段。"; + const events = decision.events ?? []; return
{!pending ? 改答案 : null}

{hasReportedTime ? `按你填的 ${clock}` : `按范围中点 ${clock}`}

范围 {segmentLabel(defaultWindow, reference)}

- {containsReference ? <> -

{advisory}

- - : } +

现有信息不足以确认更准确的出生时间。

+ {containsReference + ? + : }
{reported ?

按 {clock} 看六张盘

: null} - {containsReference && evidence ?

{evidence}

: null} + {events.length ?
+

你提到的事

+ +
: null}
用 {clock} - {canNarrow && onUseAnswerWindow ? - 收窄到 {range} - : null} + {canNarrow && onUseAnswerWindow ? <> + 按 {range} 这段比较 +

只用于比较,不代表真实时间已被证实,随时可以改回。

+ : null}
; } + const evidence = matchedEvidence(decision); return <> {!pending ? 改答案 : null} @@ -1064,19 +1077,22 @@ function ReadingPlan({ ); } -export function HomeView({ history, onStartOver }: { +export function HomeView({ history, onStartOver, reportedAt, pending, onRestoreDefault }: { readonly history: ChartChoiceHistory; readonly onStartOver: () => void; + readonly reportedAt: string | null; + readonly pending: boolean; + readonly onRestoreDefault: (recordId: string) => void; }) { const current = history.records.find((record) => record.id === history.current_record_id) ?? null; const decision = current?.detail.decision; - const confidence = CONFIDENCE_LABEL[decision?.evidence_summary?.confidence ?? ""]; + const comparisonOnly = current?.detail.provenance?.source === "user_choice_from_answers"; return (
{current ? (
-

当前使用

+

{comparisonOnly ? "你选的比较范围" : "当前使用"}

{savedAtLabel(current.created_at)} 保存

{clockLabel( @@ -1084,7 +1100,7 @@ export function HomeView({ history, onStartOver }: { )}

{decision?.segment ? <>

范围 {segmentLabel(decision.segment)}

- {confidence ?

{confidence}

: null} + {comparisonOnly ?

{reportedAt ? `你填的 ${clockLabel(reportedAt)} 没有改` : "你填的范围没有改"}

: null} @@ -1105,8 +1121,13 @@ export function HomeView({ history, onStartOver }: { }
看星盘 - 重新纠正 + 重新纠正
+ {comparisonOnly && current.reusable ?
+ onRestoreDefault(current.id)}> + {reportedAt ? "改回填的时间" : "改回原范围"} + +
: null}
) : (
开始纠正
diff --git a/frontend/src/lib/chart-choice/contract.ts b/frontend/src/lib/chart-choice/contract.ts index a85d6d26..fdac9113 100644 --- a/frontend/src/lib/chart-choice/contract.ts +++ b/frontend/src/lib/chart-choice/contract.ts @@ -251,7 +251,7 @@ export type ChartChoiceRecordDetail = { readonly saved_at: string; } & Partial & ChartChoiceEvidence; /** The adoption data saved by the history RPC. */ - readonly provenance?: { readonly contract?: string; readonly segment?: ChartChoiceWindow }; + readonly provenance?: { readonly contract?: string; readonly segment?: ChartChoiceWindow; readonly source?: string }; /** Labels of the flipped charts with both sides' ascendant, in comparison order. */ readonly charts: readonly { readonly chart: string; readonly label: string; readonly domain: string }[]; }; diff --git a/frontend/src/lib/chart-choice/server.ts b/frontend/src/lib/chart-choice/server.ts index 9a44a966..6aa8fdf6 100644 --- a/frontend/src/lib/chart-choice/server.ts +++ b/frontend/src/lib/chart-choice/server.ts @@ -28,6 +28,7 @@ import { declaredNeedsRange } from "./declared.ts"; const apiBase = () => process.env.JYOTISH_API_BASE?.trim() || "http://127.0.0.1:5200"; const ENGINE_TIMEOUT_MS = 8000; +const RESTORE_WIDE_RANGE_TIMEOUT_MS = 20_000; const ROUNDS_VERSION = "chart-choice-v2"; const MAX_RECORD_SPAN_MINUTES = Math.max(...RECORD_RANGE_CHOICES); @@ -73,7 +74,13 @@ async function loadSubject(supabase: unknown, userId: string, subjectId: unknown throw new ChartChoiceError("subject_not_found", 404); } const row = declarationRow(resolved.row); - const prepared = await prepareChartViewProfile({ row }); + const declared = lagnaPrecheckWindow({ row }); + // Date and location still come from the declaration. For a range-only + // declaration the chart profile needs a calculation clock to resolve them. + const midpoint = declared && !reportedAt(declared) ? midpointAt(declared) : null; + const prepared = await prepareChartViewProfile({ + row: midpoint ? { ...row, birth_date: midpoint.slice(0, 10), reported_birth_time: midpoint.slice(11, 16) } : row, + }); if (!prepared.ok) throw new ChartChoiceError("profile_incomplete", 409); return { subjectId: resolved.subjectId, @@ -84,7 +91,7 @@ async function loadSubject(supabase: unknown, userId: string, subjectId: unknown time: resolved.row.active_birth_time, provenance: resolved.row.active_birth_provenance, }, - declared: lagnaPrecheckWindow({ row }), + declared, location: { lat: prepared.profile.latitude, lon: prepared.profile.longitude, @@ -98,6 +105,11 @@ function minutesOf(at: string): number { return Date.parse(`${at}:00Z`) / 60_000; } +function midpointAt(window: ChartChoiceWindow): string { + const minute = Math.floor((minutesOf(window.start_at) + minutesOf(window.end_at)) / 2); + return new Date(minute * 60_000).toISOString().slice(0, 16); +} + const AT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/; function reportedAt(declared: LagnaPrecheckWindow): string | null { @@ -135,13 +147,13 @@ function scanWindow(subject: Subject, requested: ChartChoiceWindow | undefined): return requested; } -async function engine(path: string, body: Record, fetchImpl: typeof fetch = fetch): Promise { +async function engine(path: string, body: Record, fetchImpl: typeof fetch = fetch, timeoutMs = ENGINE_TIMEOUT_MS): Promise { let response: Response; try { response = await fetchImpl(`${apiBase()}${path}`, { method: "POST", headers: { "Content-Type": "application/json" }, - signal: AbortSignal.timeout(ENGINE_TIMEOUT_MS), + signal: AbortSignal.timeout(timeoutMs), body: JSON.stringify(body), }); } catch { @@ -464,6 +476,52 @@ export async function adoptChartChoice( return saveChartChoice(admin, userId, request, await decideChartChoice(supabase, userId, request, fetchImpl, admin)); } +/** Restore the default for the current comparison using the declaration and no questionnaire answers. */ +export async function restoreChartChoiceDefault( + supabase: unknown, + admin: RpcClient, + userId: string, + request: { subjectId?: string; recordId: string }, + fetchImpl?: typeof fetch, +): Promise<{ saved_at: string }> { + const subject = await loadSubject(supabase, userId, request.subjectId); + const history = await readHistory(admin, userId, subject.subjectId); + const current = history.records.find((record) => record.id === history.current_record_id); + if (current?.id !== request.recordId || !current.reusable + || current.detail.provenance?.source !== "user_choice_from_answers") { + throw new ChartChoiceError("record_stale", 409); + } + const declared = scanWindow(subject, undefined); + const at = subject.declared && reportedAt(subject.declared); + const start = minutesOf(declared.start_at); + const end = minutesOf(declared.end_at); + let window = declared; + let reference = at; + if (end - start > MAX_RECORD_SPAN_MINUTES * 2) { + // The engine accepts a short scan plus the full range for its default projection. + reference ??= midpointAt(declared); + const around = windowAround(reference, MAX_RECORD_SPAN_MINUTES); + window = { start_at: around.start_at > declared.start_at ? around.start_at : declared.start_at, + end_at: around.end_at < declared.end_at ? around.end_at : declared.end_at }; + } + const packet = await engine("/api/chart_choice/decide", { + ...engineBody(subject, window), adaptive: true, stop: true, + ...(window !== declared ? { reported_at: reference, reported_default_window: declared } : {}), + }, fetchImpl, window === declared ? ENGINE_TIMEOUT_MS : RESTORE_WIDE_RANGE_TIMEOUT_MS); + if (packet.status !== "complete" || packet.engine_version !== ROUNDS_VERSION || !packet.decision.reported_choice) { + throw new ChartChoiceError("no_choice", 409); + } + const latest = await readHistory(admin, userId, subject.subjectId); + if (latest.current_record_id !== current.id || !latest.records.some((record) => record.id === current.id && record.reusable)) { + throw new ChartChoiceError("record_stale", 409); + } + const { decision } = await saveChartChoice(admin, userId, { adaptive: true }, { + packet, subject, answers: {}, + decision: { ...packet.decision, reported_choice: { ...packet.decision.reported_choice, reported_known: at !== null } }, + }); + return { saved_at: decision.saved_at }; +} + async function saveChartChoice(admin: RpcClient, userId: string, request: ChartChoiceAdoptRequest, result: DecidedChoice) { const { packet, answers, subject } = result; let decision = result.decision; @@ -520,7 +578,7 @@ async function saveChartChoice(admin: RpcClient, userId: string, request: ChartC p_time: time.length === 5 ? `${time}:00` : time, p_date: date, p_offset: subject.location.tz, - ...rangeToSave(subject, request.resumeRecordId ? undefined : request.window, request.rangeChosen), + ...rangeToSave(subject, request.resumeRecordId ? undefined : request.window, request.rangeChosen && !request.useAnswerWindow), p_provenance: { contract: multi ? ROUNDS_VERSION : "chart-choice-v1", source, diff --git a/frontend/src/lib/personal-report-longform-birth.ts b/frontend/src/lib/personal-report-longform-birth.ts index 19ea44ae..dc297867 100644 --- a/frontend/src/lib/personal-report-longform-birth.ts +++ b/frontend/src/lib/personal-report-longform-birth.ts @@ -138,14 +138,15 @@ export function buildLongformBirthPayload( const year = Number.parseInt(dateMatch[1], 10); const account = asRecord(row); const provenance = asRecord(account?.active_birth_provenance); + const comparisonOnly = provenance?.source === "user_choice_from_answers"; const multiClock = provenance?.contract === "chart-choice-v2" && (account?.birth_time_status === "accepted" || account?.birth_time_status === "confirmed") ? reportAdoptedClock(account.active_birth_time) : undefined; const rectified = account && (provenance?.contract !== "chart-choice-v2" || multiClock) ? rectifiedReadingWindow(account, dateMatch[0], multiClock ?? timeMatch[0]) : null; - const readingMinuteConfirmed = rectified + const readingMinuteConfirmed = !comparisonOnly && (rectified ? rectified.source === "rectified_range" && rectified.before_minutes === 0 && rectified.after_minutes === 0 - : extras.readingMinuteConfirmed ?? extras.birthTimeAccuracy === "confirmed"; + : extras.readingMinuteConfirmed ?? extras.birthTimeAccuracy === "confirmed"); const birthWindow = rectified ?? (!readingMinuteConfirmed && account ? readingBirthWindow(account, timeMatch[0]) : undefined); const gender = parseProfileGender(account?.gender); return { @@ -165,7 +166,7 @@ export function buildLongformBirthPayload( today: extras.today, target_year: extras.targetYear, age: extras.targetYear - year, - birth_time_accuracy: rectified && readingMinuteConfirmed ? "confirmed" : extras.birthTimeAccuracy, + birth_time_accuracy: comparisonOnly ? "provisional" : rectified && readingMinuteConfirmed ? "confirmed" : extras.birthTimeAccuracy, ...(readingMinuteConfirmed ? {} : { reading_minute_confirmed: false }), ...(birthWindow ? { birth_time_window: birthWindow } : {}), ...(extras.candidateRange ? { candidate_range: extras.candidateRange } : {}), diff --git a/frontend/tests/chart-choice-rounds-page-20261113.test.tsx b/frontend/tests/chart-choice-rounds-page-20261113.test.tsx index c9b59dd0..1e0ed1e8 100644 --- a/frontend/tests/chart-choice-rounds-page-20261113.test.tsx +++ b/frontend/tests/chart-choice-rounds-page-20261113.test.tsx @@ -8,6 +8,8 @@ import { ChartQuestion, FinalStep, HomeView, QuestionnaireRange, RangeStep, Roun import type { ChartChoiceChartAnswers, ChartChoiceEnginePacket, ChartChoiceCompletion, ChartChoiceQuestionnaire, ChartChoiceRecord } from "../src/lib/chart-choice/contract"; import { eventSides } from "../src/lib/chart-choice/view"; +register(new URL("./support/stub-server-only.mjs", import.meta.url).href, import.meta.url); + type Choice = Extract; type Complete = Extract; const fixture = JSON.parse(readFileSync(new URL("./fixtures/chart-choice-rounds-display-20261113.json", import.meta.url), "utf8")) as { @@ -61,11 +63,12 @@ test("the current record shows the adopted time and keeps unresolved charts visi unchanged: [], decision: { ...decision, chosen_side: "none", votes: null, both_versions_domains: [] }, charts: [] }, }; const history = { current_record_id: record.id, records: [record] }; - const html = renderToStaticMarkup(); + const html = renderToStaticMarkup(); assert.ok(html.includes(`

${record.saved_time}

`)); assert.match(html, /范围 /); assert.equal((html.match(/还没定/g) ?? []).length, decision.undetermined_charts.length); - assert.doesNotMatch(html, /继续|历史|件事|你填的是/); + assert.doesNotMatch(html, /继续|历史|件事|你填的是|把握|倾向|改回/); assert.match(html, /看星盘/); assert.match(html, /重新纠正/); }); @@ -92,7 +95,8 @@ test("questionnaire displays age years and hides rejected constraints from the r assert.match(renderToStaticMarkup(), /第一印象/); const final = renderToStaticMarkup(); assert.doesNotMatch(final, /日常补充题指向不同时段|这个方法有多准|再补几件事|件有日期的事/); - assert.doesNotMatch(final, /出生时间在|已验证|准确/); + assert.doesNotMatch(final, /出生时间在|已验证|准确率/); + assert.match(final, /现有信息不足以确认更准确的出生时间/); assert.match(final, /按你填的 05:00/); assert.match(final, />用 05:00<\/button>/); assert.doesNotMatch(final, /chart-choice-card/); @@ -121,15 +125,20 @@ test("adaptive results offer narrowing only when the answer window contains the assert.ok(actions); assert.ok(final.includes(`>用 ${clock}`)); assert.ok(final.includes(`${knowsTime ? "按你填的" : "按范围中点"} ${clock}`)); - assert.doesNotMatch(final, /出生时间在|已验证|准确|你的经历更像|改用/); + assert.doesNotMatch(final, /出生时间在|已验证|把握|倾向|对得上|吻合|更像|改用/); + assert.match(final, /范围 [^<]+<\/p>]*>现有信息不足以确认更准确的出生时间。/); + assert.match(final, /你提到的事/); assert.equal((final.match(/换)/g) ?? []).length, reported.undetermined_charts.length); if (kind === "inside") { - assert.match(final, knowsTime ? /你的回答和填的时间对得上/ : /你的回答落在范围中间这段/); - assert.match(actions, />收窄到 .*<\/button>/); - assert.match(final, /和这段时间的大运对得上/); + assert.match(actions, />按 .* 这段比较<\/button>/); + assert.match(actions, /只用于比较,不代表真实时间已被证实,随时可以改回/); assert.match(band, /is-highlighted/); + const sameWidth = renderToStaticMarkup(); + assert.doesNotMatch(sameWidth, /这段比较<\/button>/); } else { - assert.doesNotMatch(final, /你的回答|收窄到|大运对得上/); + assert.doesNotMatch(final, /你的回答|收窄到|这段比较<\/button>/); assert.equal((actions.match(/ { + for (const [reportedAt, copy, label] of [ + ["1997-08-08T05:00", "你填的 05:00 没有改", "改回填的时间"], + [null, "你填的范围没有改", "改回原范围"], + ] as const) { + const { decision, window, reported_at } = adaptiveAdoption.inside.result; + const current: ChartChoiceRecord = { + id: "comparison", created_at: "2026-11-13T03:00:00Z", saved_date: decision.saved_at.slice(0, 10), + saved_time: decision.saved_at.slice(11), chosen_side: "none", rule: decision.rule, reusable: true, + detail: { window, reported_at, line: null, other_lines: decision.remaining_key_lines, questions: [], + answers: {}, unchanged: [], charts: [], provenance: { source: "user_choice_from_answers" }, + decision: { ...decision, chosen_side: "none", votes: null, both_versions_domains: [] } }, + }; + const html = renderToStaticMarkup(); + assert.ok(html.includes(copy)); + assert.ok(html.includes(`>${label}`)); + assert.match(html, /你选的比较范围/); + assert.doesNotMatch(html, /当前使用|把握|倾向|对得上|更像|改用/); + } +}); + test("the chart choice API service passes through the three display fields and saves final details", async () => { - register(new URL("./support/stub-server-only.mjs", import.meta.url).href, import.meta.url); const { adoptChartChoice, buildChartChoicePacket, previewAdaptiveChartChoice, previewChartChoice } = await import("../src/lib/chart-choice/server"); const profile = { name: "虚构甲", birth_date: "1997-08-08", reported_birth_time: "05:00", birth_time_source: "approximate", @@ -166,11 +196,13 @@ test("the chart choice API service passes through the three display fields and s let saved: Partial | undefined; let provenance: Record | undefined; let savedClock: unknown; + let declaredStart: unknown; const admin = { rpc: async (name: string, args: Record) => { assert.equal(name, "chart_choice_adopt_v4"); saved = (args.p_detail as { decision: ChartChoiceCompletion }).decision; provenance = args.p_provenance as Record; savedClock = args.p_time; + declaredStart = args.p_window_start; return { data: {}, error: null }; } }; await adoptChartChoice(caller, admin, "fixture", request, fetchEngine); @@ -223,4 +255,59 @@ test("the chart choice API service passes through the three display fields and s assert.equal(provenance?.source, "user_choice_from_answers"); assert.deepEqual(provenance?.segment, suggested.segment); assert.equal(saved?.evidence_summary?.confidence, suggested.evidence_summary?.confidence); + + const noRangeProfile = { ...profile, declared_window_start: null, declared_window_end: null, + uncertainty_before_minutes: null, uncertainty_after_minutes: null, birth_time_uncertainty_known: false }; + const noRangeCaller = { from: () => ({ select: () => ({ eq: () => ({ + maybeSingle: async () => ({ data: noRangeProfile, error: null }), + }) }) }) }; + const quickRange = { ...stopped, window: adaptiveAdoption.outside.packet.window, rangeChosen: true }; + await adoptChartChoice(noRangeCaller, admin, "fixture", quickRange, adaptiveEngine); + assert.equal(declaredStart, "04:30"); + await adoptChartChoice(noRangeCaller, admin, "fixture", { ...quickRange, useAnswerWindow: true }, adaptiveEngine); + assert.equal(declaredStart, null); + assert.equal(provenance?.source, "user_choice_from_answers"); +}); + +test("restoring the current comparison saves the default with no questionnaire replay", async () => { + const { restoreChartChoiceDefault } = await import("../src/lib/chart-choice/server"); + for (const scenario of [ + { input: adaptiveAdoption.inside, known: true, source: "reported_time_default" }, + { input: adaptiveMidpoint.inside, known: false, source: "window_midpoint_default" }, + ]) { + const { packet, result } = scenario.input; + const profile = { + name: "虚构甲", birth_date: packet.reported_at.slice(0, 10), + reported_birth_time: scenario.known ? packet.reported_at.slice(11) : null, + birth_time_source: scenario.known ? "approximate" : "period_only", + birth_time_status: "reported", birth_time_precision_kind: scenario.known ? "minutes" : "period", + declared_window_start: packet.window.start_at.slice(11), declared_window_end: packet.window.end_at.slice(11), + uncertainty_before_minutes: 30, uncertainty_after_minutes: 30, birth_time_uncertainty_known: true, + latitude: 36.42, longitude: 114.21, timezone_offset: 8, timezone_id: "Asia/Shanghai", birth_place_label: "虚构市", + }; + const caller = { from: () => ({ select: () => ({ eq: () => ({ maybeSingle: async () => ({ data: profile, error: null }) }) }) }) }; + const current = { id: "comparison", reusable: true, + detail: { window: packet.window, provenance: { source: "user_choice_from_answers" } } }; + const history = { current_record_id: current.id, records: [current] }; + let writes = 0; + const admin = { rpc: async (name: string, args: Record) => { + if (name === "chart_choice_records_v1") return { data: history, error: null }; + assert.equal(name, "chart_choice_adopt_v4"); + assert.equal((args.p_provenance as Record).source, scenario.source); + assert.equal(args.p_window_start, null); + writes++; + return { data: {}, error: null }; + } }; + const fetchEngine: typeof fetch = async (_url, options) => { + const body = JSON.parse(String(options?.body)); + assert.equal(body.stop, true); + assert.equal(body.adaptive, true); + assert.equal(body.chart_answers, undefined); + return Response.json(result); + }; + await assert.rejects(restoreChartChoiceDefault(caller, admin, "fixture", { recordId: "older" }, fetchEngine), /record_stale/); + const restored = await restoreChartChoiceDefault(caller, admin, "fixture", { recordId: current.id }, fetchEngine); + assert.equal(restored.saved_at, result.decision.reported_choice?.saved_at); + assert.equal(writes, 1); + } }); diff --git a/frontend/tests/personal-report-birth-window-20261113.test.ts b/frontend/tests/personal-report-birth-window-20261113.test.ts index ddb124a7..cc7ee1c7 100644 --- a/frontend/tests/personal-report-birth-window-20261113.test.ts +++ b/frontend/tests/personal-report-birth-window-20261113.test.ts @@ -158,6 +158,20 @@ test("v2 reports reject a segment that excludes the exact saved second", () => { }); }); +test("a questionnaire comparison stays provisional even when saved to a single second", () => { + const start = "2000-01-01T05:00:00"; + for (const end of [start, "2000-01-01T05:00:01", "2000-01-01T05:17:00"]) { + const payload = request({ + ...adopted, birth_time_status: "confirmed", active_birth_provenance: { + contract: "chart-choice-v2", source: "user_choice_from_answers", segment: { start_at: start, end_at: end }, + }, + }, true); + assert.equal(payload.reading_minute_confirmed, false); + assert.equal(payload.birth_time_accuracy, "provisional"); + assert.equal(payload.minute, 0); + } +}); + test("reported and legacy profiles use intake bounds without treating candidate bounds as adopted", () => { const intake = { uncertainty_before_minutes: 10, uncertainty_after_minutes: 20 }; for (const row of [