#292 生时纠正暂停问卷和选边 #293

Open
jesse wants to merge 4 commits from codex/rect-pause-20261115 into staging
15 changed files with 1811 additions and 40 deletions
+4
View File
@@ -4,6 +4,10 @@
- 判断依据与证据比对时,宫位写法、中英文星体和星座名视为同一事实;修订时告诉写作具体哪个词没对上;合盘生成失败页标题改为「婚恋合盘」。占星 Skill 版本不变(BUG-1897)。
## 2026-11-15 — 生时纠正暂时停用问卷和选边
- 直接显示填写时间或范围中点,保留六盘变化、快速选范围和历史记录。占星 Skill 版本不变(BUG-1896)。
## 2026-11-15 — 合盘分开显示正式核对和研究扫描
- 报告和追问分别展示五类事件的正式核对、研究日期和冲突说明。附录列出两人的运限条件、相关点位及法律婚姻缺项。旧报告重新打开时使用已保存的依据。占星 Skill 版本不变(BUG-1895)。
+11
View File
@@ -25615,3 +25615,14 @@
- 验证与防复发:`tests/test_synastry_gate_repair_20261115.py`(混排证据通过、错宫错星仍拒、中英互认、D1、星宿、数组仍拒);`frontend/tests/synastry-gate-repair-20261115.test.ts`(修订细节、截断上限、改法说明、提示词、失败页标题)。
- 关联记录:#281、#289、BUG-1892、BUG-1895。
- 修复版本:`claude/synastry-gate-repair-20261115`。
## BUG-1896 | 生时纠正答完问卷仍回到填写时间或范围中点
- 状态:fixed-pending-verify(待独立验收)。
- 首次发现 / 最近更新:2026-11-15 / 2026-11-15。
- 触发与现象:首次进入或重新纠正时出现年龄选项和年份步进。选项未覆盖用户经历,答完也不会改变采用的时间或范围。
- 根因:在答题结果不得缩窄自报范围的规则下,入口仍沿用问卷和选边。回测显示按答题缩窄表现低于随机。
- 修复:默认直接给出填写时间或原范围中点。保留六盘、快速范围选择及历史。共用开关暂停问卷、选边和续答请求。
- 验证与防复发:原生引擎夹具覆盖决定窗口、无分界线、历史重新进入、快速范围写回和中点。API 回归核对禁用请求的错误码。详见 [#292 进度](tasks/PROGRESS-rect-pause-20261115.md)。
- 关联记录:#260、#279、BUG-1649、BUG-1886、BUG-1890。
- 修复版本:`codex/rect-pause-20261115`,待独立验收。
@@ -0,0 +1,31 @@
# 生时纠正停用问卷和选边执行记录(2026-11-15)
- 工单 #292,BUG-1896,范围按 Claude 的任务书。基线为 `1c9017698cc9fd577aa87b4615fc13f067126abe`,任务书提交为 `655e07ad2c76f077305f582ab20c350b812b85cd`。
## 开工盘点
| 步骤、组件 | API 和既有参数 | 原入口 |
| --- | --- | --- |
| `chart_range → chart_question → final`,`QuestionnaireRange`、`ChartQuestion`、`FinalStep` | `/api/chart-choice/packet` 的 `adaptive`、`window`。采用端点用 `adaptive`、`chart_answers`、`stop`、`mode` | 首次进入、首页重新纠正、改变范围 |
| `range → choose`,`RangeStep`、`RoundStep` | 采用端点用 `resolved`、`answers`、`event_answers`、`appearance`、`birthmarks`、`stop`。回到计算端点用非空 `resolved` 继续 | 旧 v2 结果和逐轮确认,返回一步继续原轮 |
| `choose → result`,`ChooseStep`、`ResultStep` | 采用端点用 `mode=preview/choice`、`answers`、`overrideSide`、`readBothAll` | 旧 v1 选盘,结果页可改选另一侧 |
| 续答兼容入口 | 两个端点的 `resumeRecordId`、`questionnaire` | 旧记录曾可继续确认。当前首页只有重新纠正,接口仍接收续答 |
| `narrow`,`ChartChoiceFlow` 中的缩小范围选项 | 计算端点用 `window` 提交所选子段 | 输入范围超出单次扫描上限 |
| 占星师问卷,`AstrologerStage` | `/api/chart-choice/astrologer` 的 `window` 和 `input`,含 `question_answers`、日期事件、胎记、外表、D9、D10 的选择 | 组件和端点保留,当前页面没有挂载该阶段 |
历史首页以 `GET /api/chart-choice` 读取当前记录与停用记录。`restore_default`、`certain`、快速范围选择与历史读取按原有语义保留。
## 实现与截图
- 计算入口直接取原生引擎的默认结果。较宽范围沿用恢复默认值时的有界扫描,六盘变化和保存采用整段自报范围。用户原先没填范围时,保留 ±15 分钟试算,明确选择的范围在保存时写回。
- 计算和采用端点对旧答题、选边和续答请求返回 HTTP 400 `questionnaire_paused`。停用的占星师问卷端点共用此错误码。页面和端点由同一个开关控制。
- [四张 390 × 844 截图](https://git.copse.top/attachments/4b93d08e-d206-477a-8e97-c86f7e40d528) 分别为填写时间和范围、只填时间、只填范围、已有记录。Chrome 使用实际组件、生产构建 CSS 和原生引擎的虚构样例响应。截图环境用本地夹具替代登录后的 API。附件 SHA-256 为 `1fa22b021d2974d40c27e8505c2656aaeb76deca470e7cf17e89884198c9d5a7`。
## 验证
- 停用开关时的生时纠正定向测试 33 项通过。启用开关的隔离 worktree 中,33 项原有生时纠正和记录测试通过。既有断言未修改。
- `tsc --noEmit` 通过,前端 lint 0 错误。生产构建通过,`/` 和 `/rectification` 都保持静态。开工基线和候选的首页 39 个入口 JS/CSS 资产 gzip 合计均为 614 357 字节,变化 0%。
- Windows 前端全量,开工基线 3 782 项、通过 3 699 项,候选 3 785 项、通过 3 702 项。两边均有 46 项失败(含 Bash、部署和数据库环境限制),失败用例逐项相同,37 项跳过,0 项取消。
- 仓库隐私测试 62 项通过。
- Windows Python 快速门执行到 pytest,1 497 项通过、2 项跳过、29 个子测试通过,3 项失败。对开工基线单独运行 `tests/test_upstream_technique_layers.py` 复现同三项失败。触发点为 Kaala 的 1955 年负 Unix 时间戳经 Windows `datetime.fromtimestamp` 转换时报 `OSError 22`,另两项缺少 `seed`。预检确认远端、运行时和适配器可用,碎片审计和预检的定向检查分别触及 90 秒和 45 秒时限。
- staging 新增 `12684c8b8` 后,在隔离工作树按该提交与本单前端变更的组合复测。生时纠正与合盘修订定向用例合计前端 37 项、Python 56 项通过。组合生产构建保持 `/` 与 `/rectification` 静态,首页入口资产 gzip 仍为 614 357 字节。其后 `3b510608e` 仅调整个人报告测试,该文件 8 项通过。
+87
View File
@@ -0,0 +1,87 @@
# 生时纠正:先停用问卷和选边(2026-11-15,Claude)
Bug 编号:BUG-1896。
## 背景
产品负责人 11-15 在 staging 真机上走完一遍问卷(#279 上线后),反馈:
- **选项残缺:** 选项只有能区分候选段的年龄(例如事业盘只有「26 岁」),用户 30 岁创业就找不到对应选项;年份只能在该年龄覆盖的一两年里调。
- **选项样式难看。**
- **答完没有结果:** 结论永远是「按你填的 05:00」和「现有信息不足以确认更准确的出生时间」。
最后一条是根本问题。#260 的回测显示,按答题缩窄比随机还差;占星师 11-14 裁定不得缩窄,#279 把旧窄范围也撤回了。所以现在答题不可能改变结果,问卷只是让用户白答。
负责人 11-15 决定**先停用问卷**。等占星师下一轮(日期区间计分、新留出集)验证有效后再开,届时重做选项 UI。
## 改法
### 一、盘点(先做,写进 PROGRESS)
列出生时纠正里所有会向用户提问、或让用户选边、选段的入口,每条写清:
- step 名、组件名、对应 API 及参数;
- 现在从哪里进入(首页「重新纠正」、首次进入、换范围后等)。
已知的入口:
- 自适应问卷:`chart_range` → `chart_question` → `final`;
- v2 轮次:`choose` 里的 `RoundStep`;
- v1:`ChooseStep` → `ResultStep`,含「换另一侧」;
- `astrologer-stage.tsx`:「感情里你更像哪一种?」等。
盘点中发现的其他入口同样处理。
### 二、页面(只改 `frontend/src/components/chart-choice/` 和对应测试)
首次进入和「重新纠正」都直接显示结果页,不出任何题目。结果页即现在问卷答完后的结果页(真机截图第 4 张)去掉「你提到的事」,内容依次为:
1. 标题「按你填的 05:00」(没填时间:「按范围中点 HH:MM」)。
2. 「范围 04:45–05:15」。没填范围时保留现有的范围选择(`RangeChips`)。
3. 「现有信息不足以确认更准确的出生时间。」
4. 范围条和参照时间。
5. 「按 05:00 看六张盘」六行,文案沿用 #279 的规则:
- 默认窗口:「前后 15 分钟试算里没有变化」;
- 用户填了范围:「在你填的范围里没有换座」;
- 会换座:「范围内会换座(hh:mm:ss 换)」。
6. 唯一的主按钮「用 05:00」,保存 `reported_time_default`(没填时间时为 `window_midpoint_default`)。保存后回首页。
如果范围内没有分界线,`certain` 分支照旧。
还要去掉:
- 「开始答题」「先到这里」「下一题」「都没有」「记不清」;
- 年龄选项、哪一年步进器;
- 「换另一侧」「更像哪一种」;
- 「你提到的事」。
问卷相关组件和代码**保留不删**,只是不再渲染。用一个常量开关(例如 `RECT_QUESTIONNAIRE_ENABLED = false`)集中控制,方便以后重开。
### 三、API
- `/api/chart-choice/adopt` 和 `/api/chart-choice/packet` 收到问卷或选边类请求时,返回 4xx 并带明确说明(code 例如 `questionnaire_paused`)。问卷或选边类请求包括 `chart_answers`、`questionnaire`、`answers`、`overrideSide`、`resolved` 非空、`resumeRecordId` 续答等,以盘点为准。
- `adaptive` 只取默认结果(不出题),保存仍走现有 `reported_time_default` / `window_midpoint_default`。
- `restore_default`、`certain`、范围选择保持可用。
- 不改迁移、不改引擎评分、不删旧枚举。
### 四、历史和下游
- 已有记录照旧显示,#279 的「已停用」逻辑不动。
- 已有的明确手动选边、#229 选边记录继续按 #279 的规则读取,本单不改。
- 报告、年运、聊天、合盘不受影响,不碰 `scripts/report_render/`。
## 验收
- **测试:**
- 首次进入与「重新纠正」直接渲染结果页,没有题目、没有「先到这里」「下一题」「更像」「换另一侧」「你提到的事」;
- 唯一按钮「用 HH:MM」,保存 `reported_time_default`;没填时间的保存 `window_midpoint_default`;
- 没填范围时可选范围,保存后范围写回(BUG-1649 行为不变);
- API 拒绝问卷和选边请求,返回 4xx 并带 code;
- 开关打开时现有问卷测试仍通过(证明代码保留可用)。
- **截图(390 宽):** 填了时间和范围;只填时间(默认 ±15);没填时间;首页已有记录。
- **门禁:** Python quick gate、chart-choice 相关前端测试、`tsc`、`npx eslint .` 0 errors。
- **文档:**
- `docs/BUG_HISTORY.md` 新增 BUG-1896(只追加这一节):现象、根因(问卷在不能缩窄的规则下没有产出)、修复、验证、防复发,关联 BUG-1886、BUG-1890;
- CHANGELOG 一行;
- 更新 `frontend/DESIGN.md` 和 `frontend/docs/VOICE.md` 里涉及问卷的段落;
- 写 PROGRESS。
+5 -3
View File
@@ -358,10 +358,12 @@ 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。刻度标签接近时移到另一侧,腾出靠近的端点。当前记录用代表时刻作标题,紧随范围和六盘状态。
- **问卷暂停(#292,BUG-1896)**首次进入和重新纠正直接打开原问卷的结果布局。先显示填写时间或范围中点,再显示范围和计算说明;没填范围时在范围下保留快速范围选项。原时间带、六盘列表和单个「用 HH:MM」主按钮沿用。历史首页保留「重新纠正」及停用记录。
- **按盘选盘(#239,BUG-1873,问卷暂停前):** 范围、答题、结果和当前记录用平面布局,靠留白和细线分组。答题用 36px 时间带、22px 衬线标题和浅底选项,选项内的 8px 小带表示覆盖范围。事件选项原地展开年份和类别,展开 0.32 秒,遵循系统的减少动态效果设置。结果突出候选段,六盘用细带展示段内换座,并列出证据与冲突。首页保留星盘、重做和按未定盘续问。
- **自适应生时纠正(#260,BUG-1881,问卷暂停前):** 题目随候选范围变化。细进度条对应已确认盘数,年龄旁附公历年份。选项以同色底、1.5px 内描边和强调色文字及细带表示选中。事件类别用细边框与实心选中态,年份加减器保持透明底,展开仍为 0.32 秒。深色模式的选项和细带使用反转表面 token。刻度标签接近时移到另一侧,腾出靠近的端点。当前记录用代表时刻作标题,紧随范围和六盘状态。
- **生时纠正停用的结果(#279,BUG-1890):** 首页先显示填写时间或原范围中点,再显示原范围。已停用的记录在历史里可展开查看当时的时间和范围。没有填误差时,六盘行写试算窗口内的变化,默认前后各 15 分钟。填写过范围时,只描述该范围内是否换座。
- **问卷结果(#260,BUG-1886):** 标题下依次显示原范围和「现有信息不足以确认更准确的出生时间。」时间带只画原范围和参照时刻,不画答题段;只有一个主按钮「用 HH:MM」,不提供收窄或比较(FIX-7)。事件列表只列用户提供的事件。比较态的首页显示「你选的比较范围」、原填写时间或范围,以及描边恢复按钮。
- **问卷结果(#260,BUG-1886,问卷暂停前):** 标题下依次显示原范围和「现有信息不足以确认更准确的出生时间。」时间带只画原范围和参照时刻,不画答题段;只有一个主按钮「用 HH:MM」,不提供收窄或比较(FIX-7)。事件列表只列用户提供的事件。比较态的首页显示「你选的比较范围」、原填写时间或范围,以及描边恢复按钮。
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.
+2 -2
View File
@@ -620,8 +620,8 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
星盘档案里问别人和你的关系,标题只用「TA 是你的」。选项只有:伴侣、孩子、父母、朋友、其他。不写「客户」「家人」来代替这五项。没选过的,名字后面的小标签写「其他」。本人的标签仍是「本人」,不问这一句。
## 生时纠正问卷(#260)
## 生时纠正结果(#260、#292)
- 结果页在范围后写「现有信息不足以确认更准确的出生时间。」不提供收窄或比较按钮(FIX-7)。事件列表只写日期和类别。
- 首次进入或点「重新纠正」后写「按你填的 HH:MM」,没填时间时写「按范围中点 HH:MM」。范围后写「现有信息不足以确认更准确的出生时间。」只有「用 HH:MM」一个主按钮。没填范围时给快速范围选项。
- 旧答题或事件推得的范围停用后,首页写「你填的时间」或「原范围中点」,历史记录写「已停用」。未填误差时,盘未换座写「前后 15 分钟试算里没有变化」。用户填过范围时写「在你填的范围里没有换座」。
@@ -1,8 +1,9 @@
import { NextResponse } from "next/server";
import { isRectQuestionnaireRequest, RECT_QUESTIONNAIRE_ENABLED, RECT_QUESTIONNAIRE_PAUSED } from "@/components/chart-choice/questionnaire-switch";
import { z } from "zod";
import { chartAnswersSchema, chartChoiceCaller, chartChoiceErrorResponse, evidenceInput, resolvedSchema, windowSchema } from "@/lib/chart-choice/route-support";
import { adoptCertainTime, adoptChartChoice, previewAdaptiveChartChoice, previewChartChoice, restoreChartChoiceDefault } from "@/lib/chart-choice/server";
import { adoptCertainTime, adoptChartChoice, adoptDefaultChartChoice, previewAdaptiveChartChoice, previewChartChoice, readDefaultChartChoice, restoreChartChoiceDefault } from "@/lib/chart-choice/server";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
export const runtime = "nodejs";
@@ -44,9 +45,22 @@ export async function POST(request: Request) {
code: "answer_window_disabled",
}, { status: 400 });
}
if (!RECT_QUESTIONNAIRE_ENABLED && isRectQuestionnaireRequest(body)) {
return NextResponse.json(RECT_QUESTIONNAIRE_PAUSED, { status: 400 });
}
const parsed = bodySchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "请求格式不对", code: "bad_request" }, { status: 400 });
try {
if (!RECT_QUESTIONNAIRE_ENABLED && (parsed.data.mode === "preview" || parsed.data.mode === "choice")) {
const { subjectId, window, rangeChosen } = parsed.data;
if (parsed.data.mode === "preview") {
const { decision } = await readDefaultChartChoice(caller.supabase, caller.userId, { subjectId, window });
return NextResponse.json({ decision });
}
return NextResponse.json(await adoptDefaultChartChoice(
caller.supabase, createAdminSupabaseClient(), caller.userId, { subjectId, window, rangeChosen },
));
}
if (parsed.data.mode === "restore_default") {
return NextResponse.json(await restoreChartChoiceDefault(
caller.supabase, createAdminSupabaseClient(), caller.userId, parsed.data,
@@ -1,4 +1,5 @@
import { NextResponse } from "next/server";
import { RECT_QUESTIONNAIRE_ENABLED, RECT_QUESTIONNAIRE_PAUSED } from "@/components/chart-choice/questionnaire-switch";
import { z } from "zod";
import { chartChoiceCaller, chartChoiceErrorResponse, windowSchema } from "@/lib/chart-choice/route-support";
@@ -28,6 +29,9 @@ const bodySchema = z.object({
export async function POST(request: Request) {
const caller = await chartChoiceCaller("POST /api/chart-choice/astrologer");
if (!caller.ok) return caller.response;
if (!RECT_QUESTIONNAIRE_ENABLED) {
return NextResponse.json(RECT_QUESTIONNAIRE_PAUSED, { status: 400 });
}
const parsed = bodySchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) return NextResponse.json({ error: "请求格式不对", code: "bad_request" }, { status: 400 });
try {
@@ -1,9 +1,10 @@
import { NextResponse } from "next/server";
import { isRectQuestionnaireRequest, RECT_QUESTIONNAIRE_ENABLED, RECT_QUESTIONNAIRE_PAUSED } from "@/components/chart-choice/questionnaire-switch";
import { z } from "zod";
import { chartChoiceCaller, chartChoiceErrorResponse, resolvedSchema, windowSchema } from "@/lib/chart-choice/route-support";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { buildChartChoicePacket } from "@/lib/chart-choice/server";
import { buildChartChoicePacket, readDefaultChartChoice } from "@/lib/chart-choice/server";
export const runtime = "nodejs";
@@ -20,9 +21,16 @@ const bodySchema = z.object({
export async function POST(request: Request) {
const caller = await chartChoiceCaller("POST /api/chart-choice/packet");
if (!caller.ok) return caller.response;
const parsed = bodySchema.safeParse(await request.json().catch(() => null));
const body: unknown = await request.json().catch(() => null);
if (!RECT_QUESTIONNAIRE_ENABLED && isRectQuestionnaireRequest(body)) {
return NextResponse.json(RECT_QUESTIONNAIRE_PAUSED, { status: 400 });
}
const parsed = bodySchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "请求格式不对", code: "bad_request" }, { status: 400 });
try {
if (!RECT_QUESTIONNAIRE_ENABLED) {
return NextResponse.json(await readDefaultChartChoice(caller.supabase, caller.userId, parsed.data));
}
return NextResponse.json(await buildChartChoicePacket(caller.supabase, caller.userId, parsed.data, undefined,
parsed.data.resumeRecordId ? createAdminSupabaseClient() : undefined));
} catch (error) {
@@ -4,6 +4,7 @@ import { Fragment, useCallback, useEffect, useRef, useState, type ReactNode } fr
import { ChevronLeft, Plus, X } from "lucide-react";
import { ChartDrawLoader } from "@/components/chart-draw-loader";
import { RECT_QUESTIONNAIRE_ENABLED } from "@/components/chart-choice/questionnaire-switch";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@@ -30,7 +31,11 @@ import { cn } from "@/lib/utils";
* 选择题、外表体质、胎记、带日期的大事(含六亲)、D9 / D10 特质,再按她的标准给吻合率和置信度。
* 选盘页不变;这里只并排给出她的方法的结果和我们的回测说明。
*/
export function AstrologerStage({
export function AstrologerStage(props: Parameters<typeof AstrologerQuestionnaire>[0]) {
return RECT_QUESTIONNAIRE_ENABLED ? <AstrologerQuestionnaire {...props} /> : null;
}
function AstrologerQuestionnaire({
subjectId,
window,
chartChoiceTime,
@@ -6,6 +6,7 @@ import { ChevronLeft, ChevronRight } from "lucide-react";
import { AppLink } from "@/components/app-link";
import { ChartDrawLoader } from "@/components/chart-draw-loader";
import { MiniVargaChart } from "@/components/chart-choice/mini-varga-chart";
import { RECT_QUESTIONNAIRE_ENABLED } from "@/components/chart-choice/questionnaire-switch";
import { SecondaryPageShell } from "@/components/secondary-page-shell";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -159,6 +160,19 @@ export function ChartChoiceFlow({ subjectId }: { readonly subjectId: string }) {
...(request.resolved === undefined ? { adaptive: true } : {}),
});
if (ticket !== generation.current) return;
if (!RECT_QUESTIONNAIRE_ENABLED) {
if (packet.status === "certain") {
setStep({ kind: "certain", packet });
} else if (packet.status === "complete" && packet.decision.reported_choice) {
setStep({ kind: "final", packet, decision: packet.decision,
body: { subjectId, adaptive: true, mode: "choice", ...(request.window ? { window: request.window } : {}) },
back: { kind: "home" },
});
} else {
throw new Error("暂时没有可用的结果,请重试。");
}
return;
}
if (packet.status === "needs_narrowing") setStep({ kind: "narrow", packet });
else if (packet.status === "questionnaire") {
const range: Step = { kind: "chart_range", packet, request, previous };
@@ -448,7 +462,10 @@ export function ChartChoiceFlow({ subjectId }: { readonly subjectId: string }) {
);
case "final":
return <FinalStep packet={step.packet} decision={step.decision} pending={pending} trial={trial}
onBack={() => restore(step.back)} onAccept={() => void saveFinal(step)} />;
defaultOnly={!RECT_QUESTIONNAIRE_ENABLED}
rangeChips={!RECT_QUESTIONNAIRE_ENABLED ? rangeChips(step.packet) : null}
onBack={RECT_QUESTIONNAIRE_ENABLED ? () => restore(step.back) : hasHistory ? toHome : undefined}
onAccept={() => void saveFinal(step)} />;
}
}
}
@@ -974,28 +991,31 @@ function matchedEvidence(decision: FinalDecision): string | null {
return `${labels.join("、")},和这段时间的大运对得上${count < 3 ? ",先作参考" : ""}。`;
}
export function FinalStep({ packet, decision, pending, onBack, onAccept, trial = false }: {
export function FinalStep({ packet, decision, pending, onBack, onAccept, trial = false, defaultOnly = false, rangeChips }: {
readonly packet: FinalPacket;
readonly decision: FinalDecision;
readonly pending: boolean;
readonly onBack: () => void;
readonly onBack?: () => void;
readonly onAccept: () => void;
readonly trial?: boolean;
readonly defaultOnly?: boolean;
readonly rangeChips?: ReactNode;
}) {
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") {
if (defaultOnly || packet.status === "questionnaire") {
const reported = "reported_choice" in decision ? decision.reported_choice : undefined;
const reference = reported?.saved_at ?? packet.reported_at;
const defaultWindow = reported?.segment ?? packet.window;
const hasReportedTime = reported?.reported_known ?? packet.reported_known;
const clock = clockLabel(reference);
const events = decision.events ?? [];
const events = defaultOnly ? [] : decision.events ?? [];
return <section className="chart-choice-flat chart-choice-result-flat">
{!pending ? <BackLink onClick={onBack}>改答案</BackLink> : null}
{!pending && onBack ? <BackLink onClick={onBack}>{defaultOnly ? "返回" : "改答案"}</BackLink> : null}
<div className="chart-choice-flat-section">
<h2 className="chart-choice-heading">{hasReportedTime ? `按你填的 ${clock}` : `按范围中点 ${clock}`}</h2>
<p className="chart-choice-fine">范围 {segmentLabel(defaultWindow, reference)}</p>
{rangeChips}
<p className="chart-choice-note">现有信息不足以确认更准确的出生时间。</p>
<ChoiceBand window={defaultWindow} reportedAt={reference} segments={[1]} />
</div>
@@ -1016,7 +1036,7 @@ export function FinalStep({ packet, decision, pending, onBack, onAccept, trial =
const evidence = matchedEvidence(decision);
return <>
<Card>
{!pending ? <BackLink onClick={onBack}>改答案</BackLink> : null}
{!pending && onBack ? <BackLink onClick={onBack}>改答案</BackLink> : null}
<p className="chart-choice-label">确认了 {decision.rounds?.length ?? 0} 处</p>
<p className="chart-choice-fine">候选时间段</p>
<h2 className="chart-choice-heading">{segmentLabel(decision.segment)}</h2>
@@ -0,0 +1,19 @@
/** Product pause. Switch on to restore both the page and the questionnaire endpoints. */
export const RECT_QUESTIONNAIRE_ENABLED = false;
const QUESTIONNAIRE_FIELDS = [
"questionnaire", "chart_answers", "answers", "overrideSide", "readBothAll", "resumeRecordId",
"event_answers", "appearance", "birthmarks",
] as const;
export function isRectQuestionnaireRequest(body: unknown): boolean {
if (!body || typeof body !== "object" || Array.isArray(body)) return false;
const fields = body as Record<string, unknown>;
return QUESTIONNAIRE_FIELDS.some((key) => Object.hasOwn(fields, key))
|| (Array.isArray(fields.resolved) && fields.resolved.length > 0);
}
export const RECT_QUESTIONNAIRE_PAUSED = {
error: "答题和选边暂已停用。现在可以保存填写的时间或范围中点。",
code: "questionnaire_paused",
} as const;
+58 -24
View File
@@ -441,6 +441,8 @@ function terminalChoice({ result, subject }: Awaited<ReturnType<typeof evaluateC
throw new ChartChoiceError("no_choice", 409);
}
function publicDecision(decision: ChartChoiceCompletion): ChartChoiceCompletion;
function publicDecision(decision: ChartChoiceDecision | ChartChoiceCompletion): ChartChoiceDecision | ChartChoiceCompletion;
function publicDecision(decision: ChartChoiceDecision | ChartChoiceCompletion): ChartChoiceDecision | ChartChoiceCompletion {
const visible = { ...decision };
delete visible.conflict;
@@ -488,6 +490,60 @@ function assertAdoptionMode(request: ChartChoiceAdoptRequest): void {
}
}
/** Resolve the full declaration without asking for evidence, even when it spans more than one scan. */
async function defaultChartChoiceOutcome(subject: Subject, declared: ChartChoiceWindow, fetchImpl?: typeof fetch) {
const at = subject.declared && reportedAt(subject.declared);
const reference = at ?? midpointAt(declared);
const wide = minutesOf(declared.end_at) - minutesOf(declared.start_at) > MAX_RECORD_SPAN_MINUTES * 2;
const around = wide ? windowAround(reference, MAX_RECORD_SPAN_MINUTES) : declared;
const 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<ChartChoiceEnginePacket>("/api/chart_choice/decide", {
...engineBody(subject, window), adaptive: true, stop: true,
...(wide ? { reported_at: reference, reported_default_window: declared } : {}),
}, fetchImpl, wide ? RESTORE_WIDE_RANGE_TIMEOUT_MS : ENGINE_TIMEOUT_MS);
if (packet.status !== "complete" || packet.engine_version !== ROUNDS_VERSION || !packet.decision.reported_choice) {
throw new ChartChoiceError("no_choice", 409);
}
const reported = { ...packet.decision.reported_choice, reported_known: at !== null };
return { subject, packet, decision: { ...packet.decision, reported_choice: reported }, answers: {}, reported, wide };
}
type DefaultChoiceRequest = Pick<ChartChoiceAdoptRequest, "subjectId" | "window" | "rangeChosen">;
async function loadDefaultChartChoice(supabase: unknown, userId: string, request: DefaultChoiceRequest, fetchImpl?: typeof fetch) {
const subject = await loadSubject(supabase, userId, request.subjectId);
const requested = scanWindow(subject, request.window);
const window = subject.declared && declaredNeedsRange(subject.declared) ? requested : scanWindow(subject, undefined);
return defaultChartChoiceOutcome(subject, window, fetchImpl);
}
export async function readDefaultChartChoice(
supabase: unknown, userId: string, request: DefaultChoiceRequest, fetchImpl?: typeof fetch,
) {
const result = await loadDefaultChartChoice(supabase, userId, request, fetchImpl);
const { packet, reported, decision, subject } = result;
if (!result.wide && !reported.remaining_key_lines && !packet.minor_lines?.length) {
// The existing certain flow checks legacy minor boundaries too.
const legacy = await engine<ChartChoicePacket>("/api/chart_choice/packet", engineBody(subject, reported.segment), fetchImpl);
if (legacy.status === "certain") return { ...legacy, decision: publicDecision(decision) };
}
return {
engine_version: packet.engine_version, status: "complete" as const,
window: reported.segment, reported_at: reported.saved_at, reported_known: reported.reported_known,
saved_at: reported.saved_at, decision: publicDecision(decision),
};
}
export async function adoptDefaultChartChoice(
supabase: unknown, admin: RpcClient, userId: string, request: DefaultChoiceRequest, fetchImpl?: typeof fetch,
) {
const choice = await loadDefaultChartChoice(supabase, userId, request, fetchImpl);
return saveChartChoice(admin, userId, { ...request, adaptive: true }, choice);
}
/** Restore the default for the current comparison using the declaration and no questionnaire answers. */
export async function restoreChartChoiceDefault(
supabase: unknown,
@@ -503,34 +559,12 @@ export async function restoreChartChoiceDefault(
|| 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<ChartChoiceEnginePacket>("/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 choice = await defaultChartChoiceOutcome(subject, scanWindow(subject, undefined), fetchImpl);
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 } },
});
const { decision } = await saveChartChoice(admin, userId, { adaptive: true }, choice);
return { saved_at: decision.saved_at };
}
@@ -0,0 +1,213 @@
import "./install-test-document";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { register } from "node:module";
import { spawnSync } from "node:child_process";
import test from "node:test";
import React from "react";
import { ChartChoiceFlow } from "../src/components/chart-choice/chart-choice-page";
import type { ChartChoiceEnginePacket, ChartChoiceRecord, ChartChoiceStart } from "../src/lib/chart-choice/contract";
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support";
Object.assign(globalThis, { React });
register(new URL("./support/stub-server-only.mjs", import.meta.url).href, import.meta.url);
type Complete = Extract<ChartChoiceEnginePacket, { status: "complete" }>;
type Certain = Extract<ChartChoiceEnginePacket, { status: "certain" }>;
type Ready = Extract<ChartChoiceStart, { status: "ready" }>;
const fixtures = JSON.parse(readFileSync(new URL("./fixtures/chart-choice-pause-native-20261115.json", import.meta.url), "utf8")) as {
defaultTrial: Complete; declared: Complete; midpoint: Complete; wideMidpoint: Complete;
certain: { legacy: Certain; default: Complete };
};
function startFor(packet: Complete, rangeAdjustable: boolean, record?: ChartChoiceRecord): Ready {
const defaultChoice = packet.decision.reported_choice!;
return {
status: "ready", subjectId: "self", name: "虚构人物",
window: defaultChoice.segment, rangeAdjustable,
reportedAt: defaultChoice.reported_known ? defaultChoice.saved_at : null,
precision: defaultChoice.reported_known ? "minutes" : "period",
history: { current_record_id: record?.id ?? null, records: record ? [record] : [] },
};
}
function currentRecord(packet: Complete): ChartChoiceRecord {
const saved = packet.decision.reported_choice!;
return {
id: "test-current", created_at: "2026-11-15T00:00:00Z", saved_date: saved.saved_at.slice(0, 10),
saved_time: saved.saved_at.slice(11), chosen_side: "none", rule: "reported_time_default", reusable: true,
detail: {
window: saved.segment, reported_at: saved.saved_at, line: null, other_lines: saved.remaining_key_lines,
questions: [], answers: {}, unchanged: [], charts: [],
decision: {
...packet.decision, ...saved, chosen_side: "none", votes: null,
both_versions_domains: [], rule: "reported_time_default",
},
},
};
}
test("first entry and restart open the default result, preserve trial language and save the chosen range", async () => {
for (const scenario of [
{ packet: fixtures.declared, adjustable: false, exported: "按你填的 05:00", restart: false },
{ packet: fixtures.defaultTrial, adjustable: true, exported: "按你填的 05:00", restart: true },
{ packet: fixtures.midpoint, adjustable: false, exported: "按范围中点 05:00", restart: false },
]) {
const h = createClientLifecycleHarness();
const originalFetch = globalThis.fetch;
let packets = 0;
let saved = false;
let submitted: Record<string, unknown> | undefined;
let packet = scenario.packet;
globalThis.fetch = async (url, request) => {
const path = String(url);
if (path.startsWith("/api/chart-choice?")) {
return Response.json(startFor(packet, scenario.adjustable && !saved, (scenario.restart || saved) ? currentRecord(packet) : undefined));
}
const body = JSON.parse(String(request?.body));
if (path === "/api/chart-choice/packet") {
packets += 1;
assert.deepEqual(Object.keys(body).sort(), body.window ? ["adaptive", "subjectId", "window"] : ["adaptive", "subjectId"]);
if (body.window) {
assert.deepEqual(body.window, fixtures.declared.window);
packet = fixtures.declared;
}
return Response.json(packet);
}
assert.equal(path, "/api/chart-choice/adopt");
saved = true;
submitted = body;
return Response.json({ decision: { saved_at: "1997-08-08T05:00" } });
};
try {
await h.render(<ChartChoiceFlow subjectId="self" />);
const waitFor = async (text: string) => {
for (let i = 0; i < 10 && !h.elements().some((element) => element.text === text); i += 1) await h.idle();
assert.ok(h.elements().some((element) => element.text === text), text);
};
if (scenario.restart) {
await waitFor("重新纠正");
assert.equal(packets, 0);
await h.event(h.elements().find((element) => element.text === "重新纠正")!);
}
await waitFor(scenario.exported);
const elements = h.elements();
assert.equal(elements.filter((element) => element.text === "用 05:00" && element.tagName === "BUTTON").length, 1);
assert.equal(elements.some((element) => /开始答题|先到这里|下一题|都没有|记不清|换另一侧|更像哪一种|你提到的事/.test(element.text)), false);
assert.equal(elements.filter((element) => element.tagName === "DT").length, 6);
assert.equal(elements.some((element) => element.getAttribute("role") === "radiogroup"), scenario.adjustable);
if (scenario.adjustable) {
assert.ok(elements.some((element) => element.text.includes("前后 15 分钟试算里没有变化")));
await h.event(elements.find((element) => element.getAttribute("role") === "radio" && element.text === "30 分钟")!);
await waitFor(scenario.exported);
assert.ok(h.elements().some((element) => element.text.includes("在你填的范围里没有换座")));
}
await h.event(h.elements().find((element) => element.tagName === "BUTTON" && element.text === "用 05:00")!);
await waitFor("当前使用");
assert.deepEqual(submitted, { subjectId: "self", adaptive: true, mode: "choice",
...(scenario.adjustable ? { window: fixtures.declared.window, rangeChosen: true } : {}),
});
assert.equal(packets, scenario.adjustable ? 2 : 1);
assert.equal(h.errors.length, 0);
} finally {
globalThis.fetch = originalFetch;
await h.close();
}
}
});
test("default packet and adoption use the native completion without evidence and preserve certain and wide ranges", async () => {
const { readDefaultChartChoice, adoptDefaultChartChoice } = await import("../src/lib/chart-choice/server");
const profile = {
name: "虚构人物", birth_date: "1997-08-08", reported_birth_time: "05:00", birth_time_source: "approximate",
birth_time_status: "reported", declared_window_start: "04:30", declared_window_end: "05:30",
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: "虚构市",
};
for (const { row, packet, rule, request, checkWide } of [
{ row: profile, packet: fixtures.declared, rule: "reported_time_default", request: {}, checkWide: false },
{ row: { ...profile, declared_window_start: null, declared_window_end: null, uncertainty_before_minutes: 0,
uncertainty_after_minutes: 0, birth_time_uncertainty_known: false, birth_time_source: "family_exact" },
packet: fixtures.declared, rule: "reported_time_default",
request: { window: fixtures.declared.window, rangeChosen: true }, checkWide: false },
{ row: { ...profile, reported_birth_time: null, birth_time_source: "period_only", birth_time_precision_kind: "period",
declared_window_start: "02:00", declared_window_end: "08:00", uncertainty_before_minutes: null, uncertainty_after_minutes: null },
packet: fixtures.wideMidpoint, rule: "window_midpoint_default", request: {}, checkWide: true },
]) {
const caller = { from: () => ({ select: () => ({ eq: () => ({ maybeSingle: async () => ({ data: row, error: null }) }) }) }) };
const fetchEngine: typeof fetch = async (url, init) => {
assert.match(String(url), /\/api\/chart_choice\/decide$/);
const body = JSON.parse(String(init?.body));
assert.equal(body.adaptive, true);
assert.equal(body.stop, true);
assert.equal(body.chart_answers ?? body.answers ?? body.resolved, undefined);
if (checkWide) {
assert.equal(body.start_at, "1997-08-08T04:00");
assert.equal(body.end_at, "1997-08-08T06:00");
assert.deepEqual(body.reported_default_window, packet.decision.reported_choice!.segment);
}
return Response.json(packet);
};
const result = await readDefaultChartChoice(caller, "fixture", request, fetchEngine);
assert.equal(result.status, "complete");
assert.equal(result.reported_known, !checkWide);
assert.deepEqual(result.window, packet.decision.reported_choice!.segment);
const admin = { rpc: async (fn: string, args: Record<string, unknown>) => {
assert.equal(fn, "chart_choice_adopt_v4");
assert.equal((args.p_provenance as { source: string }).source, rule);
assert.equal(args.p_window_start, request.rangeChosen ? "04:30" : null);
assert.equal(args.p_window_end, request.rangeChosen ? "05:30" : null);
assert.equal(args.p_time, "05:00:00");
return { data: {}, error: null };
} };
assert.equal((await adoptDefaultChartChoice(caller, admin, "fixture", request, fetchEngine)).decision.rule, rule);
}
const { legacy, default: native } = fixtures.certain;
const caller = { from: () => ({ select: () => ({ eq: () => ({ maybeSingle: async () => ({ data: {
...profile, reported_birth_time: legacy.reported_at.slice(11), declared_window_start: legacy.window.start_at.slice(11),
declared_window_end: legacy.window.end_at.slice(11), uncertainty_before_minutes: 1, uncertainty_after_minutes: 1,
}, error: null }) }) }) }) };
const paths: string[] = [];
const certain = await readDefaultChartChoice(caller, "fixture", {}, async (url) => {
const path = String(url).split("/").at(-1)!;
paths.push(path);
return Response.json(path === "packet" ? legacy : native);
});
assert.equal(certain.status, "certain");
assert.deepEqual(paths, ["decide", "packet"]);
assert.equal(certain.saved_at, legacy.saved_at);
});
test("chart choice API rejects questionnaire, resume, side and evidence inputs with an explicit 400", () => {
const program = `
import assert from "node:assert/strict";
import { mock } from "node:test";
mock.module("server-only", { namedExports: {} });
mock.module("@/lib/supabase/server", { namedExports: {
createServerSupabaseClient: async () => ({auth:{getUser:async () => ({data:{user:{id:"fixture"}}})}}),
}});
mock.module("@/lib/supabase/admin", { namedExports: { createAdminSupabaseClient: () => { throw new Error("adoption must not run"); } } });
mock.module("@/lib/product-access", { namedExports: { isProductEnabled: async () => true } });
mock.module("@/lib/request-rate-limit", { namedExports: { consumeUserRequestRateLimit: () => ({ok:true}) } });
const packet = (await import("./src/app/api/chart-choice/packet/route.ts")).POST;
const adopt = (await import("./src/app/api/chart-choice/adopt/route.ts")).POST;
const astrologer = (await import("./src/app/api/chart-choice/astrologer/route.ts")).POST;
const run = async (handler, body) => {
const response = await handler(new Request("http://localhost/api/chart-choice", {method:"POST",body:JSON.stringify(body)}));
assert.equal(response.status, 400, JSON.stringify(body));
assert.equal((await response.json()).code, "questionnaire_paused");
};
for (const handler of [packet, adopt]) {
for (const body of [{questionnaire:true}, {chart_answers:{}}, {answers:{}}, {overrideSide:"before"},
{resolved:[{}]}, {resumeRecordId:"old-record"}, {event_answers:{}}, {appearance:"before"}, {birthmarks:[]},
{readBothAll:true}]) await run(handler, body);
}
await run(astrologer, {input:{}});
`;
const run = spawnSync(process.execPath, ["--experimental-test-module-mocks", "--import", "tsx", "--input-type=module", "--eval", program], {
cwd: new URL("..", import.meta.url), encoding: "utf8", timeout: 15_000,
});
assert.equal(run.status, 0, run.stderr || run.stdout);
});
File diff suppressed because it is too large Load Diff