diff --git a/CHANGELOG.md b/CHANGELOG.md index a8fd6ef5..e813fd72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # 印度占星 Skill 更新日志 +## 2026-10-23 — 明说要校正时,已采用过的人能直接新开一次 + +- 已经采用过出生时间的人,在对话里说要做生时纠正,会直接开始一次新的核对,不再只回「可在历史对话里接着做。」 +- 真没做完的校正仍然接着做。这次如果接不回原来那次,也直接新开。 +- 已采用过的账户,普通对话里的校正提议卡会重新出现。 +- Skill 版本不变(BUG-1423)。 + +## 2026-10-23 — 星盘页本命和月亮的行星表重新占满整行 + +- 宽屏下,本命和月亮的行星表回到参数卡旁边那一整行,宿主、pada、状态不用横拖就能看见。分盘、Bhava、行运仍是整行。 +- Skill 版本不变(BUG-1422)。 + +## 2026-10-23 — 新账户可以先问一个问题,档案能标明和你的关系 + +- 每个账户有一次不扣点的正式提问。问完才算用掉;中途停下、失败,或只是寒暄,这一次还在。选了比标准档更贵的模型时,只扣高出的那一部分。 +- 星盘档案里,别人可以标明是伴侣、孩子、父母、朋友或其他。没选过的显示「其他」。本人仍然只标「本人」。 +- Skill 版本不变。 + ## 2026-10-23 — 行星解释的六维力量合计改读已算出的总分 - 六维力量合计改读已算出的 rupa 总分。有总分时不再写成「未列出」。Saturn / KP 的结果段和整段补到上游篇幅,这一版先不做。 @@ -23,7 +41,7 @@ ## 2026-10-22 — 完整数据版写出 Jaimini Yogi 与 Ava Yogi 坐标 - 这一节以前因为找不到计算模块,每张盘都是 blocked。现在用本命太阳、月亮黄经按 93°20′ 和 186°40′ 算出两点,中英附录各有一张表。空的 Tajika 候选改成一句「本年没有候选」;Goel 星座强度里没有落入行星时写「无」。 -- Skill 版本不变(BUG-1421)。 +- Skill 版本不变(BUG-1424)。 ## 2026-10-22 — 星盘大运右栏可以切到 Yogini diff --git a/CONTEXT.md b/CONTEXT.md index 5b5aacc9..bd076f42 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -72,6 +72,10 @@ _避免使用_:用户反馈状态、通知状态 一张星盘档案对应的一个人。户主本人也是其中一个人。不要再叫「当前星盘」来指这个人。 _避免使用_:当前星盘、关系、默认星盘 +**关系(人物)**: +这个人相对户主的家里关系:伴侣、孩子、父母、朋友或其他。只记在别人的档案上,本人不记。没填过的显示「其他」。它不是人物的别名。 +_避免使用_:用「关系」称呼这个人、家人(笼统代替这五项)、客户 + **星盘档案**: 侧栏页面 `/people` 里保存的人物列表。最多 5 人,含本人。本人不可删除。 _避免使用_:星盘库、星盘资料(设置里的旧面板) diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index b3334fd1..d2b8bb7d 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -19254,7 +19254,55 @@ - 复发自:无 - 修复版本:本提交 -## BUG-1421 | Yogi / Ava Yogi 每张盘都是 blocked +## 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`。 + +## BUG-1422 | 星盘页本命和月亮的行星表被挤进左栏 + +- 状态:resolved +- 首次发现:2026-10-22 +- 最近更新:2026-10-23 +- 影响面:星盘页桌面宽度下的本命表和月亮盘表 +- 用户现象:宽屏下选「本命」或「月亮」时,行星表只占左栏。宿主、pada、状态三列要横拖才看得到,右栏在参数卡下面空着。分盘、Bhava、行运的表正常。 +- 触发条件:桌面宽度(≥1024px)打开星盘页的本命或月亮。来自 #87 给这两张表包了一层符号说明。 +- 根因:跨栏按类名而非结构。桌面规则只认表格外层的两个类名。本命和月亮的直接子项变成了符号说明那一层,规则匹配不上,表就排进左栏。 +- 修复:网格自己用固定的整行容器装表格,跨栏只认这个容器。表格内容、列数、列宽不变。 +- 验证:`frontend/tests/chart-table-span-20261023.test.tsx`。五种盘型的网格都是三个直接子项,第三个是整行容器;桌面样式只让这个容器跨栏。 +- 防复发:上面两条断言。再往表外包一层,结构断言会失败;跨栏不再跟着表格类名走。 +- 相关记录:BUG-1083(窄屏仍只能在表自己的盒子里横滚)。issue #102。回归来源 #87。 +- 复发自:无 +- 修复版本:`codex/chart-table-span-20261023` + +## BUG-1423 | 明说要做生时纠正只回一句「可在历史对话里接着做」 + +- 状态:resolved +- 首次发现:2026-10-22 +- 最近更新:2026-10-23 +- 影响面:对话里明确要求做生时校正的入口,以及普通对话里的校正提议卡 +- 用户现象:已经采用过出生时间的人,在对话里说要做生时纠正,只收到一句「可在历史对话里接着做。」没有卡片,也进不了校正。真正没做完的校正同样接不回去。 +- 触发条件:本人档案;出生时间显示已采用;在对话里明确要求校正。没做完的校正也会走到同一句,因为查找会话失败被吞掉。 +- 根因:三条。一,找会话时直接读一张服务角色没有权限的表,失败被静默吞掉,所以真正没做完的也续不上。二,把已经采用(实际上的终点)算作还在进行,明说入口和提议卡一起被挡住。三,失败时只给一句话,没有能点的出口。 +- 修复:入口只把还没做完的五种状态当作进行中,已采用不再挡住,明说要校正就新开一次,提议卡也按这个口径恢复。查找会话改走已有的校正读取接口。读不到就记一条不含个人资料的日志,并直接新开。删掉「可在历史对话里接着做。」共享的状态集合和数据库没有改。 +- 验证:入口口径单测;接不回时改为新开并有一条日志;`database-rectification-resume-session-20261023.test.ts` 用服务角色经接口拿到进行中校正的会话,直接读表仍然被拒绝。 +- 防复发:上面的口径单测和这条真库测试。再改回直接读表,或把已采用重新算作进行中,测试会失败。 +- 相关记录:BUG-1306、BUG-1248、BUG-177。issue #103。 +- 复发自:BUG-1306 +- 修复版本:`codex/rectification-explicit-open-20261023` + +## BUG-1424 | Yogi / Ava Yogi 每张盘都是 blocked - 状态:resolved - 首次发现:2026-10-21 @@ -19266,6 +19314,7 @@ - 修复:模块放进 `scripts/annual_native/yogi_sphuta.py`,读 `chart.planets.Sun/Moon.degree_raw`。导入失败写入 `report.errors` 并带上 `ImportError` 原文,不再记成 blocked。算出的点写入 `yogi_avayogi_local`,中英附录出表。 - 验证:`tests/test_yogi_sphuta_native.py`(虚构盘日、月与 `degree_raw` 差 ≤ 1e-6°;公式 93°20′ / 186°40′)。`tests/test_full_data_parity_chapters.py` 三张虚构盘中英都有 Yogi 与 Ava Yogi,格子不是「未判定」。 - 防复发:引擎源码不得再出现 `yogi_sphuta_local_unavailable`。缺经度时 reason 写 `sun_or_moon_longitude_missing`。 -- 相关记录:issue #91 T4。编号原拟 BUG-1420,与 staging 上「Bug 历史整份重复」冲突,合并后改为 BUG-1421。 +- 相关记录:issue #91 T4。编号原拟 BUG-1420,与 staging 上「Bug 历史整份重复」冲突后曾改为 BUG-1421;该号已被 #95 占用,合并 staging 后改为 BUG-1424。 - 复发自:无。 - 修复版本:分支 `codex/full-data-parity-1-20261021`。 + diff --git a/docs/tasks/PROGRESS-chart-table-span-20261023.md b/docs/tasks/PROGRESS-chart-table-span-20261023.md new file mode 100644 index 00000000..0e977197 --- /dev/null +++ b/docs/tasks/PROGRESS-chart-table-span-20261023.md @@ -0,0 +1,30 @@ +# PROGRESS · 星盘页行星表整行(2026-10-23) + +Issue #102。BUG-1422。 + +## 基线 + +- 开工时 `origin/staging` = `d72adb5754eab4d66ac3a47c4edcb12704e29815`(任务书写的 `9049b5e99` 已过期)。 +- 本文件最大编号是 BUG-1420,本单用 BUG-1422。 + +## 做了什么 + +- 星盘页网格的第三格改成固定的整行容器。本命、月亮、分盘、Bhava、行运的表都放在里面。 +- 桌面跨栏只认这个容器。表格自己的类名不再负责跨栏。 +- 容器可以缩到比内容窄,窄屏时表仍在自己的框里横滑。 +- 表格的字、列、列宽没改。符号说明的气泡还在原来的位置。 + +## 自测 + +| 检查 | 命令 | 结果 | +| --- | --- | --- | +| 类型 | `frontend\node_modules\.bin\tsc --noEmit -p frontend` | 退出码 0 | +| lint | `frontend` 下 `npm run lint` | 退出码 0。0 error,122 条既有 warning,本轮不改 | +| 定向测试 | `node node_modules/tsx/dist/cli.mjs --test` 跑新结构测试,加上 `chart-page-view.test.tsx`、`chart-varga-table.test.tsx`、`chart-page-mobile-width-20260929.test.ts` | 49 条通过,0 失败 | +| 构建 | `frontend` 下 `npm run build` | 退出码 0。路由表 `/` 为 `○ Static`。预渲染 `index.html` 引用 33 个 JS、3 个 CSS,gzip-9 合计 646,234 字节(JS 575,154,CSS 71,080)。没有用改动前的代码再构建一次,所以不写增减百分比 | + +未跑前端全量 `npm test`(验收时跑)。没有登录,本机浏览器截图没做,留给 `docs/testing/chart-table-span-20261023.md`。 + +## 偏离 + +- 无。编号说明:本分支的 BUG-1422 与尚未合并的 #95 分支各自占用了同一个号。若 #95 先进入 staging,验收时需要给本条改号。 diff --git a/docs/tasks/PROGRESS-full-data-parity-1-b-20261023.md b/docs/tasks/PROGRESS-full-data-parity-1-b-20261023.md index 0306a355..9db72601 100644 --- a/docs/tasks/PROGRESS-full-data-parity-1-b-20261023.md +++ b/docs/tasks/PROGRESS-full-data-parity-1-b-20261023.md @@ -58,4 +58,4 @@ ## 编号 -Yogi 记录在本分支曾写成 BUG-1420。staging 上 BUG-1420 已是「Bug 历史整份重复」。合并后 Yogi 改为 BUG-1421。更新日志里的编号一并改掉。 +Yogi 记录在本分支曾写成 BUG-1420,后改为 BUG-1421。staging 上 BUG-1421 已被 #95 占用,当前最大号是 BUG-1423。合并 origin/staging 后 Yogi 改为 BUG-1424。更新日志里的编号一并改掉。 diff --git a/docs/tasks/PROGRESS-onboarding-1-data-20261022.md b/docs/tasks/PROGRESS-onboarding-1-data-20261022.md new file mode 100644 index 00000000..1f69a00d --- /dev/null +++ b/docs/tasks/PROGRESS-onboarding-1-data-20261022.md @@ -0,0 +1,52 @@ +# 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。 +- 没有单独造一套会员夹具去测「会员不占免费额度」;规则写在预扣函数里:来源是会员时不写入占用。 diff --git a/docs/tasks/PROGRESS-rectification-explicit-open-20261023.md b/docs/tasks/PROGRESS-rectification-explicit-open-20261023.md new file mode 100644 index 00000000..be2fd661 --- /dev/null +++ b/docs/tasks/PROGRESS-rectification-explicit-open-20261023.md @@ -0,0 +1,40 @@ +# PROGRESS · 明说要校正能打开(2026-10-23) + +Issue #103。BUG-1423。 + +## 基线 + +- 开工时 `origin/staging` = `d72adb5754eab4d66ac3a47c4edcb12704e29815`(任务书写的 `dd5ae8d03` 已过期)。 +- 本文件最大编号是 BUG-1420,本单用 BUG-1423。 + +## 做了什么 + +- 对话入口不再把「已采用」当成还在进行。明说要校正就新开一次,普通提议卡也按这个口径恢复。 +- 真没做完的五种状态仍接着做。找会话改走已有的校正读取接口,不再直接读那张表。 +- 读不到会话时记一条日志(只有请求号、错误类别、有没有 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-1423 与尚未合并的 #95、#102 分支各自占用了同一个号。若那些先进入 staging,验收时给本条改号。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 969113d3..c53124da 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -37,10 +37,10 @@ | 任务书 | 进度 | 主题 | 状态 | 落点 | | --- | --- | --- | --- | --- | -| `TASK-rectification-explicit-open-20261023.md` | — | **明说要校正只回一句话(issue #103)**:找会话直接读一张对服务角色撤权的表、必然失败被吞(真正进行中的也续不上);`candidate_accepted` 被算作进行中挡住入口和提议卡;兜底只给一句话。产品选 C;只改入口口径、经 RPC 找会话、删兜底,不改共享状态集合、不动表 | 待执行(循环) | 分支 `codex/rectification-explicit-open-20261023` | -| `TASK-chart-table-span-20261023.md` | — | **星盘页本命 / 月亮行星表挤进左栏(issue #102)**:#87 给表包了 `ChartSymbolHelp` 后跨栏规则失配;根因修法是 `VedicLayout` 用固定整行容器承载表格,跨栏只认容器;加结构断言防复发 | 待执行(循环) | 分支 `codex/chart-table-span-20261023` | +| `TASK-rectification-explicit-open-20261023.md` | `PROGRESS-rectification-explicit-open-20261023.md` | **明说要校正只回一句话(issue #103)**:找会话直接读一张对服务角色撤权的表、必然失败被吞(真正进行中的也续不上);`candidate_accepted` 被算作进行中挡住入口和提议卡;兜底只给一句话。产品选 C;只改入口口径、经 RPC 找会话、删兜底,不改共享状态集合、不动表。BUG-1423 | 已合入 staging | 分支 `codex/rectification-explicit-open-20261023` | +| `TASK-chart-table-span-20261023.md` | `PROGRESS-chart-table-span-20261023.md` | **星盘页本命 / 月亮行星表挤进左栏(issue #102)**:#87 给表包了 `ChartSymbolHelp` 后跨栏规则失配;根因修法是 `VedicLayout` 用固定整行容器承载表格,跨栏只认容器;加结构断言防复发。BUG-1422 | 已合入 staging | 分支 `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-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` | (一)已合入 staging;(二)(三)(四)待领取 | 原型 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`(未推送) | @@ -529,4 +529,5 @@ | `TASK-synastry-staging-fix-fix-20261022.md` | `PROGRESS-synastry-staging-fix-fix-20261022.md` | **#85 第 1 次打回**(T7~T11 主体通过):新地址没登记进路由清单致快速门红;CHANGELOG 与 T11 矛盾;侧栏点亮测试是空断言 | 已合入 staging | `codex/synastry-staging-fix-20261021` | | `TASK-report-english-han-leak-fix-20261022.md` | `PROGRESS-report-english-han-leak-fix-20261022.md` | **#89 第 1 次打回**:改了冻结的校正打分文件 `jaimini.py`;测试的 PyJHora 屏蔽没传到子进程(测的不是生产那份报告,也是慢的原因);缺英文时留空藏住漏洞 | 已合入 staging | `codex/report-english-han-leak-20261021` | | `TASK-full-data-parity-1-fix-20261022.md` | `PROGRESS-full-data-parity-1-fix-20261022.md` | **#91 A 段第 1 次打回**:新六章 69 节里 16 节在三张虚构盘上整节「未判定」,其中 12 节数据在资料包里、是读错了字段;测试抓不到整节未判定;新六章绕过了报告口径替换;中文附录悄悄丢列和瑜伽条目要量出来 | 本地完成,待验收 | `codex/full-data-parity-1-20261021` | +| `TASK-full-data-parity-1-b-fix-20261023.md` | `PROGRESS-full-data-parity-1-b-20261023.md` | **#91 B 段打回(本单累计第 3 次,交产品定执行方)**:T9 解释补长不在让步清单却没做(行星一章 4,574 字符,目标约 8,970);D8 后两年缺整宫、Tajika、Tripataki、Saham 轨迹、年主依据 | 需拍板 | `codex/full-data-parity-1-20261021` | | — | `PROGRESS-full-data-parity-1-fix2-20261022.md` | **#91 A 段第 2 次打回**(Claude 口头修复单 F1~F4):11 节只印「未判定」和键名改读真实记录;测试只认非第一列真实值、禁止键名格;中文瑜伽组合句由中文源给出(BUG-1418);六章降为 `##`、节 `###`(BUG-1419) | 本地完成,待验收 | `codex/full-data-parity-1-fix2-20261022` | diff --git a/docs/tasks/TASK-full-data-parity-1-b-fix-20261023.md b/docs/tasks/TASK-full-data-parity-1-b-fix-20261023.md new file mode 100644 index 00000000..b6310882 --- /dev/null +++ b/docs/tasks/TASK-full-data-parity-1-b-fix-20261023.md @@ -0,0 +1,47 @@ +# 修复单:完整数据版补齐(一)B 段(issue #91,2026-10-23) + +- 被验提交:`d74760b10`(分支 `codex/full-data-parity-1-20261021`,B 段 T4~T10) +- 原任务书:`TASK-full-data-parity-1-20261021.md` +- 执行方:待产品定(见 issue #91 的 escalate 评论)。同一分支继续,开工前 `git merge origin/staging`(不 rebase)。 +- BUG 编号:本分支 Yogi 用的 BUG-1421 已被 #95 占用(staging 当前最大号 1423)。合并时改成当时最大号 +1,CHANGELOG、PROGRESS 同步。 + +## 验收结论(Claude,Linux,屏蔽 PyJHora,0 号虚构盘先英文后中文) + +做对了的:Yogi / Ava Yogi 改为原生计算,不再整盘 `blocked`;标准大运表出了子运,没有规则的体系写明「本体系未提供子运」,Kalachakra 标「简化模型」、主运不换;年运附表六节出现;从报告月起 36 个月 × 9 星,报告口径与 KP 口径各 324 行;新模块都在 `scripts/annual_native/`,根目录文件数不变;没碰冻结打分文件;没有导入 `jhora`;新增的异常处理都记日志带原因;「六维力量合计为未列出」已修。生成:英文 1,263,805 字符、中文 753,562 字符,合计约 24 秒,远低于 180 秒。 + +**未通过的两项:** + +## K1 · T9 解释补长没做,但它不在让步清单里(必修) + +PROGRESS 把「行星、宫主解释补到上游篇幅」写成「让步顺序第一项」。任务书第 8 节的让步顺序第一项只有 **T9 的 Saturn / KP 结果段**;解释补长是产品 D12 的决定,不能让。现在 0 号盘英文「Planets, Nakshatras and Houses」一章 4,574 字符(目标是上游同章 8,970 字符量级),每星仍是 6 句固定骨架。宫主逐宫解释 5,664 字符,也是骨架句。 + +修法:照任务书 T9 前半句做:`build_graha_interpretation_pack`、`build_bhavesh_interpretation_pack` 按上游 main 对应生成器补传统段落,「可能」口吻,叠加功能吉凶 v2,中英各自从源字段出。 + +验收:0、1、9 号虚构盘两章中英字数都不低于任务书标准(英文 ≥ 8,970 字符量级,中文按比例),PROGRESS 列逐盘逐章字数;红线 7 模板口径测试(不断言死亡、寿命、疾病诊断、离婚、流产、不孕,不写再婚、几段关系、早夭)中英全文都跑。Saturn / KP 结果段可以继续让,写进 PROGRESS。 + +## K2 · 后两年只展开了一半(必修) + +D8 要求「当年 + 后两年,**每一年都有**」全套。现在 2027、2028 两年只有年度行星、年度大运、Mudda、Patyayini、月返照、Tithi Pravesha 六项。下面这些只出了当年(2026),后两年没有,PROGRESS 也没写: + +- 年盘整宫框架(Annual Return House Framework) +- Tajika 相位与瑜伽候选、Tajika 力量 +- Tripataki +- 年度 Saham 公式轨迹 +- 年主与 Muntha 的依据行(后两年只有 Muntha 星座一行) + +修法:后两年按当年同一套函数逐年生成,挂在每年自己的小节下(中英一一对应)。已有当年字段对 3 张虚构盘逐值不变。 + +验收:0、1、9 号盘中英两版,三年里每一年上面每一项都出现且有数值;加一条结构测试逐年断言。 + +## 可以接受的偏差(记录在案) + +- 72 年生命周期年返不做:在让步清单里。 +- 现居地八年总览只在资料包带现居地坐标时出:现居地读取在 `_annual_payload_from_args` 里到不了,打开会改已有年运数值。保持不动,Claude 另行评估。 +- 年度北印度图盘面板不新做:阅读页已用算好的黄经印成文字表(「Annual Chart Numeric Data」三年都有)。 +- 三年星历不走 `kp_monthly_transits`:那条旧路径是视位置,违反红线 1;改用本系统真恒星黄经是对的。 + +## 交回前必跑 + +- PROGRESS 里列出的 19 个定向测试文件,加上新增的逐年结构测试。 +- 200 张英文扫描(屏蔽 PyJHora)。 +- 快速门、全量 pytest、前端全量由 Claude 复验。 diff --git a/docs/testing/chart-table-span-20261023.md b/docs/testing/chart-table-span-20261023.md new file mode 100644 index 00000000..41ce17a3 --- /dev/null +++ b/docs/testing/chart-table-span-20261023.md @@ -0,0 +1,6 @@ +# 星盘页行星表整行(2026-10-23) + +登录后打开星盘页。不要用真实出生资料做记录,用站内已有的示例盘即可。 + +1. 把窗口拉到大约 1280 宽。依次看「本命」「月亮」「D9」「Bhava」「行运」。每张表都占满盘和参数卡下面的整行,宿主、pada、状态不用横拖就能看见。 +2. 把窗口收到大约 390 宽(或手机)。页面本身不左右滚动。表如果放不下,只在表自己的框里横滑。 diff --git a/docs/testing/onboarding-journey-20261022.md b/docs/testing/onboarding-journey-20261022.md new file mode 100644 index 00000000..cb7a1d9b --- /dev/null +++ b/docs/testing/onboarding-journey-20261022.md @@ -0,0 +1,25 @@ +# 新用户启程 · 真机清单(2026-10-22) + +后面的 issue 往这份清单末尾追加。本页先只覆盖数据层(issue #95,T1 首问免费、T2 关系与引导状态)。起步三问、启程清单、一次性提示和动效还没做,不要在这一轮里验收它们。 + +用新注册的邮箱。不要把真实出生资料写进记录。 + +## 首问免费 + +1. 新账户点数是 0。建完档之后,第一次正式提问能发出去,点数仍是 0,不弹兑换窗。 +2. 这一问回答完成之后,再问一次:弹出原来的兑换窗,文案不变。 +3. 另开一个新账户。第一问发出去之后立刻停止。再问一次仍然不扣点。 +4. 另开一个新账户。第一问如果只被当成寒暄,问完之后正式提问仍然不扣点。 +5. 后台用量里,这次免费提问的资金来源显示「首问免费」。若当时选了更贵的模型,显示「首问免费(差价 N 点)」,账户只少这 N 点。 + +## 人物关系 + +1. 星盘档案里添加一个人,选「孩子」,保存。刷新之后,列表和详情的名字后面仍是「孩子」。 +2. 找一份关系还是空的旧档案,打开编辑,补上「父母」,保存后再打开仍是「父母」。没改过的空档案,名字后面显示「其他」。 +3. 本人这一行仍只显示「本人」,编辑本人时没有「TA 是你的」。 + +## 以后追加 + +- (二)先看谁的盘、起步三问 +- (三)启程清单、一次性提示 +- (四)动效,以及打开「减少动态效果」再走一遍 diff --git a/docs/testing/rectification-explicit-open-20261023.md b/docs/testing/rectification-explicit-open-20261023.md new file mode 100644 index 00000000..10afad7f --- /dev/null +++ b/docs/testing/rectification-explicit-open-20261023.md @@ -0,0 +1,8 @@ +# 真机清单 · 明说要校正能打开(2026-10-23) + +用测试账号。不要把真实出生时间写进记录。 + +1. 已经采用过出生时间的账户,在对话里说「帮我做生时纠正」。应直接进入一次新的校正,不再只出现「可在历史对话里接着做。」 +2. 有一次答到一半、还没采用的校正。从对话里说要校正,应接回那一次。 +3. 已采用过的账户,普通对话问到时机一类的问题时,校正提议卡会出现。 +4. 已采用的旧校正还在,再开一次新的并采用另一个时间。打开旧的那次对话:采用后的核对题只读,不弹错误,控制台没有未处理的报错。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index b15398ac..a2ba226f 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -1136,7 +1136,7 @@ D40 / D45 / D60 的表格上方,出生时间不是「已校正」时多一句 **不再按断点复制参数。** 原本同一组参数有三种画法:中宫叠一张 `.chart-page-center-card`(手机两行、桌面六行),盘下方再来一张 `.chart-page-params-below` 重复那六行,由 media query 三选一。同样的事实画三遍、靠断点开关,改一处要改三处。键值表让每个字段只出现一次,`.chart-page-center-card` / `-compact` / `-extra` / `.chart-page-params-below` / `.chart-page-boundary-desktop` 五个类连同它们的 767/768 开关块一并删除,由 `chart-page-view.test.tsx` 守着不许回来。 -桌面(≥1024)盘面与参数表左右并排,行星表是跨两列的整行——它的列宽需要完整版心。2026-09-29 起桌面印度盘宽 440px;当晚(BUG-1100,产品「框太丑」)上限从绘图区移到外框 `.chart-page-vedic-stage`(440px + 两侧 `--space-3` 内边距 + 边框),外框贴合星图,不再是一张宽卡片中间放一张窄盘;手机端外框不设上限;西洋桌面绘图区上限 480px。低于此断点的结果盘不收紧,参数区和表格不缩。等待盘与结果盘复用同一桌面列宽与绘图容器,不再使用独立 400px 等待盘上限;印度等待绘图容器以 400/452 比例预留结果图例带,内部空 SVG 仍为 400×400,避免揭幕时外框和说明区纵向跳动,不改共享绘图。只有盘位动(2026-09-30 起为宫位依次点亮,不再整张呼吸),失败后退出,参数表不新增骨架。 +桌面(≥1024)盘面与参数表左右并排,行星表是跨两列的整行——它的列宽需要完整版心。表格区由 `VedicLayout` 的整行容器承载,不靠表格自身类名跨栏。2026-09-29 起桌面印度盘宽 440px;当晚(BUG-1100,产品「框太丑」)上限从绘图区移到外框 `.chart-page-vedic-stage`(440px + 两侧 `--space-3` 内边距 + 边框),外框贴合星图,不再是一张宽卡片中间放一张窄盘;手机端外框不设上限;西洋桌面绘图区上限 480px。低于此断点的结果盘不收紧,参数区和表格不缩。等待盘与结果盘复用同一桌面列宽与绘图容器,不再使用独立 400px 等待盘上限;印度等待绘图容器以 400/452 比例预留结果图例带,内部空 SVG 仍为 400×400,避免揭幕时外框和说明区纵向跳动,不改共享绘图。只有盘位动(2026-09-30 起为宫位依次点亮,不再整张呼吸),失败后退出,参数表不新增骨架。 **符号说明(2026-09-29;当晚改为气泡,BUG-1101)。** `/chart` 开启共享 SVG 的可选交互模式:说明是标记旁的小气泡(`.chart-symbol-bubble`,最宽 220px,`--color-canvas` 底 + 细边 + `--shadow-elevated`),默认在标记上方 8px,离外框顶不足 48px 时翻到下方,左右被夹在外框内;气泡不接收指针,不会挡住旁边的标记。电脑:鼠标悬停出现、移开消失,点击与键盘聚焦同样出现。手机:轻点标记出现,触摸产生的离开事件不关它,点盘外任意处或 3 秒后自动消失。盘下不再有「停在或轻点……」提示行和说明行;读屏器仍从一个隐藏的 `role="status"` 读到同一句话;Esc、离开、失焦可关闭,切盘/人物/数据身份变化清理旧说明。行星与其逆行短线共用一个目标;西洋 caption 和命中圆用一致的行星解析,原点击过滤相位及再次点击取消保留。报告、校正、静态导出默认不启用该模式;纯绘图层不持有 hooks,静态 SVG 白名单不扩。 @@ -1146,7 +1146,7 @@ D40 / D45 / D60 的表格上方,出生时间不是「已校正」时多一句 标题旁的人物切换仍是当前阅读/对话上下文,不替换户主资料,也不是「设为默认」。上次选择可同步预读,但账户与服务端目录绑定完成前,星盘、星历、报告和新建对话不发默认本人的首屏请求;目录暂时失败应可恢复,不假登出,不先闪别人的内容。档案增删改后切换器重新取目录;同一人物不重复广播切换。会话列表更新合并metadata,保留已加载消息、深链与分页外对话;可见列表仅属于当前人物,换账户不保留旧数据。 -档案数量仍本人加最多十九个他人(一共 20 人),无关系字段,本人不可删除。表单添加/编辑标题必须与操作一致。删除确认只显示服务端成功取得的对话/报告数;查询失败时显示「暂时查不到这个人的对话数量,稍后再试」,禁用确认,不能显示0冒充已知。服务端成功后才移除档案并刷新目录;删除当前人物才回到本人。删除未交付报告释放预留点数,已交付/已结算不退,不用客户端猜测退款结果。 +档案数量仍本人加最多十九个他人(一共 20 人),本人不可删除。别人的档案可以标明和户主的关系(伴侣、孩子、父母、朋友、其他);没填过的显示「其他」,本人不记这一项,仍只标「本人」。表单添加/编辑标题必须与操作一致。删除确认只显示服务端成功取得的对话/报告数;查询失败时显示「暂时查不到这个人的对话数量,稍后再试」,禁用确认,不能显示0冒充已知。服务端成功后才移除档案并刷新目录;删除当前人物才回到本人。删除未交付报告释放预留点数,已交付/已结算不退,不用客户端猜测退款结果。 「和TA对话」新会话绑定选中的人物;「和我合盘」中的我保持本人,新会话显式绑定本人但不改变全局选择。普通新建深链用已绑定的当前人物。今日星语的可用性与缓存指纹取当前人物,不能由户主是否有分钟压掉他人的卡片。星历暖进只复用同账户/同人物缓存,首次失败仍显示可重试的静态错误句,不新增spinner、骨架或「正在加载」。跨scope迟到响应不得覆盖当前视图或分页游标。 @@ -1158,11 +1158,12 @@ D40 / D45 / D60 的表格上方,出生时间不是「已校正」时多一句 - **小于 1024px:列表 → 详情。** 同一路由里用 `.people-archive[data-view]` 切换,只显示一栏。点人时压一条 `?person=` 历史记录,浏览器返回和详情顶部的「‹ 返回」走同一条路回到列表;刷新详情地址直接打开那个人。桌面点人不压历史。 - **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-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-10-23):只在编辑或添加别人时出现,放在出生地点之后、性别之前。标题「TA 是你的」,五个选项:伴侣、孩子、父母、朋友、其他。复用性别那一排的尺寸和样式,不新造颜色。没点过就保持空,页面上仍显示「其他」;点了才写入。本人的编辑表单没有这一项。 +- **编辑表单里的性别(选填)**(2026-09-27):放在关系之后、岁差之前(女 / 男 / 不填,44px,一行说明),随「保存」一起提交;选「不填」存为空。自 2026-09-30(BUG-1119)起这是设置性别的唯一入口,个人资料里不再有。 - **删除只在编辑视图底部**,单独一张卡,删除确认、用量计数、查询失败句全部沿用。 - **头像颜色**:本人用 `--color-action-strong` + `--color-on-dark`(同账户首字圆);其他人按 id 稳定地取四个元素色 `--color-element-fire / earth / air / water` 之一,字用 `--color-ink`。不新造色值。 diff --git a/frontend/docs/VOICE.md b/frontend/docs/VOICE.md index 17939e16..84e7a1cc 100644 --- a/frontend/docs/VOICE.md +++ b/frontend/docs/VOICE.md @@ -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,3 +505,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 不写「系统繁忙」「请稍后重试」。计算要等空位时,进度行只写「人有点多,排一下队」。这一轮最终没排上:「人有点多,这一轮没排上,点数已经退回。」对话太密:「这一会儿问得有点密,先停一下。」校正回合太密:「这一会儿校正问得有点密,先停一下。」这一小时报告太多:「这一小时的报告已经够多了,先停一下。」 +## 人物关系(2026-10-23) + +星盘档案里问别人和你的关系,标题只用「TA 是你的」。选项只有:伴侣、孩子、父母、朋友、其他。不写「客户」「家人」来代替这五项。没选过的,名字后面的小标签写「其他」。本人的标签仍是「本人」,不问这一句。 + diff --git a/frontend/src/app/(app)/page.tsx b/frontend/src/app/(app)/page.tsx index 07a9bd5c..7645c155 100644 --- a/frontend/src/app/(app)/page.tsx +++ b/frontend/src/app/(app)/page.tsx @@ -1275,7 +1275,7 @@ export default function Home() { '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`; +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`; export async function GET(request: Request) { try { @@ -59,6 +61,8 @@ 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), diff --git a/frontend/src/app/api/consult/route.ts b/frontend/src/app/api/consult/route.ts index edd51fec..062f24f9 100644 --- a/frontend/src/app/api/consult/route.ts +++ b/frontend/src/app/api/consult/route.ts @@ -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[] = []; diff --git a/frontend/src/app/api/onboarding-state/route.ts b/frontend/src/app/api/onboarding-state/route.ts new file mode 100644 index 00000000..dc89283c --- /dev/null +++ b/frontend/src/app/api/onboarding-state/route.ts @@ -0,0 +1,62 @@ +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); + } +} diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 919936f1..d707bc79 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -5808,8 +5808,9 @@ button.synastry-dd-add { border-radius: 0 0 var(--radius-md) var(--radius-md); } @media print { .chart-page-type-sheet { display: none !important; } } .chart-page-precision-hint, .chart-page-table-note { margin: 0 0 var(--space-2); } -/* A note + table pair is one grid item, so the table keeps its own scroll box. */ -.chart-page-varga-table-block { min-width: 0; } +/* The table row is one grid item. A note and its table stay in one scroll box. */ +.chart-page-varga-table-block, +.chart-page-vedic-tables { min-width: 0; } .chart-page-varga-table-block > .chart-page-planet-table-wrap + .chart-page-planet-table-wrap { margin-top: var(--space-4); } /* A body the Bhava boundaries move to another house. */ .chart-page-planet-table tr.is-shifted > td, @@ -6341,10 +6342,9 @@ button.synastry-dd-add { border-radius: 0 0 var(--radius-md) var(--radius-md); } card with empty sides around a 440px chart. 440 + the card's padding. */ .chart-page-vedic-stage { width: 100%; max-width: calc(440px + 2 * var(--space-3) + 2px); } .chart-page-western-wheel { max-width: 480px; } - /* Chart and parameters sit side by side; the planet table is a full-width - row under both, because its columns need the whole measure. */ - .chart-page-planet-table-wrap, - .chart-page-varga-table-block { grid-column: 1 / -1; } + /* Chart and parameters sit side by side. The third grid item is the table + row, so a wrapper around a table cannot drop it back into the left column. */ + .chart-page-vedic-tables { grid-column: 1 / -1; } .chart-page-basics-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } } diff --git a/frontend/src/components/admin/billing-operations-resources.tsx b/frontend/src/components/admin/billing-operations-resources.tsx index 6dd7b959..666a0160 100644 --- a/frontend/src/components/admin/billing-operations-resources.tsx +++ b/frontend/src/components/admin/billing-operations-resources.tsx @@ -418,6 +418,8 @@ type Usage = { durationMs: number | null; cacheReadTokens: number | null; timing: UsageTimingView | null; + billingSource: string | null; + freeCreditCost: number | null; createdAt: string; }; @@ -436,7 +438,11 @@ export function UsageResource() { { title: "资金来源", dataIndex: "source", - render: (value) => value, + render: (_value, item) => { + if (item.billingSource !== "first_consult_free") return item.source; + const difference = item.freeCreditCost ?? 0; + return difference > 0 ? `首问免费(差价 ${difference} 点)` : "首问免费"; + }, }, { title: "模型", @@ -485,12 +491,12 @@ export function UsageResource() { title="用量、Token 与成本" columns={columns} statusOptions={[ - "subscription", - "credits", - "complimentary", - "chat.standard", - "rectification", - ].map((value) => ({ value, label: value }))} + { value: "subscription", label: "subscription" }, + { value: "credits", label: "credits" }, + { value: "first_consult_free", label: "首问免费" }, + { value: "chat.standard", label: "chat.standard" }, + { value: "rectification", label: "rectification" }, + ]} /> ); } diff --git a/frontend/src/components/chart-page/chart-birth-editor.tsx b/frontend/src/components/chart-page/chart-birth-editor.tsx index 5bd69c43..5557e6ae 100644 --- a/frontend/src/components/chart-page/chart-birth-editor.tsx +++ b/frontend/src/components/chart-page/chart-birth-editor.tsx @@ -88,6 +88,7 @@ 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} diff --git a/frontend/src/components/chart-page/chart-vedic-tab.tsx b/frontend/src/components/chart-page/chart-vedic-tab.tsx index feffb016..3eac0bdb 100644 --- a/frontend/src/components/chart-page/chart-vedic-tab.tsx +++ b/frontend/src/components/chart-page/chart-vedic-tab.tsx @@ -350,7 +350,9 @@ function VedicLayout({ view, chart, ariaLabel, ascendantHouse, children }: { chart can and cannot be read for. */}

