diff --git a/CHANGELOG.md b/CHANGELOG.md index eac78136..f3643395 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # 印度占星 Skill 更新日志 +## 2026-10-21 — 合盘时间线写出起止,删掉的模型改用默认模型 + +- 关系时间线每段标题写出起止年月。第一段从本月开始时,起点写「现在」或 Now。 +- 英文报告恢复收起的分析逻辑(Analysis)和时间线依据。依据来自英文那一次生成。 +- 普通对话钉住的模型版本已被删除时,下一轮改用当前默认模型继续,并记下这个模型。没有默认模型时仍说「模型暂不可用」。对话里不另加一句说明。 +- 合盘选人页在开关结果回来之前只显示标题和副标题。开关连不上时说「合盘服务暂时连不上,请稍后再试」。档案读失败仍说「星盘档案暂时读不出来」。 +- Skill 版本不变(BUG-1400~1403)。 + ## 2026-10-21 — 婚恋合盘点「开始合盘」不再直接报错 - 自托管数据库客户端补上「大于等于 / 小于」两种查询,合盘报告可以正常创建(BUG-1399)。Skill 版本不变。 @@ -11,7 +19,7 @@ ## 2026-10-20 — 后台可以修改并删除已发布模型 - 编辑已发布或已下线的模型会另存成下一版草稿,当前线上版本保持不变。更换供应商模型时,显示名跟着新模型走。 -- 模型版本列表可以删除草稿、已发布和已下线版本。还有其他已发布模型时,不能删掉当前默认模型。已经绑定被删版本的会话不能继续提问。 +- 模型版本列表可以删除草稿、已发布和已下线版本。还有其他已发布模型时,不能删掉当前默认模型。当时,已经绑定被删版本的会话不能继续提问;2026-10-21 起,普通咨询改用当前默认模型。 - Skill 版本不变。新增迁移 `20261020150000_admin_model_version_draft_and_delete.sql`。 ## 2026-10-20 — 完整合盘报告定价 19 点 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 407faf44..f8989e4b 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -36920,23 +36920,6 @@ - 复发自:无。 - 修复版本:分支 `codex/synastry-page-dynamic-20261020`。 -## BUG-1397 | 婚恋合盘页在构建时被写成「服务暂未开放」 - -- 状态:resolved -- 首次发现:2026-10-20 -- 最近更新:2026-10-20 -- 影响面:`/synastry`、侧栏「婚恋合盘」 -- 用户现象:后台 `product.compatibility` 已发布、已开启、灰度 100%,打开婚恋合盘仍只看到「合盘服务暂未开放」。 -- 触发条件:构建镜像时没有数据库。页面在服务端调用 `isProductEnabled("compatibility")`,查询失败被当成关闭,Next 把这个结果预渲染成静态页。staging 上该页响应带 `X-Nextjs-Prerender: 1`、`X-Nextjs-Cache: HIT`,部署标记是 `123295135`。 -- 根因:开关读取发生在构建期,而不是每次打开页面。失败分支又把关闭写成页面正文,于是静态结果被缓存,之后把开关打开也不会变。 -- 修复:`/synastry` 改成静态外壳,只渲染选人组件。选人组件打开后再请求 `/api/product-access`。返回 `compatibility: false` 才显示「合盘服务暂未开放」。不给这个次级页加 `force-dynamic`。 -- 验证:`frontend/tests/synastry-full-report.test.ts` 锁定页面源码不再调用 `isProductEnabled`、也不含那句关闭文案;选人组件先看开关,初始状态不是关闭。`frontend/tests/stale-client-recovery.test.ts` 锁定该页不是 `force-dynamic`。 -- 防复发:不要在会预渲染的页面里调用 `isProductEnabled`。次级页继续用静态外壳,开关放到浏览器里读。 -- 相关记录:BUG-1123、BUG-1395。issue #82。 -- 复发自:无。 -- 修复版本:分支 `codex/synastry-page-dynamic-20261020`。 - - ## BUG-1398 | 后台保存已发布模型报「提交内容不符合业务约束」,也不能删除 - 状态:resolved(本地修复,待提交) @@ -36968,3 +36951,68 @@ - 相关记录:BUG-990(同类:兼容层缺 `not(col,"is",null)`);issue #82。 - 复发自:BUG-990 同类问题。BUG-990 的防复发是「新写法须 test:db 真查或部署后 smoke」,#82 的创建路径没有真库测试,验收也没在 staging 点过一次,所以没拦住。 - 修复版本:分支 `codex/synastry-gte-fix-20261021`。 + +## BUG-1400 | 合盘关系时间线看不出每段的起止年月 + +- 状态:resolved(待 staging 真机复测) +- 首次发现:2026-10-21(产品负责人真机) +- 最近更新:2026-10-21 +- 影响面:婚恋合盘报告的关系时间线 +- 用户现象:每一段只显示阶段名和描述,看不出是哪段时间。 +- 触发条件:打开一份已经生成的合盘报告,看到关系时间线。 +- 根因:`TimelineSegment` 已有 `start` / `end`(`YYYY-MM`),`synastry-report-view.tsx` 的标题只渲染 `phase`。#82 要求写出起止年月,显示层没用这两个字段。 +- 修复:标题写成「2027 年 9 月 – 2029 年 3 月 阶段名」,英文「Sep 2027 – Mar 2029 Settling」。破折号是 en dash。中文阶段名前面是表意空格。只有第一段、且起点的年月等于本地今天所在月时,起点写「现在」/「Now」;后面的段即使从本月开始仍写日期。 +- 验证:`frontend/tests/synastry-full-report.test.ts` 用公开名人配对 Albert / Mileva 的既有大运窗口渲染报告,第一段在该月显示「现在 – 1906 年 6 月 靠近」,英文「Now – Jun 1906 Closer」。 +- 防复发:该渲染测试锁定中英文起止,以及「只有第一段才写现在」。 +- 相关记录:issue #85、#82。BUG-1401。 +- 复发自:无。 +- 修复版本:分支 `codex/synastry-staging-fix-20261021`。 + +## BUG-1401 | 英文合盘报告把分析逻辑和时间线依据藏掉了 + +- 状态:resolved(待 staging 真机复测) +- 首次发现:2026-10-21(产品负责人真机) +- 最近更新:2026-10-21 +- 影响面:婚恋合盘报告的英文版 +- 用户现象:切到 English 之后,正文各节和时间线都没有「分析逻辑」。 +- 触发条件:报告已有英文正文,语言切到 English。 +- 根因:`67c1e6881` 重排时给分析折叠块加了 `language === "zh"`,英文分支渲染成空。改之前中英文都显示。DESIGN.md 没写这条。 +- 修复:中英文都用 `
`,默认收起。英文摘要是 Analysis,依据行是「Chart: 」和「So: 」。正文来自英文那一次生成,不翻译中文。 +- 验证:`frontend/tests/synastry-full-report.test.ts` 在英文模式下断言每个正文节和时间线各有一块 Analysis,且没有展开、没有中文「分析逻辑」。 +- 防复发:该测试按节数加时间线段数计数折叠块。 +- 相关记录:issue #85、#82。BUG-1400。 +- 复发自:无。 +- 修复版本:分支 `codex/synastry-staging-fix-20261021`。 + +## BUG-1402 | 后台删掉已发布模型后,绑定它的旧对话不能再提问 + +- 状态:resolved(待 staging 真机复测) +- 首次发现:2026-10-21 +- 最近更新:2026-10-21 +- 影响面:普通咨询 `POST /api/consult`;`chat_sessions.model_id` / `model_config_version` +- 用户现象:模型版本被删除后,旧对话下一轮不能继续。 +- 触发条件:会话钉住的 `model_config_version` 已经不在已发布或已下线列表里,下一轮仍用这个会话提问。客户端若继续带上被删的模型 id,也会被旧逻辑当成「会话模型已经变化」。 +- 根因:`resolveSessionLanguageModel` 按钉住的版本查找,删掉的行返回空,咨询准备直接回 503「模型暂不可用」。版本还能解析时,请求里的模型 id 必须和会话一致,否则 409。产品决定(K1):解析不到时改用当前默认模型继续,对话里不另加提示。 +- 修复:咨询准备在解析失败时读取当前默认模型,把会话 `model_id` 写成这个默认模型。钉版本的触发器在模型 id 不变时会留下旧版本,所以回退 id 和原来相同时,先把 `model_id` 写成空再写回,让触发器记下当前已发布版本。没有默认模型时仍是 503「模型暂不可用」。客户端下一轮仍带被删 id 时,只要这个 id 已经不是另一个在线模型,就按默认模型继续,不回 409。客户端改指另一个仍在线的模型时,仍回 409「会话模型已经变化」。合盘追问和生时校正的解析路径没有改。 +- 验证:`frontend/tests/consult-session-model-fallback.test.ts` 四条:被删版本改写为默认模型并带上新的配置版本;下一轮仍送被删 id 时不再写库、继续用默认模型;另一个在线模型仍是 changed;没有默认模型时 `loadConsultTurnHead` 回 503「模型暂不可用」。 +- 防复发:上述四条。不要在解析为空时直接 503,除非目录里没有默认模型。同 id 回写必须先清空,否则触发器不会刷新版本。 +- 相关记录:BUG-1398。issue #85。产品决定 K1。 +- 复发自:无。BUG-1398 的发布说明写了「已经绑定被删版本的会话不能继续提问」,本条按 K1 改掉这个后果。 +- 修复版本:分支 `codex/synastry-staging-fix-20261021`。 + +## BUG-1403 | 合盘开关读失败时文案说成档案错误,开关回来前会闪一下选人页 + +- 状态:resolved(待 staging 真机复测) +- 首次发现:2026-10-21(产品负责人真机) +- 最近更新:2026-10-21 +- 影响面:`/synastry` 选人页 +- 用户现象:产品开关请求失败时,页面写「星盘档案暂时读不出来」。开关实际关闭时,会先闪出选人区,再变成「合盘服务暂未开放」。 +- 触发条件:打开婚恋合盘。开关请求抛错、返回非成功,或 `compatibility` 不是布尔值;以及开关返回 `false` 之前组件已经按「开着」画出选人区。 +- 根因:`synastry-picker.tsx` 的初始状态是未关闭,选人区一开始就渲染。读取失败和档案失败共用同一句档案文案。 +- 修复:开关结果回来之前只渲染标题和副标题。`compatibility === false` 才显示「合盘服务暂未开放」,并且发生在读取档案之前。请求失败或结果不是开/关时显示「合盘服务暂时连不上,请稍后再试」。档案读取失败仍是「星盘档案暂时读不出来」。同一轮删掉 `docs/BUG_HISTORY.md` 里重复的第二段 BUG-1397。 +- 验证:`frontend/tests/synastry-full-report.test.ts` 渲染选人页(包在侧栏 Provider 里),初始 HTML 有副标题、没有「开始合盘」和「第一个人」;源码锁定新文案,以及先关闭再读档案。 +- 防复发:该渲染测试。不要把开关未返回画成选人区,也不要把开关失败写成档案失败。 +- 相关记录:BUG-1397。issue #85。 +- 复发自:无。 +- 修复版本:分支 `codex/synastry-staging-fix-20261021`。 + diff --git a/docs/tasks/PROGRESS-synastry-staging-fix-20261021.md b/docs/tasks/PROGRESS-synastry-staging-fix-20261021.md new file mode 100644 index 00000000..a20961ee --- /dev/null +++ b/docs/tasks/PROGRESS-synastry-staging-fix-20261021.md @@ -0,0 +1,47 @@ +# 进度:婚恋合盘 staging 修复(T3–T6 与红线 3) + +- 任务书:`docs/tasks/TASK-synastry-staging-fix-20261021.md` +- 基线:`origin/staging` `998d07baf`(T1、T2 已在这条上,BUG-1399) +- 分支:`codex/synastry-staging-fix-20261021` +- 工作树:`.worktrees/synastry-staging-fix-20261021` +- 记录:BUG-1400~1403。重复的第二段 BUG-1397 已删。 +- 未推 staging,未改 `.gitea/`。真机未做。 + +## 做了什么 + +- 时间线标题用 `timelineHeading`:中文「2027 年 9 月 – 2029 年 3 月 阶段名」,英文「Sep 2027 – Mar 2029 Settling」。只有第一段、且起点年月等于本地今天所在月时,起点写「现在」/「Now」。 +- 英文报告恢复收起的分析逻辑,和时间线依据同一套 `
`。英文摘要是 Analysis,依据行是 Chart / So。正文来自英文那一次生成。 +- 普通咨询钉住的模型版本解析不到时,改用当前默认模型,并把 `model_id` 写回会话(版本号仍由原触发器钉)。回退 id 与原 id 相同时先清空再写回,否则触发器留着旧版本。没有默认模型时仍是 503「模型暂不可用」。请求若仍带已删 id,不回 409;若改指另一个仍在线的模型,仍是 409「会话模型已经变化」。对话里没有新加提示。合盘追问和生时校正的解析没有改。打开页面时原有的「此前选择的模型已下线,已切换为默认模型。」没有动。 +- 选人页在开关结果回来之前只渲染标题和副标题。开关连不上时说「合盘服务暂时连不上,请稍后再试」。档案失败仍是「星盘档案暂时读不出来」。 +- 红线 3:`frontend/tests/database-synastry-full-create.test.ts` 用真的自托管客户端走 `createSynastryFullReport`,到预留成功、任务入队。模型目录打桩,产品和计费走真库。没有手写 `Query` 桩。 + +## 断言变更 + +| 测试 | 原值 | 新值 | 原因 | +| --- | --- | --- | --- | +| `synastry-full-report.test.ts` 选人页初始状态 | `[serviceClosed, setServiceClosed] = useState(false)` | `useState<"pending" \| "open" \| "closed" \| "unreachable">("pending")` | BUG-1403。开关未返回时不再画出选人区 | +| 同上,关闭与读档案的顺序 | `setServiceClosed(true)` 在 `fetchAccount()` 之前 | `setAccess("closed")` 在 `fetchAccount()` 之前 | 状态从布尔改成四态,关闭文案不变 | + +## 测试 + +| 检查 | 结果 | +| --- | --- | +| `tsc --noEmit` | 0 错 | +| `npm run lint` | 0 error,122 warning,与基线相同 | +| 定向:`consult-session-model-fallback.test.ts`、`synastry-full-report.test.ts` | 19 通过,0 失败,0 cancelled | +| 红线 3 单独跑 | 通过。本机 Docker 29.8.0。返回 202,报告 `generating`,任务 `queued`,预留 `reserved`,虚构账户点数 100 变为 81 | +| `npm run test:db` | 90 条,通过 74,失败 16,cancelled 0。新建的创建测试在全量里是通过的(ok 73)。16 条失败都是本机 Windows 环境:`G:\G:\` 路径被拼两次、WSL 里没有 bash、迁移脚本因此起不来。没有合盘业务断言失败 | +| 非库测试(`database-*` 以外的 `tests/*.test.ts(x)`,串行) | 5018 条,通过 4969,失败 49,cancelled 0。失败是同一类环境问题:`path.join` 吃了 file URL 的 pathname、tsx 解析 `tailwind-merge` 报错、WSL、shell/Docker。没有本轮新增测试的失败。一次把库测试和全量绑在一起、并发 2 的 `npm test` 跑到约 3327 条时进程退出,没有汇总行,不把它当成结果 | +| `npm run build` | `/` 为 Static(路由表 `○ /`)。`index.html` 引用的 `/_next/static` js/css 逐个 gzip -9:JS 576,261,CSS 70,491,合计 646,752。对照已记录的 643,725,差 +0.47%,在 ±2% 内。测量脚本没有留在仓库里,这个合计是按 `index.html` 引用的静态文件重算的 | + +隐私扫描 `tests/test_repo_privacy_markers.py` 没有跑:这个工作树和主检出都没有可用的 `.venv`。文档里没有真实个人出生资料。 + +## 真机复测(产品负责人,执行方未做,不得写成已通过) + +部署到 staging 之后: + +1. 婚恋合盘选两个人,点「开始合盘」,应进入等待页,而不是「报告生成暂时不可用」。 +2. 等待页走完,报告揭幕。关系时间线每一段能看出起止年月;若第一段从本月起,起点是「现在」。 +3. 切到 English。各节和时间线都有收起的 Analysis,点开能看到 Chart / So。 +4. 从报告末尾选一节进对话,发出问题,能按这一节问下去。 +5. 另:后台删掉某个非默认的已发布模型后,钉着它的旧咨询对话还能继续,并且改用当前默认模型。没有默认模型时才是「模型暂不可用」。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 8339e8a6..63900fb5 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -509,5 +509,5 @@ | `TASK-niryaana-shoola-pvr-20261018.md` | `PROGRESS-niryaana-shoola-pvr-20261018.md` | #84 Niryaana Shoola 改 PVR 口径(我们的表与 Shoola 逐行相同;推翻 BUG-1283 所选 profile;占星师 7a773d13) | 待验收 | `codex/niryaana-shoola-pvr-20261018` | | `TASK-synastry-full-report-20261018.md` | #82 | 婚恋合盘:选人页 + 完整合盘报告(关系画像九节:结论+折叠的分析逻辑+关系时间线+技术依据摘要;英文按需;计费键 synastry.full) | 排队(等 #78、#79、#81) | `codex/synastry-full-report-20261018`;动表 | | `TASK-synastry-report-chat-20261018.md` | `PROGRESS-synastry-report-chat-20261018.md` | 从合盘报告某一节进入对话问依据(绑定报告与节、对话里可用术语、只依据已存计算结果并逐条核对、计费照普通对话) | 已验收 | `549e37b99`;动表 | -| `TASK-synastry-staging-fix-20261021.md` | #85 | 婚恋合盘 staging 点「开始合盘」即失败(兼容层缺 `.gte`,P0 已由 Claude 修,BUG-1399)+ 时间线无起止、英文缺分析逻辑、删模型后旧对话改用默认模型(执行方从 T3 做起,BUG-1400 起) | P0 已推 staging;其余待执行 | `codex/synastry-staging-fix-20261021`;动表 | +| `TASK-synastry-staging-fix-20261021.md` | `PROGRESS-synastry-staging-fix-20261021.md` | #85 婚恋合盘 staging:P0 `.gte` 已在 staging(BUG-1399)。本分支补时间线起止、英文分析逻辑、删模型后旧对话改用默认模型、选人页开关文案,以及合盘创建真库测试(BUG-1400~1403) | 待验收 | `codex/synastry-staging-fix-20261021` | | `TASK-synastry-report-chat-fix-20261020.md` | `PROGRESS-synastry-report-chat-20261018.md` | 大运核对要认报告里的「小运」和英文 mahadasha / antardasha;预填文案只留一句 | 已验收 | `549e37b99` | diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index a9bffd04..d6af4424 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -2,7 +2,7 @@ ## 婚恋合盘报告(2026-10-19) -选人页标题下是副标题「看这段感情往后的走向,和两人之间的助力与考验。」两个人并排,中间一个 ×;窄屏改成上下排,× 不显示。各自是自定义列表,最多二十人,列表可滚动。资料不全的人灰掉,不能选;已经在另一边的人不能再选。列表最底一行是「+ 新增星盘」,点开一张卡片:称呼、性别(男 / 女)、出生日期、出生时间、出生地,右下角「取消」和「保存并选中」。满二十人时这一行变灰,并提示去星盘档案删人。「开始合盘」居中,点数说明在按钮正下方。之前的合盘只列人名和日期。生成中是轨道和四步(算两张完整命盘、整理两人之间的证据、写报告、检查报告),句子是「正在写 甲 × 乙 的合盘报告…」。报告页回到「‹ 婚恋合盘」,标题下是日期和「已保存,再次打开不扣点」,语言是「中文 / English」。九节编号,分析逻辑默认收起。英文准备时同一套轨道,写「正在写英文版…」,揭幕后整份替换。报告末尾「对哪一段想再问清楚?」,下面是八枚胶囊(`.synastry-report-ask`),不含共同练习。点一枚只新建对话并预填问题,不自动发送。对话里人物选择器锁住,显示「甲 × 乙」。合盘删除后输入框占位是「这份合盘已删除」,发送停用。 +选人页标题下是副标题「看这段感情往后的走向,和两人之间的助力与考验。」两个人并排,中间一个 ×;窄屏改成上下排,× 不显示。各自是自定义列表,最多二十人,列表可滚动。资料不全的人灰掉,不能选;已经在另一边的人不能再选。列表最底一行是「+ 新增星盘」,点开一张卡片:称呼、性别(男 / 女)、出生日期、出生时间、出生地,右下角「取消」和「保存并选中」。满二十人时这一行变灰,并提示去星盘档案删人。「开始合盘」居中,点数说明在按钮正下方。之前的合盘只列人名和日期。生成中是轨道和四步(算两张完整命盘、整理两人之间的证据、写报告、检查报告),句子是「正在写 甲 × 乙 的合盘报告…」。报告页回到「‹ 婚恋合盘」,标题下是日期和「已保存,再次打开不扣点」,语言是「中文 / English」。九节编号,分析逻辑默认收起。英文报告同样收起分析逻辑(Analysis,依据行是 Chart 和 So),时间线每段标题写出起止年月;第一段从本月起时,起点写「现在」或 Now。英文准备时同一套轨道,写「正在写英文版…」,揭幕后整份替换。报告末尾「对哪一段想再问清楚?」,下面是八枚胶囊(`.synastry-report-ask`),不含共同练习。点一枚只新建对话并预填问题,不自动发送。对话里人物选择器锁住,显示「甲 × 乙」。合盘删除后输入框占位是「这份合盘已删除」,发送停用。 ## 生时校正从对话进入、做完回到原对话(2026-10-07,BUG-1247~1251,在途) diff --git a/frontend/docs/VOICE.md b/frontend/docs/VOICE.md index 3c0c3e9e..be7f7c22 100644 --- a/frontend/docs/VOICE.md +++ b/frontend/docs/VOICE.md @@ -34,7 +34,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 - 吵完之后,怎样递出台阶才不会变成认输? - 过日子的安排里,哪一件需要现在就说清楚? -报告末尾「对哪一段想再问清楚?」列出前八节(含关系时间线,不含共同练习)。点进去是一条新对话,输入框预先写好这句,用户自己发送。中文预填是「合盘报告里「节名」这一段,在我们两人的盘里依据是什么?」英文报告用英文问同一件事。对话里可以说星曜、宫位、九分盘、大运,但只能引用这份报告已经写下的计算。材料里没有的,说「这份合盘没算到」。对不上的盘面,说「这一点在这份合盘里没有算到,我不能确定」。报告删了之后,输入框写「这份合盘已删除」,不能再发送。英文还没写好时只说「英文版即将提供」。选人页副标题是「看这段感情往后的走向,和两人之间的助力与考验。」按钮是「开始合盘」。点数说明是「生成一份完整合盘报告,用掉 N 点。大约要几分钟。」,N 从配置读。没有配置时说「计费配置不可用」。下拉占位是「选择一个人」。资料不齐写「资料未齐」,已经选在另一边写「已在另一边」。档案满了写「档案已满(最多 20 人)」,旁边是「去星盘档案删一个」。新增星盘卡片用「称呼」「性别」「出生日期」「出生时间」「出生地」,按钮是「取消」和「保存并选中」。产品关着时说「合盘服务暂未开放」。英文次数用尽时说「英文版暂时无法再试」。生成中说「正在写 甲 × 乙 的合盘报告…」,四步是「算两张完整命盘」「整理两人之间的证据」「写报告」「检查报告」,下面是「大约要几分钟。可以先离开,写好后在「之前的合盘」和「报告」里都能找到。」英文等待说「正在写英文版…」和「第一次切到英文时才写,不扣点,大约一两分钟。」报告标题下是「已保存,再次打开不扣点」。 +报告末尾「对哪一段想再问清楚?」列出前八节(含关系时间线,不含共同练习)。点进去是一条新对话,输入框预先写好这句,用户自己发送。中文预填是「合盘报告里「节名」这一段,在我们两人的盘里依据是什么?」英文报告用英文问同一件事。对话里可以说星曜、宫位、九分盘、大运,但只能引用这份报告已经写下的计算。材料里没有的,说「这份合盘没算到」。对不上的盘面,说「这一点在这份合盘里没有算到,我不能确定」。报告删了之后,输入框写「这份合盘已删除」,不能再发送。英文还没写好时只说「英文版即将提供」。选人页副标题是「看这段感情往后的走向,和两人之间的助力与考验。」按钮是「开始合盘」。点数说明是「生成一份完整合盘报告,用掉 N 点。大约要几分钟。」,N 从配置读。没有配置时说「计费配置不可用」。下拉占位是「选择一个人」。资料不齐写「资料未齐」,已经选在另一边写「已在另一边」。档案满了写「档案已满(最多 20 人)」,旁边是「去星盘档案删一个」。新增星盘卡片用「称呼」「性别」「出生日期」「出生时间」「出生地」,按钮是「取消」和「保存并选中」。产品关着时说「合盘服务暂未开放」。开关读不到时说「合盘服务暂时连不上,请稍后再试」。开关结果回来之前,选人页只显示标题和副标题。英文次数用尽时说「英文版暂时无法再试」。生成中说「正在写 甲 × 乙 的合盘报告…」,四步是「算两张完整命盘」「整理两人之间的证据」「写报告」「检查报告」,下面是「大约要几分钟。可以先离开,写好后在「之前的合盘」和「报告」里都能找到。」英文等待说「正在写英文版…」和「第一次切到英文时才写,不扣点,大约一两分钟。」报告标题下是「已保存,再次打开不扣点」。 ## 生时校正从对话进入(2026-10-07,BUG-1247~1251;2026-10-12,BUG-1306) diff --git a/frontend/src/app/api/consult/route.ts b/frontend/src/app/api/consult/route.ts index c1849b39..edd51fec 100644 --- a/frontend/src/app/api/consult/route.ts +++ b/frontend/src/app/api/consult/route.ts @@ -16,6 +16,7 @@ import { consultPrepObservabilityEvent, type ConsultPrepPhase, } from "@/lib/consult/prep-log"; +import { reconcileConsultSessionModel } from "@/lib/consult/session-model-fallback"; import { loadConsultTurnHead } from "@/lib/consult/turn-prep"; import { consultationCompletionSchema, @@ -286,6 +287,7 @@ export async function POST(request: Request) { const prepParallel = consultPrepParallelEnabled(); const prepPhases: ConsultPrepPhase[] = []; + let consultUserId = ""; const synastryHandoff: { current: { userId: string; @@ -304,6 +306,7 @@ export async function POST(request: Request) { readPendingDeletion: (userId) => refuseWhenAccountDeletionPending(userId), readBody: async () => chatRequestSchema.safeParse(await request.json().catch(() => null)), readSession: async (userId, parsed) => { + consultUserId = userId; const result = await supabase .from("chat_sessions") .select("id,model_id,model_config_version,session_type,messages,title,theme,chart_profile_id,chart_profile_name,chart_profile_role,context_summary,synastry_report_id,synastry_section,synastry_partner_subject_id") @@ -333,6 +336,29 @@ export async function POST(request: Request) { chatSession.model_config_version, ); }, + reconcileModel: async (chatSession, requestedModelId) => { + const sessionId = "id" in chatSession && typeof chatSession.id === "string" ? chatSession.id : ""; + const decision = await reconcileConsultSessionModel({ + session: { ...chatSession, id: sessionId }, + requestedModelId, + resolveBound: resolveSessionLanguageModel, + loadCatalog: loadLanguageModelCatalog, + persistModelId: async (id, modelId) => { + const updated = await supabase + .from("chat_sessions") + .update({ model_id: modelId }) + .eq("id", id) + .eq("user_id", consultUserId); + return !updated.error; + }, + }); + if (decision.kind !== "ready") return decision; + return { + kind: "ready" as const, + chatSession: decision.session, + sessionModel: decision.model, + }; + }, }); if (!head.ok) { // A pending account deletion freezes the account: nothing new is charged. diff --git a/frontend/src/components/synastry/synastry-picker.tsx b/frontend/src/components/synastry/synastry-picker.tsx index c3dc8832..541768bc 100644 --- a/frontend/src/components/synastry/synastry-picker.tsx +++ b/frontend/src/components/synastry/synastry-picker.tsx @@ -29,21 +29,27 @@ export function SynastryPicker() { const [creditCost, setCreditCost] = useState(null); const [pricingNote, setPricingNote] = useState(""); const [earlier, setEarlier] = useState([]); - const [serviceClosed, setServiceClosed] = useState(false); + const [access, setAccess] = useState<"pending" | "open" | "closed" | "unreachable">("pending"); const pickRef = useRef(null); async function load(prefer?: { id: string; slot: Slot }) { - const priceResponse = await fetch("/api/product-access", { cache: "no-store" }); + let priceResponse: Response; + try { + priceResponse = await fetch("/api/product-access", { cache: "no-store" }); + } catch { + setAccess("unreachable"); + return; + } const price = await priceResponse.json().catch(() => null) as { compatibility?: boolean; synastryCreditCost?: number | null } | null; if (priceResponse.ok && price?.compatibility === false) { - setServiceClosed(true); + setAccess("closed"); return; } if (!priceResponse.ok || price?.compatibility !== true) { - setMessage("星盘档案暂时读不出来"); + setAccess("unreachable"); return; } - setServiceClosed(false); + setAccess("open"); if (typeof price.synastryCreditCost === "number") { setCreditCost(price.synastryCreditCost); setPricingNote(""); @@ -161,26 +167,24 @@ export function SynastryPicker() { } } - if (serviceClosed) { - return ( - <> - -

合盘服务暂未开放

- - ); - } - const archiveFull = people.filter((person) => person.role === "other").length >= MAX_SAVED_SUBJECTS - 1; const ready = Boolean(left && right && left !== right) && !personIncomplete(people, left) && !personIncomplete(people, right); + const heading = ( +
+

婚恋合盘

+

看这段感情往后的走向,和两人之间的助力与考验。

+
+ ); return ( <>
-
-

婚恋合盘

-

看这段感情往后的走向,和两人之间的助力与考验。

-
+ {heading} + {access === "closed" ?

合盘服务暂未开放

: null} + {access === "unreachable" ?

合盘服务暂时连不上,请稍后再试

: null} + {access === "open" ? ( + <>
) : null} + + ) : null}
{adding ? ( (props.initialLanguage ?? "zh"); @@ -109,6 +123,7 @@ export function SynastryReportView(props: { } if (!snapshot) return null; + const today = props.clock ?? new Date(); const body: LanguageBody | null = language === "en" && english ? english : snapshot; if (!body) return null; const names = snapshot.names ?? {}; @@ -137,19 +152,17 @@ export function SynastryReportView(props: {

{heading(index, section.title || SECTION_TITLES[id][language], language)}

{section.text}

- {language === "zh" ? ( -
- 分析逻辑 -
    - {(section.reasons ?? []).map((reason, reasonIndex) => ( -
  1. -

    盘面:{reason.basis}

    -

    所以:{reason.plain}

    -
  2. - ))} -
-
- ) : null} +
+ {analysisTitle(language)} +
    + {(section.reasons ?? []).map((reason, reasonIndex) => ( +
  1. +

    {chartLine(language, reason.basis)}

    +

    {soLine(language, reason.plain)}

    +
  2. + ))} +
+
); })} @@ -157,16 +170,21 @@ export function SynastryReportView(props: {

{heading(7, SECTION_TITLES.timeline[language], language)}

{language === "zh" ?

把两个人各自走到的人生阶段放在一起看,这段关系在哪段时间会是什么样子。说的是倾向和条件,不是会发生什么的预言。

: null}
    - {(body.timeline ?? []).map((segment) => ( + {(body.timeline ?? []).map((segment, index) => (
  1. -
    {segment.phase}
    +
    {timelineHeading({ + start: segment.start, + end: segment.end, + phase: segment.phase, + language, + firstSegment: index === 0, + today, + })}

    {segment.text}

    - {language === "zh" ? ( -
    - 分析逻辑 -

    盘面:{segment.basis}

    -
    - ) : null} +
    + {analysisTitle(language)} +

    {chartLine(language, segment.basis)}

    +
  2. ))}
diff --git a/frontend/src/lib/consult/session-model-fallback.ts b/frontend/src/lib/consult/session-model-fallback.ts new file mode 100644 index 00000000..a562945e --- /dev/null +++ b/frontend/src/lib/consult/session-model-fallback.ts @@ -0,0 +1,66 @@ +import type { LanguageModelCatalog, ResolvedLanguageModel } from "@/mastra/model"; + +export type SessionModelBinding = { + id: string; + model_id: string | null; + model_config_version?: number | null; +}; + +export type ConsultModelDecision = + | { kind: "changed" } + | { kind: "unavailable" } + | { kind: "ready"; session: Session & { model_id: string }; model: ResolvedLanguageModel }; + +/** + * A deleted or unpublished pinned version cannot answer the next turn. + * Keep going on the current default model and store that choice on the session. + * A request that still names a live, different model stays a model-changed refusal. + */ +export async function reconcileConsultSessionModel(input: { + session: Session; + requestedModelId: string; + resolveBound: (modelId: string, configVersion: number | null | undefined) => Promise; + loadCatalog: () => Promise; + persistModelId: (sessionId: string, modelId: string | null) => Promise; +}): Promise> { + if (!input.session.model_id) return { kind: "changed" }; + + const bound = await input.resolveBound(input.session.model_id, input.session.model_config_version); + let model = bound; + let modelId = input.session.model_id; + let configVersion = input.session.model_config_version ?? null; + + if (!model) { + const catalog = await input.loadCatalog(); + const fallback = catalog.defaultModelId + ? catalog.models.find((entry) => entry.id === catalog.defaultModelId) ?? null + : null; + if (!fallback) return { kind: "unavailable" }; + // The pin trigger keeps the old version when the model id does not change. + // Clear it first so the following write stores the live published version. + if (fallback.id === input.session.model_id) { + const cleared = await input.persistModelId(input.session.id, null); + if (!cleared) return { kind: "unavailable" }; + } + const saved = await input.persistModelId(input.session.id, fallback.id); + if (!saved) return { kind: "unavailable" }; + model = fallback; + modelId = fallback.id; + configVersion = fallback.configVersion ?? null; + } + + if (input.requestedModelId !== modelId) { + const requested = await input.resolveBound(input.requestedModelId, null); + if (requested) return { kind: "changed" }; + } + + return { + kind: "ready", + model, + session: { + ...input.session, + model_id: modelId, + model_config_version: configVersion, + }, + }; +} diff --git a/frontend/src/lib/consult/turn-prep.ts b/frontend/src/lib/consult/turn-prep.ts index fe79768c..3f662621 100644 --- a/frontend/src/lib/consult/turn-prep.ts +++ b/frontend/src/lib/consult/turn-prep.ts @@ -46,6 +46,14 @@ export type ConsultTurnHeadDeps Promise>; readSession: (userId: string, parsed: Parsed) => Promise>; resolveModel: (chatSession: Session) => Promise; + reconcileModel?: ( + chatSession: Session, + requestedModelId: string, + ) => Promise< + | { kind: "changed" } + | { kind: "unavailable" } + | { kind: "ready"; chatSession: Session & { model_id: string }; sessionModel: Model } + >; }; export type ConsultTurnHead = @@ -157,6 +165,19 @@ export async function loadConsultTurnHead< if (!session.data) return { ok: false, response: sessionMissing() }; const chatSession = session.data; if (chatSession.session_type !== "consultation") return { ok: false, response: notConsultation() }; + if (deps.reconcileModel) { + const modelStarted = Date.now(); + const reconciled = await deps.reconcileModel(chatSession, body.data.modelId); + timings.modelMs = elapsedSince(modelStarted); + if (reconciled.kind === "changed") return { ok: false, response: modelChanged() }; + if (reconciled.kind === "unavailable") return { ok: false, response: modelUnavailable() }; + return { + ok: true, + parsed: body, + chatSession: reconciled.chatSession, + sessionModel: reconciled.sessionModel, + }; + } if (!chatSession.model_id || chatSession.model_id !== body.data.modelId) { return { ok: false, response: modelChanged() }; } diff --git a/frontend/src/lib/synastry-timeline-label.ts b/frontend/src/lib/synastry-timeline-label.ts new file mode 100644 index 00000000..566364ec --- /dev/null +++ b/frontend/src/lib/synastry-timeline-label.ts @@ -0,0 +1,40 @@ +const EN_MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] as const; + +function monthParts(value: string): { year: number; month: number } | null { + const match = /^(\d{4})-(\d{2})/.exec(value); + if (!match) return null; + const month = Number(match[2]); + if (month < 1 || month > 12) return null; + return { year: Number(match[1]), month }; +} + +function monthKey(date: Date): string { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`; +} + +function endpoint(value: string, language: "zh" | "en", now: boolean): string { + if (now) return language === "zh" ? "现在" : "Now"; + const parsed = monthParts(value); + if (!parsed) return value; + if (language === "en") return `${EN_MONTHS[parsed.month - 1]} ${parsed.year}`; + return `${parsed.year} 年 ${parsed.month} 月`; +} + +/** Title line for one relationship-timeline segment. The first segment says Now when it starts this month. */ +export function timelineHeading(input: { + start?: string; + end?: string; + phase?: string; + language: "zh" | "en"; + firstSegment: boolean; + today: Date; +}): string { + const phase = input.phase?.trim() ?? ""; + const start = input.start ?? ""; + const end = input.end ?? ""; + if (!start || !end) return phase; + const now = input.firstSegment && start.slice(0, 7) === monthKey(input.today); + const range = `${endpoint(start, input.language, now)} – ${endpoint(end, input.language, false)}`; + if (!phase) return range; + return input.language === "zh" ? `${range} ${phase}` : `${range} ${phase}`; +} diff --git a/frontend/tests/consult-session-model-fallback.test.ts b/frontend/tests/consult-session-model-fallback.test.ts new file mode 100644 index 00000000..5e748e3d --- /dev/null +++ b/frontend/tests/consult-session-model-fallback.test.ts @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { reconcileConsultSessionModel } from "../src/lib/consult/session-model-fallback.ts"; +import { loadConsultTurnHead } from "../src/lib/consult/turn-prep.ts"; +import type { ResolvedLanguageModel } from "../src/mastra/model.ts"; + +function model(id: string, version: number): ResolvedLanguageModel { + return { + id, + label: id, + description: "", + creditCost: 1, + isDefault: id === "default-model", + mode: "openai", + model: { providerId: "openai", modelId: id, apiKey: "sk-test" }, + configVersion: version, + }; +} + +test("a session pinned to a deleted version continues on the default model", async () => { + const writes: Array = []; + const decision = await reconcileConsultSessionModel({ + session: { id: "session-1", model_id: "retired-model", model_config_version: 4 }, + requestedModelId: "retired-model", + resolveBound: async (modelId) => modelId === "retired-model" ? null : model(modelId, 1), + loadCatalog: async () => ({ + models: [model("default-model", 2)], + publicModels: [], + defaultModelId: "default-model", + issues: [], + }), + persistModelId: async (_sessionId, modelId) => { + writes.push(modelId); + return true; + }, + }); + assert.equal(decision.kind, "ready"); + if (decision.kind !== "ready") return; + assert.equal(decision.model.id, "default-model"); + assert.equal(decision.session.model_id, "default-model"); + assert.equal(decision.session.model_config_version, 2); + assert.deepEqual(writes, ["default-model"]); +}); + +test("the next turn still uses the default when the client sends the deleted model id", async () => { + const decision = await reconcileConsultSessionModel({ + session: { id: "session-1", model_id: "default-model", model_config_version: 2 }, + requestedModelId: "retired-model", + resolveBound: async (modelId, version) => { + if (modelId === "retired-model") return null; + if (modelId === "default-model" && version === 2) return model("default-model", 2); + if (modelId === "default-model" && version == null) return model("default-model", 2); + return null; + }, + loadCatalog: async () => ({ models: [], publicModels: [], defaultModelId: null, issues: [] }), + persistModelId: async () => { + throw new Error("persist should not run"); + }, + }); + assert.equal(decision.kind, "ready"); + if (decision.kind !== "ready") return; + assert.equal(decision.model.id, "default-model"); +}); + +test("a live different model is still refused as a model change", async () => { + const decision = await reconcileConsultSessionModel({ + session: { id: "session-1", model_id: "default-model", model_config_version: 2 }, + requestedModelId: "other-model", + resolveBound: async (modelId) => model(modelId, modelId === "other-model" ? 1 : 2), + loadCatalog: async () => ({ models: [], publicModels: [], defaultModelId: null, issues: [] }), + persistModelId: async () => false, + }); + assert.equal(decision.kind, "changed"); +}); + +test("a deleted version with no default model still reports the model as unavailable", async () => { + const head = await loadConsultTurnHead({ + parallel: false, + getUser: async () => ({ data: { user: { id: "user-1" } }, error: null }), + readPendingDeletion: async () => null, + readBody: async () => ({ success: true as const, data: { sessionId: "session-1", modelId: "retired-model" } }), + readSession: async () => ({ + data: { id: "session-1", model_id: "retired-model", model_config_version: 4, session_type: "consultation" }, + error: null, + }), + resolveModel: async (): Promise => null, + reconcileModel: async (chatSession, requestedModelId) => { + const decision = await reconcileConsultSessionModel({ + session: chatSession, + requestedModelId, + resolveBound: async () => null, + loadCatalog: async () => ({ models: [], publicModels: [], defaultModelId: null, issues: ["default_model_unavailable"] }), + persistModelId: async () => false, + }); + if (decision.kind !== "ready") return decision; + return { kind: "ready" as const, chatSession: decision.session, sessionModel: decision.model }; + }, + }); + assert.equal(head.ok, false); + if (head.ok) return; + assert.equal(head.response.status, 503); + const body = await head.response.json() as { error?: string }; + assert.equal(body.error, "模型暂不可用"); +}); diff --git a/frontend/tests/database-synastry-full-create.test.ts b/frontend/tests/database-synastry-full-create.test.ts new file mode 100644 index 00000000..73ae711a --- /dev/null +++ b/frontend/tests/database-synastry-full-create.test.ts @@ -0,0 +1,139 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { register } from "node:module"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { closeLocalPostgresDataPool, createLocalPostgresDataClient } from "../src/lib/db/local-postgres-client-core.ts"; +import { startPostgresFixture, type PostgresFixture } from "./helpers/postgres-fixture.ts"; + +// The synastry create path must reach a real reservation and a queued job +// through the self-hosted client. The model catalog is stubbed. Fictional rows only. +const runner = fileURLToPath(new URL("../scripts/db-migrate.mjs", import.meta.url)); +const docker = spawnSync("docker", ["version", "--format", "{{.Server.Version}}"], { stdio: "ignore" }).status === 0; +const MODEL_ID = "synastry-fixture-model"; + +function applyMigrations(fixture: PostgresFixture): void { + const result = spawnSync(process.execPath, [runner], { + encoding: "utf8", + env: { + ...process.env, + SCHEMA_DATABASE_URL: fixture.connectionUrl("schema_owner", "schema-owner-test-password"), + }, + }); + assert.equal(result.status, 0, result.stderr || result.stdout); +} + +test("createSynastryFullReport reserves synastry.full and enqueues a job on the real client", { + skip: docker ? false : "docker unavailable", +}, async () => { + await register(new URL("./support/stub-server-only.mjs", import.meta.url).href, import.meta.url); + const fixture = startPostgresFixture(); + const serviceUrl = fixture.connectionUrl("service_runtime", "service-runtime-test-password"); + const appUrl = fixture.connectionUrl("app_runtime", "app-runtime-test-password"); + const adminUrl = fixture.connectionUrl("admin_runtime", "admin-runtime-test-password"); + const previousAuth = process.env.AUTH_PROVIDER; + const previousAdminUrl = process.env.ADMIN_DATABASE_URL; + process.env.AUTH_PROVIDER = "self-hosted"; + process.env.ADMIN_DATABASE_URL = adminUrl; + try { + applyMigrations(fixture); + const userId = randomUUID(); + const personId = randomUUID(); + fixture.psql(` + insert into identity.users (id, name, email, email_verified, email_verified_at) + values ('${userId}', '虚构甲', '${userId}@example.invalid', true, now()); + update public.profiles set + name = '虚构甲', + credits = 100, + birth_date = '1991-04-07', + reported_birth_time = '09:15', + birth_time_status = 'reported', + birth_time_source = 'family_exact', + latitude = 31.2, + longitude = 121.4, + timezone_offset = 8, + timezone_id = 'Asia/Shanghai', + ayanamsa = 'lahiri', + birth_place_label = '虚构市' + where id = '${userId}'; + insert into public.chart_profiles ( + id, user_id, role, profile, updated_at, name, birth_date, reported_birth_time, + birth_time_source, latitude, longitude, timezone_id, timezone_offset, + birth_place_label, ayanamsa, birth_time_status + ) values ( + '${personId}', '${userId}', 'other', '{"name":"虚构乙"}'::jsonb, now(), + '虚构乙', '1992-05-06', '10:20', 'family_exact', 22.3, 114.1, + 'Asia/Shanghai', 8, '虚构港', 'lahiri', 'reported' + ); + with provider as ( + insert into public.model_providers (code, name, provider_type, encrypted_api_key, enabled) + values ('synastry-fixture', 'Synastry Fixture', 'openai', 'test-ciphertext', true) + returning id + ), config as ( + insert into public.model_configs (model_id) values ('${MODEL_ID}') returning id + ) + insert into public.model_config_versions ( + config_id, version, provider_id, label, provider_model, enabled, is_default, status, published_at + ) + select config.id, 1, provider.id, 'Fixture', 'gpt-test', true, true, 'published', now() + from config cross join provider; + `); + + const catalog = await import("../src/lib/model-catalog.ts"); + catalog.setModelCatalogIoForTests({ + queryAdminRows: (async () => [{ + model_id: MODEL_ID, + version: 1, + label: "Fixture", + description: "", + provider_model: "gpt-test", + credit_cost: 1, + is_default: true, + provider_code: "synastry-fixture", + provider_type: "openai" as const, + base_url: null, + encrypted_api_key: "test-ciphertext", + input_cost: 0, + output_cost: 0, + context_window: null, + }]) as typeof catalog.setModelCatalogIoForTests extends (next: infer T) => void + ? T extends { queryAdminRows?: infer Q } ? Q : never + : never, + decryptModelProviderApiKey: () => "sk-test", + }); + catalog.invalidateLanguageModelCatalog(); + + const { createSynastryFullReport } = await import("../src/lib/synastry-full-create.ts"); + const user = createLocalPostgresDataClient(appUrl, { id: userId, email: `${userId}@example.invalid` }); + const admin = createLocalPostgresDataClient(serviceUrl, null, "service_role"); + const created = await createSynastryFullReport({ + userId, + body: { reportType: "synastry_full", subjectA: "self", subjectB: personId }, + supabase: user, + admin, + }); + assert.equal(created.status, 202, JSON.stringify(created.body)); + const report = created.body.report as { id?: string; status?: string } | undefined; + assert.equal(typeof report?.id, "string"); + assert.equal(report?.status, "generating"); + assert.equal(typeof created.body.jobId, "string"); + assert.equal(fixture.psql(`select status from public.personal_reports where id = '${report?.id}'`), "generating"); + assert.equal(fixture.psql(`select status from public.personal_report_jobs where user_id = '${userId}'`), "queued"); + assert.equal(fixture.psql(`select status from public.usage_reservations where user_id = '${userId}'`), "reserved"); + assert.equal(fixture.psql(`select credits from public.profiles where id = '${userId}'`), "81"); + catalog.setModelCatalogIoForTests(null); + catalog.invalidateLanguageModelCatalog(); + } finally { + const database = await import("../src/lib/admin/database.ts"); + await database.adminDatabasePool().end().catch(() => undefined); + if (previousAuth === undefined) delete process.env.AUTH_PROVIDER; + else process.env.AUTH_PROVIDER = previousAuth; + if (previousAdminUrl === undefined) delete process.env.ADMIN_DATABASE_URL; + else process.env.ADMIN_DATABASE_URL = previousAdminUrl; + await closeLocalPostgresDataPool(serviceUrl); + await closeLocalPostgresDataPool(appUrl); + fixture.stop(); + } +}); diff --git a/frontend/tests/synastry-full-report.test.ts b/frontend/tests/synastry-full-report.test.ts index 25dd135d..c67137f0 100644 --- a/frontend/tests/synastry-full-report.test.ts +++ b/frontend/tests/synastry-full-report.test.ts @@ -7,10 +7,13 @@ import { renderToStaticMarkup } from "react-dom/server"; import { createElement } from "react"; import { SynastryReportView } from "../src/components/synastry/synastry-report-view.tsx"; +import { SynastryPicker } from "../src/components/synastry/synastry-picker.tsx"; +import { SidebarProvider } from "../src/components/ui/sidebar.tsx"; import { writeSynastryLanguage } from "../src/lib/synastry-full-generate.ts"; import { EVIDENCE_LABELS, PRIVATE_KEY_NAMES, sectionEvidence, SYNASTRY_LENGTH, systemPrompt, userPrompt, SYNASTRY_SECTION_IDS, type SynastrySectionId } from "../src/lib/synastry-full-sections.ts"; import { buildTechnicalBasis } from "../src/lib/synastry-full-technical.ts"; import { buildTimelineSegments } from "../src/lib/synastry-full-timeline.ts"; +import { timelineHeading } from "../src/lib/synastry-timeline-label.ts"; const sections = ["climate", "emotion", "communication", "safety", "conflict", "daily", "direction"] as const; @@ -113,10 +116,17 @@ test("the synastry page is a static shell and reads the flag in the browser", () assert.doesNotMatch(page, /isProductEnabled|合盘服务暂未开放|force-dynamic/); assert.match(page, //); assert.match(picker, /\/api\/product-access/); - assert.match(picker, /\[serviceClosed, setServiceClosed\] = useState\(false\)/); + // 原值:`[serviceClosed, setServiceClosed] = useState(false)`,首屏就画出选人区 + // 新值:access 从 pending 开始,开关结果回来前只有标题和副标题 + // 原因:BUG-1403。开关未返回时不再闪一下选人页;读失败改「合盘服务暂时连不上,请稍后再试」 + assert.match(picker, /useState<"pending" \| "open" \| "closed" \| "unreachable">\("pending"\)/); assert.match(picker, /compatibility === false/); assert.match(picker, /合盘服务暂未开放/); - assert.ok(picker.indexOf("setServiceClosed(true)") < picker.indexOf("fetchAccount()")); + assert.match(picker, /合盘服务暂时连不上,请稍后再试/); + assert.ok(picker.indexOf('setAccess("closed")') < picker.indexOf("fetchAccount()")); + const pending = renderToStaticMarkup(createElement(SidebarProvider, null, createElement(SynastryPicker))); + assert.match(pending, /看这段感情往后的走向,和两人之间的助力与考验。/); + assert.doesNotMatch(pending, /开始合盘|第一个人/); assert.match(picker, /看这段感情往后的走向,和两人之间的助力与考验。/); assert.match(picker, /开始合盘/); assert.match(picker, /+ 新增星盘/); @@ -177,6 +187,117 @@ test("report view keeps analysis folded and offers a section ask entry", () => { assert.match(html, /可以一起聊聊/); }); +const celebrityPeriods = { + nameA: "Albert", + nameB: "Mileva", + from: "1905-06-01", + periodsA: [ + { level: "md" as const, lord: "Saturn", lordCn: "土星", start: "1903-01-01", end: "1922-01-01" }, + { level: "ad" as const, lord: "Venus", lordCn: "金星", start: "1905-03-01", end: "1907-11-01" }, + ], + periodsB: [ + { level: "md" as const, lord: "Jupiter", lordCn: "木星", start: "1900-01-01", end: "1916-01-01" }, + { level: "ad" as const, lord: "Moon", lordCn: "月亮", start: "1905-01-01", end: "1906-06-01" }, + ], +}; + +test("timeline headings show the month span, and the first segment says now when it starts this month", () => { + const today = new Date(2026, 9, 21); + assert.equal(timelineHeading({ + start: "2027-09", + end: "2029-03", + phase: "阶段名", + language: "zh", + firstSegment: true, + today, + }), "2027 年 9 月 – 2029 年 3 月 阶段名"); + assert.equal(timelineHeading({ + start: "2027-09", + end: "2029-03", + phase: "Settling", + language: "en", + firstSegment: true, + today, + }), "Sep 2027 – Mar 2029 Settling"); + assert.equal(timelineHeading({ + start: "2026-10", + end: "2029-03", + phase: "阶段名", + language: "zh", + firstSegment: true, + today, + }), "现在 – 2029 年 3 月 阶段名"); + assert.equal(timelineHeading({ + start: "2026-10", + end: "2029-03", + phase: "Settling", + language: "en", + firstSegment: true, + today, + }), "Now – Mar 2029 Settling"); + assert.equal(timelineHeading({ + start: "2026-10", + end: "2029-03", + phase: "阶段名", + language: "zh", + firstSegment: false, + today, + }), "2026 年 10 月 – 2029 年 3 月 阶段名"); + + const segments = buildTimelineSegments(celebrityPeriods); + const clock = new Date(1905, 5, 15); + const html = renderToStaticMarkup(createElement(SynastryReportView, { + reportId: "11111111-1111-4111-8111-111111111111", + clock, + snapshot: { + names: { a: "Albert", b: "Mileva" }, + sections: sections.map((id) => ({ id, title: id, text: "你们之间先把话说完。", reasons: prose().reasons })), + timeline: segments.map((segment, index) => ({ + ...segment, + phase: index === 0 ? "靠近" : "磨合", + text: "这段时间先把日常说清楚。", + })), + practices: [{ text: "每周谈一次。", evidence: "证据甲" }], + technical: buildTechnicalBasis({ dossier: {}, names: { a: "Albert", b: "Mileva" }, contacts: [], timing: [] }), + }, + })); + assert.match(html, /现在 – 1906 年 6 月 靠近/); + assert.match(html, /Albert × Mileva/); + assert.equal(html.includes("1905 年 6 月 –"), false); +}); + +test("english report keeps an analysis fold on every prose section and on the timeline", () => { + const segments = buildTimelineSegments({ ...celebrityPeriods, language: "en" }); + const englishSections = sections.map((id) => ({ + id, + title: id, + text: "Say the next step out loud.", + reasons: [{ plain: "Confirm the other person finished listening.", basis: "Venus contact" }], + })); + const html = renderToStaticMarkup(createElement(SynastryReportView, { + reportId: "11111111-1111-4111-8111-111111111111", + initialLanguage: "en", + clock: new Date(1905, 5, 15), + snapshot: { + names: { a: "Albert", b: "Mileva" }, + sections: englishSections, + timeline: segments.map((segment) => ({ ...segment, phase: "Closer", text: "Name the daily rhythm." })), + practices: [{ text: "Talk once a week.", evidence: "evidence" }], + english: { + sections: englishSections, + timeline: segments.map((segment) => ({ ...segment, phase: "Closer", text: "Name the daily rhythm." })), + practices: [{ text: "Talk once a week.", evidence: "evidence" }], + }, + }, + })); + assert.equal(html.match(/Analysis<\/summary>/g)?.length, englishSections.length + segments.length); + assert.match(html, /Chart: Venus contact/); + assert.match(html, /So: Confirm the other person finished listening\./); + assert.match(html, /Now – Jun 1906 Closer/); + assert.equal(html.includes("
{ const dossier = { relationship_axis: { lane: "closeness" },