Compare commits

..
Author SHA1 Message Date
jesse-uxandGrok (agent loop) 4a9ad6a1bd fix(rectification): open a new case when an accepted one blocks chat
An accepted rectification no longer counts as in progress at the chat entry, so an explicit request starts a new case and the offer card can return. An unfinished case still resumes through the case RPC. A failed lookup is logged without personal data and opens a new case.

Self-test: tsc 0, lint 0 errors, 47 targeted tests, test:db failures match the Windows list, next build with / static.

Co-Authored-By: Grok (agent loop)
2026-10-23 01:55:59 +08:00
Jesse_ChenandClaude Opus 5.5 d72adb5754 docs(tasks): rectification explicit open brief (agent loop, issue #103)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Ad9rqAB8pXXvyzh7f4X8v
2026-10-23 00:30:54 +08:00
Jesse_ChenandClaude Opus 5.5 dd5ae8d032 docs(tasks): chart table span brief (agent loop, issue #102)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Ad9rqAB8pXXvyzh7f4X8v
2026-10-23 00:26:37 +08:00
Jesse_ChenandClaude Opus 5.5 9049b5e997 docs(tasks): ui kit prototype v5 confirmed by product
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Ad9rqAB8pXXvyzh7f4X8v
2026-10-23 00:23:31 +08:00
Jesse_ChenandClaude Opus 5.5 052a4bf70c docs(tasks): ui kit — synastry start button shows cost in a confirm dialog
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Ad9rqAB8pXXvyzh7f4X8v
2026-10-23 00:22:22 +08:00
Jesse_ChenandClaude Opus 5.5 23f1b31787 docs(tasks): ui kit — segmented Tabs for chart-type, language and theme switches
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Ad9rqAB8pXXvyzh7f4X8v
2026-10-23 00:17:30 +08:00
Jesse_ChenandClaude Opus 5.5 01ec3d229b docs: ui kit migration brief (shadcn/lucide/motion/resizable/dnd-kit/zustand); component-library rule in AGENTS
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Ad9rqAB8pXXvyzh7f4X8v
2026-10-23 00:15:44 +08:00
Jesse_ChenandClaude Opus 5.5 3632b0eabd docs(agents): fix bugs at the cause; cut redundant UI copy but keep accessible names
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Ad9rqAB8pXXvyzh7f4X8v
2026-10-23 00:11:56 +08:00
50 changed files with 860 additions and 1380 deletions
+7 -1
View File
@@ -92,6 +92,8 @@
这份文件是产品与代码 Bug 的长期历史入口;`docs/research/pre_work_error_ledger.md` 负责基础设施、外部引擎、碎片目录与预检风险,两者不得互相替代。
**修在根因上(2026-10-23 产品定)**:Fix a bug at its cause, reasoning from first principles and established best practice. Do not patch over the symptom.
1. 修复前必须用报错原文、接口路径、状态码、模块名和用户操作搜索历史记录,优先检查相同模块的根因与防复发措施。
2. 若历史问题复发,必须关联原 `BUG-NNN`,说明旧测试、约束或发布门禁为何未拦住;不得把复发伪装成无关的新问题。
3. Bug 修复必须在同一变更中更新 `docs/BUG_HISTORY.md`:补充现有记录或新增连续编号,并记录状态、现象、触发条件、根因、修复、验证、防复发、关联记录和修复版本。
@@ -110,13 +112,17 @@
## 7. 前端红线
1. `./node_modules/.bin/tsc --noEmit` 通过;`npm run lint` **0 error**(react-hooks 编译器规则会拦 effect 内同步 setState、render 写 ref 等)。
2. `next build` 后 `/` 保持 `○ Static`;首屏 gzip 变化在 ±2% 内,超出要在进度记录里给出原因。
2. `next build` 后 `/` 保持 `○ Static`;首屏 gzip 变化在 ±2% 内,超出要在进度记录里给出原因。动效、拖拽、可拖宽布局这类库(Motion、dnd-kit、react-resizable-panels)一律按需加载,不进首屏;±2% 不因引入它们放宽(2026-10-23 产品定)。
3. 测试总数不得低于开工时 `origin/staging` 的实测;改任何既有断言必须写"原值 / 新值 / 原因"三栏说明,不得静默弱化。
4. 合同测试的 fixture 必须来自真实引擎响应(golden),不得手造形状。
5. 改 UI 的提交同时更新 `frontend/DESIGN.md`;新文案对照 `frontend/docs/VOICE.md`。
6. 不改数据库结构的轮次不得顺带动迁移;动表的轮次必须真跑 `npm run test:db`。staging 迁移在部署之前自动应用,因此必须对当前已部署的那一版代码向后兼容:加列、加表、加索引、加触发器、加函数可以同轮;删列、删表、重命名、收紧 `NOT NULL` / `CHECK`、改类型必须拆成两轮——先加新的并部署代码,再删旧的。自托管 PostgreSQL 兼容层的 stub 测不到真实查询语法,新用到的查询写法必须 `test:db` 真查或部署后 smoke(BUG-990:未实现的 `not(col,"is",null)` 让归档列表 500 了两周)。
7. 不得顺手升级依赖、不得顺手修不在任务书里的 warning;发现了写进 `BLOCKED.md` 或进度记录。
8. 移动、删除或改写组件与语句前,先在 `frontend/tests/` 和 `tests/` 里 grep 该文件路径、要删的类名与语句,找出按源码文本断言的合同测试,同一轮跟着改(按第 3 条写三栏)。漏改会让门禁红而业务代码是对的,已发生多次:BUG-933 / 934 / 939 / 992 / 1014。
9. Avoid unnecessary secondary labels and descriptions. Let the layout do the explaining.(2026-10-23 产品定)
10. Avoid unnecessary supporting text, descriptive copy, and redundant UI microcopy.(2026-10-23 产品定)
11. 删掉重复解释布局的文案,但保留必要的表单标签和控件名称。比如搜索框旁边已有「搜索」按钮,视觉上可以省掉重复标签,代码里的可访问名称却不能一起删。界面清爽和读屏能用不冲突。(2026-10-23 产品定)
12. **已有依赖能正确表达的组件和行为,严禁自行设计实现**(2026-10-23 产品定)。指定依赖:组件用 shadcn/ui(`base-nova`,见 `frontend/components.json`,缺的组件用 shadcn CLI 加,不手抄改写);图标只用 Lucide;动效只用 Motion;可拖宽 / 可收起的分栏用 react-resizable-panels(经 shadcn `Resizable`);拖拽排序用 dnd-kit;跨组件状态用 Zustand。GSAP 不再使用。新增手写的按钮、弹窗、菜单、下拉、页签、抽屉、提示框由 lint 拦截;迁移完成前存量文件列在白名单里,白名单只许减少。占星专有的画法(星盘、星图、报告排版)不在此列。
## 8. 隐私与安全
+5 -4
View File
@@ -1,10 +1,11 @@
# 印度占星 Skill 更新日志
## 2026-10-23 — 新账户可以先问一个问题,档案能标明和你的关系
## 2026-10-23 — 明说要校正时,已采用过的人能直接新开一次
- 每个账户有一次不扣点的正式提问。问完才算用掉;中途停下、失败,或只是寒暄,这一次还在。选了比标准档更贵的模型时,只扣高出的那一部分。
- 星盘档案里,别人可以标明是伴侣、孩子、父母、朋友或其他。没选过的显示「其他」。本人仍然只标「本人」。
- Skill 版本不变。
- 已经采用过出生时间的人,在对话里说要做生时纠正,会直接开始一次新的核对,不再只回「可在历史对话里接着做。」
- 真没做完的校正仍然接着做。这次如果接不回原来那次,也直接新开。
- 已采用过的账户,普通对话里的校正提议卡会重新出现。
- Skill 版本不变(BUG-1421)。
## 2026-10-22 — 星盘大运右栏可以切到 Yogini
-4
View File
@@ -72,10 +72,6 @@ _避免使用_:用户反馈状态、通知状态
一张星盘档案对应的一个人。户主本人也是其中一个人。不要再叫「当前星盘」来指这个人。
_避免使用_:当前星盘、关系、默认星盘
**关系(人物)**:
这个人相对户主的家里关系:伴侣、孩子、父母、朋友或其他。只记在别人的档案上,本人不记。没填过的显示「其他」。它不是人物的别名。
_避免使用_:用「关系」称呼这个人、家人(笼统代替这五项)、客户
**星盘档案**:
侧栏页面 `/people` 里保存的人物列表。最多 5 人,含本人。本人不可删除。
_避免使用_:星盘库、星盘资料(设置里的旧面板)
+11 -11
View File
@@ -19254,18 +19254,18 @@
- 复发自:无
- 修复版本:本提交
## BUG-1421 | 新账户第一次提问就被兑换窗拦住
## BUG-1421 | 明说要做生时纠正只回一句「可在历史对话里接着做」
- 状态:resolved
- 首次发现:2026-10-22
- 最近更新:2026-10-23
- 影响面:已登录、点数为 0、没有会员的新账户。第一次正式提问发不出去,直接打开兑换窗。
- 用户现象:建完档之后,还没问成任何问题,输入框就已经按「没有点数」处理,发送时弹出兑换。
- 触发条件:账户点数默认是 0,发送前只看「点数用完且没有会员」。
- 根因:没有「每个账户一次、由服务端记账」的首问。前端和预扣都把 0 点当成必须先付费。
- 修复:服务端在同一笔预扣里判断这一次是否还免费。标准档这一问不扣点;选了更贵的模型只扣高出标准档的差价。回答完成才算用掉。停止、失败或寒暄都把这一次还回来。账户接口只多给一个 `firstConsultFree`。前端在它为真时不拦这一问。老账户里已经有过一次完成的正式咨询,迁移时记为已用过。
- 验证:`frontend/tests/database-onboarding-journey.test.ts`(0 点首问、并发只有一条免费、取消和寒暄后仍可用、高档只扣差价、已有正式咨询的老账户不再免费)。`frontend/tests/onboarding-journey-data.test.ts`。
- 防复发:数据库测试里,没有公布标准档价格时仍按传入的点数预扣,避免把所有咨询都变成免费;并发两条时只有一条成功且余额仍是 0。
- 相关记录:任务书 `TASK-onboarding-journey-20261022.md` T1。issue #95。T3、T4 的现象不在本条。
- 复发自:无
- 修复版本:分支 `codex/onboarding-1-data-20261022`。
- 影响面:对话里明确要求做生时校正的入口,以及普通对话里的校正提议卡
- 用户现象:已经采用过出生时间的人,在对话里说要做生时纠正,只收到一句「可在历史对话里接着做。」没有卡片,也进不了校正。真正没做完的校正同样接不回去。
- 触发条件:本人档案;出生时间显示已采用;在对话里明确要求校正。没做完的校正也会走到同一句,因为查找会话失败被吞掉。
- 根因:三条。一,找会话时直接读一张服务角色没有权限的表,失败被静默吞掉,所以真正没做完的也续不上。二,把已经采用(实际上的终点)算作还在进行,明说入口和提议卡一起被挡住。三,失败时只给一句话,没有能点的出口。
- 修复:入口只把还没做完的五种状态当作进行中,已采用不再挡住,明说要校正就新开一次,提议卡也按这个口径恢复。查找会话改走已有的校正读取接口。读不到就记一条不含个人资料的日志,并直接新开。删掉「可在历史对话里接着做。」共享的状态集合和数据库没有改。
- 验证:入口口径单测;接不回时改为新开并有一条日志;`database-rectification-resume-session-20261023.test.ts` 用服务角色经接口拿到进行中校正的会话,直接读表仍然被拒绝。
- 防复发:上面的口径单测和这条真库测试。再改回直接读表,或把已采用重新算作进行中,测试会失败。
- 相关记录:BUG-1306、BUG-1248、BUG-177。issue #103。
- 复发自:BUG-1306
- 修复版本:`codex/rectification-explicit-open-20261023`
@@ -1,52 +0,0 @@
# PROGRESS · 新用户启程(一)数据层(2026-10-22)
Issue #95。只做 T1 首问免费和 T2 引导状态、人物关系。T3–T7 不做。
## 基线
- 开工时 `origin/staging` = `dfa9acaf05089dbb94ee8afe34c1d5626ffb1b23`(任务书写的 `c1aa72fcb` 已过期)。
- BUG-1420 已被「Bug 历史去重」占用。本轮首问免费记 **BUG-1421**。
## 做了什么
- 迁移 `20261022190000_onboarding_first_consult_and_relation.sql`:账户上记下首问是否用过、当前占用的请求、引导状态;别人的档案增加关系。只加列和约束,不删废列。
- 预扣在同一笔事务里占用免费额度。回答完成才记为已用。取消或寒暄把占用还回去,不记成已用。
- 账户接口增加 `firstConsultFree`。前端发送前和输入框占位,在它为真时不再当「没有点数」。
- 已登录接口读写引导状态。只接受清单关闭时间、看过的提示、进过星盘页。提示 id 只收任务书 T6 那八个。未登录 401。写入用当前登录的人,不读请求体里的用户 id。
- 档案的添加和编辑(档案页和星盘页编辑别人)加「TA 是你的」。本人不出现这项。空关系显示「其他」。
## 红线 3 怎么选
选了 **只扣差价**。免费覆盖的是已经公布的 `chat.standard` / `standard` 价格。传入的价格更高时,只扣多出来的部分。这样标准档仍是 0 点,更贵的模型也不会整问白送。
没有公布中的标准档价格时,**不免费**,仍按传入的点数扣。现有计费测试没有这条价格,因此不会被误改成免费。
会员已经包住这一问时,不占用免费额度,留给还没有会员的那一次。合盘报告会话不占用这份额度。
0 点流水不能写进点数流水(现有约束不允许金额为 0)。记在用量台账上,后台「资金来源」显示「首问免费」;差价大于 0 时显示「首问免费(差价 N 点)」。筛选里原来对不上的 complimentary 换成「首问免费」。
## 老账户
只把「已经有过一条完成的、且不是寒暄的咨询」标成用过。只有寒暄的账户仍然有这一次。迁移结束时跑一遍,测试可以再跑同一函数。
## 本人与关系
本人档案行已经被现有约束拒绝写入(`chart_subject_self_mirror_retired`)。关系只存在别人的行上。本人界面仍只标「本人」。
## 自测
| 检查 | 命令 | 结果 |
| --- | --- | --- |
| 类型 | `frontend\node_modules\.bin\tsc --noEmit -p frontend` | 通过,0 错 |
| lint | `frontend` 下 `npm run lint` | 0 error(原有 warning 未动) |
| 定向测试 | `node node_modules/tsx/dist/cli.mjs --test` 跑了本轮改到的测试文件 | 通过 |
| 数据库 | `frontend` 下 `npm run test:db` | 91 项,通过 77,失败 14,取消 0。其中 13 条失败与 `tools/agent-loop/windows-known-failures.txt` 同名。多出的一条是校正遥测,见下方 |
未跑前端全量 `npm test` 和 Python 快速门(本轮不改 Python)。
全套里多出的失败是「rectification telemetry: server-only write, dedupe, retention, aggregate-only admin read, account cascade」。迁移进程退出码 1,报错只有 database migration failed,用时约 10 秒,容器日志没有 SQL 错误。同一轮里本单自己的数据库测试通过。这条遥测单独再跑:通过 1,失败 0。没有把它写进已知失败清单。
## 偏离
- 任务书 BUG 起点 1420 已占用,顺延为 1421。
- 没有单独造一套会员夹具去测「会员不占免费额度」;规则写在预扣函数里:来源是会员时不写入占用。
@@ -0,0 +1,40 @@
# PROGRESS · 明说要校正能打开(2026-10-23)
Issue #103。BUG-1421。
## 基线
- 开工时 `origin/staging` = `d72adb5754eab4d66ac3a47c4edcb12704e29815`(任务书写的 `dd5ae8d03` 已过期)。
- 本文件最大编号是 BUG-1420,本单用 BUG-1421。
## 做了什么
- 对话入口不再把「已采用」当成还在进行。明说要校正就新开一次,普通提议卡也按这个口径恢复。
- 真没做完的五种状态仍接着做。找会话改走已有的校正读取接口,不再直接读那张表。
- 读不到会话时记一条日志(只有请求号、错误类别、有没有 caseId),然后新开,不再说去历史对话接着做。
- 删掉过期的「每人最多一条进行中校正」常量。共享状态集合和数据库没改。
## 已知边界
摘要只看最近一次。如果最近一次已经采用、更早还有一次没做完,这次按没有进行中处理,直接新开。更早那次仍留在历史对话里。数据库允许同一个人有多条校正。
## 自测
| 检查 | 命令 | 结果 |
| --- | --- | --- |
| 类型 | `frontend\node_modules\.bin\tsc --noEmit -p frontend` | 退出码 0 |
| lint | `frontend` 下 `npm run lint` | 退出码 0。0 error,122 条既有 warning,本轮不改 |
| 定向测试 | 任务书点名的三个测试文件 | 47 条通过,0 失败 |
| 真库 | `database-rectification-resume-session-20261023.test.ts` | 1 条通过。服务角色经接口拿到进行中校正的会话;直接读表被拒绝 |
| 全量真库 | `frontend` 下 `npm run test:db` | 91 条里通过 78、失败 13。失败名单与 `tools/agent-loop/windows-known-failures.txt` 逐条相同,没有多出来的 |
| 构建 | `frontend` 下 `npm run build` | 退出码 0。路由表 `/` 为 `○ Static`。预渲染 `index.html` 引用 33 个 JS、3 个 CSS,gzip-9 合计 646,232 字节(JS 575,154,CSS 71,078)。没有用改动前的代码再构建一次,所以不写增减百分比 |
未跑前端全量 `npm test`(验收时跑)。真实模型会不会调用工具,留给 `docs/testing/rectification-explicit-open-20261023.md`。
## 让步
- T3 的自动化测试改写成真机清单第 4 步。入口和真库测试没有让。
## 偏离
- 无。编号说明:本分支的 BUG-1421 与尚未合并的 #95、#102 分支各自占用了同一个号。若那些先进入 staging,验收时给本条改号。
+5 -2
View File
@@ -37,8 +37,11 @@
| 任务书 | 进度 | 主题 | 状态 | 落点 |
| --- | --- | --- | --- | --- |
| `TASK-onboarding-journey-20261022.md` | `PROGRESS-onboarding-1-data-20261022.md` | **新用户启程**:建档加「先看谁的盘」(自己 / 帮别人看,本人资料可后补)、人物关系字段、首问免费(服务端原子)、起步三问、启程清单(七项从数据推导,收费项标价,老用户可见)、八条一次性提示(含星盘四页签)、启程流程专用动效。产品 2026-10-22 拍板 D1~D6;动表经产品同意进循环,拆四个 issue 串行。BUG-1420 已被文档去重占用,本轮首问免费记 BUG-1421。(一)T1+T2 在 `codex/onboarding-1-data-20261022` | (一)待验收;(二)(三)(四)待领取 | 原型 https://claude.ai/artifact/PcTwMVb9RD611R3qStxv3U |
| `TASK-site-motion-20261022.md` | — | **全站动效**:换页按侧栏方向滑入、选中块跟随、页签、盘型切换星体移宫、换人、翻日期、列表补位、弹窗从触发处长出、抽屉、顶部提示、点数滚动、中英交叉淡入;重写 DESIGN §6(推翻 180 ms 上限与 Spatial Instant),不引动画库,先验证 View Transitions。产品 2026-10-22 拍板;三个 issue,排在启程(三)之后、启程(四)之前 | 待领取(循环) | 原型 https://claude.ai/artifact/7u9goivnmTNSGTNWNeVMQd |
| `TASK-rectification-explicit-open-20261023.md` | `PROGRESS-rectification-explicit-open-20261023.md` | **明说要校正只回一句话(issue #103)**:找会话直接读一张对服务角色撤权的表、必然失败被吞(真正进行中的也续不上);`candidate_accepted` 被算作进行中挡住入口和提议卡;兜底只给一句话。产品选 C;只改入口口径、经 RPC 找会话、删兜底,不改共享状态集合、不动表。BUG-1421 | 待验收 | 分支 `codex/rectification-explicit-open-20261023` |
| `TASK-chart-table-span-20261023.md` | — | **星盘页本命 / 月亮行星表挤进左栏(issue #102)**:#87 给表包了 `ChartSymbolHelp` 后跨栏规则失配;根因修法是 `VedicLayout` 用固定整行容器承载表格,跨栏只认容器;加结构断言防复发 | 待执行(循环) | 分支 `codex/chart-table-span-20261023` |
| `TASK-ui-kit-migration-20261023.md` | — | **界面统一换成组件库**:shadcn base-nova(补 `shadcn/tailwind.css`)· Lucide · Motion · react-resizable-panels · dnd-kit · Zustand;lint 拦手写控件(白名单只减不增);对话旁星盘面板;会话、档案拖动排序(动表);长相不变。产品 2026-10-23 拍板 D1~D8;8 个 issue(K0~K7)串行,取代全站动效 #98 #100 #101 | 待领取(循环) | 原型 https://claude.ai/artifact/PGnWsjHhu38KJseEfEMNWG |
| `TASK-onboarding-journey-20261022.md` | — | **新用户启程**:建档加「先看谁的盘」(自己 / 帮别人看,本人资料可后补)、人物关系字段、首问免费(服务端原子)、起步三问、启程清单(七项从数据推导,收费项标价,老用户可见)、八条一次性提示(含星盘四页签)、启程流程专用动效。产品 2026-10-22 拍板 D1~D6;动表经产品同意进循环,拆四个 issue 串行。BUG-1420 起 | 待领取(循环) | 原型 https://claude.ai/artifact/PcTwMVb9RD611R3qStxv3U |
| `TASK-site-motion-20261022.md` | — | **全站动效**:换页按侧栏方向滑入、选中块跟随、页签、盘型切换星体移宫、换人、翻日期、列表补位、弹窗从触发处长出、抽屉、顶部提示、点数滚动、中英交叉淡入;重写 DESIGN §6(推翻 180 ms 上限与 Spatial Instant),不引动画库,先验证 View Transitions。产品 2026-10-22 拍板;三个 issue,排在启程(三)之后、启程(四)之前;**2026-10-23 被 ui-kit-migration 取代** | 已取代 | 原型 https://claude.ai/artifact/7u9goivnmTNSGTNWNeVMQd |
| `TASK-rectification-narrower-delivery-20261016.md` | `PROGRESS-rectification-narrower-delivery-fix-20261017.md` | **交付区间收窄**:显示改按活着的簇分段。第 1 次打回只补两处显示漏段(取整差 8 分、缺区间整段退回)。对半出题和门槛 8 改 6 仍不做。基线与对照各 924 条,12 格包含率都过红线。BUG-1367 起,本轮原记 BUG-1370,因与婚恋合盘保留号冲突改为 BUG-1392 | 待验收 | `codex/rectification-narrower-delivery-20261016` |
| —(子代理直接执行,无任务书) | `PROGRESS-bug-1060-d9-double-transit-20260927.md` | **双重过运 D9 目标错配(BUG-1060)**:D9 第 N 宫目标误用 D9 上升、D9 宫主目标误用 D1 度数,改为标签所写的 D9 星座;D1 / CL 层逐字节不变;数据卡 golden 重采集(一张盘跨层结论 9→10);另记 BUG-1061 跨层数字比对(未修) | 已验收(待部署) | `codex/bug-1060-d9-double-transit-20260927`(未推送) |
| —(子代理直接执行,无任务书;产品 2026-09-27 授权) | `PROGRESS-bug-1061-cross-layer-pairing-20260927.md` | **双重过运跨层配对按名字数字比对(BUG-1061)**:改为结构化身份配对(D1 第 N 宫↔D9 第 N 宫;D1 第 N 宫主↔同一颗星的 D9 目标);各层结果与 D1/CL golden 逐字节不变;48 样本跨层条目 31→38;数据卡 golden 重采集(乔布斯 6→9、泰勒 9→10) | 已验收(待部署) | `codex/bug-1061-cross-layer-pairing-20260927`(未推送) |
@@ -0,0 +1,80 @@
# TASK · 星盘页本命 / 月亮盘的行星表被挤进左栏(issue #102,2026-10-23)
## 基线
- `origin/staging`:写本单时为 `9049b5e99`。开工时以当时的 `origin/staging` 为准。
- 执行方:Grok(Windows + Docker)。分支 `codex/chart-table-span-20261023`,只推这一个分支。
- BUG 编号:开工时运行 `grep -oE "BUG-[0-9]+" docs/BUG_HISTORY.md | sort -t- -k2 -n | tail -1`,从下一个号起(写本单时最大号是 BUG-1420)。
## 事故实证
- **现象**:桌面宽度(≥1024px)下,星盘页选「本命」或「月亮」时,行星表只占左栏,和盘一样宽(约 466px)。宿主、pada、状态三列要横向拖动才看得到;右栏在参数卡下面空着。D2–D60、Bhava、行运的表都正常。
- **布局规则**:`frontend/src/app/globals.css` 的桌面媒体段(`@media (min-width: 1024px)`,符号 `.chart-page-planet-table-wrap, .chart-page-varga-table-block { grid-column: 1 / -1; }`)把这两个类名排成跨两栏的整行。
- **网格结构**:`frontend/src/components/chart-page/chart-vedic-tab.tsx` 的 `VedicLayout` 网格下有三类直接子项:`.chart-page-vedic-stage`(盘)、`.chart-page-params`(参数卡)、`{children}`(表)。
- **出问题的地方**:同文件 `NatalTable` 在 #87(`d2edf8b7a`,「上升的状态格改成「/」」)里被外包了一层 `ChartSymbolHelp`,渲染出来是 `<div class="chart-page-symbol-help">`。网格的直接子项变成了这个 div,上面的跨栏规则匹配不上,表就被自动排进了左栏第二行。
- **为什么分盘没事**:`ChartTypeBody` 里分盘外面多了一层 `.chart-page-varga-table-block`,仍然命中跨栏规则。本命、月亮两处直接放 `NatalTable`,所以出事。
- **违反的设计**:`frontend/DESIGN.md` 写着「桌面(≥1024)盘面与参数表左右并排,行星表是跨两列的整行——它的列宽需要完整版心」。
- **为什么没拦住**:#87 的进度记录(`docs/tasks/PROGRESS-chart-ascendant-slash-20261021.md`)写明没跑全量 `npm test`、没跑 `next build`,也没登录后看页面;而且现有测试没有一条断言 `grid-column`。
## 根因
跨栏规则是按「表格外层恰好是某个类名」写的,网格自己并不掌握「第三行是整行」这件事。所以任何人往表外面再包一层,布局就会悄悄坏掉。这一次是 #87 包了一层 `ChartSymbolHelp`,下一次可能是别的。
## 决策记录
- 产品 2026-10-22 把本问题提成 issue #102,2026-10-23 要求先规划。
- 按 AGENTS.md §5「修在根因上」,**不采用**「在跨栏规则里再补一个 `.chart-page-symbol-help` 类名」这种对症补丁。改为让网格自己掌握结构:`VedicLayout` 用一个固定的整行容器装 `children`,跨栏规则只认这个容器。
- 不改表格的列数、列宽,不动 D1 的字节冻结。
## 硬红线
1. 不改表格内容、列、列宽,也不改 `ChartSymbolHelp` 的行为(「/」的说明气泡必须照常弹出,不能被裁掉)。
2. 手机端不得回归 BUG-1083:390px 宽时页面 `scrollWidth` 必须等于 390,表格只能在自己的盒子里横滚。
3. `chart-varga-table.test.tsx` 的 D1 字节冻结、`chart-page-mobile-width-20260929.test.ts` 的现有断言都要照常通过。若必须改,按「原值 / 新值 / 原因」三栏写。
4. 本单只修布局。组件库迁移 K3(#107)会整体改造这一页,本单不要提前换组件。
## 任务
### T1 网格自己掌握整行
- `VedicLayout` 里把 `{children}` 包进一个固定容器,比如 `<div className="chart-page-vedic-tables">`。这个容器永远是网格的第三个直接子项。
- `globals.css` 桌面段的跨栏规则改为针对这个容器(`grid-column: 1 / -1`)。原来那两个类名如果别处不再需要跨栏,就从规则里删掉;删之前先按 AGENTS.md §7 第 8 条 grep 源码文本合同测试。
- 容器加 `min-width: 0`,保证窄屏时表格只在自己的盒子里横滚。
- `ChartTypeBody` 里分盘、Bhava、行运的 `.chart-page-varga-table-block` 保持不变。它们现在落在新容器里面,不再负责跨栏。
**验收**:在 1024px、1280px 宽下,「本命」「月亮」「D9」「Bhava」「行运」五种盘型的表都占满整行,宿主、pada、状态三列不用横拖就看得到。
### T2 回归测试
- 新增测试:分别渲染 `ChartVedicTab` 的 D1、MOON、D9、BHAVA、TRANSIT,断言 `.chart-page-vedic-layout` 恰好有三个直接子项,并且第三个是 `.chart-page-vedic-tables`。
- 在同一个测试文件里,断言 `globals.css` 的桌面媒体段包含 `.chart-page-vedic-tables { grid-column: 1 / -1; }`(或等价写法)。
- 以后谁再往表外包东西,这两条断言就会失败。
### T3 文档
- `docs/BUG_HISTORY.md` 新增一条:回归来源 #87,根因写「跨栏按类名而非结构」,防复发写上面 T2 的两条断言。
- `frontend/DESIGN.md` 星盘页一节补一句:「表格区由 `VedicLayout` 的整行容器承载,不靠表格自身类名跨栏。」
- `CHANGELOG.md` 写一行。
## 开工前置命令
```bash
git fetch origin --prune
git worktree add -b codex/chart-table-span-20261023 .worktrees/chart-table-span-20261023 origin/staging
cd .worktrees/chart-table-span-20261023 && git status -sb
cd frontend && npm ci && ./node_modules/.bin/tsc --noEmit && npm run lint
./node_modules/.bin/tsx --test tests/chart-page-view.test.tsx tests/chart-varga-table.test.tsx tests/chart-page-mobile-width-20260929.test.ts # 记下基线
```
## 验收分工
- **Grok 自跑**:
- `tsc --noEmit`;
- `npm run lint`;
- 上面三个测试文件加新测试;
- `npm run build`,记录 `/` 是否 Static 和首屏 gzip;
- 在本机浏览器 1280px 和 390px 宽下各截一张本命盘页面的图,附在回报里。
- **Claude 验收时跑**:
- 前端全量 `npm test`,和基线逐条对比(#87 当时就是漏了这一步);
- 复核截图。
- **真机清单**:`docs/testing/chart-table-span-20261023.md`,写两步:宽屏看本命和月亮的表是不是整行;手机上页面不横向滚动。
@@ -1,5 +1,7 @@
# TASK · 新用户启程:先看谁的盘、首问免费、起步三问、启程清单、一次性提示与动效(2026-10-22)
> **2026-10-23 更新:** 红线 8 和 T7 里的「用 CSS 和 Web Animations API、复用 `lib/motion/` 底座」改为「**用 Motion**,复用 `TASK-ui-kit-migration-20261023.md` K0 做好的 Motion 预设」。界面控件一律用 shadcn 组件(AGENTS.md §7 第 12 条)。文案按 AGENTS.md §7 第 9~11 条删减,可访问名称要保留。顺序也跟着调整:#96 等迁移单 K0 合入后再开工;#99 改为依赖 K0,不再依赖 #98。
设计原型(可点,含两条路径与全部动效):https://claude.ai/artifact/PcTwMVb9RD611R3qStxv3U 。原型与本任务书不一致时,以本任务书为准。原型里的名字、日期、星位都是虚构示例。
## 基线
@@ -0,0 +1,134 @@
# TASK · 说「帮我做生时纠正」只回一句「可在历史对话里接着做」(issue #103,2026-10-23)
## 基线
- 写本单时 `origin/staging` 为 `dd5ae8d03`。开工时以当时的 `origin/staging` 为准。
- 执行方:Grok(Windows + Docker),分支 `codex/rectification-explicit-open-20261023`,只推这个分支。
- BUG 编号:开工时用 `grep -oE "BUG-[0-9]+" docs/BUG_HISTORY.md | sort -t- -k2 -n | tail -1` 核对,从下一个号起。写本单时的最大号是 BUG-1420。
- **本单不需要动表**(理由见决策记录 D3)。
## 事故实证
**现象**:产品负责人在 staging 对话里说「请你帮我做一下生时纠正」,只收到一句「可在历史对话里接着做。」,没有卡片、链接或按钮,校正进不去。该账户的出生时间显示「已采用」。
**链路**(文件、行号以开工时为准,按符号定位):
1. **识别**:模型调用 `offer-birth-time-rectification` 并传 `explicitRequest: true`(`frontend/src/mastra/consultation-tools.ts`)。
2. **判定**:`decideRectificationOffer`(`src/lib/rectification-in-chat-server.ts`)读取 `getRectificationEntrySummary(...).hasResumableCase`,再进入 `decideRectificationOfferWith`(`src/lib/rectification-in-chat.ts`),得到 `case_in_progress`。
3. **「可续做」的状态集合**:`RESUMABLE_CASE_STATUSES`(`src/lib/contracts/rectification-entry.ts`)与 SQL 函数 `agentic_rectification_resumable_statuses()`(`20260812010000_agentic_rectification_v9_runtime.sql`)都包含 `candidate_accepted`。
- 但确认门实际上永远不开(Bug 历史:`confirmation_allowed=false`,「本会话以代表性时间收口」),`candidate_accepted` 在现实里就是终点。
- 结果:**每个采用过时间的用户都会被永久判为「有进行中的校正」**。明说要校正进不去;普通对话里的校正提议卡也再不会出现。
4. **找那次校正的会话**:`finishExplicitRectificationRequest`(`consultation-tools.ts`)调用 `loadResumableSessionId`,实现在 `src/app/api/consult/route.ts`。它直接 `from("agentic_rectification_cases").select("session_id")` 读表。
- 而 `20260814010000_immutable_skill_registry.sql` 写了 `revoke all on table public.agentic_rectification_cases from service_role;`,之后没有任何迁移把权限授回。`20260904010000_read_report_candidate_range.sql` 的注释也写明:直接读表的权限保持撤销,只能走 RPC。
- 自托管环境下,admin 客户端以 `set local role service_role` 运行,所以这次查询**必然**报 permission denied。`if (error || !data) return null` 把错误吞掉,也没有日志。
- **结果:即使是真正没做完的校正,也永远续不上,必然落到那一句话。**
- 单测没拦住的原因:测试里的 `loadResumableSessionId` 是注入的假函数,从来没有碰过真库。
5. **兜底**:拿到 null 后写成 `kind: "refused"` + `RECTIFICATION_DIRECT_RESUME_ELSEWHERE_COPY`。`stream-agent-response.ts` 只在 `new` / `resume` 时下发 `rectificationDirectOpen`,所以前端只剩一句话。
## 根因
1. **找会话的方式错了**:绕开 RPC 直接读一张对服务角色撤销了权限的表,失败又被静默吞掉。
2. **「进行中」的口径错了**:把一个实际上的终点状态(`candidate_accepted`)算作「进行中」,这个口径同时挡住了明说入口和提议卡。
3. **兜底设计错了**:失败时只给一句话,没有任何能操作的出口。
## 决策记录
| # | 决定 | 来源 |
| --- | --- | --- |
| D1 | **已采用(`candidate_accepted`)的校正不算「进行中」**。用户明说要校正时,直接开一次新的;普通对话里的提议卡也按同一口径恢复出现 | 产品 2026-10-22 选 C |
| D2 | 删掉「可在历史对话里接着做」这个兜底:**说了要校正,就一定打开一个能操作的校正** | 产品 2026-10-22 |
| D3 | **只改「入口判定」的口径,不改共享的 `RESUMABLE_CASE_STATUSES` 和 SQL 函数**。理由是那个集合还被采用后补证据、重新出卡、暂停恢复、Skill 升级(不在集合内会被当成 `case_terminal`)、改资料触发器(置 `needs_rebaseline`)、以 `session` 打开时的 resumed / readonly 判定共同使用;从里面删掉 `candidate_accepted` 会让旧会话里的采用后核对题变成只读、Skill 升级报错。按 AGENTS.md §5「修在根因上」,根因是入口口径,不是状态机。因此本单**不动表** | Claude 规划 |
| D4 | 真正没做完的校正(`draft` / `collecting_evidence` / `candidate_ready` / `needs_rebaseline` / `paused`)仍然续做,但改为**经 RPC** 拿到会话号(现成的 `getRectificationCase(accounting, userId, caseId)`,`src/lib/rectification-agentic/v9/case-service.ts`,返回值带 `sessionId`)。RPC 失败时打日志,并改为直接新开一次。数据库早已允许同一用户并行多个校正(BUG-177 删了唯一索引,`rectification-v9-database.test.ts` 已验证) | Claude 规划 |
**推翻的旧决策**:
- BUG-1306 修复段原话「进行中的 case 不新开:查到 `agentic_rectification_cases.session_id` 就打开那次,查不到就说可以在历史对话里接着做」:本单推翻「查不到就说去历史接着做」,并改为经 RPC 查找。
- BUG-1306 10-12 决策日志「服务端本人 / 开关 / 进行中 case / 价格判定不变」中的「进行中 case」:口径改为不含 `candidate_accepted`。
- BUG-1248 提议卡条件「无进行中 case」:沿用,但口径同上。
- 与 BUG-177「不得用存在 resumable Case 阻止新 Case」一致,本单是在对话入口上重申这一点。
## 硬红线
1. 不改 `RESUMABLE_CASE_STATUSES`、`agentic_rectification_resumable_statuses()` 及其它 SQL;不新增迁移。
2. 非本人、开关关闭、没有价格这三种拒绝,行为不变。
3. 扣点规则不变:开始答第一题时才扣。
4. 日志不得含任何个人资料,只记 `requestId`、错误类别和 caseId 是否存在。
5. 旧会话里已采用的那次校正,采用后核对题在**新校正还没采用前**必须照常能答。新校正采用了不同的时间之后,旧那次的核对题不得报错或弹出错误提示,按只读处理即可(`segmentSelection` 为 null 时的界面状态)。
6. 改既有断言必须写「原值 / 新值 / 原因」三栏。按 AGENTS.md §7 第 8 条,先 grep 源码文本合同测试,同一轮里跟着改。
## 任务
### T1 入口口径:已采用不算进行中
- 新增一个只给入口用的判定,名称执行方定,例如 `blocksNewRectification(status)`:命中 D4 列的五种状态返回 true,`candidate_accepted` 返回 false。
- `getRectificationEntrySummary` 返回的 `latestResumable.status` 如果是 `candidate_accepted`,入口就视为「没有进行中的校正」。
- `decideRectificationOffer`(明说入口和普通提议卡共用)改用这个判定。
- 已知边界:summary 只返回最近活动的一次可续做校正。如果最近一次是已采用的、更早还有一次没做完的,本单按「没有进行中的」处理,直接新开。数据库允许并行,用户在历史对话里仍能找到那次。在进度记录里写明这一点。
**验收**
- 单测:最近一次是 `candidate_accepted` → 明说要校正得到 `new`,提议卡可以出现;最近一次是 `collecting_evidence` → 得到 `resume`。
### T2 经 RPC 找会话,删掉兜底
- `loadResumableSessionId` 改为调用 `getRectificationCase(accounting, userId, caseId)`,取 `sessionId`。不再直接读表。
- `finishExplicitRectificationRequest` 的分支:
- 有进行中的校正且拿到 `sessionId` → `kind: "resume"`;
- 有进行中的校正,但 RPC 失败或拿不到 `sessionId` → 打日志(红线 4),然后 `kind: "new"`;
- 没有进行中的校正 → `kind: "new"`。
- 删除常量 `RECTIFICATION_DIRECT_RESUME_ELSEWHERE_COPY`,以及 `consultation-tools.ts` 里给模型的「去历史对话接着做」说明;同步删掉 `frontend/docs/VOICE.md` 里的对应口径。
- 清理 `case-status.ts` 里已经过期的 `MAX_RESUMABLE_CASES_PER_USER = 1`:注释说它「镜像 DB 部分唯一索引」,但索引已被 BUG-177 删除。先 grep 引用;删除前确认没有业务逻辑依赖,测试里的断言按三栏改。
**验收**
- 单测:RPC 抛错 → `new`,并且有一条日志调用(不含个人资料)。
- **真库测试**(`test:db`):用服务角色调 `loadResumableSessionId` 的新实现,能拿到一个进行中校正的 `sessionId`。旧实现在同样条件下会 permission denied,这条测试就是当初缺的防线。
### T3 旧会话不报错
- 准备一个已采用的旧校正,再开一个新校正,并让新校正采用不同的时间。然后打开旧会话:
- 采用后核对题显示为只读;
- 不弹错误;
- 控制台没有未处理的异常。
- 用交互测试或组件测试覆盖。
### T4 文档
- `docs/BUG_HISTORY.md` 新增一条:关联 BUG-1306、BUG-1248、BUG-177。根因写三条(读表权限、口径、兜底);防复发写 T2 的真库测试和 T1 的口径单测。
- `CHANGELOG.md` 写一行用户可感知的变化。
- `docs/testing/rectification-explicit-open-20261023.md` 真机清单:
1. 已采用过时间的账户,在对话里说「帮我做生时纠正」,应直接进入新的校正;
2. 一个答到一半的校正,从对话里说要校正,应接回那次;
3. 普通对话问到时机类问题时,提议卡会出现(已采用的账户)。
## 让步顺序
1. 先砍 T3 的自动化测试,改为写进真机清单。
2. T1、T2、真库测试都不可让。
## 开工前置命令
```bash
git fetch origin --prune
git worktree add -b codex/rectification-explicit-open-20261023 .worktrees/rectification-explicit-open-20261023 origin/staging
cd .worktrees/rectification-explicit-open-20261023 && git status -sb
grep -oE "BUG-[0-9]+" docs/BUG_HISTORY.md | sort -t- -k2 -n | tail -1
cd frontend && npm ci && ./node_modules/.bin/tsc --noEmit && npm run lint
./node_modules/.bin/tsx --test tests/rectification-explicit-entry-20261012.test.ts tests/rectification-in-chat-20261007.test.ts tests/rectification-v9-contracts.test.ts # 记基线
```
## 验收分工
| 谁 | 跑什么 |
| --- | --- |
| Grok 自跑 | `tsc --noEmit`;`npm run lint`;上面三个测试文件加新增测试;`npm run test:db`,与 `tools/agent-loop/windows-known-failures.txt` 逐条比对;`npm run build` |
| Claude 验收时跑 | 前端全量 `npm test`,与基线逐条对比;复核没有新迁移、`RESUMABLE_CASE_STATUSES` 和 SQL 函数未改;复核日志不含个人资料 |
| 环境缺口 | 真实模型会不会在用户明说时调用工具,Claude 这边没有模型凭据,留给真机清单第 1 步 |
现有会受影响的断言(改时按三栏写):
- `rectification-explicit-entry-20261012.test.ts`
- 断言 `RESUME_ELSEWHERE_COPY` 原文(约第 129–132 行);
- 「an in-progress case resumes that session, or says to continue in history」(约第 243 行),其中 loader 返回 null 或抛错时期望 `refused`;
- 普通调用 note 匹配 `/历史对话/`;
- 约第 695–698 行的源码文本合同(`openRectificationFromChat(...)` 恰好出现 1 次)。
- `rectification-in-chat-20261007.test.ts`:`hasResumableCase` 为 yes 时期望 `case_in_progress`(约第 107–115 行),需要补「最近一次是已采用」的分支。
- `rectification-v9-contracts.test.ts`:`MAX_RESUMABLE_CASES_PER_USER === 1`(T2 清理时)。
+2
View File
@@ -1,5 +1,7 @@
# TASK · 全站动效:换页、页签、盘型、列表、弹窗、抽屉、提示、数字(2026-10-22)
> **2026-10-23 更新:本单不再单独执行。** 产品推翻了下面的 D3「不引入动画库」,改为一律用 Motion(见 `TASK-ui-kit-migration-20261023.md` 的 D2)。本单的**动效规范表仍然有效**,由迁移单 K0 做成 Motion 预设;M0~M4 并入迁移单 K1~K5。issue #98、#100、#101 不再执行。
设计原型(可在「现在 / 改后」之间对比):https://claude.ai/artifact/7u9goivnmTNSGTNWNeVMQd 。原型和本任务书不一致时,以本任务书为准。
## 基线
@@ -0,0 +1,260 @@
# TASK · 界面统一换成组件库:shadcn/ui · Lucide · Motion · react-resizable-panels · dnd-kit · Zustand(2026-10-23)
可交互原型(保持现在的 Claude 风格,全部用下列依赖实现,带「显示组件来源」开关):https://claude.ai/artifact/PGnWsjHhu38KJseEfEMNWG 。原型和本任务书不一致时,以本任务书为准。**产品 2026-10-23 确认原型定稿(第 5 版)**,验收时以它为视觉参照。原型的数据是虚构的。
## 基线
- 写本单时的 `origin/staging` 是 `3632b0eab`。每个 issue 开工时,以当时的 `origin/staging` 为准。
- BUG 编号:开工时用 `grep -oE "BUG-[0-9]+" docs/BUG_HISTORY.md | sort -t- -k2 -n | tail -1` 核对最大号,从下一个号开始(写本单时是 BUG-1420)。
- 本单拆成 8 个 issue,串行执行,见文末「循环交接」。其中 K1 和 K4 要动表(加排序列),产品已经同意。
## 现状实证(2026-10-23 在 staging 代码上实测)
1. **shadcn 装了,但几乎没用。**
- `frontend/components.json` 已经配成 `style: "base-nova"`、`iconLibrary: "lucide"`。
- 但 `src/components/ui/` 下只有 8 个组件:button、calendar、popover、select、sidebar、sonner、tabs、textarea。
- 业务代码(不含后台)里有手写 `<button>` 148 处、原生 `<dialog>` 8 个、自写 `role="dialog"` 弹层 3 个,还有 4 处自己 `createPortal`。
2. **样式全靠手写。** `src/app/globals.css` 有 7002 行、902 个自定义类、14 段 `@keyframes`。同一种控件各写一份,例如:
- 会话行菜单 `.session-actions` / `.session-action-item`,其实就是一个手写的 DropdownMenu;
- 账户入口 `.profile-trigger`;
- `.new-chat`、`.report-nav-button` 等。
3. **漏引 shadcn 的变体规则。** `globals.css` 没有 `@import "shadcn/tailwind.css"`。base-nova 组件用到的 `data-horizontal:`、`data-vertical:`、`data-active:` 等变体都定义在这个文件里,漏引时页签方向、分段按钮拼接、选中态都会错。原型第一版就踩过这个坑。
4. **动效分散。** 有 14 段手写关键帧;`chat-message-row.tsx` 里用了一处 GSAP。
5. **状态分散。** 首页 `Home()` 和各个 hook 里共有 171 处 `useState`、2 个 context,状态靠参数一层层往下传。
6. **没有拖拽,也没有可拖宽的分栏。** 侧栏宽度是写死的;想看星盘只能单独开一页。
7. 手写控件最多的文件(按钮、弹窗、菜单出现次数):
- `email-otp-login.tsx` 18
- `people/people-page.tsx` 12
- `report-export-drawer.tsx` 7
- `synastry-add-dialog.tsx` 6
- `chart-type-picker.tsx` 6
- `birth-time-choice-question.tsx` 6
- `onboarding-redeem-paywall.tsx` 5
- `birth-time-candidate-result.tsx` 5
- `app/(app)/page.tsx` 5
- 其余见各 issue 的文件清单。
## 根因
- 引入 shadcn 的时候只拷了几个组件,之后每个新功能都自己写控件和样式;没有规矩、也没有检查拦住手写。
- 动效、状态、拖拽都没有定好用哪个库,于是各写各的。
## 决策记录(产品负责人 2026-10-23 拍板,全部同意)
| # | 决定 | 推翻了什么 |
| --- | --- | --- |
| D1 | **加 4 个依赖、去掉 1 个**:加 `motion`、`react-resizable-panels`(v4,经 shadcn `Resizable`)、`@dnd-kit/core` + `@dnd-kit/sortable` + `@dnd-kit/utilities`、`zustand`;去掉 `gsap` 和 `@gsap/react`。版本固定到发布满两周的版本 | AGENTS.md §7.7「不得顺手升级依赖」:本单是产品点名授权的新增 |
| D2 | **动效一律用 Motion** | `TASK-site-motion-20261022.md` 的 D3「不引入动画库」,以及 `TASK-onboarding-journey-20261022.md` 红线 8 的「用 CSS 和 Web Animations API」。site-motion 的**动效规范表仍然有效**,按表里的时长和曲线做成 Motion 预设;它的 M0~M4 并入本单 |
| D3 | **已有依赖能正确表达的组件和行为,严禁自行设计实现**,并加 lint 自动拦截 | 已写进 AGENTS.md §7 第 12 条 |
| D4 | **首屏 ±2% 不放宽**。Motion、dnd-kit、react-resizable-panels 按需加载,不进首屏 | 已写进 AGENTS.md §7 第 2 条 |
| D5 | **新功能:在对话旁边拉出星盘面板**,可以拖宽、可以收起 | 无 |
| D6 | **会话和星盘档案可以拖动排序,顺序跨设备保留**。要加排序列,产品同意动表 | 无 |
| D7 | **长相不变**:配色、字体、圆角沿用现有的 Claude 风格设计令牌,映射成 shadcn 的 CSS 变量(`--background`、`--primary` 等)。本单只换实现,不换长相 | 无 |
| D9 | **二选一、多选一的切换一律用分段式 `Tabs`**(盘型、中英、主题等),不用描边的 `ToggleGroup`;`ToggleGroup` 只留给可多选或标签式的场景 | 无 |
| D8 | **文案规矩**按 AGENTS.md §7 第 9~11 条执行:删掉解释布局的副标题、说明句、重复标签,可访问名称保留 | 无 |
## 硬红线
1. 不得手抄或改写 shadcn 组件。缺什么组件就用 `npx shadcn@<固定版本> add <组件名>` 加。加进来之后只许改设计令牌和类名组合,不许改组件的行为。
2. `globals.css` 第一行之后必须 `@import "shadcn/tailwind.css";`。没有这一行,验收直接不通过。
3. 首屏:`next build` 后 `/` 仍为 `○ Static`,首屏 gzip ±2%。Motion 用 `LazyMotion` + `m` 组件,`domAnimation` 特性包异步加载;dnd-kit、react-resizable-panels 只在用到的页面里动态引入。
4. 揭幕后不得出现新的等待动画(AGENTS.md §6)。动效只回应用户操作;一律包在 `MotionConfig reducedMotion="user"` 里,尊重系统的减少动态效果设置。
5. 冻结合同:`Home()` 的 `useState` / `useRef` 数只许减少(状态迁进 Zustand 后应当明显下降)。`ChatComposer` 仍然是唯一的输入框,只是改为用 shadcn `InputGroup` 实现。
6. 不得改动业务行为和数据:同样的输入,接口请求、扣点、落库都必须和改前一致。每个 issue 都要对比改前改后的全量测试;改了既有断言要写「原值 / 新值 / 原因」三栏。
7. 删除手写类和关键帧之前,按 AGENTS.md §7 第 8 条先 grep 源码文本合同测试,同一轮跟着改。
8. 动表只加列(排序列可为空,有默认值),对已部署代码向后兼容;必须真跑 `npm run test:db`。
9. 星盘画法、星图、报告排版、天空过场这些占星专有的绘制保留手写,不算违规;但其中的按钮、菜单等控件仍要换成组件。
10. 文案按 D8 删减时,读屏名称不能跟着删:图标按钮要有 `aria-label`,表单控件要有可访问名称。
## 任务分解
### K0 底座与规矩
- 加 D1 列的依赖,去掉 gsap。`chat-message-row.tsx` 里的 GSAP 改用 Motion,效果不变。
- `globals.css` 加 `@import "shadcn/tailwind.css"`。把现有设计令牌映射到 shadcn 变量,深色、浅色都要映射(D7)。
- 用 shadcn CLI 加以下组件:
- dialog、alert-dialog、sheet、drawer
- dropdown-menu、tooltip、card、badge、table、separator、scroll-area、avatar
- input、input-group、label、field、toggle、toggle-group、resizable
- checkbox、radio-group、switch、collapsible、empty、kbd、skeleton
- 说明:skeleton 只给星盘页 BUG-1016 那一处例外用。
- `src/lib/motion/`:
- 把 `TASK-site-motion-20261022.md` 动效规范表里的每一档做成 Motion 预设(时长、曲线、方向);
- 包装好 `LazyMotion` 和 `MotionConfig`;
- 不另写任何动画逻辑。
- `src/stores/`:先建骨架,有会话、人物、账户、界面四个 store。状态在后面各个 issue 里逐步迁入。
- **lint 规则**:在 `src/components/ui/**` 以外:
- 出现 JSX 原生 `button`、`dialog`、`select`、`input type=checkbox|radio`,或 `role="dialog|menu|menuitem|tablist|tab|listbox"`,或 `createPortal`,就报错;
- 用字符充当图标(☰ ↑ × ⋯ ✓ ▾ 等出现在 JSX 文本里)也报错。
- **存量白名单**:迁移前已有的违规文件列进白名单,再加一条测试,断言白名单只能变短、不能变长。
- 星盘和星图里的 SVG 绘制不受图标规则限制,规则里写明豁免哪些路径。
**验收**
- `npm ls` 里能看到新依赖,看不到 gsap。
- 往 `src/components/` 下新加一个含 `<button>` 的文件,lint 报错。
- 首屏 gzip ±2%,`/` Static。
- 界面截图和改前一致:K0 只加底座,不换控件。
### K1 外壳:侧栏、会话列表、账户菜单、设置、页面切换
文件:`app-sidebar.tsx`、`sidebar-session-row.tsx`、`theme-preference-menu.tsx`、`account-dialog-overlay.tsx`、`profile-panel.tsx`、`billing-panel.tsx`、`account-deletion-section.tsx`、`account-deletion-gate.tsx`、`feedback-dialog.tsx`、`subject-switcher.tsx`,以及 `globals.css` 里对应的手写类。
- **侧栏**:
- 用 shadcn `Sidebar` 的菜单组件;
- 桌面上放进 `ResizablePanelGroup`,可以拖宽(200–360 px),拖到最左收起,宽度记在本机;
- 手机上用 `Sheet` 从左边滑出;
- 当前页的高亮底色用 Motion 的 `layoutId` 滑动。
- **会话行**:
- 更多菜单用 `DropdownMenu`(收藏、重命名、转发、删除);
- 删除前弹 `AlertDialog`;
- 拖动排序用 dnd-kit,键盘也能排;
- 顺序写进新加的排序列(D6,动表);收藏的会话仍然排在最前。
- **账户菜单**:
- 用 `DropdownMenu`,主题是 `DropdownMenuRadioGroup`;
- 触发按钮只显示头像和名字,点数不在这里重复显示(D8)。
- **设置弹窗**:
- 用 `Dialog` + `Tabs`,三个分区名不变;主题选择也用分段式 `Tabs`;
- 注销确认用 `AlertDialog`,反馈用 `Dialog`。
- **页面切换**:Motion 的 `AnimatePresence`,方向跟随侧栏顺序(site-motion 规范表的「页面切换」档)。
- **顶栏点数**:
- 改成正常大小的 `Button variant="outline"`,内容是 Lucide `Coins` 图标加数字;
- 「点」字只放在 `aria-label` 里;
- 数字变化时上下滚动。
- 现有的手写类 `.session-row`、`.session-actions`、`.profile-trigger`、`.new-chat`、`.report-nav-button` 等,迁移后删除,并从 lint 白名单里移出对应文件。
**验收**
- 拖动会话排序后刷新、换设备,顺序都保持(DB 测试加交互测试)。
- 快速连点侧栏 5 次,最终停在最后点的那一页,没有残留的旧页。
- 侧栏拖到最左能收起,宽度刷新后仍然记得。
- 手机 375 px 宽时侧栏是抽屉,页面不横向滚动。
### K2 对话页
文件:`chat-composer.tsx`、`chat-message-row.tsx`、`chat-message-actions.tsx`、`conversation-follow-ups.tsx`、`reply-down-reason.tsx`、`model-selector.tsx`、`onboarding-redeem-paywall.tsx`、`starter-home.tsx`、`app/(app)/page.tsx`、`jump-to-latest-button.tsx`。
- **输入框**:
- `ChatComposer` 改用 `InputGroup` + `InputGroupTextarea`;
- 底部 `InputGroupAddon align="block-end"` 放两样东西:左边是模型选择(`DropdownMenu`),右边是圆形发送按钮;
- 发送按钮和输入框同在一个框里,底边对齐;
- 模型选择从顶栏移进输入框,各模型的价格只在菜单里显示。
- **回答操作**:赞、踩、复制、重新生成用 `Button size="icon-sm"`,每个配 `Tooltip`;「不满意原因」用 `Dialog`。
- **追问**:用 `Button variant="outline" size="sm"`,依次浮出(Motion 的 stagger)。
- **兑换窗**:改用 `Dialog`,文案不变。
- **对话旁的星盘面板(D5)**:
- 顶栏加一个 `PanelRightOpen` / `PanelRightClose` 图标按钮,配 `Tooltip`,带 `aria-label`;
- 面板放在 `ResizablePanelGroup` 里,可以拖宽、可以收起,宽窄记在本机;
- 面板里是当前人物的本命盘(复用星盘页的绘制组件);
- 手机上不显示这个入口。
- **对话状态**:当前会话、消息、流式状态迁进 Zustand 的会话 store;`Home()` 里对应的 `useState` 删掉。
- **流式输出**:逐字释放的逻辑不改;消息进场用 Motion。
**验收**
- 回归原有的对话测试:流式、停止、排队、撤回、退点、寒暄免费。
- `Home()` 的 hook 数下降,写进进度记录。
- 星盘面板打开、拖宽、收起、刷新后仍然记得状态。
### K3 星盘页
文件:`chart-page/*`(`chart-page-view.tsx`、`chart-type-picker.tsx`、`chart-vedic-tab.tsx`、`chart-dasha-tab.tsx`、`chart-basics-tab.tsx`、`chart-western-tab.tsx`、`chart-birth-editor.tsx`、`chart-param-table.tsx`、`chart-symbol-help.tsx`)。
- **四个页签**:用 `Tabs`,`variant="line"`。
- **盘型条**(本命、D9、D10、月亮…):用分段式 `Tabs`(`TabsList` 默认变体:灰底槽,选中的一格浮起亮底),盘型多时用 `ScrollArea` 横向滚动;不用描边的 `ToggleGroup`(产品 10-23 看过,嫌丑)。「星盘类型」面板用 `Popover` 或 `Sheet`。
- **盘与参数**:桌面上两栏放在 `ResizablePanelGroup` 里,可以拖宽;手机上上下排。
- **行星表**:
- 用 `Table`,**跨满整行**(继承 #102 的修复,不得回归);
- 状态列统一用 `Badge`:落陷、敌用 `destructive`,其余用 `secondary`,没有状态就写「—」。同一列不得出现两种徽标形状。
- **出生资料就地编辑**:用 `Popover` 或 `Dialog`,表单用 `Field` 和 `Input`。
- **Motion**:换盘型时星体从旧宫位移到新宫位;换人时格线重画;大运展开用 `Collapsible` 加 Motion。
- 星盘 SVG 的绘制保留手写(红线 9)。
**验收**
- 切盘型后星体最终坐标和直接渲染一致(坐标断言)。
- D1 字节冻结测试仍然通过。
- 1024、1280、390 三种宽度下截图都正常。
### K4 星盘档案与婚恋合盘
文件:`people/people-page.tsx`、`people-home-links.tsx`、`chart-profile-form.tsx`、`profile-gender-field.tsx`、`synastry/*`(`synastry-add-dialog.tsx`、`synastry-picker.tsx`、`synastry-report-view.tsx` 等)。
- **人物卡**:用 `Card`,加 `CardAction`,更多菜单用 `DropdownMenu`;编辑用 `Dialog`;删除用 `AlertDialog`。
- **拖动排序**:用 dnd-kit 的网格排序,顺序写进 `chart_profiles` 的新排序列(D6,动表);本人始终排在第一。
- **关系选择**:用 `ToggleGroup`,表单的标签和读屏名称都要保留(D8)。
- **合盘选人页**:
- 去掉页内重复的标题和副标题(顶栏已经有页名);
- 两个 `Select` 左右并排,中间一个 Lucide `X`(`aria-hidden`);
- 下方正中放「开始合盘」按钮,图标用 Lucide `HeartHandshake`(和侧栏一致,不用 `Sparkles`);按钮上不写价格。点了弹 `AlertDialog` 确认:「会用掉 {价格} 点,余额 {余额} 点。」,按钮是「取消 / 开始」;余额不够时说「需要 {价格} 点,余额只有 {余额} 点。」,主按钮换成「去补点数」(产品 10-23 定);
- 手机上两个选择框上下排;
- 两个 `Select` 都有可访问名称「第一个人」「第二个人」。
**验收**
- 拖动排序后刷新、换设备,顺序都保持。
- 删人后,相关的合盘和报告照原逻辑处理。
- 合盘按钮在桌面和手机上都居中。
### K5 报告、星历、登录与其余页面
文件:
- `personal-report/*`:`report-actions.tsx`、`report-export-drawer.tsx`、`personal-report-center.tsx`、`report-edge-jump.tsx`、`report-row-delete.tsx`
- `ephemeris/*`
- `email-otp-login.tsx`、`legal/*`、`app/global-error.tsx`、`markdown-code-block.tsx`、`birth-sky/birth-sky-replay.tsx` 里的控件
具体改法:
- **中英切换**:用分段式 `Tabs`(同盘型条),带 `aria-label="报告语言"`;不用描边的 `ToggleGroup`。
- **导出**:用 `Sheet`(桌面从右侧滑出,手机从底部);删掉说明句和文件大小(D8)。
- **星历翻日期**:用 `Button` 加 Lucide 箭头,内容按方向翻页(Motion)。卡片不得被翻页容器裁掉边框,原型第一版就出过这个问题。
- **登录页**:输入框和按钮换成组件;验证码输入用 shadcn `InputOTP`。
**验收**
- BUG-1126 回归测试通过(中英切换后不残留旧语言节点)。
- 登录页 E2E(或交互测试)全过。
### K6 生时校正界面
文件:`birth-time-*`、`rectification-*` 系列组件(`birth-time-choice-question.tsx`、`birth-time-candidate-result.tsx`、`birth-time-rectification.tsx`、`rectification-choice-card.tsx`、`rectification-board.tsx` 等)。
- 只换控件和动效,**题目逻辑、计分、落库一概不动**。
- 选项卡片改用 `ToggleGroup` 或 `RadioGroup`。
- 排在 #103(校正入口修复)合入之后。
**验收**:校正的全量前端测试、Python `test_rectification_*`,改前改后逐条一致。
### K7 收尾
- 删掉 `globals.css` 里已经没人用的手写类和 `@keyframes`,进度记录里写清改前改后的行数。
- lint 白名单清空(占星绘制的豁免除外)。
- 重写 `frontend/DESIGN.md`:
- 第一节改为「组件一律用 shadcn base-nova,令牌如下」,动效节改为 Motion 预设表;
- 旧的 `CLAUDE_DESIGN.md` 改编说明降为参考。
- 更新 `frontend/docs/VOICE.md`:按 D8 删掉已不存在的说明句。
## 让步顺序
做不完时,按下面的顺序先砍:
1. K7 里 `globals.css` 的彻底清理(白名单仍然只减不增)
2. 星盘面板宽窄的本机记忆
3. 换人时的格线重画
4. 拖动排序的键盘操作(鼠标拖动不让)
D3 的 lint 规则、`shadcn/tailwind.css`、首屏体积、文案规矩、读屏名称都不能让。
## 循环交接
- **执行方**:Grok(Windows + Docker)。每个 issue 一个分支,只推自己的分支。
- **Grok 自跑**:`tsc --noEmit`、`npm run lint`、相关测试文件、`npm run build`(记录 `/` 是否 Static 和首屏 gzip)。动表的 issue 还要跑 `npm run test:db`,与 `tools/agent-loop/windows-known-failures.txt` 逐条比对。
- **Claude 验收时跑**:前端全量 `npm test`,逐条对比基线;复核首屏 gzip;对照原型截图检查。
- **真机清单**:`docs/testing/ui-kit-migration-20261023.md`,由 K1 创建,后面的 issue 往里追加。
- 每个 issue 在同一变更里更新 `docs/BUG_HISTORY.md`(体验改进类记 `enhancement`)、`CHANGELOG.md` 和 `frontend/DESIGN.md`。
| issue | 内容 | 分支 | 前置 |
| --- | --- | --- | --- |
| 组件库(K0) | 底座与规矩 | `codex/ui-kit-0-base-20261023` | #95 合入 |
| 组件库(K1) | 外壳 | `codex/ui-kit-1-shell-20261023` | #97 合入 |
| 组件库(K2) | 对话页 | `codex/ui-kit-2-chat-20261023` | K1 合入 |
| 组件库(K3) | 星盘页 | `codex/ui-kit-3-chart-20261023` | K2、#102 合入 |
| 组件库(K4) | 档案与合盘 | `codex/ui-kit-4-people-20261023` | K3 合入 |
| 组件库(K5) | 报告、星历、登录 | `codex/ui-kit-5-pages-20261023` | K4 合入 |
| 组件库(K6) | 生时校正界面 | `codex/ui-kit-6-rectification-20261023` | K5、#103 合入 |
| 组件库(K7) | 收尾 | `codex/ui-kit-7-cleanup-20261023` | K6 合入 |
新用户启程的几单也跟着调整:#96 改为等 K0 合入后再开工,新界面直接用组件库;#99(启程动效)改为依赖 K0 的 Motion 预设,不再依赖 #98。#98、#100、#101(全站动效一、二、三)并入 K1~K5,不再单独执行。
@@ -1,25 +0,0 @@
# 新用户启程 · 真机清单(2026-10-22)
后面的 issue 往这份清单末尾追加。本页先只覆盖数据层(issue #95,T1 首问免费、T2 关系与引导状态)。起步三问、启程清单、一次性提示和动效还没做,不要在这一轮里验收它们。
用新注册的邮箱。不要把真实出生资料写进记录。
## 首问免费
1. 新账户点数是 0。建完档之后,第一次正式提问能发出去,点数仍是 0,不弹兑换窗。
2. 这一问回答完成之后,再问一次:弹出原来的兑换窗,文案不变。
3. 另开一个新账户。第一问发出去之后立刻停止。再问一次仍然不扣点。
4. 另开一个新账户。第一问如果只被当成寒暄,问完之后正式提问仍然不扣点。
5. 后台用量里,这次免费提问的资金来源显示「首问免费」。若当时选了更贵的模型,显示「首问免费(差价 N 点)」,账户只少这 N 点。
## 人物关系
1. 星盘档案里添加一个人,选「孩子」,保存。刷新之后,列表和详情的名字后面仍是「孩子」。
2. 找一份关系还是空的旧档案,打开编辑,补上「父母」,保存后再打开仍是「父母」。没改过的空档案,名字后面显示「其他」。
3. 本人这一行仍只显示「本人」,编辑本人时没有「TA 是你的」。
## 以后追加
- (二)先看谁的盘、起步三问
- (三)启程清单、一次性提示
- (四)动效,以及打开「减少动态效果」再走一遍
@@ -0,0 +1,8 @@
# 真机清单 · 明说要校正能打开(2026-10-23)
用测试账号。不要把真实出生时间写进记录。
1. 已经采用过出生时间的账户,在对话里说「帮我做生时纠正」。应直接进入一次新的校正,不再只出现「可在历史对话里接着做。」
2. 有一次答到一半、还没采用的校正。从对话里说要校正,应接回那一次。
3. 已采用过的账户,普通对话问到时机一类的问题时,校正提议卡会出现。
4. 已采用的旧校正还在,再开一次新的并采用另一个时间。打开旧的那次对话:采用后的核对题只读,不弹错误,控制台没有未处理的报错。
+4 -5
View File
@@ -1146,7 +1146,7 @@ D40 / D45 / D60 的表格上方,出生时间不是「已校正」时多一句
标题旁的人物切换仍是当前阅读/对话上下文,不替换户主资料,也不是「设为默认」。上次选择可同步预读,但账户与服务端目录绑定完成前,星盘、星历、报告和新建对话不发默认本人的首屏请求;目录暂时失败应可恢复,不假登出,不先闪别人的内容。档案增删改后切换器重新取目录;同一人物不重复广播切换。会话列表更新合并metadata,保留已加载消息、深链与分页外对话;可见列表仅属于当前人物,换账户不保留旧数据。
档案数量仍本人加最多十九个他人(一共 20 人),本人不可删除。别人的档案可以标明和户主的关系(伴侣、孩子、父母、朋友、其他);没填过的显示「其他」,本人不记这一项,仍只标「本人」。表单添加/编辑标题必须与操作一致。删除确认只显示服务端成功取得的对话/报告数;查询失败时显示「暂时查不到这个人的对话数量,稍后再试」,禁用确认,不能显示0冒充已知。服务端成功后才移除档案并刷新目录;删除当前人物才回到本人。删除未交付报告释放预留点数,已交付/已结算不退,不用客户端猜测退款结果。
档案数量仍本人加最多十九个他人(一共 20 人),无关系字段,本人不可删除。表单添加/编辑标题必须与操作一致。删除确认只显示服务端成功取得的对话/报告数;查询失败时显示「暂时查不到这个人的对话数量,稍后再试」,禁用确认,不能显示0冒充已知。服务端成功后才移除档案并刷新目录;删除当前人物才回到本人。删除未交付报告释放预留点数,已交付/已结算不退,不用客户端猜测退款结果。
「和TA对话」新会话绑定选中的人物;「和我合盘」中的我保持本人,新会话显式绑定本人但不改变全局选择。普通新建深链用已绑定的当前人物。今日星语的可用性与缓存指纹取当前人物,不能由户主是否有分钟压掉他人的卡片。星历暖进只复用同账户/同人物缓存,首次失败仍显示可重试的静态错误句,不新增spinner、骨架或「正在加载」。跨scope迟到响应不得覆盖当前视图或分页游标。
@@ -1158,12 +1158,11 @@ D40 / D45 / D60 的表格上方,出生时间不是「已校正」时多一句
- **小于 1024px:列表 → 详情。** 同一路由里用 `.people-archive[data-view]` 切换,只显示一栏。点人时压一条 `?person=<id>` 历史记录,浏览器返回和详情顶部的「‹ 返回」走同一条路回到列表;刷新详情地址直接打开那个人。桌面点人不压历史。
- **1024px 及以上:两栏。** 左列表(18–22rem),右详情;「‹ 返回」隐藏。右栏正在看的那一行用中性浅底(`is-open`),强调色只给当前人物。
- **列表行**(`.people-archive-row`,最小 64px):40px 圆形首字头像(本人写「我」)+ 名字(本人后跟灰底「本人」标签;别人后跟同一枚标签,写关系,没填过写「其他」)+ 一行等宽 `tabular-nums` 的「出生日期 · 排盘时间 · 城市」+ 行尾「›」。资料不完整时这一行写「这份资料还不完整」。**当前人物**(`is-current`,与顶栏「当前星盘」同一个人)是 `--color-action-strong` 描边 + `--color-action-soft` 底。
- **列表行**(`.people-archive-row`,最小 64px):40px 圆形首字头像(本人写「我」)+ 名字(本人后跟灰底「本人」标签)+ 一行等宽 `tabular-nums` 的「出生日期 · 排盘时间 · 城市」+ 行尾「›」。资料不完整时这一行写「这份资料还不完整」。**当前人物**(`is-current`,与顶栏「当前星盘」同一个人)是 `--color-action-strong` 描边 + `--color-action-soft` 底。
- **添加**:虚线卡片「+ 添加一个人」(48px,`--color-border-strong` 虚线,文字 `--color-action`),下面小字「还可以保存 N 个人」,N 仍是 `MAX_SAVED_SUBJECTS - 人数`;到顶时换成「最多保存 20 个人的星盘」并禁用。
- **人多时的滚动**(2026-10-17):`.people-archive-rows` 限高 `min(70vh, 640px)`,只在列表内部纵向滚动。标题旁和聊天里的人物菜单(`.chat-profile-picker-options`)限高 `min(50vh, 320px)`,同样只纵向滚动,名单放到 20 人也不把弹层撑出屏幕。两处都 `overflow-x: hidden`。
- **详情卡**:头部 48px 头像 + 名字(别人的名字后跟关系标签,没填过写「其他」;本人的标题仍只有名字)+「本人 · 出生地」(他人只写出生地);两格时间卡「填报时间」「排盘时间」,等宽大号数字。只有采用或确认了**校正结果**(状态 accepted / confirmed **且**有校正案例)时,排盘卡换成 `--color-success` 描边 + `--color-success-muted` 底,标签写「排盘时间 · 已校正」——旧资料读回时的 `confirmed` 兜底不算。下面是出生日期、岁差(显示「Raman(默认)」这类设置里的名字,不露小写键)与性别(「女」「男」,没填写「未填」),再下面三个等宽快捷入口「和 TA 对话 / 看星盘 / 生成报告」(图标用侧栏同款 lucide)。他人另有「和我合盘」;每个人底部都有「编辑」,本人在它上面加一行灰字「本人不能删除」。**本人的编辑视图(2026-09-28,BUG-1081)**与他人同一张表(称呼、出生资料、岁差、性别),标题「编辑我的资料」、说明「称呼和出生资料。」,没有删除区;保存走账户资料接口,保存后首页、星盘、星历按新资料重读。他人已经保存过出生时间时,编辑表单锁住这一项,旁边只留「填报的出生时间保存后不能再改」,说明里不再重复。保存他人成功后清掉星盘页和星历页缓存(BUG-1411),再打开仍是刚保存的资料。
- **编辑表单里的关系**(2026-10-23):只在编辑或添加别人时出现,放在出生地点之后、性别之前。标题「TA 是你的」,五个选项:伴侣、孩子、父母、朋友、其他。复用性别那一排的尺寸和样式,不新造颜色。没点过就保持空,页面上仍显示「其他」;点了才写入。本人的编辑表单没有这一项。
- **编辑表单里的性别(选填)**(2026-09-27):放在关系之后、岁差之前(女 / 男 / 不填,44px,一行说明),随「保存」一起提交;选「不填」存为空。自 2026-09-30(BUG-1119)起这是设置性别的唯一入口,个人资料里不再有。
- **详情卡**:头部 48px 头像 + 名字 +「本人 · 出生地」(他人只写出生地);两格时间卡「填报时间」「排盘时间」,等宽大号数字。只有采用或确认了**校正结果**(状态 accepted / confirmed **且**有校正案例)时,排盘卡换成 `--color-success` 描边 + `--color-success-muted` 底,标签写「排盘时间 · 已校正」——旧资料读回时的 `confirmed` 兜底不算。下面是出生日期、岁差(显示「Raman(默认)」这类设置里的名字,不露小写键)与性别(「女」「男」,没填写「未填」),再下面三个等宽快捷入口「和 TA 对话 / 看星盘 / 生成报告」(图标用侧栏同款 lucide)。他人另有「和我合盘」;每个人底部都有「编辑」,本人在它上面加一行灰字「本人不能删除」。**本人的编辑视图(2026-09-28,BUG-1081)**与他人同一张表(称呼、出生资料、岁差、性别),标题「编辑我的资料」、说明「称呼和出生资料。」,没有删除区;保存走账户资料接口,保存后首页、星盘、星历按新资料重读。他人已经保存过出生时间时,编辑表单锁住这一项,旁边只留「填报的出生时间保存后不能再改」,说明里不再重复。保存他人成功后清掉星盘页和星历页缓存(BUG-1411),再打开仍是刚保存的资料。
- **编辑表单里的性别(选填)**(2026-09-27):放在出生地点之后、岁差之前(女 / 男 / 不填,44px,一行说明),随「保存」一起提交;选「不填」存为空。自 2026-09-30(BUG-1119)起这是设置性别的唯一入口,个人资料里不再有。
- **删除只在编辑视图底部**,单独一张卡,删除确认、用量计数、查询失败句全部沿用。
- **头像颜色**:本人用 `--color-action-strong` + `--color-on-dark`(同账户首字圆);其他人按 id 稳定地取四个元素色 `--color-element-fire / earth / air / water` 之一,字用 `--color-ink`。不新造色值。
+2 -6
View File
@@ -39,8 +39,8 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
## 生时校正从对话进入(2026-10-07,BUG-1247~1251;2026-10-12,BUG-1306)
- **何时提议**:校正就在这个对话里能开。用户说推得不准时先分清读法问题和时间问题。只是不认同某个解释,按改读规则认下、改读,不提校正。不承诺能收窄出生时间,不在对话里收集经历做校正。说出过去的具体事情和盘对不上、说出生时间是估的或记不准、但没有明确要做校正,要先看过盘再提议。同一次对话提议过、用户没接,不再主动提;用户自己再要求时传 `userAsked=true`。还没看盘就顺口提议,不算已经提议过。规则只定义在 `consultation-thinking-plan.ts` 的 `RECTIFICATION_OFFER_RULE`。不用关键词判断。
- **明确要求则直接进入**:用户这一轮明确要做校正(校正、纠正、核对出生时间都算),由模型判断,不用关键词。第一步就可以打开,不用先排盘写解读。服务端放行后直接打开校正,不用点卡。过渡只有一句「好,我们开始核对出生时间。」已经有一次没做完的,直接接着做那一次,不新开。开案还没成功时,回答下面仍是普通提议卡,不说「已经开始」;打开失败可以再点「开始核对出生时间」。开案并绑定来源之后才改成「已经开始」。
- **做不了必须说一句**:开关关着,或暂时没有价格,都说「出生时间核对这会儿还做不了。」不写内部原因,不写价格,不得沉默。家人档案说「给家人校正暂时还不支持」,不开案。有进行中的校正但这次打不开原来的会话,说「可在历史对话里接着做。」
- **明确要求则直接进入**:用户这一轮明确要做校正(校正、纠正、核对出生时间都算),由模型判断,不用关键词。第一步就可以打开,不用先排盘写解读。服务端放行后直接打开校正,不用点卡。过渡只有一句「好,我们开始核对出生时间。」已经采用过时间的,不算还在进行,明说要校正就新开一次。真没做完的,直接接着做那一次。这次如果接不回原来的会话,也直接新开。开案还没成功时,回答下面仍是普通提议卡,不说「已经开始」;打开失败可以再点「开始核对出生时间」。开案并绑定来源之后才改成「已经开始」。
- **做不了必须说一句**:开关关着,或暂时没有价格,都说「出生时间核对这会儿还做不了。」不写内部原因,不写价格,不得沉默。家人档案说「给家人校正暂时还不支持」,不开案。不再说「可在历史对话里接着做。」
- **正文怎么提**(没有明确要求、只是时间可能不准):一句,放在最后一段(例:「如果想确认出生时间准不准,可以用下面的卡片,答几道过去经历的题核对一下。」),不写价格和题数——卡片上写。
- **卡片**:「可以用几道过去经历的题,核对一下你的出生时间。大约 N 题,会用掉 M 点;开始答第一题时才扣。」按钮「开始核对出生时间」;已开始(开案并绑定来源之后,不是结算当时)「出生时间核对已经开始。」/「继续核对出生时间」。点数来自 `feature_pricing`,不写死。
- **回来**:分隔线「出生时间改为 HH:MM(生时校正)」/「出生时间没有改」;之前的回答上方「按校正前的时间」;按钮「按新时间重新看:「原问题」」。校正面按钮「回到刚才的对话」。不再说「新建对话」。
@@ -505,7 +505,3 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
不写「系统繁忙」「请稍后重试」。计算要等空位时,进度行只写「人有点多,排一下队」。这一轮最终没排上:「人有点多,这一轮没排上,点数已经退回。」对话太密:「这一会儿问得有点密,先停一下。」校正回合太密:「这一会儿校正问得有点密,先停一下。」这一小时报告太多:「这一小时的报告已经够多了,先停一下。」
## 人物关系(2026-10-23)
星盘档案里问别人和你的关系,标题只用「TA 是你的」。选项只有:伴侣、孩子、父母、朋友、其他。不写「客户」「家人」来代替这五项。没选过的,名字后面的小标签写「其他」。本人的标签仍是「本人」,不问这一句。
+1 -1
View File
@@ -1275,7 +1275,7 @@ export default function Home() {
<ChatComposer
inputRef={composerInput}
inputLabel={!synastryChat && !profileComplete && onboardingStep === "name" ? "输入你的称呼" : "输入你的问题"}
placeholder={ordinaryComposerPlaceholder({ locked: rectificationComposerLocked, hasAccount: Boolean(account), profileComplete: synastryChat || profileComplete, onboardingName: onboardingStep === "name", presetMessageFinished, creditsEmpty: Boolean(account && account.credits === 0 && !account.activeSubscription && !account.firstConsultFree), subjectDeleted, synastryClosed })}
placeholder={ordinaryComposerPlaceholder({ locked: rectificationComposerLocked, hasAccount: Boolean(account), profileComplete: synastryChat || profileComplete, onboardingName: onboardingStep === "name", presetMessageFinished, creditsEmpty: Boolean(account && account.credits === 0 && !account.activeSubscription), subjectDeleted, synastryClosed })}
maxLength={!synastryChat && !profileComplete && onboardingStep === "name" ? 80 : 500}
inputDisabled={synastryClosed || subjectDeleted || sessionMessagesLoading || rectificationSurfaceOpen || activeRectificationSession || (!synastryChat && !profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))}
submitLabel={!synastryChat && !profileComplete && onboardingStep === "name" ? "确认称呼" : "发送"}
+1 -7
View File
@@ -28,11 +28,6 @@ function adminEntryUrl(): string {
: "/admin";
}
function firstConsultFreeFromProfile(profile: object): boolean {
return Object.prototype.hasOwnProperty.call(profile, "first_consult_free_used_at")
&& (profile as { first_consult_free_used_at?: unknown }).first_consult_free_used_at == null;
}
function isMissingProfileColumn(error: { code?: string; message?: string } | null) {
const message = error?.message?.toLowerCase() ?? "";
return error?.code === "PGRST204"
@@ -57,7 +52,7 @@ export async function GET() {
const admin = createAdminSupabaseClient();
let { data: profile, error: profileError } = await supabase
.from("profiles")
.select("credits,active_birth_time,birth_time_status,birth_date,active_birth_date,active_birth_timezone_offset,active_birth_provenance,reported_birth_time,birth_time_source,birth_time_period,declared_window_start,declared_window_end,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,timezone_id,timezone_source,ayanamsa,name,birth_time,rectification_case_id,gender,first_consult_free_used_at")
.select("credits,active_birth_time,birth_time_status,birth_date,active_birth_date,active_birth_timezone_offset,active_birth_provenance,reported_birth_time,birth_time_source,birth_time_period,declared_window_start,declared_window_end,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,timezone_id,timezone_source,ayanamsa,name,birth_time,rectification_case_id,gender")
.eq("id", userId)
.single();
@@ -173,7 +168,6 @@ export async function GET() {
},
chartBirth: resolveServerOwnedChartBirth(profile),
compatibility,
firstConsultFree: firstConsultFreeFromProfile(profile),
});
} catch (error) {
if (isSupabaseConfigurationError(error)) {
+1 -5
View File
@@ -25,12 +25,10 @@ type Row = {
model_steps: unknown;
classification_duration_ms: string | null;
answer_reasoning_ms: string | null;
billing_source: string | null;
free_credit_cost: string | null;
total_count: string;
};
const LIST_SQL = `select l.id,l.user_id,u.email,l.request_id,l.feature_key,l.source,l.requested_model_id,l.actual_model_id,l.model_config_version,l.input_tokens,l.output_tokens,l.cost_microusd::text,l.duration_ms,l.created_at,l.metadata->'cache'->>'readTokens' as cache_read_tokens,l.metadata->'modelSteps' as model_steps,l.metadata->'classification'->>'durationMs' as classification_duration_ms,l.metadata->'answer'->>'reasoning_ms' as answer_reasoning_ms,l.metadata->>'billingSource' as billing_source,l.metadata->>'creditCost' as free_credit_cost,count(*) over()::text total_count from public.usage_ledger l left join identity.users u on u.id=l.user_id where ($1::text is null or u.email ilike $1 or l.request_id ilike $1 or l.actual_model_id ilike $1) and ($2::text is null or l.source=$2 or l.feature_key=$2 or l.metadata->>'billingSource'=$2) order by l.created_at desc limit $3 offset $4`;
const LIST_SQL = `select l.id,l.user_id,u.email,l.request_id,l.feature_key,l.source,l.requested_model_id,l.actual_model_id,l.model_config_version,l.input_tokens,l.output_tokens,l.cost_microusd::text,l.duration_ms,l.created_at,l.metadata->'cache'->>'readTokens' as cache_read_tokens,l.metadata->'modelSteps' as model_steps,l.metadata->'classification'->>'durationMs' as classification_duration_ms,l.metadata->'answer'->>'reasoning_ms' as answer_reasoning_ms,count(*) over()::text total_count from public.usage_ledger l left join identity.users u on u.id=l.user_id where ($1::text is null or u.email ilike $1 or l.request_id ilike $1 or l.actual_model_id ilike $1) and ($2::text is null or l.source=$2 or l.feature_key=$2) order by l.created_at desc limit $3 offset $4`;
export async function GET(request: Request) {
try {
@@ -61,8 +59,6 @@ export async function GET(request: Request) {
durationMs: row.duration_ms,
cacheReadTokens: row.cache_read_tokens == null ? null : Number(row.cache_read_tokens),
timing: usageTimingFromLedger(row.model_steps, row.classification_duration_ms, row.answer_reasoning_ms),
billingSource: row.billing_source,
freeCreditCost: row.free_credit_cost == null ? null : Number(row.free_credit_cost),
createdAt: row.created_at.toISOString(),
})),
total: Number(rows[0]?.total_count ?? 0),
+2 -15
View File
@@ -33,7 +33,7 @@ import { consultationDomainSchema } from "@/lib/consultation-domain-registry";
import { parseAgentReply } from "@/lib/agent-reply";
import { revisitQuestionForOffer, sessionHasRectificationOffer } from "@/lib/rectification-in-chat";
import { decideRectificationOffer, persistOpenedDirectOffer } from "@/lib/rectification-in-chat-server";
import { getRectificationEntrySummary } from "@/lib/rectification-agentic/v9/case-service";
import { loadResumableRectificationSessionId } from "@/lib/rectification-resume-session";
import { createConsultationReplyMetadata } from "@/lib/consultation-reply-metadata";
import {
ConsultationPlanValidationError,
@@ -1025,20 +1025,7 @@ export async function POST(request: Request) {
alreadyOffered: sessionHasRectificationOffer(chatSession?.messages),
revisitQuestion: revisitQuestionForOffer(chatSession?.messages, visibleQuestion, requestId),
sourceSessionId: sessionId,
loadResumableSessionId: async () => {
const summary = await getRectificationEntrySummary(accounting, userId);
const caseId = summary.latestResumable?.caseId;
if (!caseId) return null;
const { data, error } = await accounting
.from("agentic_rectification_cases")
.select("session_id")
.eq("user_id", userId)
.eq("id", caseId)
.maybeSingle();
if (error || !data) return null;
const resumableSessionId = (data as { session_id?: unknown }).session_id;
return typeof resumableSessionId === "string" && resumableSessionId ? resumableSessionId : null;
},
loadResumableSessionId: () => loadResumableRectificationSessionId(accounting, userId),
};
const hooks = createConsultationRuntimeHooks(state);
const usages: Promise<Usage>[] = [];
@@ -1,62 +0,0 @@
import { NextResponse } from "next/server";
import { parseOnboardingPatch, parseOnboardingState } from "@/lib/onboarding-state";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
import { createServerSupabaseClient } from "@/lib/supabase/server";
export const runtime = "nodejs";
function unavailable(error: unknown) {
if (isSupabaseConfigurationError(error)) {
return NextResponse.json({ error: "数据库尚未配置", code: "DATABASE_NOT_CONFIGURED" }, { status: 503 });
}
return NextResponse.json({ error: "引导状态暂时不可用" }, { status: 500 });
}
async function signedInUser() {
const supabase = await createServerSupabaseClient();
const { data: { user }, error } = await supabase.auth.getUser();
if (error || !user) return { supabase, user: null };
return { supabase, user };
}
export async function GET() {
try {
const { supabase, user } = await signedInUser();
if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
const { data, error } = await supabase
.from("profiles")
.select("onboarding_state")
.eq("id", user.id)
.single();
if (error || !data) return NextResponse.json({ error: "暂时无法读取引导状态" }, { status: 500 });
return NextResponse.json({ state: parseOnboardingState(data.onboarding_state) });
} catch (error) {
return unavailable(error);
}
}
export async function PATCH(request: Request) {
try {
const { user } = await signedInUser();
if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
const parsed = parseOnboardingPatch(await request.json().catch(() => null));
if (!parsed.ok) return NextResponse.json({ error: parsed.error }, { status: 400 });
const admin = createAdminSupabaseClient();
const { data, error } = await admin.rpc("save_onboarding_state", {
p_user_id: user.id,
p_patch: parsed.patch,
});
if (error) return NextResponse.json({ error: "暂时无法保存引导状态" }, { status: 500 });
const row = (Array.isArray(data) ? data[0] : data) as { success?: boolean; state?: unknown; error_code?: string } | null;
if (!row?.success) {
return NextResponse.json(
{ error: "引导状态没有保存", code: row?.error_code ?? "invalid_state" },
{ status: row?.error_code === "unauthorized" ? 401 : 400 },
);
}
return NextResponse.json({ state: parseOnboardingState(row.state) });
} catch (error) {
return unavailable(error);
}
}
@@ -418,8 +418,6 @@ type Usage = {
durationMs: number | null;
cacheReadTokens: number | null;
timing: UsageTimingView | null;
billingSource: string | null;
freeCreditCost: number | null;
createdAt: string;
};
@@ -438,11 +436,7 @@ export function UsageResource() {
{
title: "资金来源",
dataIndex: "source",
render: (_value, item) => {
if (item.billingSource !== "first_consult_free") return item.source;
const difference = item.freeCreditCost ?? 0;
return difference > 0 ? `首问免费(差价 ${difference} 点)` : "首问免费";
},
render: (value) => value,
},
{
title: "模型",
@@ -491,12 +485,12 @@ export function UsageResource() {
title="用量、Token 与成本"
columns={columns}
statusOptions={[
{ value: "subscription", label: "subscription" },
{ value: "credits", label: "credits" },
{ value: "first_consult_free", label: "首问免费" },
{ value: "chat.standard", label: "chat.standard" },
{ value: "rectification", label: "rectification" },
]}
"subscription",
"credits",
"complimentary",
"chat.standard",
"rectification",
].map((value) => ({ value, label: value }))}
/>
);
}
@@ -88,7 +88,6 @@ export function ChartBirthEditorForm({
nameInputId="chart-birth-edit-name"
showAyanamsa
showGender
showRelation={!self}
lockReportedBirthTime={!self && reportedTimeStored(baseline)}
notice={birthFactsChanged(baseline, draft) ? CHART_BIRTH_EDIT_NOTICE : null}
onSubmit={onSubmit}
@@ -3,7 +3,6 @@
import type { Dispatch, FormEvent, ReactNode, SetStateAction } from "react";
import { AyanamsaPreferenceField } from "@/components/ayanamsa-preference-field";
import { ProfileFields } from "@/components/profile-fields";
import { PersonRelationField } from "@/components/person-relation-field";
import { ProfileGenderField } from "@/components/profile-gender-field";
import { resolveAyanamsa } from "@/lib/ayanamsa";
import type { Profile } from "@/lib/home-types";
@@ -17,8 +16,6 @@ export type ChartProfileFormProps = {
readonly showAyanamsa?: boolean;
/** Optional gender (女 / 男 / 不填); saved with the form. */
readonly showGender?: boolean;
/** Household link. Only for someone else; self does not store relation. */
readonly showRelation?: boolean;
readonly onSubmit: (event: FormEvent<HTMLFormElement>) => void;
readonly onCancel?: () => void;
readonly cancelLabel?: string;
@@ -37,7 +34,6 @@ export function ChartProfileForm({
nameInputId,
showAyanamsa = false,
showGender = false,
showRelation = false,
onSubmit,
onCancel,
cancelLabel = "取消",
@@ -55,13 +51,6 @@ export function ChartProfileForm({
nameInputId={nameInputId}
lockReportedBirthTime={lockReportedBirthTime}
/>
{showRelation ? (
<PersonRelationField
idPrefix={nameInputId}
value={value.relation}
onChange={(relation) => onChange((current) => ({ ...current, relation }))}
/>
) : null}
{showGender ? (
<ProfileGenderField
idPrefix={nameInputId}
@@ -6,7 +6,6 @@ import { FileText, MessageSquareText, Orbit } from "lucide-react";
import { newChatHref } from "@/lib/chat-session-url";
import { effectiveBirthDate } from "@/lib/effective-birth-date";
import { ChartProfileForm } from "@/components/chart-profile-form";
import { personRelationLabel } from "@/lib/person-relation";
import { SecondaryPageShell } from "@/components/secondary-page-shell";
import { bindCurrentSubjectAccount, invalidateSubjectCatalog, setCurrentSubject, useCurrentSubject } from "@/lib/current-subject";
import { fetchAccount } from "@/lib/home-cloud-sync";
@@ -410,9 +409,7 @@ export function PeopleArchiveView({
<PeopleAvatar person={person} />
<span className="people-archive-row-name">
<b>{peopleDisplayName(person.profile, person.role)}</b>
{person.role === "self"
? <span className="people-archive-tag">本人</span>
: <span className="people-archive-tag">{personRelationLabel(person.profile.relation)}</span>}
{person.role === "self" ? <span className="people-archive-tag">本人</span> : null}
</span>
<small className="people-archive-row-meta">
{person.incomplete ? "这份资料还不完整" : peopleRowMeta(person.profile)}
@@ -442,7 +439,6 @@ export function PeopleArchiveView({
nameInputId="people-archive-name"
showAyanamsa
showGender
showRelation={!editingSelf}
lockReportedBirthTime={lockReportedBirthTime}
onSubmit={(event) => void (editingSelf ? saveSelf(event) : saveOther(event))}
onCancel={onCancelEdit}
@@ -454,7 +450,7 @@ export function PeopleArchiveView({
<header className="people-archive-head">
<PeopleAvatar person={selected} large />
<div>
<h2>{peopleDisplayName(selected.profile, selected.role)}{selected.role === "self" ? null : <span className="people-archive-tag">{personRelationLabel(selected.profile.relation)}</span>}</h2>
<h2>{peopleDisplayName(selected.profile, selected.role)}</h2>
<p>{selected.role === "self" ? `本人 · ${profilePlaceLabel(selected.profile)}` : profilePlaceLabel(selected.profile)}</p>
</div>
</header>
@@ -1,47 +0,0 @@
"use client";
import {
PERSON_RELATIONS,
PERSON_RELATION_LABELS,
PERSON_RELATION_TITLE,
type PersonRelation,
} from "@/lib/person-relation";
/**
* 「TA 是你的」 on someone else's chart. An empty stored value looks like 其他
* and is written only when that choice is clicked.
*/
export function PersonRelationField({
value,
onChange,
idPrefix,
}: {
readonly value: PersonRelation | null | undefined;
readonly onChange: (next: PersonRelation) => void;
readonly idPrefix: string;
}) {
const titleId = `${idPrefix}-relation-title`;
const shown = value ?? "other";
return (
<div className="profile-gender-field">
<span className="profile-gender-title" id={titleId}>{PERSON_RELATION_TITLE}</span>
<div className="profile-gender-options" role="radiogroup" aria-labelledby={titleId}>
{PERSON_RELATIONS.map((option) => {
const selected = option === shown;
return (
<button
key={option}
className={`profile-gender-option${selected ? " is-selected" : ""}`}
type="button"
role="radio"
aria-checked={selected}
onClick={() => onChange(option)}
>
{PERSON_RELATION_LABELS[option]}
</button>
);
})}
</div>
</div>
);
}
+1 -1
View File
@@ -615,7 +615,7 @@ export function useConsultationRun(params: ConsultationRunParams) {
? { kind: "consult" as const, mode: "general_no_birth_time" as const, time: null }
: initialConsultationRoute;
if (account.credits <= 0 && !account.activeSubscription && !account.firstConsultFree) {
if (account.credits <= 0 && !account.activeSubscription) {
if (!openRedeemPaywallOnce()) openAccountDialog("billing", { source: "insufficient-credits" });
return false;
}
-9
View File
@@ -1,5 +1,4 @@
import { isAyanamsaName, resolveAyanamsa } from "./ayanamsa.ts";
import { parsePersonRelation } from "./person-relation.ts";
import { parseProfileGender } from "./profile-gender.ts";
import { MAX_OTHER_SUBJECTS } from "./subject-birth.ts";
import { asRecord as recordOf } from "@/lib/guards";
@@ -90,12 +89,6 @@ export function parseChartSubjectWrite(value: unknown): { ok: true; write: Chart
if (genderSent && profile.gender != null && profile.gender !== "" && !gender) {
return { ok: false, error: "性别只能选女、男或不填" };
}
// Same omit-when-absent rule as gender, so an old client does not clear relation.
const relationSent = Object.prototype.hasOwnProperty.call(profile, "relation");
const relation = parsePersonRelation(profile.relation);
if (relationSent && profile.relation != null && profile.relation !== "" && !relation) {
return { ok: false, error: "关系只能选伴侣、孩子、父母、朋友或其他" };
}
const columns: Record<string, string | number | null> = {
name,
birth_date: birthDate,
@@ -117,7 +110,6 @@ export function parseChartSubjectWrite(value: unknown): { ok: true; write: Chart
ayanamsa,
birth_time_status: status,
...(genderSent ? { gender } : {}),
...(relationSent ? { relation } : {}),
};
const mirror = {
name,
@@ -182,7 +174,6 @@ export function libraryProfileFromSubjectRow(row: Record<string, unknown>): Reco
timezoneOffset: typeof row.timezone_offset === "number" ? row.timezone_offset : null,
ayanamsa: resolveAyanamsa(row),
...(parseProfileGender(row.gender) ? { gender: parseProfileGender(row.gender) } : {}),
...("relation" in row ? { relation: parsePersonRelation(row.relation) } : {}),
...(typeof row.active_birth_date === "string" ? { activeDate: row.active_birth_date.slice(0, 10) } : {}),
...(typeof row.active_birth_timezone_offset === "number"
? { activeTimezoneOffset: row.active_birth_timezone_offset }
-4
View File
@@ -26,7 +26,6 @@ import {
import { resolveAyanamsa } from "@/lib/ayanamsa";
import { loadedChinaCountry } from "@/lib/china-locations-client";
import { effectiveBirthDate } from "@/lib/effective-birth-date";
import { parsePersonRelation } from "@/lib/person-relation";
import { parseProfileGender } from "@/lib/profile-gender";
/** Needs the city table loaded (china-locations-client.ts); undefined until then. */
@@ -322,9 +321,6 @@ export function readProfile(value: unknown): Profile {
timezoneOffset,
ayanamsa: resolveAyanamsa(profile),
...(parseProfileGender(profile.gender) ? { gender: parseProfileGender(profile.gender) } : {}),
...(Object.prototype.hasOwnProperty.call(profile, "relation")
? { relation: parsePersonRelation(profile.relation) }
: {}),
...(chartRelationship ? { chartRelationship } : {}),
};
}
-5
View File
@@ -2,7 +2,6 @@ import type { BeamAvatar } from "@/lib/beam-avatar";
import type { ConsultationEntrypoint } from "@/lib/consultation-entrypoint";
import type { BirthTimeDraft } from "@/lib/birth-time-intake-model";
import { DEFAULT_AYANAMSA, type AyanamsaName } from "@/lib/ayanamsa";
import type { PersonRelation } from "@/lib/person-relation";
import type { ProfileGender } from "@/lib/profile-gender";
import type { AgentActivityView, ChatMessage } from "@/lib/chat-message-view";
import type { ConsultationTimelineRow } from "@/lib/consultation-run-timeline";
@@ -35,8 +34,6 @@ export type Profile = BirthTimeDraft & {
ayanamsa: AyanamsaName;
/** Optional; absent or null = not filled (TASK-consult-gender-optional-20260927). */
gender?: ProfileGender | null;
/** Household link for someone else. Absent or null displays as 其他 and is not the old chartRelationship vocabulary. */
relation?: PersonRelation | null;
chartRelationship?: ChartRelationship;
};
export type ChartRelationship = "self" | "partner" | "family" | "friend" | "client" | "other";
@@ -147,8 +144,6 @@ export type Account = {
profile: unknown;
chartBirth?: { fingerprint?: string | null } | null;
compatibility?: boolean;
/** True while this account's one free standard question is still available. Missing means the paywall stays. */
firstConsultFree?: boolean;
};
export type OnboardingStep = "name" | "birth" | "place" | "rectification";
export type AccountDialog = "profile" | "chart-library" | "billing" | "general" | "logout";
-92
View File
@@ -1,92 +0,0 @@
/** Server-owned onboarding flags. The only keys the checklist and hints may store. */
export const ONBOARDING_STATE_KEYS = ["checklistDismissedAt", "hintsSeen", "chartVisitedAt"] as const;
export const ONBOARDING_HINT_IDS = [
"follow-up",
"sidebar-chart",
"tab-chart",
"tab-info",
"tab-dasha",
"tab-west",
"syn-ready",
"ephemeris",
] as const;
export type OnboardingHintId = (typeof ONBOARDING_HINT_IDS)[number];
export type OnboardingState = {
readonly checklistDismissedAt?: string | null;
readonly hintsSeen?: readonly OnboardingHintId[];
readonly chartVisitedAt?: string | null;
};
const TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?(?:Z|[+-]\d{2}:\d{2})$/;
function isHintId(value: unknown): value is OnboardingHintId {
return ONBOARDING_HINT_IDS.some((id) => id === value);
}
function timestamp(value: unknown): string | null | undefined {
if (value == null) return null;
if (typeof value !== "string" || !TIMESTAMP.test(value) || Number.isNaN(Date.parse(value))) return undefined;
return value;
}
export function parseOnboardingState(value: unknown): OnboardingState {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
const record = value as Record<string, unknown>;
const state: {
checklistDismissedAt?: string | null;
hintsSeen?: OnboardingHintId[];
chartVisitedAt?: string | null;
} = {};
if ("checklistDismissedAt" in record) {
const parsed = timestamp(record.checklistDismissedAt);
if (parsed !== undefined) state.checklistDismissedAt = parsed;
}
if ("chartVisitedAt" in record) {
const parsed = timestamp(record.chartVisitedAt);
if (parsed !== undefined) state.chartVisitedAt = parsed;
}
if (Array.isArray(record.hintsSeen)) {
const hints = record.hintsSeen.filter(isHintId);
if (hints.length === record.hintsSeen.length) state.hintsSeen = hints;
}
return state;
}
export function parseOnboardingPatch(
value: unknown,
): { ok: true; patch: OnboardingState } | { ok: false; error: string } {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return { ok: false, error: "引导状态格式不正确" };
}
const record = value as Record<string, unknown>;
const unknownKey = Object.keys(record).find((key) => !ONBOARDING_STATE_KEYS.some((allowed) => allowed === key));
if (unknownKey) return { ok: false, error: "只能记下清单、提示和是否进过星盘页" };
if (Object.keys(record).length === 0) return { ok: false, error: "没有要保存的引导状态" };
const patch: {
checklistDismissedAt?: string | null;
hintsSeen?: OnboardingHintId[];
chartVisitedAt?: string | null;
} = {};
if ("checklistDismissedAt" in record) {
const parsed = timestamp(record.checklistDismissedAt);
if (parsed === undefined) return { ok: false, error: "时间格式不正确" };
patch.checklistDismissedAt = parsed;
}
if ("chartVisitedAt" in record) {
const parsed = timestamp(record.chartVisitedAt);
if (parsed === undefined) return { ok: false, error: "时间格式不正确" };
patch.chartVisitedAt = parsed;
}
if ("hintsSeen" in record) {
if (!Array.isArray(record.hintsSeen) || record.hintsSeen.some((item) => !isHintId(item))) {
return { ok: false, error: "这个提示不在名单里" };
}
patch.hintsSeen = record.hintsSeen.filter(isHintId);
}
return { ok: true, patch };
}
-25
View File
@@ -1,25 +0,0 @@
/** Household link on someone else's chart. Self is not stored here. */
export const PERSON_RELATIONS = ["partner", "child", "parent", "friend", "other"] as const;
export type PersonRelation = (typeof PERSON_RELATIONS)[number];
export const PERSON_RELATION_LABELS: Readonly<Record<PersonRelation, string>> = {
partner: "伴侣",
child: "孩子",
parent: "父母",
friend: "朋友",
other: "其他",
};
export const PERSON_RELATION_TITLE = "TA 是你的";
export function parsePersonRelation(value: unknown): PersonRelation | null {
return PERSON_RELATIONS.find((item) => item === value) ?? null;
}
/** A missing value displays as 其他. The column stays null until the person picks one. */
export function personRelationLabel(value: unknown): string {
const relation = parsePersonRelation(value);
return relation ? PERSON_RELATION_LABELS[relation] : PERSON_RELATION_LABELS.other;
}
@@ -61,13 +61,6 @@ export function evidenceWritesAllowed(
return isResumableStatus(status);
}
/**
* The one-resumable-case-per-user invariant, mirrored from the database
* partial unique index. The database is the enforcement point; this is the
* contract the service layer relies on.
*/
export const MAX_RESUMABLE_CASES_PER_USER = 1;
export const RECTIFICATION_SKILL_NAME = "jyotish-birth-time-rectification";
// 原值: "10.0.28" / 新值: "10.0.29" / 原因: 年月阶段改口述并禁止追问本人
// 原值: "10.0.29" / 新值: "10.0.30" / 原因: D2 出卡句与 D4 卡上百分比规则写进 Skill(2026-09-26)
@@ -9,6 +9,7 @@ import {
appendRectificationReturn,
attachOpenedOfferToFreeMessage,
bindOfferToCase,
blocksNewRectification,
decideRectificationOfferWith,
offerForCase,
sourceFromSessionRows,
@@ -35,7 +36,10 @@ export async function decideRectificationOffer(input: {
return decideRectificationOfferWith({
subjectRole: input.subjectRole,
productEnabled: () => isProductEnabled("rectification"),
hasResumableCase: async () => (await getRectificationEntrySummary(input.accounting, input.userId)).hasResumableCase,
hasResumableCase: async () => {
const summary = await getRectificationEntrySummary(input.accounting, input.userId);
return blocksNewRectification(summary.latestResumable?.status ?? "");
},
creditCost: async () => (await resolveFeaturePricing(input.accounting, "rectification", input.modelId)).credit_cost,
});
}
+17 -1
View File
@@ -86,7 +86,23 @@ 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 = "给家人校正暂时还不支持";
export const RECTIFICATION_DIRECT_RESUME_ELSEWHERE_COPY = "可在历史对话里接着做。";
/**
* Chat entry only. An accepted case is a finished stop, so it must not block
* a new explicit open or the ordinary offer card. The shared resumable set
* still includes that status for old sessions, skill upgrade and rebaseline.
*/
const ENTRY_BLOCKING_RECTIFICATION_STATUSES = new Set<string>([
"draft",
"collecting_evidence",
"candidate_ready",
"needs_rebaseline",
"paused",
]);
export function blocksNewRectification(status: string): boolean {
return ENTRY_BLOCKING_RECTIFICATION_STATUSES.has(status);
}
export function rectificationReturnDividerText(record: Pick<RectificationReturnRecord, "adopted" | "clock">): string {
return record.adopted && record.clock
@@ -0,0 +1,50 @@
import type { DataClient } from "@/lib/db/data-client";
import {
RectificationCaseServiceError,
getRectificationCase,
getRectificationEntrySummary,
} from "@/lib/rectification-agentic/v9/case-service";
import { blocksNewRectification } from "@/lib/rectification-in-chat";
/**
* Raised when an in-progress case cannot be resumed. The message is the
* category only, so a log of this error cannot carry a profile or a quote.
*/
export class RectificationResumeLookupError extends Error {
readonly errorCategory: string;
readonly hasCaseId: boolean;
constructor(errorCategory: string, hasCaseId: boolean) {
super(errorCategory);
this.name = "RectificationResumeLookupError";
this.errorCategory = errorCategory;
this.hasCaseId = hasCaseId;
}
}
function safeCategory(value: string): string {
return /^[a-z0-9_]+$/.test(value) ? value : "lookup_failed";
}
/**
* Session of the latest case that still blocks a new chat entry.
* Accepted cases do not block. The read goes through the case RPC: the
* service role cannot select the cases table.
*/
export async function loadResumableRectificationSessionId(
accounting: DataClient,
userId: string,
): Promise<string | null> {
const summary = await getRectificationEntrySummary(accounting, userId);
const latest = summary.latestResumable;
if (!latest?.caseId || !blocksNewRectification(latest.status)) return null;
try {
const view = await getRectificationCase(accounting, userId, latest.caseId);
if (!view.sessionId) throw new RectificationResumeLookupError("missing_session", true);
return view.sessionId;
} catch (error) {
if (error instanceof RectificationResumeLookupError) throw error;
const category = error instanceof RectificationCaseServiceError ? error.code : "lookup_failed";
throw new RectificationResumeLookupError(safeCategory(category), true);
}
}
+2 -6
View File
@@ -7,7 +7,6 @@
* to the household profile.
*/
import { parsePersonRelation, type PersonRelation } from "./person-relation.ts";
import { resolveServerOwnedChartBirth } from "./chart-birth-truth.ts";
import {
ACCOUNT_BIRTH_SELECT,
@@ -38,9 +37,9 @@ export class ConsultationSubjectError extends Error {
export const SELF_SUBJECT_ID = "self";
export const CHART_SUBJECT_SELECT = "id,user_id,role,name,birth_date,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,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,ayanamsa,active_birth_time,active_birth_date,active_birth_timezone_offset,birth_time_status,gender,relation";
export const CHART_SUBJECT_SELECT = "id,user_id,role,name,birth_date,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,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,ayanamsa,active_birth_time,active_birth_date,active_birth_timezone_offset,birth_time_status,gender";
export const CHART_SUBJECT_API_SELECT = "id,user_id,role,name,birth_date,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,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,ayanamsa,active_birth_time,active_birth_date,active_birth_timezone_offset,birth_time_status,gender,relation,profile,updated_at";
export const CHART_SUBJECT_API_SELECT = "id,user_id,role,name,birth_date,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,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,ayanamsa,active_birth_time,active_birth_date,active_birth_timezone_offset,birth_time_status,gender,profile,updated_at";
export const MAX_OTHER_SUBJECTS = 19;
export const MAX_SAVED_SUBJECTS = 20;
@@ -49,7 +48,6 @@ export type ResolvedSubjectBirth = Readonly<{
subjectId: string;
role: "self" | "other";
name: string;
relation: PersonRelation | null;
row: Record<string, unknown>;
chartable: boolean;
}>;
@@ -142,7 +140,6 @@ export async function resolveSubjectBirth(input: {
subjectId: SELF_SUBJECT_ID,
role: "self",
name: text(row.name),
relation: null,
row,
chartable,
});
@@ -198,7 +195,6 @@ export async function resolveSubjectBirth(input: {
subjectId,
role: "other",
name: text(accountRow.name),
relation: parsePersonRelation(row.relation),
row: accountRow,
chartable,
});
+39 -9
View File
@@ -2,7 +2,6 @@ import {
RECTIFICATION_DIRECT_NEW_COPY,
RECTIFICATION_DIRECT_NOT_SELF_COPY,
RECTIFICATION_DIRECT_RESUME_COPY,
RECTIFICATION_DIRECT_RESUME_ELSEWHERE_COPY,
RECTIFICATION_DIRECT_UNAVAILABLE_COPY,
RECTIFICATION_OFFER_TOOL_ID,
type RectificationOfferDecision,
@@ -1155,7 +1154,7 @@ const evidenceLookupInputSchema = z.object({
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: "用户已经有一次没做完的生时校正。正文用一句话告诉他可以在历史对话里接着做。",
case_in_progress: "用户已经有一次没做完的生时校正。这次不出提议卡,正文照常回答这一轮的问题。",
pricing_unavailable: `出生时间核对这会儿做不了。正文必须用这一句说明:「${RECTIFICATION_DIRECT_UNAVAILABLE_COPY}」,不写价格。`,
};
@@ -1176,16 +1175,33 @@ function offerRecord(
};
}
function resumeFailureLog(error: unknown): { errorCategory: string; hasCaseId: boolean } {
if (error && typeof error === "object" && "errorCategory" in error && "hasCaseId" in error) {
const record = error as { errorCategory?: unknown; hasCaseId?: unknown };
const errorCategory = typeof record.errorCategory === "string" && /^[a-z0-9_]+$/.test(record.errorCategory)
? record.errorCategory
: "lookup_failed";
return { errorCategory, hasCaseId: record.hasCaseId === true };
}
return { errorCategory: "lookup_failed", hasCaseId: false };
}
function logResumeLookupFailed(requestId: string, errorCategory: string, hasCaseId: boolean) {
console.warn("rectification_resume_lookup_failed", { requestId, errorCategory, hasCaseId });
}
/**
* Explicit request (BUG-1306): the server already decided. A pass records an
* offered card with autoOpen and tells the stream to open. Status stays
* offered until the case is bound. A case already in progress resumes that
* case. Anything else is one sentence and writes no offer.
* case. If that session cannot be read, open a new one instead of a dead end.
* Anything else is one sentence and writes no offer.
*/
async function finishExplicitRectificationRequest(
state: ConsultationRuntimeState,
hook: RectificationOfferHook,
decision: RectificationOfferDecision,
requestId: string,
) {
if (decision.ok) {
state.rectificationOffer = offerRecord(hook, decision, "offered", true);
@@ -1202,9 +1218,13 @@ async function finishExplicitRectificationRequest(
}
if (decision.reason === "case_in_progress") {
let resumeSessionId: string | null = null;
let lookupFailed = false;
try {
resumeSessionId = await hook.loadResumableSessionId();
} catch {
} catch (error) {
lookupFailed = true;
const failure = resumeFailureLog(error);
logResumeLookupFailed(requestId, failure.errorCategory, failure.hasCaseId);
resumeSessionId = null;
}
if (resumeSessionId) {
@@ -1221,12 +1241,18 @@ async function finishExplicitRectificationRequest(
note: `只写这一句,不写解读:「${RECTIFICATION_DIRECT_RESUME_COPY}」`,
};
}
if (!lookupFailed) logResumeLookupFailed(requestId, "missing_session", false);
state.rectificationDirectEntry = {
kind: "refused",
sentence: RECTIFICATION_DIRECT_RESUME_ELSEWHERE_COPY,
kind: "new",
sentence: RECTIFICATION_DIRECT_NEW_COPY,
sourceSessionId: hook.sourceSessionId,
};
return { offered: false as const, reason: decision.reason, note: RECTIFICATION_OFFER_REFUSAL_NOTES.case_in_progress };
return {
offered: false as const,
directEntry: "new" as const,
reason: decision.reason,
note: `只写这一句过渡,不写解读:「${RECTIFICATION_DIRECT_NEW_COPY}」`,
};
}
const sentence = decision.reason === "not_self"
? RECTIFICATION_DIRECT_NOT_SELF_COPY
@@ -1244,7 +1270,11 @@ async function finishExplicitRectificationRequest(
* whether a card may be shown or this turn opens rectification. Never calculates.
* The rule text lives only in RECTIFICATION_OFFER_RULE.
*/
function createRectificationOfferTool(ctx: Readonly<{ state: ConsultationRuntimeState; rectificationOffer?: RectificationOfferHook }>) {
function createRectificationOfferTool(ctx: Readonly<{
state: ConsultationRuntimeState;
requestId: string;
rectificationOffer?: RectificationOfferHook;
}>) {
return createTool({
id: RECTIFICATION_OFFER_TOOL_ID,
description: "Offer birth-time rectification under this answer, or open it when explicitRequest is true. Call before writing the answer. Pass explicitRequest=true only when the user explicitly asks to do rectification this turn; then write only the one transition sentence in the result and no reading. Pass userAsked=true only when the user asks again after an earlier offer in this conversation. Never call it for a disagreement with an interpretation. Do not promise to narrow the birth time or collect life events here. The result says whether a card will be shown and what one sentence to write.",
@@ -1274,7 +1304,7 @@ function createRectificationOfferTool(ctx: Readonly<{ state: ConsultationRuntime
return { offered: false as const, reason: "already_offered_in_session", note: "这次对话里已经提议过一次,用户没接。不再主动提校正,正常回答。" };
}
const decision = await hook.decide();
if (input.explicitRequest === true) return finishExplicitRectificationRequest(ctx.state, hook, decision);
if (input.explicitRequest === true) return finishExplicitRectificationRequest(ctx.state, hook, decision, ctx.requestId);
if (!decision.ok) return { offered: false as const, reason: decision.reason, note: RECTIFICATION_OFFER_REFUSAL_NOTES[decision.reason] };
ctx.state.rectificationOffer = offerRecord(hook, decision, "offered");
return { offered: true as const, note: "你的回答下面会出现一张提议卡,写着大约几题、几点。正文最后一段必须有一句提到它(例:「如果想确认出生时间准不准,可以用下面的卡片,答几道过去经历的题核对一下。」),不写价格和题数,不催。" };
@@ -1,647 +0,0 @@
begin;
-- Issue #95 T1+T2. Add-only. The one free standard question is held at reserve
-- and consumed only when a non-smalltalk answer completes. Old accounts that
-- already finished a non-smalltalk consult are not given another free one.
alter table public.profiles
add column if not exists first_consult_free_used_at timestamptz,
add column if not exists first_consult_free_request_id text,
add column if not exists onboarding_state jsonb;
alter table public.profiles
drop constraint if exists profiles_onboarding_state_object;
alter table public.profiles
add constraint profiles_onboarding_state_object
check (onboarding_state is null or jsonb_typeof(onboarding_state) = 'object');
alter table public.chart_profiles
add column if not exists relation text;
alter table public.chart_profiles
drop constraint if exists chart_profiles_relation_check;
alter table public.chart_profiles
add constraint chart_profiles_relation_check
check (relation is null or relation in ('partner', 'child', 'parent', 'friend', 'other'));
comment on column public.profiles.first_consult_free_used_at is
'Set when the one free standard question is completed. Null means it is still available.';
comment on column public.profiles.first_consult_free_request_id is
'Request id currently holding the free question. Cleared on cancel or smalltalk.';
comment on column public.chart_profiles.relation is
'partner, child, parent, friend, or other. Null on old rows; the self profile does not use this column.';
-- The browser may read its own profile. Only the service route may write onboarding_state.
grant select (
onboarding_state,
first_consult_free_used_at,
first_consult_free_request_id
) on table public.profiles to service_role;
grant insert (relation) on table public.chart_profiles to authenticated;
grant update (relation) on table public.chart_profiles to authenticated;
grant select (relation) on table public.chart_profiles to service_role;
create or replace function public.backfill_first_consult_free()
returns integer
language plpgsql
security definer
set search_path = ''
as $$
declare
v_count integer;
begin
update public.profiles as profile
set first_consult_free_used_at = coalesce((
select min(request.updated_at)
from public.consultation_requests as request
where request.user_id = profile.id
and request.status = 'completed'
and coalesce(request.response_message->>'responseKind', '') <> 'smalltalk'
), clock_timestamp())
where profile.first_consult_free_used_at is null
and exists (
select 1
from public.consultation_requests as request
where request.user_id = profile.id
and request.status = 'completed'
and coalesce(request.response_message->>'responseKind', '') <> 'smalltalk'
);
get diagnostics v_count = row_count;
return v_count;
end;
$$;
revoke all on function public.backfill_first_consult_free() from public, anon, authenticated;
select public.backfill_first_consult_free();
create or replace function public.release_first_consult_free_hold(p_user_id uuid, p_request_id text)
returns void
language plpgsql
security definer
set search_path = ''
as $$
begin
update public.profiles as profile
set first_consult_free_request_id = null
where profile.id = p_user_id
and profile.first_consult_free_used_at is null
and profile.first_consult_free_request_id = btrim(coalesce(p_request_id, ''));
end;
$$;
revoke all on function public.release_first_consult_free_hold(uuid, text) from public, anon, authenticated;
create or replace function public.consume_first_consult_free(p_user_id uuid, p_request_id text)
returns void
language plpgsql
security definer
set search_path = ''
as $$
declare
v_request_id text := btrim(coalesce(p_request_id, ''));
v_amount integer;
begin
update public.profiles as profile
set first_consult_free_used_at = clock_timestamp()
where profile.id = p_user_id
and profile.first_consult_free_used_at is null
and profile.first_consult_free_request_id = v_request_id;
if not found then
return;
end if;
select reservation.credit_amount into v_amount
from public.usage_reservations as reservation
where reservation.user_id = p_user_id
and reservation.request_id = v_request_id;
update public.usage_ledger as ledger
set metadata = coalesce(ledger.metadata, '{}'::jsonb) || jsonb_build_object(
'billingSource', 'first_consult_free',
'creditCost', coalesce(v_amount, 0)
)
where ledger.user_id = p_user_id
and ledger.request_id = v_request_id;
end;
$$;
revoke all on function public.consume_first_consult_free(uuid, text) from public, anon, authenticated;
create or replace function public.save_onboarding_state(p_user_id uuid, p_patch jsonb)
returns table(success boolean, state jsonb, error_code text)
language plpgsql
security definer
set search_path = ''
as $$
declare
v_current jsonb;
v_next jsonb;
v_key text;
v_value jsonb;
begin
if p_user_id is null then
return query select false, null::jsonb, 'unauthorized'::text;
return;
end if;
if jsonb_typeof(p_patch) is distinct from 'object' then
return query select false, null::jsonb, 'invalid_state'::text;
return;
end if;
if exists (
select 1
from jsonb_object_keys(p_patch) as key
where key not in ('checklistDismissedAt', 'hintsSeen', 'chartVisitedAt')
) then
return query select false, null::jsonb, 'invalid_key'::text;
return;
end if;
if p_patch ? 'hintsSeen' then
if jsonb_typeof(p_patch->'hintsSeen') is distinct from 'array' then
return query select false, null::jsonb, 'invalid_hints'::text;
return;
end if;
if exists (
select 1
from jsonb_array_elements(p_patch->'hintsSeen') as elem
where jsonb_typeof(elem) is distinct from 'string'
or (elem #>> '{}') not in (
'follow-up', 'sidebar-chart', 'tab-chart', 'tab-info',
'tab-dasha', 'tab-west', 'syn-ready', 'ephemeris'
)
) then
return query select false, null::jsonb, 'invalid_hint'::text;
return;
end if;
end if;
foreach v_key in array array['checklistDismissedAt', 'chartVisitedAt'] loop
if p_patch ? v_key then
v_value := p_patch->v_key;
if jsonb_typeof(v_value) is distinct from 'null' and jsonb_typeof(v_value) is distinct from 'string' then
return query select false, null::jsonb, 'invalid_timestamp'::text;
return;
end if;
if jsonb_typeof(v_value) = 'string' then
begin
perform (v_value #>> '{}')::timestamptz;
exception
when others then
return query select false, null::jsonb, 'invalid_timestamp'::text;
return;
end;
end if;
end if;
end loop;
select profile.onboarding_state into v_current
from public.profiles as profile
where profile.id = p_user_id
for update;
if not found then
return query select false, null::jsonb, 'profile_missing'::text;
return;
end if;
v_next := coalesce(v_current, '{}'::jsonb) || p_patch;
update public.profiles as profile
set onboarding_state = v_next
where profile.id = p_user_id
returning profile.onboarding_state into v_next;
return query select true, v_next, null::text;
end;
$$;
revoke all on function public.save_onboarding_state(uuid, jsonb) from public, anon, authenticated;
grant execute on function public.save_onboarding_state(uuid, jsonb) to service_role;
create or replace function public.reserve_consultation_usage(
p_user_id uuid,
p_request_id text,
p_session_id uuid,
p_requested_model_id text,
p_credit_cost integer
)
returns table(
success boolean,
reservation_id uuid,
source text,
credits integer,
subscription_id uuid,
reason text,
retry_after_seconds integer
)
language plpgsql
security definer
set search_path = ''
as $$
declare
v_authorization record;
v_session_type text;
v_profile public.profiles%rowtype;
v_standard integer;
v_charge integer;
v_free boolean := false;
begin
if p_user_id is null or p_session_id is null or btrim(coalesce(p_request_id, '')) = '' then
return query select false, null::uuid, null::text, null::integer,
null::uuid, 'invalid_request'::text, null::integer;
return;
end if;
perform pg_advisory_xact_lock(hashtextextended(p_user_id::text || ':' || btrim(p_request_id), 0));
select session.session_type into v_session_type
from public.chat_sessions as session
where session.id = p_session_id
and session.user_id = p_user_id
and (session.session_type = 'consultation' or (session.session_type = 'synastry_report' and session.synastry_report_id is not null));
if not found then
return query select false, null::uuid, null::text,
(select profile.credits from public.profiles as profile where profile.id = p_user_id),
null::uuid, 'session_missing'::text, null::integer;
return;
end if;
if exists (
select 1
from public.consultation_requests as request
where request.user_id = p_user_id
and request.request_id = btrim(p_request_id)
) then
return query select false, null::uuid, null::text,
(select profile.credits from public.profiles as profile where profile.id = p_user_id),
null::uuid, 'request_conflict'::text, null::integer;
return;
end if;
v_charge := p_credit_cost;
if v_session_type = 'consultation' then
select profile.* into v_profile
from public.profiles as profile
where profile.id = p_user_id
for update;
if found
and v_profile.first_consult_free_used_at is null
and (
v_profile.first_consult_free_request_id is null
or v_profile.first_consult_free_request_id = btrim(p_request_id)
) then
-- Difference only: the free question covers the published standard price.
-- No published standard price means do not waive.
select pricing.credit_cost into v_standard
from public.feature_pricing as pricing
where pricing.feature_key = 'chat.standard'
and pricing.model_tier = 'standard'
and pricing.status = 'published'
and pricing.enabled
and (pricing.effective_from is null or pricing.effective_from <= clock_timestamp())
and (pricing.effective_to is null or pricing.effective_to > clock_timestamp())
order by pricing.version desc
limit 1;
if v_standard is not null and p_credit_cost >= 0 then
v_charge := greatest(0, p_credit_cost - v_standard);
v_free := true;
end if;
end if;
end if;
select * into v_authorization
from public.authorize_usage(
p_user_id,
'chat.standard',
p_requested_model_id,
btrim(p_request_id),
v_charge
);
if coalesce(v_authorization.success, false) then
insert into public.consultation_requests(user_id, request_id, session_id, status)
values (p_user_id, btrim(p_request_id), p_session_id, 'reserved');
if v_free and v_authorization.source is distinct from 'subscription' then
update public.profiles as profile
set first_consult_free_request_id = btrim(p_request_id)
where profile.id = p_user_id
and profile.first_consult_free_used_at is null
and (
profile.first_consult_free_request_id is null
or profile.first_consult_free_request_id = btrim(p_request_id)
);
if not found then
raise exception 'first_consult_free_hold_lost';
end if;
end if;
end if;
return query select
v_authorization.success,
v_authorization.reservation_id,
v_authorization.source,
v_authorization.credits,
v_authorization.subscription_id,
v_authorization.reason,
v_authorization.retry_after_seconds;
end;
$$;
revoke all on function public.reserve_consultation_usage(uuid, text, uuid, text, integer)
from public, anon, authenticated;
grant execute on function public.reserve_consultation_usage(uuid, text, uuid, text, integer)
to service_role;
do $$ begin
if exists(select 1 from pg_roles where rolname = 'admin_runtime') then
grant execute on function public.reserve_consultation_usage(uuid, text, uuid, text, integer)
to admin_runtime;
end if;
end $$;
create or replace function public.cancel_consultation_credit(p_user_id uuid, p_request_id text)
returns table(success boolean, credits integer, error_code text)
language plpgsql
security definer
set search_path = ''
as $$
declare
v_request public.consultation_requests%rowtype;
v_release record;
v_balance integer;
begin
if p_user_id is null or btrim(coalesce(p_request_id, '')) = '' then
return query select false, null::integer, 'invalid_request'::text;
return;
end if;
perform pg_advisory_xact_lock(hashtextextended(p_user_id::text || ':' || btrim(p_request_id), 0));
select request.* into v_request
from public.consultation_requests as request
where request.user_id = p_user_id
and request.request_id = btrim(p_request_id)
for update;
if not found then
return query select false, null::integer, 'request_missing'::text;
return;
end if;
select profile.credits into v_balance
from public.profiles as profile
where profile.id = p_user_id;
if v_request.status = 'completed' then
return query select false, v_balance, 'request_completed'::text;
return;
end if;
if v_request.status = 'cancelled' then
perform public.release_first_consult_free_hold(p_user_id, p_request_id);
return query select true, v_balance, null::text;
return;
end if;
if v_request.status <> 'reserved' then
return query select false, v_balance, 'invalid_request_status'::text;
return;
end if;
select * into v_release
from public.release_usage(p_user_id, btrim(p_request_id), 'consultation_cancelled');
if not coalesce(v_release.success, false) then
return query select false, v_release.credits, v_release.error_code;
return;
end if;
update public.consultation_requests
set status = 'cancelled', updated_at = clock_timestamp()
where user_id = p_user_id
and request_id = btrim(p_request_id)
and status = 'reserved';
perform public.release_first_consult_free_hold(p_user_id, p_request_id);
return query select true, v_release.credits, null::text;
end;
$$;
revoke all on function public.cancel_consultation_credit(uuid, text)
from public, anon, authenticated;
grant execute on function public.cancel_consultation_credit(uuid, text)
to service_role;
do $$ begin
if exists(select 1 from pg_roles where rolname = 'admin_runtime') then
grant execute on function public.cancel_consultation_credit(uuid, text)
to admin_runtime;
end if;
end $$;
create or replace function public.complete_consultation_response(
p_user_id uuid,
p_request_id text,
p_session_id uuid,
p_response_message jsonb,
p_actual_usage jsonb
)
returns table(success boolean, credits integer, error_code text)
language plpgsql
security definer
set search_path = ''
as $$
declare
v_request public.consultation_requests%rowtype;
v_settlement record;
v_balance integer;
begin
if p_user_id is null or p_session_id is null or btrim(coalesce(p_request_id, '')) = '' then
return query select false, null::integer, 'invalid_request'::text;
return;
end if;
if jsonb_typeof(p_response_message) <> 'object'
or p_response_message->>'role' <> 'assistant'
or btrim(coalesce(p_response_message->>'text', '')) = '' then
return query select false, null::integer, 'invalid_response_message'::text;
return;
end if;
perform pg_advisory_xact_lock(hashtextextended(p_user_id::text || ':' || btrim(p_request_id), 0));
select request.* into v_request
from public.consultation_requests as request
where request.user_id = p_user_id
and request.request_id = btrim(p_request_id)
and request.session_id = p_session_id
for update;
if not found then
return query select false, null::integer, 'request_missing'::text;
return;
end if;
if v_request.status = 'cancelled' then
return query select false, null::integer, 'request_cancelled'::text;
return;
end if;
if v_request.status = 'completed' then
select profile.credits into v_balance from public.profiles as profile where profile.id = p_user_id;
return query select v_request.response_message = p_response_message, v_balance,
case when v_request.response_message = p_response_message then null::text else 'response_conflict'::text end;
return;
end if;
update public.chat_sessions as session
set messages = session.messages || jsonb_build_array(p_response_message),
updated_at = clock_timestamp()
where session.id = p_session_id
and session.user_id = p_user_id
and (session.session_type = 'consultation' or (session.session_type = 'synastry_report' and session.synastry_report_id is not null));
if not found then
return query select false, null::integer, 'session_missing'::text;
return;
end if;
update public.consultation_requests
set response_message = p_response_message,
updated_at = clock_timestamp()
where user_id = p_user_id and request_id = btrim(p_request_id);
select * into v_settlement
from public.complete_usage(p_user_id, btrim(p_request_id), coalesce(p_actual_usage, '{}'::jsonb));
if not coalesce(v_settlement.success, false) then
raise exception 'consultation_usage_settlement_failed:%', coalesce(v_settlement.error_code, 'unknown');
end if;
perform public.consume_first_consult_free(p_user_id, p_request_id);
update public.consultation_requests
set status = 'completed', updated_at = clock_timestamp()
where user_id = p_user_id and request_id = btrim(p_request_id);
return query select true, v_settlement.credits, null::text;
end;
$$;
revoke all on function public.complete_consultation_response(uuid, text, uuid, jsonb, jsonb)
from public, anon, authenticated;
grant execute on function public.complete_consultation_response(uuid, text, uuid, jsonb, jsonb)
to service_role;
do $$ begin
if exists(select 1 from pg_roles where rolname = 'admin_runtime') then
grant execute on function public.complete_consultation_response(uuid, text, uuid, jsonb, jsonb)
to admin_runtime;
end if;
end $$;
create or replace function public.complete_consultation_free(
p_user_id uuid,
p_request_id text,
p_session_id uuid,
p_response_message jsonb,
p_actual_usage jsonb
)
returns table(success boolean, credits integer, error_code text)
language plpgsql
security definer
set search_path = ''
as $$
declare
v_request public.consultation_requests%rowtype;
v_res public.usage_reservations%rowtype;
v_settlement record;
v_balance integer;
begin
if p_user_id is null or p_session_id is null or btrim(coalesce(p_request_id, '')) = '' then
return query select false, null::integer, 'invalid_request'::text;
return;
end if;
if jsonb_typeof(p_response_message) is distinct from 'object'
or p_response_message->>'role' is distinct from 'assistant'
or p_response_message->>'responseKind' is distinct from 'smalltalk'
or jsonb_typeof(p_response_message->'text') is distinct from 'string'
or btrim(coalesce(p_response_message->>'text', '')) = ''
or length(p_response_message->>'text') > 20
or (p_response_message - array['role', 'text', 'responseKind']) <> '{}'::jsonb then
return query select false, null::integer, 'invalid_response_message'::text;
return;
end if;
if jsonb_typeof(p_actual_usage) is distinct from 'object' then
return query select false, null::integer, 'invalid_actual_usage'::text;
return;
end if;
perform pg_advisory_xact_lock(hashtextextended(p_user_id::text || ':' || btrim(p_request_id), 0));
select request.* into v_request
from public.consultation_requests as request
where request.user_id = p_user_id
and request.request_id = btrim(p_request_id)
and request.session_id = p_session_id
for update;
if not found then
return query select false, null::integer, 'request_missing'::text;
return;
end if;
if v_request.status = 'cancelled' then
return query select false, null::integer, 'request_cancelled'::text;
return;
end if;
if v_request.status = 'completed' then
select profile.credits into v_balance from public.profiles as profile where profile.id = p_user_id;
return query select coalesce(v_request.response_message = p_response_message, false), v_balance,
case when v_request.response_message = p_response_message then null::text else 'response_conflict'::text end;
return;
end if;
if v_request.status <> 'reserved' then
return query select false, null::integer, 'invalid_request_status'::text;
return;
end if;
select reservation.* into v_res from public.usage_reservations as reservation
where reservation.user_id = p_user_id and reservation.request_id = btrim(p_request_id)
for update;
if not found or v_res.status <> 'reserved' or v_res.feature_key <> 'chat.standard' then
return query select false, null::integer, 'invalid_reservation'::text;
return;
end if;
select profile.credits into v_balance from public.profiles as profile
where profile.id = p_user_id for update;
if not found then
return query select false, null::integer, 'profile_missing'::text;
return;
end if;
update public.chat_sessions as session
set messages = session.messages || jsonb_build_array(p_response_message), updated_at = clock_timestamp()
where session.id = p_session_id and session.user_id = p_user_id and session.session_type = 'consultation';
if not found then
return query select false, null::integer, 'session_missing'::text;
return;
end if;
select * into v_settlement from public.complete_usage(
p_user_id, btrim(p_request_id),
p_actual_usage || jsonb_build_object('metadata', coalesce(p_actual_usage->'metadata', '{}'::jsonb)
|| jsonb_build_object('responseKind', 'smalltalk', 'freeCompletion', true))
);
if not coalesce(v_settlement.success, false) then
raise exception 'consultation_free_usage_settlement_failed:%', coalesce(v_settlement.error_code, 'unknown');
end if;
if v_res.source = 'credits' and v_res.credit_amount > 0 then
update public.profiles as profile
set credits = profile.credits + v_res.credit_amount, updated_at = clock_timestamp()
where profile.id = p_user_id returning profile.credits into v_balance;
insert into public.credit_transactions(user_id, transaction_type, amount, balance_after, request_id, model)
values(p_user_id, 'refund', v_res.credit_amount, v_balance, v_res.request_id, v_res.requested_model_id);
end if;
update public.usage_reservations
set status = 'released', released_at = clock_timestamp(), release_reason = 'consultation_smalltalk_free'
where id = v_res.id;
update public.consultation_requests
set status = 'completed', response_message = p_response_message, updated_at = clock_timestamp()
where user_id = p_user_id and request_id = btrim(p_request_id);
-- Smalltalk does not use the free question.
perform public.release_first_consult_free_hold(p_user_id, p_request_id);
return query select true, v_balance, null::text;
end;
$$;
revoke all on function public.complete_consultation_free(uuid, text, uuid, jsonb, jsonb)
from public, anon, authenticated;
grant execute on function public.complete_consultation_free(uuid, text, uuid, jsonb, jsonb)
to service_role;
commit;
@@ -54,11 +54,8 @@ test("the paywall only opens from a refused send; the typed question stays in th
assert.doesNotMatch(page, /setOnboardingPaywallOpen\(true\)/);
assert.doesNotMatch(page, /!starterHomeVisible\s*\n\s*\|\| !account/);
// Pre-flight balance check: before send() clears the draft, paywall first, else billing.
// 原值: if (account.credits <= 0 && !account.activeSubscription)
// 新值: 同一条件再加 && !account.firstConsultFree
// 原因: 首问免费只看服务端给的布尔值,0 点且仍有这一次时不再先弹兑换窗(#95 T1)
const guard = hook.slice(hook.indexOf("if (account.credits <= 0 && !account.activeSubscription && !account.firstConsultFree) {"));
assert.match(guard, /^if \(account\.credits <= 0 && !account\.activeSubscription && !account\.firstConsultFree\) \{\s*if \(!openRedeemPaywallOnce\(\)\) openAccountDialog\("billing", \{ source: "insufficient-credits" \}\);\s*return false;/);
const guard = hook.slice(hook.indexOf("if (account.credits <= 0 && !account.activeSubscription) {"));
assert.match(guard, /^if \(account\.credits <= 0 && !account\.activeSubscription\) \{\s*if \(!openRedeemPaywallOnce\(\)\) openAccountDialog\("billing", \{ source: "insufficient-credits" \}\);\s*return false;/);
assert.ok(hook.indexOf("if (!openRedeemPaywallOnce())") < hook.indexOf("conversationAnchor.pinLatestTurn();\n setDraft(\"\");"));
// 402: same order; the existing rollback restores the draft (reserve did not commit).
assert.match(hook, /if \(status === 402 && !openRedeemPaywallOnce\(\)\) openAccountDialog\("billing", \{ source: "insufficient-credits" \}\);/);
@@ -59,12 +59,9 @@ test("in-app destinations navigate client-side so the chat survives the round tr
});
test("both insufficient-credit paths are soft so the typed question is not thrown away", () => {
// 原值: if (account.credits <= 0 && !account.activeSubscription) {
// 新值: if (account.credits <= 0 && !account.activeSubscription && !account.firstConsultFree) {
// 原因: 首问免费只看服务端给的布尔值,0 点且仍有这一次时不再先弹兑换窗(#95 T1)
const creditGuard = sourceBetween(
pageSource,
"if (account.credits <= 0 && !account.activeSubscription && !account.firstConsultFree) {",
"if (account.credits <= 0 && !account.activeSubscription) {",
"const [year, month, day]",
);
const sendSource = pageSource.slice(pageSource.indexOf(" async function send("));
@@ -1,142 +0,0 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import test from "node:test";
import pg from "pg";
import { startPostgresFixture } from "./helpers/postgres-fixture.ts";
const runner = fileURLToPath(new URL("../scripts/db-migrate.mjs", import.meta.url));
const docker = spawnSync("docker", ["version", "--format", "{{.Server.Version}}"], { stdio: "ignore" }).status === 0;
const sessionA = "99999999-9999-4999-8999-999999999981";
const sessionB = "99999999-9999-4999-8999-999999999982";
const sessionC = "99999999-9999-4999-8999-999999999983";
const chartOld = "99999999-9999-4999-8999-999999999984";
const chartChild = "99999999-9999-4999-8999-999999999985";
test("first consult is free once, onboarding state is service-owned, and relation is constrained", { skip: docker ? false : "docker unavailable" }, async () => {
const fixture = startPostgresFixture();
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);
fixture.psql(`insert into identity.users(name,email,email_verified,email_verified_at) values
('Fictional A','onboarding-a@example.com',true,now()),
('Fictional B','onboarding-b@example.com',true,now()),
('Fictional C','onboarding-c@example.com',true,now()),
('Fictional D','onboarding-d@example.com',true,now());`);
const user = (email: string) => fixture.psql(`select id from identity.users where email='${email}'`);
const a = user("onboarding-a@example.com");
const b = user("onboarding-b@example.com");
const c = user("onboarding-c@example.com");
const d = user("onboarding-d@example.com");
const session = (id: string, owner: string) =>
`insert into public.chat_sessions(id,user_id,title,theme,model_id,messages,session_type,updated_at) values ('${id}','${owner}','test','general','test-model','[]','consultation',now());`;
fixture.psql(`update public.profiles set credits=0 where id in ('${a}','${c}');
update public.profiles set credits=5 where id='${b}';
${session(sessionA, a)}
${session(sessionB, b)}
${session(sessionC, c)}`);
for (const column of ["onboarding_state", "first_consult_free_used_at", "first_consult_free_request_id"]) {
assert.equal(fixture.psql(`select has_column_privilege('authenticated','public.profiles','${column}','update')`), "f", column);
assert.equal(fixture.psql(`select has_column_privilege('service_role','public.profiles','${column}','select')`), "t", column);
}
assert.equal(fixture.psql("select has_function_privilege('authenticated','public.save_onboarding_state(uuid,jsonb)','execute')"), "f");
assert.equal(fixture.psql("select has_function_privilege('service_role','public.save_onboarding_state(uuid,jsonb)','execute')"), "t");
assert.throws(() => fixture.psql(`set role authenticated; update public.profiles set onboarding_state='{}'::jsonb where id='${a}'`));
const save = (patch: string) => fixture.psql(
`select success::text || ':' || coalesce(error_code,'ok') || ':' || coalesce(state->>'chartVisitedAt','') || ':' || coalesce(state->'hintsSeen'->>0,'') from public.save_onboarding_state('${a}','${patch}'::jsonb)`,
);
assert.equal(save('{"chartVisitedAt":"2026-10-22T00:00:00Z"}'), "true:ok:2026-10-22T00:00:00Z:");
assert.equal(save('{"hintsSeen":["follow-up"]}'), "true:ok:2026-10-22T00:00:00Z:follow-up");
assert.equal(save('{"nope":true}'), "f:invalid_key::");
assert.equal(save('{"hintsSeen":["not-a-hint"]}'), "f:invalid_hint::");
assert.equal(fixture.psql(`select onboarding_state->>'chartVisitedAt' from public.profiles where id='${a}'`), "2026-10-22T00:00:00Z");
assert.equal(fixture.psql(`select save_onboarding_state.success::text from public.save_onboarding_state('${b}','{"hintsSeen":["tab-chart"]}'::jsonb)`), "true");
assert.equal(fixture.psql(`select onboarding_state->>'chartVisitedAt' from public.profiles where id='${a}'`), "2026-10-22T00:00:00Z");
assert.throws(() => fixture.psql(`insert into public.chart_profiles(id,user_id,role,profile,name,relation) values ('${chartChild}','${a}','other','{}','示例乙','cousin')`));
fixture.psql(`insert into public.chart_profiles(id,user_id,role,profile,name) values ('${chartOld}','${a}','other','{}','示例丙')`);
assert.equal(fixture.psql(`select coalesce(relation,'null') from public.chart_profiles where id='${chartOld}'`), "null");
fixture.psql(`update public.chart_profiles set relation='child' where id='${chartOld}'`);
assert.equal(fixture.psql(`select relation from public.chart_profiles where id='${chartOld}'`), "child");
assert.throws(() => fixture.psql(`insert into public.chart_profiles(user_id,role,profile,name,relation) values ('${a}','self','{}','示例甲','partner')`));
fixture.psql(`insert into public.consultation_requests(user_id,request_id,status,response_message) values
('${c}','old-done','completed','{"role":"assistant","text":"done","responseKind":"reading"}'),
('${d}','old-chat','completed','{"role":"assistant","text":"你好","responseKind":"smalltalk"}');`);
assert.equal(fixture.psql("select public.backfill_first_consult_free()"), "1");
assert.equal(fixture.psql("select public.backfill_first_consult_free()"), "0");
assert.equal(fixture.psql(`select (first_consult_free_used_at is null)::text from public.profiles where id='${c}'`), "f");
assert.equal(fixture.psql(`select (first_consult_free_used_at is null)::text from public.profiles where id='${d}'`), "true");
assert.equal(fixture.psql(`select (first_consult_free_used_at is null)::text from public.profiles where id='${a}'`), "true");
const reserve = (owner: string, id: string, sid: string, cost: number) =>
fixture.psql(`select success::text || ':' || coalesce(reason,'ok') from public.reserve_consultation_usage('${owner}','${id}','${sid}','test-model',${cost})`);
assert.equal(reserve(a, "no-price", sessionA, 1), "f:insufficient_credits");
fixture.psql(`insert into public.feature_pricing(feature_key,model_tier,credit_cost,version,status,enabled) values ('chat.standard','standard',3,1,'published',true)`);
assert.equal(reserve(c, "old-account", sessionC, 3), "f:insufficient_credits");
const clients = [
new pg.Client({ connectionString: fixture.connectionUrl("schema_owner", "schema-owner-test-password") }),
new pg.Client({ connectionString: fixture.connectionUrl("schema_owner", "schema-owner-test-password") }),
];
try {
await Promise.all(clients.map((client) => client.connect()));
const raced = await Promise.all([
clients[0]!.query(`select success from public.reserve_consultation_usage('${a}','race-1','${sessionA}','test-model',3)`),
clients[1]!.query(`select success from public.reserve_consultation_usage('${a}','race-2','${sessionA}','test-model',3)`),
]);
const wins = raced.filter((result) => result.rows[0]?.success === true);
assert.equal(wins.length, 1);
} finally {
await Promise.all(clients.map((client) => client.end()));
}
assert.equal(fixture.psql(`select credits::text from public.profiles where id='${a}'`), "0");
assert.equal(fixture.psql(`select count(*) from public.credit_transactions where user_id='${a}' and request_id in ('race-1','race-2')`), "0");
const held = fixture.psql(`select first_consult_free_request_id from public.profiles where id='${a}'`);
assert.match(held, /^race-[12]$/);
assert.equal(fixture.psql(`select (first_consult_free_used_at is null)::text from public.profiles where id='${a}'`), "true");
assert.equal(fixture.psql(`select success::text from public.cancel_consultation_credit('${a}','${held}')`), "true");
assert.equal(fixture.psql(`select coalesce(first_consult_free_request_id,'null') || ':' || (first_consult_free_used_at is null)::text from public.profiles where id='${a}'`), "null:true");
assert.equal(reserve(a, "smalltalk", sessionA, 3), "true:ok");
assert.equal(
fixture.psql(`select success::text from public.complete_consultation_free('${a}','smalltalk','${sessionA}','{"role":"assistant","text":"你好","responseKind":"smalltalk"}','{}')`),
"true",
);
assert.equal(fixture.psql(`select coalesce(first_consult_free_request_id,'null') || ':' || (first_consult_free_used_at is null)::text from public.profiles where id='${a}'`), "null:true");
assert.equal(reserve(a, "standard", sessionA, 3), "true:ok");
assert.equal(
fixture.psql(`select success::text from public.complete_consultation_response('${a}','standard','${sessionA}','{"role":"assistant","text":"a full answer"}','{}')`),
"true",
);
assert.equal(fixture.psql(`select (first_consult_free_used_at is null)::text from public.profiles where id='${a}'`), "f");
assert.equal(fixture.psql(`select credits::text from public.profiles where id='${a}'`), "0");
assert.equal(fixture.psql(`select (metadata->>'billingSource') || ':' || (metadata->>'creditCost') from public.usage_ledger where user_id='${a}' and request_id='standard'`), "first_consult_free:0");
assert.equal(reserve(a, "second", sessionA, 3), "f:insufficient_credits");
assert.equal(reserve(b, "premium-chat", sessionB, 5), "true:ok");
assert.equal(fixture.psql(`select credits::text from public.profiles where id='${b}'`), "3");
assert.equal(
fixture.psql(`select success::text from public.complete_consultation_free('${b}','premium-chat','${sessionB}','{"role":"assistant","text":"你好","responseKind":"smalltalk"}','{}')`),
"true",
);
assert.equal(fixture.psql(`select credits::text || ':' || (first_consult_free_used_at is null)::text from public.profiles where id='${b}'`), "5:true");
assert.equal(reserve(b, "premium-paid", sessionB, 5), "true:ok");
assert.equal(fixture.psql(`select credits::text from public.profiles where id='${b}'`), "3");
assert.equal(fixture.psql(`select amount::text from public.credit_transactions where user_id='${b}' and request_id='premium-paid' and transaction_type='reserve'`), "-2");
assert.equal(
fixture.psql(`select success::text from public.complete_consultation_response('${b}','premium-paid','${sessionB}','{"role":"assistant","text":"a premium answer"}','{}')`),
"true",
);
assert.equal(fixture.psql(`select (first_consult_free_used_at is null)::text from public.profiles where id='${b}'`), "f");
assert.equal(fixture.psql(`select (metadata->>'billingSource') || ':' || (metadata->>'creditCost') from public.usage_ledger where user_id='${b}' and request_id='premium-paid'`), "first_consult_free:2");
} finally {
fixture.stop();
}
});
@@ -0,0 +1,77 @@
// Service role can resume an in-progress case only through the case RPC.
// A direct select of agentic_rectification_cases is revoked. Fictional ids only.
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import test from "node:test";
import { closeLocalPostgresDataPool, createLocalPostgresDataClient } from "../src/lib/db/local-postgres-client-core.ts";
import { loadResumableRectificationSessionId } from "../src/lib/rectification-resume-session.ts";
import { startPostgresFixture } from "./helpers/postgres-fixture.ts";
const docker = spawnSync("docker", ["version"], { stdio: "ignore" }).status === 0;
const runner = fileURLToPath(new URL("../scripts/db-migrate.mjs", import.meta.url));
test("service role loads an in-progress rectification session through the case RPC", { skip: !docker && "docker unavailable" }, async () => {
const fixture = startPostgresFixture();
const url = fixture.connectionUrl("service_runtime", "service-runtime-test-password");
const userId = randomUUID();
const sessionId = randomUUID();
const caseId = randomUUID();
const snapshot = {
birth_date: "2000-06-15",
reported_birth_time: "00:10",
active_birth_time: null,
birth_time_source: "family_exact",
birth_time_period: null,
uncertainty_before_minutes: 15,
uncertainty_after_minutes: 15,
latitude: 40,
longitude: -74,
timezone_id: "America/New_York",
timezone_offset: -4,
};
const range = {
start_time: "23:55",
end_time: "00:25",
candidate_intervals: [{ start_at: "2000-06-14T23:55", end_at: "2000-06-15T00:25" }],
};
try {
const migration = spawnSync(process.execPath, [runner], {
encoding: "utf8",
env: {
...process.env,
SCHEMA_DATABASE_URL: fixture.connectionUrl("schema_owner", "schema-owner-test-password"),
},
});
assert.equal(migration.status, 0, `${migration.stdout}\n${migration.stderr}`);
fixture.psqlAs("identity_runtime", "identity-runtime-test-password", `
insert into identity.users(id,name,email,email_verified)
values ('${userId}','Synthetic Resume Fixture','${userId}@example.invalid',true);
`);
fixture.psql(`
update public.profiles set birth_date='2000-06-15',reported_birth_time='00:10',birth_time_source='family_exact',
birth_time_status='reported',uncertainty_before_minutes=15,uncertainty_after_minutes=15,
latitude=40,longitude=-74,timezone_id='America/New_York',timezone_offset=-4 where id='${userId}';
insert into public.chat_sessions(id,user_id,title,theme,session_type,messages)
values('${sessionId}','${userId}','Synthetic resume','general','birth_time_rectification','[]');
insert into public.agentic_rectification_cases(id,user_id,session_id,status,skill_name,skill_version,
baseline_profile_fingerprint,baseline_birth_snapshot,candidate_range)
values('${caseId}','${userId}','${sessionId}','collecting_evidence','jyotish-birth-time-rectification','9.0.0',
'${"a".repeat(64)}','${JSON.stringify(snapshot)}','${JSON.stringify(range)}');
`);
const service = createLocalPostgresDataClient(url, null, "service_role");
const direct = await service
.from("agentic_rectification_cases")
.select("session_id")
.eq("id", caseId)
.maybeSingle();
assert.ok(direct.error, "a direct table read stays revoked for service_role");
assert.match(String(direct.error?.message ?? ""), /permission denied/);
assert.equal(await loadResumableRectificationSessionId(service, userId), sessionId);
} finally {
await closeLocalPostgresDataPool(url);
fixture.stop();
}
});
@@ -1,82 +0,0 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { parseChartSubjectWrite, libraryProfileFromSubjectRow } from "../src/lib/chart-subject-write.ts";
import { readProfile } from "../src/lib/home-profile.ts";
import { parseOnboardingPatch, parseOnboardingState } from "../src/lib/onboarding-state.ts";
import { parsePersonRelation, personRelationLabel } from "../src/lib/person-relation.ts";
const route = readFileSync(new URL("../src/app/api/onboarding-state/route.ts", import.meta.url), "utf8");
const account = readFileSync(new URL("../src/app/api/account/route.ts", import.meta.url), "utf8");
test("person relation accepts only the five household values", () => {
assert.equal(parsePersonRelation("child"), "child");
assert.equal(parsePersonRelation("family"), null);
assert.equal(parsePersonRelation(null), null);
assert.equal(personRelationLabel(null), "其他");
assert.equal(personRelationLabel("parent"), "父母");
assert.equal(personRelationLabel("other"), "其他");
});
test("a chart write omits relation unless the client sent the key", () => {
const omitted = parseChartSubjectWrite({ name: "示例乙", date: "1992-03-12" });
assert.equal(omitted.ok, true);
if (!omitted.ok) return;
assert.equal("relation" in omitted.write.columns, false);
const child = parseChartSubjectWrite({ name: "示例乙", date: "1992-03-12", relation: "child" });
assert.equal(child.ok, true);
if (!child.ok) return;
assert.equal(child.write.columns.relation, "child");
const cleared = parseChartSubjectWrite({ name: "示例乙", date: "1992-03-12", relation: null });
assert.equal(cleared.ok, true);
if (!cleared.ok) return;
assert.equal(cleared.write.columns.relation, null);
const illegal = parseChartSubjectWrite({ name: "示例乙", date: "1992-03-12", relation: "client" });
assert.equal(illegal.ok, false);
});
test("an old chart row with a null relation still reads", () => {
const profile = libraryProfileFromSubjectRow({ name: "示例乙", relation: null, profile: {} });
assert.equal(profile.relation, null);
assert.equal(readProfile(profile).relation, null);
assert.equal("relation" in readProfile({ name: "示例乙" }), false);
});
test("onboarding state keeps only the three keys and the hint list", () => {
const saved = parseOnboardingPatch({
chartVisitedAt: "2026-10-22T00:00:00Z",
hintsSeen: ["follow-up", "tab-chart"],
});
assert.equal(saved.ok, true);
assert.equal(parseOnboardingPatch({ userId: "someone-else" }).ok, false);
assert.equal(parseOnboardingPatch({ hintsSeen: ["not-a-hint"] }).ok, false);
assert.equal(parseOnboardingPatch({ checklistDismissedAt: "tomorrow" }).ok, false);
assert.deepEqual(parseOnboardingState({
chartVisitedAt: "2026-10-22T00:00:00Z",
hintsSeen: ["follow-up"],
extra: true,
}), {
chartVisitedAt: "2026-10-22T00:00:00Z",
hintsSeen: ["follow-up"],
});
});
test("the onboarding route is login-only and writes the session user", () => {
assert.match(route, /请先登录/);
assert.match(route, /status: 401/);
assert.match(route, /p_user_id: user\.id/);
assert.doesNotMatch(route, /userId|body\.id|p_user_id: parsed/);
});
test("account GET exposes firstConsultFree from the column, and a missing column is not free", () => {
assert.match(account, /first_consult_free_used_at/);
assert.match(account, /firstConsultFree: firstConsultFreeFromProfile\(profile\)/);
assert.match(account, /hasOwnProperty\.call\(profile, "first_consult_free_used_at"\)/);
const getSource = account.slice(account.indexOf("export async function GET"), account.indexOf("export async function PATCH"));
const fallbacks = getSource.slice(getSource.indexOf("isMissingProfileColumn"));
assert.equal(fallbacks.includes("first_consult_free_used_at"), false);
});
@@ -106,30 +106,6 @@ test("D2 list rows: avatar, name, 本人 tag, one monospace meta line, chevron",
assert.match(markup, /class="people-archive-hint">还可以保存 18 个人</);
});
test("someone else shows 其他 until a relation is saved; the form asks TA 是你的", () => {
assert.match(render(), /<b>示例乙<\/b><span class="people-archive-tag">其他<\/span>/);
const childPeople = [people[0]!, { ...people[1]!, profile: { ...otherProfile, relation: "child" as const } }];
assert.match(render({ people: childPeople }), /<b>示例乙<\/b><span class="people-archive-tag">孩子<\/span>/);
const detail = render({
view: "detail",
people: childPeople,
selected: childPeople[1],
selectedId: childPeople[1]!.id,
});
assert.match(detail, /<h2>示例乙<span class="people-archive-tag">孩子<\/span><\/h2>/);
const editing = render({
view: "detail",
selected: people[1],
selectedId: people[1]!.id,
editing: true,
draft: { ...otherProfile, relation: "child" },
});
assert.match(editing, /TA 是你的/);
assert.match(editing, />孩子</);
const selfEdit = render({ view: "detail", editing: true, draft: selfProfile });
assert.doesNotMatch(selfEdit, /TA 是你的/);
});
test("D2 the current person carries is-current, whoever it is", () => {
const selfCurrent = render();
assert.match(selfCurrent, /class="people-archive-row is-current[^"]*" aria-current="true"[\s\S]*示例甲/);
@@ -85,10 +85,7 @@ test("account PATCH accepts gender alone, clears it with null, and rejects anyth
assert.equal(accountProfilePatchSchema.safeParse({ gender: "女" }).success, false);
const route = readFileSync(new URL("../src/app/api/account/route.ts", import.meta.url), "utf8");
assert.match(route, /payload\.gender !== undefined \? \{ gender: payload\.gender \}/);
// 原值: /rectification_case_id,gender"\)/
// 新值: /rectification_case_id,gender,first_consult_free_used_at"\)/
// 原因: 账户 GET 在 gender 后多读首问是否已用,用来返回 firstConsultFree(#95 T1)
assert.match(route, /rectification_case_id,gender,first_consult_free_used_at"\)/, "GET returns the stored gender and the free-question flag");
assert.match(route, /rectification_case_id,gender"\)/, "GET returns the stored gender");
});
test("people writes carry gender only when sent; reads expose it only when stored", () => {
@@ -108,13 +108,7 @@ test("people edit form: the gender field is part of the form and writes the draf
const editing = renderToStaticMarkup(<PeopleArchiveView {...viewProps({
selected: people[1]!, selectedId: people[1]!.id, editing: true, draft: otherProfile,
})} />);
// 原值: ["女:false", "男:true", "不填:false"]
// 新值: 性别单选前面多一组「TA 是你的」(空关系显示为已选「其他」)
// 原因: 档案编辑他人时要能补关系(#95 T2);性别单选本身没改
assert.deepEqual(radios(editing), [
"伴侣:false", "孩子:false", "父母:false", "朋友:false", "其他:true",
"女:false", "男:true", "不填:false",
]);
assert.deepEqual(radios(editing), ["女:false", "男:true", "不填:false"]);
});
test("detail card: 性别 shows the stored value, 未填 when empty; 本人 edits it in the same form as everyone", () => {
@@ -11,10 +11,10 @@ import { RectificationOfferCard } from "../src/components/rectification-in-chat-
import { consultationAgentPublicEventSchema, createNdjsonParser, type AgentExecutionReceipt, type ConsultationAgentPublicEvent } from "../src/lib/consultation-agent-events.ts";
import { RECTIFICATION_OFFER_RULE } from "../src/lib/consultation-thinking-plan.ts";
import {
blocksNewRectification,
RECTIFICATION_DIRECT_NEW_COPY,
RECTIFICATION_DIRECT_NOT_SELF_COPY,
RECTIFICATION_DIRECT_RESUME_COPY,
RECTIFICATION_DIRECT_RESUME_ELSEWHERE_COPY,
RECTIFICATION_DIRECT_UNAVAILABLE_COPY,
RECTIFICATION_OFFER_OPENED_COPY,
RECTIFICATION_OFFER_START_LABEL,
@@ -48,7 +48,6 @@ const DIRECT_SENTENCES = [
RECTIFICATION_DIRECT_RESUME_COPY,
RECTIFICATION_DIRECT_UNAVAILABLE_COPY,
RECTIFICATION_DIRECT_NOT_SELF_COPY,
RECTIFICATION_DIRECT_RESUME_ELSEWHERE_COPY,
] as const;
const STOP_FINISH = { type: "finish", payload: { stepResult: { reason: "stop" }, output: { usage: {}, steps: [{}] } } };
@@ -123,13 +122,22 @@ function walkSources(dir: string): string[] {
}
test("direct-entry sentences stay within the free-completion length", () => {
assert.deepEqual(DIRECT_SENTENCES.map((sentence) => Array.from(sentence).length), [13, 13, 14, 11, 11]);
// 原值:[13, 13, 14, 11, 11],含「可在历史对话里接着做。」
// 新值:[13, 13, 14, 11]
// 原因:接不回进行中的校正时改为新开,不再留这句没有出口的兜底
assert.deepEqual(DIRECT_SENTENCES.map((sentence) => Array.from(sentence).length), [13, 13, 14, 11]);
for (const sentence of DIRECT_SENTENCES) assert.ok(Array.from(sentence).length <= 20);
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(RECTIFICATION_DIRECT_RESUME_ELSEWHERE_COPY, "可在历史对话里接着做。");
assert.equal(blocksNewRectification("candidate_accepted"), false);
assert.equal(blocksNewRectification("collecting_evidence"), true);
assert.equal(blocksNewRectification("draft"), true);
assert.equal(blocksNewRectification("candidate_ready"), true);
assert.equal(blocksNewRectification("needs_rebaseline"), true);
assert.equal(blocksNewRectification("paused"), true);
assert.equal(blocksNewRectification("confirmed"), false);
});
test("an opened offer with no case id binds, and a reload can find that case", () => {
@@ -240,7 +248,19 @@ test("explicitRequest bypasses an earlier offer, and userAsked alone still only
assert.equal(held.decisions(), 1);
});
test("an in-progress case resumes that session, or says to continue in history", async () => {
async function withResumeWarnings(run: () => Promise<void>): Promise<unknown[][]> {
const warnings: unknown[][] = [];
const original = console.warn;
console.warn = (...args: unknown[]) => { warnings.push(args); };
try {
await run();
} finally {
console.warn = original;
}
return warnings;
}
test("an in-progress case resumes that session, or opens a new one when the session cannot be read", async () => {
const resume = offerTool({
decide: async () => ({ ok: false, reason: "case_in_progress" }),
loadResumableSessionId: async () => RESUME_SESSION,
@@ -257,29 +277,49 @@ test("an in-progress case resumes that session, or says to continue in history",
});
assert.equal(resume.loads(), 1);
const missing = offerTool({ decide: async () => ({ ok: false, reason: "case_in_progress" }) });
const refused = await missing.tool.execute({ explicitRequest: true });
assert.equal(refused.directEntry, undefined);
assert.equal(missing.state.rectificationDirectEntry?.kind, "refused");
assert.equal(missing.state.rectificationDirectEntry?.sentence, RECTIFICATION_DIRECT_RESUME_ELSEWHERE_COPY);
assert.equal(missing.state.rectificationOffer, undefined);
assert.match(String(refused.note), /历史对话/);
assert.doesNotMatch(String(refused.note), /不要提校正/);
const thrown = offerTool({
decide: async () => ({ ok: false, reason: "case_in_progress" }),
loadResumableSessionId: async () => { throw new Error("db down"); },
// 原值:loader 返回 null 时 kind=refused,句子是「可在历史对话里接着做。」
// 新值:kind=new,句子是「好,我们开始核对出生时间。」,并记一条不含原文的日志
// 原因:没有会话号时必须仍能打开一次校正
const missingWarnings = await withResumeWarnings(async () => {
const missing = offerTool({ decide: async () => ({ ok: false, reason: "case_in_progress" }) });
const opened = await missing.tool.execute({ explicitRequest: true });
assert.equal(opened.directEntry, "new");
assert.equal(missing.state.rectificationDirectEntry?.kind, "new");
assert.equal(missing.state.rectificationDirectEntry?.sentence, RECTIFICATION_DIRECT_NEW_COPY);
assert.equal(missing.state.rectificationOffer, undefined);
assert.match(String(opened.note), /好,我们开始核对出生时间/);
assert.doesNotMatch(String(opened.note), /历史对话/);
});
const fallback = await thrown.tool.execute({ explicitRequest: true });
assert.equal(thrown.state.rectificationDirectEntry?.kind, "refused");
assert.equal(thrown.state.rectificationDirectEntry?.sentence, RECTIFICATION_DIRECT_RESUME_ELSEWHERE_COPY);
assert.match(String(fallback.note), /历史对话/);
assert.equal(missingWarnings.length, 1);
assert.equal(missingWarnings[0]?.[0], "rectification_resume_lookup_failed");
assert.deepEqual(missingWarnings[0]?.[1], { requestId: REQUEST_ID, errorCategory: "missing_session", hasCaseId: false });
const thrownWarnings = await withResumeWarnings(async () => {
const thrown = offerTool({
decide: async () => ({ ok: false, reason: "case_in_progress" }),
loadResumableSessionId: async () => { throw new Error("db down"); },
});
const fallback = await thrown.tool.execute({ explicitRequest: true });
assert.equal(thrown.state.rectificationDirectEntry?.kind, "new");
assert.equal(thrown.state.rectificationDirectEntry?.sentence, RECTIFICATION_DIRECT_NEW_COPY);
assert.equal(fallback.directEntry, "new");
assert.doesNotMatch(JSON.stringify(fallback), /db down/);
});
assert.equal(thrownWarnings.length, 1);
const thrownPayload = JSON.stringify(thrownWarnings[0]);
assert.match(thrownPayload, /lookup_failed/);
assert.match(thrownPayload, new RegExp(REQUEST_ID));
assert.doesNotMatch(thrownPayload, /db down/);
const quiet = offerTool({ decide: async () => ({ ok: false, reason: "case_in_progress" }) });
const ordinary = await quiet.tool.execute({});
assert.equal(quiet.loads(), 0);
assert.equal(quiet.state.rectificationDirectEntry, undefined);
assert.match(String(ordinary.note), /历史对话/);
// 原值:普通提议的说明匹配 /历史对话/
// 新值:说明这次不出卡,不提历史对话
// 原因:那句兜底已经删掉;没做完时仍不出新卡
assert.match(String(ordinary.note), /不出提议卡/);
assert.doesNotMatch(String(ordinary.note), /历史对话/);
assert.doesNotMatch(String(ordinary.note), /不要提校正/);
});
@@ -682,9 +722,14 @@ test("the offer rule is defined once, and direct entry settles with the free com
assert.match(windowBlock, /precomputeWindowConsultation/);
assert.match(windowBlock, /运行合同不完整/);
assert.match(natalBlock, /运行合同不完整/);
assert.match(route, /getRectificationEntrySummary\(accounting, userId\)/);
assert.match(route, /\.from\("agentic_rectification_cases"\)/);
assert.match(route, /\.select\("session_id"\)/);
// 原值:route 直接 getRectificationEntrySummary 后 from("agentic_rectification_cases").select("session_id")
// 新值:route 调用 loadResumableRectificationSessionId,不再读这张表
// 原因:服务角色对该表没有读权限,直接读必然失败还被吞掉
assert.match(route, /loadResumableRectificationSessionId\(accounting, userId\)/);
assert.doesNotMatch(route, /\.from\("agentic_rectification_cases"\)/);
assert.doesNotMatch(route, /\.select\("session_id"\)/);
const serverFile = src("../src/lib/rectification-in-chat-server.ts");
assert.match(serverFile, /blocksNewRectification\(summary\.latestResumable\?\.status \?\? ""\)/);
const stream = src("../src/lib/stream-agent-response.ts");
assert.match(stream, /if \(options\.state\.rectificationDirectEntry\) return true;/);
@@ -15,6 +15,7 @@ import {
import {
BIRTH_TIME_CHANGED_HISTORY_NOTE,
RECTIFICATION_OFFER_TOOL_ID,
blocksNewRectification,
appendRectificationReturn,
bindOfferToCase,
decideRectificationOfferWith,
@@ -111,6 +112,21 @@ test("the server decides whether a card may be shown: self, product on, no case
assert.deepEqual(await decideRectificationOfferWith({ subjectRole: "other", productEnabled: yes, hasResumableCase: no, creditCost: price }), { ok: false, reason: "not_self" });
assert.deepEqual(await decideRectificationOfferWith({ subjectRole: "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" });
// 原值:只要摘要说有可续做校正,就判 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 });
});
@@ -4,7 +4,6 @@ import { fileURLToPath } from "node:url";
import test from "node:test";
import {
MAX_RESUMABLE_CASES_PER_USER,
RECTIFICATION_CASE_STATUSES,
RECTIFICATION_SKILL_NAME,
RECTIFICATION_SKILL_VERSION,
@@ -91,7 +90,10 @@ test("terminal transitions are one-way and evidence writes stop at terminal", ()
assert.equal(evidenceWritesAllowed("confirmed"), false);
assert.equal(evidenceWritesAllowed("closed"), false);
assert.equal(evidenceWritesAllowed("superseded"), false);
assert.equal(MAX_RESUMABLE_CASES_PER_USER, 1);
// 原值:MAX_RESUMABLE_CASES_PER_USER === 1
// 新值:不再导出这个常量
// 原因:它声称镜像「每人最多一条进行中校正」的部分唯一索引,该索引已由 BUG-177 删除,没有业务逻辑再读它
assert.equal(readFileSync(new URL("../src/lib/rectification-agentic/v9/case-status.ts", import.meta.url), "utf8").includes("MAX_RESUMABLE_CASES_PER_USER"), false);
});
test("the active rectification skill pins the v10 identity and lives in the right directory", () => {