{view.vedic.boundary}

- {children} +
+ {children} +
); } diff --git a/frontend/src/components/chart-profile-form.tsx b/frontend/src/components/chart-profile-form.tsx index 78606ec7..c55a2f07 100644 --- a/frontend/src/components/chart-profile-form.tsx +++ b/frontend/src/components/chart-profile-form.tsx @@ -3,6 +3,7 @@ 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"; @@ -16,6 +17,8 @@ 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) => void; readonly onCancel?: () => void; readonly cancelLabel?: string; @@ -34,6 +37,7 @@ export function ChartProfileForm({ nameInputId, showAyanamsa = false, showGender = false, + showRelation = false, onSubmit, onCancel, cancelLabel = "取消", @@ -51,6 +55,13 @@ export function ChartProfileForm({ nameInputId={nameInputId} lockReportedBirthTime={lockReportedBirthTime} /> + {showRelation ? ( + onChange((current) => ({ ...current, relation }))} + /> + ) : null} {showGender ? ( {peopleDisplayName(person.profile, person.role)} - {person.role === "self" ? 本人 : null} + {person.role === "self" + ? 本人 + : {personRelationLabel(person.profile.relation)}} {person.incomplete ? "这份资料还不完整" : peopleRowMeta(person.profile)} @@ -439,6 +442,7 @@ export function PeopleArchiveView({ nameInputId="people-archive-name" showAyanamsa showGender + showRelation={!editingSelf} lockReportedBirthTime={lockReportedBirthTime} onSubmit={(event) => void (editingSelf ? saveSelf(event) : saveOther(event))} onCancel={onCancelEdit} @@ -450,7 +454,7 @@ export function PeopleArchiveView({
-

