Merge #160 rectification for other people into staging integration
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:
@@ -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 大运」两张卡和深读「命性格局」「排盘底册」里的表都搬到这里,不再重复。
|
||||
|
||||
@@ -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
@@ -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
@@ -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 的校正记录一起删除,扣过的点不退,和删除对话一样,不另写一句。本人不能删除,不要给删除按钮配一句绕过去的确认。
|
||||
|
||||
余额不足时输入框占位写「余额不足,可在账户与点数中补充」,不要叫用户去会员页。
|
||||
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user