Compare commits

..
Author SHA1 Message Date
jesse-uxandGrok (agent loop) fc2283693d fix(chart): span natal and moon planet tables across the row
VedicLayout now owns the third grid item, so a wrapper around the table cannot drop the natal or Moon table into the left column. Desktop grid-column targets that row. BUG-1421.

Self-test: tsc exit 0, lint 0 errors, 49 targeted tests, next build with / static.

Co-Authored-By: Grok (agent loop)
2026-10-23 01:22:46 +08:00
22 changed files with 219 additions and 354 deletions
+2 -4
View File
@@ -1,10 +1,8 @@
# 印度占星 Skill 更新日志 # 印度占星 Skill 更新日志
## 2026-10-23 — 明说要校正时,已采用过的人能直接新开一次 ## 2026-10-23 — 星盘页本命和月亮的行星表重新占满整行
- 已经采用过出生时间的人,在对话里说要做生时纠正,会直接开始一次新的核对,不再只回「可在历史对话里接着做。」 - 宽屏下,本命和月亮的行星表回到参数卡旁边那一整行,宿主、pada、状态不用横拖就能看见。分盘、Bhava、行运仍是整行。
- 真没做完的校正仍然接着做。这次如果接不回原来那次,也直接新开。
- 已采用过的账户,普通对话里的校正提议卡会重新出现。
- Skill 版本不变(BUG-1421)。 - Skill 版本不变(BUG-1421)。
## 2026-10-22 — 星盘大运右栏可以切到 Yogini ## 2026-10-22 — 星盘大运右栏可以切到 Yogini
+11 -11
View File
@@ -19254,18 +19254,18 @@
- 复发自:无 - 复发自:无
- 修复版本:本提交 - 修复版本:本提交
## BUG-1421 | 明说要做生时纠正只回一句「可在历史对话里接着做」 ## BUG-1421 | 星盘页本命和月亮的行星表被挤进左栏
- 状态:resolved - 状态:resolved
- 首次发现:2026-10-22 - 首次发现:2026-10-22
- 最近更新:2026-10-23 - 最近更新:2026-10-23
- 影响面:对话里明确要求做生时校正的入口,以及普通对话里的校正提议卡 - 影响面:星盘页桌面宽度下的本命表和月亮盘表
- 用户现象:已经采用过出生时间的人,在对话里说要做生时纠正,只收到一句「可在历史对话里接着做。」没有卡片,也进不了校正。真正没做完的校正同样接不回去。 - 用户现象:宽屏下选「本命」或「月亮」时,行星表只占左栏。宿主、pada、状态三列要横拖才看得到,右栏在参数卡下面空着。分盘、Bhava、行运的表正常。
- 触发条件:本人档案;出生时间显示已采用;在对话里明确要求校正。没做完的校正也会走到同一句,因为查找会话失败被吞掉。 - 触发条件:桌面宽度(≥1024px)打开星盘页的本命或月亮。来自 #87 给这两张表包了一层符号说明。
- 根因:三条。一,找会话时直接读一张服务角色没有权限的表,失败被静默吞掉,所以真正没做完的也续不上。二,把已经采用(实际上的终点)算作还在进行,明说入口和提议卡一起被挡住。三,失败时只给一句话,没有能点的出口。 - 根因:跨栏按类名而非结构。桌面规则只认表格外层的两个类名。本命和月亮的直接子项变成了符号说明那一层,规则匹配不上,表就排进左栏。
- 修复:入口只把还没做完的五种状态当作进行中,已采用不再挡住,明说要校正就新开一次,提议卡也按这个口径恢复。查找会话改走已有的校正读取接口。读不到就记一条不含个人资料的日志,并直接新开。删掉「可在历史对话里接着做。」共享的状态集合和数据库没有改。 - 修复:网格自己用固定的整行容器装表格,跨栏只认这个容器。表格内容、列数、列宽不变。
- 验证:入口口径单测;接不回时改为新开并有一条日志;`database-rectification-resume-session-20261023.test.ts` 用服务角色经接口拿到进行中校正的会话,直接读表仍然被拒绝。 - 验证:`frontend/tests/chart-table-span-20261023.test.tsx`。五种盘型的网格都是三个直接子项,第三个是整行容器;桌面样式只让这个容器跨栏。
- 防复发:上面的口径单测和这条真库测试。再改回直接读表,或把已采用重新算作进行中,测试会失败。 - 防复发:上面两条断言。再往表外包一层,结构断言会失败;跨栏不再跟着表格类名走。
- 相关记录:BUG-1306、BUG-1248、BUG-177。issue #103。 - 相关记录:BUG-1083(窄屏仍只能在表自己的盒子里横滚)。issue #102。回归来源 #87。
- 复发自:BUG-1306 - 复发自:无
- 修复版本:`codex/rectification-explicit-open-20261023` - 修复版本:`codex/chart-table-span-20261023`
@@ -0,0 +1,30 @@
# PROGRESS · 星盘页行星表整行(2026-10-23)
Issue #102。BUG-1421。
## 基线
- 开工时 `origin/staging` = `d72adb5754eab4d66ac3a47c4edcb12704e29815`(任务书写的 `9049b5e99` 已过期)。
- 本文件最大编号是 BUG-1420,本单用 BUG-1421。
## 做了什么
- 星盘页网格的第三格改成固定的整行容器。本命、月亮、分盘、Bhava、行运的表都放在里面。
- 桌面跨栏只认这个容器。表格自己的类名不再负责跨栏。
- 容器可以缩到比内容窄,窄屏时表仍在自己的框里横滑。
- 表格的字、列、列宽没改。符号说明的气泡还在原来的位置。
## 自测
| 检查 | 命令 | 结果 |
| --- | --- | --- |
| 类型 | `frontend\node_modules\.bin\tsc --noEmit -p frontend` | 退出码 0 |
| lint | `frontend` 下 `npm run lint` | 退出码 0。0 error,122 条既有 warning,本轮不改 |
| 定向测试 | `node node_modules/tsx/dist/cli.mjs --test` 跑新结构测试,加上 `chart-page-view.test.tsx`、`chart-varga-table.test.tsx`、`chart-page-mobile-width-20260929.test.ts` | 49 条通过,0 失败 |
| 构建 | `frontend` 下 `npm run build` | 退出码 0。路由表 `/` 为 `○ Static`。预渲染 `index.html` 引用 33 个 JS、3 个 CSS,gzip-9 合计 646,234 字节(JS 575,154,CSS 71,080)。没有用改动前的代码再构建一次,所以不写增减百分比 |
未跑前端全量 `npm test`(验收时跑)。没有登录,本机浏览器截图没做,留给 `docs/testing/chart-table-span-20261023.md`。
## 偏离
- 无。编号说明:本分支的 BUG-1421 与尚未合并的 #95 分支各自占用了同一个号。若 #95 先进入 staging,验收时需要给本条改号。
@@ -1,40 +0,0 @@
# PROGRESS · 明说要校正能打开(2026-10-23)
Issue #103。BUG-1421。
## 基线
- 开工时 `origin/staging` = `d72adb5754eab4d66ac3a47c4edcb12704e29815`(任务书写的 `dd5ae8d03` 已过期)。
- 本文件最大编号是 BUG-1420,本单用 BUG-1421。
## 做了什么
- 对话入口不再把「已采用」当成还在进行。明说要校正就新开一次,普通提议卡也按这个口径恢复。
- 真没做完的五种状态仍接着做。找会话改走已有的校正读取接口,不再直接读那张表。
- 读不到会话时记一条日志(只有请求号、错误类别、有没有 caseId),然后新开,不再说去历史对话接着做。
- 删掉过期的「每人最多一条进行中校正」常量。共享状态集合和数据库没改。
## 已知边界
摘要只看最近一次。如果最近一次已经采用、更早还有一次没做完,这次按没有进行中处理,直接新开。更早那次仍留在历史对话里。数据库允许同一个人有多条校正。
## 自测
| 检查 | 命令 | 结果 |
| --- | --- | --- |
| 类型 | `frontend\node_modules\.bin\tsc --noEmit -p frontend` | 退出码 0 |
| lint | `frontend` 下 `npm run lint` | 退出码 0。0 error,122 条既有 warning,本轮不改 |
| 定向测试 | 任务书点名的三个测试文件 | 47 条通过,0 失败 |
| 真库 | `database-rectification-resume-session-20261023.test.ts` | 1 条通过。服务角色经接口拿到进行中校正的会话;直接读表被拒绝 |
| 全量真库 | `frontend` 下 `npm run test:db` | 91 条里通过 78、失败 13。失败名单与 `tools/agent-loop/windows-known-failures.txt` 逐条相同,没有多出来的 |
| 构建 | `frontend` 下 `npm run build` | 退出码 0。路由表 `/` 为 `○ Static`。预渲染 `index.html` 引用 33 个 JS、3 个 CSS,gzip-9 合计 646,232 字节(JS 575,154,CSS 71,078)。没有用改动前的代码再构建一次,所以不写增减百分比 |
未跑前端全量 `npm test`(验收时跑)。真实模型会不会调用工具,留给 `docs/testing/rectification-explicit-open-20261023.md`。
## 让步
- T3 的自动化测试改写成真机清单第 4 步。入口和真库测试没有让。
## 偏离
- 无。编号说明:本分支的 BUG-1421 与尚未合并的 #95、#102 分支各自占用了同一个号。若那些先进入 staging,验收时给本条改号。
+2 -2
View File
@@ -37,8 +37,8 @@
| 任务书 | 进度 | 主题 | 状态 | 落点 | | 任务书 | 进度 | 主题 | 状态 | 落点 |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| `TASK-rectification-explicit-open-20261023.md` | `PROGRESS-rectification-explicit-open-20261023.md` | **明说要校正只回一句话(issue #103)**:找会话直接读一张对服务角色撤权的表、必然失败被吞(真正进行中的也续不上);`candidate_accepted` 被算作进行中挡住入口和提议卡;兜底只给一句话。产品选 C;只改入口口径、经 RPC 找会话、删兜底,不改共享状态集合、不动表。BUG-1421 | 待验收 | 分支 `codex/rectification-explicit-open-20261023` | | `TASK-rectification-explicit-open-20261023.md` | — | **明说要校正只回一句话(issue #103)**:找会话直接读一张对服务角色撤权的表、必然失败被吞(真正进行中的也续不上);`candidate_accepted` 被算作进行中挡住入口和提议卡;兜底只给一句话。产品选 C;只改入口口径、经 RPC 找会话、删兜底,不改共享状态集合、不动表 | 待执行(循环) | 分支 `codex/rectification-explicit-open-20261023` |
| `TASK-chart-table-span-20261023.md` | — | **星盘页本命 / 月亮行星表挤进左栏(issue #102)**:#87 给表包了 `ChartSymbolHelp` 后跨栏规则失配;根因修法是 `VedicLayout` 用固定整行容器承载表格,跨栏只认容器;加结构断言防复发 | 待执行(循环) | 分支 `codex/chart-table-span-20261023` | | `TASK-chart-table-span-20261023.md` | `PROGRESS-chart-table-span-20261023.md` | **星盘页本命 / 月亮行星表挤进左栏(issue #102)**:#87 给表包了 `ChartSymbolHelp` 后跨栏规则失配;根因修法是 `VedicLayout` 用固定整行容器承载表格,跨栏只认容器;加结构断言防复发。BUG-1421 | 待验收 | 分支 `codex/chart-table-span-20261023` |
| `TASK-ui-kit-migration-20261023.md` | — | **界面统一换成组件库**:shadcn base-nova(补 `shadcn/tailwind.css`)· Lucide · Motion · react-resizable-panels · dnd-kit · Zustand;lint 拦手写控件(白名单只减不增);对话旁星盘面板;会话、档案拖动排序(动表);长相不变。产品 2026-10-23 拍板 D1~D8;8 个 issue(K0~K7)串行,取代全站动效 #98 #100 #101 | 待领取(循环) | 原型 https://claude.ai/artifact/PGnWsjHhu38KJseEfEMNWG | | `TASK-ui-kit-migration-20261023.md` | — | **界面统一换成组件库**:shadcn base-nova(补 `shadcn/tailwind.css`)· Lucide · Motion · react-resizable-panels · dnd-kit · Zustand;lint 拦手写控件(白名单只减不增);对话旁星盘面板;会话、档案拖动排序(动表);长相不变。产品 2026-10-23 拍板 D1~D8;8 个 issue(K0~K7)串行,取代全站动效 #98 #100 #101 | 待领取(循环) | 原型 https://claude.ai/artifact/PGnWsjHhu38KJseEfEMNWG |
| `TASK-onboarding-journey-20261022.md` | — | **新用户启程**:建档加「先看谁的盘」(自己 / 帮别人看,本人资料可后补)、人物关系字段、首问免费(服务端原子)、起步三问、启程清单(七项从数据推导,收费项标价,老用户可见)、八条一次性提示(含星盘四页签)、启程流程专用动效。产品 2026-10-22 拍板 D1~D6;动表经产品同意进循环,拆四个 issue 串行。BUG-1420 起 | 待领取(循环) | 原型 https://claude.ai/artifact/PcTwMVb9RD611R3qStxv3U | | `TASK-onboarding-journey-20261022.md` | — | **新用户启程**:建档加「先看谁的盘」(自己 / 帮别人看,本人资料可后补)、人物关系字段、首问免费(服务端原子)、起步三问、启程清单(七项从数据推导,收费项标价,老用户可见)、八条一次性提示(含星盘四页签)、启程流程专用动效。产品 2026-10-22 拍板 D1~D6;动表经产品同意进循环,拆四个 issue 串行。BUG-1420 起 | 待领取(循环) | 原型 https://claude.ai/artifact/PcTwMVb9RD611R3qStxv3U |
| `TASK-site-motion-20261022.md` | — | **全站动效**:换页按侧栏方向滑入、选中块跟随、页签、盘型切换星体移宫、换人、翻日期、列表补位、弹窗从触发处长出、抽屉、顶部提示、点数滚动、中英交叉淡入;重写 DESIGN §6(推翻 180 ms 上限与 Spatial Instant),不引动画库,先验证 View Transitions。产品 2026-10-22 拍板;三个 issue,排在启程(三)之后、启程(四)之前;**2026-10-23 被 ui-kit-migration 取代** | 已取代 | 原型 https://claude.ai/artifact/7u9goivnmTNSGTNWNeVMQd | | `TASK-site-motion-20261022.md` | — | **全站动效**:换页按侧栏方向滑入、选中块跟随、页签、盘型切换星体移宫、换人、翻日期、列表补位、弹窗从触发处长出、抽屉、顶部提示、点数滚动、中英交叉淡入;重写 DESIGN §6(推翻 180 ms 上限与 Spatial Instant),不引动画库,先验证 View Transitions。产品 2026-10-22 拍板;三个 issue,排在启程(三)之后、启程(四)之前;**2026-10-23 被 ui-kit-migration 取代** | 已取代 | 原型 https://claude.ai/artifact/7u9goivnmTNSGTNWNeVMQd |
@@ -0,0 +1,6 @@
# 星盘页行星表整行(2026-10-23)
登录后打开星盘页。不要用真实出生资料做记录,用站内已有的示例盘即可。
1. 把窗口拉到大约 1280 宽。依次看「本命」「月亮」「D9」「Bhava」「行运」。每张表都占满盘和参数卡下面的整行,宿主、pada、状态不用横拖就能看见。
2. 把窗口收到大约 390 宽(或手机)。页面本身不左右滚动。表如果放不下,只在表自己的框里横滑。
@@ -1,8 +0,0 @@
# 真机清单 · 明说要校正能打开(2026-10-23)
用测试账号。不要把真实出生时间写进记录。
1. 已经采用过出生时间的账户,在对话里说「帮我做生时纠正」。应直接进入一次新的校正,不再只出现「可在历史对话里接着做。」
2. 有一次答到一半、还没采用的校正。从对话里说要校正,应接回那一次。
3. 已采用过的账户,普通对话问到时机一类的问题时,校正提议卡会出现。
4. 已采用的旧校正还在,再开一次新的并采用另一个时间。打开旧的那次对话:采用后的核对题只读,不弹错误,控制台没有未处理的报错。
+1 -1
View File
@@ -1136,7 +1136,7 @@ D40 / D45 / D60 的表格上方,出生时间不是「已校正」时多一句
**不再按断点复制参数。** 原本同一组参数有三种画法:中宫叠一张 `.chart-page-center-card`(手机两行、桌面六行),盘下方再来一张 `.chart-page-params-below` 重复那六行,由 media query 三选一。同样的事实画三遍、靠断点开关,改一处要改三处。键值表让每个字段只出现一次,`.chart-page-center-card` / `-compact` / `-extra` / `.chart-page-params-below` / `.chart-page-boundary-desktop` 五个类连同它们的 767/768 开关块一并删除,由 `chart-page-view.test.tsx` 守着不许回来。 **不再按断点复制参数。** 原本同一组参数有三种画法:中宫叠一张 `.chart-page-center-card`(手机两行、桌面六行),盘下方再来一张 `.chart-page-params-below` 重复那六行,由 media query 三选一。同样的事实画三遍、靠断点开关,改一处要改三处。键值表让每个字段只出现一次,`.chart-page-center-card` / `-compact` / `-extra` / `.chart-page-params-below` / `.chart-page-boundary-desktop` 五个类连同它们的 767/768 开关块一并删除,由 `chart-page-view.test.tsx` 守着不许回来。
桌面(≥1024)盘面与参数表左右并排,行星表是跨两列的整行——它的列宽需要完整版心。2026-09-29 起桌面印度盘宽 440px;当晚(BUG-1100,产品「框太丑」)上限从绘图区移到外框 `.chart-page-vedic-stage`(440px + 两侧 `--space-3` 内边距 + 边框),外框贴合星图,不再是一张宽卡片中间放一张窄盘;手机端外框不设上限;西洋桌面绘图区上限 480px。低于此断点的结果盘不收紧,参数区和表格不缩。等待盘与结果盘复用同一桌面列宽与绘图容器,不再使用独立 400px 等待盘上限;印度等待绘图容器以 400/452 比例预留结果图例带,内部空 SVG 仍为 400×400,避免揭幕时外框和说明区纵向跳动,不改共享绘图。只有盘位动(2026-09-30 起为宫位依次点亮,不再整张呼吸),失败后退出,参数表不新增骨架。 桌面(≥1024)盘面与参数表左右并排,行星表是跨两列的整行——它的列宽需要完整版心。表格区由 `VedicLayout` 的整行容器承载,不靠表格自身类名跨栏。2026-09-29 起桌面印度盘宽 440px;当晚(BUG-1100,产品「框太丑」)上限从绘图区移到外框 `.chart-page-vedic-stage`(440px + 两侧 `--space-3` 内边距 + 边框),外框贴合星图,不再是一张宽卡片中间放一张窄盘;手机端外框不设上限;西洋桌面绘图区上限 480px。低于此断点的结果盘不收紧,参数区和表格不缩。等待盘与结果盘复用同一桌面列宽与绘图容器,不再使用独立 400px 等待盘上限;印度等待绘图容器以 400/452 比例预留结果图例带,内部空 SVG 仍为 400×400,避免揭幕时外框和说明区纵向跳动,不改共享绘图。只有盘位动(2026-09-30 起为宫位依次点亮,不再整张呼吸),失败后退出,参数表不新增骨架。
**符号说明(2026-09-29;当晚改为气泡,BUG-1101)。** `/chart` 开启共享 SVG 的可选交互模式:说明是标记旁的小气泡(`.chart-symbol-bubble`,最宽 220px,`--color-canvas` 底 + 细边 + `--shadow-elevated`),默认在标记上方 8px,离外框顶不足 48px 时翻到下方,左右被夹在外框内;气泡不接收指针,不会挡住旁边的标记。电脑:鼠标悬停出现、移开消失,点击与键盘聚焦同样出现。手机:轻点标记出现,触摸产生的离开事件不关它,点盘外任意处或 3 秒后自动消失。盘下不再有「停在或轻点……」提示行和说明行;读屏器仍从一个隐藏的 `role="status"` 读到同一句话;Esc、离开、失焦可关闭,切盘/人物/数据身份变化清理旧说明。行星与其逆行短线共用一个目标;西洋 caption 和命中圆用一致的行星解析,原点击过滤相位及再次点击取消保留。报告、校正、静态导出默认不启用该模式;纯绘图层不持有 hooks,静态 SVG 白名单不扩。 **符号说明(2026-09-29;当晚改为气泡,BUG-1101)。** `/chart` 开启共享 SVG 的可选交互模式:说明是标记旁的小气泡(`.chart-symbol-bubble`,最宽 220px,`--color-canvas` 底 + 细边 + `--shadow-elevated`),默认在标记上方 8px,离外框顶不足 48px 时翻到下方,左右被夹在外框内;气泡不接收指针,不会挡住旁边的标记。电脑:鼠标悬停出现、移开消失,点击与键盘聚焦同样出现。手机:轻点标记出现,触摸产生的离开事件不关它,点盘外任意处或 3 秒后自动消失。盘下不再有「停在或轻点……」提示行和说明行;读屏器仍从一个隐藏的 `role="status"` 读到同一句话;Esc、离开、失焦可关闭,切盘/人物/数据身份变化清理旧说明。行星与其逆行短线共用一个目标;西洋 caption 和命中圆用一致的行星解析,原点击过滤相位及再次点击取消保留。报告、校正、静态导出默认不启用该模式;纯绘图层不持有 hooks,静态 SVG 白名单不扩。
+2 -2
View File
@@ -39,8 +39,8 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
## 生时校正从对话进入(2026-10-07,BUG-1247~1251;2026-10-12,BUG-1306) ## 生时校正从对话进入(2026-10-07,BUG-1247~1251;2026-10-12,BUG-1306)
- **何时提议**:校正就在这个对话里能开。用户说推得不准时先分清读法问题和时间问题。只是不认同某个解释,按改读规则认下、改读,不提校正。不承诺能收窄出生时间,不在对话里收集经历做校正。说出过去的具体事情和盘对不上、说出生时间是估的或记不准、但没有明确要做校正,要先看过盘再提议。同一次对话提议过、用户没接,不再主动提;用户自己再要求时传 `userAsked=true`。还没看盘就顺口提议,不算已经提议过。规则只定义在 `consultation-thinking-plan.ts` 的 `RECTIFICATION_OFFER_RULE`。不用关键词判断。 - **何时提议**:校正就在这个对话里能开。用户说推得不准时先分清读法问题和时间问题。只是不认同某个解释,按改读规则认下、改读,不提校正。不承诺能收窄出生时间,不在对话里收集经历做校正。说出过去的具体事情和盘对不上、说出生时间是估的或记不准、但没有明确要做校正,要先看过盘再提议。同一次对话提议过、用户没接,不再主动提;用户自己再要求时传 `userAsked=true`。还没看盘就顺口提议,不算已经提议过。规则只定义在 `consultation-thinking-plan.ts` 的 `RECTIFICATION_OFFER_RULE`。不用关键词判断。
- **明确要求则直接进入**:用户这一轮明确要做校正(校正、纠正、核对出生时间都算),由模型判断,不用关键词。第一步就可以打开,不用先排盘写解读。服务端放行后直接打开校正,不用点卡。过渡只有一句「好,我们开始核对出生时间。」已经采用过时间的,不算还在进行,明说要校正就新开一次。真没做完的,直接接着做那一次。这次如果接不回原来的会话,也直接新开。开案还没成功时,回答下面仍是普通提议卡,不说「已经开始」;打开失败可以再点「开始核对出生时间」。开案并绑定来源之后才改成「已经开始」。 - **明确要求则直接进入**:用户这一轮明确要做校正(校正、纠正、核对出生时间都算),由模型判断,不用关键词。第一步就可以打开,不用先排盘写解读。服务端放行后直接打开校正,不用点卡。过渡只有一句「好,我们开始核对出生时间。」已经有一次没做完的,直接接着做那一次,不新开。开案还没成功时,回答下面仍是普通提议卡,不说「已经开始」;打开失败可以再点「开始核对出生时间」。开案并绑定来源之后才改成「已经开始」。
- **做不了必须说一句**:开关关着,或暂时没有价格,都说「出生时间核对这会儿还做不了。」不写内部原因,不写价格,不得沉默。家人档案说「给家人校正暂时还不支持」,不开案。不再说「可在历史对话里接着做。」 - **做不了必须说一句**:开关关着,或暂时没有价格,都说「出生时间核对这会儿还做不了。」不写内部原因,不写价格,不得沉默。家人档案说「给家人校正暂时还不支持」,不开案。有进行中的校正但这次打不开原来的会话,说「可在历史对话里接着做。」
- **正文怎么提**(没有明确要求、只是时间可能不准):一句,放在最后一段(例:「如果想确认出生时间准不准,可以用下面的卡片,答几道过去经历的题核对一下。」),不写价格和题数——卡片上写。 - **正文怎么提**(没有明确要求、只是时间可能不准):一句,放在最后一段(例:「如果想确认出生时间准不准,可以用下面的卡片,答几道过去经历的题核对一下。」),不写价格和题数——卡片上写。
- **卡片**:「可以用几道过去经历的题,核对一下你的出生时间。大约 N 题,会用掉 M 点;开始答第一题时才扣。」按钮「开始核对出生时间」;已开始(开案并绑定来源之后,不是结算当时)「出生时间核对已经开始。」/「继续核对出生时间」。点数来自 `feature_pricing`,不写死。 - **卡片**:「可以用几道过去经历的题,核对一下你的出生时间。大约 N 题,会用掉 M 点;开始答第一题时才扣。」按钮「开始核对出生时间」;已开始(开案并绑定来源之后,不是结算当时)「出生时间核对已经开始。」/「继续核对出生时间」。点数来自 `feature_pricing`,不写死。
- **回来**:分隔线「出生时间改为 HH:MM(生时校正)」/「出生时间没有改」;之前的回答上方「按校正前的时间」;按钮「按新时间重新看:「原问题」」。校正面按钮「回到刚才的对话」。不再说「新建对话」。 - **回来**:分隔线「出生时间改为 HH:MM(生时校正)」/「出生时间没有改」;之前的回答上方「按校正前的时间」;按钮「按新时间重新看:「原问题」」。校正面按钮「回到刚才的对话」。不再说「新建对话」。
+15 -2
View File
@@ -33,7 +33,7 @@ import { consultationDomainSchema } from "@/lib/consultation-domain-registry";
import { parseAgentReply } from "@/lib/agent-reply"; import { parseAgentReply } from "@/lib/agent-reply";
import { revisitQuestionForOffer, sessionHasRectificationOffer } from "@/lib/rectification-in-chat"; import { revisitQuestionForOffer, sessionHasRectificationOffer } from "@/lib/rectification-in-chat";
import { decideRectificationOffer, persistOpenedDirectOffer } from "@/lib/rectification-in-chat-server"; import { decideRectificationOffer, persistOpenedDirectOffer } from "@/lib/rectification-in-chat-server";
import { loadResumableRectificationSessionId } from "@/lib/rectification-resume-session"; import { getRectificationEntrySummary } from "@/lib/rectification-agentic/v9/case-service";
import { createConsultationReplyMetadata } from "@/lib/consultation-reply-metadata"; import { createConsultationReplyMetadata } from "@/lib/consultation-reply-metadata";
import { import {
ConsultationPlanValidationError, ConsultationPlanValidationError,
@@ -1025,7 +1025,20 @@ export async function POST(request: Request) {
alreadyOffered: sessionHasRectificationOffer(chatSession?.messages), alreadyOffered: sessionHasRectificationOffer(chatSession?.messages),
revisitQuestion: revisitQuestionForOffer(chatSession?.messages, visibleQuestion, requestId), revisitQuestion: revisitQuestionForOffer(chatSession?.messages, visibleQuestion, requestId),
sourceSessionId: sessionId, sourceSessionId: sessionId,
loadResumableSessionId: () => loadResumableRectificationSessionId(accounting, userId), loadResumableSessionId: async () => {
const summary = await getRectificationEntrySummary(accounting, userId);
const caseId = summary.latestResumable?.caseId;
if (!caseId) return null;
const { data, error } = await accounting
.from("agentic_rectification_cases")
.select("session_id")
.eq("user_id", userId)
.eq("id", caseId)
.maybeSingle();
if (error || !data) return null;
const resumableSessionId = (data as { session_id?: unknown }).session_id;
return typeof resumableSessionId === "string" && resumableSessionId ? resumableSessionId : null;
},
}; };
const hooks = createConsultationRuntimeHooks(state); const hooks = createConsultationRuntimeHooks(state);
const usages: Promise<Usage>[] = []; const usages: Promise<Usage>[] = [];
+6 -6
View File
@@ -5808,8 +5808,9 @@ button.synastry-dd-add { border-radius: 0 0 var(--radius-md) var(--radius-md); }
@media print { .chart-page-type-sheet { display: none !important; } } @media print { .chart-page-type-sheet { display: none !important; } }
.chart-page-precision-hint, .chart-page-precision-hint,
.chart-page-table-note { margin: 0 0 var(--space-2); } .chart-page-table-note { margin: 0 0 var(--space-2); }
/* A note + table pair is one grid item, so the table keeps its own scroll box. */ /* The table row is one grid item. A note and its table stay in one scroll box. */
.chart-page-varga-table-block { min-width: 0; } .chart-page-varga-table-block,
.chart-page-vedic-tables { min-width: 0; }
.chart-page-varga-table-block > .chart-page-planet-table-wrap + .chart-page-planet-table-wrap { margin-top: var(--space-4); } .chart-page-varga-table-block > .chart-page-planet-table-wrap + .chart-page-planet-table-wrap { margin-top: var(--space-4); }
/* A body the Bhava boundaries move to another house. */ /* A body the Bhava boundaries move to another house. */
.chart-page-planet-table tr.is-shifted > td, .chart-page-planet-table tr.is-shifted > td,
@@ -6341,10 +6342,9 @@ button.synastry-dd-add { border-radius: 0 0 var(--radius-md) var(--radius-md); }
card with empty sides around a 440px chart. 440 + the card's padding. */ card with empty sides around a 440px chart. 440 + the card's padding. */
.chart-page-vedic-stage { width: 100%; max-width: calc(440px + 2 * var(--space-3) + 2px); } .chart-page-vedic-stage { width: 100%; max-width: calc(440px + 2 * var(--space-3) + 2px); }
.chart-page-western-wheel { max-width: 480px; } .chart-page-western-wheel { max-width: 480px; }
/* Chart and parameters sit side by side; the planet table is a full-width /* Chart and parameters sit side by side. The third grid item is the table
row under both, because its columns need the whole measure. */ row, so a wrapper around a table cannot drop it back into the left column. */
.chart-page-planet-table-wrap, .chart-page-vedic-tables { grid-column: 1 / -1; }
.chart-page-varga-table-block { grid-column: 1 / -1; }
.chart-page-basics-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } .chart-page-basics-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
} }
@@ -350,7 +350,9 @@ function VedicLayout({ view, chart, ariaLabel, ascendantHouse, children }: {
chart can and cannot be read for. */} chart can and cannot be read for. */}
<p className="chart-page-boundary">{view.vedic.boundary}</p> <p className="chart-page-boundary">{view.vedic.boundary}</p>
</div> </div>
{children} <div className="chart-page-vedic-tables">
{children}
</div>
</div> </div>
); );
} }
@@ -61,6 +61,13 @@ export function evidenceWritesAllowed(
return isResumableStatus(status); return isResumableStatus(status);
} }
/**
* The one-resumable-case-per-user invariant, mirrored from the database
* partial unique index. The database is the enforcement point; this is the
* contract the service layer relies on.
*/
export const MAX_RESUMABLE_CASES_PER_USER = 1;
export const RECTIFICATION_SKILL_NAME = "jyotish-birth-time-rectification"; export const RECTIFICATION_SKILL_NAME = "jyotish-birth-time-rectification";
// 原值: "10.0.28" / 新值: "10.0.29" / 原因: 年月阶段改口述并禁止追问本人 // 原值: "10.0.28" / 新值: "10.0.29" / 原因: 年月阶段改口述并禁止追问本人
// 原值: "10.0.29" / 新值: "10.0.30" / 原因: D2 出卡句与 D4 卡上百分比规则写进 Skill(2026-09-26) // 原值: "10.0.29" / 新值: "10.0.30" / 原因: D2 出卡句与 D4 卡上百分比规则写进 Skill(2026-09-26)
@@ -9,7 +9,6 @@ import {
appendRectificationReturn, appendRectificationReturn,
attachOpenedOfferToFreeMessage, attachOpenedOfferToFreeMessage,
bindOfferToCase, bindOfferToCase,
blocksNewRectification,
decideRectificationOfferWith, decideRectificationOfferWith,
offerForCase, offerForCase,
sourceFromSessionRows, sourceFromSessionRows,
@@ -36,10 +35,7 @@ export async function decideRectificationOffer(input: {
return decideRectificationOfferWith({ return decideRectificationOfferWith({
subjectRole: input.subjectRole, subjectRole: input.subjectRole,
productEnabled: () => isProductEnabled("rectification"), productEnabled: () => isProductEnabled("rectification"),
hasResumableCase: async () => { hasResumableCase: async () => (await getRectificationEntrySummary(input.accounting, input.userId)).hasResumableCase,
const summary = await getRectificationEntrySummary(input.accounting, input.userId);
return blocksNewRectification(summary.latestResumable?.status ?? "");
},
creditCost: async () => (await resolveFeaturePricing(input.accounting, "rectification", input.modelId)).credit_cost, creditCost: async () => (await resolveFeaturePricing(input.accounting, "rectification", input.modelId)).credit_cost,
}); });
} }
+1 -17
View File
@@ -86,23 +86,7 @@ export const RECTIFICATION_DIRECT_NEW_COPY = "好,我们开始核对出生时
export const RECTIFICATION_DIRECT_RESUME_COPY = "核对还没做完,我们接着做。"; export const RECTIFICATION_DIRECT_RESUME_COPY = "核对还没做完,我们接着做。";
export const RECTIFICATION_DIRECT_UNAVAILABLE_COPY = "出生时间核对这会儿还做不了。"; export const RECTIFICATION_DIRECT_UNAVAILABLE_COPY = "出生时间核对这会儿还做不了。";
export const RECTIFICATION_DIRECT_NOT_SELF_COPY = "给家人校正暂时还不支持"; export const RECTIFICATION_DIRECT_NOT_SELF_COPY = "给家人校正暂时还不支持";
export const RECTIFICATION_DIRECT_RESUME_ELSEWHERE_COPY = "可在历史对话里接着做。";
/**
* Chat entry only. An accepted case is a finished stop, so it must not block
* a new explicit open or the ordinary offer card. The shared resumable set
* still includes that status for old sessions, skill upgrade and rebaseline.
*/
const ENTRY_BLOCKING_RECTIFICATION_STATUSES = new Set<string>([
"draft",
"collecting_evidence",
"candidate_ready",
"needs_rebaseline",
"paused",
]);
export function blocksNewRectification(status: string): boolean {
return ENTRY_BLOCKING_RECTIFICATION_STATUSES.has(status);
}
export function rectificationReturnDividerText(record: Pick<RectificationReturnRecord, "adopted" | "clock">): string { export function rectificationReturnDividerText(record: Pick<RectificationReturnRecord, "adopted" | "clock">): string {
return record.adopted && record.clock return record.adopted && record.clock
@@ -1,50 +0,0 @@
import type { DataClient } from "@/lib/db/data-client";
import {
RectificationCaseServiceError,
getRectificationCase,
getRectificationEntrySummary,
} from "@/lib/rectification-agentic/v9/case-service";
import { blocksNewRectification } from "@/lib/rectification-in-chat";
/**
* Raised when an in-progress case cannot be resumed. The message is the
* category only, so a log of this error cannot carry a profile or a quote.
*/
export class RectificationResumeLookupError extends Error {
readonly errorCategory: string;
readonly hasCaseId: boolean;
constructor(errorCategory: string, hasCaseId: boolean) {
super(errorCategory);
this.name = "RectificationResumeLookupError";
this.errorCategory = errorCategory;
this.hasCaseId = hasCaseId;
}
}
function safeCategory(value: string): string {
return /^[a-z0-9_]+$/.test(value) ? value : "lookup_failed";
}
/**
* Session of the latest case that still blocks a new chat entry.
* Accepted cases do not block. The read goes through the case RPC: the
* service role cannot select the cases table.
*/
export async function loadResumableRectificationSessionId(
accounting: DataClient,
userId: string,
): Promise<string | null> {
const summary = await getRectificationEntrySummary(accounting, userId);
const latest = summary.latestResumable;
if (!latest?.caseId || !blocksNewRectification(latest.status)) return null;
try {
const view = await getRectificationCase(accounting, userId, latest.caseId);
if (!view.sessionId) throw new RectificationResumeLookupError("missing_session", true);
return view.sessionId;
} catch (error) {
if (error instanceof RectificationResumeLookupError) throw error;
const category = error instanceof RectificationCaseServiceError ? error.code : "lookup_failed";
throw new RectificationResumeLookupError(safeCategory(category), true);
}
}
+9 -39
View File
@@ -2,6 +2,7 @@ import {
RECTIFICATION_DIRECT_NEW_COPY, RECTIFICATION_DIRECT_NEW_COPY,
RECTIFICATION_DIRECT_NOT_SELF_COPY, RECTIFICATION_DIRECT_NOT_SELF_COPY,
RECTIFICATION_DIRECT_RESUME_COPY, RECTIFICATION_DIRECT_RESUME_COPY,
RECTIFICATION_DIRECT_RESUME_ELSEWHERE_COPY,
RECTIFICATION_DIRECT_UNAVAILABLE_COPY, RECTIFICATION_DIRECT_UNAVAILABLE_COPY,
RECTIFICATION_OFFER_TOOL_ID, RECTIFICATION_OFFER_TOOL_ID,
type RectificationOfferDecision, type RectificationOfferDecision,
@@ -1154,7 +1155,7 @@ const evidenceLookupInputSchema = z.object({
const RECTIFICATION_OFFER_REFUSAL_NOTES: Readonly<Record<Exclude<RectificationOfferDecision, { ok: true }>["reason"], string>> = { const RECTIFICATION_OFFER_REFUSAL_NOTES: Readonly<Record<Exclude<RectificationOfferDecision, { ok: true }>["reason"], string>> = {
not_self: `问的不是用户本人的盘,生时校正暂时只支持本人。正文用这一句说明:「${RECTIFICATION_DIRECT_NOT_SELF_COPY}」,不出卡。`, not_self: `问的不是用户本人的盘,生时校正暂时只支持本人。正文用这一句说明:「${RECTIFICATION_DIRECT_NOT_SELF_COPY}」,不出卡。`,
product_disabled: `出生时间核对这会儿做不了。正文必须用这一句说明:「${RECTIFICATION_DIRECT_UNAVAILABLE_COPY}」,不写内部原因。`, product_disabled: `出生时间核对这会儿做不了。正文必须用这一句说明:「${RECTIFICATION_DIRECT_UNAVAILABLE_COPY}」,不写内部原因。`,
case_in_progress: "用户已经有一次没做完的生时校正。这次不出提议卡,正文照常回答这一轮的问题。", case_in_progress: "用户已经有一次没做完的生时校正。正文用一句话告诉他可以在历史对话里接着做。",
pricing_unavailable: `出生时间核对这会儿做不了。正文必须用这一句说明:「${RECTIFICATION_DIRECT_UNAVAILABLE_COPY}」,不写价格。`, pricing_unavailable: `出生时间核对这会儿做不了。正文必须用这一句说明:「${RECTIFICATION_DIRECT_UNAVAILABLE_COPY}」,不写价格。`,
}; };
@@ -1175,33 +1176,16 @@ function offerRecord(
}; };
} }
function resumeFailureLog(error: unknown): { errorCategory: string; hasCaseId: boolean } {
if (error && typeof error === "object" && "errorCategory" in error && "hasCaseId" in error) {
const record = error as { errorCategory?: unknown; hasCaseId?: unknown };
const errorCategory = typeof record.errorCategory === "string" && /^[a-z0-9_]+$/.test(record.errorCategory)
? record.errorCategory
: "lookup_failed";
return { errorCategory, hasCaseId: record.hasCaseId === true };
}
return { errorCategory: "lookup_failed", hasCaseId: false };
}
function logResumeLookupFailed(requestId: string, errorCategory: string, hasCaseId: boolean) {
console.warn("rectification_resume_lookup_failed", { requestId, errorCategory, hasCaseId });
}
/** /**
* Explicit request (BUG-1306): the server already decided. A pass records an * Explicit request (BUG-1306): the server already decided. A pass records an
* offered card with autoOpen and tells the stream to open. Status stays * offered card with autoOpen and tells the stream to open. Status stays
* offered until the case is bound. A case already in progress resumes that * offered until the case is bound. A case already in progress resumes that
* case. If that session cannot be read, open a new one instead of a dead end. * case. Anything else is one sentence and writes no offer.
* Anything else is one sentence and writes no offer.
*/ */
async function finishExplicitRectificationRequest( async function finishExplicitRectificationRequest(
state: ConsultationRuntimeState, state: ConsultationRuntimeState,
hook: RectificationOfferHook, hook: RectificationOfferHook,
decision: RectificationOfferDecision, decision: RectificationOfferDecision,
requestId: string,
) { ) {
if (decision.ok) { if (decision.ok) {
state.rectificationOffer = offerRecord(hook, decision, "offered", true); state.rectificationOffer = offerRecord(hook, decision, "offered", true);
@@ -1218,13 +1202,9 @@ async function finishExplicitRectificationRequest(
} }
if (decision.reason === "case_in_progress") { if (decision.reason === "case_in_progress") {
let resumeSessionId: string | null = null; let resumeSessionId: string | null = null;
let lookupFailed = false;
try { try {
resumeSessionId = await hook.loadResumableSessionId(); resumeSessionId = await hook.loadResumableSessionId();
} catch (error) { } catch {
lookupFailed = true;
const failure = resumeFailureLog(error);
logResumeLookupFailed(requestId, failure.errorCategory, failure.hasCaseId);
resumeSessionId = null; resumeSessionId = null;
} }
if (resumeSessionId) { if (resumeSessionId) {
@@ -1241,18 +1221,12 @@ async function finishExplicitRectificationRequest(
note: `只写这一句,不写解读:「${RECTIFICATION_DIRECT_RESUME_COPY}」`, note: `只写这一句,不写解读:「${RECTIFICATION_DIRECT_RESUME_COPY}」`,
}; };
} }
if (!lookupFailed) logResumeLookupFailed(requestId, "missing_session", false);
state.rectificationDirectEntry = { state.rectificationDirectEntry = {
kind: "new", kind: "refused",
sentence: RECTIFICATION_DIRECT_NEW_COPY, sentence: RECTIFICATION_DIRECT_RESUME_ELSEWHERE_COPY,
sourceSessionId: hook.sourceSessionId, sourceSessionId: hook.sourceSessionId,
}; };
return { return { offered: false as const, reason: decision.reason, note: RECTIFICATION_OFFER_REFUSAL_NOTES.case_in_progress };
offered: false as const,
directEntry: "new" as const,
reason: decision.reason,
note: `只写这一句过渡,不写解读:「${RECTIFICATION_DIRECT_NEW_COPY}」`,
};
} }
const sentence = decision.reason === "not_self" const sentence = decision.reason === "not_self"
? RECTIFICATION_DIRECT_NOT_SELF_COPY ? RECTIFICATION_DIRECT_NOT_SELF_COPY
@@ -1270,11 +1244,7 @@ async function finishExplicitRectificationRequest(
* whether a card may be shown or this turn opens rectification. Never calculates. * whether a card may be shown or this turn opens rectification. Never calculates.
* The rule text lives only in RECTIFICATION_OFFER_RULE. * The rule text lives only in RECTIFICATION_OFFER_RULE.
*/ */
function createRectificationOfferTool(ctx: Readonly<{ function createRectificationOfferTool(ctx: Readonly<{ state: ConsultationRuntimeState; rectificationOffer?: RectificationOfferHook }>) {
state: ConsultationRuntimeState;
requestId: string;
rectificationOffer?: RectificationOfferHook;
}>) {
return createTool({ return createTool({
id: RECTIFICATION_OFFER_TOOL_ID, id: RECTIFICATION_OFFER_TOOL_ID,
description: "Offer birth-time rectification under this answer, or open it when explicitRequest is true. Call before writing the answer. Pass explicitRequest=true only when the user explicitly asks to do rectification this turn; then write only the one transition sentence in the result and no reading. Pass userAsked=true only when the user asks again after an earlier offer in this conversation. Never call it for a disagreement with an interpretation. Do not promise to narrow the birth time or collect life events here. The result says whether a card will be shown and what one sentence to write.", description: "Offer birth-time rectification under this answer, or open it when explicitRequest is true. Call before writing the answer. Pass explicitRequest=true only when the user explicitly asks to do rectification this turn; then write only the one transition sentence in the result and no reading. Pass userAsked=true only when the user asks again after an earlier offer in this conversation. Never call it for a disagreement with an interpretation. Do not promise to narrow the birth time or collect life events here. The result says whether a card will be shown and what one sentence to write.",
@@ -1304,7 +1274,7 @@ function createRectificationOfferTool(ctx: Readonly<{
return { offered: false as const, reason: "already_offered_in_session", note: "这次对话里已经提议过一次,用户没接。不再主动提校正,正常回答。" }; return { offered: false as const, reason: "already_offered_in_session", note: "这次对话里已经提议过一次,用户没接。不再主动提校正,正常回答。" };
} }
const decision = await hook.decide(); const decision = await hook.decide();
if (input.explicitRequest === true) return finishExplicitRectificationRequest(ctx.state, hook, decision, ctx.requestId); if (input.explicitRequest === true) return finishExplicitRectificationRequest(ctx.state, hook, decision);
if (!decision.ok) return { offered: false as const, reason: decision.reason, note: RECTIFICATION_OFFER_REFUSAL_NOTES[decision.reason] }; if (!decision.ok) return { offered: false as const, reason: decision.reason, note: RECTIFICATION_OFFER_REFUSAL_NOTES[decision.reason] };
ctx.state.rectificationOffer = offerRecord(hook, decision, "offered"); ctx.state.rectificationOffer = offerRecord(hook, decision, "offered");
return { offered: true as const, note: "你的回答下面会出现一张提议卡,写着大约几题、几点。正文最后一段必须有一句提到它(例:「如果想确认出生时间准不准,可以用下面的卡片,答几道过去经历的题核对一下。」),不写价格和题数,不催。" }; return { offered: true as const, note: "你的回答下面会出现一张提议卡,写着大约几题、几点。正文最后一段必须有一句提到它(例:「如果想确认出生时间准不准,可以用下面的卡片,答几道过去经历的题核对一下。」),不写价格和题数,不催。" };
@@ -0,0 +1,97 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import test from "node:test";
import { ChartVedicTab } from "../src/components/chart-page/chart-vedic-tab.tsx";
import { assembleChartView } from "../src/lib/chart-view-load.ts";
import { chartViewOkSchema, type ChartViewOk } from "../src/lib/chart-view-contract.ts";
Object.assign(globalThis, { React });
const golden = JSON.parse(readFileSync(new URL("./fixtures/chart-view-golden.json", import.meta.url), "utf8"));
const bhavaGolden = JSON.parse(readFileSync(new URL("./fixtures/chart-view-bhava-golden.json", import.meta.url), "utf8"));
const transitPacket = JSON.parse(readFileSync(new URL("./fixtures/ephemeris-chart-transit-2026-09-15.golden.json", import.meta.url), "utf8"));
const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const profile = {
name: "示例",
date: "1990-06-15",
time: "12:00",
placeLabel: "北京",
latitude: 39.9042,
longitude: 116.4074,
timezoneOffset: 8,
timezoneId: "Asia/Shanghai",
ayanamsa: "raman",
birthTimeStatus: "confirmed",
};
function directDivClasses(html: string, parentClass: string): string[] {
const needle = `<div class="${parentClass}">`;
const start = html.indexOf(needle);
assert.ok(start >= 0, parentClass);
let index = start + needle.length;
let depth = 1;
const classes: string[] = [];
while (index < html.length && depth > 0) {
const nextOpen = html.indexOf("<div", index);
const nextClose = html.indexOf("</div>", index);
assert.ok(nextClose >= 0);
if (nextOpen >= 0 && nextOpen < nextClose) {
if (depth === 1) {
const tagEnd = html.indexOf(">", nextOpen);
const tag = html.slice(nextOpen, tagEnd + 1);
classes.push(tag.match(/class="([^"]*)"/)?.[1] ?? "");
}
depth += 1;
index = html.indexOf(">", nextOpen) + 1;
} else {
depth -= 1;
index = nextClose + "</div>".length;
}
}
return classes;
}
async function fullView(): Promise<ChartViewOk> {
const result = await assembleChartView({
userId: "user-1",
profile,
asOf: "2026-09-15",
layers: ["varga", "bhava", "transit"],
postEngine: async (path, _body, cacheScope) => {
if (path === "/api/chart" && cacheScope?.startsWith("transit:")) return { status: "ok", payload: transitPacket };
if (path === "/api/chart") return { status: "ok", payload: golden.chart };
if (path === "/api/varga_full") return { status: "ok", payload: golden.varga_full };
if (path === "/api/bhava_chalit") return { status: "ok", payload: bhavaGolden.bhava_chalit };
return { status: "http_error", path, elapsedMs: 1, httpStatus: 500 };
},
});
assert.equal(result.body.status, "ok");
return chartViewOkSchema.parse(result.body);
}
test("every Vedic chart type puts its table in the layout's full-width row", async () => {
const view = await fullView();
for (const vargaId of ["D1", "MOON", "D9", "BHAVA", "TRANSIT"]) {
const markup = renderToStaticMarkup(React.createElement(ChartVedicTab, { view, vargaId, onSelectVarga: () => {} }));
const children = directDivClasses(markup, "chart-page-vedic-layout");
assert.deepEqual(children, [
"chart-page-vedic-stage",
"chart-page-params",
"chart-page-vedic-tables",
], vargaId);
}
});
test("the desktop chart grid spans the table row, not a table's own class", () => {
const desktopStart = globalStyles.indexOf("@media (min-width: 1024px) {\n .chart-page-vedic-layout,");
assert.ok(desktopStart >= 0);
const desktopEnd = globalStyles.indexOf("\n}", desktopStart);
const desktop = globalStyles.slice(desktopStart, desktopEnd);
assert.match(desktop, /\.chart-page-vedic-tables \{ grid-column: 1 \/ -1; \}/);
assert.doesNotMatch(desktop, /\.chart-page-planet-table-wrap[\s\S]*grid-column/);
assert.doesNotMatch(desktop, /\.chart-page-varga-table-block[\s\S]*grid-column/);
});
@@ -1,77 +0,0 @@
// Service role can resume an in-progress case only through the case RPC.
// A direct select of agentic_rectification_cases is revoked. Fictional ids only.
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import test from "node:test";
import { closeLocalPostgresDataPool, createLocalPostgresDataClient } from "../src/lib/db/local-postgres-client-core.ts";
import { loadResumableRectificationSessionId } from "../src/lib/rectification-resume-session.ts";
import { startPostgresFixture } from "./helpers/postgres-fixture.ts";
const docker = spawnSync("docker", ["version"], { stdio: "ignore" }).status === 0;
const runner = fileURLToPath(new URL("../scripts/db-migrate.mjs", import.meta.url));
test("service role loads an in-progress rectification session through the case RPC", { skip: !docker && "docker unavailable" }, async () => {
const fixture = startPostgresFixture();
const url = fixture.connectionUrl("service_runtime", "service-runtime-test-password");
const userId = randomUUID();
const sessionId = randomUUID();
const caseId = randomUUID();
const snapshot = {
birth_date: "2000-06-15",
reported_birth_time: "00:10",
active_birth_time: null,
birth_time_source: "family_exact",
birth_time_period: null,
uncertainty_before_minutes: 15,
uncertainty_after_minutes: 15,
latitude: 40,
longitude: -74,
timezone_id: "America/New_York",
timezone_offset: -4,
};
const range = {
start_time: "23:55",
end_time: "00:25",
candidate_intervals: [{ start_at: "2000-06-14T23:55", end_at: "2000-06-15T00:25" }],
};
try {
const migration = spawnSync(process.execPath, [runner], {
encoding: "utf8",
env: {
...process.env,
SCHEMA_DATABASE_URL: fixture.connectionUrl("schema_owner", "schema-owner-test-password"),
},
});
assert.equal(migration.status, 0, `${migration.stdout}\n${migration.stderr}`);
fixture.psqlAs("identity_runtime", "identity-runtime-test-password", `
insert into identity.users(id,name,email,email_verified)
values ('${userId}','Synthetic Resume Fixture','${userId}@example.invalid',true);
`);
fixture.psql(`
update public.profiles set birth_date='2000-06-15',reported_birth_time='00:10',birth_time_source='family_exact',
birth_time_status='reported',uncertainty_before_minutes=15,uncertainty_after_minutes=15,
latitude=40,longitude=-74,timezone_id='America/New_York',timezone_offset=-4 where id='${userId}';
insert into public.chat_sessions(id,user_id,title,theme,session_type,messages)
values('${sessionId}','${userId}','Synthetic resume','general','birth_time_rectification','[]');
insert into public.agentic_rectification_cases(id,user_id,session_id,status,skill_name,skill_version,
baseline_profile_fingerprint,baseline_birth_snapshot,candidate_range)
values('${caseId}','${userId}','${sessionId}','collecting_evidence','jyotish-birth-time-rectification','9.0.0',
'${"a".repeat(64)}','${JSON.stringify(snapshot)}','${JSON.stringify(range)}');
`);
const service = createLocalPostgresDataClient(url, null, "service_role");
const direct = await service
.from("agentic_rectification_cases")
.select("session_id")
.eq("id", caseId)
.maybeSingle();
assert.ok(direct.error, "a direct table read stays revoked for service_role");
assert.match(String(direct.error?.message ?? ""), /permission denied/);
assert.equal(await loadResumableRectificationSessionId(service, userId), sessionId);
} finally {
await closeLocalPostgresDataPool(url);
fixture.stop();
}
});
@@ -11,10 +11,10 @@ import { RectificationOfferCard } from "../src/components/rectification-in-chat-
import { consultationAgentPublicEventSchema, createNdjsonParser, type AgentExecutionReceipt, type ConsultationAgentPublicEvent } from "../src/lib/consultation-agent-events.ts"; import { consultationAgentPublicEventSchema, createNdjsonParser, type AgentExecutionReceipt, type ConsultationAgentPublicEvent } from "../src/lib/consultation-agent-events.ts";
import { RECTIFICATION_OFFER_RULE } from "../src/lib/consultation-thinking-plan.ts"; import { RECTIFICATION_OFFER_RULE } from "../src/lib/consultation-thinking-plan.ts";
import { import {
blocksNewRectification,
RECTIFICATION_DIRECT_NEW_COPY, RECTIFICATION_DIRECT_NEW_COPY,
RECTIFICATION_DIRECT_NOT_SELF_COPY, RECTIFICATION_DIRECT_NOT_SELF_COPY,
RECTIFICATION_DIRECT_RESUME_COPY, RECTIFICATION_DIRECT_RESUME_COPY,
RECTIFICATION_DIRECT_RESUME_ELSEWHERE_COPY,
RECTIFICATION_DIRECT_UNAVAILABLE_COPY, RECTIFICATION_DIRECT_UNAVAILABLE_COPY,
RECTIFICATION_OFFER_OPENED_COPY, RECTIFICATION_OFFER_OPENED_COPY,
RECTIFICATION_OFFER_START_LABEL, RECTIFICATION_OFFER_START_LABEL,
@@ -48,6 +48,7 @@ const DIRECT_SENTENCES = [
RECTIFICATION_DIRECT_RESUME_COPY, RECTIFICATION_DIRECT_RESUME_COPY,
RECTIFICATION_DIRECT_UNAVAILABLE_COPY, RECTIFICATION_DIRECT_UNAVAILABLE_COPY,
RECTIFICATION_DIRECT_NOT_SELF_COPY, RECTIFICATION_DIRECT_NOT_SELF_COPY,
RECTIFICATION_DIRECT_RESUME_ELSEWHERE_COPY,
] as const; ] as const;
const STOP_FINISH = { type: "finish", payload: { stepResult: { reason: "stop" }, output: { usage: {}, steps: [{}] } } }; const STOP_FINISH = { type: "finish", payload: { stepResult: { reason: "stop" }, output: { usage: {}, steps: [{}] } } };
@@ -122,22 +123,13 @@ function walkSources(dir: string): string[] {
} }
test("direct-entry sentences stay within the free-completion length", () => { test("direct-entry sentences stay within the free-completion length", () => {
// 原值:[13, 13, 14, 11, 11],含「可在历史对话里接着做。」 assert.deepEqual(DIRECT_SENTENCES.map((sentence) => Array.from(sentence).length), [13, 13, 14, 11, 11]);
// 新值:[13, 13, 14, 11]
// 原因:接不回进行中的校正时改为新开,不再留这句没有出口的兜底
assert.deepEqual(DIRECT_SENTENCES.map((sentence) => Array.from(sentence).length), [13, 13, 14, 11]);
for (const sentence of DIRECT_SENTENCES) assert.ok(Array.from(sentence).length <= 20); for (const sentence of DIRECT_SENTENCES) assert.ok(Array.from(sentence).length <= 20);
assert.equal(RECTIFICATION_DIRECT_NEW_COPY, "好,我们开始核对出生时间。"); assert.equal(RECTIFICATION_DIRECT_NEW_COPY, "好,我们开始核对出生时间。");
assert.equal(RECTIFICATION_DIRECT_RESUME_COPY, "核对还没做完,我们接着做。"); assert.equal(RECTIFICATION_DIRECT_RESUME_COPY, "核对还没做完,我们接着做。");
assert.equal(RECTIFICATION_DIRECT_UNAVAILABLE_COPY, "出生时间核对这会儿还做不了。"); assert.equal(RECTIFICATION_DIRECT_UNAVAILABLE_COPY, "出生时间核对这会儿还做不了。");
assert.equal(RECTIFICATION_DIRECT_NOT_SELF_COPY, "给家人校正暂时还不支持"); assert.equal(RECTIFICATION_DIRECT_NOT_SELF_COPY, "给家人校正暂时还不支持");
assert.equal(blocksNewRectification("candidate_accepted"), false); assert.equal(RECTIFICATION_DIRECT_RESUME_ELSEWHERE_COPY, "可在历史对话里接着做。");
assert.equal(blocksNewRectification("collecting_evidence"), true);
assert.equal(blocksNewRectification("draft"), true);
assert.equal(blocksNewRectification("candidate_ready"), true);
assert.equal(blocksNewRectification("needs_rebaseline"), true);
assert.equal(blocksNewRectification("paused"), true);
assert.equal(blocksNewRectification("confirmed"), false);
}); });
test("an opened offer with no case id binds, and a reload can find that case", () => { test("an opened offer with no case id binds, and a reload can find that case", () => {
@@ -248,19 +240,7 @@ test("explicitRequest bypasses an earlier offer, and userAsked alone still only
assert.equal(held.decisions(), 1); assert.equal(held.decisions(), 1);
}); });
async function withResumeWarnings(run: () => Promise<void>): Promise<unknown[][]> { test("an in-progress case resumes that session, or says to continue in history", async () => {
const warnings: unknown[][] = [];
const original = console.warn;
console.warn = (...args: unknown[]) => { warnings.push(args); };
try {
await run();
} finally {
console.warn = original;
}
return warnings;
}
test("an in-progress case resumes that session, or opens a new one when the session cannot be read", async () => {
const resume = offerTool({ const resume = offerTool({
decide: async () => ({ ok: false, reason: "case_in_progress" }), decide: async () => ({ ok: false, reason: "case_in_progress" }),
loadResumableSessionId: async () => RESUME_SESSION, loadResumableSessionId: async () => RESUME_SESSION,
@@ -277,49 +257,29 @@ test("an in-progress case resumes that session, or opens a new one when the sess
}); });
assert.equal(resume.loads(), 1); assert.equal(resume.loads(), 1);
// 原值:loader 返回 null 时 kind=refused,句子是「可在历史对话里接着做。」 const missing = offerTool({ decide: async () => ({ ok: false, reason: "case_in_progress" }) });
// 新值:kind=new,句子是「好,我们开始核对出生时间。」,并记一条不含原文的日志 const refused = await missing.tool.execute({ explicitRequest: true });
// 原因:没有会话号时必须仍能打开一次校正 assert.equal(refused.directEntry, undefined);
const missingWarnings = await withResumeWarnings(async () => { assert.equal(missing.state.rectificationDirectEntry?.kind, "refused");
const missing = offerTool({ decide: async () => ({ ok: false, reason: "case_in_progress" }) }); assert.equal(missing.state.rectificationDirectEntry?.sentence, RECTIFICATION_DIRECT_RESUME_ELSEWHERE_COPY);
const opened = await missing.tool.execute({ explicitRequest: true }); assert.equal(missing.state.rectificationOffer, undefined);
assert.equal(opened.directEntry, "new"); assert.match(String(refused.note), /历史对话/);
assert.equal(missing.state.rectificationDirectEntry?.kind, "new"); assert.doesNotMatch(String(refused.note), /不要提校正/);
assert.equal(missing.state.rectificationDirectEntry?.sentence, RECTIFICATION_DIRECT_NEW_COPY);
assert.equal(missing.state.rectificationOffer, undefined);
assert.match(String(opened.note), /好,我们开始核对出生时间/);
assert.doesNotMatch(String(opened.note), /历史对话/);
});
assert.equal(missingWarnings.length, 1);
assert.equal(missingWarnings[0]?.[0], "rectification_resume_lookup_failed");
assert.deepEqual(missingWarnings[0]?.[1], { requestId: REQUEST_ID, errorCategory: "missing_session", hasCaseId: false });
const thrownWarnings = await withResumeWarnings(async () => { const thrown = offerTool({
const thrown = offerTool({ decide: async () => ({ ok: false, reason: "case_in_progress" }),
decide: async () => ({ ok: false, reason: "case_in_progress" }), loadResumableSessionId: async () => { throw new Error("db down"); },
loadResumableSessionId: async () => { throw new Error("db down"); },
});
const fallback = await thrown.tool.execute({ explicitRequest: true });
assert.equal(thrown.state.rectificationDirectEntry?.kind, "new");
assert.equal(thrown.state.rectificationDirectEntry?.sentence, RECTIFICATION_DIRECT_NEW_COPY);
assert.equal(fallback.directEntry, "new");
assert.doesNotMatch(JSON.stringify(fallback), /db down/);
}); });
assert.equal(thrownWarnings.length, 1); const fallback = await thrown.tool.execute({ explicitRequest: true });
const thrownPayload = JSON.stringify(thrownWarnings[0]); assert.equal(thrown.state.rectificationDirectEntry?.kind, "refused");
assert.match(thrownPayload, /lookup_failed/); assert.equal(thrown.state.rectificationDirectEntry?.sentence, RECTIFICATION_DIRECT_RESUME_ELSEWHERE_COPY);
assert.match(thrownPayload, new RegExp(REQUEST_ID)); assert.match(String(fallback.note), /历史对话/);
assert.doesNotMatch(thrownPayload, /db down/);
const quiet = offerTool({ decide: async () => ({ ok: false, reason: "case_in_progress" }) }); const quiet = offerTool({ decide: async () => ({ ok: false, reason: "case_in_progress" }) });
const ordinary = await quiet.tool.execute({}); const ordinary = await quiet.tool.execute({});
assert.equal(quiet.loads(), 0); assert.equal(quiet.loads(), 0);
assert.equal(quiet.state.rectificationDirectEntry, undefined); assert.equal(quiet.state.rectificationDirectEntry, undefined);
// 原值:普通提议的说明匹配 /历史对话/ assert.match(String(ordinary.note), /历史对话/);
// 新值:说明这次不出卡,不提历史对话
// 原因:那句兜底已经删掉;没做完时仍不出新卡
assert.match(String(ordinary.note), /不出提议卡/);
assert.doesNotMatch(String(ordinary.note), /历史对话/);
assert.doesNotMatch(String(ordinary.note), /不要提校正/); assert.doesNotMatch(String(ordinary.note), /不要提校正/);
}); });
@@ -722,14 +682,9 @@ test("the offer rule is defined once, and direct entry settles with the free com
assert.match(windowBlock, /precomputeWindowConsultation/); assert.match(windowBlock, /precomputeWindowConsultation/);
assert.match(windowBlock, /运行合同不完整/); assert.match(windowBlock, /运行合同不完整/);
assert.match(natalBlock, /运行合同不完整/); assert.match(natalBlock, /运行合同不完整/);
// 原值:route 直接 getRectificationEntrySummary 后 from("agentic_rectification_cases").select("session_id") assert.match(route, /getRectificationEntrySummary\(accounting, userId\)/);
// 新值:route 调用 loadResumableRectificationSessionId,不再读这张表 assert.match(route, /\.from\("agentic_rectification_cases"\)/);
// 原因:服务角色对该表没有读权限,直接读必然失败还被吞掉 assert.match(route, /\.select\("session_id"\)/);
assert.match(route, /loadResumableRectificationSessionId\(accounting, userId\)/);
assert.doesNotMatch(route, /\.from\("agentic_rectification_cases"\)/);
assert.doesNotMatch(route, /\.select\("session_id"\)/);
const serverFile = src("../src/lib/rectification-in-chat-server.ts");
assert.match(serverFile, /blocksNewRectification\(summary\.latestResumable\?\.status \?\? ""\)/);
const stream = src("../src/lib/stream-agent-response.ts"); const stream = src("../src/lib/stream-agent-response.ts");
assert.match(stream, /if \(options\.state\.rectificationDirectEntry\) return true;/); assert.match(stream, /if \(options\.state\.rectificationDirectEntry\) return true;/);
@@ -15,7 +15,6 @@ import {
import { import {
BIRTH_TIME_CHANGED_HISTORY_NOTE, BIRTH_TIME_CHANGED_HISTORY_NOTE,
RECTIFICATION_OFFER_TOOL_ID, RECTIFICATION_OFFER_TOOL_ID,
blocksNewRectification,
appendRectificationReturn, appendRectificationReturn,
bindOfferToCase, bindOfferToCase,
decideRectificationOfferWith, decideRectificationOfferWith,
@@ -112,21 +111,6 @@ test("the server decides whether a card may be shown: self, product on, no case
assert.deepEqual(await decideRectificationOfferWith({ subjectRole: "other", productEnabled: yes, hasResumableCase: no, creditCost: price }), { ok: false, reason: "not_self" }); assert.deepEqual(await decideRectificationOfferWith({ subjectRole: "other", productEnabled: yes, hasResumableCase: no, creditCost: price }), { ok: false, reason: "not_self" });
assert.deepEqual(await decideRectificationOfferWith({ subjectRole: "self", productEnabled: no, hasResumableCase: no, creditCost: price }), { ok: false, reason: "product_disabled" }); assert.deepEqual(await decideRectificationOfferWith({ subjectRole: "self", productEnabled: no, hasResumableCase: no, creditCost: price }), { ok: false, reason: "product_disabled" });
assert.deepEqual(await decideRectificationOfferWith({ subjectRole: "self", productEnabled: yes, hasResumableCase: yes, creditCost: price }), { ok: false, reason: "case_in_progress" }); assert.deepEqual(await decideRectificationOfferWith({ subjectRole: "self", productEnabled: yes, hasResumableCase: yes, creditCost: price }), { ok: false, reason: "case_in_progress" });
// 原值:只要摘要说有可续做校正,就判 case_in_progress
// 新值:已采用(candidate_accepted)不再挡住入口;collecting_evidence 仍挡住
// 原因:已采用是终点。入口改看 blocksNewRectification,不改共享状态集合
assert.deepEqual(await decideRectificationOfferWith({
subjectRole: "self",
productEnabled: yes,
hasResumableCase: async () => blocksNewRectification("candidate_accepted"),
creditCost: price,
}), { ok: true, creditCost: 3, questionEstimate: 8 });
assert.deepEqual(await decideRectificationOfferWith({
subjectRole: "self",
productEnabled: yes,
hasResumableCase: async () => blocksNewRectification("collecting_evidence"),
creditCost: price,
}), { ok: false, reason: "case_in_progress" });
assert.deepEqual(await decideRectificationOfferWith({ subjectRole: "self", productEnabled: yes, hasResumableCase: no, creditCost: async () => { throw new Error("feature_pricing_unavailable"); } }), { ok: false, reason: "pricing_unavailable" }); assert.deepEqual(await decideRectificationOfferWith({ subjectRole: "self", productEnabled: yes, hasResumableCase: no, creditCost: async () => { throw new Error("feature_pricing_unavailable"); } }), { ok: false, reason: "pricing_unavailable" });
assert.deepEqual(await decideRectificationOfferWith({ subjectRole: "self", productEnabled: yes, hasResumableCase: no, creditCost: price }), { ok: true, creditCost: 3, questionEstimate: 8 }); assert.deepEqual(await decideRectificationOfferWith({ subjectRole: "self", productEnabled: yes, hasResumableCase: no, creditCost: price }), { ok: true, creditCost: 3, questionEstimate: 8 });
}); });
@@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url";
import test from "node:test"; import test from "node:test";
import { import {
MAX_RESUMABLE_CASES_PER_USER,
RECTIFICATION_CASE_STATUSES, RECTIFICATION_CASE_STATUSES,
RECTIFICATION_SKILL_NAME, RECTIFICATION_SKILL_NAME,
RECTIFICATION_SKILL_VERSION, RECTIFICATION_SKILL_VERSION,
@@ -90,10 +91,7 @@ test("terminal transitions are one-way and evidence writes stop at terminal", ()
assert.equal(evidenceWritesAllowed("confirmed"), false); assert.equal(evidenceWritesAllowed("confirmed"), false);
assert.equal(evidenceWritesAllowed("closed"), false); assert.equal(evidenceWritesAllowed("closed"), false);
assert.equal(evidenceWritesAllowed("superseded"), false); assert.equal(evidenceWritesAllowed("superseded"), false);
// 原值:MAX_RESUMABLE_CASES_PER_USER === 1 assert.equal(MAX_RESUMABLE_CASES_PER_USER, 1);
// 新值:不再导出这个常量
// 原因:它声称镜像「每人最多一条进行中校正」的部分唯一索引,该索引已由 BUG-177 删除,没有业务逻辑再读它
assert.equal(readFileSync(new URL("../src/lib/rectification-agentic/v9/case-status.ts", import.meta.url), "utf8").includes("MAX_RESUMABLE_CASES_PER_USER"), false);
}); });
test("the active rectification skill pins the v10 identity and lives in the right directory", () => { test("the active rectification skill pins the v10 identity and lives in the right directory", () => {