{peopleDisplayName(selected.profile, selected.role)}

+

{peopleDisplayName(selected.profile, selected.role)}{selected.role === "self" ? null : {personRelationLabel(selected.profile.relation)}}

{selected.role === "self" ? `本人 · ${profilePlaceLabel(selected.profile)}` : profilePlaceLabel(selected.profile)}

diff --git a/frontend/src/components/person-relation-field.tsx b/frontend/src/components/person-relation-field.tsx new file mode 100644 index 00000000..09b084fd --- /dev/null +++ b/frontend/src/components/person-relation-field.tsx @@ -0,0 +1,47 @@ +"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 ( +
+ {PERSON_RELATION_TITLE} +
+ {PERSON_RELATIONS.map((option) => { + const selected = option === shown; + return ( + + ); + })} +
+
+ ); +} diff --git a/frontend/src/hooks/use-consultation-run.ts b/frontend/src/hooks/use-consultation-run.ts index ee06a93c..f8f19442 100644 --- a/frontend/src/hooks/use-consultation-run.ts +++ b/frontend/src/hooks/use-consultation-run.ts @@ -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) { + if (account.credits <= 0 && !account.activeSubscription && !account.firstConsultFree) { if (!openRedeemPaywallOnce()) openAccountDialog("billing", { source: "insufficient-credits" }); return false; } diff --git a/frontend/src/lib/chart-subject-write.ts b/frontend/src/lib/chart-subject-write.ts index 9248cbfb..d99c7a0b 100644 --- a/frontend/src/lib/chart-subject-write.ts +++ b/frontend/src/lib/chart-subject-write.ts @@ -1,4 +1,5 @@ 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"; @@ -89,6 +90,12 @@ 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 = { name, birth_date: birthDate, @@ -110,6 +117,7 @@ export function parseChartSubjectWrite(value: unknown): { ok: true; write: Chart ayanamsa, birth_time_status: status, ...(genderSent ? { gender } : {}), + ...(relationSent ? { relation } : {}), }; const mirror = { name, @@ -174,6 +182,7 @@ export function libraryProfileFromSubjectRow(row: Record): 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 } diff --git a/frontend/src/lib/home-profile.ts b/frontend/src/lib/home-profile.ts index 5ca86d05..1a9f40c7 100644 --- a/frontend/src/lib/home-profile.ts +++ b/frontend/src/lib/home-profile.ts @@ -26,6 +26,7 @@ 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. */ @@ -321,6 +322,9 @@ 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 } : {}), }; } diff --git a/frontend/src/lib/home-types.ts b/frontend/src/lib/home-types.ts index c7ecbfbe..6b0f6c0a 100644 --- a/frontend/src/lib/home-types.ts +++ b/frontend/src/lib/home-types.ts @@ -2,6 +2,7 @@ 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"; @@ -34,6 +35,8 @@ 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"; @@ -144,6 +147,8 @@ 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"; diff --git a/frontend/src/lib/onboarding-state.ts b/frontend/src/lib/onboarding-state.ts new file mode 100644 index 00000000..6029c520 --- /dev/null +++ b/frontend/src/lib/onboarding-state.ts @@ -0,0 +1,92 @@ +/** 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; + 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; + 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 }; +} diff --git a/frontend/src/lib/person-relation.ts b/frontend/src/lib/person-relation.ts new file mode 100644 index 00000000..940eb2c6 --- /dev/null +++ b/frontend/src/lib/person-relation.ts @@ -0,0 +1,25 @@ +/** 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> = { + 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; +} diff --git a/frontend/src/lib/rectification-agentic/v9/case-status.ts b/frontend/src/lib/rectification-agentic/v9/case-status.ts index 9ef6a7ce..5b888624 100644 --- a/frontend/src/lib/rectification-agentic/v9/case-status.ts +++ b/frontend/src/lib/rectification-agentic/v9/case-status.ts @@ -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) diff --git a/frontend/src/lib/rectification-in-chat-server.ts b/frontend/src/lib/rectification-in-chat-server.ts index 2414a299..db275422 100644 --- a/frontend/src/lib/rectification-in-chat-server.ts +++ b/frontend/src/lib/rectification-in-chat-server.ts @@ -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, }); } diff --git a/frontend/src/lib/rectification-in-chat.ts b/frontend/src/lib/rectification-in-chat.ts index f912d4c4..b1a08a09 100644 --- a/frontend/src/lib/rectification-in-chat.ts +++ b/frontend/src/lib/rectification-in-chat.ts @@ -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([ + "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): string { return record.adopted && record.clock diff --git a/frontend/src/lib/rectification-resume-session.ts b/frontend/src/lib/rectification-resume-session.ts new file mode 100644 index 00000000..1c84951a --- /dev/null +++ b/frontend/src/lib/rectification-resume-session.ts @@ -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 { + 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); + } +} diff --git a/frontend/src/lib/subject-birth.ts b/frontend/src/lib/subject-birth.ts index 71204a2c..c22f9149 100644 --- a/frontend/src/lib/subject-birth.ts +++ b/frontend/src/lib/subject-birth.ts @@ -7,6 +7,7 @@ * to the household profile. */ +import { parsePersonRelation, type PersonRelation } from "./person-relation.ts"; import { resolveServerOwnedChartBirth } from "./chart-birth-truth.ts"; import { ACCOUNT_BIRTH_SELECT, @@ -37,9 +38,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"; +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_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 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 MAX_OTHER_SUBJECTS = 19; export const MAX_SAVED_SUBJECTS = 20; @@ -48,6 +49,7 @@ export type ResolvedSubjectBirth = Readonly<{ subjectId: string; role: "self" | "other"; name: string; + relation: PersonRelation | null; row: Record; chartable: boolean; }>; @@ -140,6 +142,7 @@ export async function resolveSubjectBirth(input: { subjectId: SELF_SUBJECT_ID, role: "self", name: text(row.name), + relation: null, row, chartable, }); @@ -195,6 +198,7 @@ export async function resolveSubjectBirth(input: { subjectId, role: "other", name: text(accountRow.name), + relation: parsePersonRelation(row.relation), row: accountRow, chartable, }); diff --git a/frontend/src/mastra/consultation-tools.ts b/frontend/src/mastra/consultation-tools.ts index b2ad3868..7bf15248 100644 --- a/frontend/src/mastra/consultation-tools.ts +++ b/frontend/src/mastra/consultation-tools.ts @@ -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["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: "你的回答下面会出现一张提议卡,写着大约几题、几点。正文最后一段必须有一句提到它(例:「如果想确认出生时间准不准,可以用下面的卡片,答几道过去经历的题核对一下。」),不写价格和题数,不催。" }; diff --git a/frontend/supabase/migrations/20261022190000_onboarding_first_consult_and_relation.sql b/frontend/supabase/migrations/20261022190000_onboarding_first_consult_and_relation.sql new file mode 100644 index 00000000..ec577113 --- /dev/null +++ b/frontend/supabase/migrations/20261022190000_onboarding_first_consult_and_relation.sql @@ -0,0 +1,647 @@ +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; diff --git a/frontend/tests/birth-sky-polish-20260928.test.ts b/frontend/tests/birth-sky-polish-20260928.test.ts index ddaaa83a..dbf3314c 100644 --- a/frontend/tests/birth-sky-polish-20260928.test.ts +++ b/frontend/tests/birth-sky-polish-20260928.test.ts @@ -54,8 +54,11 @@ 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. - 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;/); + // 原值: 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;/); 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" \}\);/); diff --git a/frontend/tests/chart-table-span-20261023.test.tsx b/frontend/tests/chart-table-span-20261023.test.tsx new file mode 100644 index 00000000..677a6d10 --- /dev/null +++ b/frontend/tests/chart-table-span-20261023.test.tsx @@ -0,0 +1,97 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import test from "node:test"; + +import { ChartVedicTab } from "../src/components/chart-page/chart-vedic-tab.tsx"; +import { assembleChartView } from "../src/lib/chart-view-load.ts"; +import { chartViewOkSchema, type ChartViewOk } from "../src/lib/chart-view-contract.ts"; + +Object.assign(globalThis, { React }); + +const golden = JSON.parse(readFileSync(new URL("./fixtures/chart-view-golden.json", import.meta.url), "utf8")); +const bhavaGolden = JSON.parse(readFileSync(new URL("./fixtures/chart-view-bhava-golden.json", import.meta.url), "utf8")); +const transitPacket = JSON.parse(readFileSync(new URL("./fixtures/ephemeris-chart-transit-2026-09-15.golden.json", import.meta.url), "utf8")); +const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); + +const profile = { + name: "示例", + date: "1990-06-15", + time: "12:00", + placeLabel: "北京", + latitude: 39.9042, + longitude: 116.4074, + timezoneOffset: 8, + timezoneId: "Asia/Shanghai", + ayanamsa: "raman", + birthTimeStatus: "confirmed", +}; + +function directDivClasses(html: string, parentClass: string): string[] { + const needle = `
`; + const start = html.indexOf(needle); + assert.ok(start >= 0, parentClass); + let index = start + needle.length; + let depth = 1; + const classes: string[] = []; + while (index < html.length && depth > 0) { + const nextOpen = html.indexOf("", index); + assert.ok(nextClose >= 0); + if (nextOpen >= 0 && nextOpen < nextClose) { + if (depth === 1) { + const tagEnd = html.indexOf(">", nextOpen); + const tag = html.slice(nextOpen, tagEnd + 1); + classes.push(tag.match(/class="([^"]*)"/)?.[1] ?? ""); + } + depth += 1; + index = html.indexOf(">", nextOpen) + 1; + } else { + depth -= 1; + index = nextClose + "
".length; + } + } + return classes; +} + +async function fullView(): Promise { + const result = await assembleChartView({ + userId: "user-1", + profile, + asOf: "2026-09-15", + layers: ["varga", "bhava", "transit"], + postEngine: async (path, _body, cacheScope) => { + if (path === "/api/chart" && cacheScope?.startsWith("transit:")) return { status: "ok", payload: transitPacket }; + if (path === "/api/chart") return { status: "ok", payload: golden.chart }; + if (path === "/api/varga_full") return { status: "ok", payload: golden.varga_full }; + if (path === "/api/bhava_chalit") return { status: "ok", payload: bhavaGolden.bhava_chalit }; + return { status: "http_error", path, elapsedMs: 1, httpStatus: 500 }; + }, + }); + assert.equal(result.body.status, "ok"); + return chartViewOkSchema.parse(result.body); +} + +test("every Vedic chart type puts its table in the layout's full-width row", async () => { + const view = await fullView(); + for (const vargaId of ["D1", "MOON", "D9", "BHAVA", "TRANSIT"]) { + const markup = renderToStaticMarkup(React.createElement(ChartVedicTab, { view, vargaId, onSelectVarga: () => {} })); + const children = directDivClasses(markup, "chart-page-vedic-layout"); + assert.deepEqual(children, [ + "chart-page-vedic-stage", + "chart-page-params", + "chart-page-vedic-tables", + ], vargaId); + } +}); + +test("the desktop chart grid spans the table row, not a table's own class", () => { + const desktopStart = globalStyles.indexOf("@media (min-width: 1024px) {\n .chart-page-vedic-layout,"); + assert.ok(desktopStart >= 0); + const desktopEnd = globalStyles.indexOf("\n}", desktopStart); + const desktop = globalStyles.slice(desktopStart, desktopEnd); + assert.match(desktop, /\.chart-page-vedic-tables \{ grid-column: 1 \/ -1; \}/); + assert.doesNotMatch(desktop, /\.chart-page-planet-table-wrap[\s\S]*grid-column/); + assert.doesNotMatch(desktop, /\.chart-page-varga-table-block[\s\S]*grid-column/); +}); diff --git a/frontend/tests/chat-navigation-a11y-contract.test.ts b/frontend/tests/chat-navigation-a11y-contract.test.ts index 4d28c855..d8801369 100644 --- a/frontend/tests/chat-navigation-a11y-contract.test.ts +++ b/frontend/tests/chat-navigation-a11y-contract.test.ts @@ -59,9 +59,12 @@ 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) {", + "if (account.credits <= 0 && !account.activeSubscription && !account.firstConsultFree) {", "const [year, month, day]", ); const sendSource = pageSource.slice(pageSource.indexOf(" async function send(")); diff --git a/frontend/tests/database-onboarding-journey.test.ts b/frontend/tests/database-onboarding-journey.test.ts new file mode 100644 index 00000000..55c6df81 --- /dev/null +++ b/frontend/tests/database-onboarding-journey.test.ts @@ -0,0 +1,142 @@ +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(); + } +}); diff --git a/frontend/tests/database-rectification-resume-session-20261023.test.ts b/frontend/tests/database-rectification-resume-session-20261023.test.ts new file mode 100644 index 00000000..fd694e58 --- /dev/null +++ b/frontend/tests/database-rectification-resume-session-20261023.test.ts @@ -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(); + } +}); diff --git a/frontend/tests/onboarding-journey-data.test.ts b/frontend/tests/onboarding-journey-data.test.ts new file mode 100644 index 00000000..a8922869 --- /dev/null +++ b/frontend/tests/onboarding-journey-data.test.ts @@ -0,0 +1,82 @@ +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); +}); diff --git a/frontend/tests/people-archive-view.test.tsx b/frontend/tests/people-archive-view.test.tsx index caff8fe9..bd21e605 100644 --- a/frontend/tests/people-archive-view.test.tsx +++ b/frontend/tests/people-archive-view.test.tsx @@ -106,6 +106,30 @@ test("D2 list rows: avatar, name, 本人 tag, one monospace meta line, chevron", assert.match(markup, /class="people-archive-hint">还可以保存 18 个人 { + assert.match(render(), /示例乙<\/b>其他<\/span>/); + const childPeople = [people[0]!, { ...people[1]!, profile: { ...otherProfile, relation: "child" as const } }]; + assert.match(render({ people: childPeople }), /示例乙<\/b>孩子<\/span>/); + const detail = render({ + view: "detail", + people: childPeople, + selected: childPeople[1], + selectedId: childPeople[1]!.id, + }); + assert.match(detail, /

示例乙孩子<\/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 selfCurrent = render(); assert.match(selfCurrent, /class="people-archive-row is-current[^"]*" aria-current="true"[\s\S]*示例甲/); diff --git a/frontend/tests/profile-gender-20260927.test.ts b/frontend/tests/profile-gender-20260927.test.ts index ebc953a4..507dfb30 100644 --- a/frontend/tests/profile-gender-20260927.test.ts +++ b/frontend/tests/profile-gender-20260927.test.ts @@ -85,7 +85,10 @@ 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 \}/); - assert.match(route, /rectification_case_id,gender"\)/, "GET returns the stored 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"); }); test("people writes carry gender only when sent; reads expose it only when stored", () => { diff --git a/frontend/tests/profile-gender-ui-20260927.test.tsx b/frontend/tests/profile-gender-ui-20260927.test.tsx index 35730c1d..c99eb5b8 100644 --- a/frontend/tests/profile-gender-ui-20260927.test.tsx +++ b/frontend/tests/profile-gender-ui-20260927.test.tsx @@ -108,7 +108,13 @@ test("people edit form: the gender field is part of the form and writes the draf const editing = renderToStaticMarkup(); - assert.deepEqual(radios(editing), ["女:false", "男:true", "不填:false"]); + // 原值: ["女:false", "男:true", "不填:false"] + // 新值: 性别单选前面多一组「TA 是你的」(空关系显示为已选「其他」) + // 原因: 档案编辑他人时要能补关系(#95 T2);性别单选本身没改 + assert.deepEqual(radios(editing), [ + "伴侣:false", "孩子:false", "父母:false", "朋友:false", "其他:true", + "女:false", "男:true", "不填:false", + ]); }); test("detail card: 性别 shows the stored value, 未填 when empty; 本人 edits it in the same form as everyone", () => { diff --git a/frontend/tests/rectification-explicit-entry-20261012.test.ts b/frontend/tests/rectification-explicit-entry-20261012.test.ts index b0a86dd3..34436ca7 100644 --- a/frontend/tests/rectification-explicit-entry-20261012.test.ts +++ b/frontend/tests/rectification-explicit-entry-20261012.test.ts @@ -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): Promise { + 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;/); diff --git a/frontend/tests/rectification-in-chat-20261007.test.ts b/frontend/tests/rectification-in-chat-20261007.test.ts index 9c515310..d7c40429 100644 --- a/frontend/tests/rectification-in-chat-20261007.test.ts +++ b/frontend/tests/rectification-in-chat-20261007.test.ts @@ -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 }); }); diff --git a/frontend/tests/rectification-v9-contracts.test.ts b/frontend/tests/rectification-v9-contracts.test.ts index a0f710e2..2da1c6a3 100644 --- a/frontend/tests/rectification-v9-contracts.test.ts +++ b/frontend/tests/rectification-v9-contracts.test.ts @@ -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", () => {