fix(synastry): show timeline spans and keep old chats on the default model
English reports hid the analysis fold, timeline titles left out the month span, and deleting a pinned model stopped the next consultation turn. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+9
-1
@@ -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 点
|
||||
|
||||
+65
-17
@@ -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 没写这条。
|
||||
- 修复:中英文都用 `<details class="synastry-why">`,默认收起。英文摘要是 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`。
|
||||
|
||||
|
||||
@@ -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」。
|
||||
- 英文报告恢复收起的分析逻辑,和时间线依据同一套 `<details class="synastry-why">`。英文摘要是 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. 另:后台删掉某个非默认的已发布模型后,钉着它的旧咨询对话还能继续,并且改用当前默认模型。没有默认模型时才是「模型暂不可用」。
|
||||
@@ -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` |
|
||||
|
||||
+1
-1
@@ -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,在途)
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
|
||||
- 吵完之后,怎样递出台阶才不会变成认输?
|
||||
- 过日子的安排里,哪一件需要现在就说清楚?
|
||||
|
||||
报告末尾「对哪一段想再问清楚?」列出前八节(含关系时间线,不含共同练习)。点进去是一条新对话,输入框预先写好这句,用户自己发送。中文预填是「合盘报告里「节名」这一段,在我们两人的盘里依据是什么?」英文报告用英文问同一件事。对话里可以说星曜、宫位、九分盘、大运,但只能引用这份报告已经写下的计算。材料里没有的,说「这份合盘没算到」。对不上的盘面,说「这一点在这份合盘里没有算到,我不能确定」。报告删了之后,输入框写「这份合盘已删除」,不能再发送。英文还没写好时只说「英文版即将提供」。选人页副标题是「看这段感情往后的走向,和两人之间的助力与考验。」按钮是「开始合盘」。点数说明是「生成一份完整合盘报告,用掉 N 点。大约要几分钟。」,N 从配置读。没有配置时说「计费配置不可用」。下拉占位是「选择一个人」。资料不齐写「资料未齐」,已经选在另一边写「已在另一边」。档案满了写「档案已满(最多 20 人)」,旁边是「去星盘档案删一个」。新增星盘卡片用「称呼」「性别」「出生日期」「出生时间」「出生地」,按钮是「取消」和「保存并选中」。产品关着时说「合盘服务暂未开放」。英文次数用尽时说「英文版暂时无法再试」。生成中说「正在写 甲 × 乙 的合盘报告…」,四步是「算两张完整命盘」「整理两人之间的证据」「写报告」「检查报告」,下面是「大约要几分钟。可以先离开,写好后在「之前的合盘」和「报告」里都能找到。」英文等待说「正在写英文版…」和「第一次切到英文时才写,不扣点,大约一两分钟。」报告标题下是「已保存,再次打开不扣点」。
|
||||
报告末尾「对哪一段想再问清楚?」列出前八节(含关系时间线,不含共同练习)。点进去是一条新对话,输入框预先写好这句,用户自己发送。中文预填是「合盘报告里「节名」这一段,在我们两人的盘里依据是什么?」英文报告用英文问同一件事。对话里可以说星曜、宫位、九分盘、大运,但只能引用这份报告已经写下的计算。材料里没有的,说「这份合盘没算到」。对不上的盘面,说「这一点在这份合盘里没有算到,我不能确定」。报告删了之后,输入框写「这份合盘已删除」,不能再发送。英文还没写好时只说「英文版即将提供」。选人页副标题是「看这段感情往后的走向,和两人之间的助力与考验。」按钮是「开始合盘」。点数说明是「生成一份完整合盘报告,用掉 N 点。大约要几分钟。」,N 从配置读。没有配置时说「计费配置不可用」。下拉占位是「选择一个人」。资料不齐写「资料未齐」,已经选在另一边写「已在另一边」。档案满了写「档案已满(最多 20 人)」,旁边是「去星盘档案删一个」。新增星盘卡片用「称呼」「性别」「出生日期」「出生时间」「出生地」,按钮是「取消」和「保存并选中」。产品关着时说「合盘服务暂未开放」。开关读不到时说「合盘服务暂时连不上,请稍后再试」。开关结果回来之前,选人页只显示标题和副标题。英文次数用尽时说「英文版暂时无法再试」。生成中说「正在写 甲 × 乙 的合盘报告…」,四步是「算两张完整命盘」「整理两人之间的证据」「写报告」「检查报告」,下面是「大约要几分钟。可以先离开,写好后在「之前的合盘」和「报告」里都能找到。」英文等待说「正在写英文版…」和「第一次切到英文时才写,不扣点,大约一两分钟。」报告标题下是「已保存,再次打开不扣点」。
|
||||
|
||||
## 生时校正从对话进入(2026-10-07,BUG-1247~1251;2026-10-12,BUG-1306)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -29,21 +29,27 @@ export function SynastryPicker() {
|
||||
const [creditCost, setCreditCost] = useState<number | null>(null);
|
||||
const [pricingNote, setPricingNote] = useState("");
|
||||
const [earlier, setEarlier] = useState<Earlier[]>([]);
|
||||
const [serviceClosed, setServiceClosed] = useState(false);
|
||||
const [access, setAccess] = useState<"pending" | "open" | "closed" | "unreachable">("pending");
|
||||
const pickRef = useRef<HTMLDivElement>(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 (
|
||||
<>
|
||||
<SecondaryHeader title="婚恋合盘" />
|
||||
<p>合盘服务暂未开放</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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 = (
|
||||
<div className="synastry-page-heading">
|
||||
<h1>婚恋合盘</h1>
|
||||
<p className="synastry-page-sub">看这段感情往后的走向,和两人之间的助力与考验。</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SecondaryHeader title="婚恋合盘" />
|
||||
<div className="synastry-picker">
|
||||
<div className="synastry-page-heading">
|
||||
<h1>婚恋合盘</h1>
|
||||
<p className="synastry-page-sub">看这段感情往后的走向,和两人之间的助力与考验。</p>
|
||||
</div>
|
||||
{heading}
|
||||
{access === "closed" ? <p>合盘服务暂未开放</p> : null}
|
||||
{access === "unreachable" ? <p role="status">合盘服务暂时连不上,请稍后再试</p> : null}
|
||||
{access === "open" ? (
|
||||
<>
|
||||
<div className="synastry-pick" ref={pickRef}>
|
||||
<PersonMenu
|
||||
label="第一个人"
|
||||
@@ -229,6 +233,8 @@ export function SynastryPicker() {
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
{adding ? (
|
||||
<SynastryAddDialog
|
||||
|
||||
@@ -6,10 +6,11 @@ import { SynastryWait } from "@/components/synastry/synastry-wait";
|
||||
import { beginSynastrySectionChat } from "@/lib/synastry-report-chat-client";
|
||||
import { SECTION_TITLES, SYNASTRY_ASK_SECTION_IDS, SYNASTRY_FOOTNOTE, SYNASTRY_PROSE_IDS, TALK_TOGETHER, type SynastryAskSectionId } from "@/lib/synastry-full-sections";
|
||||
import type { TechnicalBlock } from "@/lib/synastry-full-technical";
|
||||
import { timelineHeading } from "@/lib/synastry-timeline-label";
|
||||
|
||||
type Reason = { plain?: string; basis?: string; evidence?: string; label?: string };
|
||||
type Prose = { id?: string; title?: string; text?: string; reasons?: Reason[] };
|
||||
type Segment = { id?: string; phase?: string; text?: string; basis?: string };
|
||||
type Segment = { id?: string; phase?: string; text?: string; basis?: string; start?: string; end?: string };
|
||||
type Practice = { text?: string; evidence?: string };
|
||||
|
||||
type LanguageBody = {
|
||||
@@ -39,6 +40,18 @@ function heading(index: number, title: string, language: "zh" | "en") {
|
||||
return language === "zh" ? `${ZH_NUMBERS[index]}、${title}` : title;
|
||||
}
|
||||
|
||||
function analysisTitle(language: "zh" | "en") {
|
||||
return language === "zh" ? "分析逻辑" : "Analysis";
|
||||
}
|
||||
|
||||
function chartLine(language: "zh" | "en", basis: string | undefined) {
|
||||
return language === "zh" ? `盘面:${basis ?? ""}` : `Chart: ${basis ?? ""}`;
|
||||
}
|
||||
|
||||
function soLine(language: "zh" | "en", plain: string | undefined) {
|
||||
return language === "zh" ? `所以:${plain ?? ""}` : `So: ${plain ?? ""}`;
|
||||
}
|
||||
|
||||
function savedLine(createdAt?: string) {
|
||||
const day = createdAt?.slice(0, 10) ?? "";
|
||||
const dated = /^\d{4}-\d{2}-\d{2}$/.test(day) ? `${day} · ` : "";
|
||||
@@ -50,6 +63,7 @@ export function SynastryReportView(props: {
|
||||
snapshot: unknown;
|
||||
createdAt?: string;
|
||||
initialLanguage?: "zh" | "en";
|
||||
clock?: Date;
|
||||
}) {
|
||||
const snapshot = asSnapshot(props.snapshot);
|
||||
const [language, setLanguage] = useState<"zh" | "en">(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: {
|
||||
<section key={id} className="synastry-section">
|
||||
<h2>{heading(index, section.title || SECTION_TITLES[id][language], language)}</h2>
|
||||
<p>{section.text}</p>
|
||||
{language === "zh" ? (
|
||||
<details className="synastry-why">
|
||||
<summary>分析逻辑</summary>
|
||||
<ol>
|
||||
{(section.reasons ?? []).map((reason, reasonIndex) => (
|
||||
<li key={`${id}-${reasonIndex}`}>
|
||||
<p>盘面:{reason.basis}</p>
|
||||
<p>所以:{reason.plain}</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</details>
|
||||
) : null}
|
||||
<details className="synastry-why">
|
||||
<summary>{analysisTitle(language)}</summary>
|
||||
<ol>
|
||||
{(section.reasons ?? []).map((reason, reasonIndex) => (
|
||||
<li key={`${id}-${reasonIndex}`}>
|
||||
<p>{chartLine(language, reason.basis)}</p>
|
||||
<p>{soLine(language, reason.plain)}</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</details>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
@@ -157,16 +170,21 @@ export function SynastryReportView(props: {
|
||||
<h2>{heading(7, SECTION_TITLES.timeline[language], language)}</h2>
|
||||
{language === "zh" ? <p className="lead">把两个人各自走到的人生阶段放在一起看,这段关系在哪段时间会是什么样子。说的是倾向和条件,不是会发生什么的预言。</p> : null}
|
||||
<ol className="synastry-tl">
|
||||
{(body.timeline ?? []).map((segment) => (
|
||||
{(body.timeline ?? []).map((segment, index) => (
|
||||
<li key={segment.id}>
|
||||
<div className="synastry-tl-h"><b>{segment.phase}</b></div>
|
||||
<div className="synastry-tl-h"><b>{timelineHeading({
|
||||
start: segment.start,
|
||||
end: segment.end,
|
||||
phase: segment.phase,
|
||||
language,
|
||||
firstSegment: index === 0,
|
||||
today,
|
||||
})}</b></div>
|
||||
<p>{segment.text}</p>
|
||||
{language === "zh" ? (
|
||||
<details className="synastry-why">
|
||||
<summary>分析逻辑</summary>
|
||||
<p>盘面:{segment.basis}</p>
|
||||
</details>
|
||||
) : null}
|
||||
<details className="synastry-why">
|
||||
<summary>{analysisTitle(language)}</summary>
|
||||
<p>{chartLine(language, segment.basis)}</p>
|
||||
</details>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
@@ -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<Session extends SessionModelBinding> =
|
||||
| { 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<Session extends SessionModelBinding>(input: {
|
||||
session: Session;
|
||||
requestedModelId: string;
|
||||
resolveBound: (modelId: string, configVersion: number | null | undefined) => Promise<ResolvedLanguageModel | null>;
|
||||
loadCatalog: () => Promise<LanguageModelCatalog>;
|
||||
persistModelId: (sessionId: string, modelId: string | null) => Promise<boolean>;
|
||||
}): Promise<ConsultModelDecision<Session>> {
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -46,6 +46,14 @@ export type ConsultTurnHeadDeps<User extends { id: string }, Parsed extends Pars
|
||||
readBody: () => Promise<BodyResult<Parsed>>;
|
||||
readSession: (userId: string, parsed: Parsed) => Promise<SessionResult<Session>>;
|
||||
resolveModel: (chatSession: Session) => Promise<Model | null>;
|
||||
reconcileModel?: (
|
||||
chatSession: Session,
|
||||
requestedModelId: string,
|
||||
) => Promise<
|
||||
| { kind: "changed" }
|
||||
| { kind: "unavailable" }
|
||||
| { kind: "ready"; chatSession: Session & { model_id: string }; sessionModel: Model }
|
||||
>;
|
||||
};
|
||||
|
||||
export type ConsultTurnHead<User, Parsed, Session, Model> =
|
||||
@@ -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() };
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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<string | null> = [];
|
||||
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<ResolvedLanguageModel | null> => 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, "模型暂不可用");
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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, /<SynastryPicker \/>/);
|
||||
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(/<summary>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("<details open"), false);
|
||||
assert.equal(html.includes("分析逻辑"), false);
|
||||
});
|
||||
|
||||
test("section evidence follows reader briefs and keeps relationship_axis", () => {
|
||||
const dossier = {
|
||||
relationship_axis: { lane: "closeness" },
|
||||
|
||||
Reference in New Issue
Block a user