Merge #160 rectification for other people into staging integration
Independent Staging Quality Gate / validate (push) Failing after 13m11s
Independent Staging Quality Gate / publish (push) Skipped

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyeZrYn6ryNcSe5Y2Y

# Conflicts:
#	CHANGELOG.md
#	frontend/src/components/chart-page/chart-page-view.tsx
This commit is contained in:
2026-11-01 17:32:14 +08:00
41 changed files with 3144 additions and 319 deletions
+13
View File
@@ -1,5 +1,17 @@
# 印度占星 Skill 更新日志
## 2026-11-01 — 生时校正可以给档案里的其他人做
- 「生时校正」页标题旁可以换人,页面按当前选中的人显示:状态卡第一行写称呼和关系,核对一步列出称呼、关系、出生时间、出生城市,「改这份」打开这个人的资料。
- 进行中的校正按人算:本人一个、每位家人各一个,可以同时进行(产品 2026-11-01 定)。
- 采用或确认的时间只写回这个人的档案;本人的资料和本人的校正都不动。之后改了这个人的出生日期、时间或地点,采用的时间作废,和本人一样。
- 删除一个人时,TA 的校正记录和校正对话一起删除,扣过的点不退,和删除对话一样。删除前的「将同时删除 N 个对话」也算上这些对话。
- 对话里给家人也会出校正提议,明确说要校正时同样开始核对,不再说「给家人校正暂时还不支持」。别人的星盘上,上升提示也带「出生时间校正」链接;起步三问下面的「去校正」看的是当前这个人的出生时间。
- 扣点不变:每次校正在第一条真实消息时扣一次,给谁做都一样。
- 数据库迁移 `20261101010000_rectification_chart_subject.sql`,只加不删,以前的校正都算本人。
- 还没做:校正对话里仍按「你」称呼,给别人做时怎么称呼待定。
- Skill 版本不变(issue #160)。
## 2026-11-01 — 新增「印度占星知识」栏目(/learn)
- 新页面 `/learn`:不登录也能看,可以被搜索引擎收录。左边是七个栏目加「全部」,右边按栏目列出「问句标题 + 一行摘要」;手机上栏目是一排可以横滑的按钮。
@@ -21,6 +33,7 @@
- 原来不带 `mode` 的 `/api/annual` 返回不变。说明见 `docs/engine/annual-package-v1.md`。
- Skill 版本不变(issue #152、#153)。
## 2026-10-30 — 星盘页:盘旁「盘面数据」卡,表格和盘联动
- 盘的右边(手机上是盘的下面)新增「盘面数据」卡,九个页签:行星、虚星、特殊点、映射、八分点、星力、大运、敌友、瑜伽。原来散在「九曜」「Vimshottari 大运」两张卡和深读「命性格局」「排盘底册」里的表都搬到这里,不再重复。
+6
View File
@@ -2,6 +2,12 @@
Purpose: read this file before substantial project work. It exists to stop repeat mistakes caused by multiple Codex windows, WorkBuddy mirrors, local drafts, backup folders, and partial cloud-git visibility.
## 2026-11-01 · issue #160 生时校正给他人(worktree `/workspace/wt-160`)
- `pre_work_check.py` exit 0,但五个 Python 步骤(运行时、碎片扫描、外部引擎适配器、远端可见性、focused)全部是「No usable project Python found」:本机没有项目 `.venv`,按约定不新建。外部引擎诊断与远端可见性这次都没有跑,不得写成预检通过。
- Python 测试改用 `uv run --no-project --with pytest --with pyswisseph` 的临时环境跑,不写进仓库。
- 没有 Docker,`tests/database-*.test.ts` 全部 skip;想在本机解包 Debian PostgreSQL 17 服务端二进制来跑 DB 测试,被会话权限拒绝,已删掉下载物,没有换别的办法绕过。新迁移只做了 libpg_query(pglast)语法和 PL/pgSQL 解析检查,没有在真库上执行过。
## 2026-09-30 · 盘型口径实现的预检与逐格平台诊断
- 开工 pre_work_check exit 1:remote verified、适配器/碎片步骤正常,focused 历史 `.workbuddy/skills/jyotish-vedic-astrology` 镜像路径断言失败;不制造镜像、不改断言。主会话保留完整 Node / Python 不同容器基线,最终分别逐名比对,不混用 npm 的 54 项与 quick 内 npm 的 56 项。
@@ -71,7 +71,7 @@
8. 不引第三方引导库或动画库。动效用 CSS 和 Web Animations API,复用 `TASK-site-motion-20261022.md` M1 的 `lib/motion/` 底座;提示用现有的 `@base-ui/react` Popover。
9. 系统开了 `prefers-reduced-motion` 时,D6 的全部动效退成淡入或直接出现,星点和闪烁去掉。
10. 清单的“做没做”**一律从真实数据推导**。唯一例外是“进过星盘页”(没有数据可推),见 T2。
11. 生时校正仍只支持本人。帮别人看时,任何地方都不出现校正提议。
11. ~~生时校正仍只支持本人。帮别人看时,任何地方都不出现校正提议。~~ 2026-11-01 解除(issue #160,产品定 1A 2A):档案里的人都能做生时校正,进行中的校正按人物分别算;帮别人看时,校正提议和起步三问下面的「去校正」看的是 TA 的出生时间。启程清单里的「做一次生时校正」仍只看本人。
12. 迁移只加不删(AGENTS.md §7.6):三列废列本单不删,关系列可为空。必须真跑 `npm run test:db`;本机没有 Docker 时,按已记录的无 Docker PG17 做法跑;仍做不了就写进 `BLOCKED.md`,不得写成通过。
13. 测试、fixture、文档里不得出现任何真实用户资料。
@@ -131,7 +131,7 @@
- `missingProfileStep()` 改为:本人资料完整,**或**档案里至少有一位资料完整的人,就算建完档。中途退出的,下次从缺的那一步接着问。
- 逐个核对事故实证 4 列出的调用点,按“当前人物”而不是“本人”判断:
- 今日星语:本人没有出生资料时,首页不显示「今日星语」按钮。
- 生时校正:当前人物不是本人时,不出现校正提议(红线 11)。
- 生时校正:~~当前人物不是本人时,不出现校正提议(红线 11)。~~ 2026-11-01 起按当前人物判断(issue #160,红线 11 已解除)。
- 「和我合盘」:本人没有出生资料时,按钮位置改为文字「加上你自己后可合盘」。
- 顺手清理事故实证 6 的死代码,并修正 DESIGN.md §9 那一段。
+14 -2
View File
@@ -1393,12 +1393,24 @@ Yogini 还在取时,右栏用和 Chara 相同的轨道等待(`OrbitWaiting`
推翻 2026-10-07「校正只从对话里的提议卡进入」(BUG-1247):产品要求校正单独有入口。
- **侧栏**:导航里「星盘」下面一项「生时校正」(Lucide `Clock3`),链接 `/rectification`。账户的 `rectification` 开关为 true 才出现;开关没到之前不出现,和「婚恋合盘」同一规则。
- **`/rectification`**:沿用二级页外壳(`SecondaryPageShell`),标题旁不放人物切换(校正只支持本人,他人见 #160)。正文一栏宽 `min(100%, 680px)` 居中,上边距 32px(手机 20px,左右 16px)。一句说明,下面一张状态卡(`.rectification-page-card`:细边、`--radius-lg`、24px 内边距,手机 20px)。状态卡分没做过 / 进行中 / 已完成三种,状态只来自服务端 `entry-summary`(含会话 id、时间窗、确认过的大事数、采用时间)。进行中是两列数字(时间窗、已收大事);已完成时刻 28px 衬线数字。主按钮是组件库默认按钮,次按钮描边,都是真链接(`AppLink`)。「开始」「再校正一次」先进核对:两行资料表(手机上标签和值上下排),「开始校正」+ 链接「改这份」(`/people?self=1`)。开始后进首页校正面(`/?rectify=1` 续上或新开;做完之后再校正用 `/?rectify=new`),「继续」「查看结果」打开那次校正的会话(`/?c=<id>`)。页面上不写价格。读取中用二级页现有的等待文案,不另做骨架。
- **`/rectification`**:沿用二级页外壳(`SecondaryPageShell`),标题旁放人物切换(2026-11-01 起,见下节 #160;之前不放)。正文一栏宽 `min(100%, 680px)` 居中,上边距 32px(手机 20px,左右 16px)。一句说明,下面一张状态卡(`.rectification-page-card`:细边、`--radius-lg`、24px 内边距,手机 20px)。状态卡分没做过 / 进行中 / 已完成三种,状态只来自服务端 `entry-summary`(含会话 id、时间窗、确认过的大事数、采用时间)。进行中是两列数字(时间窗、已收大事);已完成时刻 28px 衬线数字。主按钮是组件库默认按钮,次按钮描边,都是真链接(`AppLink`)。「开始」「再校正一次」先进核对:两行资料表(手机上标签和值上下排),「开始校正」+ 链接「改这份」(`/people?self=1`)。开始后进首页校正面(`/?rectify=1` 续上或新开;做完之后再校正用 `/?rectify=new`),「继续」「查看结果」打开那次校正的会话(`/?c=<id>`)。页面上不写价格。读取中用二级页现有的等待文案,不另做骨架。
- **对话回答下面**(`.rectification-offer-card`):改成一行,不再是带底色的卡片:灰字一句 + 主色下划线链接「去做生时校正」和 Lucide `ArrowRight`。点链接去 `/rectification`,不从这段对话开案例;对话进行中链接置灰不可点。回来后的分隔线、「按校正前的时间」不变(旧的已绑定案例照常显示)。
- **明确要求**:过渡句之后跳到 `/rectification`,不再直接打开校正面。
- **建完档的首页**:起步三问下面一行浅底圆角条(`.starter-rectify-hint`,`--color-canvas-soft`),一句 + 描边胶囊「去校正」。只在本人出生时间是估的、首问还没用掉时出现,按启程进场依次浮出。
- **建完档的首页**:起步三问下面一行浅底圆角条(`.starter-rectify-hint`,`--color-canvas-soft`),一句 + 描边胶囊「去校正」。只在当前人物出生时间是估的、首问还没用掉时出现(#160 之前只看本人),按启程进场依次浮出。
- **启程清单**:本人时间是估的账户在「问第一个问题」后多一项「做一次生时校正」,清单变成 8 项;其他账户仍是 7 项。
## 生时校正给档案里的其他人(2026-11-01,issue #160)
产品定 1A 2A:进行中的校正按人物分别算,可以同时进行;删人时 TA 的校正记录一起删除,扣过的点不退。
- **选人**:`/rectification` 用二级页默认的人物切换(`CurrentSubjectSwitcher`),页面跟着当前人物走;未登录时标题旁空着。换人后状态卡按新的人重读 `entry-summary?subject=<id>`,上一个人的结果不会顶着显示,读取中仍用等待文案。
- **状态卡第一行**(`.rectification-page-person`):称呼(正文字号、500)+ 关系小标签(`.rectification-page-person-relation`:细边、`--radius-sm`、小字、次要墨色)。一行不换行,称呼太长省略号截断,标签不挤到下一行。本人的标签写「本人」。不加说明句。
- **核对**:资料表四行「称呼」「关系」「出生时间」「出生城市」,「改这份」打开这个人的编辑(`/people?edit=<id>`;本人仍是 `/people?self=1`)。
- **开始**:`/?rectify=1&rectifyFor=<id>`(做完再校正 `/?rectify=new&rectifyFor=<id>`);本人仍是不带参数的老地址。校正会话绑到那个人(`chat_sessions.chart_profile_id`),侧栏在那个人下面列出。
- **星盘页上升提示**:别人的盘也带「出生时间校正」链接(以前只有本人带)。
- **采用之后**:时间只写回那个人的档案;本人的资料和本人的校正不动。页面刷新人物列表和星盘、星历缓存。
- **数据**:迁移 `20261101010000_rectification_chart_subject.sql`,只加不删:案例加 `chart_profile_id`(空 = 本人,老案例都是空),`chart_profiles` 加 `active_birth_provenance`。
## 用量页分段计时(2026-10-05,在途)
`/admin/usage` 在原表格上多一列「分段」。格子里四行:分类耗时、第 0 步、第 1 步、第 1 步开始到第一个正文字符。每步带推理、输出、输入、缓存输入 token。供应商没返回的数显示「—」。不新开页面,不改表,数字来自 `usage_ledger.metadata` 里已有的 JSON。没有新的等待动画或输入框。
+17 -4
View File
@@ -48,7 +48,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
- 有了你们两个人,就能看你们俩的关系画像。价格写在按钮上。
- 每天的天象和行运在这里,可以按日期翻。
首页开场语下面可以有一行今日趋势(今日星语卡片的 trend,每日生成);还没生成时写「今天的星语还没写出来。」,没有出生分钟时不写。本人没有出生资料时,这一行和「今日星语」入口都不出现。入口按钮下面平时不写字,只在用户需要动手时写一句:有没做完的校正写「上次那次校正还没完成,可以在历史对话里接着做。」;当前人物不是本人写「生时校正暂时只支持本人。」。不再写「不确定出生时间时,用记得住的经历一步步缩小范围」「上次已经校正完,可以拿最新资料再来一次」,也不要用「·」把两句不相干的提示拼成一行。
首页开场语下面可以有一行今日趋势(今日星语卡片的 trend,每日生成);还没生成时写「今天的星语还没写出来。」,没有出生分钟时不写。本人没有出生资料时,这一行和「今日星语」入口都不出现。入口按钮下面平时不写字,只在用户需要动手时写一句:有没做完的校正写「上次那次校正还没完成,可以在历史对话里接着做。」。2026-11-01 起(issue #160)档案里的人都能做校正,不再写「生时校正暂时只支持本人。」。不再写「不确定出生时间时,用记得住的经历一步步缩小范围」「上次已经校正完,可以拿最新资料再来一次」,也不要用「·」把两句不相干的提示拼成一行。
## 婚恋合盘报告(2026-10-19)
@@ -88,14 +88,27 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
- 读不到:「校正记录这会儿读不到。」按钮「再试一次」。未登录:「登录后可以做生时校正。」「去登录」。开关关着:「生时校正这会儿还做不了。」本人资料不全:「先把你自己的出生日期、时间和地点填好,才能做生时校正。」「去填资料」。
- **对话回答下面**:一句「出生时间还不确定。」加链接「去做生时校正」,指向 `/rectification`。不写题数和点数,开没开过案都是这一句。
- **明确要求**:过渡句仍是「好,我们开始核对出生时间。」/「核对还没做完,我们接着做。」,之后跳到 `/rectification`,由页面给「开始」或「继续」。
- **建完档的首页**:本人出生时间是估的(家人记得、时段、不知道),且首问还没用掉时,起步三问下面一行「出生时间还不确定,可以先做生时校正。」加按钮「去校正」。帮别人看时不出现。
- **建完档的首页**:当前人物的出生时间是估的(家人记得、时段、不知道),且首问还没用掉时,起步三问下面一行「出生时间还不确定,可以先做生时校正。」加按钮「去校正」。2026-11-01 起帮别人看时也出现(issue #160),看的是 TA 的时间。
- **启程清单**:同样只对本人时间是估的账户多一项「做一次生时校正」,采用过时间就打勾。
## 生时校正给档案里的其他人(2026-11-01,issue #160)
产品 2026-11-01 定 1A 2A:进行中的校正按人物分别算(本人一个、每位家人各一个,可以同时进行);删除人物时 TA 的校正记录一起删除,扣过的点不退。
- **`/rectification`**:标题旁是人物切换,页面跟着当前人物走。状态卡第一行写这个人的称呼,后面一个小标签写关系(本人写「本人」,没填关系写「其他」)。不加说明句。
- **状态卡**:别人没做过时小字是「TA 现在用的时间」;本人仍是「你现在用的时间」。进行中、已完成的写法不变,数的是这个人自己的那次。
- **核对**:四行「称呼」「关系」「出生时间」「出生城市」,按钮「开始校正」,链接「改这份」(打开这个人的编辑)。
- **资料不全**:本人仍是「先把你自己的出生日期、时间和地点填好,才能做生时校正。」;别人是「先把 TA 的出生日期、时间和地点填好,才能做生时校正。」按钮都是「去填资料」。
- **首页校正面**:资料不全时一句「请先把 TA 的出生资料填完整,再开始生时校正。」
- **对话里**:给家人和本人一样出提议、一样能「好,我们开始核对出生时间。」,有没有进行中的那次只看这个人自己的。
- 扣点不变:每次校正在第一条真实消息时扣一次,给谁做都一样。
- 校正对话里 agent 仍按「你」称呼(「我们来把你的出生时间缩小……」);给别人做时怎么称呼还没定,这次不改。
## 生时校正从对话进入(2026-10-07,BUG-1247~1251;2026-10-12,BUG-1306)
- **何时提议**:校正就在这个对话里能开。用户说推得不准时先分清读法问题和时间问题。只是不认同某个解释,按改读规则认下、改读,不提校正。不承诺能收窄出生时间,不在对话里收集经历做校正。说出过去的具体事情和盘对不上、说出生时间是估的或记不准、但没有明确要做校正,要先看过盘再提议。同一次对话提议过、用户没接,不再主动提;用户自己再要求时传 `userAsked=true`。还没看盘就顺口提议,不算已经提议过。规则只定义在 `consultation-thinking-plan.ts` 的 `RECTIFICATION_OFFER_RULE`。不用关键词判断。
- **明确要求则直接进入**:用户这一轮明确要做校正(校正、纠正、核对出生时间都算),由模型判断,不用关键词。第一步就可以打开,不用先排盘写解读。服务端放行后直接打开校正,不用点卡。过渡只有一句「好,我们开始核对出生时间。」已经采用过时间的,不算还在进行,明说要校正就新开一次。真没做完的,直接接着做那一次。这次如果接不回原来的会话,也直接新开。开案还没成功时,回答下面仍是普通提议卡,不说「已经开始」;打开失败可以再点「开始核对出生时间」。开案并绑定来源之后才改成「已经开始」。
- **做不了必须说一句**:开关关着,或暂时没有价格,都说「出生时间核对这会儿还做不了。」不写内部原因,不写价格,不得沉默。家人档案说「给家人校正暂时还不支持」,不开案。不再说「可在历史对话里接着做。」
- **做不了必须说一句**:开关关着,或暂时没有价格,都说「出生时间核对这会儿还做不了。」不写内部原因,不写价格,不得沉默。家人档案和本人一样能开(issue #160),不再说「给家人校正暂时还不支持」。不再说「可在历史对话里接着做。」
- **正文怎么提**(没有明确要求、只是时间可能不准):一句,放在最后一段(例:「如果想确认出生时间准不准,可以用下面的卡片,答几道过去经历的题核对一下。」),不写价格和题数——卡片上写。
- **卡片**:「可以用几道过去经历的题,核对一下你的出生时间。大约 N 题,会用掉 M 点;开始答第一题时才扣。」按钮「开始核对出生时间」;已开始(开案并绑定来源之后,不是结算当时)「出生时间核对已经开始。」/「继续核对出生时间」。点数来自 `feature_pricing`,不写死。
- **回来**:分隔线「出生时间改为 HH:MM(生时校正)」/「出生时间没有改」;之前的回答上方「按校正前的时间」;按钮「按新时间重新看:「原问题」」。校正面按钮「回到刚才的对话」。不再说「新建对话」。
@@ -509,7 +522,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
账户与点数的页签只用:会员套餐 / 点数包 / 兑换码 / 订单记录。购买中按钮写「跳转收银台…」,轮询中写「等待支付结果」,不要写「正在加载」。
星盘档案不写关系。人数到顶时只说「最多保存 20 个人的星盘」。当前人物不是本人时,生时校正入口禁用,提示「生时校正暂时只支持本人。」删除一个人之前必须写出「将同时删除 N 个对话和 M 份报告,无法恢复。」本人不能删除,不要给删除按钮配一句绕过去的确认。
星盘档案不写关系。人数到顶时只说「最多保存 20 个人的星盘」。生时校正对档案里的每个人都能用(issue #160),不再禁用入口,也不再写「生时校正暂时只支持本人。」删除一个人之前必须写出「将同时删除 N 个对话和 M 份报告,无法恢复。」这个数里也算上 TA 的生时校正对话:删人时 TA 的校正记录一起删除,扣过的点不退,和删除对话一样,不另写一句。本人不能删除,不要给删除按钮配一句绕过去的确认。
余额不足时输入框占位写「余额不足,可在账户与点数中补充」,不要叫用户去会员页。
+3 -5
View File
@@ -26,7 +26,6 @@ import {
rectificationOrdinaryComposerReopen,
} from "@/lib/rectification-session-composer-guard";
import { ConversationalBirthTimeRectification } from "@/components/conversational-birth-time-rectification-lazy";
import { declaredBirthTime } from "@/lib/rectification-surface-state";
import { toggleChatMessageFeedback } from "@/components/chat-message-actions";
import type { ChatTranscriptActions } from "@/components/chat-transcript";
import { ChatStreamingTranscript, useStreamingReplyHasText } from "@/components/chat-streaming-transcript";
@@ -431,7 +430,6 @@ export default function Home() {
: activeReplyOutcome?.phase ?? "idle";
const replyAnnouncement = chatReplyAnnouncement(replyPhase, activeReplyOutcome?.replyOrdinal ?? 0);
const accountId = account?.user.id;
const rectificationDeclaration = { declaredTime: declaredBirthTime(profile), birthDate: profile.date || null };
const rectificationErrorMessage = rectificationError === "profile_incomplete"
? "服务端未能读取完整出生资料,请重新确认并保存后再开始生时校正。"
: rectificationError;
@@ -466,7 +464,7 @@ export default function Home() {
});
const {
setRectificationHeaderSlot, rectificationPanel, resumeRectificationSession,
setRectificationHeaderSlot, rectificationPanel, rectificationDeclaration, handleRectificationSaved, resumeRectificationSession,
openRectificationSession, reopenRectificationSession,
} = useRectificationSurface({
account, activeSessionIdRef, chartLibrary, creatingSession, modelCatalog,
@@ -1339,7 +1337,7 @@ export default function Home() {
selectedModelId={activeSession?.modelId ?? ""}
onSelectModel={(modelId) => void selectSessionModel(modelId)}
onOpenBilling={(options) => openAccountDialog("billing", { source: options?.source ?? "rectification" })}
onSaved={() => void refreshAccount()}
onSaved={handleRectificationSaved}
panel={rectificationPanel}
/>
)}
@@ -1355,7 +1353,7 @@ export default function Home() {
chartSignsFallback={dailyStarlanguage.kind === "unavailable"}
onAsk={(text) => void startSuggestedConsultation(text)}
onNudge={nudgeComposer}
rectifyHint={currentSubjectId === "self" && account.rectification === true && birthTimeNeedsRectification(profile)}
rectifyHint={account.rectification === true && Boolean(dailyProfile) && birthTimeNeedsRectification(dailyProfile ?? profile)}
/>
) : null}
{jumpToLatestVisible && (
+4 -2
View File
@@ -1035,17 +1035,19 @@ export async function POST(request: Request) {
const state = createConsultationRuntimeState({ plannedSteps: AGENT_MAX_STEPS });
latencyState = state;
// C2/C3 (BUG-1248): the offer tool asks the server, never decides itself.
// Issue #160: the person this conversation is about, owner or not.
const rectificationSubjectId = prepared.subject?.role === "other" ? prepared.subject.chartProfileId : null;
const rectificationOffer: RectificationOfferHook = {
decide: () => decideRectificationOffer({
accounting,
userId,
subjectRole: prepared.subject?.role === "other" ? "other" : "self",
subjectId: rectificationSubjectId,
modelId: selectedModel.id,
}),
alreadyOffered: sessionHasRectificationOffer(chatSession?.messages),
revisitQuestion: revisitQuestionForOffer(chatSession?.messages, visibleQuestion, requestId),
sourceSessionId: sessionId,
loadResumableSessionId: () => loadResumableRectificationSessionId(accounting, userId),
loadResumableSessionId: () => loadResumableRectificationSessionId(accounting, userId, rectificationSubjectId),
};
const hooks = createConsultationRuntimeHooks(state);
const usages: Promise<Usage>[] = [];
@@ -7,15 +7,18 @@ import {
getRectificationEntrySummary,
mapRectificationRpcError,
} from "@/lib/rectification-agentic/v9/case-service";
import { SELF_SUBJECT_ID, isChartSubjectId, subjectIdFromQuery } from "@/lib/subject-birth";
export const runtime = "nodejs";
/**
* GET /api/rectification/cases/entry-summary
* GET /api/rectification/cases/entry-summary[?subject=<chart_profiles id>]
*
* Server-truth context for homepage copy; the primary homepage action always creates.
* Server-truth context for the /rectification card, one person at a time
* (issue #160). No `subject` or `self` is the account owner; an id that is not
* this user's reads as "nothing on record" (the RPC filters by user).
*/
export async function GET() {
export async function GET(request: Request) {
let supabase;
let accounting;
try {
@@ -32,8 +35,17 @@ export async function GET() {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const subject = subjectIdFromQuery(new URL(request.url).searchParams.get("subject"));
if (subject !== SELF_SUBJECT_ID && !isChartSubjectId(subject)) {
return NextResponse.json({ error: "请求内容不正确", code: "invalid_subject" }, { status: 400 });
}
try {
return NextResponse.json(await getRectificationEntrySummary(accounting, user.id));
return NextResponse.json(await getRectificationEntrySummary(
accounting,
user.id,
subject === SELF_SUBJECT_ID ? null : subject,
));
} catch (error) {
if (error instanceof RectificationCaseServiceError) {
const view = mapRectificationRpcError(
+29 -2
View File
@@ -7091,6 +7091,32 @@ button.synastry-dd-add { border-radius: 0 0 var(--radius-md) var(--radius-md); }
color: var(--color-ink-tertiary);
font-size: var(--type-body-sm);
}
/* Whose chart (issue #160): the name, then the relation as a quiet tag.
One line; a long name is cut, the tag never wraps under it. */
.rectification-page-person {
display: flex;
align-items: center;
gap: var(--space-2);
min-width: 0;
margin: 0 0 var(--space-1);
white-space: nowrap;
}
.rectification-page-person-name {
overflow: hidden;
color: var(--color-ink);
font-size: var(--type-body-md);
font-weight: 500;
text-overflow: ellipsis;
}
.rectification-page-person-relation {
flex: none;
padding: 1px var(--space-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-ink-secondary);
font-size: var(--type-body-sm);
line-height: 1.5;
}
.rectification-page-label--done {
color: var(--color-success);
}
@@ -7197,8 +7223,9 @@ button.synastry-dd-add { border-radius: 0 0 var(--radius-md) var(--radius-md); }
}
}
/* Onboarding prompt under the starter questions (issue #145): self chart with an
estimated time only. A quiet row, not a card; the link goes to /rectification. */
/* Onboarding prompt under the starter questions (issue #145): the current
person's time is an estimate (anyone in the list since #160). A quiet row,
not a card; the link goes to /rectification. */
.starter-rectify-hint {
display: flex;
flex-wrap: wrap;
@@ -86,8 +86,8 @@ export function ChartPageRoute({ learnLinks }: { learnLinks?: ChartLearnLinks })
);
}
function ChartLagnaPrecheckNote({ value, self, learnLinks }: { value: ChartViewLagnaPrecheck; self: boolean; learnLinks?: ChartLearnLinks }) {
const line = lagnaPrecheckLine(value, { self });
function ChartLagnaPrecheckNote({ value, learnLinks }: { value: ChartViewLagnaPrecheck; learnLinks?: ChartLearnLinks }) {
const line = lagnaPrecheckLine(value);
// 「这个怎么看」 (issue #157): only while the ascendant can still change, and only when the article exists.
const learn = value.state === "fixed" ? null : chartLearnLink(learnLinks, "birth-time-uncertain");
return (
@@ -362,7 +362,7 @@ export function ChartPageView({
<Pencil aria-hidden="true" />
</Button>
{view.lagnaPrecheck ? (
<ChartLagnaPrecheckNote value={view.lagnaPrecheck} self={(view.subjectId ?? "self") === "self"} learnLinks={learnLinks} />
<ChartLagnaPrecheckNote value={view.lagnaPrecheck} learnLinks={learnLinks} />
) : null}
</>
)
@@ -7,12 +7,23 @@ import { AppLink } from "@/components/app-link";
import { SecondaryPageShell } from "@/components/secondary-page-shell";
import { Button, buttonVariants } from "@/components/ui/button";
import { useSessionList } from "@/hooks/use-session-list";
import { formatBirthMoment, profilePlaceLabel, readProfile, selfChartComplete } from "@/lib/home-profile";
import { useCurrentSubject, useCurrentSubjectProfile, useCurrentSubjectReady } from "@/lib/current-subject";
import {
formatBirthMoment,
missingOtherProfileStep,
profilePlaceLabel,
readProfile,
selfChartComplete,
} from "@/lib/home-profile";
import type { Profile } from "@/lib/home-types";
import { entrySummaryFromResponse, type RectificationEntrySummary } from "@/lib/rectification-entry";
import {
RECTIFICATION_PAGE_COPY as COPY,
evidenceCountLabel,
rectificationEntrySummaryPath,
rectificationPageState,
rectificationPersonFacts,
rectificationProfileHref,
rectificationSessionHref,
rectificationStartHref,
type RectificationPageState,
@@ -20,9 +31,11 @@ import {
import { cn } from "@/lib/utils";
type SummaryRead =
| { readonly status: "loading" }
| { readonly status: "failed" }
| { readonly status: "ready"; readonly summary: RectificationEntrySummary };
| { readonly status: "loading"; readonly subjectId: string }
| { readonly status: "failed"; readonly subjectId: string }
| { readonly status: "ready"; readonly subjectId: string; readonly summary: RectificationEntrySummary };
type PersonFacts = { readonly name: string; readonly relation: string };
const EMPTY_SUMMARY: RectificationEntrySummary = {
hasResumableCase: false,
@@ -31,13 +44,13 @@ const EMPTY_SUMMARY: RectificationEntrySummary = {
latestTerminal: null,
};
async function readEntrySummary(): Promise<SummaryRead> {
async function readEntrySummary(subjectId: string): Promise<SummaryRead> {
try {
const response = await fetch("/api/rectification/cases/entry-summary", { cache: "no-store" });
if (!response.ok) return { status: "failed" };
return { status: "ready", summary: entrySummaryFromResponse(await response.json()) };
const response = await fetch(rectificationEntrySummaryPath(subjectId), { cache: "no-store" });
if (!response.ok) return { status: "failed", subjectId };
return { status: "ready", subjectId, summary: entrySummaryFromResponse(await response.json()) };
} catch {
return { status: "failed" };
return { status: "failed", subjectId };
}
}
@@ -58,52 +71,77 @@ function PageLink({
);
}
/** 称呼 and 关系 of the person this page is for (issue #160). Data, not a caption. */
function PersonLine({ person }: { readonly person: PersonFacts }) {
return (
<p className="rectification-page-person">
<span className="rectification-page-person-name">{person.name}</span>
<span className="rectification-page-person-relation">{person.relation}</span>
</p>
);
}
export function RectificationEntryPage() {
const list = useSessionList();
const signedIn = Boolean(list.account) && !list.signedOut;
const enabled = list.account?.rectification;
const profile = readProfile(list.account?.profile);
const selfComplete = selfChartComplete(profile);
const [read, setRead] = useState<SummaryRead>({ status: "loading" });
const [checking, setChecking] = useState(false);
const needsSummary = signedIn && enabled === true && selfComplete;
const selfProfile = readProfile(list.account?.profile);
// Issue #160: the page is for the person chosen beside the title.
const subjectId = useCurrentSubject();
const forOther = subjectId !== "self";
const catalogReady = useCurrentSubjectReady();
const otherProfile = useCurrentSubjectProfile(selfProfile);
const profile: Profile | null = forOther ? otherProfile : selfProfile;
const profileComplete = profile
? (forOther ? missingOtherProfileStep(profile) === null : selfChartComplete(profile))
: false;
const person = rectificationPersonFacts(subjectId, profile);
const [read, setRead] = useState<SummaryRead>({ status: "loading", subjectId });
const [checking, setChecking] = useState<string | null>(null);
const needsSummary = signedIn && enabled === true && profileComplete;
// Signed out there is no one to choose.
const beside = signedIn ? undefined : <span />;
const load = useCallback(() => {
setRead({ status: "loading" });
void readEntrySummary().then(setRead);
}, []);
setRead({ status: "loading", subjectId });
void readEntrySummary(subjectId).then(setRead);
}, [subjectId]);
useEffect(() => {
if (!needsSummary) return;
let live = true;
void readEntrySummary().then((next) => {
void readEntrySummary(subjectId).then((next) => {
if (live) setRead(next);
});
return () => {
live = false;
};
}, [needsSummary]);
}, [needsSummary, subjectId]);
if (!list.settled && !list.signedOut) {
return <SecondaryPageShell title={COPY.title} besideTitle={<span />} waiting={COPY.waiting} />;
if ((!list.settled && !list.signedOut) || (signedIn && forOther && !catalogReady)) {
return <SecondaryPageShell title={COPY.title} besideTitle={beside} waiting={COPY.waiting} />;
}
if (needsSummary && read.status === "loading") {
return <SecondaryPageShell title={COPY.title} besideTitle={<span />} waiting={COPY.waiting} />;
// A summary read for the previous person never stands in for this one.
const current: SummaryRead = read.subjectId === subjectId ? read : { status: "loading", subjectId };
if (needsSummary && current.status === "loading") {
return <SecondaryPageShell title={COPY.title} besideTitle={beside} waiting={COPY.waiting} />;
}
const state: RectificationPageState = rectificationPageState({
signedOut: !signedIn,
enabled,
selfComplete,
summary: read.status === "ready" ? read.summary : EMPTY_SUMMARY,
profileComplete,
forOther,
summary: current.status === "ready" ? current.summary : EMPTY_SUMMARY,
});
const checkProfile = checking === subjectId && (state.kind === "none" || state.kind === "done") ? profile : null;
return (
<SecondaryPageShell title={COPY.title} besideTitle={<span />}>
<SecondaryPageShell title={COPY.title} besideTitle={beside}>
<div className="rectification-page">
{checking && (state.kind === "none" || state.kind === "done") ? (
{checkProfile ? (
<section className="rectification-page-check" aria-labelledby="rectification-check-title">
<Button type="button" variant="ghost" className="rectification-page-back" onClick={() => setChecking(false)}>
<Button type="button" variant="ghost" className="rectification-page-back" onClick={() => setChecking(null)}>
<ChevronLeft aria-hidden="true" />
{COPY.back}
</Button>
@@ -111,24 +149,40 @@ export function RectificationEntryPage() {
{COPY.checkTitle}
</h2>
<dl className="rectification-page-facts" data-onboarding-enter="rectification-facts" data-onboarding-index={1}>
<div>
<dt>{COPY.personName}</dt>
<dd>{person.name}</dd>
</div>
<div>
<dt>{COPY.personRelation}</dt>
<dd>{person.relation}</dd>
</div>
<div>
<dt>{COPY.birthTime}</dt>
<dd>{formatBirthMoment(profile)}</dd>
<dd>{formatBirthMoment(checkProfile)}</dd>
</div>
<div>
<dt>{COPY.birthPlace}</dt>
<dd>{profilePlaceLabel(profile)}</dd>
<dd>{profilePlaceLabel(checkProfile)}</dd>
</div>
</dl>
<div className="rectification-page-actions" data-onboarding-enter="rectification-check-actions" data-onboarding-index={2}>
<PageLink href={rectificationStartHref(state)}>{COPY.confirmStart}</PageLink>
<PageLink href="/people?self=1" variant="link">{COPY.editProfile}</PageLink>
<PageLink href={rectificationStartHref(state, subjectId)}>{COPY.confirmStart}</PageLink>
<PageLink href={rectificationProfileHref(subjectId)} variant="link">{COPY.editProfile}</PageLink>
</div>
</section>
) : (
<>
<p className="rectification-page-intro" data-onboarding-enter="rectification-intro" data-onboarding-index={0}>{COPY.intro}</p>
<StatusCard state={state} failed={needsSummary && read.status === "failed"} onRetry={load} onStart={() => setChecking(true)} />
<StatusCard
state={state}
subjectId={subjectId}
profile={profile}
person={signedIn && enabled === true ? person : null}
failed={needsSummary && current.status === "failed"}
onRetry={load}
onStart={() => setChecking(subjectId)}
/>
</>
)}
</div>
@@ -138,22 +192,29 @@ export function RectificationEntryPage() {
function StatusCard({
state,
subjectId,
profile,
person,
failed,
onRetry,
onStart,
}: {
readonly state: RectificationPageState;
readonly subjectId: string;
readonly profile: Profile | null;
readonly person: PersonFacts | null;
readonly failed: boolean;
readonly onRetry: () => void;
readonly onStart: () => void;
}) {
const list = useSessionList();
const profile = readProfile(list.account?.profile);
const enter = { "data-onboarding-enter": "rectification-status", "data-onboarding-index": 1 } as const;
const who = person ? <PersonLine person={person} /> : null;
const currentTimeLabel = subjectId === "self" ? COPY.currentTime : COPY.otherCurrentTime;
if (failed) {
return (
<section className="rectification-page-card" {...enter}>
{who}
<p className="rectification-page-note">{COPY.loadFailed}</p>
<div className="rectification-page-actions">
<Button type="button" variant="outline" onClick={onRetry}>{COPY.retry}</Button>
@@ -177,17 +238,20 @@ function StatusCard({
</section>
);
case "self-missing":
case "other-missing":
return (
<section className="rectification-page-card" {...enter}>
<p className="rectification-page-note">{COPY.selfMissing}</p>
<div className="rectification-page-actions"><PageLink href="/people?self=1">{COPY.fillSelf}</PageLink></div>
{who}
<p className="rectification-page-note">{state.kind === "self-missing" ? COPY.selfMissing : COPY.otherMissing}</p>
<div className="rectification-page-actions"><PageLink href={rectificationProfileHref(subjectId)}>{COPY.fillSelf}</PageLink></div>
</section>
);
case "none":
return (
<section className="rectification-page-card" aria-label={COPY.currentTime} {...enter}>
<p className="rectification-page-label">{COPY.currentTime}</p>
<p className="rectification-page-value">{formatBirthMoment(profile)}</p>
<section className="rectification-page-card" aria-label={currentTimeLabel} {...enter}>
{who}
<p className="rectification-page-label">{currentTimeLabel}</p>
<p className="rectification-page-value">{profile ? formatBirthMoment(profile) : null}</p>
<div className="rectification-page-actions">
<Button type="button" data-onboarding-press="" onClick={onStart}>{COPY.start}</Button>
</div>
@@ -196,6 +260,7 @@ function StatusCard({
case "active":
return (
<section className="rectification-page-card" aria-label={COPY.activeLabel} {...enter}>
{who}
<p className="rectification-page-label">{COPY.activeLabel}</p>
<dl className="rectification-page-stats">
{state.range ? (
@@ -212,18 +277,19 @@ function StatusCard({
) : null}
</dl>
<div className="rectification-page-actions">
<PageLink href={rectificationSessionHref(state.sessionId)}>{COPY.resume}</PageLink>
<PageLink href={rectificationSessionHref(state.sessionId, subjectId)}>{COPY.resume}</PageLink>
</div>
</section>
);
case "done":
return (
<section className="rectification-page-card" aria-label={COPY.doneLabel} {...enter}>
{who}
<p className="rectification-page-label rectification-page-label--done">{COPY.doneLabel}</p>
<p className="rectification-page-time">{state.time}</p>
<p className="rectification-page-note">{COPY.doneNote}</p>
<div className="rectification-page-actions">
{state.sessionId ? <PageLink href={rectificationSessionHref(state.sessionId)}>{COPY.viewResult}</PageLink> : null}
{state.sessionId ? <PageLink href={rectificationSessionHref(state.sessionId, subjectId)}>{COPY.viewResult}</PageLink> : null}
<Button type="button" variant="outline" data-onboarding-press="" onClick={onStart}>{COPY.again}</Button>
</div>
</section>
@@ -32,7 +32,7 @@ export function StarterQuestions({
readonly chartSignsFallback: boolean;
readonly onAsk: (question: string) => void;
readonly onNudge: () => void;
/** Self chart with an estimated time (issue #145): one line and a link to /rectification. */
/** The current person's time is an estimate (issue #145; anyone in the list since #160): one line and a link to /rectification. */
readonly rectifyHint?: boolean;
}) {
const questions = starterQuestions(audience, signs);
+95 -29
View File
@@ -6,10 +6,16 @@ import { resolveSessionTitle } from "@/lib/agent-reply";
import { fetchSessionDetail, mergeHydratedSession } from "@/lib/home-cloud-sync";
import { rectificationOpenIdentity } from "@/lib/rectification-session-open";
import { showChatNotice as setComposerNotice } from "@/lib/chat-notice";
import { RECTIFICATION_ENTRY_QUERY_KEY, rectificationEntryIntent, writeSessionUrl } from "@/lib/chat-session-url";
import {
RECTIFICATION_ENTRY_QUERY_KEY,
RECTIFICATION_SUBJECT_QUERY_KEY,
rectificationEntryIntent,
rectificationEntrySubject,
writeSessionUrl,
} from "@/lib/chat-session-url";
import type { ConsultationEntrypoint } from "@/lib/consultation-entrypoint";
import { readCurrentSubjectId } from "@/lib/current-subject";
import { chartSnapshotForSession, missingProfileStep } from "@/lib/home-profile";
import { peekSubjectCatalog, readCurrentSubjectId, refreshSubjectCatalog } from "@/lib/current-subject";
import { chartSnapshotForSession, missingOtherProfileStep, missingProfileStep } from "@/lib/home-profile";
import {
timestamp,
type Account,
@@ -35,6 +41,7 @@ import type {
} from "@/components/conversational-birth-time-rectification";
import type { PublicLanguageModelCatalog } from "@/lib/public-models";
import {
declaredBirthTime,
hydrateRectificationCase,
parsePersistedRectificationTurns,
RECTIFICATION_HYDRATION_INCOMPLETE_NOTICE,
@@ -46,6 +53,7 @@ import {
import { notifyError } from "@/lib/notify";
import { bindOfferToCase } from "@/lib/rectification-in-chat";
import { loadRectificationSurface } from "@/lib/rectification-surface-loader";
import { invalidateChartPage, invalidateEphemerisPage } from "@/lib/secondary-page-data";
export type RectificationShellState = {
sessionId: string | null;
@@ -127,9 +135,25 @@ export type RectificationSurfaceParams = {
rectificationSessionOpenerRef: MutableRefObject<(exactSessionId: string) => Promise<void>>;
};
/** The live openers. `/?rectify=1` queues one homepage open, `/?rectify=new` one new case, after the hook is ready. */
let latestChartRectifyStart: () => Promise<unknown> = async () => undefined;
let latestNewRectifyStart: () => Promise<unknown> = async () => undefined;
/**
* The live openers. `/?rectify=1` queues one homepage open, `/?rectify=new` one
* new case, after the hook is ready; `&rectifyFor=<id>` names the person
* (issue #160), otherwise it is the account owner.
*/
let latestChartRectifyStart: (subjectId: string) => Promise<unknown> = async () => undefined;
let latestNewRectifyStart: (subjectId: string) => Promise<unknown> = async () => undefined;
const OTHER_PROFILE_INCOMPLETE_NOTICE = "请先把 TA 的出生资料填完整,再开始生时校正。";
/**
* The home chart library, plus the shared people catalog when the library has
* not loaded this person yet (a queued `/?rectify=1&rectifyFor=` open can run
* before it does).
*/
function libraryWith(chartLibrary: readonly ChartLibraryRecord[], subjectId: string): readonly ChartLibraryRecord[] {
if (subjectId === "self" || chartLibrary.some((record) => record.id === subjectId)) return chartLibrary;
return [...chartLibrary, ...(peekSubjectCatalog() ?? []).filter((record) => record.id === subjectId)];
}
export function useRectificationSurface(params: RectificationSurfaceParams) {
const {
@@ -177,6 +201,8 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
// cases started from history or before 2026-10-07.
const [rectificationSource, setRectificationSource] = useState<{ caseId: string; sessionId: string } | null>(null);
const [rectificationReadonly, setRectificationReadonly] = useState(false);
// Whose chart the open case is (issue #160): "self" or a chart_profiles id.
const [rectificationSubjectId, setRectificationSubjectId] = useState("self");
const [rectificationShouldStartOpening, setRectificationShouldStartOpening] = useState(false);
const [rectificationTurns, setRectificationTurns] = useState<PersistedRectificationTurn[]>([]);
const [rectificationSnapshot, setRectificationSnapshot] = useState<RectificationCaseSnapshotPayload | null>(null);
@@ -214,14 +240,27 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
intent: "homepage" | "session" | "new",
exactSessionId: string | null,
sourceSessionId: string | null = null,
subjectId = "self",
) {
if (!account || !modelCatalog || creatingSession || rectificationLoading || rectificationOpenInFlight.current
|| rectificationMutationPending) {
sessionSelectionSource.current = "user";
return null;
}
// Another person (issue #160): a known incomplete record stops here. The
// server reads that person itself and answers profile_incomplete otherwise.
const forOther = intent !== "session" && subjectId !== "self";
const otherRecord = forOther
? libraryWith(chartLibrary, subjectId).find((record) => record.id === subjectId && record.role === "other")
: undefined;
if (otherRecord && missingOtherProfileStep(otherRecord.profile) !== null) {
sessionSelectionSource.current = "user";
setComposerNotice(OTHER_PROFILE_INCOMPLETE_NOTICE);
return null;
}
// The owner's onboarding gate; another person's data was checked above.
const missingStep = missingProfileStep(profile, chartLibrary);
if (missingStep) {
if (missingStep && !forOther) {
sessionSelectionSource.current = "user";
setRectificationSessionId(null);
setRectificationCaseId(null);
@@ -253,12 +292,14 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
const response = await fetch("/api/rectification/cases/open", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(openRectificationRequestBody(intent, exactSessionId, sourceSessionId)),
body: JSON.stringify(openRectificationRequestBody(intent, exactSessionId, sourceSessionId, subjectId)),
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
const code = payload?.code;
if (code === "profile_incomplete") {
if (code === "profile_incomplete" && forOther) {
assignOpenError(OTHER_PROFILE_INCOMPLETE_NOTICE);
} else if (code === "profile_incomplete") {
assignOpenError("profile_incomplete");
handleRectificationProfileIncomplete();
} else if (code === "invalid_open_request") {
@@ -282,6 +323,8 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
// Merge the server-created session into the local list. The browser
// never generates a Case id; it only mirrors the returned binding.
const existing = sessions.find((session) => session.id === opened.sessionId);
// The server says whose case it is; an older server only answers for the owner.
const caseSubjectId = opened.subjectId ?? (intent === "session" ? "self" : subjectId);
const openIdentity = rectificationOpenIdentity(existing, {
title: resolveSessionTitle("生时校正", undefined, {
entrypoint: "birth_time_rectification",
@@ -302,7 +345,7 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
pinned: existing?.pinned ?? false,
archivedAt: existing?.archivedAt ?? null,
messagesHydrated: true,
...chartSnapshotForSession("self", chartLibrary, profile),
...chartSnapshotForSession(caseSubjectId, libraryWith(chartLibrary, caseSubjectId), profile),
};
setSessions((current) => [merged, ...current.filter((session) => session.id !== merged.id)]);
void persistSession(merged).catch(() => {});
@@ -344,6 +387,7 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
setDraftEntrypoint(null);
setRectificationSessionId(opened.sessionId);
setRectificationCaseId(opened.caseId);
setRectificationSubjectId(caseSubjectId);
setRectificationShouldStartOpening(opened.shouldStartOpening);
setRectificationReadonly(
opened.disposition === "readonly" || isTerminalRectificationStatus(opened.status),
@@ -369,17 +413,13 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
}
}
function rectificationLimitedToSelf(): boolean {
if (readCurrentSubjectId() === "self") return false;
notifyError("生时校正暂时只支持本人。");
setRectificationError("生时校正暂时只支持本人。");
return true;
}
/** C4: the offer card under a consultation answer starts (or resumes) the case. */
/**
* C4: the offer card under a consultation answer starts (or resumes) the
* case, for the person the conversation is about (issue #160: anyone in the
* profile list, not only the owner).
*/
async function openRectificationFromChat(sourceSessionId: string) {
if (rectificationLimitedToSelf()) return null;
return openRectificationCase("homepage", null, sourceSessionId);
return openRectificationCase("homepage", null, sourceSessionId, readCurrentSubjectId());
}
async function openRectificationSession(exactSessionId: string) {
@@ -431,15 +471,15 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
await openRectificationSession(exactSessionId);
}
async function startNewRectification() {
if (rectificationLimitedToSelf()) return;
await openRectificationCase("new", null, null);
/** 再校正一次: a fresh case for the same person as the one on screen, else the given one. */
async function startNewRectification(subjectId?: string) {
const target = subjectId ?? (rectificationCaseId ? rectificationSubjectId : readCurrentSubjectId());
await openRectificationCase("new", null, null, target);
}
/** Chart page link. Homepage resumes a case already open; it does not raise the new-case conflict. */
async function openChartPageRectification() {
if (rectificationLimitedToSelf()) return;
await openRectificationCase("homepage", null, null);
/** `/?rectify=1` link. Homepage resumes a case already open; it does not raise the new-case conflict. */
async function openChartPageRectification(subjectId = "self") {
await openRectificationCase("homepage", null, null, subjectId);
}
function handleRectificationProfileIncomplete() {
@@ -482,16 +522,40 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
const search = globalThis.location?.search ?? "";
const intent = rectificationEntryIntent(search);
if (!intent) return;
const subjectId = rectificationEntrySubject(search);
chartRectifyConsumed.current = true;
const url = new URL(globalThis.location.href);
url.searchParams.delete(RECTIFICATION_ENTRY_QUERY_KEY);
url.searchParams.delete(RECTIFICATION_SUBJECT_QUERY_KEY);
globalThis.history.replaceState(null, "", `${url.pathname}${url.search}`);
sessionSelectionSource.current = "user";
queueMicrotask(() => {
void (intent === "new" ? latestNewRectifyStart() : latestChartRectifyStart());
void (intent === "new" ? latestNewRectifyStart(subjectId) : latestChartRectifyStart(subjectId));
});
});
/**
* After an adoption or a finished case. The owner's time lives on the
* account; another person's adopted time was written to that person's own
* row (issue #160), so the people list and the chart caches are re-read.
*/
function handleRectificationSaved() {
void refreshAccount();
if (rectificationSubjectId === "self") return;
void refreshSubjectCatalog().catch(() => undefined);
invalidateChartPage();
invalidateEphemerisPage();
}
// The surface's declared time and date are the case's person's, not always the owner's (issue #160).
const rectificationPerson = rectificationSubjectId === "self"
? profile
: libraryWith(chartLibrary, rectificationSubjectId).find((record) => record.id === rectificationSubjectId)?.profile ?? profile;
const rectificationDeclaration = {
declaredTime: declaredBirthTime(rectificationPerson),
birthDate: rectificationPerson.date || null,
};
const rectificationPanel: RectificationChatPanel = {
caseId: rectificationCaseId ?? "",
sessionId: rectificationSessionId ?? "",
@@ -504,7 +568,7 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
headerSlot: rectificationHeaderSlot,
onMessagesChange: handleRectificationMessagesChange,
onCompleted: () => {
void refreshAccount();
handleRectificationSaved();
if (rectificationCaseId && rectificationSessionId) {
void refreshRectificationCase(rectificationCaseId, rectificationSessionId);
}
@@ -519,6 +583,8 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
rectificationHeaderSlot,
setRectificationHeaderSlot,
rectificationPanel,
rectificationDeclaration,
handleRectificationSaved,
resumeRectificationSession,
refreshRectificationEntrySummary,
refreshRectificationCase,
+8 -5
View File
@@ -61,9 +61,12 @@ export function parseChartSubjectWrite(value: unknown): { ok: true; write: Chart
return { ok: false, error: "不能直接写入排盘采用时间" };
}
const statusRaw = text(profile.birthTimeStatus ?? profile.birth_time_status, 40);
if (statusRaw === "accepted" || statusRaw === "confirmed") {
return { ok: false, error: "不能把出生时间标成已采用" };
}
// Issue #160: an adopted or confirmed status is written only by the
// rectification RPC. A form that echoes it back (editing someone whose time
// was adopted) leaves the stored status alone instead of failing the save;
// a new declaration still drops it in the database
// (chart_profiles_zz_drop_adopted_on_declaration).
const adoptedEcho = statusRaw === "accepted" || statusRaw === "confirmed";
const name = text(profile.name, 80);
if (!name) return { ok: false, error: "请填写称呼" };
const birthDate = date(profile.date ?? profile.birth_date);
@@ -81,7 +84,7 @@ export function parseChartSubjectWrite(value: unknown): { ok: true; write: Chart
const timezoneOffset = finite(profile.timezoneOffset ?? profile.timezone_offset);
const timezoneId = text(profile.timezoneId ?? profile.timezone_id, 80);
const label = text(profile.birthPlaceLabel ?? profile.birth_place_label, 160);
const status = oneOf(statusRaw, STATUSES) ?? (reported ? "reported" : null);
const status = adoptedEcho ? null : oneOf(statusRaw, STATUSES) ?? (reported ? "reported" : null);
const ayanamsa = isAyanamsaName(profile.ayanamsa) ? profile.ayanamsa : resolveAyanamsa(profile);
// Optional gender: written only when the client sends the key, so a caller
// that does not know the field never clears it. null / "" clear it.
@@ -115,7 +118,7 @@ export function parseChartSubjectWrite(value: unknown): { ok: true; write: Chart
birth_place_provider: text(profile.birthPlaceProvider ?? profile.birth_place_provider, 40) || null,
birth_place_provider_id: text(profile.birthPlaceProviderId ?? profile.birth_place_provider_id, 120) || null,
ayanamsa,
birth_time_status: status,
...(adoptedEcho ? {} : { birth_time_status: status }),
...(genderSent ? { gender } : {}),
...(relationSent ? { relation } : {}),
};
+17 -4
View File
@@ -2,6 +2,8 @@ export const SESSION_URL_QUERY_KEY = "c";
export const NEW_CHAT_QUERY_KEY = "new";
/** Chart-page ascendant note. Value must be exactly "1". Home opens the self rectification case. */
export const RECTIFICATION_ENTRY_QUERY_KEY = "rectify";
/** Whose chart the queued open is for: a chart_profiles id. Absent = the account owner (issue #160). */
export const RECTIFICATION_SUBJECT_QUERY_KEY = "rectifyFor";
/**
* Legacy key only. Until BUG-1052 a 401 or a secondary-page link stashed the
* current `?c=` here and a bare `/` reopened it. Nothing writes it any more;
@@ -43,11 +45,22 @@ export const RECTIFICATION_PAGE_HREF = "/rectification";
/**
* Home URL that opens the rectification surface. `homepage` resumes an open
* case or creates one; `new` starts a fresh case ("再校正一次"). The case is
* still the self profile only.
* case or creates one; `new` starts a fresh case ("再校正一次"). `subjectId`
* is the person the case is for; "self" or nothing is the account owner
* (issue #160).
*/
export function rectificationEntryHref(intent: "homepage" | "new" = "homepage"): string {
return `/?${RECTIFICATION_ENTRY_QUERY_KEY}=${intent === "new" ? "new" : "1"}`;
export function rectificationEntryHref(intent: "homepage" | "new" = "homepage", subjectId = "self"): string {
const base = `/?${RECTIFICATION_ENTRY_QUERY_KEY}=${intent === "new" ? "new" : "1"}`;
return SESSION_URL_ID_PATTERN.test(subjectId)
? `${base}&${RECTIFICATION_SUBJECT_QUERY_KEY}=${encodeURIComponent(subjectId)}`
: base;
}
/** The person a queued `?rectify=` open is for: a chart_profiles id, or "self". */
export function rectificationEntrySubject(search: string): string {
const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
const value = params.get(RECTIFICATION_SUBJECT_QUERY_KEY) ?? "";
return SESSION_URL_ID_PATTERN.test(value) ? value : "self";
}
export function rectificationEntryIntent(search: string): "homepage" | "new" | null {
@@ -1,88 +1,90 @@
export const RECTIFICATION_CASE_STATUSES = [
"draft",
"collecting_evidence",
"candidate_ready",
"candidate_accepted",
"needs_rebaseline",
"paused",
"confirmed",
"closed",
"abandoned",
"superseded",
] as const;
export type RectificationCaseStatus = (typeof RECTIFICATION_CASE_STATUSES)[number];
/** Statuses that may be resumed by the user. */
export const RESUMABLE_CASE_STATUSES: readonly RectificationCaseStatus[] = [
"draft",
"collecting_evidence",
"candidate_ready",
"candidate_accepted",
"needs_rebaseline",
"paused",
];
/** Statuses that only allow read-only history access. */
export const TERMINAL_CASE_STATUSES: readonly RectificationCaseStatus[] = [
"confirmed",
"closed",
"abandoned",
"superseded",
];
export type OpenRectificationDisposition = "created" | "resumed" | "readonly";
/** Wire shape. Status stays a string so cached and older payloads still parse. */
export type OpenRectificationCaseResponseWire = Readonly<{
disposition: OpenRectificationDisposition;
caseId: string;
sessionId: string;
status: string;
shouldStartOpening: boolean;
skillVersion: string;
}>;
export type OpenRectificationCaseResponse = Omit<OpenRectificationCaseResponseWire, "status"> & {
readonly status: RectificationCaseStatus;
};
/** Range, count and times feed the /rectification status card (issue #145); older payloads omit them. */
export type RectificationEntryRange = Readonly<{ startTime: string; endTime: string }>;
type LatestResumableWire = Readonly<{
caseId: string;
status: string;
lastActivityAt: string;
sessionId?: string | null;
candidateRange?: RectificationEntryRange | null;
acceptedTime?: string | null;
evidenceCount?: number | null;
}>;
type LatestTerminalWire = Readonly<{
caseId: string;
status: string;
hasUsableTime: boolean;
sessionId?: string | null;
acceptedTime?: string | null;
confirmedTime?: string | null;
}>;
export type RectificationEntrySummaryWire = Readonly<{
hasResumableCase: boolean;
hasTerminalCaseWithTime: boolean;
latestResumable: LatestResumableWire | null;
latestTerminal: LatestTerminalWire | null;
}>;
export type RectificationEntrySummary = Readonly<{
hasResumableCase: boolean;
hasTerminalCaseWithTime: boolean;
latestResumable: (Omit<LatestResumableWire, "status"> & {
readonly status: RectificationCaseStatus;
}) | null;
latestTerminal: (Omit<LatestTerminalWire, "status"> & {
readonly status: RectificationCaseStatus;
}) | null;
}>;
export const RECTIFICATION_CASE_STATUSES = [
"draft",
"collecting_evidence",
"candidate_ready",
"candidate_accepted",
"needs_rebaseline",
"paused",
"confirmed",
"closed",
"abandoned",
"superseded",
] as const;
export type RectificationCaseStatus = (typeof RECTIFICATION_CASE_STATUSES)[number];
/** Statuses that may be resumed by the user. */
export const RESUMABLE_CASE_STATUSES: readonly RectificationCaseStatus[] = [
"draft",
"collecting_evidence",
"candidate_ready",
"candidate_accepted",
"needs_rebaseline",
"paused",
];
/** Statuses that only allow read-only history access. */
export const TERMINAL_CASE_STATUSES: readonly RectificationCaseStatus[] = [
"confirmed",
"closed",
"abandoned",
"superseded",
];
export type OpenRectificationDisposition = "created" | "resumed" | "readonly";
/** Wire shape. Status stays a string so cached and older payloads still parse. */
export type OpenRectificationCaseResponseWire = Readonly<{
disposition: OpenRectificationDisposition;
caseId: string;
sessionId: string;
status: string;
shouldStartOpening: boolean;
skillVersion: string;
/** Whose chart (issue #160): a chart_profiles id, or null for the account owner. Older payloads omit it. */
subjectId?: string | null;
}>;
export type OpenRectificationCaseResponse = Omit<OpenRectificationCaseResponseWire, "status"> & {
readonly status: RectificationCaseStatus;
};
/** Range, count and times feed the /rectification status card (issue #145); older payloads omit them. */
export type RectificationEntryRange = Readonly<{ startTime: string; endTime: string }>;
type LatestResumableWire = Readonly<{
caseId: string;
status: string;
lastActivityAt: string;
sessionId?: string | null;
candidateRange?: RectificationEntryRange | null;
acceptedTime?: string | null;
evidenceCount?: number | null;
}>;
type LatestTerminalWire = Readonly<{
caseId: string;
status: string;
hasUsableTime: boolean;
sessionId?: string | null;
acceptedTime?: string | null;
confirmedTime?: string | null;
}>;
export type RectificationEntrySummaryWire = Readonly<{
hasResumableCase: boolean;
hasTerminalCaseWithTime: boolean;
latestResumable: LatestResumableWire | null;
latestTerminal: LatestTerminalWire | null;
}>;
export type RectificationEntrySummary = Readonly<{
hasResumableCase: boolean;
hasTerminalCaseWithTime: boolean;
latestResumable: (Omit<LatestResumableWire, "status"> & {
readonly status: RectificationCaseStatus;
}) | null;
latestTerminal: (Omit<LatestTerminalWire, "status"> & {
readonly status: RectificationCaseStatus;
}) | null;
}>;
+6 -9
View File
@@ -177,14 +177,13 @@ function signList(signs: readonly string[]): string {
return signs.map((sign) => `${sign}座`).join("、");
}
/** Header sentence. `linked` is the words 「出生时间校正」. A fixed ascendant has no link. Someone else's chart never links. */
export function lagnaPrecheckLine(
value: ChartViewLagnaPrecheck,
audience: { readonly self?: boolean } = {},
): LagnaPrecheckLine {
const self = audience.self !== false;
/**
* Header sentence. `linked` is the words 「出生时间校正」. A fixed ascendant has
* no link. Since issue #160 someone else's chart links too: /rectification
* opens for the same current person.
*/
export function lagnaPrecheckLine(value: ChartViewLagnaPrecheck): LagnaPrecheckLine {
if (value.state === "unknown_time") {
if (!self) return { before: "不知道出生时间时,上升星座定不下来。", linked: false, after: "" };
return {
before: "不知道出生时间时,上升星座定不下来;做一次",
linked: true,
@@ -201,7 +200,6 @@ export function lagnaPrecheckLine(
}
if (value.signs.length >= 3) {
const listed = `按你给的时间段,上升可能落在${signList(value.signs)}中的一个。`;
if (!self) return { before: listed, linked: false, after: "" };
return { before: `${listed}想定下来可以做一次`, linked: true, after: "。" };
}
const [first, second] = value.signs;
@@ -211,6 +209,5 @@ export function lagnaPrecheckLine(
? `按你给的时间段(${value.periodStart}–${value.periodEnd})`
: "按你给的时间精度";
const signs = `${lead},上升可能是${first ?? ""}座,也可能是${second ?? ""}座${change}。`;
if (!self) return { before: signs, linked: false, after: "" };
return { before: `${signs}想定下来可以做一次`, linked: true, after: "。" };
}
@@ -126,7 +126,7 @@ export async function mutateCaseForWidenWindow(input: {
});
const source = input.dossier.case.birthTimeSource;
if (source !== "hospital_record") {
await patchV9BirthUncertainty(input.accounting, input.userId, {
await patchV9BirthUncertainty(input.accounting, input.userId, input.caseId, {
birth_time_source: "approximate",
uncertainty_before_minutes: chosen.radius,
uncertainty_after_minutes: chosen.radius,
@@ -37,6 +37,7 @@ import {
stageForClockWindow,
} from "./search-window.ts";
import {
openRequestSubjectId,
openResponse,
type OpenRectificationCaseRequest,
type OpenRectificationCaseResponse,
@@ -213,18 +214,50 @@ function baselineFingerprint(baseline: V9BaselineSnapshot): string {
return createHash("sha256").update(canonical).digest("hex");
}
const OWNER_BIRTH_COLUMNS =
"birth_date,birth_place_label,reported_birth_time,birth_time_source,birth_time_period,birth_time_clue,declared_window_start,declared_window_end,uncertainty_before_minutes,uncertainty_after_minutes,latitude,longitude,timezone_id,timezone_offset,ayanamsa";
/** chart_profiles has no birth_time_clue column; the rest have the same names (issue #160). */
const SUBJECT_BIRTH_COLUMNS =
"birth_date,birth_place_label,reported_birth_time,birth_time_source,birth_time_period,declared_window_start,declared_window_end,uncertainty_before_minutes,uncertainty_after_minutes,latitude,longitude,timezone_id,timezone_offset,ayanamsa";
async function readBirthRow(
accounting: AccountingClient,
userId: string,
subjectId: string | null,
): Promise<unknown> {
if (!subjectId) {
const { data, error } = await accounting
.from("profiles")
.select(OWNER_BIRTH_COLUMNS)
.eq("id", userId)
.single();
if (error || !data) throw new RectificationCaseServiceError("profile_unavailable");
return data;
}
// Another person in the account's list. The owner check is in the query and
// again in open_agentic_rectification_case_v3.
const { data, error } = await accounting
.from("chart_profiles")
.select(SUBJECT_BIRTH_COLUMNS)
.eq("id", subjectId)
.eq("user_id", userId)
.eq("role", "other")
.maybeSingle();
if (error) throw new RectificationCaseServiceError("profile_unavailable");
if (!data) throw new RectificationCaseServiceError("subject_not_found");
return data;
}
/**
* Baseline for a new case. `subjectId` null reads the account owner
* (public.profiles); otherwise that chart_profiles row of the same user.
*/
export async function loadV9RectificationProfile(
accounting: AccountingClient,
userId: string,
subjectId: string | null = null,
): Promise<V9RectificationProfile> {
const { data, error } = await accounting
.from("profiles")
.select(
"birth_date,birth_place_label,reported_birth_time,birth_time_source,birth_time_period,birth_time_clue,declared_window_start,declared_window_end,uncertainty_before_minutes,uncertainty_after_minutes,latitude,longitude,timezone_id,timezone_offset,ayanamsa",
)
.eq("id", userId)
.single();
if (error || !data) throw new RectificationCaseServiceError("profile_unavailable");
const data = await readBirthRow(accounting, userId, subjectId);
let resolvedData: unknown;
try {
@@ -308,6 +341,8 @@ const KNOWN_RPC_ERROR_CODES = new Map<string, { status: number; code: string; me
["agentic_rectification_source_session_not_consultation", { status: 400, code: "source_session_not_consultation", message: "来源不是咨询对话" }],
["agentic_rectification_source_session_invalid_theme", { status: 400, code: "source_session_invalid_theme", message: "来源对话主题不可用于校正" }],
["agentic_rectification_profile_incomplete", { status: 422, code: "profile_incomplete", message: "出生资料不完整" }],
["agentic_rectification_subject_not_found", { status: 404, code: "subject_not_found", message: "这个人的资料不存在或已删除" }],
["agentic_rectification_subject_mismatch", { status: 409, code: "subject_mismatch", message: "这次校正不是这个人的" }],
["agentic_rectification_active_case_conflict", { status: 409, code: "active_case_conflict", message: "仍有未完成的校正,请先继续或明确结束当前校正" }],
["agentic_rectification_session_not_found", { status: 404, code: "case_session_not_found", message: "校正会话不存在或无权访问" }],
["agentic_rectification_session_not_rectification", { status: 400, code: "session_not_rectification", message: "该会话不是生时校正会话" }],
@@ -441,8 +476,9 @@ export async function openRectificationCase(
productDomain = "general",
): Promise<OpenRectificationCaseResponse> {
let profile: V9RectificationProfile | null = null;
const subjectId = openRequestSubjectId(request);
if (request.intent === "homepage" || request.intent === "new") {
profile = await loadV9RectificationProfile(accounting, userId);
profile = await loadV9RectificationProfile(accounting, userId, subjectId);
}
const activeSkill = resolveActiveSkillPackage(RECTIFICATION_SKILL_NAME);
@@ -452,7 +488,7 @@ export async function openRectificationCase(
const skill = request.intent === "session"
? await resolveBoundSkillForSessionOpen(accounting, userId, request, activeSkill)
: activeSkill;
const { data, error } = await accounting.rpc("open_agentic_rectification_case_v2", {
const args = {
p_user_id: userId,
p_request_id: request.requestId,
p_intent: request.intent,
@@ -465,7 +501,18 @@ export async function openRectificationCase(
p_baseline_birth_snapshot: profile?.baseline ?? {},
p_candidate_range: profile?.candidateRange ?? { start_time: "00:00", end_time: "23:59" },
...(request.intent !== "session" ? { p_product_domain: productDomain } : {}),
});
};
// Issue #160. Creating for another person goes through v3, which checks the
// person and binds the new case and its conversation to them. Reopening an
// exact session also goes through v3 with the same arguments as before (no
// domain, no person), so the answer says whose case it is. Creating for the
// owner keeps the previous call.
const { data, error } = subjectId || request.intent === "session"
? await accounting.rpc("open_agentic_rectification_case_v3", {
...args,
...(subjectId ? { p_chart_profile_id: subjectId } : {}),
})
: await accounting.rpc("open_agentic_rectification_case_v2", args);
if (error) throw new RectificationCaseServiceError(mapRectificationRpcError(error).code);
const response = openResponse(readRpcData(data));
if (!response) throw new RectificationCaseServiceError("invalid_open_response");
@@ -480,13 +527,20 @@ export async function openRectificationCase(
return response;
}
/** One person's cases (issue #160, 1A): `subjectId` null is the account owner. */
export async function getRectificationEntrySummary(
accounting: AccountingClient,
userId: string,
subjectId: string | null = null,
): Promise<RectificationEntrySummary> {
const { data, error } = await accounting.rpc("get_agentic_rectification_entry_summary", {
p_user_id: userId,
});
const { data, error } = subjectId
? await accounting.rpc("get_agentic_rectification_entry_summary_v2", {
p_user_id: userId,
p_chart_profile_id: subjectId,
})
: await accounting.rpc("get_agentic_rectification_entry_summary", {
p_user_id: userId,
});
if (error) throw new RectificationCaseServiceError(mapRectificationRpcError(error).code);
const row = readRpcData(data);
if (!row || typeof row !== "object") throw new RectificationCaseServiceError("invalid_entry_summary");
@@ -2,8 +2,9 @@
* V9 case open request/response contracts.
*
* The browser submits only an intent + idempotency key (+ exact sessionId for
* the session intent, or an owned sourceSessionId for a consultation handoff).
* The server owns profile normalization, baseline
* the session intent, or an owned sourceSessionId for a consultation handoff,
* or the chart_profiles id of the person when it is not the account owner,
* issue #160). The server owns profile normalization, baseline
* snapshot/fingerprint generation, candidate range derivation and every
* disposition decision.
*/
@@ -40,6 +41,7 @@ export const openRectificationCaseRequestSchema = z.discriminatedUnion(
intent: z.literal("homepage"),
requestId: z.string().uuid(),
sourceSessionId: z.string().uuid().optional(),
subjectId: z.string().uuid().optional(),
})
.strict(),
z
@@ -54,6 +56,7 @@ export const openRectificationCaseRequestSchema = z.discriminatedUnion(
intent: z.literal("new"),
requestId: z.string().uuid(),
supersedeActive: z.literal(false).optional(),
subjectId: z.string().uuid().optional(),
})
.strict(),
],
@@ -98,6 +101,11 @@ export function shouldStartOpening(
return turnCount === 0;
}
/** The person a create request is for: a chart_profiles id, or null for the account owner. */
export function openRequestSubjectId(request: OpenRectificationCaseRequest): string | null {
return request.intent === "session" ? null : request.subjectId ?? null;
}
export function openResponse(
value: unknown,
): OpenRectificationCaseResponse | null {
@@ -123,6 +131,7 @@ export function openResponse(
status: row.status,
shouldStartOpening: row.should_start_opening === true,
skillVersion,
subjectId: typeof row.chart_profile_id === "string" && row.chart_profile_id ? row.chart_profile_id : null,
};
}
@@ -1681,29 +1681,27 @@ export async function setV9EvidenceDateReliability(
});
}
/**
* Widening the window also widens the declared uncertainty of the person the
* case belongs to: the owner's profile, or that chart_profiles row (issue
* #160). The server picks the row from the case, never from the caller.
*/
export async function patchV9BirthUncertainty(
accounting: AccountingClient & {
from?: (table: string) => {
update: (values: Record<string, unknown>) => {
eq: (column: string, value: string) => PromiseLike<{ error: { message: string } | null }>;
};
};
},
accounting: AccountingClient,
userId: string,
caseId: string,
patch: {
birth_time_source: "approximate";
uncertainty_before_minutes: number;
uncertainty_after_minutes: number;
},
): Promise<void> {
if (typeof accounting.from !== "function") {
throw new RectificationToolServiceError("profile_patch_unavailable");
}
const result = await accounting.from("profiles").update({
birth_time_source: patch.birth_time_source,
uncertainty_before_minutes: patch.uncertainty_before_minutes,
uncertainty_after_minutes: patch.uncertainty_after_minutes,
}).eq("id", userId);
const result = await accounting.rpc("patch_agentic_rectification_case_uncertainty_v1", {
p_user_id: userId,
p_case_id: caseId,
p_before_minutes: patch.uncertainty_before_minutes,
p_after_minutes: patch.uncertainty_after_minutes,
});
if (result.error) throw new RectificationToolServiceError(result.error.message);
}
+17 -3
View File
@@ -115,22 +115,36 @@ export function openResponseFromPayload(value: unknown): OpenRectificationCaseRe
status,
shouldStartOpening: row.shouldStartOpening === true,
skillVersion: typeof row.skillVersion === "string" ? row.skillVersion : "",
subjectId: typeof row.subjectId === "string" && row.subjectId ? row.subjectId : null,
};
}
export type RectificationEntryOpenIntent = "homepage" | "session" | "new";
/** Build the server open request body; the browser never adds business state. */
const CHART_SUBJECT_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/**
* Build the server open request body; the browser never adds business state.
* `subjectId` names the person (a chart_profiles id); "self" sends nothing,
* so the owner's request is the same body as before issue #160. The server
* still reads that person's birth data itself.
*/
export function openRectificationRequestBody(
intent: RectificationEntryOpenIntent,
sessionId: string | null,
sourceSessionId: string | null = null,
): { intent: RectificationEntryOpenIntent; requestId: string; sessionId?: string; sourceSessionId?: string } {
subjectId = "self",
): { intent: RectificationEntryOpenIntent; requestId: string; sessionId?: string; sourceSessionId?: string; subjectId?: string } {
const requestId = globalThis.crypto?.randomUUID?.() ?? fallbackUuid();
if (intent === "session" && sessionId) {
return { intent, requestId, sessionId };
}
return { intent, requestId, ...(intent === "homepage" && sourceSessionId ? { sourceSessionId } : {}) };
return {
intent,
requestId,
...(intent === "homepage" && sourceSessionId ? { sourceSessionId } : {}),
...(CHART_SUBJECT_ID.test(subjectId) ? { subjectId } : {}),
};
}
function fallbackUuid(): string {
@@ -26,18 +26,21 @@ import {
* admin client.
*/
/** C3: whether the offer card may be shown, decided by the server and never by the model. */
/**
* C3: whether the offer card may be shown, decided by the server and never by
* the model. `subjectId` is the chart_profiles id of the person this
* conversation is about, or null for the account owner (issue #160).
*/
export async function decideRectificationOffer(input: {
accounting: DataClient;
userId: string;
subjectRole: "self" | "other";
subjectId: string | null;
modelId: string;
}): Promise<RectificationOfferDecision> {
return decideRectificationOfferWith({
subjectRole: input.subjectRole,
productEnabled: () => isProductEnabled("rectification"),
hasResumableCase: async () => {
const summary = await getRectificationEntrySummary(input.accounting, input.userId);
const summary = await getRectificationEntrySummary(input.accounting, input.userId, input.subjectId);
return blocksNewRectification(summary.latestResumable?.status ?? "");
},
creditCost: async () => (await resolveFeaturePricing(input.accounting, "rectification", input.modelId)).credit_cost,
+4 -6
View File
@@ -62,7 +62,7 @@ export function parseRectificationReturnRecord(value: unknown): RectificationRet
/** C3: the server's answer to "may the offer card be shown". */
export type RectificationOfferDecision =
| Readonly<{ ok: true; creditCost: number; questionEstimate: number }>
| Readonly<{ ok: false; reason: "not_self" | "product_disabled" | "case_in_progress" | "pricing_unavailable" }>;
| Readonly<{ ok: false; reason: "product_disabled" | "case_in_progress" | "pricing_unavailable" }>;
// ---- copy (VOICE.md「生时校正并入对话」) ----
@@ -84,7 +84,6 @@ export const RECTIFICATION_BEFORE_CHANGE_LABEL = "按校正前的时间";
export const RECTIFICATION_DIRECT_NEW_COPY = "好,我们开始核对出生时间。";
export const RECTIFICATION_DIRECT_RESUME_COPY = "核对还没做完,我们接着做。";
export const RECTIFICATION_DIRECT_UNAVAILABLE_COPY = "出生时间核对这会儿还做不了。";
export const RECTIFICATION_DIRECT_NOT_SELF_COPY = "给家人校正暂时还不支持";
/**
* Chat entry only. An accepted case is a finished stop, so it must not block
@@ -266,17 +265,16 @@ export function lastBirthTimeChangeIndex(messages: readonly unknown[]): number {
/**
* C3: the offer decision with its reads injected (the server wires the real
* product switch, entry summary and feature pricing). Order matters: a
* non-self subject never reaches the database.
* product switch, entry summary and feature pricing). Since issue #160 any
* person in the account's list can be rectified, so the server reads that
* person's own open case; another person's open case never blocks this one.
*/
export async function decideRectificationOfferWith(input: {
subjectRole: "self" | "other";
productEnabled: () => Promise<boolean>;
hasResumableCase: () => Promise<boolean>;
creditCost: () => Promise<number>;
questionEstimate?: number;
}): Promise<RectificationOfferDecision> {
if (input.subjectRole !== "self") return { ok: false, reason: "not_self" };
if (!await input.productEnabled().catch(() => false)) return { ok: false, reason: "product_disabled" };
// An unreadable summary does not block the offer: opening the case re-checks
// for a conflict and says so.
+45 -8
View File
@@ -1,10 +1,13 @@
/**
* The standalone /rectification page (issue #145). Which card it shows comes
* from the server's entry summary and the account; the page never guesses
* from session lists. Self profile only (other people: issue #160).
* from session lists. Since issue #160 the page is for the current person
* (the switcher beside the title): the owner or anyone in the profile list,
* each with their own open case (product 2026-11-01, 1A).
*/
import { rectificationEntryHref, sessionHref } from "./chat-session-url.ts";
import { personRelationLabel } from "./person-relation.ts";
import { blocksNewRectification } from "./rectification-in-chat.ts";
import type { RectificationEntrySummary } from "./rectification-entry.ts";
@@ -12,6 +15,7 @@ export type RectificationPageState =
| { readonly kind: "signed-out" }
| { readonly kind: "unavailable" }
| { readonly kind: "self-missing" }
| { readonly kind: "other-missing" }
| { readonly kind: "none" }
| {
readonly kind: "active";
@@ -56,8 +60,10 @@ export const RECTIFICATION_PAGE_COPY = {
signIn: "去登录",
unavailable: "生时校正这会儿还做不了。",
selfMissing: "先把你自己的出生日期、时间和地点填好,才能做生时校正。",
otherMissing: "先把 TA 的出生日期、时间和地点填好,才能做生时校正。",
fillSelf: "去填资料",
currentTime: "你现在用的时间",
otherCurrentTime: "TA 现在用的时间",
start: "开始",
activeLabel: "进行中",
windowLabel: "时间窗",
@@ -69,6 +75,9 @@ export const RECTIFICATION_PAGE_COPY = {
again: "再校正一次",
back: "返回",
checkTitle: "先核对一下资料",
personName: "称呼",
personRelation: "关系",
selfRelation: "本人",
birthDate: "出生日期",
birthTime: "出生时间",
birthPlace: "出生城市",
@@ -90,12 +99,16 @@ export function rectificationPageState(input: {
readonly signedOut: boolean;
/** `account.rectification`; missing means the flag did not arrive, so the page stays closed. */
readonly enabled: boolean | undefined;
readonly selfComplete: boolean;
/** The current person's birth date, time and place are filled in. */
readonly profileComplete: boolean;
/** The current person is someone in the profile list, not the account owner. */
readonly forOther?: boolean;
/** That person's own cases only; another person's open case never shows here. */
readonly summary: RectificationEntrySummary;
}): RectificationPageState {
if (input.signedOut) return { kind: "signed-out" };
if (input.enabled !== true) return { kind: "unavailable" };
if (!input.selfComplete) return { kind: "self-missing" };
if (!input.profileComplete) return { kind: input.forOther ? "other-missing" : "self-missing" };
const open = input.summary.latestResumable;
if (open && blocksNewRectification(open.status)) {
@@ -122,15 +135,39 @@ export function rectificationPageState(input: {
return { kind: "none" };
}
/** Where 开始校正 goes: a fresh case after a finished one, otherwise the homepage opener. */
export function rectificationStartHref(state: RectificationPageState): string {
return rectificationEntryHref(state.kind === "done" ? "new" : "homepage");
/**
* Where 开始校正 goes: a fresh case after a finished one, otherwise the
* homepage opener, both for `subjectId` ("self" = the account owner).
*/
export function rectificationStartHref(state: RectificationPageState, subjectId = "self"): string {
return rectificationEntryHref(state.kind === "done" ? "new" : "homepage", subjectId);
}
/**
* 继续 / 查看结果: the case's own conversation. Without a session id (an older
* server) the homepage opener still resumes the open case.
*/
export function rectificationSessionHref(sessionId: string | null): string {
return sessionId ? sessionHref("", sessionId) : rectificationEntryHref("homepage");
export function rectificationSessionHref(sessionId: string | null, subjectId = "self"): string {
return sessionId ? sessionHref("", sessionId) : rectificationEntryHref("homepage", subjectId);
}
/** One person's cases (issue #160): the owner reads the bare path, anyone else adds `?subject=`. */
export function rectificationEntrySummaryPath(subjectId: string): string {
const base = "/api/rectification/cases/entry-summary";
return subjectId === "self" ? base : `${base}?subject=${encodeURIComponent(subjectId)}`;
}
/** Where 改这份 / 去填资料 go for the current person. */
export function rectificationProfileHref(subjectId: string): string {
return subjectId === "self" ? "/people?self=1" : `/people?edit=${encodeURIComponent(subjectId)}`;
}
/** 称呼 and 关系 of the current person (the 核对 step and the status card). */
export function rectificationPersonFacts(
subjectId: string,
profile: { readonly name: string; readonly relation?: unknown } | null,
): { readonly name: string; readonly relation: string } {
const self = subjectId === "self";
const name = profile?.name.trim() || (self ? "我" : "未命名资料");
return { name, relation: self ? RECTIFICATION_PAGE_COPY.selfRelation : personRelationLabel(profile?.relation) };
}
@@ -27,15 +27,17 @@ function safeCategory(value: string): string {
}
/**
* Session of the latest case that still blocks a new chat entry.
* Session of the latest case that still blocks a new chat entry, for one
* person (`subjectId` null = the account owner, issue #160).
* 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,
subjectId: string | null = null,
): Promise<string | null> {
const summary = await getRectificationEntrySummary(accounting, userId);
const summary = await getRectificationEntrySummary(accounting, userId, subjectId);
const latest = summary.latestResumable;
if (!latest?.caseId || !blocksNewRectification(latest.status)) return null;
try {
+1 -5
View File
@@ -1,6 +1,5 @@
import {
RECTIFICATION_DIRECT_NEW_COPY,
RECTIFICATION_DIRECT_NOT_SELF_COPY,
RECTIFICATION_DIRECT_RESUME_COPY,
RECTIFICATION_DIRECT_UNAVAILABLE_COPY,
RECTIFICATION_OFFER_TOOL_ID,
@@ -1156,7 +1155,6 @@ const evidenceLookupInputSchema = z.object({
}).strict();
const RECTIFICATION_OFFER_REFUSAL_NOTES: Readonly<Record<Exclude<RectificationOfferDecision, { ok: true }>["reason"], string>> = {
not_self: `问的不是用户本人的盘,生时校正暂时只支持本人。正文用这一句说明:「${RECTIFICATION_DIRECT_NOT_SELF_COPY}」,不出卡。`,
product_disabled: `出生时间核对这会儿做不了。正文必须用这一句说明:「${RECTIFICATION_DIRECT_UNAVAILABLE_COPY}」,不写内部原因。`,
case_in_progress: "用户已经有一次没做完的生时校正。这次不出提议卡,正文照常回答这一轮的问题。",
pricing_unavailable: `出生时间核对这会儿做不了。正文必须用这一句说明:「${RECTIFICATION_DIRECT_UNAVAILABLE_COPY}」,不写价格。`,
@@ -1258,9 +1256,7 @@ async function finishExplicitRectificationRequest(
note: `只写这一句过渡,不写解读:「${RECTIFICATION_DIRECT_NEW_COPY}」`,
};
}
const sentence = decision.reason === "not_self"
? RECTIFICATION_DIRECT_NOT_SELF_COPY
: RECTIFICATION_DIRECT_UNAVAILABLE_COPY;
const sentence = RECTIFICATION_DIRECT_UNAVAILABLE_COPY;
state.rectificationDirectEntry = {
kind: "refused",
sentence,
File diff suppressed because it is too large Load Diff
+8 -4
View File
@@ -181,6 +181,9 @@ test("the birth line is followed by whether the ascendant changes inside the sta
assert.doesNotMatch(period, /出生时间校正/);
});
// 原值:别人的盘只陈述上升,不带「出生时间校正」链接
// 新值:别人的盘和本人一样带链接,指向 /rectification(页面跟着当前人物走)
// 原因:issue #160 档案里的人都能做生时校正。测试名沿用原名(frontend/AGENTS.md:测试名不得消失)
test("someone else's chart states the ascendant without a rectification link", async () => {
const view = await okView();
const other = { ...view, subjectId: "person-1" };
@@ -201,11 +204,12 @@ test("someone else's chart states the ascendant without a rectification link", a
precisionKind: "unknown" as const,
};
const otherTwo = withSidebarProvider(React.createElement(ChartPageView, { view: { ...other, lagnaPrecheck: mayChange } }));
assert.match(otherTwo, /上升可能是白羊座,也可能是金牛座(大约 12:01 换座)。/);
assert.doesNotMatch(otherTwo, /出生时间校正|href="\/rectification"|href="\/\?rectify=1"|想定下来/);
assert.match(otherTwo, /上升可能是白羊座,也可能是金牛座(大约 12:01 换座)。想定下来可以做一次/);
assert.match(otherTwo, /href="\/rectification"/);
assert.doesNotMatch(otherTwo, /href="\/\?rectify=1"/);
const otherUnknown = withSidebarProvider(React.createElement(ChartPageView, { view: { ...other, lagnaPrecheck: unknown } }));
assert.match(otherUnknown, /不知道出生时间时,上升星座定不下来。/);
assert.doesNotMatch(otherUnknown, /出生时间校正|href="\/rectification"|href="\/\?rectify=1"|可以缩小范围/);
assert.match(otherUnknown, /不知道出生时间时,上升星座定不下来;做一次/);
assert.match(otherUnknown, /href="\/rectification"/);
const selfTwo = withSidebarProvider(React.createElement(ChartPageView, { view: { ...self, lagnaPrecheck: mayChange } }));
assert.match(selfTwo, /href="\/rectification"/);
assert.match(selfTwo, />出生时间校正</);
@@ -276,8 +276,11 @@ test("homepage creation and sidebar selection resolve through distinct server in
// 原值(2): openRectificationCase("homepage", null, pendingConsultationQuestion, sourceSessionId)
// 新值(2): openRectificationCase("homepage", null, sourceSessionId)
// 原因(2): BUG-1247(TASK-rectification-in-chat-step1-20261006 P3/C1):首页生时校正按钮与文字口令删除,校正只从对话里的提议卡进入
assert.match(page, /openRectificationCase\(\"homepage\", null, sourceSessionId\)/);
assert.match(page, /openRectificationCase\(\"new\", null, null\)/);
// 原值(3): openRectificationCase("homepage", null, sourceSessionId) / openRectificationCase("new", null, null)
// 新值(3): 两处都多传人物:readCurrentSubjectId() / target
// 原因(3): issue #160 档案里的人都能做校正,案例记下是谁的盘
assert.match(page, /openRectificationCase\(\"homepage\", null, sourceSessionId, readCurrentSubjectId\(\)\)/);
assert.match(page, /openRectificationCase\(\"new\", null, null, target\)/);
assert.doesNotMatch(page, /sourceSession\.sessionType === "birth_time_rectification"/);
assert.doesNotMatch(page, /sessions\.find\(\(session\) => session\.sessionType === "birth_time_rectification"\)/);
assert.match(component, /<RectificationAgenticChat/);
@@ -0,0 +1,240 @@
/**
* Issue #160 (product 2026-11-01: 1A 2A) against real PostgreSQL: a case for
* another person in the profile list is opened for that person, adopts into
* that person's chart_profiles row only, counts as that person's one open
* case, and is deleted with the person. Fictional people and places only; the
* permission booleans in the receipt are controlled setup, not evidence that
* any engine sample passed a gate.
*/
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 { resolveActiveSkillPackage } from "../src/lib/skill-package-registry.ts";
import { startPostgresFixture, type PostgresFixture } 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));
const APP_PASSWORD = "app-runtime-test-password";
function lastLine(value: string): string {
const lines = value.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
return lines[lines.length - 1] ?? "";
}
function asUser(fixture: PostgresFixture, userId: string, sql: string): string {
return lastLine(fixture.psqlAs(
"app_runtime",
APP_PASSWORD,
`set role authenticated; select set_config('request.jwt.claim.sub', '${userId}', true); ${sql}`,
));
}
test("another person's case is that person's: opened for them, adopted into their row only, counted per person, deleted with them", { skip: !docker && "docker unavailable" }, async () => {
const fixture = startPostgresFixture();
const url = fixture.connectionUrl("service_runtime", "service-runtime-test-password");
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.stderr);
const service = createLocalPostgresDataClient(url, null, "service_role");
const skill = resolveActiveSkillPackage("jyotish-birth-time-rectification");
const userId = randomUUID();
const strangerId = randomUUID();
const person = randomUUID();
const strangersPerson = randomUUID();
fixture.psqlAs("identity_runtime", "identity-runtime-test-password", `
insert into identity.users(id,name,email,email_verified) values
('${userId}','Synthetic Household','${userId}@example.invalid',true),
('${strangerId}','Synthetic Stranger','${strangerId}@example.invalid',true);
`);
// Owner: an exact family time. Person: the dated boundary of database-adopted-birth-date.
fixture.psql(`
update public.profiles set birth_date='1990-01-02',reported_birth_time='08:00',birth_time_source='family_exact',
birth_time_status='reported',uncertainty_before_minutes=15,uncertainty_after_minutes=15,
latitude=31.2,longitude=121.5,timezone_id='Asia/Shanghai',timezone_offset=8,birth_place_label='Synthetic Harbour'
where id='${userId}';
insert into public.chart_profiles(id,user_id,role,profile,name,birth_date,reported_birth_time,birth_time_source,
uncertainty_before_minutes,uncertainty_after_minutes,latitude,longitude,timezone_id,timezone_offset,
birth_place_label,ayanamsa,birth_time_status)
values
('${person}','${userId}','other','{"name":"Synthetic Parent"}','Synthetic Parent','2000-06-15','00:10','family_exact',
15,15,40,-74,'America/New_York',-4,'Synthetic Bay','lahiri','reported'),
('${strangersPerson}','${strangerId}','other','{"name":"Synthetic Other"}','Synthetic Other','2001-01-01','12:00','family_exact',
15,15,40,-74,'America/New_York',-5,'Synthetic Bay','lahiri','reported');
`);
const ownerBefore = fixture.psql(`select row_to_json(p)::text from public.profiles p where id='${userId}'`);
// --- open: one open case per person, at the same time (1A) ---------------
const personSnapshot = {
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 ownerSnapshot = {
birth_date: "1990-01-02", reported_birth_time: "08:00", active_birth_time: null,
birth_time_source: "family_exact", birth_time_period: null,
uncertainty_before_minutes: 15, uncertainty_after_minutes: 15,
latitude: 31.2, longitude: 121.5, timezone_id: "Asia/Shanghai", timezone_offset: 8,
};
const openArgs = (snapshot: unknown, range: unknown) => ({
p_user_id: userId, p_request_id: randomUUID(), p_intent: "new", p_session_id: null,
p_skill_name: skill.name, p_skill_version: skill.version, p_skill_sha256: skill.sha256, p_skill_source_commit: skill.sourceCommit,
p_baseline_profile_fingerprint: "a".repeat(64), p_baseline_birth_snapshot: snapshot, p_candidate_range: range,
p_product_domain: "general",
});
const personRange = { start_time: "23:55", end_time: "00:25", candidate_intervals: [{ start_at: "2000-06-14T23:55", end_at: "2000-06-15T00:25" }] };
const ownerRange = { start_time: "07:45", end_time: "08:15", candidate_intervals: [{ start_at: "1990-01-02T07:45", end_at: "1990-01-02T08:15" }] };
const personArgs = { ...openArgs(personSnapshot, personRange), p_chart_profile_id: person };
const openedForPerson = await service.rpc("open_agentic_rectification_case_v3", personArgs);
assert.equal(openedForPerson.error, null, JSON.stringify(openedForPerson.error));
const personOpen = openedForPerson.data as { case_id: string; session_id: string; disposition: string; chart_profile_id: string };
assert.equal(personOpen.disposition, "created");
assert.equal(personOpen.chart_profile_id, person);
assert.equal(fixture.psql(`select chart_profile_id::text from public.agentic_rectification_cases where id='${personOpen.case_id}'`), person);
assert.equal(fixture.psql(`select chart_profile_id || '|' || chart_profile_role from public.chat_sessions where id='${personOpen.session_id}'`), `${person}|other`);
const replay = await service.rpc("open_agentic_rectification_case_v3", personArgs);
assert.equal(replay.error, null);
assert.equal((replay.data as { case_id: string }).case_id, personOpen.case_id, "a repeated request id is the same case");
const replayAsOwner = await service.rpc("open_agentic_rectification_case_v3", { ...personArgs, p_chart_profile_id: null });
assert.match(String(replayAsOwner.error?.message), /agentic_rectification_subject_mismatch/);
const foreign = await service.rpc("open_agentic_rectification_case_v3", { ...openArgs(personSnapshot, personRange), p_chart_profile_id: strangersPerson });
assert.match(String(foreign.error?.message), /agentic_rectification_subject_not_found/, "another account's person is not this account's");
const openedForOwner = await service.rpc("open_agentic_rectification_case_v2", openArgs(ownerSnapshot, ownerRange));
assert.equal(openedForOwner.error, null, JSON.stringify(openedForOwner.error));
const ownerOpen = openedForOwner.data as { case_id: string; session_id: string };
assert.equal(fixture.psql(`select coalesce(chart_profile_id::text,'owner') from public.agentic_rectification_cases where id='${ownerOpen.case_id}'`), "owner");
const reopened = await service.rpc("open_agentic_rectification_case_v3", {
p_user_id: userId, p_request_id: randomUUID(), p_intent: "session", p_session_id: personOpen.session_id,
p_skill_name: skill.name, p_skill_version: skill.version, p_skill_sha256: skill.sha256, p_skill_source_commit: skill.sourceCommit,
p_baseline_profile_fingerprint: "session-view", p_baseline_birth_snapshot: {}, p_candidate_range: { start_time: "00:00", end_time: "23:59" },
});
assert.equal(reopened.error, null, JSON.stringify(reopened.error));
assert.equal((reopened.data as { chart_profile_id: string }).chart_profile_id, person, "reopening a session says whose case it is");
const summaryFor = async (subject: string | null) => {
const read = subject
? await service.rpc("get_agentic_rectification_entry_summary_v2", { p_user_id: userId, p_chart_profile_id: subject })
: await service.rpc("get_agentic_rectification_entry_summary", { p_user_id: userId });
assert.equal(read.error, null, JSON.stringify(read.error));
return read.data as { has_resumable_case: boolean; latest_resumable: { case_id: string } | null };
};
assert.equal((await summaryFor(person)).latest_resumable?.case_id, personOpen.case_id);
assert.equal((await summaryFor(null)).latest_resumable?.case_id, ownerOpen.case_id, "the owner and the person each have one open case");
assert.equal((await summaryFor(strangersPerson)).has_resumable_case, false);
// --- adoption writes back to the person only -----------------------------
const intervals = [{ start_at: "2000-06-14T23:55", end_at: "2000-06-15T00:25" }];
const cluster = [{ segment_index: 0, start_index: 0, end_index: 16, start_offset_minutes: 0,
end_offset_minutes: 16, start_at: "2000-06-14T23:55", end_at: "2000-06-15T00:11" }];
const candidates = [
{ time: "23:55", candidate_date: "2000-06-14", window_index: 0, window_offset_minutes: 0, segment_index: 0 },
{ time: "00:11", candidate_date: "2000-06-15", window_index: 16, window_offset_minutes: 16, segment_index: 0 },
].map((row, i) => ({ ...row, id: row.time, rank: i + 1, relative_support: 50, tied_minute_count: 1,
probability: i ? 0.4 : 0.6, posterior_score: i ? 55 : 60, status: "active",
cluster_range: [row.time, row.time], cluster_intervals: [{ segment_index: 0,
start_index: row.window_index, end_index: row.window_index,
start_offset_minutes: row.window_offset_minutes, end_offset_minutes: row.window_offset_minutes,
start_at: `${row.candidate_date}T${row.time}`, end_at: `${row.candidate_date}T${row.time}` }] }));
const inference = {
revision: 0, range_start: "23:55", range_end: "00:25",
candidate_set_id: "dated-v1:23:55-00:25:2000-06-14T23:55@0@0@0,2000-06-15T00:11@16@16@0",
candidates, representative_time: "23:55", credible_range: ["23:55", "00:11"], credible_intervals: cluster,
};
const adoptCase = randomUUID();
const adoptSession = randomUUID();
fixture.psql(`
insert into public.chat_sessions(id,user_id,title,theme,session_type,messages,chart_profile_id,chart_profile_role)
values('${adoptSession}','${userId}','Synthetic boundary','general','birth_time_rectification','[]','${person}','other');
insert into public.agentic_rectification_cases(id,user_id,session_id,status,skill_name,skill_version,
baseline_profile_fingerprint,baseline_birth_snapshot,candidate_range,chart_profile_id)
values('${adoptCase}','${userId}','${adoptSession}','candidate_ready','jyotish-birth-time-rectification','9.0.0',
'${"a".repeat(64)}','${JSON.stringify(personSnapshot)}','${JSON.stringify(personRange)}','${person}');
`);
const persisted = await service.rpc("persist_agentic_rectification_candidate_v2", {
p_user_id: userId, p_case_id: adoptCase, p_engine_result_id: "synthetic-subject-boundary",
p_evidence_ledger_fingerprint: "b".repeat(64), p_candidate_range_fingerprint: "c".repeat(64),
p_skill_version: "9.0.0", p_algorithm_version: "synthetic-boundary-v1",
p_event_contract_version: "rectification-event-contract-v2", p_decision_policy_version: "rectification-candidate-policy-v2",
p_candidate_range: personRange, p_candidates: candidates,
p_decision_receipt: { display_allowed: true, accept_allowed: true, confirm_allowed: true,
representative_time: "23:55", overall_confidence: "low", margin_percent: 1,
candidate_window_contract: "dated-v1", candidate_intervals: intervals,
candidate_timezone_offset: -4, candidate_timezone_id: "America/New_York", inference_state: inference },
p_execution_ledger: [{ phase: "candidate.score", status: "completed", engine: "synthetic-boundary" }],
});
assert.equal(persisted.error, null, JSON.stringify(persisted.error));
const row = persisted.data as { result_id: string; candidates: { time: string; candidate_id: string }[] };
const candidateId = row.candidates.find((c) => c.time === "23:55")!.candidate_id;
const ownerCaseBefore = fixture.psql(`select to_jsonb(c)::text from public.agentic_rectification_cases c where id='${ownerOpen.case_id}'`);
const accepted = await service.rpc("accept_agentic_rectification_candidate_for_case_v2", {
p_user_id: userId, p_case_id: adoptCase, p_result_id: row.result_id, p_candidate_id: candidateId, p_request_id: randomUUID(),
});
assert.equal(accepted.error, null, JSON.stringify(accepted.error));
assert.equal((accepted.data as Record<string, unknown>).saved_date, "2000-06-14");
assert.equal(fixture.psql(`select to_char(active_birth_time,'HH24:MI') || '|' || active_birth_date || '|' || active_birth_timezone_offset
|| '|' || birth_time_status || '|' || (active_birth_provenance->>'contract') || '|' || (active_birth_provenance->>'case_id')
from public.chart_profiles where id='${person}'`), `23:55|2000-06-14|-4|accepted|dated-v1|${adoptCase}`);
assert.equal(fixture.psql(`select row_to_json(p)::text from public.profiles p where id='${userId}'`), ownerBefore,
"the owner's profile is untouched by another person's adoption");
assert.equal(fixture.psql(`select to_jsonb(c)::text from public.agentic_rectification_cases c where id='${ownerOpen.case_id}'`), ownerCaseBefore,
"the owner's case is untouched");
const turnId = randomUUID();
fixture.psql(`insert into public.agentic_rectification_turns(id,case_id,model_name,user_message,assistant_message,status)
values('${turnId}','${adoptCase}','synthetic','Confirm synthetic boundary','Synthetic acknowledgment','completed')`);
const confirmed = await service.rpc("confirm_agentic_rectification_candidate_for_case_v2", {
p_user_id: userId, p_case_id: adoptCase, p_result_id: row.result_id, p_candidate_id: candidateId,
p_request_id: randomUUID(), p_source_turn_id: turnId, p_consent_quote: "Confirm synthetic boundary",
});
assert.equal(confirmed.error, null, JSON.stringify(confirmed.error));
assert.equal(fixture.psql(`select birth_time_status from public.chart_profiles where id='${person}'`), "confirmed");
assert.equal(fixture.psql(`select row_to_json(p)::text from public.profiles p where id='${userId}'`), ownerBefore);
// --- the browser still cannot write an adopted minute --------------------
assert.throws(() => asUser(fixture, userId, `update public.chart_profiles set birth_time_status='accepted' where id='${person}'`));
assert.throws(() => asUser(fixture, userId, `update public.chart_profiles set active_birth_time='01:00' where id='${person}'`));
assert.throws(() => asUser(fixture, userId,
`select set_config('jyotisha.rectification_subject_write', 'x', true); update public.chart_profiles set birth_time_status='accepted' where id='${person}'`),
/chart_subject_birth_status_locked/, "the marker means nothing outside the adoption function");
// --- a new declaration drops the adopted minute and rebaselines only that person
assert.equal(fixture.psql(`select status from public.agentic_rectification_cases where id='${personOpen.case_id}'`), "draft");
asUser(fixture, userId, `update public.chart_profiles set birth_date='2000-06-16' where id='${person}'`);
assert.equal(fixture.psql(`select (active_birth_time is null and active_birth_date is null and active_birth_timezone_offset is null
and active_birth_provenance is null)::text || '|' || birth_time_status from public.chart_profiles where id='${person}'`), "true|reported");
assert.equal(fixture.psql(`select status from public.agentic_rectification_cases where id='${personOpen.case_id}'`), "needs_rebaseline");
assert.equal(fixture.psql(`select status from public.agentic_rectification_cases where id='${ownerOpen.case_id}'`), "draft",
"editing a person never rebaselines the owner's case");
fixture.psql(`update public.agentic_rectification_cases set status='draft' where id='${personOpen.case_id}'`);
fixture.psql(`update public.profiles set birth_place_label='Synthetic Harbour', latitude=31.3 where id='${userId}'`);
assert.equal(fixture.psql(`select status from public.agentic_rectification_cases where id='${ownerOpen.case_id}'`), "needs_rebaseline");
assert.equal(fixture.psql(`select status from public.agentic_rectification_cases where id='${personOpen.case_id}'`), "draft",
"editing the owner never rebaselines another person's case");
// --- deleting the person deletes that person's cases (2A) ----------------
assert.equal(asUser(fixture, userId, `select session_count::text from public.chart_subject_usage('${person}');`), "2",
"the delete prompt counts the person's rectification conversations");
asUser(fixture, userId, `select public.delete_chart_subject('${person}');`);
assert.equal(fixture.psql(`select count(*) from public.agentic_rectification_cases where user_id='${userId}' and chart_profile_id is not null`), "0");
assert.equal(fixture.psql(`select count(*) from public.agentic_rectification_cases where id in ('${personOpen.case_id}','${adoptCase}')`), "0");
assert.equal(fixture.psql(`select count(*) from public.chat_sessions where id in ('${personOpen.session_id}','${adoptSession}')`), "0");
assert.equal(fixture.psql(`select count(*) from public.agentic_rectification_results where id='${row.result_id}'`), "0");
assert.equal(fixture.psql(`select count(*) from public.agentic_rectification_candidate_decisions where case_id='${adoptCase}'`), "0");
assert.equal(fixture.psql(`select count(*) from public.agentic_rectification_cases where id='${ownerOpen.case_id}'`), "1");
assert.equal(fixture.psql(`select count(*) from public.chat_sessions where id='${ownerOpen.session_id}'`), "1");
assert.equal(fixture.psql(`select count(*) from public.chart_profiles where id='${person}'`), "0");
} finally {
await closeLocalPostgresDataPool(url);
fixture.stop();
}
});
+7 -8
View File
@@ -136,12 +136,11 @@ test("header sentences name the sign and link only when it can change", () => {
assert.equal(lagnaPrecheckLine(unknown).linked, true);
assert.match(lagnaPrecheckLine(unknown).before, /不知道出生时间时/);
const otherTwo = lagnaPrecheckLine(two, { self: false });
assert.equal(otherTwo.linked, false);
assert.equal(otherTwo.after, "");
assert.match(otherTwo.before, /大约 12:01 换座)。$/);
assert.doesNotMatch(otherTwo.before, /出生时间校正|想定下来/);
const otherUnknown = lagnaPrecheckLine(unknown, { self: false });
assert.equal(otherUnknown.linked, false);
assert.equal(otherUnknown.before, "不知道出生时间时,上升星座定不下来。");
// 原值:别人的盘 `lagnaPrecheckLine(value, { self: false })` 不带链接,句子在「换座)。」结束,未知时间只说「不知道出生时间时,上升星座定不下来。」
// 新值:不再分本人和别人,任何人的盘都是同一句、都带「出生时间校正」链接
// 原因:issue #160 起档案里的人都能做生时校正;/rectification 跟着当前人物走
assert.equal(lagnaPrecheckLine.length, 1);
assert.deepEqual(lagnaPrecheckLine(two), twoLine);
assert.match(twoLine.before, /大约 12:01 换座)。想定下来可以做一次$/);
assert.equal(lagnaPrecheckLine(unknown).before, "不知道出生时间时,上升星座定不下来;做一次");
});
@@ -971,7 +971,10 @@ test("stream failures remove empty assistant placeholders", () => {
test("rectification sessions are opened by the server Case API; the browser never creates them", () => {
assert.match(page, /\/api\/rectification\/cases\/open/);
// 原值:intent/exactSessionId;新值:附 sourceSessionId;原因:仅传 owned 来源引用,领域仍由服务端读取。
assert.match(page, /openRectificationRequestBody\(intent, exactSessionId, sourceSessionId\)/);
// 原值(2):openRectificationRequestBody(intent, exactSessionId, sourceSessionId)
// 新值(2):多传人物 subjectId(本人不进请求体,请求体和以前一样)
// 原因(2):issue #160 档案里的人都能做校正;出生资料仍由服务端按这个人去读
assert.match(page, /openRectificationRequestBody\(intent, exactSessionId, sourceSessionId, subjectId\)/);
assert.match(page, /openResponseFromPayload\(payload\)/);
assert.doesNotMatch(page, /sessions\.find\(\(session\) => session\.sessionType === "birth_time_rectification"\)/);
assert.doesNotMatch(page, /hasRectificationSession/);
@@ -0,0 +1,415 @@
/**
* Issue #160 (product 2026-11-01: 1A 2A): rectification for the other people
* in the account's profile list. Fictional ids and birth data only.
*/
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
RECTIFICATION_SUBJECT_QUERY_KEY,
rectificationEntryHref,
rectificationEntryIntent,
rectificationEntrySubject,
} from "../src/lib/chat-session-url.ts";
import { openRectificationRequestBody, openResponseFromPayload } from "../src/lib/rectification-entry.ts";
import {
RECTIFICATION_PAGE_COPY,
rectificationEntrySummaryPath,
rectificationPageState,
rectificationPersonFacts,
rectificationProfileHref,
rectificationSessionHref,
rectificationStartHref,
} from "../src/lib/rectification-page.ts";
import {
RectificationCaseServiceError,
getRectificationEntrySummary,
mapRectificationRpcError,
openRectificationCase,
} from "../src/lib/rectification-agentic/v9/case-service.ts";
import {
openRequestSubjectId,
openResponse,
parseOpenRectificationCaseRequest,
} from "../src/lib/rectification-agentic/v9/open-request.ts";
import { patchV9BirthUncertainty } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import { loadResumableRectificationSessionId } from "../src/lib/rectification-resume-session.ts";
import { parseChartSubjectWrite } from "../src/lib/chart-subject-write.ts";
const PERSON = "aaaaaaaa-1111-4111-8111-aaaaaaaaaaaa";
const OTHER_PERSON = "bbbbbbbb-2222-4222-8222-bbbbbbbbbbbb";
const REQUEST = "cccccccc-3333-4333-8333-cccccccccccc";
const CASE = "dddddddd-4444-4444-8444-dddddddddddd";
const SESSION = "eeeeeeee-5555-4555-8555-eeeeeeeeeeee";
const USER = "ffffffff-6666-4666-8666-ffffffffffff";
const personRow = {
birth_date: "1991-03-09",
birth_place_label: "虚构市",
reported_birth_time: "10:00:00",
birth_time_source: "approximate",
birth_time_period: null,
declared_window_start: null,
declared_window_end: null,
uncertainty_before_minutes: 30,
uncertainty_after_minutes: 30,
latitude: 30.5,
longitude: 114.3,
timezone_id: "Asia/Shanghai",
timezone_offset: 8,
ayanamsa: "lahiri",
};
const createdPayload = {
disposition: "created",
case_id: CASE,
session_id: SESSION,
status: "draft",
should_start_opening: true,
skill_version: "9.0.0",
};
type Read = { table: string; columns: string; filters: Record<string, string> };
function accountingFor(input: {
rows: Record<string, Record<string, unknown> | null>;
rpc: (fn: string, args: Record<string, unknown>) => { data: unknown; error: { message: string } | null };
}) {
const reads: Read[] = [];
const rpcs: Array<{ fn: string; args: Record<string, unknown> }> = [];
const client = {
from(table: string) {
const read: Read = { table, columns: "", filters: {} };
reads.push(read);
const builder = {
select(columns: string) { read.columns = columns; return builder; },
eq(column: string, value: string) { read.filters[column] = value; return builder; },
async single() {
const row = input.rows[table] ?? null;
return row ? { data: row, error: null } : { data: null, error: { message: "not found" } };
},
async maybeSingle() {
return { data: input.rows[table] ?? null, error: null };
},
};
return builder;
},
async rpc(fn: string, args: Record<string, unknown>) {
rpcs.push({ fn, args });
return input.rpc(fn, args);
},
};
return { client: client as never, reads, rpcs };
}
test("the open request takes a person id for new cases only; the owner sends the same body as before", () => {
const forOther = parseOpenRectificationCaseRequest({ intent: "homepage", requestId: REQUEST, subjectId: PERSON });
assert.ok(forOther);
assert.equal(openRequestSubjectId(forOther), PERSON);
const again = parseOpenRectificationCaseRequest({ intent: "new", requestId: REQUEST, subjectId: PERSON });
assert.equal(again && openRequestSubjectId(again), PERSON);
const owner = parseOpenRectificationCaseRequest({ intent: "homepage", requestId: REQUEST });
assert.equal(owner && openRequestSubjectId(owner), null);
assert.equal(parseOpenRectificationCaseRequest({ intent: "homepage", requestId: REQUEST, subjectId: "self" }), null);
assert.equal(parseOpenRectificationCaseRequest({ intent: "session", requestId: REQUEST, sessionId: SESSION, subjectId: PERSON }), null,
"an exact session already belongs to one person");
assert.deepEqual(Object.keys(openRectificationRequestBody("homepage", null, null, "self")).sort(), ["intent", "requestId"]);
assert.equal(openRectificationRequestBody("homepage", null, null, PERSON).subjectId, PERSON);
assert.equal(openRectificationRequestBody("new", null, null, PERSON).subjectId, PERSON);
assert.equal("subjectId" in openRectificationRequestBody("session", SESSION, null, PERSON), false);
assert.equal("subjectId" in openRectificationRequestBody("homepage", null, null, "not-an-id"), false);
});
test("the open answer says whose case it is; an older payload reads as the owner", () => {
assert.equal(openResponse({ ...createdPayload, chart_profile_id: PERSON })?.subjectId, PERSON);
assert.equal(openResponse({ ...createdPayload, chart_profile_id: null })?.subjectId, null);
assert.equal(openResponse(createdPayload)?.subjectId, null);
assert.equal(openResponseFromPayload({ disposition: "created", caseId: CASE, sessionId: SESSION, status: "draft", subjectId: PERSON })?.subjectId, PERSON);
assert.equal(openResponseFromPayload({ disposition: "created", caseId: CASE, sessionId: SESSION, status: "draft" })?.subjectId, null);
});
test("the queued home open carries the person; the owner URL is unchanged", () => {
assert.equal(rectificationEntryHref("homepage", "self"), "/?rectify=1");
assert.equal(rectificationEntryHref("new"), "/?rectify=new");
assert.equal(rectificationEntryHref("homepage", PERSON), `/?rectify=1&${RECTIFICATION_SUBJECT_QUERY_KEY}=${PERSON}`);
assert.equal(rectificationEntryHref("new", "garbage"), "/?rectify=new");
assert.equal(rectificationEntrySubject(`?rectify=new&rectifyFor=${PERSON}`), PERSON);
assert.equal(rectificationEntryIntent(`?rectify=new&rectifyFor=${PERSON}`), "new");
assert.equal(rectificationEntrySubject("?rectify=1"), "self");
assert.equal(rectificationEntrySubject("?rectify=1&rectifyFor=../../x"), "self");
});
test("the page links, summary path and person facts follow the current person", () => {
assert.equal(rectificationStartHref({ kind: "none" }, PERSON), `/?rectify=1&rectifyFor=${PERSON}`);
assert.equal(rectificationStartHref({ kind: "done", sessionId: SESSION, time: "07:42" }, PERSON), `/?rectify=new&rectifyFor=${PERSON}`);
assert.equal(rectificationSessionHref(SESSION, PERSON), `/?c=${SESSION}`);
assert.equal(rectificationSessionHref(null, PERSON), `/?rectify=1&rectifyFor=${PERSON}`);
assert.equal(rectificationEntrySummaryPath("self"), "/api/rectification/cases/entry-summary");
assert.equal(rectificationEntrySummaryPath(PERSON), `/api/rectification/cases/entry-summary?subject=${PERSON}`);
assert.equal(rectificationProfileHref("self"), "/people?self=1");
assert.equal(rectificationProfileHref(PERSON), `/people?edit=${PERSON}`);
assert.deepEqual(rectificationPersonFacts("self", { name: "小林" }), { name: "小林", relation: "本人" });
assert.deepEqual(rectificationPersonFacts("self", { name: " " }), { name: "我", relation: "本人" });
assert.deepEqual(rectificationPersonFacts(PERSON, { name: "阿岚", relation: "parent" }), { name: "阿岚", relation: "父母" });
assert.deepEqual(rectificationPersonFacts(PERSON, { name: "阿岚", relation: null }), { name: "阿岚", relation: "其他" });
const empty = { hasResumableCase: false, hasTerminalCaseWithTime: false, latestResumable: null, latestTerminal: null };
assert.equal(rectificationPageState({ signedOut: false, enabled: true, profileComplete: false, forOther: true, summary: empty }).kind, "other-missing");
assert.equal(RECTIFICATION_PAGE_COPY.otherMissing, "先把 TA 的出生日期、时间和地点填好,才能做生时校正。");
assert.equal(RECTIFICATION_PAGE_COPY.otherCurrentTime, "TA 现在用的时间");
for (const text of Object.values(RECTIFICATION_PAGE_COPY)) assert.doesNotMatch(text, /只支持本人|暂时还不支持/);
});
test("a case for another person reads that person's row and opens through v3; the owner's row is not read", async () => {
const fake = accountingFor({
rows: { chart_profiles: personRow },
rpc: (fn) => fn === "open_agentic_rectification_case_v3"
? { data: { ...createdPayload, chart_profile_id: PERSON }, error: null }
: { data: null, error: { message: `unexpected rpc ${fn}` } },
});
const opened = await openRectificationCase(fake.client, USER, { intent: "homepage", requestId: REQUEST, subjectId: PERSON }, "career");
assert.equal(opened.subjectId, PERSON);
assert.deepEqual(fake.reads.map((read) => read.table), ["chart_profiles"]);
assert.deepEqual(fake.reads[0].filters, { id: PERSON, user_id: USER, role: "other" });
assert.doesNotMatch(fake.reads[0].columns, /birth_time_clue/);
const open = fake.rpcs.find((call) => call.fn === "open_agentic_rectification_case_v3");
assert.ok(open);
assert.equal(open.args.p_chart_profile_id, PERSON);
assert.equal(open.args.p_product_domain, "career");
assert.equal(open.args.p_user_id, USER);
const snapshot = open.args.p_baseline_birth_snapshot as Record<string, unknown>;
assert.equal(snapshot.birth_date, "1991-03-09");
assert.equal(snapshot.reported_birth_time, "10:00");
assert.equal(snapshot.active_birth_time, null, "a fresh case never inherits an adopted minute");
assert.equal(fake.rpcs.some((call) => call.fn === "open_agentic_rectification_case_v2"), false);
});
test("the owner's new case still reads profiles and calls v2 without a person", async () => {
const fake = accountingFor({
rows: { profiles: personRow },
rpc: (fn) => fn === "open_agentic_rectification_case_v2"
? { data: createdPayload, error: null }
: { data: null, error: { message: `unexpected rpc ${fn}` } },
});
const opened = await openRectificationCase(fake.client, USER, { intent: "new", requestId: REQUEST });
assert.equal(opened.subjectId, null);
assert.deepEqual(fake.reads.map((read) => [read.table, read.filters]), [["profiles", { id: USER }]]);
assert.equal("p_chart_profile_id" in fake.rpcs[0].args, false);
});
test("a person who is not in this account is not found, and nothing is opened", async () => {
const fake = accountingFor({
rows: { chart_profiles: null },
rpc: (fn) => ({ data: null, error: { message: `unexpected rpc ${fn}` } }),
});
await assert.rejects(
() => openRectificationCase(fake.client, USER, { intent: "homepage", requestId: REQUEST, subjectId: OTHER_PERSON }),
(error: unknown) => error instanceof RectificationCaseServiceError && error.code === "subject_not_found",
);
assert.equal(fake.rpcs.length, 0);
assert.deepEqual(mapRectificationRpcError(new Error("agentic_rectification_subject_not_found")),
{ status: 404, code: "subject_not_found", message: "这个人的资料不存在或已删除" });
assert.equal(mapRectificationRpcError(new Error("agentic_rectification_subject_mismatch")).status, 409);
});
test("open cases are counted per person (1A): the summary and the resume lookup read one person", async () => {
const fake = accountingFor({
rows: {},
rpc: (fn, args) => {
if (fn === "get_agentic_rectification_entry_summary_v2") {
return {
data: {
chart_profile_id: args.p_chart_profile_id,
has_resumable_case: true,
has_terminal_case_with_time: false,
latest_resumable: { case_id: CASE, status: "collecting_evidence", last_activity_at: "x", session_id: SESSION },
latest_terminal: null,
},
error: null,
};
}
if (fn === "get_agentic_rectification_entry_summary") {
return { data: { has_resumable_case: false, has_terminal_case_with_time: false, latest_resumable: null, latest_terminal: null }, error: null };
}
if (fn === "get_agentic_rectification_case") {
return { data: { case_id: CASE, session_id: SESSION, status: "collecting_evidence" }, error: null };
}
return { data: null, error: { message: `unexpected rpc ${fn}` } };
},
});
const forPerson = await getRectificationEntrySummary(fake.client, USER, PERSON);
assert.equal(forPerson.hasResumableCase, true);
const forOwner = await getRectificationEntrySummary(fake.client, USER);
assert.equal(forOwner.hasResumableCase, false, "another person's open case does not block the owner");
assert.equal(await loadResumableRectificationSessionId(fake.client, USER, PERSON), SESSION);
assert.equal(await loadResumableRectificationSessionId(fake.client, USER), null);
assert.deepEqual(
fake.rpcs.filter((call) => call.fn.startsWith("get_agentic_rectification_entry_summary")).map((call) => [call.fn, call.args.p_chart_profile_id ?? null]),
[
["get_agentic_rectification_entry_summary_v2", PERSON],
["get_agentic_rectification_entry_summary", null],
["get_agentic_rectification_entry_summary_v2", PERSON],
["get_agentic_rectification_entry_summary", null],
],
);
});
test("widening patches the case's person through the case, never a table the caller picks", async () => {
const fake = accountingFor({ rows: {}, rpc: () => ({ data: null, error: null }) });
await patchV9BirthUncertainty(fake.client, USER, CASE, {
birth_time_source: "approximate",
uncertainty_before_minutes: 45,
uncertainty_after_minutes: 45,
});
assert.deepEqual(fake.rpcs, [{
fn: "patch_agentic_rectification_case_uncertainty_v1",
args: { p_user_id: USER, p_case_id: CASE, p_before_minutes: 45, p_after_minutes: 45 },
}]);
assert.equal(fake.reads.length, 0);
});
test("the self-only limits are gone from the surface, the chat offer and the copy", () => {
const surface = readFileSync(new URL("../src/hooks/use-rectification-surface.ts", import.meta.url), "utf8");
assert.doesNotMatch(surface, /rectificationLimitedToSelf|只支持本人/);
assert.match(surface, /refreshSubjectCatalog\(\)/, "another person's adopted time refreshes the people list");
assert.match(surface, /function handleRectificationSaved\(\)/);
const inChat = readFileSync(new URL("../src/lib/rectification-in-chat.ts", import.meta.url), "utf8");
assert.doesNotMatch(inChat, /not_self|给家人校正暂时还不支持/);
const tools = readFileSync(new URL("../src/mastra/consultation-tools.ts", import.meta.url), "utf8");
assert.doesNotMatch(tools, /not_self|只支持本人/);
const page = readFileSync(new URL("../src/components/rectification-page/rectification-entry-page.tsx", import.meta.url), "utf8");
assert.match(page, /useCurrentSubject\(\)/);
assert.match(page, /rectificationEntrySummaryPath\(subjectId\)/);
assert.match(page, /COPY\.personName/);
assert.match(page, /COPY\.personRelation/);
const voice = readFileSync(new URL("../docs/VOICE.md", import.meta.url), "utf8");
assert.doesNotMatch(voice, /当前人物不是本人时,生时校正入口禁用/);
assert.doesNotMatch(voice, /家人档案说「给家人校正暂时还不支持」/);
});
test("editing someone whose time was adopted saves, and the adopted status stays the database's to change", () => {
const base = { name: "阿岚", date: "1991-03-09", reportedTime: "10:00", birthTimeSource: "approximate" };
for (const echoed of ["accepted", "confirmed"]) {
const parsed = parseChartSubjectWrite({ ...base, birthTimeStatus: echoed });
assert.equal(parsed.ok, true, echoed);
assert.equal(parsed.ok && "birth_time_status" in parsed.write.columns, false, `${echoed} is not written by the browser`);
}
const reported = parseChartSubjectWrite({ ...base, birthTimeStatus: "reported" });
assert.equal(reported.ok && reported.write.columns.birth_time_status, "reported");
const fresh = parseChartSubjectWrite(base);
assert.equal(fresh.ok && fresh.write.columns.birth_time_status, "reported");
assert.equal(parseChartSubjectWrite({ ...base, activeBirthTime: "10:05" }).ok, false, "an adopted minute is still never accepted from the browser");
const migration = readFileSync(new URL("../supabase/migrations/20261101010000_rectification_chart_subject.sql", import.meta.url), "utf8");
assert.match(migration, /create trigger chart_profiles_zz_drop_adopted_on_declaration\nbefore update on public\.chart_profiles/);
});
const MIGRATIONS = new URL("../supabase/migrations/", import.meta.url);
const migration = readFileSync(new URL("20261101010000_rectification_chart_subject.sql", MIGRATIONS), "utf8");
function functionBody(sql: string, name: string): string {
const start = sql.search(new RegExp(`create (or replace )?function public\\.${name}\\(`));
assert.ok(start >= 0, `${name} missing`);
const end = sql.indexOf("\n$$;", start);
assert.ok(end > start, `${name} not closed`);
return sql.slice(start, end + 4);
}
test("the migration only adds: old cases stay the owner's, and no data is rewritten", () => {
assert.match(migration, /add column if not exists chart_profile_id uuid\s+references public\.chart_profiles\(id\) on delete cascade;/);
assert.match(migration, /alter table public\.chart_profiles\s+add column if not exists active_birth_provenance jsonb;/);
assert.doesNotMatch(migration, /\bdrop (table|column|index)\b/i);
assert.doesNotMatch(migration, /\btruncate\b/i);
// The only write of a case's chart_profile_id is the new case in v3.
const sets = [...migration.matchAll(/update public\.agentic_rectification_cases\s+set chart_profile_id = /g)];
assert.equal(sets.length, 1);
const v3 = functionBody(migration, "open_agentic_rectification_case_v3");
assert.ok(v3.includes("set chart_profile_id = p_chart_profile_id"));
assert.match(v3, /where id = p_chart_profile_id and user_id = p_user_id and role = 'other'/);
assert.match(v3, /agentic_rectification_subject_mismatch/);
assert.match(migration, /grant execute on function public\.open_agentic_rectification_case_v3\(\s*uuid, uuid, text, uuid, text, text, text, text, text, jsonb, jsonb, text, uuid\s*\) to service_role;/);
// Deletes exist only inside delete_chart_subject (2A).
const outside = migration.replace(functionBody(migration, "delete_chart_subject"), "");
assert.doesNotMatch(outside, /\bdelete from\b/i);
});
test("the copied adoption RPCs differ from their previous definitions only in the birth row read and write", () => {
// 20260920020000 is stored with CRLF; the copy is LF. Same SQL.
const previous = (file: string) => readFileSync(new URL(file, MIGRATIONS), "utf8").replace(/\r\n/g, "\n");
const lockRead = / select \* into v_profile\n from public\.profiles\n where id = p_user_id\n for update;\n if not found then\n/;
const lockReadNew = " v_profile := public.agentic_rectification_case_birth_row(v_case, true);\n if v_profile.id is null then\n";
const write = (status: string) => new RegExp(` update public\\.profiles\\n set active_birth_time = v_candidate\\.candidate_time,[\\s\\S]*?birth_time_status = '${status}',[\\s\\S]*? where id = p_user_id;\\n`);
const writeNew = (status: string) => ` perform public.agentic_rectification_write_case_birth(\n v_case, v_candidate.candidate_time, v_saved_date, v_saved_offset, v_provenance, '${status}',\n case\n when v_result.overall_confidence = 'high' then 100\n when v_result.overall_confidence = 'medium' then 70\n else 40\n end\n );\n`;
const accept = functionBody(previous("20260920020000_adopted_birth_date.sql"), "accept_agentic_rectification_candidate_for_case_v2")
.replace(lockRead, lockReadNew).replace(write("accepted"), writeNew("accepted"));
assert.equal(functionBody(migration, "accept_agentic_rectification_candidate_for_case_v2"), accept);
const confirm = functionBody(previous("20260920020000_adopted_birth_date.sql"), "confirm_agentic_rectification_candidate_for_case_v2")
.replace(lockRead, lockReadNew).replace(write("confirmed"), writeNew("confirmed"));
assert.equal(functionBody(migration, "confirm_agentic_rectification_candidate_for_case_v2"), confirm);
const vedastro = functionBody(previous("20260827020000_rectification_vedastro_validation_retry.sql"), "refresh_agentic_rectification_vedastro_validation")
.replace(" select * into v_profile\n from public.profiles\n where id = p_user_id;\n if not found then\n",
" v_profile := public.agentic_rectification_case_birth_row(v_case, false);\n if v_profile.id is null then\n");
assert.equal(functionBody(migration, "refresh_agentic_rectification_vedastro_validation"), vedastro);
const segment = functionBody(previous("20261001010000_rectification_segment_adoption.sql"), "accept_agentic_rectification_segment_for_case_v1")
.replace("create function", "create or replace function")
.replace(" select * into v_profile from public.profiles where id = p_user_id for update;\n if not found then raise",
" v_profile := public.agentic_rectification_case_birth_row(v_case, true);\n if v_profile.id is null then raise")
.replace(/ update public\.profiles set birth_time=v_time,[\s\S]*?where id=p_user_id;\n/,
" perform public.agentic_rectification_write_case_birth(v_case, v_time, v_date, v_offset, v_provenance, null, null);\n");
assert.equal(functionBody(migration, "accept_agentic_rectification_segment_for_case_v1"), segment);
for (const name of [
"accept_agentic_rectification_candidate_for_case_v2",
"confirm_agentic_rectification_candidate_for_case_v2",
"accept_agentic_rectification_segment_for_case_v1",
"refresh_agentic_rectification_vedastro_validation",
"get_agentic_rectification_segment_selection_v1",
]) {
assert.doesNotMatch(functionBody(migration, name), /public\.profiles\b(?!%rowtype)/, `${name} must not read or write the owner's row directly`);
}
});
test("another person's minute is written only by the adoption helper, and the owner's triggers ignore other people", () => {
const write = functionBody(migration, "agentic_rectification_write_case_birth");
assert.match(write, /if p_case\.chart_profile_id is null then\n update public\.profiles/);
assert.match(write, /set_config\('jyotisha\.rectification_subject_write', p_case\.id::text, true\)/);
assert.match(write, /where id = p_case\.chart_profile_id and user_id = p_case\.user_id and role = 'other'/);
const guard = functionBody(migration, "chart_profiles_guard_birth");
assert.match(guard, /current_setting\('jyotisha\.rectification_subject_write', true\)/);
assert.match(guard, /current_user not in \('anon', 'authenticated'\)/);
assert.match(guard, /raise exception 'chart_subject_active_birth_is_locked'/);
assert.match(guard, /raise exception 'chart_subject_birth_status_locked'/);
assert.match(migration, /revoke all on function public\.agentic_rectification_write_case_birth\([\s\S]*?\) from public, anon, authenticated, service_role;/);
const ownerRebaseline = functionBody(migration, "agentic_rectification_profiles_rebaseline_guard");
assert.match(ownerRebaseline, /and chart_profile_id is null/);
const ownerInvalidate = functionBody(migration, "invalidate_agentic_rectification_results_on_profile_change");
assert.match(ownerInvalidate, /c\.chart_profile_id is not null/);
const personRebaseline = functionBody(migration, "agentic_rectification_chart_profiles_rebaseline");
assert.match(personRebaseline, /and chart_profile_id = new\.id/);
assert.match(migration, /create trigger agentic_rectification_chart_profiles_rebaseline_trigger\nafter update on public\.chart_profiles/);
});
test("deleting a person deletes that person's cases first, in an order the foreign keys allow (2A)", () => {
const remove = functionBody(migration, "delete_chart_subject");
const order = [
"delete from public.agentic_rectification_candidate_decisions",
"delete from public.agentic_rectification_results",
"delete from public.chat_sessions\n where user_id = owner\n and id in (select session_id from public.agentic_rectification_cases",
"delete from public.agentic_rectification_cases where user_id = owner and chart_profile_id = p_id",
"delete from public.chat_sessions where user_id = owner and chart_profile_id = p_id::text",
"delete from public.personal_reports",
"delete from public.synastry_reports",
"delete from public.chart_profiles where id = p_id and user_id = owner and role = 'other'",
].map((step) => remove.indexOf(step));
assert.ok(order.every((index) => index > 0), JSON.stringify(order));
assert.deepEqual([...order].sort((a, b) => a - b), order);
assert.match(remove, /security definer/);
assert.match(remove, /owner uuid := \(select auth\.uid\(\)\)/);
});
@@ -513,7 +513,10 @@ test("history opens with its bound Skill and reads old engine receipt values unc
const accounting = fakeAccounting({
open_agentic_rectification_case: () => open,
get_agentic_rectification_skill_identity: () => ({ skill_name: bound.name, skill_version: bound.version, skill_sha256: bound.sha256, skill_source_commit: bound.sourceCommit }),
open_agentic_rectification_case_v2: (_fn, args) => {
// 原值:重开历史会话的第三步是 open_agentic_rectification_case_v2
// 新值:改走 open_agentic_rectification_case_v3(参数不变,多 p_product_domain / p_chart_profile_id 两个 null)
// 原因:issue #160 重开时要从服务端知道是谁的盘
open_agentic_rectification_case_v3: (_fn, args) => {
assert.equal(args.p_skill_version, bound.version);
assert.equal(args.p_skill_sha256, bound.sha256);
assert.equal("p_engine_version" in args, false);
@@ -34,15 +34,19 @@ const EMPTY: RectificationEntrySummary = {
latestTerminal: null,
};
function state(summary: RectificationEntrySummary, patch: Partial<{ signedOut: boolean; enabled: boolean | undefined; selfComplete: boolean }> = {}) {
return rectificationPageState({ signedOut: false, enabled: true, selfComplete: true, summary, ...patch });
// 原值:输入字段叫 selfComplete(只看本人资料)
// 新值:改叫 profileComplete(当前人物的资料),另有 forOther
// 原因:issue #160 起 /rectification 跟着当前人物走,档案里的人也能校正
function state(summary: RectificationEntrySummary, patch: Partial<{ signedOut: boolean; enabled: boolean | undefined; profileComplete: boolean; forOther: boolean }> = {}) {
return rectificationPageState({ signedOut: false, enabled: true, profileComplete: true, summary, ...patch });
}
test("the page refuses before reading cases: signed out, product off or unknown, no self chart", () => {
assert.equal(state(EMPTY, { signedOut: true }).kind, "signed-out");
assert.equal(state(EMPTY, { enabled: false }).kind, "unavailable");
assert.equal(state(EMPTY, { enabled: undefined }).kind, "unavailable", "a flag that did not arrive keeps the page closed");
assert.equal(state(EMPTY, { selfComplete: false }).kind, "self-missing");
assert.equal(state(EMPTY, { profileComplete: false }).kind, "self-missing");
assert.equal(state(EMPTY, { profileComplete: false, forOther: true }).kind, "other-missing");
assert.equal(state(EMPTY).kind, "none");
});
@@ -202,7 +206,10 @@ test("the starter home shows the rectification prompt only when asked, linking t
assert.match(hinted, /href="\/rectification"/);
const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
assert.match(page, /rectifyHint=\{currentSubjectId === "self" && account\.rectification === true && birthTimeNeedsRectification\(profile\)\}/);
// 原值:rectifyHint={currentSubjectId === "self" && account.rectification === true && birthTimeNeedsRectification(profile)},帮别人看时不出现
// 新值:看当前人物(dailyProfile,本人时就是本人资料)的时间是不是估的
// 原因:issue #160 起档案里的人都能做校正,启程任务书红线 11 解除
assert.match(page, /rectifyHint=\{account\.rectification === true && Boolean\(dailyProfile\) && birthTimeNeedsRectification\(dailyProfile \?\? profile\)\}/);
});
test("the sidebar lists 生时校正 under 星盘, behind the account flag", () => {
@@ -13,7 +13,6 @@ import { RECTIFICATION_OFFER_RULE } from "../src/lib/consultation-thinking-plan.
import {
blocksNewRectification,
RECTIFICATION_DIRECT_NEW_COPY,
RECTIFICATION_DIRECT_NOT_SELF_COPY,
RECTIFICATION_DIRECT_RESUME_COPY,
RECTIFICATION_DIRECT_UNAVAILABLE_COPY,
RECTIFICATION_OFFER_TOOL_ID,
@@ -44,14 +43,13 @@ const DIRECT_SENTENCES = [
RECTIFICATION_DIRECT_NEW_COPY,
RECTIFICATION_DIRECT_RESUME_COPY,
RECTIFICATION_DIRECT_UNAVAILABLE_COPY,
RECTIFICATION_DIRECT_NOT_SELF_COPY,
] as const;
const STOP_FINISH = { type: "finish", payload: { stepResult: { reason: "stop" }, output: { usage: {}, steps: [{}] } } };
type OfferDecision =
| { ok: true; creditCost: number; questionEstimate: number }
| { ok: false; reason: "not_self" | "product_disabled" | "case_in_progress" | "pricing_unavailable" };
| { ok: false; reason: "product_disabled" | "case_in_progress" | "pricing_unavailable" };
function offer(status: RectificationOfferRecord["status"] = "offered", extra: Partial<RectificationOfferRecord> = {}): RectificationOfferRecord {
return {
@@ -122,12 +120,14 @@ test("direct-entry sentences stay within the free-completion length", () => {
// 原值:[13, 13, 14, 11, 11],含「可在历史对话里接着做。」
// 新值:[13, 13, 14, 11]
// 原因:接不回进行中的校正时改为新开,不再留这句没有出口的兜底
assert.deepEqual(DIRECT_SENTENCES.map((sentence) => Array.from(sentence).length), [13, 13, 14, 11]);
// 原值(2):[13, 13, 14, 11],第四句是「给家人校正暂时还不支持」
// 新值(2):[13, 13, 14]
// 原因(2):issue #160 起档案里的人都能做校正,这句和 not_self 一起删掉
assert.deepEqual(DIRECT_SENTENCES.map((sentence) => Array.from(sentence).length), [13, 13, 14]);
for (const sentence of DIRECT_SENTENCES) assert.ok(Array.from(sentence).length <= 20);
assert.equal(RECTIFICATION_DIRECT_NEW_COPY, "好,我们开始核对出生时间。");
assert.equal(RECTIFICATION_DIRECT_RESUME_COPY, "核对还没做完,我们接着做。");
assert.equal(RECTIFICATION_DIRECT_UNAVAILABLE_COPY, "出生时间核对这会儿还做不了。");
assert.equal(RECTIFICATION_DIRECT_NOT_SELF_COPY, "给家人校正暂时还不支持");
assert.equal(blocksNewRectification("candidate_accepted"), false);
assert.equal(blocksNewRectification("collecting_evidence"), true);
assert.equal(blocksNewRectification("draft"), true);
@@ -337,16 +337,13 @@ test("explicit refusals are one sentence, and none of the four notes says to hid
assert.match(String(priced.note), /不写价格/);
assert.doesNotMatch(String(priced.note), /不要提校正/);
const family = offerTool({ decide: async () => ({ ok: false, reason: "not_self" }) });
// 原值:not_self(家人档案)明确要求时只写「给家人校正暂时还不支持」,不开校正
// 新值:没有 not_self;给家人明确要求校正和本人一样走「好,我们开始核对出生时间。」
// 原因:issue #160(产品 2026-11-01,1A)档案里的人都能校正,进行中的案例按人物分别算
const family = offerTool();
const other = await family.tool.execute({ explicitRequest: true });
assert.equal(family.state.rectificationDirectEntry?.sentence, RECTIFICATION_DIRECT_NOT_SELF_COPY);
assert.match(String(other.note), /给家人校正暂时还不支持/);
assert.doesNotMatch(String(other.note), /不要提校正/);
const plainFamily = offerTool({ decide: async () => ({ ok: false, reason: "not_self" }) });
const plain = await plainFamily.tool.execute({});
assert.equal(plainFamily.state.rectificationDirectEntry, undefined);
assert.doesNotMatch(String(plain.note), /不要提校正/);
assert.equal(family.state.rectificationDirectEntry?.sentence, RECTIFICATION_DIRECT_NEW_COPY);
assert.doesNotMatch(String(other.note), /给家人校正暂时还不支持/);
const plainPrice = offerTool({ decide: async () => ({ ok: false, reason: "pricing_unavailable" }) });
assert.doesNotMatch(String((await plainPrice.tool.execute({})).note), /不要提校正/);
const plainOff = offerTool({ decide: async () => ({ ok: false, reason: "product_disabled" }) });
@@ -722,7 +719,10 @@ test("the offer rule is defined once, and direct entry settles with the free com
// 原值:route 直接 getRectificationEntrySummary 后 from("agentic_rectification_cases").select("session_id")
// 新值:route 调用 loadResumableRectificationSessionId,不再读这张表
// 原因:服务角色对该表没有读权限,直接读必然失败还被吞掉
assert.match(route, /loadResumableRectificationSessionId\(accounting, userId\)/);
// 原值(2):loadResumableRectificationSessionId(accounting, userId)
// 新值(2):多传这次对话的人物 rectificationSubjectId(本人为 null)
// 原因(2):issue #160(1A)进行中的案例按人物分别算,接着做的只能是这个人的那次
assert.match(route, /loadResumableRectificationSessionId\(accounting, userId, rectificationSubjectId\)/);
assert.doesNotMatch(route, /\.from\("agentic_rectification_cases"\)/);
assert.doesNotMatch(route, /\.select\("session_id"\)/);
const serverFile = src("../src/lib/rectification-in-chat-server.ts");
@@ -770,8 +770,11 @@ test("the offer rule is defined once, and direct entry settles with the free com
const surface = src("../src/hooks/use-rectification-surface.ts");
const fromChat = surface.slice(surface.indexOf("async function openRectificationFromChat"), surface.indexOf("async function openRectificationSession"));
assert.match(fromChat, /return null/);
assert.match(fromChat, /return openRectificationCase\("homepage", null, sourceSessionId\)/);
// 原值:fromChat 先 rectificationLimitedToSelf()(非本人 return null),再 openRectificationCase("homepage", null, sourceSessionId)
// 新值:不再拦非本人,按当前人物开:openRectificationCase("homepage", null, sourceSessionId, readCurrentSubjectId())
// 原因:issue #160 档案里的人都能做校正
assert.doesNotMatch(fromChat, /rectificationLimitedToSelf|只支持本人/);
assert.match(fromChat, /return openRectificationCase\("homepage", null, sourceSessionId, readCurrentSubjectId\(\)\)/);
});
function cardHtml(record: RectificationOfferRecord) {
@@ -109,29 +109,29 @@ test("the server decides whether a card may be shown: self, product on, no case
const yes = async () => true;
const no = async () => false;
const price = async () => 3;
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: yes, hasResumableCase: yes, creditCost: price }), { ok: false, reason: "case_in_progress" });
// 原值:decideRectificationOfferWith({ subjectRole: "other", ... }) → { ok: false, reason: "not_self" },家人档案不出卡
// 新值:不再收 subjectRole,没有 not_self;hasResumableCase 由服务端按这个人物去读(decideRectificationOffer 的 subjectId)
// 原因:issue #160(产品 2026-11-01,1A)档案里的人都能校正,进行中的案例按人物分别算。测试名沿用原名(frontend/AGENTS.md:测试名不得消失)
assert.deepEqual(await decideRectificationOfferWith({ productEnabled: no, hasResumableCase: no, creditCost: price }), { ok: false, reason: "product_disabled" });
assert.deepEqual(await decideRectificationOfferWith({ 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: price }), { ok: true, creditCost: 3, questionEstimate: 8 });
assert.deepEqual(await decideRectificationOfferWith({ productEnabled: yes, hasResumableCase: no, creditCost: async () => { throw new Error("feature_pricing_unavailable"); } }), { ok: false, reason: "pricing_unavailable" });
assert.deepEqual(await decideRectificationOfferWith({ productEnabled: yes, hasResumableCase: no, creditCost: price }), { ok: true, creditCost: 3, questionEstimate: 8 });
});
function offerTool(options: { alreadyOffered?: boolean; decide?: () => Promise<{ ok: true; creditCost: number; questionEstimate: number } | { ok: false; reason: "not_self" | "product_disabled" | "case_in_progress" | "pricing_unavailable" }> } = {}) {
function offerTool(options: { alreadyOffered?: boolean; decide?: () => Promise<{ ok: true; creditCost: number; questionEstimate: number } | { ok: false; reason: "product_disabled" | "case_in_progress" | "pricing_unavailable" }> } = {}) {
const state = createConsultationRuntimeState();
// 原值:建出来还没排盘也能直接提议。
// 新值:这些用例表示已经看过盘,所以先标计算完成。
@@ -171,11 +171,13 @@ test("the offer tool records one offer per turn and never re-offers unasked in t
assert.equal(declined.state.rectificationOffer, undefined);
assert.equal((await declined.tool.execute({ userAsked: true })).offered, true, "the user asking again opens it");
const family = offerTool({ decide: async () => ({ ok: false, reason: "not_self" }) });
const refused = await family.tool.execute({});
assert.equal(refused.offered, false);
assert.match(String(refused.note), /给家人校正暂时还不支持/);
assert.equal(family.state.rectificationOffer, undefined);
// 原值:not_self 时不出卡,正文说「给家人校正暂时还不支持」
// 新值:家人档案和本人一样出卡(是否出卡只看开关、这个人物有没有进行中的案例和价格)
// 原因:issue #160(产品 2026-11-01,1A)
const family = offerTool();
const offeredForFamily = await family.tool.execute({});
assert.equal(offeredForFamily.offered, true);
assert.doesNotMatch(String(offeredForFamily.note ?? ""), /给家人校正暂时还不支持/);
});
test("the completed event carries the offer, so the card appears without a reload (BUG-1248)", () => {
@@ -280,7 +282,10 @@ test("nothing is sent until the revisit button is clicked; the offer opens the c
assert.match(cards, /onClick=\{\(\) => onRevisit\(record\.sourceQuestion\)\}/);
assert.doesNotMatch(cards, /useEffect/);
const surface = readFileSync(new URL("../src/hooks/use-rectification-surface.ts", import.meta.url), "utf8");
assert.match(surface, /openRectificationCase\("homepage", null, sourceSessionId\)/);
// 原值:openRectificationCase("homepage", null, sourceSessionId)
// 新值:多传当前人物 readCurrentSubjectId()
// 原因:issue #160 档案里的人都能做校正,案例记下是谁的盘
assert.match(surface, /openRectificationCase\("homepage", null, sourceSessionId, readCurrentSubjectId\(\)\)/);
assert.doesNotMatch(surface.slice(surface.indexOf("async function returnToRectificationSource")), /\/api\/consult/);
});
@@ -46,9 +46,12 @@ test("production open route reads owned consultation themes and fixes new-case t
calls.push({fn,args});
if(fn==='get_agentic_rectification_skill_identity')return {error:null,data:{skill_name:skill.name,skill_version:skill.version,skill_sha256:skill.sha256,skill_source_commit:skill.sourceCommit}};
if(fn==='open_agentic_rectification_case')return {error:null,data:response()};
if(fn==='open_agentic_rectification_case_v2'){
if(args.p_intent==='session')assert.ok(!Object.hasOwn(args,'p_product_domain'));
else if(persistedDomain===null)persistedDomain=args.p_product_domain;
// 原值:exact session 也走 open_agentic_rectification_case_v2
// 新值:exact session 走 v3(同样不带 p_product_domain),新开本人的 case 仍走 v2
// 原因:issue #160 重开时要从服务端知道是谁的盘
if(fn==='open_agentic_rectification_case_v2'||fn==='open_agentic_rectification_case_v3'){
if(args.p_intent==='session'){assert.equal(fn,'open_agentic_rectification_case_v3');assert.ok(!Object.hasOwn(args,'p_product_domain'));}
else{assert.equal(fn,'open_agentic_rectification_case_v2');if(persistedDomain===null)persistedDomain=args.p_product_domain;}
return {error:null,data:response()};
}
throw new Error('unexpected RPC '+fn);
@@ -99,7 +102,7 @@ test("production open route reads owned consultation themes and fixes new-case t
const general=await post({intent:'homepage',requestId:body.requestId}); assert.equal(general.status,200); assert.equal(persistedDomain,'general'); assert.equal(queries.length,0);
calls=[]; queries=[];
const exact=await post({intent:'session',sessionId,requestId:body.requestId}); assert.equal(exact.status,200);
assert.equal(queries.length,0); assert.ok(!Object.hasOwn(calls.find(c=>c.fn==='open_agentic_rectification_case_v2').args,'p_product_domain'));
assert.equal(queries.length,0); assert.ok(!Object.hasOwn(calls.find(c=>c.fn==='open_agentic_rectification_case_v3').args,'p_product_domain'));
assert.equal((await post({...body,domain:'career'})).status,400);
`;
const result = spawnSync(process.execPath, ["--experimental-test-module-mocks", "--import", "tsx", "--input-type=module", "--eval", script], {
@@ -579,7 +579,7 @@ test("session open uses the Case-bound skill identity, not the current registry
error: null,
};
}
if (fn === "open_agentic_rectification_case_v2") {
if (fn === "open_agentic_rectification_case_v3") {
captured.v2 = args;
return { data: sessionOpenPayload(bound.version), error: null };
}
@@ -594,11 +594,16 @@ test("session open uses the Case-bound skill identity, not the current registry
});
assert.equal(response.disposition, "readonly");
// 原值:第三步是 open_agentic_rectification_case_v2
// 新值:第三步是 open_agentic_rectification_case_v3(同样的参数,仍不带 p_product_domain,也不带 p_chart_profile_id)
// 原因:issue #160 重开某次会话时要知道这次是谁的盘,v3 在回执里带上 chart_profile_id
assert.deepEqual(captured.names, [
"open_agentic_rectification_case",
"get_agentic_rectification_skill_identity",
"open_agentic_rectification_case_v2",
"open_agentic_rectification_case_v3",
]);
assert.equal(Object.hasOwn(captured.v2 ?? {}, "p_product_domain"), false);
assert.equal(Object.hasOwn(captured.v2 ?? {}, "p_chart_profile_id"), false);
assert.equal(captured.v2?.p_session_id, sessionId);
assert.equal(captured.v2?.p_skill_name, bound.name);
assert.equal(captured.v2?.p_skill_version, "10.0.17");
@@ -103,6 +103,17 @@ export function fakeAccounting(
const rawClient: FakeAccounting["client"] = {
rpc(fn, args) {
calls.push({ fn, args });
// Issue #160: widening patches the case's person through this RPC
// instead of profiles.update. Recorded in the same shape so the widen
// tests keep asserting the same patch.
if (fn === "patch_agentic_rectification_case_uncertainty_v1" && !handlers[fn]) {
profilePatches.push({
birth_time_source: "approximate",
uncertainty_before_minutes: args.p_before_minutes,
uncertainty_after_minutes: args.p_after_minutes,
});
return Promise.resolve({ data: null, error: null });
}
// Production decisions now read the birth snapshot. Fakes that never
// stubbed this RPC used to omit the date; a fictional early date keeps
// modern probes askable and does not relax the adult floor.