Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
413140b40c | ||
|
|
2579fa14e5 | ||
|
|
7fbdcab113 | ||
|
|
57ee79cc5f | ||
|
|
dd9efb3447 | ||
|
|
e1afd0ea3d | ||
|
|
dc80a43aae | ||
|
|
9d2f63b6ac | ||
|
|
229df727c9 |
@@ -1,5 +1,25 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-10-21 — 同一对人可以再生成一份合盘
|
||||
|
||||
- 已经写好的合盘还在。再点「开始合盘」会新写一份,并再扣一次点数。正在写的那份仍打开原来的等待页,不重复扣。选人页在点数说明下写出已经合过几次、再生成会另扣多少点。Skill 版本不变(BUG-1408)。
|
||||
|
||||
## 2026-10-21 — 合盘两人顺序跟选人页一致
|
||||
|
||||
- 先选的人写在前面。等待页、报告标题、报告中心卡片和追问对话标题用同一个顺序。同一对正在生成时,仍打开那份。Skill 版本不变(BUG-1407)。
|
||||
|
||||
## 2026-10-21 — 合盘正文不再重复节标题
|
||||
|
||||
- 某一节的正文如果以这一节的标题开头,质检会要求重写这一节。保存前也会把开头重复的标题去掉。中文和英文都是这样。Skill 版本不变(BUG-1406)。
|
||||
|
||||
## 2026-10-21 — 合盘报告有自己的地址
|
||||
|
||||
- 合盘报告改走 `/synastry/报告`。开始合盘、之前的合盘和报告中心的合盘卡片都进这个地址,侧栏仍是「婚恋合盘」。旧的 `/reports/报告` 会换成新地址,英文链接保留 `?lang=en`。Skill 版本不变(BUG-1405)。
|
||||
|
||||
## 2026-10-21 — 合盘报告两侧空白也能滚动
|
||||
|
||||
- 合盘报告改由整宽的外层滚动,中间一栏只负责居中排版。指针停在两侧空白处时,整份报告一起动。打印仍按整页展开。Skill 版本不变(BUG-1404)。
|
||||
|
||||
## 2026-10-21 — 合盘时间线写出起止,删掉的模型改用默认模型
|
||||
|
||||
- 关系时间线每段标题写出起止年月。第一段从本月开始时,起点写「现在」或 Now。
|
||||
|
||||
@@ -37016,3 +37016,82 @@
|
||||
- 复发自:无。
|
||||
- 修复版本:分支 `codex/synastry-staging-fix-20261021`。
|
||||
|
||||
## BUG-1404 | 合盘报告两侧空白滚不动
|
||||
|
||||
- 状态:resolved(待 staging 真机复测)
|
||||
- 首次发现:2026-10-21(产品负责人真机)
|
||||
- 最近更新:2026-10-21
|
||||
- 影响面:合盘报告阅读页
|
||||
- 用户现象:鼠标停在报告两侧空白处滚动时,报告不动。停在中间正文上可以滚。
|
||||
- 触发条件:桌面宽度下打开一份已写好的合盘报告,指针放在 720px 正文栏以外的空白处滚动。
|
||||
- 根因:`.synastry-report` 自己是滚动口,宽度又是 `min(720px, 100%)`。两侧空白属于外层 `.chat-panel`(`overflow: clip`),滚轮到不了中间这一栏。个人报告是整宽的 `.personal-report-reader` 在滚,内文再居中。
|
||||
- 修复:新增整宽的 `.synastry-report-scroll`,高度 100%、纵向滚动,和 `.personal-report-reader` 同一做法。`.synastry-report` 只保留居中和排版,不再带 `height: 100%` 和 `overflow`。打印时外层改为 `height: auto`、`overflow: visible`,长报告不会被裁成一页。
|
||||
- 验证:`frontend/tests/synastry-full-report.test.ts` 锁定外层整宽滚动、内栏没有 `height: 100%` / `overflow: auto`、阅读页用这个外层包住报告,以及打印解锁。
|
||||
- 防复发:上述样式与源码断言。不要把滚动再放回宽度受限的 `.synastry-report`。
|
||||
- 相关记录:issue #85。
|
||||
- 复发自:无。
|
||||
- 修复版本:分支 `codex/synastry-staging-fix-20261021`。
|
||||
|
||||
## BUG-1405 | 合盘报告打开后侧栏变成「我的报告」
|
||||
|
||||
- 状态:resolved(待 staging 真机复测)
|
||||
- 首次发现:2026-10-21(产品负责人真机)
|
||||
- 最近更新:2026-10-21
|
||||
- 影响面:婚恋合盘报告的地址、报告中心卡片、选人页的「开始合盘」和「之前的合盘」
|
||||
- 用户现象:点「开始合盘」之后,侧栏当前项从「婚恋合盘」变成「我的报告」。浏览器标题也是「个人报告」。
|
||||
- 触发条件:从选人页进入一份合盘,或从报告中心打开合盘卡片。地址是 `/reports/报告`。
|
||||
- 根因:合盘和个盘共用个人报告阅读页。侧栏按地址前缀点亮,「我的报告」是 `/reports`。任务书原先只要求跳进阅读页,没有给合盘单独的地址。
|
||||
- 修复:新增 `/synastry/报告`,仍用原来的阅读页,文档标题是「婚恋合盘 · Jyotisha」,`?lang=` 保留。选人页的「开始合盘」和「之前的合盘」、报告中心的合盘卡片改走这个地址。旧的 `/reports/报告` 打开合盘时换成新地址,不另留一条历史。新地址若打开的是个人报告,换回 `/reports/报告`。左上角「‹ 婚恋合盘」不变。打印样式只在打印时收起侧栏。侧栏本来就用地址前缀点亮 `/synastry`,所以新地址仍是「婚恋合盘」。
|
||||
- 验证:`frontend/tests/synastry-report-surface.test.ts`。旧地址带 `lang=en` 换成 `/synastry/报告?lang=en`;个人报告留在 `/reports`;新页面标题和 `surface="synastry"`;选人页和报告中心的链接;侧栏对 `/synastry/` 前缀点亮。失败态带上报告类型,个人报告的失败断言从「没有 reportType」改为带上 `personal_full`。
|
||||
- 防复发:上述测试。不要把合盘再链回 `/reports/报告`。
|
||||
- 相关记录:issue #85。BUG-1404。
|
||||
- 复发自:无。
|
||||
- 修复版本:分支 `codex/synastry-staging-fix-20261021`。
|
||||
|
||||
## BUG-1406 | 合盘正文开头重复节标题
|
||||
|
||||
- 状态:resolved(待 staging 真机复测)
|
||||
- 首次发现:2026-10-21(产品负责人真机)
|
||||
- 最近更新:2026-10-21
|
||||
- 影响面:合盘报告各节正文,中文和英文
|
||||
- 用户现象:节标题下面,正文又用同一句话开头,标题等于出现了两次。
|
||||
- 触发条件:模型把这一节的标题写进正文开头。保存和质检都没有拦住。
|
||||
- 根因:每一节单独生成,提示里有节名,但保存前不剥离开头的标题,质检也不把这件事当成失败。
|
||||
- 修复:正文以该节标题开头(可重复一次,后面是空白)时,质检记 `repeated_heading` 并只重写这一节。重写提示写明不要以节标题开头。通过之后,保存前再剥掉开头重复的标题。中文和英文同一条规则。标题后面紧接着别的字、不是空白也不是再来一遍标题时,不剥,避免误伤。
|
||||
- 验证:`frontend/tests/synastry-full-report.test.ts`:正文以「沟通与理解」开头时先重写,保存结果不再以这个标题开头;英文标题同样剥掉;「沟通与理解力」不剥。`tests/test_synastry_full_reader_gate.py`:同一中文样例和英文「Communication and Understanding」被质检判为失败。
|
||||
- 防复发:上述两条。不要只在页面上藏起来,生成和质检都要认。
|
||||
- 相关记录:issue #85。
|
||||
- 复发自:无。
|
||||
- 修复版本:分支 `codex/synastry-staging-fix-20261021`。
|
||||
|
||||
## BUG-1407 | 合盘两人顺序和选人页相反
|
||||
|
||||
- 状态:resolved(待 staging 真机复测)
|
||||
- 首次发现:2026-10-21(产品负责人真机)
|
||||
- 最近更新:2026-10-21
|
||||
- 影响面:合盘等待页、报告标题、报告中心卡片、追问对话标题
|
||||
- 用户现象:选人页先选甲、再选乙。等待页是甲 × 乙。写完之后,报告标题、报告中心卡片和追问对话标题变成乙 × 甲。
|
||||
- 触发条件:先选的那个人,编号排在后选的那个人后面。已经写好的报告保留当时的顺序。再生成一份时按这次的选人顺序(BUG-1408)。
|
||||
- 根因:去重用的一对编号按编号从小到大存放。生成时把较小的编号当成甲、较大的编号当成乙。等待页读的是选人页当场记下的顺序,所以只有等待页是对的。
|
||||
- 修复:创建时另存选人顺序(先选的人、后选的人)。生成按这个顺序加载两个人,报告标题、卡片摘要和追问标题都用这个顺序。去重用的编号排序不变。没有这份顺序的旧报告仍按原来的编号顺序。等待页本来就是选人顺序,没有改。
|
||||
- 验证:`frontend/tests/synastry-display-order.test.ts`:先选的人编号更大时,标题和追问标题仍是甲 × 乙;等待页句子是甲在前。`frontend/tests/database-synastry-full-create.test.ts`:请求先是本人、后是另一人时,存下的顺序是本人在前,而去重编号仍是另一人在前。
|
||||
- 防复发:上述两条。生成不要再把较小的编号直接当成甲。
|
||||
- 相关记录:issue #85。
|
||||
- 复发自:无。
|
||||
- 修复版本:分支 `codex/synastry-staging-fix-20261021`。
|
||||
|
||||
## BUG-1408 | 同一对人不能再生成一份合盘
|
||||
|
||||
- 状态:resolved(待 staging 真机复测)
|
||||
- 首次发现:2026-10-21(产品负责人)
|
||||
- 最近更新:2026-10-21
|
||||
- 影响面:婚恋合盘的「开始合盘」、点数、之前的合盘
|
||||
- 用户现象:同一对人已经有一份写好的合盘,再点「开始合盘」会打开上一份,不能新写。
|
||||
- 触发条件:两人、出生资料和规则版本都没变,上一份状态是 ready。
|
||||
- 根因:创建时把 ready 和 generating 都当成可复用,并且数据库不允许同一对有第二份 ready。这是 #82 决策 E3。产品在 K6 推翻了复用这一部分。
|
||||
- 修复:只有 generating 才打开原来那份、不扣点。ready 时新建一份并再扣 `synastry.full`,旧报告保留。去掉只允许一份 ready 的索引。引擎结果仍落在原来的 `synastry_reports` 一行上,报告正文按新的一份重写。选人页在点数说明下写出已经合过几次、再生成会另扣多少点;正在生成时不写这句。
|
||||
- 验证:`frontend/tests/database-synastry-full-create.test.ts`:generating 时第二次仍是原 id、点数不变;写成 ready 后再建得到新 id、点数再减。`frontend/tests/database-synastry-full-report.test.ts`:同一对可以留下两份 ready。`frontend/tests/synastry-full-report.test.ts`:已有两份且不在生成中时,渲染出「这两人已经合过 2 次,再生成会另扣 19 点」。
|
||||
- 防复发:上述三条。不要把 ready 再当成复用,也不要把那条唯一索引加回来。
|
||||
- 相关记录:issue #85。推翻 #82 决策 E3 的复用部分。BUG-1407。
|
||||
- 复发自:无。
|
||||
- 修复版本:分支 `codex/synastry-staging-fix-20261021`。
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# 进度:婚恋合盘 staging 修复(T3–T6 与红线 3)
|
||||
# 进度:婚恋合盘 staging 修复(T3–T6 已部署;T8 滚动已做)
|
||||
|
||||
- 任务书:`docs/tasks/TASK-synastry-staging-fix-20261021.md`
|
||||
- 基线:`origin/staging` `998d07baf`(T1、T2 已在这条上,BUG-1399)
|
||||
@@ -45,3 +45,68 @@
|
||||
3. 切到 English。各节和时间线都有收起的 Analysis,点开能看到 Chart / So。
|
||||
4. 从报告末尾选一节进对话,发出问题,能按这一节问下去。
|
||||
5. 另:后台删掉某个非默认的已发布模型后,钉着它的旧咨询对话还能继续,并且改用当前默认模型。没有默认模型时才是「模型暂不可用」。
|
||||
|
||||
## T8 报告滚动(BUG-1404)
|
||||
|
||||
- 合盘报告外层改为整宽的 `.synastry-report-scroll`(`width: 100%`、`height: 100%`、`overflow-y: auto`)。`.synastry-report` 只居中排版,不再自带 `height: 100%` 和 `overflow`。打印时外层 `height: auto`、`overflow: visible`。
|
||||
- 真机(执行方未做,不得写成已通过):桌面打开一份合盘报告,把指针放在正文栏两侧的空白上滚动,整份报告应跟着动。窄屏和打印另看一眼,长报告不要被裁成一页。
|
||||
|
||||
## T7 独立地址(BUG-1405)
|
||||
|
||||
- 新页面 `/synastry/[reportId]` 复用 `PersonalReportPage`,`surface="synastry"`,文档标题「婚恋合盘 · Jyotisha」,`lang` 仍走 `readReportLanguage`。
|
||||
- 选人页「开始合盘」和「之前的合盘」、报告中心的合盘卡片改到 `/synastry/报告`。个人报告卡片仍是 `/reports/报告`。
|
||||
- 旧地址打开合盘时 `location.replace` 到新地址,英文保留 `?lang=en`,不另留历史。新地址打开个人报告时换回 `/reports/报告`。
|
||||
- 合盘揭幕后的打印样式加上 `media="print"`,平时侧栏还在。
|
||||
- 失败态带上 `reportType`。个人报告失败断言的原值是没有这个字段,新值带上 `personal_full`,因为合盘地址要能认出个人报告。
|
||||
- 真机(执行方未做,不得写成已通过):点「开始合盘」后,侧栏当前项仍是「婚恋合盘」。
|
||||
- 左上角回到选人页仍是「‹ 婚恋合盘」。新路由让页面链接规则不接受普通 `<a href="/synastry">`,所以改成 `Link`,目的地不变。
|
||||
|
||||
## T9 重复标题(BUG-1406)
|
||||
|
||||
- 正文以该节标题开头时,质检返回 `repeated_heading`,只重写这一节。通过后保存前再剥掉开头的标题。中文和英文同一条规则。「沟通与理解力」这种紧挨着的字不剥。
|
||||
- 真机(执行方未做,不得写成已通过):打开一份新写的合盘,各节标题下面的第一句不要再重复标题。
|
||||
|
||||
## T10 两人顺序(BUG-1407)
|
||||
|
||||
- 根因:去重把两个人按编号从小到大存放,生成又把较小的编号当成甲。等待页用的是选人页当场记下的顺序,所以只有等待页没反。
|
||||
- 创建时把选人顺序另存一节。生成按这个顺序加载两个人,标题、卡片和追问标题跟着走。去重用的编号排序不动。同一对正在生成时,仍打开那份,顺序是当时保存的。
|
||||
- 真机(执行方未做,不得写成已通过):先选甲、再选乙。等待页、揭幕后的标题、报告中心卡片、从报告进的对话标题都是甲 × 乙。
|
||||
|
||||
## 这一轮检查
|
||||
|
||||
| 检查 | 结果 |
|
||||
| --- | --- |
|
||||
| `tsc --noEmit` | 0 错 |
|
||||
| 定向:滚动、重复标题、地址、顺序、报告渲染 | 通过,0 cancelled。失败态断言原值没有 `reportType`,新值带上 `personal_full` |
|
||||
| 质检 `tests/test_synastry_full_reader_gate.py` | 通过。正文以节标题开头判失败 |
|
||||
| 合盘创建真库(单独) | 通过。请求先是本人、后是另一人;顺序节是本人在前,去重编号是另一人在前 |
|
||||
| `npm run build` | `/` 与 `/synastry` 为 Static。`/synastry/[reportId]` 为 Dynamic。首屏引用的 js/css gzip -9:JS 577,319,CSS 70,518,合计 647,837。对照 643,725,差 +0.64%,在 ±2% 内 |
|
||||
| 全量 `eslint` | 0 error,122 warning,与基线相同。中途有过 1 个 error:新路由不接受 `<a href="/synastry">`。返回链接改成 `Link` 后消失 |
|
||||
|
||||
全量 Python 快速门没有跑:这个工作树没有项目虚拟环境。质检定向测试用系统 Python 跑过。
|
||||
|
||||
未推 staging,未改 `.gitea/`。真机未做,上面的真机步骤都不得写成已通过。
|
||||
|
||||
## T11 允许重新生成(BUG-1408)
|
||||
|
||||
- 已有 `ready` 报告时,「开始合盘」新建一份并再扣 `synastry.full`。旧报告留在「之前的合盘」。已有 `generating` 时仍打开那份等待页,不扣点。
|
||||
- 去掉 `personal_reports_synastry_pair_ready_uidx`,同一对可以有多份写好的报告。`synastry_reports` 的一对一行没动,引擎结果继续复用,正文按新报告重写。
|
||||
- 选人页在点数说明下,两人已有写好的合盘且没有正在生成时,显示「这两人已经合过 N 次,再生成会另扣 X 点」。N、X 来自这对人的 ready 份数和当前点数。
|
||||
- 真机(执行方未做,不得写成已通过):同一对人再点「开始合盘」,应进入一份新的等待页,点数再减一次。上一份仍在「之前的合盘」里。按钮下能看到已经合过几次。
|
||||
|
||||
## 断言变更(T11)
|
||||
|
||||
| 测试 | 原值 | 新值 | 原因 |
|
||||
| --- | --- | --- | --- |
|
||||
| `synastry-full-create-route.test.ts` 同一对复用 | 已有 ready 时 status 200、`reused`、不扣点 | 只有 generating 复用,status 202、不扣点 | K6,BUG-1408。ready 不再复用 |
|
||||
| `database-synastry-full-report.test.ts` 第二份 ready | 插入被 `personal_reports_synastry_pair_ready_uidx` 拒绝,count 为 1 | 第二份 ready 可以留下,count 为 2 | 旧报告要保留,不能再靠这个唯一索引挡住 |
|
||||
|
||||
## 这一轮检查(T11)
|
||||
|
||||
| 检查 | 结果 |
|
||||
| --- | --- |
|
||||
| `tsc --noEmit` | 0 错 |
|
||||
| `npm run lint` | 0 error,122 warning,与基线相同 |
|
||||
| 选人页渲染、创建路由 | 通过,0 cancelled。复用断言从 ready 的 200 改为 generating 的 202 |
|
||||
| 合盘创建真库 | 通过。generating 时第二次仍是原 id,点数停在 81;写成 ready 后再建得到新 id,点数到 62。ready 份数是 1,生成中为真 |
|
||||
| 同一对两份 ready | 通过。第二份 ready 留下,count 为 2。随后取消排队任务,指纹变化的另一份仍能写入 |
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# 进度:婚恋合盘 T7~T11 第 1 次打回(issue #85)
|
||||
|
||||
- 修复单:`docs/tasks/TASK-synastry-staging-fix-fix-20261022.md`
|
||||
- 被验提交:`57ee79cc5`
|
||||
- 分支:`codex/synastry-staging-fix-20261021`
|
||||
- 工作树:`.worktrees/synastry-staging-fix-20261021`
|
||||
- 开工:`git fetch origin --prune` 后 `git merge origin/staging`(不 rebase)。无冲突。合入提交 `2579fa14e`,只带进任务书和验收清单。
|
||||
- 没有新 BUG 号。没有改数据库、`.gitea`、引擎。没有 push。
|
||||
|
||||
## 做了什么
|
||||
|
||||
- G1:`tests/test_api_server_security.py` 的路由表在 `synastry` 和 `terms` 之间加入 `synastry/[reportId]`,旁边写了三栏。
|
||||
- G2:`CHANGELOG.md` 的 BUG-1407 和 `PROGRESS-synastry-staging-fix-20261021.md` 的 T10,把「同一对已经有报告时,仍打开原来那份」改成「同一对正在生成时,仍打开那份」。其余句子没动。
|
||||
- G3:删掉 `frontend/tests/synastry-report-surface.test.ts` 里永远成立的 `assert.equal("/synastry/abc".startsWith("/synastry/"), true)`。改为按现有侧栏渲染写法(`SidebarProvider` + `AppSidebar`)渲染侧栏,`pathname` 为 `/synastry/abc`,当前项 `data-active="true"` 是「婚恋合盘」,「我的报告」不是。合盘开关按外壳传入打开,否则这一项不会画出来。
|
||||
|
||||
## 断言三栏
|
||||
|
||||
| 位置 | 原值 | 新值 | 原因 |
|
||||
| --- | --- | --- | --- |
|
||||
| `tests/test_api_server_security.py` 路由表 | `synastry` 后面直接是 `terms` | 加入 `synastry/[reportId]` | issue #85 T7 合盘报告独立地址 |
|
||||
| `frontend/tests/synastry-report-surface.test.ts` 侧栏当前项 | `assert.equal("/synastry/abc".startsWith("/synastry/"), true)` | `pathname` 为 `/synastry/abc` 时渲染侧栏,`data-active="true"` 的是「婚恋合盘」,「我的报告」不是 | 原断言永远成立,没有渲染侧栏 |
|
||||
|
||||
## 命令与数字
|
||||
|
||||
| 检查 | 结果 |
|
||||
| --- | --- |
|
||||
| 快速门 | 退出码 0。pytest 1099 条:通过 1097,失败 0,错误 0,跳过 2。G1 `test_capability_audit_scans_registry_and_local_sources` 通过。失败名单空。跳过的两条不是失败:`test_dig_bala_jupiter_synthetic_north_china_case_needs_better_than_house_only_linear_model`、`test_slice_glyphs_cover_their_table_characters` |
|
||||
| `tsc --noEmit -p .` | 退出码 0,0 错 |
|
||||
| `npm run lint` | 退出码 0。0 error,122 warning,与上一轮进度记录的基线相同 |
|
||||
| `synastry-report-surface.test.ts` | 退出码 0。2 通过,0 失败,0 cancelled |
|
||||
|
||||
工作树没有 `.venv`,解释器用 `.worktrees/agent-loop/.venv/Scripts/python.exe`。
|
||||
|
||||
`tools/agent-loop/windows-known-failures.txt` 列的是前端 `test:db` 的 13 条,不是这次 pytest。这次 pytest 没有失败,没有多出来的名字。
|
||||
|
||||
## 偏离
|
||||
|
||||
- 快速门加了 `--skip-frontend-runtime`。`quick` 默认还会跑前端全量 `npm test`、lint 和 `next build`。修复单写明全量测试和 build 由验收方复跑,本轮不跑。Python 编译、审计和 pytest 目标与 `--profile quick` 相同。
|
||||
- 第一次快速门退出码 1,发生在 pytest 之前:控制台编码是 GBK,`scripts/validate_bphs_invariants.py` 打印符号时 `UnicodeEncodeError`。没有改这个脚本。重跑设置 `PYTHONUTF8=1` 后,上面的退出码 0 才是结果。
|
||||
- 为了在门禁只印一行时仍能数 pytest,设置了 `PYTEST_ADDOPTS` 把 junit 写到 `gate-logs/`(已被忽略,未提交)。
|
||||
- `docs/tasks/README.md` 里这份修复单的进度改指 `PROGRESS-synastry-staging-fix-fix-20261022.md`,状态从待领取改为待验收。修复单「不用改的」那一节没有动。
|
||||
- 快速门把 5 个 `references/oracle/artifacts/pending_packets/*.json` 改成了 CRLF,内容无差。已还原,没有提交。
|
||||
@@ -509,7 +509,7 @@
|
||||
| `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` | `PROGRESS-synastry-staging-fix-20261021.md` | #85 婚恋合盘 staging:P0 `.gte` 已在 staging(BUG-1399)。本分支补时间线起止、英文分析逻辑、删模型后旧对话改用默认模型、选人页开关文案,以及合盘创建真库测试(BUG-1400~1403) | 待验收 | `codex/synastry-staging-fix-20261021` |
|
||||
| `TASK-synastry-staging-fix-20261021.md` | `PROGRESS-synastry-staging-fix-20261021.md` | #85 婚恋合盘 staging:T3~T6 已在 staging `ff6b228dd`(BUG-1400~1403)。本分支继续报告滚动、独立地址、重复标题、两人顺序(BUG-1404~1407) | 待验收 | `codex/synastry-staging-fix-20261021` |
|
||||
| `TASK-synastry-report-chat-fix-20261020.md` | `PROGRESS-synastry-report-chat-20261018.md` | 大运核对要认报告里的「小运」和英文 mahadasha / antardasha;预填文案只留一句 | 已验收 | `549e37b99` |
|
||||
| `TASK-other-subject-birth-time-edit-20261021.md` | — | 其他人的出生时间可以修改(去掉 `chart_profiles_guard_birth` 的填报时间不可改;推翻 people-archive P1 §验收 2 该条;active 锁不变) | 排队(等 #86 合入) | `codex/other-subject-birth-time-edit-20261021`;动表;不进循环 |
|
||||
| `TASK-chart-ascendant-slash-20261021.md` | — | #87 上升状态格「不适用」改「/」+ 悬停 / 点按说明;报告上升行同改并在表下加说明(推翻 10-15 口径) | 待领取(等 #86 合入) | `codex/chart-ascendant-slash-20261021` |
|
||||
@@ -520,3 +520,4 @@
|
||||
| `TASK-goel-jaimini-strength-20261022.md` | — | #94 搬上游公开的 Goel Jaimini 强度与 Brahma / Maheshwara / Rudra(4 个上游原样文件,按 #81 决策 A 先例根目录预算 448→452;来源串改书目引用);只做计算、不改报告;冻结身份不变 | 待领取(可与 #89 并行;须在 #91 印章前合入) | `codex/goel-jaimini-strength-20261022` |
|
||||
| `TASK-chart-subject-table-fix-20261022.md` | `PROGRESS-chart-subject-table-20261021.md` | **#86 第 1 次打回**:报告所有表被强制单行(打印跑出纸面);保存他人复制了档案页代码且已分叉;点编辑框内边距会丢修改 | 待领取 | `codex/chart-subject-table-20261021` |
|
||||
| `TASK-full-data-parity-3-20261022.md` | — | #93 完整数据版补齐(三):「独立年度方法参考」正式环境调 PyJHora(子进程隔离;装上后其余输出逐字节不变,7 个换路径开关钉死);输入默认用本地时间与正确年份(D6 待产品确认) | 待领取(等 #91 合入) | `codex/full-data-parity-3-20261022` |
|
||||
| `TASK-synastry-staging-fix-fix-20261022.md` | `PROGRESS-synastry-staging-fix-fix-20261022.md` | **#85 第 1 次打回**(T7~T11 主体通过):新地址没登记进路由清单致快速门红;CHANGELOG 与 T11 矛盾;侧栏点亮测试是空断言 | 待验收 | `codex/synastry-staging-fix-20261021` |
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# 修复单:婚恋合盘 T7~T11 验收遗留(issue #85,第 1 次打回,2026-10-22)
|
||||
|
||||
- 被验提交:`57ee79cc5`(分支 `codex/synastry-staging-fix-20261021`)
|
||||
- 原任务书:`TASK-synastry-staging-fix-20261021.md`(T7~T11)
|
||||
- 执行方:Grok。同一分支继续,开工前 `git merge origin/staging`(不 rebase),只推这个分支。
|
||||
- BUG 编号:本分支已用 1404~1408,保持不变(其他分支撞号由 Claude 合入时重编)。
|
||||
|
||||
## 验收结论(Claude,Linux / Node 22)
|
||||
|
||||
| 项 | 结论 |
|
||||
| --- | --- |
|
||||
| tsc / lint | 0 错;0 error |
|
||||
| 前端全量 `npm test` | 5101 条,失败 25,名单与 staging `9812f3173` 基线逐名一致 |
|
||||
| 逐行审阅 T7~T11 | 通过:范围干净;新迁移只删一个唯一索引,对已部署代码向后兼容,真库测试覆盖「两份 ready 并存」「生成中复用不扣点」「ready 后再建扣点」;改动的断言都有三栏;双击不会重复扣点(`busy` + 每人一份生成中的索引,输方 409 并释放预留);新地址的归属校验仍在服务端 |
|
||||
| 全量 pytest | **多 1 条失败**,见 G1 |
|
||||
| Python 快速门 | **pytest 一步失败**(同 G1)。进度记录写了「没有跑快速门」,正是这条漏掉的原因 |
|
||||
| build | 本机内存不足被系统杀掉,Claude 另行重跑,不算本单问题 |
|
||||
|
||||
## G1 · 新页面没登记进路由清单(必修)
|
||||
|
||||
`tests/test_api_server_security.py::test_capability_audit_scans_registry_and_local_sources` 失败:
|
||||
|
||||
```
|
||||
At index 34 diff: 'synastry/[reportId]' != 'terms'
|
||||
```
|
||||
|
||||
该用例断言 `audit['surfaces']['app_routes']` 等于一张写死的前端路由表。T7 新增了 `app/(app)/synastry/[reportId]/page.tsx`,表里没加。按 AGENTS §7.8 同轮改:在表中按字母序加入 `'synastry/[reportId]'`,旁边写三栏(原值:`synastry` 后面直接是 `terms` / 新值:加入 `synastry/[reportId]` / 原因:issue #85 T7 合盘报告独立地址)。
|
||||
|
||||
## G2 · 文档与 T11 矛盾(必修,纯文字)
|
||||
|
||||
`CHANGELOG.md` BUG-1407 那条(「合盘两人顺序跟选人页一致」)和 `PROGRESS-synastry-staging-fix-20261021.md` T10 节都写着「同一对已经有报告时,仍打开原来那份」。T11 之后这句只对**正在生成**的报告成立。改成「同一对正在生成时,仍打开那份」。
|
||||
|
||||
## G3 · 侧栏点亮的测试是空断言(必修,小)
|
||||
|
||||
`frontend/tests/synastry-report-surface.test.ts`「the synastry route reuses the reader and the sidebar stays on 婚恋合盘」里 `assert.equal("/synastry/abc".startsWith("/synastry/"), true)` 永远成立,测的不是页面。任务书要求的是:渲染侧栏(`pathname = "/synastry/<id>"`),断言当前项(`aria-current="page"` 或现有的选中类)是「婚恋合盘」、不是「我的报告」。照现有侧栏渲染测试的写法补一条,删掉这句空断言(三栏)。
|
||||
|
||||
## 交回前必跑
|
||||
|
||||
- `PATH` 用工作树 `.venv`:`python scripts/run_quality_gate.py --profile quick`,回报里写退出码与 pytest 通过 / 失败数(Windows 已知失败按名单列出)。
|
||||
- `tsc --noEmit`、`npm run lint`、`synastry-report-surface.test.ts`。
|
||||
- 全量 `npm test`、build 由 Claude 复验。
|
||||
|
||||
## 不用改的(记录在案)
|
||||
|
||||
`stripLeadingSectionTitle` 在保存路径上基本走不到(质检已拦)、质检不认「标题:」紧跟标点的写法、`synastry_order` 行出现在进度列表里、后续对话的人物 id 仍按排序后的顺序——都不影响用户,本轮不动。
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
## 婚恋合盘报告(2026-10-19)
|
||||
|
||||
选人页标题下是副标题「看这段感情往后的走向,和两人之间的助力与考验。」两个人并排,中间一个 ×;窄屏改成上下排,× 不显示。各自是自定义列表,最多二十人,列表可滚动。资料不全的人灰掉,不能选;已经在另一边的人不能再选。列表最底一行是「+ 新增星盘」,点开一张卡片:称呼、性别(男 / 女)、出生日期、出生时间、出生地,右下角「取消」和「保存并选中」。满二十人时这一行变灰,并提示去星盘档案删人。「开始合盘」居中,点数说明在按钮正下方。之前的合盘只列人名和日期。生成中是轨道和四步(算两张完整命盘、整理两人之间的证据、写报告、检查报告),句子是「正在写 甲 × 乙 的合盘报告…」。报告页回到「‹ 婚恋合盘」,标题下是日期和「已保存,再次打开不扣点」,语言是「中文 / English」。九节编号,分析逻辑默认收起。英文报告同样收起分析逻辑(Analysis,依据行是 Chart 和 So),时间线每段标题写出起止年月;第一段从本月起时,起点写「现在」或 Now。英文准备时同一套轨道,写「正在写英文版…」,揭幕后整份替换。报告末尾「对哪一段想再问清楚?」,下面是八枚胶囊(`.synastry-report-ask`),不含共同练习。点一枚只新建对话并预填问题,不自动发送。对话里人物选择器锁住,显示「甲 × 乙」。合盘删除后输入框占位是「这份合盘已删除」,发送停用。
|
||||
选人页标题下是副标题「看这段感情往后的走向,和两人之间的助力与考验。」两个人并排,中间一个 ×;窄屏改成上下排,× 不显示。各自是自定义列表,最多二十人,列表可滚动。资料不全的人灰掉,不能选;已经在另一边的人不能再选。列表最底一行是「+ 新增星盘」,点开一张卡片:称呼、性别(男 / 女)、出生日期、出生时间、出生地,右下角「取消」和「保存并选中」。满二十人时这一行变灰,并提示去星盘档案删人。「开始合盘」居中,点数说明在按钮正下方。这两人已经有写好的合盘、且没有一份正在生成时,点数说明下面再写已经合过几次、再生成会另扣多少点。之前的合盘只列人名和日期。生成中是轨道和四步(算两张完整命盘、整理两人之间的证据、写报告、检查报告),句子是「正在写 甲 × 乙 的合盘报告…」。报告页回到「‹ 婚恋合盘」,标题下是日期和「已保存,再次打开不扣点」,语言是「中文 / English」。九节编号,分析逻辑默认收起。英文报告同样收起分析逻辑(Analysis,依据行是 Chart 和 So),时间线每段标题写出起止年月;第一段从本月起时,起点写「现在」或 Now。英文准备时同一套轨道,写「正在写英文版…」,揭幕后整份替换。报告末尾「对哪一段想再问清楚?」,下面是八枚胶囊(`.synastry-report-ask`),不含共同练习。点一枚只新建对话并预填问题,不自动发送。对话里人物选择器锁住,显示「甲 × 乙」。合盘删除后输入框占位是「这份合盘已删除」,发送停用。报告在整宽的外层滚动,两侧空白也能滚;中间一栏只负责居中排版。合盘报告单独走 `/synastry/报告`。开始合盘、之前的合盘和报告中心的合盘卡片都进这个地址,侧栏仍亮「婚恋合盘」。旧的 `/reports/报告` 会换成新地址,并保留语言。
|
||||
|
||||
## 生时校正从对话进入、做完回到原对话(2026-10-07,BUG-1247~1251,在途)
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
|
||||
- 吵完之后,怎样递出台阶才不会变成认输?
|
||||
- 过日子的安排里,哪一件需要现在就说清楚?
|
||||
|
||||
报告末尾「对哪一段想再问清楚?」列出前八节(含关系时间线,不含共同练习)。点进去是一条新对话,输入框预先写好这句,用户自己发送。中文预填是「合盘报告里「节名」这一段,在我们两人的盘里依据是什么?」英文报告用英文问同一件事。对话里可以说星曜、宫位、九分盘、大运,但只能引用这份报告已经写下的计算。材料里没有的,说「这份合盘没算到」。对不上的盘面,说「这一点在这份合盘里没有算到,我不能确定」。报告删了之后,输入框写「这份合盘已删除」,不能再发送。英文还没写好时只说「英文版即将提供」。选人页副标题是「看这段感情往后的走向,和两人之间的助力与考验。」按钮是「开始合盘」。点数说明是「生成一份完整合盘报告,用掉 N 点。大约要几分钟。」,N 从配置读。没有配置时说「计费配置不可用」。下拉占位是「选择一个人」。资料不齐写「资料未齐」,已经选在另一边写「已在另一边」。档案满了写「档案已满(最多 20 人)」,旁边是「去星盘档案删一个」。新增星盘卡片用「称呼」「性别」「出生日期」「出生时间」「出生地」,按钮是「取消」和「保存并选中」。产品关着时说「合盘服务暂未开放」。开关读不到时说「合盘服务暂时连不上,请稍后再试」。开关结果回来之前,选人页只显示标题和副标题。英文次数用尽时说「英文版暂时无法再试」。生成中说「正在写 甲 × 乙 的合盘报告…」,四步是「算两张完整命盘」「整理两人之间的证据」「写报告」「检查报告」,下面是「大约要几分钟。可以先离开,写好后在「之前的合盘」和「报告」里都能找到。」英文等待说「正在写英文版…」和「第一次切到英文时才写,不扣点,大约一两分钟。」报告标题下是「已保存,再次打开不扣点」。
|
||||
报告末尾「对哪一段想再问清楚?」列出前八节(含关系时间线,不含共同练习)。点进去是一条新对话,输入框预先写好这句,用户自己发送。中文预填是「合盘报告里「节名」这一段,在我们两人的盘里依据是什么?」英文报告用英文问同一件事。对话里可以说星曜、宫位、九分盘、大运,但只能引用这份报告已经写下的计算。材料里没有的,说「这份合盘没算到」。对不上的盘面,说「这一点在这份合盘里没有算到,我不能确定」。报告删了之后,输入框写「这份合盘已删除」,不能再发送。英文还没写好时只说「英文版即将提供」。选人页副标题是「看这段感情往后的走向,和两人之间的助力与考验。」按钮是「开始合盘」。点数说明是「生成一份完整合盘报告,用掉 N 点。大约要几分钟。」,N 从配置读。这两人已经有写好的合盘、且没有一份正在生成时,点数说明下面加「这两人已经合过 N 次,再生成会另扣 X 点」,N、X 都读实际数字。正在生成时不写这句。没有配置时说「计费配置不可用」。下拉占位是「选择一个人」。资料不齐写「资料未齐」,已经选在另一边写「已在另一边」。档案满了写「档案已满(最多 20 人)」,旁边是「去星盘档案删一个」。新增星盘卡片用「称呼」「性别」「出生日期」「出生时间」「出生地」,按钮是「取消」和「保存并选中」。产品关着时说「合盘服务暂未开放」。开关读不到时说「合盘服务暂时连不上,请稍后再试」。开关结果回来之前,选人页只显示标题和副标题。英文次数用尽时说「英文版暂时无法再试」。生成中说「正在写 甲 × 乙 的合盘报告…」,四步是「算两张完整命盘」「整理两人之间的证据」「写报告」「检查报告」,下面是「大约要几分钟。可以先离开,写好后在「之前的合盘」和「报告」里都能找到。」英文等待说「正在写英文版…」和「第一次切到英文时才写,不扣点,大约一两分钟。」报告标题下是「已保存,再次打开不扣点」。
|
||||
|
||||
## 生时校正从对话进入(2026-10-07,BUG-1247~1251;2026-10-12,BUG-1306)
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import { PersonalReportPage } from "@/components/personal-report/personal-report-page";
|
||||
import { readReportLanguage } from "@/lib/report-language";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "婚恋合盘 · Jyotisha",
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default async function SynastryReportPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ reportId: string }>;
|
||||
searchParams: Promise<{ lang?: string | string[] }>;
|
||||
}) {
|
||||
const { reportId } = await params;
|
||||
const { lang } = await searchParams;
|
||||
return (
|
||||
<PersonalReportPage
|
||||
reportId={reportId}
|
||||
initialLanguage={readReportLanguage(lang)}
|
||||
surface="synastry"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -46,7 +46,7 @@ import {
|
||||
} from "@/lib/subject-birth";
|
||||
import { consultationSubjectFailureResponse } from "@/lib/consultation-subject-resolver";
|
||||
import { clampReportLimit, nextReportCursor, parseReportCursor, reportCursorFilter } from "@/lib/report-cursor";
|
||||
import { createSynastryFullReport } from "@/lib/synastry-full-create";
|
||||
import { createSynastryFullReport, readSynastryPairState } from "@/lib/synastry-full-create";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 120;
|
||||
@@ -111,6 +111,16 @@ export async function GET(request: Request) {
|
||||
return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
}
|
||||
const url = new URL(request.url);
|
||||
if (url.searchParams.get("reportType") === "synastry_full" && url.searchParams.get("pair") === "1") {
|
||||
const state = await readSynastryPairState({
|
||||
userId: user.id,
|
||||
supabase,
|
||||
admin: createAdminSupabaseClient(),
|
||||
subjectA: url.searchParams.get("subjectA"),
|
||||
subjectB: url.searchParams.get("subjectB"),
|
||||
});
|
||||
return toNextResponse(state);
|
||||
}
|
||||
// Keyset pages of 10 (BUG-1124). No `limit` is the first page, so an old
|
||||
// client that never sends one gets the newest 10 (was a fixed 20).
|
||||
const limit = clampReportLimit(url.searchParams.get("limit"));
|
||||
|
||||
@@ -4756,17 +4756,25 @@ button.synastry-dd-add { border-radius: 0 0 var(--radius-md) var(--radius-md); }
|
||||
}
|
||||
.synastry-steps li.done { color: var(--color-ink-tertiary); text-decoration: line-through; }
|
||||
|
||||
.synastry-report {
|
||||
width: min(720px, 100%);
|
||||
.synastry-report-scroll {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
touch-action: pan-y;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
.synastry-report {
|
||||
width: min(720px, 100%);
|
||||
margin-inline: auto;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 22px;
|
||||
padding: var(--space-6);
|
||||
overflow: auto;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.synastry-report-back { color: var(--color-ink-secondary); font-size: 14px; text-decoration: none; }
|
||||
@@ -5384,6 +5392,7 @@ button.synastry-dd-add { border-radius: 0 0 var(--radius-md) var(--radius-md); }
|
||||
.personal-report-print-always { display: block !important; }
|
||||
.personal-report-appendix-details:not([open]) > .personal-report-print-always { display: block !important; }
|
||||
.personal-report-reader { width: auto !important; height: auto !important; min-height: 0 !important; overflow: visible !important; background: #fff !important; }
|
||||
.synastry-report-scroll { width: auto !important; height: auto !important; min-height: 0 !important; overflow: visible !important; }
|
||||
.personal-report-reader-body { width: auto !important; padding: 0 !important; }
|
||||
.personal-report-toc { display: none !important; }
|
||||
.personal-report-md-layout { display: block !important; }
|
||||
|
||||
@@ -28,6 +28,11 @@ import {
|
||||
|
||||
const LIST_POLL_INTERVAL_MS = 3000;
|
||||
|
||||
function reportOpenHref(report: Pick<ReportListItem, "id" | "reportType">): string {
|
||||
const id = encodeURIComponent(report.id);
|
||||
return report.reportType === "synastry_full" ? `/synastry/${id}` : `/reports/${id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* `nextCursor` is where 「加载更多」 continues; `extended` says the user has
|
||||
* opened pages past the first, so a refreshed first page is merged over them
|
||||
@@ -258,13 +263,13 @@ export function PersonalReportCenter() {
|
||||
</div>
|
||||
{report.status === "ready" ? (
|
||||
<div className="report-center-row-actions">
|
||||
<Button render={<AppLink href={`/reports/${encodeURIComponent(report.id)}`} />} nativeButton={false} variant="outline">
|
||||
<Button render={<AppLink href={reportOpenHref(report)} />} nativeButton={false} variant="outline">
|
||||
查看报告
|
||||
</Button>
|
||||
</div>
|
||||
) : report.status === "generating" ? (
|
||||
<div className="report-center-row-actions">
|
||||
<Button render={<AppLink href={`/reports/${encodeURIComponent(report.id)}`} />} nativeButton={false} variant="outline">
|
||||
<Button render={<AppLink href={reportOpenHref(report)} />} nativeButton={false} variant="outline">
|
||||
查看进度
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
} from "@/lib/personal-report-longform-copy";
|
||||
import { projectOrdinaryReportMarkdown } from "@/lib/report-public-projection";
|
||||
import { containsHan, type EnglishEditionStatus, type ReportLanguage } from "@/lib/report-language";
|
||||
import { reportSurfaceRedirect, reportTypeForSurface, type ReportSurface } from "@/lib/synastry-report-href";
|
||||
import {
|
||||
describeReportProgress,
|
||||
REPORT_PROGRESS_STALL_MS,
|
||||
@@ -57,7 +58,7 @@ export type ReportLoadState =
|
||||
sections?: readonly ReportSectionProgress[];
|
||||
}
|
||||
| { phase: "timed-out" }
|
||||
| { phase: "failed"; failureCode: string | null; failureSummary?: string | null; appendixLastErrorCode?: string | null }
|
||||
| { phase: "failed"; failureCode: string | null; failureSummary?: string | null; appendixLastErrorCode?: string | null; reportType?: string }
|
||||
| { phase: "invalid"; message: string }
|
||||
| { phase: "network-error" }
|
||||
| {
|
||||
@@ -200,6 +201,7 @@ export function classifyReportEnvelope(statusCode: number, json: unknown): Repor
|
||||
return {
|
||||
phase: "failed",
|
||||
failureCode: appendixLastErrorCode ?? code,
|
||||
...(typeof view.reportType === "string" ? { reportType: view.reportType } : {}),
|
||||
...(summary ? { failureSummary: summary } : {}),
|
||||
...(appendixLastErrorCode ? { appendixLastErrorCode } : {}),
|
||||
};
|
||||
@@ -291,7 +293,15 @@ export function formatWaitedDuration(elapsedMs: number): string {
|
||||
return minutes > 0 ? `${minutes} 分 ${seconds} 秒` : `${seconds} 秒`;
|
||||
}
|
||||
|
||||
export function PersonalReportPage({ reportId, initialLanguage = "zh" }: { reportId: string; initialLanguage?: ReportLanguage }) {
|
||||
export function PersonalReportPage({
|
||||
reportId,
|
||||
initialLanguage = "zh",
|
||||
surface = "personal",
|
||||
}: {
|
||||
reportId: string;
|
||||
initialLanguage?: ReportLanguage;
|
||||
surface?: ReportSurface;
|
||||
}) {
|
||||
const [state, setState] = useState<ReportLoadState>({ phase: "loading" });
|
||||
const [language, setLanguage] = useState<ReportLanguage>(initialLanguage);
|
||||
const [drawnThrough, setDrawnThrough] = useState<number | null>(null);
|
||||
@@ -413,8 +423,23 @@ export function PersonalReportPage({ reportId, initialLanguage = "zh" }: { repor
|
||||
onPoll: tick,
|
||||
});
|
||||
|
||||
const redirectTo = reportSurfaceRedirect({
|
||||
surface,
|
||||
reportType: reportTypeForSurface(state),
|
||||
reportId,
|
||||
language,
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!redirectTo) return;
|
||||
window.location.replace(redirectTo);
|
||||
}, [redirectTo]);
|
||||
|
||||
if (state.phase === "loading") return <ReportOpening />;
|
||||
|
||||
if (redirectTo) {
|
||||
return <SecondaryHeader title={redirectTo.startsWith("/synastry/") ? "婚恋合盘" : "个人报告"} />;
|
||||
}
|
||||
|
||||
if (state.phase === "generating" && state.reportType === "synastry_full") {
|
||||
return (
|
||||
<>
|
||||
@@ -554,9 +579,11 @@ export function PersonalReportPage({ reportId, initialLanguage = "zh" }: { repor
|
||||
if (state.phase === "synastry-ready") {
|
||||
return (
|
||||
<>
|
||||
<style>{REPORT_SHELL_PRINT_CSS}</style>
|
||||
<style media="print">{REPORT_SHELL_PRINT_CSS}</style>
|
||||
<SecondaryHeader title="婚恋合盘" />
|
||||
<SynastryReportView reportId={state.reportId} snapshot={state.synastry} createdAt={state.createdAt} initialLanguage={language} />
|
||||
<div className="synastry-report-scroll">
|
||||
<SynastryReportView reportId={state.reportId} snapshot={state.synastry} createdAt={state.createdAt} initialLanguage={language} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ export function SynastryPicker() {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [creditCost, setCreditCost] = useState<number | null>(null);
|
||||
const [pricingNote, setPricingNote] = useState("");
|
||||
const [readyCount, setReadyCount] = useState(0);
|
||||
const [pairGenerating, setPairGenerating] = useState(false);
|
||||
const [earlier, setEarlier] = useState<Earlier[]>([]);
|
||||
const [access, setAccess] = useState<"pending" | "open" | "closed" | "unreachable">("pending");
|
||||
const pickRef = useRef<HTMLDivElement>(null);
|
||||
@@ -93,6 +95,41 @@ export function SynastryPicker() {
|
||||
return () => window.clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const timer = window.setTimeout(() => {
|
||||
if (access !== "open" || !left || !right || left === right) {
|
||||
setReadyCount(0);
|
||||
setPairGenerating(false);
|
||||
return;
|
||||
}
|
||||
setReadyCount(0);
|
||||
setPairGenerating(false);
|
||||
const params = new URLSearchParams({
|
||||
reportType: "synastry_full",
|
||||
pair: "1",
|
||||
subjectA: left,
|
||||
subjectB: right,
|
||||
});
|
||||
void fetch(`/api/reports?${params}`, { cache: "no-store" })
|
||||
.then((response) => response.ok ? response.json() : null)
|
||||
.then((payload: { readyCount?: unknown; generating?: unknown } | null) => {
|
||||
if (cancelled || !payload) return;
|
||||
setReadyCount(typeof payload.readyCount === "number" ? payload.readyCount : 0);
|
||||
setPairGenerating(payload.generating === true);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setReadyCount(0);
|
||||
setPairGenerating(false);
|
||||
});
|
||||
}, 0);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [access, left, right]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open && !adding) return;
|
||||
function onKey(event: KeyboardEvent) {
|
||||
@@ -161,7 +198,7 @@ export function SynastryPicker() {
|
||||
b: people.find((person) => person.id === right)?.name ?? "",
|
||||
};
|
||||
sessionStorage.setItem(`synastry-wait:${payload.report.id}`, JSON.stringify(pair));
|
||||
window.location.assign(`/reports/${payload.report.id}`);
|
||||
window.location.assign(`/synastry/${payload.report.id}`);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -215,7 +252,7 @@ export function SynastryPicker() {
|
||||
</div>
|
||||
<div className="synastry-go">
|
||||
<button type="button" className="button-primary" onClick={() => void createReport()} disabled={busy || !ready}>开始合盘</button>
|
||||
{creditCost !== null ? <p className="synastry-cost">生成一份完整合盘报告,用掉 {creditCost} 点。大约要几分钟。</p> : pricingNote ? <p className="synastry-cost">{pricingNote}</p> : null}
|
||||
<SynastryGoNotes creditCost={creditCost} pricingNote={pricingNote} readyCount={readyCount} generating={pairGenerating} />
|
||||
{message ? <p className="synastry-cost" role="status">{message}</p> : null}
|
||||
</div>
|
||||
{earlier.length > 0 ? (
|
||||
@@ -224,7 +261,7 @@ export function SynastryPicker() {
|
||||
<ul>
|
||||
{earlier.map((report) => (
|
||||
<li key={report.id}>
|
||||
<a className="synastry-history-item" href={`/reports/${report.id}`}>
|
||||
<a className="synastry-history-item" href={`/synastry/${report.id}`}>
|
||||
<span className="who">{historyWho(report.cardSummary)}</span>
|
||||
{report.createdAt ? <span className="meta">{historyWhen(report.createdAt)}</span> : null}
|
||||
</a>
|
||||
@@ -250,6 +287,26 @@ export function SynastryPicker() {
|
||||
);
|
||||
}
|
||||
|
||||
export function synastryAgainNote(readyCount: number, creditCost: number | null, generating: boolean): string | null {
|
||||
if (generating || creditCost === null || readyCount < 1) return null;
|
||||
return `这两人已经合过 ${readyCount} 次,再生成会另扣 ${creditCost} 点`;
|
||||
}
|
||||
|
||||
export function SynastryGoNotes(props: {
|
||||
creditCost: number | null;
|
||||
pricingNote: string;
|
||||
readyCount: number;
|
||||
generating: boolean;
|
||||
}) {
|
||||
const again = synastryAgainNote(props.readyCount, props.creditCost, props.generating);
|
||||
return (
|
||||
<>
|
||||
{props.creditCost !== null ? <p className="synastry-cost">生成一份完整合盘报告,用掉 {props.creditCost} 点。大约要几分钟。</p> : props.pricingNote ? <p className="synastry-cost">{props.pricingNote}</p> : null}
|
||||
{again ? <p className="synastry-cost">{again}</p> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function personRow(id: string, role: "self" | "other", profile: Profile): Person {
|
||||
const gender = parseProfileGender(profile.gender);
|
||||
const date = effectiveBirthDate(profile);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { SynastryWait } from "@/components/synastry/synastry-wait";
|
||||
import { beginSynastrySectionChat } from "@/lib/synastry-report-chat-client";
|
||||
@@ -131,7 +132,7 @@ export function SynastryReportView(props: {
|
||||
|
||||
return (
|
||||
<article className="synastry-report">
|
||||
<a className="synastry-report-back" href="/synastry">‹ 婚恋合盘</a>
|
||||
<Link className="synastry-report-back" href="/synastry">‹ 婚恋合盘</Link>
|
||||
<header className="synastry-report-heading">
|
||||
<h1>{title}</h1>
|
||||
<div className="synastry-report-meta">
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/** Selection order for a synastry report. The pair key stays sorted for dedup. */
|
||||
|
||||
export const SYNASTRY_ORDER_SECTION = "synastry_order";
|
||||
|
||||
export function synastryDisplaySubjectIds(low: string, high: string, order: unknown): [string, string] {
|
||||
const row = order && typeof order === "object" && !Array.isArray(order)
|
||||
? order as Record<string, unknown>
|
||||
: null;
|
||||
const first = typeof row?.first === "string" ? row.first : "";
|
||||
const second = typeof row?.second === "string" ? row.second : "";
|
||||
if (first !== second && (first === low || first === high) && (second === low || second === high)) {
|
||||
return [first, second];
|
||||
}
|
||||
return [low, high];
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type { DataQueryBuilder } from "@/lib/db/data-client";
|
||||
import { consultationSubjectFailureResponse } from "@/lib/consultation-subject-resolver";
|
||||
import { FeaturePricingError, resolveFeaturePricing } from "@/lib/feature-pricing";
|
||||
import { MARRIAGE_MATCH_RULES_VERSION, marriageMatchFingerprint } from "@/lib/marriage-match-service";
|
||||
import { SYNASTRY_ORDER_SECTION } from "@/lib/synastry-display-order";
|
||||
import { loadLanguageModelCatalog } from "@/lib/model-catalog";
|
||||
import { REPORT_STABLE_CODES } from "@/lib/personal-report-codes";
|
||||
import { readPersonalReportDailyLimit } from "@/lib/personal-report-entitlement";
|
||||
@@ -52,21 +53,27 @@ function selectReports(admin: unknown, columns: string): Query {
|
||||
return (admin as { from(table: string): { select(columns: string): Query } }).from("personal_reports").select(columns);
|
||||
}
|
||||
|
||||
export async function createSynastryFullReport(input: Readonly<{
|
||||
type OpenedPair = {
|
||||
userId: string;
|
||||
subjectA: string;
|
||||
subjectB: string;
|
||||
pair: ReturnType<typeof pairOf>;
|
||||
};
|
||||
|
||||
async function openSynastryPair(input: Readonly<{
|
||||
userId: string | null;
|
||||
body: unknown;
|
||||
supabase: unknown;
|
||||
admin: Parameters<typeof createSupabasePersonalReportService>[0];
|
||||
}>): Promise<SynastryCreateResponse> {
|
||||
if (!input.userId) return { status: 401, body: { error: "请先登录" } };
|
||||
subjectA: unknown;
|
||||
subjectB: unknown;
|
||||
}>): Promise<{ response: SynastryCreateResponse } | OpenedPair> {
|
||||
if (!input.userId) return { response: { status: 401, body: { error: "请先登录" } } };
|
||||
if (!await isProductEnabled("compatibility") || !await isProductEnabled("report_center")) {
|
||||
return { status: 503, body: { error: "合盘服务暂未开放", code: "compatibility_product_disabled" } };
|
||||
return { response: { status: 503, body: { error: "合盘服务暂未开放", code: "compatibility_product_disabled" } } };
|
||||
}
|
||||
const body = input.body && typeof input.body === "object" ? input.body as Record<string, unknown> : {};
|
||||
const subjectA = subjectId(body.subjectA ?? "self");
|
||||
const subjectB = subjectId(body.subjectB ?? body.with);
|
||||
if (!subjectA || !subjectB) return { status: 400, body: { error: "请选择两个人", code: "subject_required" } };
|
||||
if (subjectA === subjectB) return { status: 400, body: { error: "请选择两个不同的人", code: "same_subject" } };
|
||||
const subjectA = subjectId(input.subjectA ?? "self");
|
||||
const subjectB = subjectId(input.subjectB);
|
||||
if (!subjectA || !subjectB) return { response: { status: 400, body: { error: "请选择两个人", code: "subject_required" } } };
|
||||
if (subjectA === subjectB) return { response: { status: 400, body: { error: "请选择两个不同的人", code: "same_subject" } } };
|
||||
|
||||
let rowA: Record<string, unknown>;
|
||||
let rowB: Record<string, unknown>;
|
||||
@@ -76,44 +83,90 @@ export async function createSynastryFullReport(input: Readonly<{
|
||||
loadSubjectBirth(input.supabase, input.userId, subjectB),
|
||||
]);
|
||||
if (!personA.chartable || !personB.chartable) {
|
||||
return { status: 422, body: { error: "请先完善出生资料", code: REPORT_STABLE_CODES.profileIncomplete } };
|
||||
return { response: { status: 422, body: { error: "请先完善出生资料", code: REPORT_STABLE_CODES.profileIncomplete } } };
|
||||
}
|
||||
rowA = personA.row;
|
||||
rowB = personB.row;
|
||||
} catch (error) {
|
||||
if (error instanceof ConsultationSubjectError) {
|
||||
const failure = consultationSubjectFailureResponse(error);
|
||||
return { status: failure.status, body: failure.body };
|
||||
return { response: { status: failure.status, body: failure.body } };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
userId: input.userId,
|
||||
subjectA,
|
||||
subjectB,
|
||||
pair: pairOf(subjectA, subjectB, marriageMatchFingerprint(rowA), marriageMatchFingerprint(rowB)),
|
||||
};
|
||||
}
|
||||
|
||||
const pair = pairOf(subjectA, subjectB, marriageMatchFingerprint(rowA), marriageMatchFingerprint(rowB));
|
||||
const requestFingerprint = createHash("sha256").update(JSON.stringify({
|
||||
...pair,
|
||||
rules: MARRIAGE_MATCH_RULES_VERSION,
|
||||
})).digest("hex");
|
||||
const existing = await selectReports(input.admin, "id,request_id,status,created_at,completed_at")
|
||||
.eq("user_id", input.userId)
|
||||
function pairQuery(admin: unknown, pair: OpenedPair["pair"], userId: string) {
|
||||
return selectReports(admin, "id,request_id,status,created_at,completed_at")
|
||||
.eq("user_id", userId)
|
||||
.eq("report_type", "synastry_full")
|
||||
.eq("subject_low", pair.low)
|
||||
.eq("subject_high", pair.high)
|
||||
.eq("fingerprint_low", pair.fingerprintLow)
|
||||
.eq("fingerprint_high", pair.fingerprintHigh)
|
||||
.eq("synastry_rules_version", MARRIAGE_MATCH_RULES_VERSION)
|
||||
.in("status", ["ready", "generating"])
|
||||
.eq("synastry_rules_version", MARRIAGE_MATCH_RULES_VERSION);
|
||||
}
|
||||
|
||||
export async function readSynastryPairState(input: Readonly<{
|
||||
userId: string | null;
|
||||
supabase: unknown;
|
||||
admin: Parameters<typeof createSupabasePersonalReportService>[0];
|
||||
subjectA: unknown;
|
||||
subjectB: unknown;
|
||||
}>): Promise<SynastryCreateResponse> {
|
||||
const opened = await openSynastryPair(input);
|
||||
if ("response" in opened) return opened.response;
|
||||
const found = await pairQuery(input.admin, opened.pair, opened.userId).in("status", ["ready", "generating"]);
|
||||
if (found.error) throw new Error(found.error.message ?? "synastry pair lookup failed");
|
||||
const rows = Array.isArray(found.data) ? found.data as Row[] : [];
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
readyCount: rows.filter((row) => row.status === "ready").length,
|
||||
generating: rows.some((row) => row.status === "generating"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function createSynastryFullReport(input: Readonly<{
|
||||
userId: string | null;
|
||||
body: unknown;
|
||||
supabase: unknown;
|
||||
admin: Parameters<typeof createSupabasePersonalReportService>[0];
|
||||
}>): Promise<SynastryCreateResponse> {
|
||||
const body = input.body && typeof input.body === "object" ? input.body as Record<string, unknown> : {};
|
||||
const opened = await openSynastryPair({
|
||||
userId: input.userId,
|
||||
supabase: input.supabase,
|
||||
subjectA: body.subjectA ?? "self",
|
||||
subjectB: body.subjectB ?? body.with,
|
||||
});
|
||||
if ("response" in opened) return opened.response;
|
||||
const { userId, subjectA, subjectB, pair } = opened;
|
||||
const requestFingerprint = createHash("sha256").update(JSON.stringify({
|
||||
...pair,
|
||||
rules: MARRIAGE_MATCH_RULES_VERSION,
|
||||
})).digest("hex");
|
||||
const existing = await pairQuery(input.admin, pair, userId)
|
||||
.eq("status", "generating")
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
if (existing.error) throw new Error(existing.error.message ?? "synastry lookup failed");
|
||||
if (existing.data?.status === "ready" || existing.data?.status === "generating") {
|
||||
if (existing.data?.status === "generating") {
|
||||
return {
|
||||
status: existing.data.status === "ready" ? 200 : 202,
|
||||
body: { report: reportView(stubRecord(input.userId, existing.data)), reused: true },
|
||||
status: 202,
|
||||
body: { report: reportView(stubRecord(userId, existing.data)), reused: true },
|
||||
};
|
||||
}
|
||||
|
||||
const inFlight = await selectReports(input.admin, "id")
|
||||
.eq("user_id", input.userId)
|
||||
.eq("user_id", userId)
|
||||
.eq("status", "generating")
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
@@ -123,7 +176,7 @@ export async function createSynastryFullReport(input: Readonly<{
|
||||
}
|
||||
const todayStart = new Date();
|
||||
todayStart.setHours(0, 0, 0, 0);
|
||||
const today = await selectReports(input.admin, "created_at").eq("user_id", input.userId).gte("created_at", todayStart.toISOString());
|
||||
const today = await selectReports(input.admin, "created_at").eq("user_id", userId).gte("created_at", todayStart.toISOString());
|
||||
if (today.error) throw new Error(today.error.message ?? "synastry daily lookup failed");
|
||||
if ((today.data ?? []).length >= readPersonalReportDailyLimit(process.env)) {
|
||||
return { status: 429, body: { error: "今日报告生成次数已达上限", code: REPORT_STABLE_CODES.rateLimited } };
|
||||
@@ -132,7 +185,7 @@ export async function createSynastryFullReport(input: Readonly<{
|
||||
const catalog = await loadLanguageModelCatalog();
|
||||
const model = catalog.models.find((entry) => entry.id === catalog.defaultModelId) ?? null;
|
||||
if (!model) return { status: 502, body: { error: "报告模型暂不可用", code: REPORT_STABLE_CODES.modelUnavailable } };
|
||||
const limited = consumeUserRateLimitSet(["reportGenerateHour"], input.userId);
|
||||
const limited = consumeUserRateLimitSet(["reportGenerateHour"], userId);
|
||||
if (!limited.ok) {
|
||||
return {
|
||||
status: 429,
|
||||
@@ -146,7 +199,7 @@ export async function createSynastryFullReport(input: Readonly<{
|
||||
try {
|
||||
const pricing = await resolveFeaturePricing(input.admin as never, "synastry.full", model.id);
|
||||
const authorization = await authorizeUsage(input.admin as never, {
|
||||
userId: input.userId,
|
||||
userId,
|
||||
requestId,
|
||||
featureKey: "synastry.full",
|
||||
requestedModelId: model.id,
|
||||
@@ -180,7 +233,7 @@ export async function createSynastryFullReport(input: Readonly<{
|
||||
const release = async (reason: string) => {
|
||||
if (!reserved) return;
|
||||
reserved = false;
|
||||
try { await releaseUsage(input.admin as never, input.userId as string, requestId, reason); } catch { /* the RPC is idempotent */ }
|
||||
try { await releaseUsage(input.admin as never, userId, requestId, reason); } catch { /* the RPC is idempotent */ }
|
||||
};
|
||||
|
||||
const persistence = createSupabasePersonalReportService(input.admin);
|
||||
@@ -190,7 +243,7 @@ export async function createSynastryFullReport(input: Readonly<{
|
||||
let created;
|
||||
try {
|
||||
created = await persistence.createGenerating({
|
||||
userId: input.userId,
|
||||
userId,
|
||||
requestId,
|
||||
requestFingerprint,
|
||||
reportType: "synastry_full",
|
||||
@@ -215,15 +268,33 @@ export async function createSynastryFullReport(input: Readonly<{
|
||||
await release(REPORT_STABLE_CODES.generationInProgress);
|
||||
return { status: 409, body: { error: "已有报告正在生成中", code: REPORT_STABLE_CODES.generationInProgress } };
|
||||
}
|
||||
const orderSaved = await (input.admin as {
|
||||
from(table: string): {
|
||||
insert(row: Record<string, unknown>): Promise<{ error: { message?: string } | null }>;
|
||||
};
|
||||
}).from("personal_report_sections").insert({
|
||||
user_id: userId,
|
||||
request_id: requestId,
|
||||
section_id: SYNASTRY_ORDER_SECTION,
|
||||
status: "ready",
|
||||
payload: { first: subjectA, second: subjectB },
|
||||
attempt_count: 1,
|
||||
max_attempts: 1,
|
||||
});
|
||||
if (orderSaved.error) {
|
||||
await persistence.markFailed(userId, created.record.id, REPORT_STABLE_CODES.generationFailed);
|
||||
await release(REPORT_STABLE_CODES.generationFailed);
|
||||
return { status: 503, body: { error: "报告任务暂时无法入队", code: REPORT_STABLE_CODES.generationFailed } };
|
||||
}
|
||||
try {
|
||||
const enqueued = await createSupabasePersonalReportJobService(input.admin as never).enqueue({
|
||||
userId: input.userId,
|
||||
userId,
|
||||
requestId,
|
||||
requestFingerprint,
|
||||
});
|
||||
return { status: 202, body: { report: reportView(created.record), jobId: enqueued.job.id } };
|
||||
} catch {
|
||||
await persistence.markFailed(input.userId, created.record.id, REPORT_STABLE_CODES.generationFailed);
|
||||
await persistence.markFailed(userId, created.record.id, REPORT_STABLE_CODES.generationFailed);
|
||||
await release(REPORT_STABLE_CODES.generationFailed);
|
||||
return { status: 503, body: { error: "报告任务暂时无法入队", code: REPORT_STABLE_CODES.generationFailed } };
|
||||
}
|
||||
|
||||
@@ -113,6 +113,36 @@ function deliveryFrom(parts: ReadonlyMap<SynastrySectionId, Record<string, unkno
|
||||
return { sections: SYNASTRY_SECTION_IDS.map((id) => parts.get(id) ?? { id }) };
|
||||
}
|
||||
|
||||
/** A body that opens with its own section title, including a repeated title. */
|
||||
export function startsWithSectionTitle(text: string, title: string): boolean {
|
||||
const heading = title.trim();
|
||||
const body = text.replace(/^\s+/, "");
|
||||
if (!heading || !body.startsWith(heading)) return false;
|
||||
const rest = body.slice(heading.length);
|
||||
return rest.length === 0 || /^[\s\u3000]/.test(rest) || rest.startsWith(heading);
|
||||
}
|
||||
|
||||
export function stripLeadingSectionTitle(text: string, title: string): string {
|
||||
const heading = title.trim();
|
||||
if (!heading) return text;
|
||||
let rest = text.replace(/^\s+/, "");
|
||||
for (let guard = 0; guard < 4 && startsWithSectionTitle(rest, heading); guard += 1) {
|
||||
rest = rest.slice(heading.length).replace(/^\s+/, "");
|
||||
}
|
||||
return rest;
|
||||
}
|
||||
|
||||
function headingFailures(parts: ReadonlyMap<SynastrySectionId, Record<string, unknown>>): { section: string; code: string }[] {
|
||||
const failures: { section: string; code: string }[] = [];
|
||||
for (const id of SYNASTRY_PROSE_IDS) {
|
||||
const row = parts.get(id);
|
||||
const title = typeof row?.title === "string" ? row.title : "";
|
||||
const text = typeof row?.text === "string" ? row.text : "";
|
||||
if (startsWithSectionTitle(text, title)) failures.push({ section: id, code: "repeated_heading" });
|
||||
}
|
||||
return failures;
|
||||
}
|
||||
|
||||
export async function writeSynastryLanguage(input: Readonly<{
|
||||
language: "zh" | "en";
|
||||
names: Readonly<{ a: string; b: string }>;
|
||||
@@ -195,16 +225,35 @@ export async function writeSynastryLanguage(input: Readonly<{
|
||||
await input.onProgress?.("checking_report");
|
||||
let parts = materialize();
|
||||
let gated = await input.gate({ delivery: deliveryFrom(parts), sectionInputs: evidence, language: input.language });
|
||||
const firstHeadings = headingFailures(parts);
|
||||
if (firstHeadings.length > 0) {
|
||||
const seen = new Set(gated.failures.map((failure) => `${failure.section}:${failure.code}`));
|
||||
gated = {
|
||||
ok: false,
|
||||
failures: [...gated.failures, ...firstHeadings.filter((failure) => !seen.has(`${failure.section}:${failure.code}`))],
|
||||
};
|
||||
}
|
||||
if (!gated.ok) {
|
||||
const failed = new Set(gated.failures.map((failure) => failure.section).filter((section) => SYNASTRY_SECTION_IDS.includes(section as SynastrySectionId)));
|
||||
const targets = failed.size > 0 ? [...failed] as SynastrySectionId[] : [...SYNASTRY_SECTION_IDS];
|
||||
await pool(targets, 3, (sectionId) => writeOne(sectionId, gated.failures.filter((failure) => failure.section === sectionId).map((failure) => failure.code)));
|
||||
parts = materialize();
|
||||
gated = await input.gate({ delivery: deliveryFrom(parts), sectionInputs: evidence, language: input.language });
|
||||
const secondHeadings = headingFailures(parts);
|
||||
if (secondHeadings.length > 0) {
|
||||
const seen = new Set(gated.failures.map((failure) => `${failure.section}:${failure.code}`));
|
||||
gated = {
|
||||
ok: false,
|
||||
failures: [...gated.failures, ...secondHeadings.filter((failure) => !seen.has(`${failure.section}:${failure.code}`))],
|
||||
};
|
||||
}
|
||||
}
|
||||
if (!gated.ok) throw new Error("synastry_gate_rejected");
|
||||
|
||||
const sections = SYNASTRY_PROSE_IDS.map((id) => parts.get(id) as { id: SynastrySectionId; title: string; text: string; reasons: SynastryReason[] });
|
||||
const sections = SYNASTRY_PROSE_IDS.map((id) => {
|
||||
const section = parts.get(id) as { id: SynastrySectionId; title: string; text: string; reasons: SynastryReason[] };
|
||||
return { ...section, text: stripLeadingSectionTitle(section.text, section.title) };
|
||||
});
|
||||
const timeline = (parts.get("timeline")?.segments ?? []) as SynastrySnapshot["timeline"];
|
||||
const practices = (parts.get("practices")?.items ?? []) as SynastrySnapshot["practices"];
|
||||
const technical = buildTechnicalBasis({
|
||||
|
||||
@@ -14,6 +14,7 @@ import { PersonalReportWorkerError } from "@/lib/personal-report-worker-core";
|
||||
import type { PersonalReportRecord } from "@/lib/personal-report-service-core";
|
||||
import { loadSubjectBirth } from "@/lib/subject-birth";
|
||||
import { marriageMatchBirthBody } from "@/lib/marriage-match-service";
|
||||
import { SYNASTRY_ORDER_SECTION, synastryDisplaySubjectIds } from "@/lib/synastry-display-order";
|
||||
import { timelineForPair, writeSynastryLanguage, type SynastrySnapshot } from "@/lib/synastry-full-generate";
|
||||
import type { TimingPeriod } from "@/lib/synastry-full-timeline";
|
||||
|
||||
@@ -160,9 +161,21 @@ export async function runSynastryFullReport(input: Readonly<{
|
||||
}
|
||||
await input.onProgress?.({ phase: "computing_charts", completed: 0, total: 9 });
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const orderRow = await input.admin.from("personal_report_sections")
|
||||
.select("payload")
|
||||
.eq("user_id", input.report.userId)
|
||||
.eq("request_id", input.report.requestId)
|
||||
.eq("section_id", SYNASTRY_ORDER_SECTION)
|
||||
.maybeSingle();
|
||||
const orderPayload = orderRow.error ? null : asRecord(orderRow.data)?.payload ?? null;
|
||||
const [firstId, secondId] = synastryDisplaySubjectIds(
|
||||
input.report.subjectLow,
|
||||
input.report.subjectHigh,
|
||||
orderPayload,
|
||||
);
|
||||
const [personA, personB] = await Promise.all([
|
||||
loadSubjectBirth(input.admin, input.report.userId, input.report.subjectLow),
|
||||
loadSubjectBirth(input.admin, input.report.userId, input.report.subjectHigh),
|
||||
loadSubjectBirth(input.admin, input.report.userId, firstId),
|
||||
loadSubjectBirth(input.admin, input.report.userId, secondId),
|
||||
]);
|
||||
const packet = asRecord(await postJson("/api/synastry_full_dossier", {
|
||||
person_a: marriageMatchBirthBody(personA.row, today),
|
||||
|
||||
@@ -197,7 +197,7 @@ const ZH_SYSTEM = `你在写一份婚恋合盘的关系画像。把盘面翻译
|
||||
不要报分数。不要用「克 / 煞 / 凶 / 吉」贴标签。不要说「注定」「必然」「命中」。不要把一方说成问题源。不要替当事人做决定。
|
||||
示例:${ZH_EXAMPLE}
|
||||
每个判断给两到三条分析。盘面行必须逐字来自证据里的某个完整字段,不要改写。证据互相冲突时把 label 降为 parameter_sensitive 或 blocked,并写成条件,不要写成应期。不要点名补充方法。
|
||||
结论、所以、时间线的阶段名和描述、共同练习里不得出现星曜、宫位、星座、分盘、大运、分数、Koota,也不得出现「克 / 吉 / 注定 / 不合 / 适合结婚」。不要写出生日期、时间、地点。不要写某年结婚、领证或分开。第一句不要写成「这段关系的核心不是……」。
|
||||
结论、所以、时间线的阶段名和描述、共同练习里不得出现星曜、宫位、星座、分盘、大运、分数、Koota,也不得出现「克 / 吉 / 注定 / 不合 / 适合结婚」。不要写出生日期、时间、地点。不要写某年结婚、领证或分开。第一句不要写成「这段关系的核心不是……」,正文也不要以这一节的标题开头。
|
||||
质检硬规则:label 只能是 ${EVIDENCE_LABELS.join("、")}。中文每一节结论至少 ${SYNASTRY_LENGTH.zhSection} 字,七节合计至少 ${SYNASTRY_LENGTH.zhTotal} 字。evidence 必须等于该节证据 JSON 里的某个完整字符串字段。`;
|
||||
|
||||
const EN_SYSTEM = `You write a relationship portrait of two people. Translate the evidence into how the relationship feels. Do not judge the people.
|
||||
@@ -207,7 +207,7 @@ Five rules: speak about the pair, not about who someone is. Speak in tendencies,
|
||||
Do not quote a score. Do not call one person the problem. Do not decide for them.
|
||||
Example: ${EN_EXAMPLE}
|
||||
Give two or three supports for each judgment. The chart line must copy one complete evidence field verbatim. When supports conflict, lower the label to parameter_sensitive or blocked and write a condition, not a dated event. Do not name supplementary methods.
|
||||
The conclusion, the "so" line, the timeline phase and description, and the shared practices must not name planets, houses, signs, nakshatras, divisional charts, dashas, scores, koota, kuja, nadi, lagna, UL, or DK. Do not include birth dates, times, or places. Do not predict a wedding or a breakup in a named year. Do not open with "The core of this relationship is not".
|
||||
The conclusion, the "so" line, the timeline phase and description, and the shared practices must not name planets, houses, signs, nakshatras, divisional charts, dashas, scores, koota, kuja, nadi, lagna, UL, or DK. Do not include birth dates, times, or places. Do not predict a wedding or a breakup in a named year. Do not open with "The core of this relationship is not". Do not start the body with the section title.
|
||||
Gate: label must be one of ${EVIDENCE_LABELS.join(", ")}. Each English section is at least ${SYNASTRY_LENGTH.enSection} words, and the seven sections together are at least ${SYNASTRY_LENGTH.enTotal} words. evidence must equal one complete string field from that section's evidence JSON.`;
|
||||
|
||||
export function systemPrompt(language: "zh" | "en"): string {
|
||||
@@ -223,14 +223,20 @@ export function userPrompt(input: Readonly<{
|
||||
}>): string {
|
||||
const title = SECTION_TITLES[input.sectionId][input.language];
|
||||
const evidence = JSON.stringify(input.evidence);
|
||||
const notes = (input.failures ?? []).map((code) => {
|
||||
if (code !== "repeated_heading") return code;
|
||||
return input.language === "en"
|
||||
? "repeated_heading (do not start the body with the section title)"
|
||||
: "repeated_heading(正文不要以节标题开头)";
|
||||
});
|
||||
if (input.language === "en") {
|
||||
const repair = input.failures && input.failures.length > 0
|
||||
? `\nThe previous draft failed this section only: ${input.failures.join("; ")}`
|
||||
const repair = notes.length > 0
|
||||
? `\nThe previous draft failed this section only: ${notes.join("; ")}`
|
||||
: "";
|
||||
return [`Names: ${input.names.a} and ${input.names.b}`, `Section: ${title}`, `Evidence: ${evidence}`, repair].filter(Boolean).join("\n");
|
||||
}
|
||||
const repair = input.failures && input.failures.length > 0
|
||||
? `\n上次没有通过,只改这一节:${input.failures.join(";")}`
|
||||
const repair = notes.length > 0
|
||||
? `\n上次没有通过,只改这一节:${notes.join(";")}`
|
||||
: "";
|
||||
return [
|
||||
`两人称呼:${input.names.a}、${input.names.b}`,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
export type ReportSurface = "personal" | "synastry";
|
||||
|
||||
export function reportReaderHref(surface: ReportSurface, reportId: string, language: "zh" | "en"): string {
|
||||
const path = surface === "synastry"
|
||||
? `/synastry/${encodeURIComponent(reportId)}`
|
||||
: `/reports/${encodeURIComponent(reportId)}`;
|
||||
return language === "en" ? `${path}?lang=en` : path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Old shared links still open /reports/:id. A synastry report there moves to
|
||||
* /synastry/:id, and a personal report opened on the synastry URL moves back.
|
||||
* Unknown types stay put. English keeps ?lang=en; Chinese keeps a clean path.
|
||||
*/
|
||||
export function reportSurfaceRedirect(input: {
|
||||
surface: ReportSurface;
|
||||
reportType: string | null;
|
||||
reportId: string;
|
||||
language: "zh" | "en";
|
||||
}): string | null {
|
||||
if (!input.reportType) return null;
|
||||
const synastry = input.reportType === "synastry_full";
|
||||
if (input.surface === "personal" && synastry) return reportReaderHref("synastry", input.reportId, input.language);
|
||||
if (input.surface === "synastry" && !synastry) return reportReaderHref("personal", input.reportId, input.language);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function reportTypeForSurface(state: { phase: string; reportType?: string }): string | null {
|
||||
if (state.phase === "synastry-ready") return "synastry_full";
|
||||
if (state.phase === "generating") return state.reportType ?? "personal_full";
|
||||
if (state.phase === "markdown-ready" || state.phase === "legacy-unavailable") return "personal_full";
|
||||
if (state.phase === "failed") return state.reportType ?? null;
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
-- A ready synastry report used to be unique per pair, so a second reading
|
||||
-- could not be saved. Product decision K6 keeps the old report and allows
|
||||
-- another one. Engine rows in synastry_reports stay unique and are reused.
|
||||
drop index if exists public.personal_reports_synastry_pair_ready_uidx;
|
||||
@@ -105,7 +105,7 @@ test("createSynastryFullReport reserves synastry.full and enqueues a job on the
|
||||
});
|
||||
catalog.invalidateLanguageModelCatalog();
|
||||
|
||||
const { createSynastryFullReport } = await import("../src/lib/synastry-full-create.ts");
|
||||
const { createSynastryFullReport, readSynastryPairState } = 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({
|
||||
@@ -123,6 +123,65 @@ test("createSynastryFullReport reserves synastry.full and enqueues a job on the
|
||||
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");
|
||||
assert.equal(fixture.psql(`select payload->>'first' from public.personal_report_sections where user_id = '${userId}' and section_id = 'synastry_order'`), "self");
|
||||
assert.equal(fixture.psql(`select payload->>'second' from public.personal_report_sections where user_id = '${userId}' and section_id = 'synastry_order'`), personId);
|
||||
assert.equal(fixture.psql(`select subject_low from public.personal_reports where id = '${report?.id}'`), personId);
|
||||
assert.equal(fixture.psql(`select subject_high from public.personal_reports where id = '${report?.id}'`), "self");
|
||||
|
||||
const again = await createSynastryFullReport({
|
||||
userId,
|
||||
body: { reportType: "synastry_full", subjectA: "self", subjectB: personId },
|
||||
supabase: user,
|
||||
admin,
|
||||
});
|
||||
assert.equal(again.status, 202, JSON.stringify(again.body));
|
||||
assert.equal(again.body.reused, true);
|
||||
assert.equal((again.body.report as { id?: string }).id, report?.id);
|
||||
assert.equal(fixture.psql(`select credits from public.profiles where id = '${userId}'`), "81");
|
||||
|
||||
const hash = "d".repeat(64);
|
||||
fixture.psql(`
|
||||
update public.personal_reports
|
||||
set status = 'ready', completed_at = now(), report_document = '{}'::jsonb,
|
||||
calculation_hash = '${hash}', evidence_hash = '${hash}'
|
||||
where id = '${report?.id}';
|
||||
update public.personal_report_jobs
|
||||
set status = 'cancelled', finished_at = now()
|
||||
where user_id = '${userId}' and status = 'queued';
|
||||
`);
|
||||
const readyState = await readSynastryPairState({
|
||||
userId,
|
||||
supabase: user,
|
||||
admin,
|
||||
subjectA: "self",
|
||||
subjectB: personId,
|
||||
});
|
||||
assert.equal(readyState.status, 200, JSON.stringify(readyState.body));
|
||||
assert.equal(readyState.body.readyCount, 1);
|
||||
assert.equal(readyState.body.generating, false);
|
||||
|
||||
const second = await createSynastryFullReport({
|
||||
userId,
|
||||
body: { reportType: "synastry_full", subjectA: "self", subjectB: personId },
|
||||
supabase: user,
|
||||
admin,
|
||||
});
|
||||
assert.equal(second.status, 202, JSON.stringify(second.body));
|
||||
assert.equal(second.body.reused, undefined);
|
||||
const secondId = (second.body.report as { id?: string }).id;
|
||||
assert.equal(typeof secondId, "string");
|
||||
assert.notEqual(secondId, report?.id);
|
||||
assert.equal(fixture.psql(`select status from public.personal_reports where id = '${secondId}'`), "generating");
|
||||
assert.equal(fixture.psql(`select credits from public.profiles where id = '${userId}'`), "62");
|
||||
const generatingState = await readSynastryPairState({
|
||||
userId,
|
||||
supabase: user,
|
||||
admin,
|
||||
subjectA: "self",
|
||||
subjectB: personId,
|
||||
});
|
||||
assert.equal(generatingState.body.readyCount, 1);
|
||||
assert.equal(generatingState.body.generating, true);
|
||||
catalog.setModelCatalogIoForTests(null);
|
||||
catalog.invalidateLanguageModelCatalog();
|
||||
} finally {
|
||||
|
||||
@@ -81,7 +81,10 @@ test("synastry reports reuse a pair, drop with the person, and settle the synast
|
||||
where id = '${reportId}';
|
||||
${closeJob}
|
||||
`);
|
||||
assert.throws(() => fixture.psql(`
|
||||
// 原值:第二份 ready 插入被 personal_reports_synastry_pair_ready_uidx 拒绝,count 为 1
|
||||
// 新值:同一对可以再有一份 ready,count 为 2
|
||||
// 原因:K6 推翻复用,旧报告保留(BUG-1408)
|
||||
fixture.psql(`
|
||||
begin;
|
||||
${insertReport(duplicateReportId, secondRequestId, fingerprint)}
|
||||
update public.personal_reports
|
||||
@@ -89,14 +92,12 @@ test("synastry reports reuse a pair, drop with the person, and settle the synast
|
||||
calculation_hash = '${skillHash}', evidence_hash = '${skillHash}'
|
||||
where id = '${duplicateReportId}';
|
||||
commit;
|
||||
`), (error: unknown) => {
|
||||
const stderr = error && typeof error === "object" && "stderr" in error ? String((error as { stderr?: unknown }).stderr) : "";
|
||||
return /personal_reports_synastry_pair_ready_uidx/.test(`${stderr}\n${String(error)}`);
|
||||
});
|
||||
`);
|
||||
assert.equal(lastLine(fixture.psql(`
|
||||
select count(*) from public.personal_reports
|
||||
where user_id = '${userId}' and fingerprint_low = '${fingerprint}' and status = 'ready';
|
||||
`)), "1");
|
||||
`)), "2");
|
||||
fixture.psql(closeJob);
|
||||
|
||||
fixture.psql(insertReport(changedReportId, changedRequestId, changedFingerprint));
|
||||
fixture.psql(`
|
||||
|
||||
@@ -63,9 +63,13 @@ test("GET envelope classification: 401/404/ready/generating/failed/invalid (real
|
||||
assert.equal(readyV2.phase, "markdown-ready");
|
||||
|
||||
assert.deepEqual(classifyReportEnvelope(200, { report: view("generating") }), { phase: "generating" });
|
||||
// 原值:失败态只有 phase 和 failureCode(以及摘要、附录错误码)
|
||||
// 新值:失败态带上 reportType
|
||||
// 原因:合盘地址要能把个人报告换回 /reports,失败的报告也要认出类型
|
||||
assert.deepEqual(classifyReportEnvelope(200, { report: view("failed", "model_unavailable") }), {
|
||||
phase: "failed",
|
||||
failureCode: "model_unavailable",
|
||||
reportType: "personal_full",
|
||||
});
|
||||
assert.deepEqual(
|
||||
classifyReportEnvelope(200, {
|
||||
@@ -75,6 +79,7 @@ test("GET envelope classification: 401/404/ready/generating/failed/invalid (real
|
||||
phase: "failed",
|
||||
failureCode: "report_schema_invalid",
|
||||
failureSummary: "4 个主题中 4 个写作失败:输出被截断",
|
||||
reportType: "personal_full",
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
@@ -90,9 +95,14 @@ test("GET envelope classification: 401/404/ready/generating/failed/invalid (real
|
||||
failureCode: "appendix_persist_failed",
|
||||
failureSummary: "报告正文未能保存,请稍后重试",
|
||||
appendixLastErrorCode: "appendix_persist_failed",
|
||||
reportType: "personal_full",
|
||||
},
|
||||
);
|
||||
assert.deepEqual(classifyReportEnvelope(200, { report: view("failed") }), { phase: "failed", failureCode: null });
|
||||
assert.deepEqual(classifyReportEnvelope(200, { report: view("failed") }), {
|
||||
phase: "failed",
|
||||
failureCode: null,
|
||||
reportType: "personal_full",
|
||||
});
|
||||
|
||||
// Server-side error envelopes carry a stable code at the top level.
|
||||
assert.deepEqual(classifyReportEnvelope(503, { error: "x", code: "report_persistence_unavailable" }), {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import { synastryDisplaySubjectIds } from "../src/lib/synastry-display-order.ts";
|
||||
import { synastryFollowupTitle } from "../src/lib/synastry-report-session.ts";
|
||||
|
||||
test("picker order outranks the sorted pair key for the title and the chat", () => {
|
||||
const ids = synastryDisplaySubjectIds("person-yi", "self", { first: "self", second: "person-yi" });
|
||||
assert.deepEqual(ids, ["self", "person-yi"]);
|
||||
const labels: Record<string, string> = { self: "甲", "person-yi": "乙" };
|
||||
const names = { a: labels[ids[0]] ?? "", b: labels[ids[1]] ?? "" };
|
||||
assert.equal(`${names.a} × ${names.b} · 婚恋合盘`, "甲 × 乙 · 婚恋合盘");
|
||||
assert.equal(synastryFollowupTitle(names), "甲 × 乙 · 合盘追问");
|
||||
assert.deepEqual(synastryDisplaySubjectIds("person-yi", "self", null), ["person-yi", "self"]);
|
||||
const wait = readFileSync(new URL("../src/components/synastry/synastry-wait.tsx", import.meta.url), "utf8");
|
||||
const run = readFileSync(new URL("../src/lib/synastry-full-run.ts", import.meta.url), "utf8");
|
||||
assert.match(wait, /\$\{a\} × \$\{b\}/);
|
||||
assert.match(run, /synastryDisplaySubjectIds\(/);
|
||||
assert.match(run, /cardSummary: `\$\{names\.a\} × \$\{names\.b\} · 婚恋合盘`/);
|
||||
});
|
||||
@@ -31,7 +31,7 @@ function runRoute(script: string): { status: number; body: Record<string, unknow
|
||||
return JSON.parse(line) as { status: number; body: Record<string, unknown>; charged: boolean };
|
||||
}
|
||||
|
||||
function script(input: { product: boolean; same: boolean; missingOther: boolean; reused: boolean }): string {
|
||||
function script(input: { product: boolean; same: boolean; missingOther: boolean; existing: "none" | "generating" }): string {
|
||||
return String.raw`
|
||||
import { mock } from "node:test";
|
||||
import { pathToFileURL } from "node:url";
|
||||
@@ -60,7 +60,7 @@ function script(input: { product: boolean; same: boolean; missingOther: boolean;
|
||||
const q = {
|
||||
select() { return q; },
|
||||
eq(column, value) { filters.push([column, value]); return q; },
|
||||
in() { return q; },
|
||||
in(column, values) { filters.push([column, values]); return q; },
|
||||
gte() { return q; },
|
||||
is() { return q; },
|
||||
or() { return q; },
|
||||
@@ -72,7 +72,11 @@ function script(input: { product: boolean; same: boolean; missingOther: boolean;
|
||||
return Promise.resolve({ data: ${input.missingOther ? "null" : "{ ...selfRow, id: otherId, user_id: 'user-1', role: 'other' }"}, error: null });
|
||||
}
|
||||
if (table === "personal_reports") {
|
||||
return Promise.resolve({ data: ${input.reused ? "{ id: 'ready-1', request_id: 'req-1', status: 'ready', created_at: '2026-10-19T00:00:00Z' }" : "null"}, error: null });
|
||||
const wantsGenerating = filters.some(([column, value]) => column === "status" && (value === "generating" || (Array.isArray(value) && value.includes("generating"))));
|
||||
if (${input.existing === "generating" ? "true" : "false"} && wantsGenerating) {
|
||||
return Promise.resolve({ data: { id: "gen-1", request_id: "req-1", status: "generating", created_at: "2026-10-19T00:00:00Z" }, error: null });
|
||||
}
|
||||
return Promise.resolve({ data: null, error: null });
|
||||
}
|
||||
return Promise.resolve({ data: null, error: null });
|
||||
},
|
||||
@@ -98,28 +102,31 @@ function script(input: { product: boolean; same: boolean; missingOther: boolean;
|
||||
}
|
||||
|
||||
test("the same person is rejected before billing", () => {
|
||||
const result = runRoute(script({ product: true, same: true, missingOther: false, reused: false }));
|
||||
const result = runRoute(script({ product: true, same: true, missingOther: false, existing: "none" }));
|
||||
assert.equal(result.status, 400);
|
||||
assert.equal(result.body.code, "same_subject");
|
||||
assert.equal(result.charged, false);
|
||||
});
|
||||
|
||||
test("someone else's id is rejected before billing", () => {
|
||||
const result = runRoute(script({ product: true, same: false, missingOther: true, reused: false }));
|
||||
const result = runRoute(script({ product: true, same: false, missingOther: true, existing: "none" }));
|
||||
assert.equal(result.status, 409);
|
||||
assert.equal(result.charged, false);
|
||||
assert.match(JSON.stringify(result.body), /不会扣点/);
|
||||
});
|
||||
|
||||
test("an unchanged pair is reused without a new reservation", () => {
|
||||
const result = runRoute(script({ product: true, same: false, missingOther: false, reused: true }));
|
||||
assert.equal(result.status, 200);
|
||||
test("a generating pair is reused without a new reservation", () => {
|
||||
// 原值:已有 ready 报告时 status 200、reused、不扣点
|
||||
// 新值:只有 generating 复用并返回 202;ready 不再走这条
|
||||
// 原因:K6 允许同一对人重新生成(BUG-1408)
|
||||
const result = runRoute(script({ product: true, same: false, missingOther: false, existing: "generating" }));
|
||||
assert.equal(result.status, 202);
|
||||
assert.equal(result.body.reused, true);
|
||||
assert.equal(result.charged, false);
|
||||
});
|
||||
|
||||
test("a closed product switch rejects the report before billing", () => {
|
||||
const result = runRoute(script({ product: false, same: false, missingOther: true, reused: false }));
|
||||
const result = runRoute(script({ product: false, same: false, missingOther: true, existing: "none" }));
|
||||
assert.equal(result.status, 503);
|
||||
assert.equal(result.body.code, "compatibility_product_disabled");
|
||||
assert.equal(result.charged, false);
|
||||
|
||||
@@ -6,10 +6,10 @@ import { fileURLToPath } from "node:url";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { createElement } from "react";
|
||||
|
||||
import { SynastryGoNotes, SynastryPicker } from "../src/components/synastry/synastry-picker.tsx";
|
||||
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 { stripLeadingSectionTitle, 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";
|
||||
@@ -98,6 +98,47 @@ test("a failed gate rewrites only the failed section once", async () => {
|
||||
assert.match(result.snapshotParts.technical[5]?.lines.at(-1) ?? "", /外部资料与真实案例核对:未进行/);
|
||||
});
|
||||
|
||||
test("a section that opens with its title is saved without the duplicate", async () => {
|
||||
const calls: SynastrySectionId[] = [];
|
||||
let checks = 0;
|
||||
const clean = prose();
|
||||
const result = await writeSynastryLanguage({
|
||||
language: "zh",
|
||||
names: { a: "甲", b: "乙" },
|
||||
dossier: {},
|
||||
segments: [
|
||||
{ id: "s1", start: "2026-01", end: "2027-01", basis: "甲:木星大运 2026-01 至 2027-01。乙:金星大运 2026-01 至 2027-01。" },
|
||||
{ id: "s2", start: "2027-01", end: "2028-01", basis: "甲:木星大运 2027-01 至 2028-01。乙:金星大运 2027-01 至 2028-01。" },
|
||||
{ id: "s3", start: "2028-01", end: "2029-01", basis: "甲:木星大运 2028-01 至 2029-01。乙:金星大运 2028-01 至 2029-01。" },
|
||||
],
|
||||
signal: new AbortController().signal,
|
||||
timeoutMs: 1000,
|
||||
write: async ({ sectionId }) => {
|
||||
calls.push(sectionId);
|
||||
if (sectionId === "timeline") {
|
||||
return { body: { segments: [{ id: "s1", phase: "靠近", text: "这段时间先把日常节奏说清楚。" }, { id: "s2", phase: "磨合", text: "这段时间把分歧说完再放下。" }, { id: "s3", phase: "协作", text: "这段时间一起把安排写下来。" }] }, inputTokens: 1, outputTokens: 1 };
|
||||
}
|
||||
if (sectionId === "practices") {
|
||||
return { body: { items: [{ text: "每周留一次不被打断的谈话。", evidence: "证据甲" }] }, inputTokens: 1, outputTokens: 1 };
|
||||
}
|
||||
const titled = sectionId === "communication" && calls.filter((id) => id === "communication").length === 1;
|
||||
return { body: titled ? { ...clean, text: `沟通与理解 沟通与理解 ${clean.text}` } : clean, inputTokens: 1, outputTokens: 1 };
|
||||
},
|
||||
gate: async () => {
|
||||
checks += 1;
|
||||
return { ok: true, failures: [] };
|
||||
},
|
||||
});
|
||||
assert.equal(checks, 2);
|
||||
assert.equal(calls.filter((id) => id === "communication").length, 2);
|
||||
const saved = result.snapshotParts.sections.find((section) => section.id === "communication");
|
||||
assert.equal(saved?.text, clean.text);
|
||||
assert.equal(saved?.text.startsWith("沟通与理解"), false);
|
||||
assert.equal(stripLeadingSectionTitle("沟通与理解 沟通与理解 沟通的天气很顺。", "沟通与理解"), "沟通的天气很顺。");
|
||||
assert.equal(stripLeadingSectionTitle("Communication and Understanding The two of you talk.", "Communication and Understanding"), "The two of you talk.");
|
||||
assert.equal(stripLeadingSectionTitle("沟通与理解力还在。", "沟通与理解"), "沟通与理解力还在。");
|
||||
});
|
||||
|
||||
test("technical appendix has six program blocks and the fixed closing line", () => {
|
||||
const blocks = buildTechnicalBasis({
|
||||
dossier: { execution_receipt: { pair_execution_tasks: [{ task: "Kuja Dosha", status: "not_applicable" }] } },
|
||||
@@ -131,6 +172,22 @@ test("the synastry page is a static shell and reads the flag in the browser", ()
|
||||
assert.match(picker, /开始合盘/);
|
||||
assert.match(picker, /+ 新增星盘/);
|
||||
assert.match(picker, /生成一份完整合盘报告,用掉/);
|
||||
const again = renderToStaticMarkup(createElement(SynastryGoNotes, {
|
||||
creditCost: 19,
|
||||
pricingNote: "",
|
||||
readyCount: 2,
|
||||
generating: false,
|
||||
}));
|
||||
assert.match(again, /这两人已经合过 2 次,再生成会另扣 19 点/);
|
||||
assert.match(again, /生成一份完整合盘报告,用掉 19 点/);
|
||||
const quiet = renderToStaticMarkup(createElement(SynastryGoNotes, {
|
||||
creditCost: 19,
|
||||
pricingNote: "",
|
||||
readyCount: 2,
|
||||
generating: true,
|
||||
}));
|
||||
assert.doesNotMatch(quiet, /再生成会另扣/);
|
||||
assert.match(picker, /pair: "1"/);
|
||||
assert.doesNotMatch(picker, /男方|女方|合不合|会不会在一起/);
|
||||
const dialog = readFileSync(new URL("../src/components/synastry/synastry-add-dialog.tsx", import.meta.url), "utf8");
|
||||
const wait = readFileSync(new URL("../src/components/synastry/synastry-wait.tsx", import.meta.url), "utf8");
|
||||
@@ -555,3 +612,22 @@ test("a public pair dossier fills every section without birth keys", { timeout:
|
||||
}
|
||||
rmSync(output, { force: true });
|
||||
});
|
||||
|
||||
test("the synastry report scrolls in a full-width outer container", () => {
|
||||
const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||||
const page = readFileSync(new URL("../src/components/personal-report/personal-report-page.tsx", import.meta.url), "utf8");
|
||||
const scroll = css.slice(css.indexOf(".synastry-report-scroll {"), css.indexOf(".synastry-report {"));
|
||||
const column = css.slice(css.indexOf(".synastry-report {"), css.indexOf(".synastry-report-back"));
|
||||
assert.match(scroll, /width:\s*100%/);
|
||||
assert.match(scroll, /height:\s*100%/);
|
||||
assert.match(scroll, /overflow-y:\s*auto/);
|
||||
assert.match(scroll, /overflow-x:\s*hidden/);
|
||||
assert.match(column, /width:\s*min\(720px, 100%\)/);
|
||||
assert.match(column, /margin-inline:\s*auto/);
|
||||
assert.doesNotMatch(column, /height:\s*100%/);
|
||||
assert.doesNotMatch(column, /overflow:\s*auto/);
|
||||
assert.match(page, /<div className="synastry-report-scroll">\s*<SynastryReportView /);
|
||||
const printAt = css.indexOf(".synastry-report-scroll { width: auto !important;");
|
||||
assert.ok(printAt > css.indexOf("@media print"));
|
||||
assert.match(css.slice(printAt, printAt + 180), /overflow: visible !important/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { PathnameContext } from "next/dist/shared/lib/hooks-client-context.shared-runtime";
|
||||
|
||||
import { AppSidebar } from "../src/components/app-sidebar.tsx";
|
||||
import { SidebarProvider } from "../src/components/ui/sidebar.tsx";
|
||||
import { reportSurfaceRedirect } from "../src/lib/synastry-report-href.ts";
|
||||
|
||||
function renderSidebar(pathname: string) {
|
||||
return renderToStaticMarkup(
|
||||
createElement(
|
||||
PathnameContext.Provider,
|
||||
{ value: pathname },
|
||||
createElement(
|
||||
SidebarProvider,
|
||||
null,
|
||||
createElement(AppSidebar, {
|
||||
sessions: [],
|
||||
activeSessionId: null,
|
||||
compatibilityEnabled: true,
|
||||
account: {
|
||||
name: "示例甲",
|
||||
email: "fictional@example.invalid",
|
||||
credits: 12,
|
||||
initial: "示",
|
||||
avatar: null,
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function navAnchor(markup: string, label: string) {
|
||||
const anchors = markup.match(/<a\b[^>]*\breport-nav-button\b[^>]*>[\s\S]*?<\/a>/g) ?? [];
|
||||
const hit = anchors.find((anchor) => anchor.includes(`>${label}<`));
|
||||
assert.ok(hit, `sidebar nav link missing: ${label}`);
|
||||
return hit;
|
||||
}
|
||||
|
||||
const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8");
|
||||
|
||||
test("an old synastry url moves to the synastry route and keeps the language", () => {
|
||||
assert.equal(
|
||||
reportSurfaceRedirect({ surface: "personal", reportType: "synastry_full", reportId: "abc", language: "en" }),
|
||||
"/synastry/abc?lang=en",
|
||||
);
|
||||
assert.equal(
|
||||
reportSurfaceRedirect({ surface: "synastry", reportType: "personal_full", reportId: "abc", language: "zh" }),
|
||||
"/reports/abc",
|
||||
);
|
||||
assert.equal(
|
||||
reportSurfaceRedirect({ surface: "synastry", reportType: "synastry_full", reportId: "abc", language: "zh" }),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
reportSurfaceRedirect({ surface: "personal", reportType: "personal_full", reportId: "abc", language: "en" }),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("the synastry route reuses the reader and the sidebar stays on 婚恋合盘", () => {
|
||||
const page = read("../src/app/(app)/synastry/[reportId]/page.tsx");
|
||||
const reader = read("../src/components/personal-report/personal-report-page.tsx");
|
||||
const sidebar = read("../src/components/app-sidebar.tsx");
|
||||
const picker = read("../src/components/synastry/synastry-picker.tsx");
|
||||
const center = read("../src/components/personal-report/personal-report-center.tsx");
|
||||
assert.match(page, /title: "婚恋合盘 · Jyotisha"/);
|
||||
assert.match(page, /readReportLanguage\(lang\)/);
|
||||
assert.match(page, /surface="synastry"/);
|
||||
assert.match(reader, /window\.location\.replace\(redirectTo\)/);
|
||||
assert.match(sidebar, /\{ href: "\/synastry", label: "婚恋合盘"/);
|
||||
assert.match(sidebar, /pathname === href \|\| pathname\.startsWith\(`\$\{href\}\/`\)/);
|
||||
// 原值: assert.equal("/synastry/abc".startsWith("/synastry/"), true)
|
||||
// 新值: pathname 为 /synastry/<id> 时渲染侧栏,当前项(data-active="true")是「婚恋合盘」,不是「我的报告」
|
||||
// 原因: 原断言永远成立,没有渲染侧栏(issue #85 G3)
|
||||
const markup = renderSidebar("/synastry/abc");
|
||||
const synastry = navAnchor(markup, "婚恋合盘");
|
||||
const reports = navAnchor(markup, "我的报告");
|
||||
assert.match(synastry, /\bdata-active="true"/);
|
||||
assert.doesNotMatch(reports, /\bdata-active="true"/);
|
||||
assert.doesNotMatch(reports, /aria-current="page"/);
|
||||
assert.match(picker, /window\.location\.assign\(`\/synastry\/\$\{payload\.report\.id\}`\)/);
|
||||
assert.match(picker, /href=\{`\/synastry\/\$\{report\.id\}`\}/);
|
||||
assert.doesNotMatch(picker, /\/reports\/\$\{/);
|
||||
assert.match(center, /reportType === "synastry_full" \? `\/synastry\/\$\{id\}` : `\/reports\/\$\{id\}`/);
|
||||
});
|
||||
@@ -225,6 +225,15 @@ def _scan_prose(text: str, language: str, section: str, *, opening: bool) -> lis
|
||||
return failures
|
||||
|
||||
|
||||
def _opens_with_title(text: str, title: str) -> bool:
|
||||
heading = title.strip()
|
||||
body = text.lstrip()
|
||||
if not heading or not body.startswith(heading):
|
||||
return False
|
||||
rest = body[len(heading):]
|
||||
return rest == "" or rest[:1].isspace() or rest.startswith(heading)
|
||||
|
||||
|
||||
def validate_synastry_reader(
|
||||
delivery: dict[str, Any],
|
||||
section_inputs: dict[str, Any] | None = None,
|
||||
@@ -254,6 +263,9 @@ def validate_synastry_reader(
|
||||
failures.append(_fail(section_id, "missing_section", section_id))
|
||||
continue
|
||||
text = str(row.get("text") or "")
|
||||
title = str(row.get("title") or "")
|
||||
if _opens_with_title(text, title):
|
||||
failures.append(_fail(section_id, "repeated_heading", title))
|
||||
prose_characters += len(re.sub(r"\s+", "", text))
|
||||
prose_words += len(re.findall(r"\b[\w']+\b", text))
|
||||
minimum = ZH_SECTION_MINIMUM if language == "zh" else EN_SECTION_MINIMUM
|
||||
|
||||
@@ -1643,6 +1643,10 @@ def test_capability_audit_scans_registry_and_local_sources() -> None:
|
||||
# 新值: 中间加入 synastry
|
||||
# 原因: 婚恋合盘页 app/(app)/synastry 进入页面扫描(issue 82,2026-10-19)
|
||||
'synastry',
|
||||
# 原值: synastry 后面直接是 terms
|
||||
# 新值: 加入 synastry/[reportId]
|
||||
# 原因: issue #85 T7 合盘报告独立地址
|
||||
'synastry/[reportId]',
|
||||
'terms',
|
||||
]
|
||||
# 原值: 期望集合含 'Case Validation'
|
||||
|
||||
@@ -180,6 +180,16 @@ def test_english_kuja_fails():
|
||||
assert "banned_term" in _one(delivery, inputs, "en")
|
||||
|
||||
|
||||
def test_a_body_that_opens_with_the_section_title_fails():
|
||||
delivery, inputs = _passing("zh")
|
||||
delivery["sections"][2]["text"] = "沟通与理解 沟通与理解 " + delivery["sections"][2]["text"]
|
||||
assert "repeated_heading" in _one(delivery, inputs)
|
||||
delivery, inputs = _passing("en")
|
||||
delivery["sections"][2]["title"] = "Communication and Understanding"
|
||||
delivery["sections"][2]["text"] = "Communication and Understanding " + delivery["sections"][2]["text"]
|
||||
assert "repeated_heading" in _one(delivery, inputs, "en")
|
||||
|
||||
|
||||
def test_english_banned_word_fails():
|
||||
delivery, inputs = _passing("en")
|
||||
delivery["sections"][1]["text"] += " Venus "
|
||||
|
||||
Reference in New Issue
Block a user