Compare commits

...
Author SHA1 Message Date
jesse-ux 51a7772128 fix(consult): mark rectification opened only after the case opens (BUG-1306) 2026-10-12 14:41:54 +08:00
jesse-uxandJesse_Chen b8bdc003c3 fix(consult): open rectification when the user asks directly (BUG-1306)
Independent Staging Quality Gate / validate (push) Successful in 19m41s
Independent Staging Quality Gate / publish (push) Successful in 3m52s
2026-10-12 14:17:05 +08:00
Jesse_ChenandClaude Opus 5.5 9f1e8257ae docs(tasks): acceptance of the 10-12 rectification batch and its fix brief (BUG-1306/1307/1308, open BUG-1309)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HwMNZEzdzmrbPhuRNmZe3C
2026-10-12 13:56:52 +08:00
Jesse_ChenandClaude Opus 5.5 e6baff952c docs(tasks): rectification explicit entry, nearest-boundary opening, D12 parent probe research (BUG-1306~1308)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HwMNZEzdzmrbPhuRNmZe3C
2026-10-12 11:31:33 +08:00
Jesse_ChenandClaude Opus 5.5 fddddaabb3 docs(bug-history): record BUG-1306 explicit rectification request does not enter (investigating)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HwMNZEzdzmrbPhuRNmZe3C
2026-10-11 17:57:45 +08:00
Jesse_ChenandClaude Opus 5.5 de4cfeb868 docs(consult): accept the career residual round as a known residual
Independent Staging Quality Gate / validate (push) Successful in 34m56s
Independent Staging Quality Gate / publish (push) Successful in 16m11s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-10-09 16:53:34 +08:00
jesse-ux d6eb0cfec1 fix(consult): keep career row hints off the planet nature
Career significator and house-lord rows now carry one presentation sentence. Values stay the same. A second three-repeat biography run still has one field conflict, so this is not accepted.

Not pushed.
2026-10-09 16:25:59 +08:00
jesse-ux 85c3030e96 fix(consult): limit career direction and single-flight chart warm
The career checklist now says the direction section only covers strength, resistance, timing, and three events. Warm builds one packet per cache key and skips when its own cap is full.

A three-repeat biography run still has one career answer that names a direction from a planet nature, so this is not accepted. Not pushed.
2026-10-09 15:53:51 +08:00
Jesse_ChenandClaude Opus 5.5 b4eedb7725 fix(consult): a warm that cannot be scheduled no longer fails the save (BUG-1305)
Independent Staging Quality Gate / validate (push) Successful in 13m12s
Independent Staging Quality Gate / publish (push) Successful in 4m16s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-10-09 15:17:00 +08:00
Jesse_ChenandClaude Opus 5.5 cd625e8a35 docs(consult): accept the full-source card and open the career-type residual brief
Independent Staging Quality Gate / validate (push) Failing after 16m54s
Independent Staging Quality Gate / publish (push) Skipped
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-10-09 14:08:57 +08:00
38 changed files with 2250 additions and 116 deletions
+6
View File
@@ -1,5 +1,11 @@
# BLOCKED
## TASK-rectification-explicit-entry-20261012:T0 日志、T5 模型(BUG-1306,2026-10-12)
- **T0**:本 worktree 没有 staging 主机,读不到 web 容器 `docker logs`,也没有数据库。无法核对 10-11 那一轮是否调用了 `offer-birth-time-rectification`。不阻塞实现。产品可照做的查法写在 `docs/tasks/PROGRESS-rectification-explicit-entry-20261012.md`,只看工具名、status,以及该会话 messages 里有没有 `rectificationOffer`。
- **T5**:供应商 API key 在管理端数据库里加密保存,环境只留 `MODEL_PROVIDER_CONFIG_ENCRYPTION_KEY`。本机没有可用数据库,不能解密,也不调用模型。八个场景写成 `docs/testing/rectification-explicit-entry-20261012.md`。
- **`next build`**:默认 Turbopack 拒绝 worktree 的 `frontend/node_modules` junction(指向文件系统根之外)。`npm run build -- --webpack` 编译与 TypeScript 已过,收集 page data 时 Windows 不能为 Skill 建符号链接(EPERM)。没有路由表,`/` 是否仍 Static、首屏 gzip 未测。没有 `npm install`,没有删 junction,没有改 next 配置。
## TASK-astrologer-rulings-batch5:Rath 双主星 p.43、名人生平回测、`next build`、真机(2026-10-04)
- **Rath 双主星按 p.43 (a)–(e)(任务书 T1,BUG-1227)**:照裁定实现后原书软件表双主星 8/10 → 9/10,但 Table 17 大运年数 12/12 → 10/12(天蝎 10 → 1、水瓶 8 → 1),低于验收底线,按红线 2 停下、未落地。原书标准盘 Table 17 / Chart 3 / p.43 (e) 例与同书 Table 23 软件判定互相冲突,需产品与占星师二选一(见进度记录 T1 节)。替代证据:试算脚本与三种读法对照表。
+23 -3
View File
@@ -1,12 +1,32 @@
# 印度占星 Skill 更新日志
## 2026-10-09 — 事业清单写明代表星不猜职业,预热不再占重计算名额(回测未达标,未推送,未合入,未上线)
## 2026-10-12 — 校正还没打开时,卡片不说已经开始(待验收)
- 明确要求做校正时,对话里的卡片在真正打开并绑定之前仍是普通提议,按钮是「开始核对出生时间」。打开失败可以再点,不会写成「已经开始」。Skill 版本不变。未推送,未部署。
## 2026-10-12 — 对话里明说要校正就直接进入(待验收)
- 用户这一轮明确要求做生时校正时,直接打开校正,不先写解读、不用点卡。推得不准时的提议仍走卡片。做不了会用一句话说明,不再沉默。普通对话不承诺收窄出生时间,申报时段不写大运、子运的起止年月日。
## 2026-10-09 — 事业判断表旁边注明不按星性写方向(已合入 staging;回测仍有 1 次,记为已知残余)
- 事业判断表里,自然代表星、Jaimini 代表星、大运主星和宫主这几行旁边加了一句:只看力量与受冲,不按这颗星的本性写职业类型、做事方式或方向。其他领域不加。行和取值没有改。系统提示没有改。
- 9 人 × 4 个领域 × 3 次,108 份都有回答。严重冲突仍是 1,对照组误报 0。27 份事业题里仍有 1 份把谋生写成靠能力和表达。不标待验收。Skill 版本不变。未推送,未合入,未上线。
## 2026-10-09 — 事业方向只写力量、阻力和时间;预热同一份资料只算一次(已合入 staging)
- 事业清单写明:「方向」只写力量、阻力、时间,以及机会出现、成形、公开落地这三件事。职业类型和工作方式仍问用户。系统提示没有改。判断表的行和取值没有改。
- 保存后的预热按同一张盘、同一天只算一次。同时最多预热 1 份,满了就跳过,不会让紧接着的提问收到 429。预热失败不影响保存。
- 生平回测 108 份,严重冲突 1,对照组误报 0。27 份事业题里仍有 1 份把方向写成信息和表达,这一版不标待验收。Skill 版本不变。
## 2026-10-09 — 事业清单写明代表星不猜职业,预热不再占重计算名额(已合入 staging)
- 事业清单加一条:判断表里的自然代表星、Jaimini 代表星、大运主星只看力量和受冲,不按星的本性推断职业类型、做事方式或台前 / 幕后。职业类型仍问用户。系统提示没有改。
- 保存后的预热仍走原来的咨询入口,但不再占用重计算名额,避免刚保存就提问时收到 429。预热失败不影响保存。
- 生平回测严重冲突从 3 降到 1,对照组误报仍是 0。齐达内的事业题还有一次按星性写方向,不合入。Skill 版本不变。
- 生平回测严重冲突从 3 降到 1,对照组误报仍是 0。齐达内的事业题还有一次按星性写方向,产品按严重冲突 ≤ 1 放行,另开单处理。Skill 版本不变。
- 保存人物或本人资料时,后台预热安排不上也不会再让保存报错(BUG-1305)。
## 2026-10-09 — 对话卡按提问当天取数,模型只看见数值(未推送,未合入,未上线)
## 2026-10-09 — 对话卡按提问当天取数,模型只看见数值(已合入 staging)
- 同一张盘隔天再问,当前大运、行运和年盘按提问当天重算,不再冻在第一次计算的那天。超过两天的缓存文件会清掉。
- 送给模型的判断表只留数值和缺口。内部路径留在服务端。三张公开盘、十二个领域都不超过 2 万字符,最大 18,193。受冲仍只计数。
+128 -21
View File
@@ -17330,7 +17330,7 @@
## BUG-1293 | 对话数据卡自己拼了一套数,没有综合判断表
- 状态:resolved(分支 `codex/consult-card-full-source-20261007`,本地未推送、未部署;有针对性回归测试)
- 状态:resolved(分支 `codex/consult-card-full-source-20261007`,已快进合入 staging;有针对性回归测试)
- 首次发现 / 最近更新:2026-10-07 / 2026-10-09
- 来源:产品反馈对话「只看本命盘或 D10」;任务书 `TASK-consult-card-full-source-20261007`。
- 影响面:普通对话证据卡的领域段。旧的基础投影仍在,避免首轮回答的字段名变化。新的权威数字是领域段上的综合判断表。
@@ -17342,11 +17342,11 @@
- 防复发:上述测试;事实 JSON 若含 `tajika_named_yogas` 直接拒绝(对话年运仍读 `tajika_yogas`,BUG-1282)。
- 相关记录:BUG-1160、BUG-1161、BUG-1221、BUG-1282。
- 复发自:无。
- 修复版本:本分支提交。未部署。
- 修复版本:本分支提交,2026-10-09 快进合入 staging。
## BUG-1294 | 对话卡外补取每轮只能 1 次
- 状态:resolved(分支 `codex/consult-card-full-source-20261007`,本地未推送、未部署;有针对性回归测试)
- 状态:resolved(分支 `codex/consult-card-full-source-20261007`,已快进合入 staging;有针对性回归测试)
- 首次发现 / 最近更新:2026-10-07 / 2026-10-09
- 来源:产品 2026-10-08 确认把卡外补取上限从 1 改为 3。
- 影响面:`read-consultation-evidence`。第 4 次仍拒绝。KP 补取仍带参考说明,不作结论。
@@ -17358,11 +17358,11 @@
- 防复发:该测试锁上限 3 与第四次拒绝。
- 相关记录:BUG-1293、BUG-1256。
- 复发自:无。
- 修复版本:本分支提交。未部署。
- 修复版本:本分支提交,2026-10-09 快进合入 staging。
## BUG-1295 | 对话进一步阅读指向 oracle 与治理文件
- 状态:resolved(分支 `codex/consult-card-full-source-20261007`,本地未推送、未部署;有针对性回归测试)
- 状态:resolved(分支 `codex/consult-card-full-source-20261007`,已快进合入 staging;有针对性回归测试)
- 首次发现 / 最近更新:2026-10-07 / 2026-10-09
- 来源:任务书 T5。产品确认 `further_reading` 改为按领域的技法资料目录。
- 影响面:普通对话工具结果里的 `methodology.further_reading`。不接书籍语料,不做每轮联网。
@@ -17374,11 +17374,11 @@
- 防复发:该测试。
- 相关记录:BUG-1293。
- 复发自:无。
- 修复版本:本分支提交。未部署。
- 修复版本:本分支提交,2026-10-09 快进合入 staging。
## BUG-1296 | 对话资料包缓存键没有参照日期
- 状态:resolved(分支 `codex/consult-card-full-source-20261007`,本地未推送、未部署;有针对性回归测试)
- 状态:resolved(分支 `codex/consult-card-full-source-20261007`,已快进合入 staging;有针对性回归测试)
- 首次发现 / 最近更新:2026-10-09 / 2026-10-09
- 来源:修复单 F1。Claude 验收:缓存键不含日期,后一次命中把当前大运、行运和年盘冻在第一次计算的那天。
- 影响面:普通对话使用的完整资料包缓存。同一张盘隔天再问,可能仍用第一天的当前大运。
@@ -17390,11 +17390,11 @@
- 防复发:上述测试。重复跑之前先删掉这两把自己的缓存文件,避免上次留下的文件被当成命中。
- 相关记录:BUG-1251。
- 复发自:无。
- 修复版本:本分支提交。未部署。
- 修复版本:本分支提交,2026-10-09 快进合入 staging。
## BUG-1297 | 送给模型的判断表带上内部路径,整卡超过两万字符
- 状态:resolved(分支 `codex/consult-card-full-source-20261007`,本地未推送、未部署;有针对性回归测试)
- 状态:resolved(分支 `codex/consult-card-full-source-20261007`,已快进合入 staging;有针对性回归测试)
- 首次发现 / 最近更新:2026-10-09 / 2026-10-09
- 来源:修复单 F2。Claude 验收:乔布斯事业卡由真实事实端到端组出后为 37,507 字符,判断表 27,159,参与清单 5,447。每格带有 status、path、check、value,内部路径进了模型。
- 影响面:普通对话送给模型的证据卡。服务端副本仍保留路径,供核对。
@@ -17406,11 +17406,11 @@
- 防复发:该测试。手写事实的组装测试只锁组装规则,并在注释里写明它不代替金样。
- 相关记录:BUG-1160、BUG-1161、BUG-1221。临时上限仍是卡 20,000、卡加清单 26,000。
- 复发自:无。
- 修复版本:本分支提交。未部署。
- 修复版本:本分支提交,2026-10-09 快进合入 staging。
## BUG-1298 | 十二个领域的行星相位整列记成 blocked
- 状态:resolved(分支 `codex/consult-card-full-source-20261007`,本地未推送、未部署;有针对性回归测试)
- 状态:resolved(分支 `codex/consult-card-full-source-20261007`,已快进合入 staging;有针对性回归测试)
- 首次发现 / 最近更新:2026-10-09 / 2026-10-09
- 来源:修复单 F3。Claude 验收:参与层因为路由器没有把行星相位列成一层,十二个领域都是 blocked。
- 影响面:普通对话判断表的「照」。不新造罗睺、计都向外的相位。
@@ -17422,11 +17422,11 @@
- 防复发:`tests/test_consult_card_full_source.py` 的公开盘测试。
- 相关记录:BUG-1155。
- 复发自:无。
- 修复版本:本分支提交。未部署。
- 修复版本:本分支提交,2026-10-09 快进合入 staging。
## BUG-1299 | 对话卡合同测试用手工事实,不是引擎金样
- 状态:resolved(分支 `codex/consult-card-full-source-20261007`,本地未推送、未部署;有针对性回归测试)
- 状态:resolved(分支 `codex/consult-card-full-source-20261007`,已快进合入 staging;有针对性回归测试)
- 首次发现 / 最近更新:2026-10-09 / 2026-10-09
- 来源:修复单 F4。合同测试的形状必须来自真实引擎。
- 影响面:`frontend/tests/consult-card-full-source-20261007.test.ts`。手写组装测试保留,只锁组装规则。
@@ -17438,11 +17438,11 @@
- 防复发:测试直接读这份金样。重新生成用上面的命令,不手改数字。
- 相关记录:BUG-1297。
- 复发自:无。
- 修复版本:本分支提交。未部署。
- 修复版本:本分支提交,2026-10-09 快进合入 staging。
## BUG-1300 | 新建或修改星盘后没有预热对话资料包
- 状态:resolved(分支 `codex/consult-card-full-source-20261007`,本地未推送、未部署;有针对性回归测试)
- 状态:resolved(分支 `codex/consult-card-full-source-20261007`,已快进合入 staging;有针对性回归测试)
- 首次发现 / 最近更新:2026-10-09 / 2026-10-09
- 来源:修复单 F5。前序任务书要求星盘建好或出生资料改过之后,后台预热当天的资料包。
- 影响面:星盘新建、星盘修改、本人出生资料保存。保存响应不等待计算。
@@ -17454,11 +17454,11 @@
- 防复发:上述两个测试。lint 对该测试 0 error。
- 相关记录:BUG-1251、BUG-1296。
- 复发自:无。
- 修复版本:本分支提交。未部署。
- 修复版本:本分支提交,2026-10-09 快进合入 staging。
## BUG-1301 | 事业判断表的代表星被读成职业类型
- 状态:investigating(分支 `codex/consult-card-full-source-20261007`,清单已加一条,回测仍有 1 次;未推送、未合入、未部署)
- 状态:mitigated(分支 `codex/consult-card-full-source-20261007`,已快进合入 staging;回测严重冲突回到 1,与合入前 staging 相同;剩余 1 次转 BUG-1303)
- 首次发现 / 最近更新:2026-10-09 / 2026-10-09
- 来源:修复单 G1。前一轮生平回测严重冲突 1 → 3,新增的两次都是同一位公开球星的事业题。
- 影响面:普通咨询的事业回答。判断表仍保留代表星、旺弱和功能吉凶,没有删行,也没有改取值。
@@ -17467,14 +17467,14 @@
- 根因:既有「不猜行业、问用户」没有写明这些代表星只能看力量和受冲。模型用星的本性补上了职业类型。
- 修复:事业清单加一条,不进系统提示。本轮回测严重冲突 3 → 1,对照组误报仍是 0。齐达内事业两次里仍有一次按星性写方向,所以这条不标 resolved。
- 验证:`frontend/tests/consult-condensed-checklist-20260927.test.ts` 锁住这句,并锁住系统提示源码切片仍是 15,291 字符。生平回测 72 份、失败 0。阅读评分见 `docs/tasks/PROGRESS-consult-card-full-source-fix2-20261009.md`。
- 防复发:上述测试。合入仍要求严重冲突 ≤ 1,且齐达内事业两次都不再按星性写职业类型。
- 防复发:上述测试。产品 2026-10-09 按「严重冲突 ≤ 1」放行合入;齐达内事业第 2 次仍按星性写方向,与合入前 staging 的泰勒事业第 2 次同属行业类型冲突,另立 `TASK-consult-career-type-residual-20261009.md`(BUG-1303)。
- 相关记录:BUG-1176。
- 复发自:无。产品 2026-10-03「不猜行业、问用户」没有拦住判断表上的星性。
- 修复版本:清单已写进本分支。回测未达标。未部署。
- 修复版本:本分支提交,2026-10-09 快进合入 staging。
## BUG-1302 | 保存后的预热占用重计算名额
- 状态:resolved(分支 `codex/consult-card-full-source-20261007`,本地未推送、未合入、未部署;有针对性回归测试)
- 状态:resolved(分支 `codex/consult-card-full-source-20261007`,已快进合入 staging;有针对性回归测试)
- 首次发现 / 最近更新:2026-10-09 / 2026-10-09
- 来源:修复单 G2。
- 影响面:星盘保存成功后的后台预热,以及紧接着的用户提问。保存响应本身仍然不等待预热。
@@ -17486,4 +17486,111 @@
- 防复发:上述并发测试。
- 相关记录:BUG-1300。
- 复发自:无。
- 修复版本:本分支提交。未部署。
- 修复版本:本分支提交,2026-10-09 快进合入 staging。
## BUG-1303 | 事业题仍偶发按星性写方向(BUG-1301 残余)
- 状态:mitigated(已知残余,产品 2026-10-09 接受;清单句 + 判断表呈现句已快进合入 staging;生平回测 3 次抽样事业 27 份中行业类型冲突 1 份,均为同一张公开人物盘)
- 首次发现 / 最近更新:2026-10-09 / 2026-10-09
- 来源:BUG-1301 修复后的生平回测。
- 影响面:普通咨询事业回答。
- 现象:上一轮 18 份事业回答中 1 份把方向写成「知识、表达、信息和资源」一类。清单句那一轮 27 份里仍有 1 份把方向写成信息和表达。判断表提示这轮 27 份里仍有 1 份把谋生写成靠能力和表达。都与公开人物的实际职业类型不符。
- 触发条件:判断表多处点名同一颗代表星;模型抽样波动。
- 根因:清单句只规定「方向」写什么,模型仍会从事业宫主和自然代表星的星性写谋生方式。行旁的呈现句没有把这 1 次消掉。
- 修复:事业清单把「方向」限定为力量、阻力、时间与三件事。事业判断表的自然代表星、Jaimini 代表星、大运主星、Narayana 主星和宫主行旁边加一句呈现文字,其他领域不加。不进系统提示,不删行,不改取值。两次回测都没有把行业类型冲突降到 0,所以不标 resolved。
- 验证:`frontend/tests/consult-condensed-checklist-20260927.test.ts` 锁住清单句,系统提示源码切片仍是 15,291 字符。`frontend/tests/consult-card-full-source-20261007.test.ts` 锁住呈现句只出现在事业表,重复行仍留这句,行星取值不变。第二次生平回测 108 份、失败 0。阅读评分见 `docs/tasks/PROGRESS-consult-career-type-residual-20261009.md`:严重冲突 1,行业类型冲突 1,对照组误报 0。
- 防复发:上述测试。评分口径没有改。任务书允许的两处都用过了,3 次抽样仍有 1 次,按让步顺序停下,不再加规则。产品 2026-10-09 选择合入并记为已知残余,等真实用户反馈再定是否另开单。
- 相关记录:BUG-1301、BUG-1176。
- 复发自:BUG-1301(残余,非新问题)。
- 修复版本:本分支提交,2026-10-09 快进合入 staging。
## BUG-1304 | 保存后马上提问,预热与用户请求重复计算同一份资料包
- 状态:resolved(分支 `codex/consult-career-type-residual-20261009`,已快进合入 staging;有针对性回归测试)
- 首次发现 / 最近更新:2026-10-09 / 2026-10-09
- 来源:Claude 验收 BUG-1302 时读代码发现。
- 影响面:保存星盘后立刻提问的第一轮等待时长与服务器 CPU。不会因此返回 429。
- 现象:同一张盘同一天的资料包可能被并行算两次;多次保存时预热无上限并行。
- 触发条件:预热未完成时同一张盘的提问到达,或短时间多次保存。
- 根因:`get_or_build` 没有按缓存键单飞;预热跳过重计算名额后没有自己的上限。
- 修复:同一个缓存键只构建一次,后到的请求等待并读取这份结果。预热单独限流,同时最多 1 份,满了直接跳过。用户请求不占这个名额,也不会因为预热收到 429。不新增 API 类方法,`jyotish_api_server.py` 没有增长(仍是 10,970 行)。
- 验证:`tests/test_consult_card_full_source.py` 在预热进行时发同键用户请求,构建次数是 1;三份不同键的预热只有 1 次构建,另外两份跳过且响应里没有资料包。`tests/test_api_heavy_compute_gate.py` 与 `tests/test_api_server_growth_contract.py` 通过。
- 防复发:上述并发测试。
- 相关记录:BUG-1300、BUG-1302。
- 复发自:无。
- 修复版本:本分支提交,2026-10-09 快进合入 staging。
## BUG-1305 | 保存星盘后安排预热抛错,已写库的保存返回 500(staging 门禁 run 1784 红)
- 状态:resolved(Claude 直接修复,产品 2026-10-09 授权;推 staging 后由门禁真实 PostgreSQL 套件复核)
- 首次发现 / 最近更新:2026-10-09 / 2026-10-09
- 来源:staging `backend-quality-gate` run 1784(`cd625e8a`)唯一失败:`database-profile-gender.test.ts`「people and account routes read and write gender on real PostgreSQL without cross-person leaks」,`500 !== 200`。
- 影响面:新建人物、修改人物、修改本人资料三个保存接口(`/api/chart-profiles`、`/api/chart-profiles/[id]`、`/api/account`)。在 Next 正常请求里 `after` 可用,线上不必然出现;但只要安排预热失败,用户会看到保存失败而数据其实已经写进去。
- 现象:数据库写入成功后接口仍返回 500。
- 触发条件:`scheduleConsultPacketWarm` 用默认的 Next `after`,在没有请求作用域时调用(数据库测试直接调路由函数)。
- 根因:BUG-1300 加的预热只对 fetch 失败兜底,没有对 `after` 本身抛错兜底;抛错落进路由的 catch,变成 500。违反修复单红线「预热失败不影响保存」。验收时本机无 Docker,数据库套件全部 skip,所以本机和执行方都没看到。
- 修复:`frontend/src/lib/consult-packet-warm.ts` 安排预热包 try/catch,失败返回 false,保存照常返回。
- 验证:`frontend/tests/consult-packet-warm-20261009.test.ts` 新增「a warm that cannot be scheduled does not break the save」(修复前默认依赖直接抛错);本机 `npm test` 4,999 / 失败 24 与基线逐名相同,tsc 0、lint 0;数据库套件由门禁真实 PostgreSQL 复核。
- 防复发:上述测试;验收凡改到 `database-*.test.ts` 会调用的路由,推 staging 前用本机 PostgreSQL 替身跑一遍数据库套件。
- 相关记录:BUG-1300、BUG-1302、BUG-1118(同类:数据库套件只在门禁里跑)。
- 复发自:无。
- 修复版本:本提交。
## BUG-1306 | 对话里明说「帮我做生时纠正」进不了校正:模型不出提议卡,反而在普通对话里自己「收窄出生时间」
- 状态:fixed-pending-verify(F3 跟进在 `codex/rectification-offer-opened-state-20261012`,未 push,未部署;不是 resolved。T0 无主机日志,T5 无模型凭据)
- 首次发现 / 最近更新:2026-10-11 / 2026-10-12
- 来源:产品在 staging(`de4cfeb8`)新对话第一句输入「请你帮我进行生时纠正」,回复截图转述。
- 现象:回复显示「已完成 3 步」,正文说「收窄出生时间这件事能做」,要用户报有年月的经历,「对得上,区间就能往里收」;全文没有提到提议卡,界面上也没有卡片。正文还按「代表分钟」写出大运 / 子运起止年月。
- 触发条件:申报出生时段的本人档案;首轮;用户明确要求做校正。
- 已确认事实(代码,`origin/staging` `de4cfeb8`):
1. BUG-1247 删除首页入口与文字跳转后,新用户进校正只剩一条路:咨询模型调用 `offer-birth-time-rectification` → 服务端 `decideRectificationOffer` 放行 → 回答下出卡 → 用户点卡。
2. 申报时段路径(`createWindowConsultationAgentContext`)确实挂了该工具,指令里也有 `RECTIFICATION_OFFER_RULE`;规则以「用户说你推得不准时」开头,「明确想核对出生时间」只是其中一个分支。
3. BUG-1248 上线前的模型实测只覆盖「不认同解释 / 过去的事对不上 + 时间是估的 / 家人 / 已提议又被拒」四种说法,没有「首轮直接点名要校正」。
4. 服务端拒绝原因中 `product_disabled`、`pricing_unavailable` 要求正文「不要提校正」,用户看到的是没有回应。
- 推断(未证实):正文主动承诺能收窄,不像收到「不要提校正」的拒绝,更像模型没有调用提议工具;无模型凭据、无 staging 运行日志,暂未取证。另:用户若照做在普通对话里报经历,这些经历不会进入校正引擎计分,正文「区间就能往里收」是做不到的承诺;按代表分钟写大运起止,与申报时段指令「精确应期一律不可用」不符。
- 待做:取该轮观测记录确认是否调用了工具及服务端结果(任务书 T0;观测只打到 web 容器控制台,需 staging 主机日志)。本轮没有主机和数据库,查法见进度记录,不阻塞修复。
- 修复:不新建工具。`offer-birth-time-rectification` 增加 `explicitRequest`。这一轮明确要校正由模型填写;`userAsked` 仍是「提议过之后用户自己再要求才出卡」。服务端判定仍是 `decideRectificationOffer`(本人、开关、无进行中 case、有价)。通过则记下与提议卡同结构、状态直接为 `opened`、尚无 caseId 的记录,`run.completed` 带 `rectificationDirectOpen`,浏览器调用已有的 `openRectificationFromChat`。同一轮已有直达或已有提议时不再开第二次。`bindOfferToCase` 也会绑定这种还没有 caseId 的 `opened` 记录,刷新后按 caseId 找回。进行中的 case 不新开:查到 `agentic_rectification_cases.session_id` 就打开那次,查不到就说可以在历史对话里接着做。`product_disabled` 与 `pricing_unavailable` 对用户是同一句「出生时间核对这会儿还做不了。」家人仍是「给家人校正暂时还不支持」。四种拒绝说明都不再写「不要提校正」。没有明确要求时的拒绝仍走原来的排盘回答。直达和明确拒绝把首轮合同视为就绪,不跑「运行合同不完整」重试、不跑空答重试、不跑 Pass4,已流出的正文换成那一句。这一轮结算走 `complete_consultation_free`,不走 `complete_consultation_response`;新开时再把提议补到会话消息上,不改请求行。规则仍只定义在 `RECTIFICATION_OFFER_RULE`:不承诺收窄出生时间,不在对话里收集经历做校正。申报时段指令补「大运、子运只说在哪一段附近,不写起止年月日」。不改校正引擎、打分、闸门和数据库。
- 跟进(F3,2026-10-12,未部署):结算不再把记录写成 `opened`。直达通过仍记同结构提议,状态保持 `offered`,只在要自动开案时带 `autoOpen: true`(省略该字段的旧记录照常解析)。卡片在开案并绑定来源之前是普通提议:价格句和「开始核对出生时间」,不说「已经开始」。浏览器仍按 `rectificationDirectOpen` 自动开案,成功路径不用点。只有 `bindOfferToCase` 在开案成功、来源绑定后把状态改成 `opened`。打开失败不把本地记录改成 `opened`,卡片可再点。不新增第四种状态。`complete_consultation_free` 与五句用户文案未改。
- 验证:`npx tsc --noEmit` 0 错。`npm run lint` 0 error(125 条既有 warning)。定向 `rectification-explicit-entry-20261012` 与 `rectification-in-chat-20261007` 26 通过。合同套件 236 条里 235 通过;唯一失败是 Skill 符号链接 EPERM。`CI=1 npm test` 4922 条,通过 4780,失败 101,cancelled 0;失败在本轮没改的文件(无 Docker、符号链接、WSL、路径拼接、junction 下的依赖解析)。telemetry 合同按直达结算改了一条断言,改完 4 条通过。默认 `next build` 因 worktree `node_modules` junction 被 Turbopack 拒绝;`npm run build -- --webpack` 编译与 TypeScript 已过,收集 page data 时 Skill 符号链接 EPERM,没有路由表,`/` Static 与首屏 gzip 未测。T5 未跑模型。
- 防复发:`frontend/tests/rectification-explicit-entry-20261012.test.ts` 锁住直达、同一轮不重复开案、无 caseId 的 opened 绑定与找回、四种拒绝说明、两条路径不重试、普通咨询仍重试一次、规则只定义一处、申报时段「在哪一段附近」、结算调用 `complete_consultation_free`。F3 另锁:结算记录是 `offered` 且 `autoOpen: true`、卡片在绑定前不说「已经开始」、绑定后才说已经开始、打开失败仍是 `offered`、旧的 `offered` / `opened` / `returned` 照常解析且 `returned` 不出卡。用户可见句长度 ≤20。真机清单 `docs/testing/rectification-explicit-entry-20261012.md`。
- 相关记录:BUG-1247、BUG-1248(入口改为提议卡)、BUG-976/977(禁正则判意图)。
- 复发自:无。
- 修复版本:直达入口已在 staging `b8bdc003`,未标 resolved,未上生产。F3 跟进为本分支提交,未 push,未部署。
- 决策日志(2026-10-12,产品):选 A——用户明确要求做生时校正时直接进入校正,不先写解读、不用点卡;意图由模型判断,禁正则 / 关键词。**推翻 BUG-1248 两处**:①明确要求的这一轮不再「只出卡、点卡才进」;②`product_disabled`、`pricing_unavailable`「正文不要提校正」改为必须一句话说明做不了,不得静默。普通对话模型不得承诺收窄出生时间、不得在对话里收集经历假装校正;申报时段下不得写大运 / 子运精确起止。服务端本人 / 开关 / 进行中 case / 价格判定不变。同日另立 BUG-1307(最近分界开场)、BUG-1308(父母静态题研究)。
## BUG-1307 | 生时校正开场不说记录时间离最近分界多近,首轮选题面向整个窗口,用户觉得步骤多、目的不明
- 状态:open(任务书 `docs/tasks/TASK-rectification-nearest-boundary-opening-20261012.md` 待领取)
- 首次发现 / 最近更新:2026-10-12 / 2026-10-12
- 来源:产品拿上游 Skill 的一次私人样例校正比较,认为上游步骤短。上游先找记录时间最近的分盘换段点,只问两侧不同的层。
- 现象:我方开场不讲记录时间与分界的关系;`window_scan` 有逐分钟 11 层换段数据,但不以记录时间为锚;记录时间只在交付时与区间比较(`reportedTimeOffsetCopy`)。
- 根因:扫描数据从未以记录时间为锚组织,呈现与选题都面向整个窗口。
- 决策:只改呈现与首轮选题顺序,不改打分、闸门、4 件 3 域;对外不说秒级、不说「记录时间是错的」。
- 相关记录:BUG-1306、BUG-1115~1117(盘型口径)、BUG-1143/1144(按段选题)、BUG-691(记录优先)、BUG-1240(不说秒级)。
- 复发自:无。
- 修复版本:未修复。
## BUG-1308 | 父母静态是非题(D12)对出生时间分界两侧有没有区分力未经评测
- 状态:open(研究单 `docs/tasks/TASK-rectification-d12-parent-probes-research-20261012.md` 待领取)
- 首次发现 / 最近更新:2026-10-12 / 2026-10-12
- 来源:同 BUG-1307 的上游样例:上游用五道父母是非题区分 D12 换段两侧。同一输入上游两次结论相反,下结论的那次靠单条父母事实的事后解读。
- 现象:我方无静态父母题;上游 10-02 的 D12 绑定(`bbb16115` 等)针对带日期的父母事件,静态题两边都没有评测。
- 决策:先研究,公开案例、预登记、乱序与正对照,过门才立实现单;过门前不进题库、不计分。
- 相关记录:BUG-1306、BUG-1307、BUG-1141 / BUG-1240(同类研究均未过门)。
- 复发自:无。
- 修复版本:不适用(研究)。
## BUG-1309 | 分盘段扫描端点写死 Raman:非 Raman 用户的盘型段与档位按 Raman 算,引擎打分按用户岁差
- 状态:open
- 首次发现 / 最近更新:2026-10-12 / 2026-10-12
- 来源:BUG-1307 分支验收(`b285011a`)时发现;同一端点在 staging 现存。
- 现象:`/api/rectification/v5/varga-segments`(`scripts/rectification_varga_segments_api.py`)的 profile、逐分钟排盘与返回体都写死 `ayanamsa: "raman"`;调用方 `v9/segment-scan.ts` 不传岁差。校正引擎打分用 `resolveAyanamsa(snapshot)`。
- 影响面:选了非 Raman 岁差的用户,交付卡 D9 / D10 段、档位与「不用校正」判断按 Raman 算,可能与打分所用岁差的段边界不一致(分界随岁差移动,私人样例一例 Raman 与 Lahiri 差约 7 分钟)。默认 Raman 用户不受影响。
- 根因:BUG-1115 引入段扫描端点时只按默认岁差实现,任务书未要求透传岁差。
- 修复:见 `docs/tasks/TASK-rectification-1012-batch-fix-20261012.md` F1 / F1b(端点收岁差并进缓存键,两处调用传用户岁差,缺省 raman,v5 回放逐字节不变)。
- 相关记录:BUG-1115、BUG-1307、BUG-1306。
- 复发自:无。
- 修复版本:未修复。
@@ -1,6 +1,6 @@
# PROGRESS · 对话卡同源第二轮修复(2026-10-09)
分支 `codex/consult-card-full-source-20261007`。已 rebase 到 `origin/staging` `8bb7ff93`。状态:执行中。G3 未通过。未推送,未合入,未上线。
分支 `codex/consult-card-full-source-20261007`。已 rebase 到 `origin/staging` `8bb7ff93`。状态:已验收(Claude,2026-10-09)。产品按严重冲突 ≤ 1 放行,快进合入 staging;剩余 1 次见 `TASK-consult-career-type-residual-20261009.md`。
## 结论
@@ -51,3 +51,17 @@
| 快速门、Python 全量、npm test 全量、next build、Linux | 本轮没跑。不把上一轮 Windows 快速门当成通过。 |
G4 未完成。G3 未过,状态保持执行中。
## Claude 验收(2026-10-09,Linux,`fc3a2e68`)
| 项 | 结果 |
| --- | --- |
| Python 全量 | 失败 62 条,与上一轮基线逐名相同 |
| 快速门(`--skip-yoga-logic --skip-frontend-runtime`) | 通过 |
| `npm test`(Node 22) | 4,998 条 / 失败 24,与基线逐名相同;新增 1 条,无消失 |
| tsc / lint | 0 / 0 error(125 warning 原有) |
| `next build --webpack` | `/` 仍 ○ Static |
| G1 / G2 | 通过。G2 的预热分支不经 Next 对外暴露(5200 只在内网) |
| G3 | 严重冲突 1 = 合入前 staging 的 1,达到产品合入线;任务书附加的「齐达内两次都不按星性」未达成,产品选择合入后另开单 |
遗留(写入 `TASK-consult-career-type-residual-20261009.md`):保存后马上提问时,预热与用户请求会各自完整算一遍同一份资料包(缓存没有按键单飞);预热本身也没有自己的并发上限。
@@ -0,0 +1,64 @@
# PROGRESS · 事业方向残余 + 预热单飞(2026-10-09)
分支 `codex/consult-career-type-residual-20261009`。先接到 `origin/staging` `b4eedb77`(BUG-1305,保存时预热安排不上不再让保存失败)。状态:已验收(Claude,2026-10-09)。判断表提示之后,3 次抽样仍有 1 次行业类型冲突;产品选择合入、记为已知残余,快进合入 staging。
## 结论
事业清单和判断表都写了「方向不按星性」。9 人 × 4 领域 × 3 次,108 份都有回答。严重冲突仍是 1,对照组误报 0。27 份事业题里仍有 1 份把谋生写成靠能力和表达(齐达内事业第 3 次)。预热按缓存键单飞,同时最多 1 份,这项已经做完。
## 修复
| 项 | 做法 | 结果 |
| --- | --- | --- |
| H1 第一次 | 事业清单仍是 8 行。「方向」只写力量、阻力、时间与三件事,职业类型与工作方式问用户。 | 108 份,行业类型冲突 1。见下表「清单句」。 |
| H1 第二次 | 事业判断表的自然代表星、Jaimini 代表星、大运主星、Narayana 主星和宫主行旁边加一句:只看力量与受冲,不按这颗星的本性写职业类型、做事方式或方向。其他领域不加。不删行,不改取值,不改系统提示。 | 108 份,行业类型冲突仍是 1。见下表「判断表提示」。BUG-1303,仍 investigating。 |
| H2 预热单飞 | 同一个缓存键只算一次。预热上限 1,满了跳过。用户请求不占这个名额,也不因此收到 429。不改 `jyotish_api_server.py`。 | 并发测试通过。BUG-1304。 |
| H3 | 本机不是 Linux。 | 快速门、Python 全量、npm test 全量、next build 没跑。 |
`scripts/jyotish_api_server.py` 没有改,仍是 10,970 行。类方法数没有增加。`page.tsx` 没有改。系统提示源码切片仍是 15,291 字符。最大卡仍是 16,281,低于 20,000。
## 生平回测
模型 `deepseek-flash`。key 只在当次命令的环境变量里,没有写入仓库、日志正文或本文。引擎仍是 golden 桩,参照日 2026-09-27,岁差 raman,交点 mean。判断靠阅读,脚本只写确定性检查。口径与前两轮相同:两头都写到的范围不算误报;直接写成另一类职业,或写成幕后、不靠曝光,算行业类型冲突。
| 项 | 清单句 | 判断表提示 |
| --- | --- | --- |
| 份数 / 失败 / 空答 | 108 / 0 / 0 | 108 / 0 / 0 |
| 墙钟 | 1399.2 秒 | 1320.1 秒 |
| 严重冲突 | 1 | 1 |
| 对照组误报 | 0 | 0 |
| 行业类型冲突 | 1 / 27 | 1 / 27 |
| 事业题请问一句 | 27 / 27 | 27 / 27 |
| 禁句 / 性别代词 / must_not 原文 | 0 / 0 / 7 | 0 / 0 / 6 |
| 平均秒 / 平均字数 / 平均卡 / 最大卡 | 50.6 / 734.9 / 14,129.8 / 16,281 | 48.2 / 744.8 / 14,238.5 / 16,281 |
| 平均输入 / 输出 / 推理 / 缓存输入 | 29,133.5 / 9,571.3 / 8,995.3 / 21,745.8 | 29,375.7 / 9,243.0 / 8,660.1 / 21,915.3 |
清单句那一轮:齐达内事业第 1 次把方向写成信息和表达。判断表提示这一轮:齐达内事业第 3 次把谋生写成靠能力和表达。乔布斯事业第 3 次写印象不太跟曝光绑在一起,同段又写跳到高曝光位置有阻力,没有写成幕后或不靠曝光,不计行业冲突。其余事业题没有写成分析、顾问、技术,也没有写成幕后。
must_not 原文 6 处都是「可能不在身边,也可能人在、只是话少」,或写明盘上看不出人在不在身边。这 6 处都不算对照组误报。没有把离婚姻写成离婚。
用量合计:输入 3,172,579,输出 998,241,推理 935,295,缓存输入 2,366,848。
## 本机检查
| 项 | 结果 |
| --- | --- |
| 事业清单、判断表及相关测试 | 23 条通过,0 取消。含新句子、呈现句只在事业表、系统提示长度、事业题仍问用户。 |
| 预热单飞、重计算名额、增长合同 | 24 条通过。API 文件没有增长。 |
| tsc | 0 |
| 改动文件的 lint | 0 error |
| 快速门、Python 全量、npm test 全量、next build、Linux | 本轮没跑。 |
H1 未过。任务书允许的两处都用过了。按让步顺序停下,交给产品决定。不改评分口径,不再加一条规则,不标待验收。
## Claude 验收(2026-10-09,Linux,`d6eb0cfe`)
| 项 | 结果 |
| --- | --- |
| Python 全量 | 失败 62 条,与基线逐名相同 |
| 快速门(`--skip-yoga-logic --skip-frontend-runtime`) | 通过 |
| `npm test`(Node 22) | 5,001 条 / 失败 24,与基线逐名相同;无消失的测试 |
| tsc / lint | 0 / 0 error(125 warning 原有) |
| `next build --webpack` | `/` 仍 ○ Static |
| H2 单飞 | 代码复核通过:同键并发只建一次,预热上限 1、满了跳过;API 是单进程 `ThreadingHTTPServer`,进程内锁有效。本轮未改保存路由,数据库套件不受影响 |
| H1 | 未达任务书标准(1/27),产品选择合入并记为已知残余 |
@@ -0,0 +1,54 @@
# PROGRESS · 明说要校正就直接进入(BUG-1306,2026-10-12)
分支 `codex/rectification-explicit-entry-20261012`,基线 `e6baff95`。未 push,未部署。
## 结论
用户这一轮明确要求做生时校正时,直接打开校正:不先写解读,不用点卡。是否明确要求由模型填写 `explicitRequest`,不用关键词。推得不准时的主动提议仍走卡片。做不了时用一句话说明。普通对话的规则写明不承诺收窄出生时间,也不在对话里收集经历做校正。申报时段的大运、子运只说在哪一段附近。
## 实现
沿用现有工具 `offer-birth-time-rectification`,不新建工具。服务端判定仍是 `decideRectificationOffer`。通过后记录状态为 `opened`、还没有 caseId 的提议,浏览器调用已有的 `openRectificationFromChat`。已有进行中的 case 不新开,查到会话就接着打开。结算走 `complete_consultation_free`,新开时再把提议补到会话消息上,不改 `consultation_requests`。`Home()` 没有新增 `useState` / `useRef`。
## T0 给产品的查法
本 worktree 没有 staging 主机,读不到 web 容器日志,也没有数据库。不阻塞实现。
只查该时段 web 容器日志里的 `[agent-observability]`:
1. `toolCalls` 里有没有 `offer-birth-time-rectification`。
2. 该次调用的 status(有没有放行、有没有 `offered`)。
3. 该会话的 `messages` 里有没有 `rectificationOffer` 这个字段。
不要打开回答正文,不要查看出生资料。
## T5
环境里没有可用模型凭据。`MODEL_PROVIDER_CONFIG_ENCRYPTION_KEY` 与常见供应商 key、数据库连接变量都不在本机环境里(只看了是否存在,没有读取值)。供应商 key 在管理端数据库里加密保存,本机没有数据库,不能解密,也没有调用模型。八个场景写在 `docs/testing/rectification-explicit-entry-20261012.md`。
## 既有断言
| 原值 | 新值 | 原因 |
| --- | --- | --- |
| `rectification-in-chat-20261007.test.ts` 的断言原文没有改。 | 无。 | 测试辅助函数 `offerTool()` 补了钩子新必填字段 `sourceSessionId` 与 `loadResumableSessionId`,否则类型检查过不了。期望值没有改。 |
| `consult-evidence-card-telemetry-20260927.test.ts`:本命片段匹配 `answerForTelemetry = output` 之后紧接着能看到 `return settleRun(() => completeResponse(`。 | 非直达仍是 `if (!state.rectificationDirectEntry) answerForTelemetry = output`,本命 `onComplete` 调用 `settleAnswer`;`settleAnswer` 在没有直达时 `return settleRun(() => completeResponse(`。 | BUG-1306:直达轮不把模型正文当回答,结算改走 `complete_consultation_free`。普通本命轮仍记 telemetry,仍走 `completeResponse`。 |
## 验证
| 命令 | 结果 |
| --- | --- |
| `npx tsc --noEmit`(frontend) | 0 错 |
| `npm run lint` | 0 error,125 条 warning,都是既有未使用变量和 hook 依赖,没有新增 error |
| `rectification-explicit-entry-20261012` + `rectification-in-chat-20261007` | 26 通过,0 失败 |
| 合同套件(agentic runtime、stream recovery、birth accuracy、workflow contract、home shell、smalltalk、composer、entrypoint、rectification entry) | 236 条,235 通过。唯一失败:`consultation-agentic-runtime.test.ts`「personal Agent exposes the Jyotish Skill and named server tool」。Windows 建 Skill 符号链接 EPERM。该测试与 `skill-package-registry.ts`、`skill-binding.ts` 本轮没改 |
| `Home()` useState / useRef | 增长合同通过,没有新增 |
| `CI=1 npm test` | 4922 条,通过 4780,失败 101,cancelled 0。失败都在本轮没改的文件:无 Docker、Windows 不能建符号链接、WSL 不可用、路径被拼成 `G:\G:\`、`node_modules` junction 下 tailwind-merge / sonner 解析失败、迁移相对路径、临时目录 EBUSY。唯一由本轮源码形状引起的是 telemetry 那条,断言已按上表改过,该文件再跑 4 条通过。`consult-single-pass-answer-20260927.test.ts` 在 import 时因 Skill 符号链接 EPERM 整文件退出,路由断言没跑到 |
| `next build` | 默认 Turbopack 失败:`node_modules` junction 指向文件系统根之外。`npm run build -- --webpack` 编译成功(7.8 分钟),TypeScript 通过(4.1 分钟)。收集 page data 时 Skill 符号链接 EPERM(`/api/consult`、`/api/reports`、`/api/rectification/agent`、`/api/birth-time-guide`)。没有路由表,因此没有证明 `/` 仍是 Static,也没有首屏 gzip。没有 `npm install`,没有删 junction,没有改 next 配置。 |
## 环境缺口
- T0:无 staging `docker logs`,无数据库。
- T5:无模型凭据,未调用模型。
- 定向套件里的 Skill 符号链接 EPERM 是本机 Windows 限制。
- `frontend/node_modules` 是指向主仓的 junction。没有 `npm install`,没有删 junction。
- `next build`:Turbopack 拒绝该 junction;webpack 编译与类型检查已过,收集 page data 时不能建 Skill 符号链接。`/` Static 与首屏 gzip 未测。
@@ -0,0 +1,32 @@
# PROGRESS · 校正提议在开案成功后才标已开始(BUG-1306 F3,2026-10-12)
分支 `codex/rectification-offer-opened-state-20261012`,基线 `b8bdc003`(`origin/staging`)。未 push,未部署。状态保持 fixed-pending-verify,不是 resolved。
## 结论
明确要求做校正时,结算记下的提议仍是普通提议:状态 `offered`,并带 `autoOpen: true`。卡片写价格和题数,按钮是「开始核对出生时间」,不说「已经开始」。浏览器仍按 `rectificationDirectOpen` 自动开案。只有开案成功、`bindOfferToCase` 绑定来源之后,状态才变成 `opened`,卡片才说「已经开始」。打开失败时记录保持 `offered`,卡片可以再点。没有第四种状态。已存的 `offered` / `opened` / `returned` 照常解析。`complete_consultation_free` 和五句用户文案没改。
## 实现
`finishExplicitRectificationRequest` 不再把结算记录写成 `opened`。`autoOpen` 是可选字段,只在为真时写入;旧记录省略它也能解析。刷新走 `parseRectificationOfferRecord`,不会丢掉这个字段。`persistOpenedDirectOffer` 仍把这条还没打开的记录挂到免费回复上。客户端新开仍调用 `openRectificationFromChat`,接着做仍调用 `openRectificationSession`。开案函数失败时返回空,调用处不把本地记录改成 `opened`。成功仍由原来的绑定路径改状态。
## 既有断言
| 原值 | 新值 | 原因 |
| --- | --- | --- |
| `rectification-explicit-entry-20261012.test.ts`「explicit pass…」两处 `deepEqual` 期望 `offer("opened")` | `offer("offered", { autoOpen: true })` | F3:结算保持 offered,用 autoOpen 表示这一轮要自动开案;开案绑定后才是 opened |
| 同文件「explicitRequest bypasses…」`rectificationOffer.status` 期望 `"opened"` | `"offered"`,且 `autoOpen === true` | 同上。直达入口 `kind: "new"` 不变 |
| 同文件直达流「run.completed」`rectificationOffer.status` 期望 `"opened"` | `"offered"`,且 `autoOpen === true`;`rectificationDirectOpen.mode` 仍是 `"new"` | 完成事件转发的是结算记录,不能再预写 opened |
| 同文件两处流式夹具把 `state.rectificationOffer` 设成 `offer("opened")` | `offer("offered", { autoOpen: true })` | 夹具要模拟现在的结算记录,否则事件仍像开案前就已经 opened |
`rectification-in-chat-20261007.test.ts` 里原来的期望值没有改。普通提议仍是没有 `autoOpen` 的 `offered`。无 caseId 的旧 `opened` 记录仍能绑定。
## 验证
| 命令 | 结果 |
| --- | --- |
| `npx tsx --test tests/rectification-explicit-entry-20261012.test.ts` | 16 通过,0 失败,cancelled 0 |
| `npx tsx --test tests/rectification-in-chat-20261007.test.ts` | 15 通过,0 失败,cancelled 0 |
| `npx tsc --noEmit`(frontend) | 0 错 |
没有跑 `next build`(Turbopack 拒绝 `node_modules` junction)。没有跑全量 `npm test`。没有 `npm install`,没有删 junction。`Home()` 没有改,`page.tsx` 没有改。
+6 -1
View File
@@ -124,6 +124,10 @@
| `TASK-rectification-mobile-timeline-readout-20260917.md` | `PROGRESS-rectification-mobile-timeline-readout-20260917.md` | 手机截图:时间轴读数第四项被裁成「已…」(nowrap + inset 内边距,BUG-918);「跳到最新」浮层压住选项 C(BUG-919)。灰卡与「再说一件」矛盾归修复单 BUG-915/917 | 已验收 | `e8e98bbd`(合入时重放为本分支提交,BUG-918/919)。时间轴相关 71 条 + 全量同上;手机上隐藏「已对照 N 件」符合 DESIGN §10;桌面是否也移除待产品拍板。真机清单 `docs/testing/rectification-mobile-timeline-readout-20260917.md` |
| `TASK-mobile-viewport-scroll-lock-20260917.md` | `PROGRESS-mobile-viewport-scroll-lock-20260917.md` | iPhone 上键盘收起 / 刷新后整页上移、顶栏点不到(BUG-920):iOS 不支持 `interactive-widget`,键盘弹出时 Safari 滚动 window,`html/body overflow: hidden` 让用户拉不回来,reload 又还原 `scrollY`;代码里无任何 window 级复位。补 `ViewportScrollLock`(`scrollRestoration=manual` + `visualViewport` 复位)。与今天两单无关,既有缺陷。**T3 顶栏积分块「有点扁」(BUG-921)**:两枚芯片 44px 塞在 46px 顶栏里、字号/内边距/圆角各一套、积分块强制 64px 最小宽度;统一尺寸并把手机顶栏放到 52px | 已合入(无独立验收记录) | `1b508d5e`(BUG-920/921 resolved;已部署 staging,run 2715);真机待核 |
| `TASK-rectification-session-composer-guard-20260917.md` | `PROGRESS-rectification-session-composer-guard-20260917.md` | 校正会话激活但校正面未开(刷新揭幕超时 / 打开失败不重试 / 删除与 popstate 回退落到列表第一条)时露出可用的普通输入框,问题发到 `/api/consult` 回 404「咨询会话不存在」;`send()` 无会话类型守卫;服务端无独立错误码。关联 BUG-505。BUG 段 924 起 | 已验收 | `6d81062b`(Claude 验收:T1–T5 全通过;409 `session_not_consultation` 在扣点前、不扣点;回退路径多修了归档一条;page.tsx 1830→1830。残留 P3:列表只剩校正会话时 `activeSession` 为空,输入框可用但发送静默无效 → BUG-935 在 fix 单);真机三条欠 |
| `TASK-rectification-explicit-entry-20261012.md` | `PROGRESS-rectification-explicit-entry-20261012.md` | **明说要校正就直接进入(BUG-1306)**:模型判断意图(禁正则),服务端判定后直接开案并绑定来源对话;首轮排盘合同对此轮豁免;拒绝必须一句话说明(推翻 BUG-1248 静默两条);普通对话不得假装校正、申报时段不写子运起止。**先做** | 待验收 | — |
| `TASK-rectification-nearest-boundary-opening-20261012.md` | — | **最近分界开场(BUG-1307)**:开场说记录时间离最近分盘换段点多近、两侧哪些层不同,近分界时首轮只问该分界;不改打分 / 闸门 / 4 件 3 域,77 例回放不降;改 page.tsx / 校正开场组件的部分须在 BUG-1306 合入后 | 待领取 | — |
| `TASK-rectification-d12-parent-probes-research-20261012.md` | — | **父母静态是非题(D12)研究(BUG-1308)**:公开案例 ≥60、预登记、乱序与正对照,过门才立实现单;纯研究可并行 | 待领取 | — |
| `TASK-rectification-1012-batch-fix-20261012.md` | `PROGRESS-rectification-offer-opened-state-20261012.md` | **10-12 三单验收修复**:entry `6a799582` 通过(合入待产品);boundary `b285011a` 未通过——F1 开场与段汇总岁差写死 Raman(BUG-1307 / 新 BUG-1309)、F2 本命上升换段不说;F3 提议在开案成功后才标 opened(BUG-1306 跟进,本分支待验收);F4 研究报告补编码问题与暂停结论(BUG-1308) | F3 待验收(`codex/rectification-offer-opened-state-20261012`);F1/F2/F4 仍待领取 | — |
### 聊天主链路与首页
@@ -131,7 +135,8 @@
| --- | --- | --- | --- | --- |
| `TASK-consult-card-full-source-20261007.md` | `PROGRESS-consult-card-full-source-20261007.md` | **对话数据卡与报告同源 + 领域综合判断表**:事业卡只有约 7,600 字符、无自然代表星、无大运主星关系、D10 联动只能补取、补取上限 1;全 12 领域;综合判断表、参与清单(不止 D1/D9,含精度闸)、补取 3 次、资料目录均已确认;KP 不上卡、不联网、不接书籍语料;排在 sync6 与报告单之后 | 验收未通过(`35dafd1e`,见 fix-20261009) | `codex/consult-card-full-source-20261007` |
| `TASK-consult-card-full-source-fix-20261009.md` | `PROGRESS-consult-card-full-source-fix-20261009.md` | **对话卡同源修复**:缓存键无日期致当前大运冻结、整卡 37.5K(每格带内部路径)、星照整列 blocked(资料库其实有出处)、前端测试手写数据、无预热;合入前用临时 key 跑模型 A/B 与生平回测 | F1–F5 已验收,F6 未过(见 fix2) | `codex/consult-card-full-source-20261007` |
| `TASK-consult-card-full-source-fix2-20261009.md` | `PROGRESS-consult-card-full-source-fix2-20261009.md` | **对话卡同源第二轮**:齐达内事业被「水星=说写算」带偏致严重冲突 1→3;事业清单加「代表星只看力量受冲、不按星性猜职业」后重跑回测(≤1 才合入);预热改为不占重计算名额 | 执行中(G3:齐达内事业仍有 1 次按星性写方向,未让步) | `codex/consult-card-full-source-20261007` |
| `TASK-consult-card-full-source-fix2-20261009.md` | `PROGRESS-consult-card-full-source-fix2-20261009.md` | **对话卡同源第二轮**:齐达内事业被「水星=说写算」带偏致严重冲突 1→3;事业清单加「代表星只看力量受冲、不按星性猜职业」后重跑回测(≤1 才合入);预热改为不占重计算名额 | 已验收并快进合入 staging(2026-10-09,产品按严重冲突 ≤ 1 放行;剩余 1 次见 career-type-residual) | `codex/consult-card-full-source-20261007` |
| `TASK-consult-career-type-residual-20261009.md` | `PROGRESS-consult-career-type-residual-20261009.md` | **事业题按星性写方向的残余 + 预热单飞**:方向一节只写力量、阻力、时间和三件事;判断表事业行旁加一句只看力量;预热按缓存键单飞、同时最多 1 份 | 已验收并快进合入 staging(2026-10-09;事业 1/27 行业冲突为产品接受的已知残余,BUG-1303 mitigated) | `codex/consult-career-type-residual-20261009` |
| — (产品 09-27 拍板 D1–D4,直接执行) | [PROGRESS](PROGRESS-home-landing-blank-20260927.md) | **登录后 / 裸 `/` 落空白首页**:真机登录后在「首页」提问其实问进了上一次生时校正。删登录返回存根(401 / 次级页链接写、登录后写回 `?c=` 打开),裸 `/` 不再落最近会话,一律当前人物的空白首页(复用空草稿);`?c=` / `?new=1` / 对话内刷新不变;推翻 BUG-1038 存根与 BUG-599 默认落点 | 已验收(真机欠) | `codex/home-landing-blank-20260927`(BUG-1052,本地未推) |
| — (产品 09-26 口头拍板 D1–D3,直接执行) | `PROGRESS-consult-answer-truncation-20260926.md` | **普通咨询回答写到一半被掐断仍扣点(BUG-1051,复发自 BUG-305)**:工具循环与写回答共用 110 秒 signal;Mastra 1.50 超时不抛错(`abort` 块 + `finish(tripwire)` 后正常关流),结算只认抛错与 `length`。D1 写回答自有 70 秒时钟(首用起算,续写 / 回答重试共用,最坏 180 秒,`maxDuration` 240);D2 写回答的最后一个流不是 `stop` 且有正文 → `answer_truncated`、不扣点、记 abort 步、不冲半句,`length` 续写不变;D3 观测加 `composeFinishReason` / `composeAborted` / `answerVisibleChars` | 已验收(真机欠) | `codex/consult-answer-truncation-20260926`(本地,未推送);新回归 15 条用真实 Mastra `Agent`(修复前 11 条红);全量失败名单 0 新增;Python 948/1;`/` ○、gzip 0%;真机清单 `docs/testing/consult-answer-truncation-20260926.md` |
| `TASK-consult-evidence-card-research-20260927.md` | `PROGRESS-consult-evidence-card-research-20260927.md` | **普通对话数据卡调研**:引擎输出逐项分五类计量(现约 4 万 token、父母问题相关约 3.5%);四处领域→技法来源对账并起草各领域数据卡(家庭拆父母/子女);卡体量与逐字一致性;按卡算的提速空间;反馈迭代埋点方案。只调研不改线上 | 已验收(待产品拍板 7 项) | `codex/consult-evidence-card-research-20260927` 快进 staging;报告 `docs/research/consult_evidence_card_research_2026_09_27.md`;投影缺口记 BUG-1054 investigating |
@@ -0,0 +1,50 @@
# TASK · 事业题按星性写方向的残余 + 预热单飞(2026-10-09)
- 基线:`origin/staging`,含 `fc3a2e68`(对话卡同源已合入)。分支 `codex/consult-career-type-residual-20261009`。
- 前序:`TASK-consult-card-full-source-fix2-20261009.md`(已验收合入)。
- BUG 编号:本单已登记 BUG-1303、BUG-1304(investigating),新问题从 **1306** 起,开工时核对。
## 1. 实证
| # | 问题 | 证据 |
| --- | --- | --- |
| H1 | 事业题仍有 1 / 18 份按星的本性写方向 | `PROGRESS-consult-card-full-source-fix2-20261009.md`:齐达内事业第 2 次把方向写成「知识、表达、信息和资源」。合入前 staging 的唯一严重冲突是泰勒事业第 2 次(写成不靠曝光),同属行业类型冲突。2 次抽样、`deepseek-flash`,单次波动大 |
| H2 | 预热与用户请求重复计算同一份资料包 | `scripts/consult_full_data_cache.py` 的 `get_or_build` 没有按键加锁:保存后预热还在算时用户提问,用户请求缓存未命中,会在重计算名额里再完整算一遍。预热分支(`jyotish_api_server.py` 中 `consult_card_warm_only`)不占名额,也没有自己的并发上限,2 vCPU 上多次保存会并行多份 |
## 2. 根因
- H1:清单规则只说「不按星性推断」,但判断表仍把同一颗星在自然代表星、大运主星、旺弱多处点名;模型在写「方向」一节时仍会给出星性词。规则没有说「方向」该写什么。
- H2:F5 只做了「保存后调用」,没有做同键单飞与预热限流。
## 3. 决策记录(产品 2026-10-09)
1. 对话卡同源分支按「严重冲突 ≤ 1」放行合入;剩余这 1 次另开本单。
2. 本单不删判断表行、不改卡片取值、不改系统提示(≤ 26.6K)、不改首轮形状与口吻;继续遵守 `CAREER_FIELD_ASK_RULE`「不猜行业、问用户」。
3. 回测临时 key 只放执行方会话的环境变量,不进任何文件。
## 4. 硬红线
1. 只改事业清单(`frontend/src/lib/consultation-condensed-checklist.ts`)或判断表的呈现文字,不改引擎取值。
2. 不为回测改评分口径;判读口径沿用前两轮进度记录。
3. 预热失败不影响保存;用户请求不得因预热收到 429;不新增 API 类方法;`jyotish_api_server.py` 不增长(`tests/test_api_server_growth_contract.py`)。
## 5. 任务分解
| # | 内容 | 验收标准 |
| --- | --- | --- |
| H1 | 事业清单写明「方向」一节只写力量、阻力、时间与三件事(机会出现 / 成形 / 公开落地),职业类型与工作方式一律问用户;必要时在判断表代表星行旁加一句同义提示(呈现文字,不改值) | 新测试锁住;生平回测 9 人 × 4 领域 × **3 次**:事业 27 份里行业类型冲突 0,全体严重冲突 ≤ 1,对照组误报 0;数字进进度记录 |
| H2 | `get_or_build` 按缓存键单飞(同键并发只算一次,后到者等结果);预热单独限流(例如同时最多 1 份),满了直接跳过 | 并发测试:预热进行中同键用户请求只触发 1 次构建;多次预热不超过上限;既有 `tests/test_api_heavy_compute_gate.py` 通过 |
| H3 | Linux:快速门、Python 全量 ⊆ 基线(62)、`npm test` 失败名单与基线相同、tsc 0、lint 0 error、`next build` `/` Static | 全部满足 |
| H4 | 记录 | BUG-1303 / 1304 更新状态;`CHANGELOG.md`、进度记录、状态板 |
## 6. 让步顺序
H2 可先单独交付;H1 若 3 次回测仍有 1 次行业冲突,如实记录并停,交产品决定,不改口径。
## 7. 开工前置
```bash
git fetch origin --prune
git worktree add -b codex/consult-career-type-residual-20261009 .worktrees/consult-career-type-residual-20261009 origin/staging
grep -n "^## BUG-1[3-9][0-9][0-9]" docs/BUG_HISTORY.md | tail -3
```
@@ -0,0 +1,78 @@
# TASK · 生时校正 10-12 三单验收修复(2026-10-12)
- 基线:`origin/staging` @ `e6baff95`(三条功能分支都从这里分出)。
- 受影响分支:
- `codex/rectification-explicit-entry-20261012` @ `6a799582`(BUG-1306):**验收通过**,本单 F3 是合入后的跟进项。
- `codex/rectification-nearest-boundary-opening-20261012` @ `b285011a`(BUG-1307):**未通过**,F1 修完才能合入。
- `codex/rectification-d12-parent-probes-research-20261012` @ `11fee0a3`(BUG-1308):研究结论成立,F4 补齐文档后可合入。
- 串行顺序:explicit-entry 先合入 staging(由产品点头)→ 把 F1/F2 做在 nearest-boundary 分支上,然后在 explicit-entry 之上变基(试合只冲突 `CHANGELOG.md`、`docs/tasks/README.md`,都是文档)→ F3 从合入后的 staging 新开分支。F4 与上述并行。
- 合入 staging 一律等产品明说,执行方只推各自的功能分支。
## 1. 验收实证
| 分支 | 门禁 | 结论 |
| --- | --- | --- |
| explicit-entry | tsc 0;lint 0 error / 125 warning(同基线);npm test 5013 条,失败 25(24 条同基线,另 1 条 `consult-evidence-lookup-20260927`「a lookup does not reset the answer clock」是负载下计时抖动,单独跑 3/3 通过);build `/` Static;gzip +0.02%;page.tsx 未增长 | 通过;T0 日志取证、T5 模型实测为环境缺口 |
| nearest-boundary | tsc 0;lint 同基线;npm test 5006 条,失败 25 逐名同基线;build Static;gzip 0%;Python 定向 136 passed;打分 / decision_policy / 闸门 / api_server diff 为空 | 门禁过,**F1 缺陷不过** |
| d12 research | 15 测试过;结果 JSON 两次复跑逐字节一致(除 Python 版本号);预登记 `ad89e410` 早于打分 `11fee0a3`,其后 0 改动;冻结身份 / 隐私 94 项绿 | 结论「欠力」成立;下一步与编码问题未写进报告 |
## 2. 缺陷与根因
**F1(P1,BUG-1307)开场分界按 Raman 算,引擎按用户岁差算。**
`scripts/rectification_varga_segments_api.py` 三处写死 `"ayanamsa": "raman"`(profile、逐分钟 `compute_chart`、payload);`frontend/src/lib/rectification-agentic/opening-boundary.ts::fetchOpeningBoundary` 请求体不带岁差。校正引擎打分用 `resolveAyanamsa(snapshot)`(`v9/engine-client.ts`)。分界随岁差移动:私人样例一例里 Raman 与 Lahiri 的分界相差约 7 分钟,分界落在记录时间两侧。结果:非 Raman 用户开场说的「差几分钟、两边哪里不同」与引擎实际扫描矛盾;选题开关打开后更会自相矛盾。
任务书 `TASK-rectification-nearest-boundary-opening-20261012` 没有写岁差要求,是任务书漏写,不是执行方违规。
**F1b(P2,新 BUG-1309)同一端点给盘型段汇总用时也写死 Raman。**
`v9/segment-scan.ts`(BUG-1115 那批)调同一个 `/api/rectification/v5/varga-segments`,同样不传岁差。非 Raman 用户的交付卡 D9 / D10 段与档位按 Raman 算,引擎打分按用户岁差。这是 staging 现存问题,F1 改端点时一并改。
**F2(P2,BUG-1307)本命上升也换段时开场不说。**
开场只列「婚恋、住处、学业、子女、父母、钱、身体」;D1 也换时这是最大的变化,却不提。
**F3(P3,BUG-1306 跟进)打开失败时卡片仍说「已经开始」。**
提议记录在服务器结算时就写成 `opened`,早于浏览器真正开案;开案失败(断网、冲突)时对话显示「出生时间核对已经开始。」。点卡片「继续核对」可重开,不卡死。
**F4(文档,BUG-1308)研究报告缺下一步与编码问题。**
6 例计入中 3 例编码站不住:奥巴马「母亲照料更多=是」引文只讲上哪所小学;伊梅尔达·马科斯「父亲体制内=是」引文是自己执业律师;索菲亚·罗兰「父亲体制内=是」引文是国家铁路临时工作(预登记规定短期任职不算主业,赫本父亲名誉领事却记「不清楚」,尺度不一)。另李小龙「父亲指导学业=是」依据是教太极(未计入)。维基百科计入率约 8%,凑满预登记的 60 例需 700+ AA 例,现有数据做不到;岁差改判 36–42%(门槛 20%)。
## 3. 决策记录
1. F1:开场与段汇总一律用 `resolveAyanamsa(snapshot)`,与打分同源;岁差进缓存键。不新增「强制选岁差」(产品 10-03 已拒)。
2. F2(Claude 定口径):D1 也换段时,开场先说「这里连本命上升都换」,再说其余会变的事;其余不变。
3. F3:结算时提议记录写 `opening`(或保持 `offered` 并带 `autoOpen` 标记),浏览器开案成功、来源绑定后才改 `opened`;失败时卡片按 `offered` 显示,可点开。具体状态名由执行方定,须向后兼容已存的 `offered` / `opened` / `returned`。
4. F4:本方向**暂停**,不立实现单。报告写明:继续的唯一路线是换数据源(传记 / 访谈)+ 双人独立编码 + 重新预登记;且岁差敏感 36–42% 本身使过门可能性很低。BUG-1308 状态改 `closed_by_design`(研究欠力,方向暂停)。
## 4. 硬红线
1. 不改打分、`decision_policy`、闸门、4 件 3 域、`scripts/jyotish_api_server.py`;不改迁移。
2. F1 后 v5 77 例(全 Raman)的段汇总与回放结果必须与改前逐字节一致——岁差参数缺省值仍为 raman。
3. 用户文案不得出现秒级、「记录时间是错的 / 不准」。
4. 改既有断言写「原值 / 新值 / 原因」三栏;文档同提交更新(BUG_HISTORY、CHANGELOG、VOICE、DESIGN)。
5. 不得写入真实用户出生资料。
## 5. 任务分解
| 项 | 内容 | 验收 |
| --- | --- | --- |
| F1 | 端点收 `ayanamsa`(白名单,与 `resolveAyanamsa` 取值一致,缺省 raman),进 profile、`compute_chart`、payload、缓存键;`fetchOpeningBoundary` 与 `segment-scan.ts` 传 `resolveAyanamsa(snapshot)` | Python 测试:同窗口 raman / lahiri 分界不同且缓存键不同;缺省等于显式 raman;前端测试:两处调用的请求体带用户岁差;v5 回放逐字节一致 |
| F2 | D1 换段时开场句式 | 前端测试:D1 换 / 不换两种文案;VOICE 记录 |
| F3 | 提议状态在开案成功后才转 `opened` | 前端测试:开案失败后卡片可再开、文案不说「已经开始」;旧会话三种状态照常显示 |
| F4 | 报告与 BUG-1308 补「编码问题清单 + 下一步二选一结论 + 暂停」 | 文档;`tests/test_repo_privacy_markers.py` 过 |
## 6. 让步顺序
F1 不可让。F2 可延到下一轮(写进 BUG-1307 已知限制)。F3 可单独排期。F4 不可让(不写清下一步,研究等于没收口)。
## 7. 开工前置
```
git fetch origin --prune
git fetch origin +refs/heads/codex/rectification-nearest-boundary-opening-20261012:refs/remotes/origin/codex/rectification-nearest-boundary-opening-20261012
git fetch origin +refs/heads/codex/rectification-d12-parent-probes-research-20261012:refs/remotes/origin/codex/rectification-d12-parent-probes-research-20261012
git status -sb
```
在原 worktree 续做(或按同名分支新建 worktree);F3 待 explicit-entry 合入后从 `origin/staging` 开 `codex/rectification-offer-opened-state-20261012`。
## 8. BUG 编号
F1、F2 记入 BUG-1307;F3 记入 BUG-1306;F4 记入 BUG-1308;F1b 新建 **BUG-1309**(开工时核对最大号,写作时 1308)。
@@ -0,0 +1,72 @@
# TASK · 研究:父母静态是非题(D12)能不能区分分界两侧(BUG-1308,2026-10-12)
- 基线:`origin/staging` @ `fddddaab`。
- 分支 / worktree:`codex/rectification-d12-parent-probes-research-20261012` / `.worktrees/rectification-d12-parent-probes-research-20261012`
- 性质:**纯研究**,只新增 `scripts/research/`、`tests/`、`docs/research/`、`references/real_case_calibration/` 下的文件,不改生产代码。与 `TASK-rectification-explicit-entry-20261012`、`TASK-rectification-nearest-boundary-opening-20261012` 并行。
- BUG 编号:本单为 BUG-1308(已建记录,状态 open)。
## 1. 背景与事故实证
- 产品 10-12 拿上游 Skill 的一次私人样例校正来比较。上游在记录时间前后最近的分界(D12 上升换座)两侧,问了五道父母是非题,例如「母亲是否比父亲更多负责日常照料」「父亲是否长期两地奔波」。题不需要年份,答得快。
- 同一样例上游两次会话结论相反。下结论的那一次,核心论据是一条父母职业事实的**事后**解读(先知道答案再读盘),且它的另一候选窗对母亲一侧的匹配评价更高。单例、事后、无对照,不能当证据。
- 我方此前同类结论:不带年份的性格题信号弱,已降级为平局裁决(±1、不淘汰)。四类方法(人拍权重 / 数据权重 / 缺席证据 / 角度技法)都不能在分钟级提出信号(`docs/research/rectification_angle_timing_2026_10_01.md`、`rectification_nadi_seconds_2026_10_05.md`)。
- 上游参照(`/workspace/yinduzhanxing` `origin/main`,只作口径参照,不搬代码):
- `bbb16115`「bind parent rectification to D12」:`father_death` / `mother_death` 加 `vargas: [D1, D12]` 与 profile `parent_father_d12` / `parent_mother_d12`;
- `fe56dd84`:父亲事件主宫默认 9 宫(Parashari),另存北印 10 宫备选 profile;
- `e3bac915`:各领域事件绑分盘(婚恋 D9+UL、事业 D10+A10、财富 D2/D11 等)。
- 注意:上游绑的是**带日期的父母事件**(如过世),不是静态是非题。静态父母题在上游也没有评测;父母盲测与现实校准合同仍待占星师确认(`docs/research/astrologer_questions_v2_2026_10_02.md` 甲2)。
- sync5(`TASK-upstream-sync5-20261002` §2 第 3 条)当时不搬上游的显式权重 / 行运锚点 / 双轨门,理由是我方 v9 架构不同、待占星师甲8 / 甲9。也不搬父母 / 事业盲测与现实校准合同,理由是甲2、甲3 未定。本单不改这一决定,只测静态父母题本身有没有区分力。
- 我方 D12 现状:家人领域已用 D12(`scripts/active_rectification_event_engine.py` family 行、`scripts/rectification/event_probes.py` `SCORING_LAYERS`),用于带日期事件的计分,没有静态父母题。
## 2. 决策记录(产品 2026-10-12)
1. 同意把「父母静态是非题」作为候选题型研究;**过门才立实现单**,过门前不进生产题库、不计分。
2. 只用公开、可查来源的案例(AA / A 级出生时间),**不用任何真实用户资料**(包括本次私人样例)。
3. 判读规则必须**在看答案之前**预登记(参照 `docs/research/nadi_seconds_preregistration_2026_10_05.json` 的做法)。
## 3. 硬红线
1. 不改生产代码、打分、题库、`decision_policy`;不升算法身份。
2. 预登记文件先提交、后跑评测;预登记后改判据算作新一轮,旧结果照记。
3. 必须有乱序对照(把父母事实在案例间随机打乱 ≥200 次)和正对照(人为埋入「与 D12 读法一致」的事实,流程须能测出),否则结论无效。
4. 父母事实必须有可引用的出处(传记、访谈、权威百科条目),每条记来源链接与可信等级;拿不到出处的不入集。
5. 文档与数据集不得含真实用户资料;推送前跑 `tests/test_repo_privacy_markers.py`。
## 4. 任务分解
### R0 案例集
- 从 `references/real_case_calibration/holdout_v5_roster.json` 的 77 例与 AA 公开名人中,挑父母信息可查的案例,目标 ≥60 例。每例记录 4–6 条可核父母事实,对应上游五题的维度:主要照料者、家庭财务主导、父亲是否指导学业、父亲职业类型(体制 / 技能奔波 / 经商等)、父母是否长期分隔两地。答案取「是 / 否 / 不清楚」。
- 验收:`references/real_case_calibration/parent_facts_v1.json` + 来源表;不清楚率逐题报告。
### R1 预登记
- 每题写清:读哪张盘(D12;D1 9/4 宫与日、月作对照)、哪几宫 / 宫主 / 征象星、什么配置算「是」。给 9 宫父 / 10 宫父两种 profile(对照上游 `fe56dd84`),各自预登记。
- 验收:`docs/research/d12_parent_probes_preregistration_2026_10_12.json` 先于评测提交。
### R2 区分力评测
- 对每例,在真实出生时间 ±10 / ±30 窗口内,取 D12 上升最近的换段点两侧各一个代表分钟,看预登记判读对「真实一侧」的选中率。另报:同窗内全部 D12 段的头段命中、真值段保留。
- 对照:乱序对照的分布与真实值的分位;正对照能被测出;Bonferroni 校正后显著才算过。
- 稳健性:换岁差(Lahiri / Raman)、换交点(mean / true)后判读改变的比例。
- 验收:`docs/research/rectification_d12_parent_probes_2026_10_12.md` + 结果 JSON;脚本两次运行逐字节一致;Python 定向测试 + 快速门全量。
### R3 结论
- 过门(真实一侧选中率显著高于乱序对照,且岁差 / 交点稳健性改变比例 <20%):写实现建议(题干、计分方式,只作平局裁决还是计分,接到 `TASK-rectification-nearest-boundary-opening-20261012` 的首轮选题)。
- 不过门:写明「不上线」,给产品一句对外口径。
## 5. 让步顺序
案例不足 60 例 → 先用能凑到的跑并标「欠力」,不下「无效」结论;稳健性只做岁差一项。正 / 乱序对照不可让。
## 6. 开工前置命令
```bash
git fetch origin --prune
git worktree add -b codex/rectification-d12-parent-probes-research-20261012 .worktrees/rectification-d12-parent-probes-research-20261012 origin/staging
grep -oE "^## BUG-[0-9]+" docs/BUG_HISTORY.md | sort -t- -k2 -n | tail -1
export PYTHONHASHSEED=0
```
完成后 BUG-1308 改 closed(附结论)或转为实现单的引用。
@@ -0,0 +1,114 @@
# TASK · 对话里明说要校正就直接进入生时校正(BUG-1306,2026-10-12)
- 基线:`origin/staging` @ `fddddaab`(已部署代码 `de4cfeb8`,含 BUG-1247~1251「校正并入对话」第一步)。
- 分支 / worktree:`codex/rectification-explicit-entry-20261012` / `.worktrees/rectification-explicit-entry-20261012`
- 串行顺序:**本单最先做**。`TASK-rectification-nearest-boundary-opening-20261012` 若改到 `frontend/src/app/(app)/page.tsx`、`use-rectification-surface.ts` 或校正开场组件,必须等本单合入 staging 后再开工;`TASK-rectification-d12-parent-probes-research-20261012` 是纯研究,可并行。
- BUG 编号:本单修 BUG-1306(已存在,状态 investigating)。实现中另发现的问题从开工时 `docs/BUG_HISTORY.md` 最大号顺延(写作时最大为 BUG-1308)。
## 1. 事故实证
产品 10-11 在 staging 新对话第一句输入「请你帮我进行生时纠正」(本人档案,申报出生时段)。回复显示「已完成 3 步」,正文:
- 「收窄出生时间这件事能做……对得上,区间就能往里收」,要用户报有年月的经历;
- 全文没有提卡片,界面上也没有提议卡;
- 按「代表分钟」写出大运、子运的起止年月。
代码事实(行号按符号定位):
| 位置 | 事实 |
| --- | --- |
| `frontend/src/lib/consultation-thinking-plan.ts` `RECTIFICATION_OFFER_RULE` | 规则以「用户说你推得不准时」开头;「明确想核对出生时间」只是其中一支 |
| `frontend/src/mastra/consultation-tools.ts` `createRectificationOfferTool` | 工具只记录提议,进入校正要用户再点卡 |
| 同文件 `RECTIFICATION_OFFER_REFUSAL_NOTES` | `product_disabled`、`pricing_unavailable` 要求「正文不要提校正」 → 用户看到的是没回应 |
| `frontend/src/app/api/consult/route.ts` `declaredWindowInstruction` | 已写「精确应期一律不可用」,模型仍写了子运起止 |
| 同文件 natal / window 两处「运行合同不完整」重试 | 首轮没调计算工具就强制重跑 → 「要校正」这一轮也会被逼着先排盘写解读 |
| BUG-1247 | 删除首页入口与文字口令后,新用户进校正只剩「模型调工具 → 服务端放行 → 出卡 → 点卡」一条路 |
| BUG-1248 上线前模型实测 | 只测了四种说法,没有「首轮直接点名要校正」 |
## 2. 根因
1. 入口完全依赖模型判断,规则写成「推得不准」场景,没覆盖「用户直接要做校正」。
2. 服务端拒绝时有两种原因被设计成静默。
3. 普通对话模型没有「不能在对话里做校正」的边界,于是自己收经历、承诺收窄。用户照做也不会进入校正引擎计分。
4. 首轮强制排盘合同没有给「要校正」这轮留出口。
## 3. 决策记录(产品 2026-10-12)
1. **用户明确要求做生时校正时,直接进入校正**:不先写解读,不用点卡。是否「明确要求」由模型判断,**禁止正则 / 关键词**(BUG-976/977 口径不变)。
2. **推翻 BUG-1248 的两条**:
- 「工具只记录提议、进入要点卡」:对用户明确要求的这一轮,改为直接打开。「推得不准、可能是时间问题」时主动提议仍走卡片,不变。
- `product_disabled`、`pricing_unavailable`「正文不要提校正」:改为**必须用一句话说明现在做不了**,不得静默。措辞对照 `frontend/docs/VOICE.md`,不写价格、不写内部原因名。
3. 普通对话模型**不得**承诺「能收窄出生时间」,不得在对话里收集经历假装校正;用户要做校正,就走第 1 条。
4. 申报时段下不得写大运、子运的精确起止(守 `declaredWindowInstruction` 原有规定)。只补指令与回归测试,不改证据卡。
5. 服务端判定不变:本人、`isProductEnabled("rectification")`、无进行中 case、`feature_pricing` 有价,缺一不开。家人档案、已有进行中 case 照原口径一句话说明(进行中的 case 可给「接着做」入口,见 T3)。
## 4. 硬红线
1. 不用正则 / 关键词判断意图,不新增前端意图判断。
2. 不改数据库结构、不动迁移;不改校正引擎、打分、`decision_policy`、闸门。
3. 守 AGENTS §6:`scripts/jyotish_api_server.py` 类方法数不增长;`frontend/src/app/(app)/page.tsx` 的 `Home()` 中 `useState` / `useRef` 数不增长(新逻辑进 hook / lib)。
4. 不在任何文件、测试、日志里写真实用户资料;实测只用公开名人 golden 盘或虚构资料。
5. 计费:直接进入校正与原来点卡开案一样,走原有开案扣费路径,不新增扣费点;这一轮咨询如果没有写解读,不得按解读扣咨询点数(与 smalltalk 路径一样核对结算)。
6. 测试总数不得低于基线;改既有断言写「原值 / 新值 / 原因」三栏。
## 5. 任务分解
### T0 取证(先做,做不到写成环境缺口)
- 目标:确认 10-11 那一轮模型是否调用了 `offer-birth-time-rectification`,服务端返回了什么。
- 途径:`[agent-observability]` 只打到 web 容器控制台(`frontend/src/lib/agent-observability.ts` `consoleSink`)。需要 staging 主机 `docker logs`(按 `deploy/README.md`)里该时段 `toolCalls`。另一佐证:该会话的 `chat_sessions.messages` 里有没有 `rectificationOffer`。
- 没有主机 / 数据库访问:写进 `BLOCKED.md`,在进度记录给产品一条可照做的查法(只查工具名与结果,不复制正文与出生资料)。**不阻塞 T1–T5**。
- 验收:进度记录写明取证结果或环境缺口。
### T1 直达工具语义
- `offer-birth-time-rectification` 增加「用户这一轮明确要求做校正」这一参数,或拆出一个开案工具(执行方选一种,写理由)。由模型判断,不用正则。
- 服务端判定通过时,`run.completed` 事件(或新事件)带「直接打开」的指令和来源会话号;浏览器收到后调用现有打开流程:`useRectificationSurface` 的打开函数,`intent=homepage` 加 `sourceSessionId`,复用 BUG-1249/1250 的来源绑定与「回到刚才的对话」。
- 本轮助手消息只留一句过渡(例如「好,我们开始核对出生时间。」),消息上记一条与提议卡同结构的记录(状态直接为 `opened`),保证刷新后来源仍能找回(`findRectificationSource` 只认带 caseId 的提议卡)。
- 验收:单测覆盖「通过 → 直接打开且绑定来源」「同一轮不重复开案」「刷新后来源可找回」;`bindRectificationSource` 对 `opened` 记录幂等。
### T2 首轮合同豁免
- natal 与 window 两条路径的「运行合同不完整」重试:本轮已经走直达(T1 通过)时不重试、不要求计算工具,也不写解读。
- 服务端拒绝时同样不重试,只写一句说明。
- 验收:单测断言两条路径在直达 / 拒绝时都不触发重试;正常咨询轮重试行为不变(原测试不改或三栏说明)。
### T3 拒绝不静默
- 改 `RECTIFICATION_OFFER_REFUSAL_NOTES`:`product_disabled`、`pricing_unavailable` 改为一句话说明现在做不了。`case_in_progress` 除说明外,直达场景下直接打开那个进行中的 case(与历史里打开相同),不新开。
- 验收:四种拒绝各一条单测,断言 note 不含「不要提校正」;文案对照 VOICE.md。
### T4 普通对话的边界
- 在 `RECTIFICATION_OFFER_RULE`(只此一处定义)里加:不承诺收窄出生时间,不在对话里收集经历做校正;用户要做就调工具。
- `declaredWindowInstruction` 补一句:大运、子运只说「在哪一段附近」,不写起止年月日。
- 验收:指令内容测试;规则仍只定义一处。
### T5 模型实测(公开名人 golden 盘或虚构资料,至少 deepseek-flash 与产品默认模型各一遍,每场景 ≥2 次)
| 场景 | 期望 |
| --- | --- |
| 首轮直接点名:「请你帮我进行生时纠正」 | 直接打开,不写解读 |
| 不同说法:「帮我校正出生时间」「想核对一下出生时间准不准」「我出生时间是估的,帮我对一下」 | 直接打开 |
| 追问轮里才提出要校正 | 直接打开 |
| 只是不认同解读:「你说我事业不顺,我觉得挺顺的」 | **不得进入**,按纠正规则改读 |
| 过去的事对不上 + 时间是估的(未明确要校正) | 出提议卡(原行为) |
| 家人档案明确要校正 | 一句话说明暂不支持,不开案 |
| 服务端 `pricing_unavailable` | 一句话说明,不静默 |
| 申报时段普通咨询 | 不出现子运起止年月日、不承诺收窄 |
- 验收:进度记录逐场景给次数与结果;任一「不得进入」场景进入了即不通过。无模型凭据时写成环境缺口,并补 `docs/testing/` 真机清单。
## 6. 让步顺序
时间不够时依次放弃:T5 中第二个模型 → T0 取证(写缺口)。T1–T4 不可让。
## 7. 开工前置命令
```bash
git fetch origin --prune
git worktree add -b codex/rectification-explicit-entry-20261012 .worktrees/rectification-explicit-entry-20261012 origin/staging
grep -oE "^## BUG-[0-9]+" docs/BUG_HISTORY.md | sort -t- -k2 -n | tail -1
```
前端门禁:`tsc --noEmit` 0 错、`npm run lint` 0 error、`npm test` 失败名单与基线逐条相同、`next build` 后 `/` Static、首屏 gzip ±2%。改 UI 同提交更新 `frontend/DESIGN.md`;`docs/BUG_HISTORY.md` 的 BUG-1306 更新为 fixed-pending-verify 并写修复与验证。真机清单写到 `docs/testing/rectification-explicit-entry-20261012.md`。
@@ -0,0 +1,95 @@
# TASK · 生时校正开场:先说记录时间离最近分界多近,首轮只问这条分界两侧的不同(BUG-1307,2026-10-12)
- 基线:`origin/staging` @ `fddddaab`(已部署代码 `de4cfeb8`)。
- 分支 / worktree:`codex/rectification-nearest-boundary-opening-20261012` / `.worktrees/rectification-nearest-boundary-opening-20261012`
- 串行顺序:若本单改到 `frontend/src/app/(app)/page.tsx`、`frontend/src/hooks/use-rectification-surface.ts` 或校正开场组件,**必须等 `TASK-rectification-explicit-entry-20261012`(BUG-1306)合入 staging 后**再以新 staging 为基线开工;只改 `scripts/rectification/**` 与 `frontend/src/lib/rectification-agentic/**` 的部分可先做。与 `TASK-rectification-d12-parent-probes-research-20261012` 无文件交叉,可并行。
- BUG 编号:本单为 BUG-1307(已建记录,状态 open)。另发现的问题从开工时 `docs/BUG_HISTORY.md` 最大号顺延。
## 1. 事故实证
产品 10-12 拿上游 Skill(占星师本机运行)的一次校正和我们比较,认为上游「步骤短很多,还能出结果」。样例是一例私人资料,本单只记相对时刻。
上游的做法:以记录时间为锚扫描前后 30 分钟,找到最近的分盘换段点,在记录时间前约 17 秒。然后列出两侧不同的层(父母盘、事业盘、居住盘,本命上升星座不变),只问这几层的区别,五道是非题。
同一样例的 PM 复算(Swiss Ephemeris,Raman,与上游两份文件的日期在一天内一致),看用户一件「某年某月」的经历落在哪个子运小段:
| 出生时刻(相对记录时间) | 该月所处小段 |
| --- | --- |
| 前 8 分钟 | 月初 4 天为金星,之后为太阳 |
| 前 4 分钟(上游自己的点估计) | 太阳 → 月亮 |
| 前 30 秒 | 太阳 → 月亮 |
| 记录时间 | 太阳 → 月亮 |
同一输入,上游两次会话结论相反:一次「保留记录时间,没有足够依据改」;一次「记录时间是错的,真实时间在记录前 13 分钟到前 17 秒之间」。后一份的事件论据与它自己的点估计矛盾(见上表),实际只靠一条父母事实的事后解读。
**结论**:上游值得借的是呈现与选题顺序:用户一听就懂「为什么要答这几道题」,题也少。不借的是秒级口径和「记录时间是错的」。
我方现状(`fddddaab`,行号按符号定位,执行方开工先复核):
| 位置 | 现状 |
| --- | --- |
| `scripts/rectification/refinement_packet.py` `window_scan` | 逐分钟扫描 11 层(D1/D9/D10/D4/D5/D7/D12/D24/D2/D11/D30)及展示层,输出 `transitions`(层、时刻、前后星座)。**不以记录时间为锚,不算记录时间离最近换段点多远** |
| 同文件 `nakshatra_boundary` | 只看代表分钟的上升离星宿交界多近,出性格类二选一,不是分盘换段 |
| 同文件 `precision_stage` | 按 D1→D9→D10→D4→D5/D24 顺序给「还差什么」,与记录时间无关 |
| `frontend/src/lib/rectification-agentic/core/segment-summary.ts` | 段汇总只对 D1/D9/D10(`TARGET_CHARTS`),按领域取盘(`targetChartsForDomain`) |
| `frontend/src/lib/rectification-agentic/core/segment-probe-order.ts` | 按段信息增益排题,窗口 ≤61 分钟默认开(BUG-1143/1144);只排序,不限定题目范围 |
| `frontend/src/lib/rectification-agentic/birth-time-provenance.ts` `reportedTimeOffsetCopy` | 用户能看到的「记录时间」只出现在交付时,说的是记录时间与**交付区间**差几分钟,不是与分界的距离 |
用户在开场看不到「记录时间离分界多近、两侧哪里不同」。
## 2. 根因
扫描数据已经有了,但从没以记录时间为锚组织过:开场不讲,选题面向整个窗口,所以用户感觉步骤多、目的不明。
## 3. 决策记录(产品 2026-10-12)
1. 采纳「最近分界开场」:开场告诉用户,记录时间离最近的分盘换段点有多近(以分钟计)、两侧哪几层不同(用人话:父母、工作、住处、婚恋……)。
2. 记录时间离分界近时,首轮只出能区分这条分界两侧的题,问完再按原流程。「近」的阈值由执行方用 77 例回放定,写进进度记录。
3. **只改呈现与选题顺序,不改打分。** `decision_policy`、闸门、4 件 3 域(BUG-1193)、采用门、交付卡档位全部不动。
4. 对外不说秒级(守 `docs/research/rectification_nadi_seconds_2026_10_05.md` §8 口径);不出现「记录时间是错的 / 不准」(守 BUG-691「记录优先、分歧并列」)。
## 4. 硬红线
1. 不改打分、`scripts/rectification/decision_policy.py`、闸门常量、4 件 3 域、算法身份;同输入同分。
2. v5 77 例持久回放(`docs/research/varga_resolution_persisted_onoff_after_fix_2026_10_01.json` 同法):±10 / ±30 真值段保留不低于 76 / 76;D9、D10 头段命中不低于修后基线;任一格下降即停,报产品。
3. 用户可见文案不得出现秒、「记录时间错了 / 不准」、「已确认」;距离以整分钟说,不足 1 分钟说「不到 1 分钟」。
4. 申报方式为 `period_only` 时没有可比的记录分钟,不出「离分界多近」这一句,走原流程。
5. 守 AGENTS §6(Home() 状态数、API server 方法数不增长);新文案对照 VOICE.md;改 UI 同提交更新 `frontend/DESIGN.md`。
6. 测试总数不低于基线;改既有断言写三栏。
## 5. 任务分解
### T1 以记录时间为锚的分界计算(Python,薄注册)
- 在 `scripts/rectification/` 新模块里,基于 `window_scan` 的 `transitions`,给出:记录时间前后最近的换段点(分钟)、该点两侧不同的层清单、各层前后星座。只读已有扫描,不改 `window_scan` 的输出语义。
- 验收:Python 定向测试用公开名人 golden 盘(AA 级);记录时间正好在段内、紧贴换段、窗口内无换段三种情况各一条;快速门全量。
### T2 开场一句话
- 校正开场(BUG-1257 开场切片所在处,执行方先定位)加一句:记录时间离最近换段点多近、两侧哪几件事不同,并说明接下来先问这几件。窗口内无换段时说「你填的时间附近这些盘都不换,先从经历开始」。
- 验收:单测覆盖三种情况与 `period_only`;文案逐条对照 VOICE.md,不含红线 3 的词。
### T3 首轮选题限定在该分界
- 记录时间离分界在阈值内时,首轮候选题只取能区分两侧的层对应的题(复用现有题库与 `segmentInformationGain`,不新增题型);问完回到原排序。
- 阈值用 77 例回放选,候选值至少 3 档(例如 2 / 5 / 10 分钟),逐档报问题数、真值段保留、头段命中。
- 验收:回放表进进度记录;红线 2 全过才默认开启,否则留开关默认关并报产品。
### T4 真机清单
- `docs/testing/rectification-nearest-boundary-opening-20261012.md`:用公开名人资料或虚构资料,记录时间紧贴分界与远离分界各走一遍。
## 6. 让步顺序
T3 回放不过门 → 只上 T1 + T2(呈现),T3 留开关默认关。T2 不可让。
## 7. 开工前置命令
```bash
git fetch origin --prune
git worktree add -b codex/rectification-nearest-boundary-opening-20261012 .worktrees/rectification-nearest-boundary-opening-20261012 origin/staging
grep -oE "^## BUG-[0-9]+" docs/BUG_HISTORY.md | sort -t- -k2 -n | tail -1
export PYTHONHASHSEED=0 # 回放命令见 docs/research/rectification_varga_resolution_2026_09_30.md 附录
```
门禁:Python 快速门全量、前端 tsc / lint / npm test(失败名单与基线逐条相同)/ build `/` Static / gzip ±2%。完成后 BUG-1307 改 fixed-pending-verify,写修复、验证与回放数字。
@@ -0,0 +1,16 @@
# 真机清单 · 明说要校正就直接进入(2026-10-12)
对应 BUG-1306,任务书 `docs/tasks/TASK-rectification-explicit-entry-20261012.md` 的 T5。在 staging 上用自己的账号走。deepseek-v4-flash 一遍,产品默认模型再一遍。每个场景至少两次。回答原文和出生资料不记进仓库。本人档案除第 6 步外都用本人。申报时段用第 8 步;其余有分钟的档案即可。
| 步 | 怎么做 | 通过标准 |
| --- | --- | --- |
| 1 | 新对话第一句:「请你帮我进行生时纠正」 | 直接打开校正。这一轮不写解读,不用点卡。过渡只有一句 |
| 2 | 另开三条新对话,分别说「帮我校正出生时间」「想核对一下出生时间准不准」「我出生时间是估的,帮我对一下」 | 三次都直接打开,不先出卡 |
| 3 | 新对话先问一件普通的事,答完后再说「请帮我做生时校正」 | 这一轮直接打开,不把刚才的解读再写一遍 |
| 4 | 新对话问事业,答完后说「你说我事业不顺,我觉得挺顺的」 | 认下、改读。不进入校正,下面没有提议卡 |
| 5 | 接着说一件过去的事和盘对不上,并说出生时间是估的,但不要说「帮我校正」 | 回答下面出现提议卡。不直接打开校正 |
| 6 | 换到一个家人档案,第一句就说「请帮我校正出生时间」 | 一句「给家人校正暂时还不支持」。不打开校正 |
| 7 | 在校正开关关闭,或价格暂时不可用时,本人说「请帮我做生时校正」 | 一句「出生时间核对这会儿还做不了。」不沉默,不写价格,不写内部原因。构造不了这一步就在进度里标环境缺口,不要改开关去试 |
| 8 | 申报出生时段的本人档案,问一件普通的事,不要提校正 | 正文不写大运、子运的起止年月日,也不承诺能收窄出生时间 |
做不了的两句和家人那句以界面上的原句为准。点数:直接进入的那一轮不按解读扣咨询点;校正本身仍按原来的开案扣费。
+2
View File
@@ -11,6 +11,8 @@
不新增等待动画;打开校正沿用既有一次揭幕。
**明确要求则直接打开(2026-10-12,BUG-1306)**:用户这一轮明说要做校正时,不再先出卡再点。回答只留一句过渡,界面沿用既有一次揭幕直接打开校正面。没有新的按钮、输入框、等待动画或 Home 状态。推得不准时的主动提议仍用 `.rectification-offer-card`。做不了时只显示一句话,不静默,也不在对话里假装校正。开案还没成功时,同一张提议卡仍是普通提议(不说「已经开始」,按钮仍是「开始核对出生时间」)。开案成功并绑定来源后才换成已开始的文案。打开失败时按钮仍可点。
## 用量页分段计时(2026-10-05,在途)
`/admin/usage` 在原表格上多一列「分段」。格子里四行:分类耗时、第 0 步、第 1 步、第 1 步开始到第一个正文字符。每步带推理、输出、输入、缓存输入 token。供应商没返回的数显示「—」。不新开页面,不改表,数字来自 `usage_ledger.metadata` 里已有的 JSON。没有新的等待动画或输入框。
+6 -4
View File
@@ -23,11 +23,13 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
首页开场语下面可以有一行今日趋势(今日星语卡片的 trend,每日生成);还没生成时写「今天的星语还没写出来。」,没有出生分钟时不写。入口按钮下面平时不写字,只在用户需要动手时写一句:有没做完的校正写「上次那次校正还没完成,可以在历史对话里接着做。」;当前人物不是本人写「生时校正暂时只支持本人。」。不再写「不确定出生时间时,用记得住的经历一步步缩小范围」「上次已经校正完,可以拿最新资料再来一次」,也不要用「·」把两句不相干的提示拼成一行。
## 生时校正从对话进入(2026-10-07,BUG-1247~1251)
## 生时校正从对话进入(2026-10-07,BUG-1247~1251;2026-10-12,BUG-1306)
- **何时提议**:用户说推得不准时先分清读法问题和时间问题。只是不认同某个解释,按纠正规则认下、改读,不提校正;说出过去的具体事情和盘对不上、说出生时间是估的或记不准、或明确想核对出生时间,agent 才调用提议工具。同一次对话提议过、用户没接,不再主动提;规则只定义在 `consultation-thinking-plan.ts` 的 `RECTIFICATION_OFFER_RULE`。不用关键词判断。
- **正文怎么提**:一句,放在最后一段(例:「如果想确认出生时间准不准,可以用下面的卡片,答几道过去经历的题核对一下。」),不写价格和题数——卡片上写。给家人档案时说「给家人校正暂时还不支持」,不出卡。
- **卡片**:「可以用几道过去经历的题,核对一下你的出生时间。大约 N 题,会用掉 M 点;开始答第一题时才扣。」按钮「开始核对出生时间」;已开始「出生时间核对已经开始。」/「继续核对出生时间」。点数来自 `feature_pricing`,不写死。
- **何时提议**:用户说推得不准时先分清读法问题和时间问题。只是不认同某个解释,按纠正规则认下、改读,不提校正。不承诺能收窄出生时间,不在对话里收集经历做校正。说出过去的具体事情和盘对不上、说出生时间是估的或记不准、但没有明确要做校正,才调用提议工具。同一次对话提议过、用户没接,不再主动提;用户自己再要求时传 `userAsked=true`。规则只定义在 `consultation-thinking-plan.ts` 的 `RECTIFICATION_OFFER_RULE`。不用关键词判断。
- **明确要求则直接进入**:用户这一轮明确要做校正,由模型判断,不用关键词。服务端放行后直接打开校正:不先写解读,不用点卡。过渡只有一句「好,我们开始核对出生时间。」已经有一次没做完的,直接接着做那一次,不新开。开案还没成功时,回答下面仍是普通提议卡,不说「已经开始」;打开失败可以再点「开始核对出生时间」。开案并绑定来源之后才改成「已经开始」。
- **做不了必须说一句**:开关关着,或暂时没有价格,都说「出生时间核对这会儿还做不了。」不写内部原因,不写价格,不得沉默。家人档案说「给家人校正暂时还不支持」,不开案。有进行中的校正但这次打不开原来的会话,说「可在历史对话里接着做。」
- **正文怎么提**(没有明确要求、只是时间可能不准):一句,放在最后一段(例:「如果想确认出生时间准不准,可以用下面的卡片,答几道过去经历的题核对一下。」),不写价格和题数——卡片上写。
- **卡片**:「可以用几道过去经历的题,核对一下你的出生时间。大约 N 题,会用掉 M 点;开始答第一题时才扣。」按钮「开始核对出生时间」;已开始(开案并绑定来源之后,不是结算当时)「出生时间核对已经开始。」/「继续核对出生时间」。点数来自 `feature_pricing`,不写死。
- **回来**:分隔线「出生时间改为 HH:MM(生时校正)」/「出生时间没有改」;之前的回答上方「按校正前的时间」;按钮「按新时间重新看:「原问题」」。校正面按钮「回到刚才的对话」。不再说「新建对话」。
## 生时校正服务器固定句(2026-10-07,BUG-1258)
+1 -1
View File
@@ -735,7 +735,7 @@ export default function Home() {
startNewChat,
continueInNewChat,
refreshAccount,
openAccountDialog, openRectificationSession,
openAccountDialog, openRectificationSession, openRectificationFromChat,
onboardingPaywallShown, setOnboardingPaywallOpen,
});
const jumpToLatestVisible = !rectificationSurfaceOpen
+99 -15
View File
@@ -15,7 +15,8 @@ import { recordModerationEvent } from "@/lib/moderation/log";
import { consultationDomainSchema } from "@/lib/consultation-domain-registry";
import { parseAgentReply } from "@/lib/agent-reply";
import { revisitQuestionForOffer, sessionHasRectificationOffer } from "@/lib/rectification-in-chat";
import { decideRectificationOffer } from "@/lib/rectification-in-chat-server";
import { decideRectificationOffer, persistOpenedDirectOffer } from "@/lib/rectification-in-chat-server";
import { getRectificationEntrySummary } from "@/lib/rectification-agentic/v9/case-service";
import { createConsultationReplyMetadata } from "@/lib/consultation-reply-metadata";
import {
ConsultationPlanValidationError,
@@ -244,7 +245,7 @@ function generalNoMinuteInstruction(hasPublicDaily: boolean) {
}
function declaredWindowInstruction() {
return "当前是声明出生窗口咨询,没有单一出生分钟。如需个人结构结论,必须调用 run-jyotish-window-consultation。探针时刻不是出生时间,不得把 00:00、时段中点、中午或任一探针写成出生分钟。只能根据稳定层作方向性回答;上升或宫位若在窗口内变化,列出可能星座,不得写成「你的上升是 X」。精确应期一律不可用。这只是粗看。" + RECTIFICATION_OFFER_RULE;
return "当前是声明出生窗口咨询,没有单一出生分钟。如需个人结构结论,必须调用 run-jyotish-window-consultation。探针时刻不是出生时间,不得把 00:00、时段中点、中午或任一探针写成出生分钟。只能根据稳定层作方向性回答;上升或宫位若在窗口内变化,列出可能星座,不得写成「你的上升是 X」。精确应期一律不可用。大运、子运只说「在哪一段附近」,不写起止年月日。这只是粗看。" + RECTIFICATION_OFFER_RULE;
}
function usesPublicDailyGeneralAgent(
@@ -902,6 +903,55 @@ export async function POST(request: Request) {
}
}
// Explicit rectification (BUG-1306): one short sentence, same free settlement
// as smalltalk, so a turn with no reading does not spend a consultation credit.
// The RPC message is only role/text/responseKind. A new case's offer is patched
// onto the session message afterwards and is not written back onto the request.
async function completeDirectRectificationTurn(
usage: Promise<{ inputTokens?: number; outputTokens?: number }>,
): Promise<AgentSettlementResult> {
const entry = latencyState?.rectificationDirectEntry;
const offer = latencyState?.rectificationOffer;
if (!entry) throw new Error("direct_entry_missing");
try {
const actualUsage = await usagePayload(usage);
const responseMessage = { role: "assistant" as const, text: entry.sentence, responseKind: "smalltalk" as const };
const completion = await retryDetachedSettlement(async () => {
const { data, error } = await accounting.rpc("complete_consultation_free", {
p_user_id: userId,
p_request_id: requestId,
p_session_id: sessionId,
p_response_message: responseMessage,
p_actual_usage: actualUsage,
});
const parsedCompletion = consultationCompletionSchema.safeParse(first(data ?? []));
if (error || !parsedCompletion.success) {
throw new CreditRpcError(error?.message || "invalid_free_completion");
}
if (!parsedCompletion.data.success && parsedCompletion.data.error_code !== "request_cancelled") {
throw new CreditRpcError(parsedCompletion.data.error_code || "free_completion_rejected");
}
if (parsedCompletion.data.success && entry.kind === "new" && offer) {
await persistOpenedDirectOffer({
client: supabase,
userId,
sessionId,
sentence: entry.sentence,
offer,
});
}
return parsedCompletion.data;
});
if (!completion.success && completion.error_code !== "request_cancelled") {
throw new CreditRpcError(completion.error_code || "free_completion_rejected");
}
return "completed";
} catch (error) {
await cancel();
throw error;
}
}
let settlement: Promise<AgentSettlementResult> | null = null;
function settleResult(action: () => Promise<AgentSettlementResult>) {
settlement ??= action();
@@ -966,6 +1016,21 @@ 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;
},
};
const hooks = createConsultationRuntimeHooks(state);
const usages: Promise<Usage>[] = [];
@@ -1203,6 +1268,28 @@ export async function POST(request: Request) {
}
: { route: "pending", status: "blocked", preciseTiming: "blocked", missingLayers: [] };
const settleAnswer = (
output: string,
agentExecutionReceipt: AgentExecutionReceipt,
techniqueTruth: string,
workflow: WorkflowReceipt,
thinkingText?: string,
thinkingSections?: PublicThinkingSection[],
) => {
if (state.rectificationDirectEntry) {
return settleRun(() => completeDirectRectificationTurn(mergeUsage(usages)), undefined);
}
return settleRun(() => completeResponse(
output,
mergeUsage(usages),
techniqueTruth,
workflow,
agentExecutionReceipt,
thinkingText,
thinkingSections,
), undefined);
};
if (usesPublicDailyGeneralAgent(consultationMode, generalDailyContext)) {
state.workflowReceipt = workflowReceipt;
const agent = getGeneralJyotishAgent(selectedModel);
@@ -1260,15 +1347,14 @@ export async function POST(request: Request) {
headers: { "x-jyotish-birth-time-mode": consultationMode },
onFirstActivity: markFirstActivity,
onFirstOutput: markFirstText,
onComplete: (output, agentExecutionReceipt, thinkingText, thinkingSections) => settleRun(() => completeResponse(
onComplete: (output, agentExecutionReceipt, thinkingText, thinkingSections) => settleAnswer(
output,
mergeUsage(usages),
agentExecutionReceipt,
generalDailyContext ? "public-panchanga-only" : "not-applicable",
workflowReceipt,
agentExecutionReceipt,
thinkingText,
thinkingSections,
), undefined),
),
onError: (error) => settleRun(
cancel,
toAgentObservabilityErrorCode(error),
@@ -1393,15 +1479,14 @@ export async function POST(request: Request) {
headers: { "x-jyotish-birth-time-mode": consultationMode },
onFirstActivity: markFirstActivity,
onFirstOutput: markFirstText,
onComplete: (output, agentExecutionReceipt, thinkingText, thinkingSections) => settleRun(() => completeResponse(
onComplete: (output, agentExecutionReceipt, thinkingText, thinkingSections) => settleAnswer(
output,
mergeUsage(usages),
agentExecutionReceipt,
state.techniqueTruth ?? "declared-window",
state.workflowReceipt ?? workflowReceipt,
agentExecutionReceipt,
thinkingText,
thinkingSections,
), undefined),
),
onError: (error) => settleRun(
cancel,
toAgentObservabilityErrorCode(error),
@@ -1506,16 +1591,15 @@ export async function POST(request: Request) {
onFirstActivity: markFirstActivity,
onFirstOutput: markFirstText,
onComplete: (output, agentExecutionReceipt, thinkingText, thinkingSections) => {
answerForTelemetry = output;
return settleRun(() => completeResponse(
if (!state.rectificationDirectEntry) answerForTelemetry = output;
return settleAnswer(
output,
mergeUsage(usages),
agentExecutionReceipt,
state.techniqueTruth ?? "unknown",
state.workflowReceipt ?? workflowReceipt,
agentExecutionReceipt,
thinkingText,
thinkingSections,
), undefined);
);
},
onError: (error) => settleRun(
cancel,
@@ -26,6 +26,8 @@ export function RectificationOfferCard({
onStart,
}: Readonly<{ offer: RectificationOfferRecord; disabled: boolean; onStart: () => void }>) {
if (offer.status === "returned") return null;
// 「已经开始」 / 继续核对 only after the case is bound. autoOpen still shows
// the ordinary offer and 「开始核对出生时间」 until status is opened.
const opened = offer.status === "opened";
return (
<div className="rectification-offer-card" role="group" aria-label="核对出生时间">
+20 -1
View File
@@ -155,6 +155,8 @@ export type ConsultationRunParams = {
onboardingPaywallShown?: MutableRefObject<boolean>;
setOnboardingPaywallOpen?: (open: boolean) => void;
openRectificationSession: (exactSessionId: string) => Promise<void> | void;
/** Direct entry from this consultation (BUG-1306). Same opener as the offer card. */
openRectificationFromChat: (sourceSessionId: string) => Promise<unknown> | void;
};
export function useConsultationRun(params: ConsultationRunParams) {
@@ -215,6 +217,7 @@ export function useConsultationRun(params: ConsultationRunParams) {
onboardingPaywallShown,
setOnboardingPaywallOpen,
openRectificationSession,
openRectificationFromChat,
} = params;
// First refusal for credits on this page → redeem paywall; later ones → billing (P4, BUG-1080).
const openRedeemPaywallOnce = () => tryOpenRedeemPaywall({
@@ -874,6 +877,7 @@ export function useConsultationRun(params: ConsultationRunParams) {
};
let agentExecutionReceipt: AgentExecutionReceipt | undefined;
let rectificationOffer: RectificationOfferRecord | undefined;
let rectificationDirectOpen: Extract<ConsultationAgentPublicEvent, { type: "run.completed" }>["rectificationDirectOpen"];
let runCompleted = false;
let truncatedFailure: Extract<ConsultationAgentPublicEvent, { type: "run.failed" }> | undefined;
const reader = response.body.getReader();
@@ -953,6 +957,7 @@ export function useConsultationRun(params: ConsultationRunParams) {
if (event.type === "run.completed") {
runCompleted = true;
rectificationOffer = event.rectificationOffer;
rectificationDirectOpen = event.rectificationDirectOpen;
if (event.responseKind === "smalltalk") {
responseKind = "smalltalk";
} else if (event.receipt) {
@@ -1054,7 +1059,10 @@ export function useConsultationRun(params: ConsultationRunParams) {
messages: [...userSession.messages, {
role: "assistant",
text: reply.text,
...(responseKind === "smalltalk" ? { responseKind } : {
...(responseKind === "smalltalk" ? {
responseKind,
...(rectificationOffer ? { rectificationOffer } : {}),
} : {
...(streamedThinking.trim() ? { thinkingText: streamedThinking.trim().slice(0, 4000) } : {}),
...(thinkingSections.length ? { thinkingSections } : {}),
techniqueTruth,
@@ -1066,6 +1074,17 @@ export function useConsultationRun(params: ConsultationRunParams) {
updatedAt: timestamp(),
};
updateSession(sessionId, () => completedSession);
if (rectificationDirectOpen?.mode === "resume" && rectificationDirectOpen.resumeSessionId) {
void openRectificationSession(rectificationDirectOpen.resumeSessionId);
} else if (rectificationDirectOpen?.mode === "new") {
// Stored offer stays offered. bindOfferToCase marks opened only after
// the case opens. A failed open must not mark it opened here.
void Promise.resolve(openRectificationFromChat(rectificationDirectOpen.sourceSessionId)).then((opened) => {
if (opened) return;
}, () => {
// The opener threw. Leave the stored offer offered.
});
}
try {
await persistSession(completedSession);
} catch (error) {
@@ -369,8 +369,8 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
/** C4: the offer card under a consultation answer starts (or resumes) the case. */
async function openRectificationFromChat(sourceSessionId: string) {
if (rectificationLimitedToSelf()) return;
await openRectificationCase("homepage", null, sourceSessionId);
if (rectificationLimitedToSelf()) return null;
return openRectificationCase("homepage", null, sourceSessionId);
}
async function openRectificationSession(exactSessionId: string) {
+14 -7
View File
@@ -54,12 +54,19 @@ export function scheduleConsultPacketWarm(
const body = consultWarmBodyFromColumns(columns);
if (!body) return false;
const apiBase = deps.apiBase ?? "http://127.0.0.1:5200";
deps.after(() => {
return deps.fetch(`${apiBase}/api/consultation_workflow`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
}).then(() => undefined).catch(() => undefined);
});
// Warm is best effort. `after` throws outside a request scope; the save has
// already been written by then, so a scheduling failure must not turn it
// into a 500 (BUG-1305).
try {
deps.after(() => {
return deps.fetch(`${apiBase}/api/consultation_workflow`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
}).then(() => undefined).catch(() => undefined);
});
} catch {
return false;
}
return true;
}
@@ -139,12 +139,21 @@ const sessionTitleSchema = z.object({
type: z.literal("session.title"),
title: z.string().trim().min(1).max(48),
}).strict();
/** Browser opens rectification from this turn without a card click (BUG-1306). */
const rectificationDirectOpenSchema = z.object({
sourceSessionId: z.string().uuid(),
mode: z.enum(["new", "resume"]),
resumeSessionId: z.string().uuid().optional(),
}).strict();
const runCompletedSchema = z.object({
type: z.literal("run.completed"),
receipt: agentExecutionReceiptSchema.optional(),
responseKind: z.literal("smalltalk").optional(),
/** The answer offered birth-time rectification (BUG-1248); the card hangs under it. */
rectificationOffer: rectificationOfferRecordSchema.optional(),
/** Present for a direct open or resume. A refusal is smalltalk and omits this. */
rectificationDirectOpen: rectificationDirectOpenSchema.optional(),
}).strict();
// A failure is the case the receipt is most needed for, so it carries the same
// allowlisted receipt a completed run does. It stays optional because the
@@ -90,13 +90,21 @@ export const CONSULT_BRIEF_CHECKLIST_SOURCE = "consultation checklist (TASK-cons
export const CAREER_SIGNIFICATOR_STRENGTH_RULE =
"判断表里的代表星(自然代表星、Jaimini 代表星、大运主星)只用来看力量与受冲,不按星的本性推断职业类型、做事方式或台前 / 幕后。";
/**
* Product 2026-10-09 residual: saying "do not infer" was not enough. The
* direction section must say what it is allowed to contain. Career type and
* working style still ask the user (CAREER_FIELD_ASK_RULE). Checklist only.
*/
export const CAREER_DIRECTION_SECTION_RULE =
"「方向」一节只写力量、阻力、时间与三件事(机会出现 / 成形 / 公开落地),职业类型与工作方式一律问用户。三件事用生活说法,不报层名和编号,不得合成一句「事业机会」:①机会出现(消息、邀约、初步接洽)②成形(合同、合作、长期项目)③公开落地(发布、到账、被看见、名声)。";
export const CONSULTATION_CONDENSED_CHECKLISTS: Readonly<Partial<Record<ConsultationDomain, readonly string[]>>> = {
career: [
CAREER_FIELD_ASK_RULE,
CAREER_SIGNIFICATOR_STRENGTH_RULE,
"必看:D1 10 宫与 10 宫主、AmK;卡上 d9 段确认 10 宫主与 AmK 的旺弱、Vargottama、D1/D9 反转;D10 上升与 10 宫;AL 与 A10;10 宫 SAV 与木星、土星所在宫的 SAV。",
"时间:Vimshottari(MD/AD/PD)与 Narayana 双轨同向才谈应期,看大运主与 10 宫、10 宫主、D10 的关系;每颗星先按功能吉凶定性。",
"分三件事看(回答里用生活说法,不报层名和编号),不得合成一句「事业机会」:①机会出现(消息、邀约、初步接洽)②成形(合同、合作、长期项目)③公开落地(发布、到账、被看见、名声)。",
CAREER_DIRECTION_SECTION_RULE,
"禁写:本命承诺弱时,不得因一段大运或一次行运断言「事业必成」;不得把接触窗写成落地窗。",
"AL 落第几宫要说出来(说明名声和外界印象这条线有没有力量,不据此断定公众型或幕后型);10 宫主、AmK 的受冲按通用读法。",
"卡外按需补取最多三次:Karakamsha、宫主链、D1→D9→D10 联动、Argala、Chara 大运;KP 精确宫头仍 blocked,只作参考、不作依据。",
@@ -16,8 +16,20 @@ export const JUDGMENT_TABLE_LABELS = {
relationOccupant: "落入",
relationAspect: "照",
relationConjunctLord: "与宫主同宫",
careerSignificatorReading: "这行只看力量与受冲,不按这颗星的本性写职业类型、做事方式或方向。",
} as const;
const CAREER_JAIMINI_ROLES = new Set(["AmK", "DK", "PK", "MK", "PiK"]);
/** Presentation only. Career significator rows keep their copied values. */
function careerSignificatorReading(domain: string, role: string | null): { reading: string } | Record<string, never> {
if (domain !== "career" || !role) return {};
const dasha = role.startsWith("Vimshottari");
const named = role === "natural" || role === "lord" || role === "Narayana MD lord" || CAREER_JAIMINI_ROLES.has(role);
if (!dasha && !named) return {};
return { reading: JUDGMENT_TABLE_LABELS.careerSignificatorReading };
}
const MALEFIC_BODIES = ["Saturn", "Mars", "Rahu", "Ketu"] as const;
const ASPECT_MALEFICS = ["Saturn", "Mars"] as const;
const DUSTHANA = new Set([6, 8, 12]);
@@ -247,6 +259,7 @@ export function assembleJudgmentTable(facts: unknown): JsonRecord {
const body = record(facts);
const planets = record(body.planets);
const houses = record(body.houses);
const domain = typeof body.domain === "string" ? body.domain : "";
const rows: Json[] = [];
const factors = Array.isArray(body.factors) ? body.factors : [];
for (const factor of factors) {
@@ -276,6 +289,7 @@ export function assembleJudgmentTable(facts: unknown): JsonRecord {
role: "lord",
citation,
...planetColumns(lord, planets, houses, "lord"),
...careerSignificatorReading(domain, "lord"),
});
} else {
rows.push({ id: `${id}:lord`, kind: "gap", citation, path: leaf(house.lord).path ?? null });
@@ -283,12 +297,14 @@ export function assembleJudgmentTable(facts: unknown): JsonRecord {
continue;
}
if (item.kind === "planet" && typeof item.planet === "string") {
const role = typeof item.role === "string" ? item.role : null;
rows.push({
id,
kind: "planet",
role: typeof item.role === "string" ? item.role : null,
role,
citation,
...planetColumns(item.planet, planets, houses, typeof item.role === "string" ? item.role : undefined),
...planetColumns(item.planet, planets, houses, role ?? undefined),
...careerSignificatorReading(domain, role),
});
continue;
}
@@ -312,7 +328,7 @@ export function assembleJudgmentTable(facts: unknown): JsonRecord {
}
return {
header_note: typeof body.header_note === "string" ? body.header_note : JUDGMENT_TABLE_LABELS.rahuNote,
domain: typeof body.domain === "string" ? body.domain : "",
domain,
domain_varga: typeof body.domain_varga === "string" ? body.domain_varga : "",
rows,
};
@@ -374,6 +390,7 @@ function compactPlanetRows(rows: Json[]): Json[] {
const repeat: JsonRecord = { id: row.id, kind: row.kind, planet: row.planet };
if (typeof row.role === "string") repeat.role = row.role;
if (typeof row.citation === "string") repeat.citation = row.citation;
if (typeof row.reading === "string") repeat.reading = row.reading;
if (row.affliction !== undefined) repeat.affliction = row.affliction;
return repeat;
});
@@ -455,11 +455,11 @@ export function natalAnswerShapeInstruction(): string {
}
/**
* C2 (TASK-rectification-in-chat-step1-20261006, BUG-1248): when "推得不准"
* may be the birth time, the agent offers rectification through the offer
* tool; it is never decided from keywords. Defined here once.
* C2 (BUG-1248) plus direct entry when this turn explicitly asks (BUG-1306).
* Never decided from keywords. Defined here once; the offer tool names the
* same parameters and does not restate this rule.
*/
export const RECTIFICATION_OFFER_RULE = "出生时间核对:用户说你推得不准时,先分清是读法问题还是时间问题。只是不认同某个解释,按纠正规则认下、改读,不提校正。用户说出过去某件具体的事和盘对不上、说自己的出生时间是估的或记不准、或明确想核对出生时间,才在写回答之前调用 offer-birth-time-rectification,再按工具结果在正文里用一句话提一下(不写价格和题数,卡片上有)。同一次对话提议过、用户没接,就不再主动提;用户自己再要求时调用并传 userAsked=true。工具说不能提供时(比如问的是家人),照工具说明用一句话说清楚。";
export const RECTIFICATION_OFFER_RULE = "出生时间核对:用户说你推得不准时,先分清是读法问题还是时间问题。只是不认同某个解释,按纠正规则认下、改读,不提校正。不承诺能收窄出生时间,不在对话里收集经历做校正。用户这一轮明确要做校正,就在写回答之前调用 offer-birth-time-rectification 并传 explicitRequest=true;工具通过后只写那一句过渡,不写解读。用户说出过去某件具体的事和盘对不上、说自己的出生时间是估的或记不准、但没有明确要做校正,才在写回答之前调用 offer-birth-time-rectification,再按工具结果在正文里用一句话提一下(不写价格和题数,卡片上有)。同一次对话提议过、用户没接,就不再主动提;用户自己再要求时调用并传 userAsked=true。工具说不能提供时(比如问的是家人),照工具说明用一句话说清楚。";
/** D4 / BUG-1166: the user corrects an earlier reading; acknowledge it, never defend the reading. */
export const FOLLOW_UP_CORRECTION_RULE = "用户纠正了前面的判断(比如说父亲其实不常在身边):先认下这件事,再只说盘上真正对得上的那几条、原来哪句读偏了;盘本身分不出的,直说「盘本身分不出这一点」,不把普通配置说成必然;不为原判断辩护。";
@@ -7,11 +7,13 @@ import { getRectificationEntrySummary } from "@/lib/rectification-agentic/v9/cas
import { loadV9CaseDossier } from "@/lib/rectification-agentic/v9/tool-service";
import {
appendRectificationReturn,
attachOpenedOfferToFreeMessage,
bindOfferToCase,
decideRectificationOfferWith,
offerForCase,
sourceFromSessionRows,
type RectificationOfferDecision,
type RectificationOfferRecord,
type RectificationReturnRecord,
type RectificationSource,
} from "@/lib/rectification-in-chat";
@@ -61,6 +63,37 @@ async function writeMessages(client: SupabaseClient, userId: string, sessionId:
return !error;
}
/**
* After a free direct-entry reply is appended, hang the offer on that message.
* The record is not opened yet (`offered` + `autoOpen`); bindOfferToCase sets
* opened after the case actually opens. Idempotent: an offer already on the
* message (including one a later bind gave a case id) is left as it is.
* Does not touch consultation_requests.
*/
export async function persistOpenedDirectOffer(input: {
client: SupabaseClient;
userId: string;
sessionId: string;
sentence: string;
offer: RectificationOfferRecord;
}): Promise<void> {
const { data, error } = await input.client
.from("chat_sessions")
.select("messages")
.eq("id", input.sessionId)
.eq("user_id", input.userId)
.maybeSingle();
if (error || !data) throw new Error("direct_offer_session_unreadable");
const attached = attachOpenedOfferToFreeMessage((data as { messages?: unknown }).messages, {
sentence: input.sentence,
offer: input.offer,
});
if (attached === "present") return;
if (attached === "missing") throw new Error("direct_offer_message_missing");
const wrote = await writeMessages(input.client, input.userId, input.sessionId, attached);
if (!wrote) throw new Error("direct_offer_write_failed");
}
/** C4: after a case opens from a consultation, bind the offer card to it. Best effort. */
export async function bindRectificationSource(input: {
client: SupabaseClient;
+47 -1
View File
@@ -28,6 +28,12 @@ export const rectificationOfferRecordSchema = z.object({
offeredAt: z.string().min(1).max(40),
caseId: z.string().uuid().optional(),
rectificationSessionId: z.string().uuid().optional(),
/**
* Present only when this turn should open the case without a click.
* Writers omit it unless it is true. Older stored offers omit it too.
* The card stays an ordinary offer until status is opened (BUG-1306 F3).
*/
autoOpen: z.boolean().optional(),
}).strict();
export type RectificationOfferRecord = z.infer<typeof rectificationOfferRecordSchema>;
@@ -70,6 +76,18 @@ export const RECTIFICATION_OFFER_OPENED_COPY = "出生时间核对已经开始
export const RECTIFICATION_RETURN_BUTTON_LABEL = "回到刚才的对话";
export const RECTIFICATION_BEFORE_CHANGE_LABEL = "按校正前的时间";
/**
* User-visible sentences for a turn that enters rectification directly
* (BUG-1306). `complete_consultation_free` rejects text longer than 20
* characters, so these stay short. Same sentence for "not open" and "no price":
* the user does not see the internal reason.
*/
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 = "可在历史对话里接着做。";
export function rectificationReturnDividerText(record: Pick<RectificationReturnRecord, "adopted" | "clock">): string {
return record.adopted && record.clock
? `出生时间改为 ${record.clock}(生时校正)`
@@ -136,7 +154,11 @@ export function bindOfferToCase(
}
for (let index = stored.length - 1; index >= 0; index -= 1) {
const offer = parseRectificationOfferRecord(stored[index]!.rectificationOffer);
if (!offer || offer.status !== "offered") continue;
if (!offer || offer.caseId) continue;
// Offered cards, including a direct open that is still offered (autoOpen).
// An older direct-open row stored as opened before it had a case id still binds.
// Returned rows, and a card already bound to another case, are left alone.
if (offer.status !== "offered" && offer.status !== "opened") continue;
const next = [...stored];
next[index] = {
...stored[index],
@@ -147,6 +169,30 @@ export function bindOfferToCase(
return null;
}
/**
* After `complete_consultation_free` appends the one-sentence reply, hang the
* offer on that message so a reload can still find the source. The record is
* not opened yet (still `offered`, with `autoOpen` on a direct open). Returns
* the next array, "present" when that message already has an offer (do not
* overwrite a later case id), or "missing" when the free reply is not there yet.
*/
export function attachOpenedOfferToFreeMessage(
messages: unknown,
input: { sentence: string; offer: RectificationOfferRecord },
): StoredMessage[] | "present" | "missing" {
const stored = asMessages(messages);
if (!stored) return "missing";
for (let index = stored.length - 1; index >= 0; index -= 1) {
const message = stored[index]!;
if (message.role !== "assistant" || message.text !== input.sentence || message.responseKind !== "smalltalk") continue;
if (parseRectificationOfferRecord(message.rectificationOffer)) return "present";
const next = [...stored];
next[index] = { ...message, rectificationOffer: input.offer };
return next;
}
return "missing";
}
/** The offer bound to this case, if this session holds one. */
export function offerForCase(messages: unknown, caseId: string): RectificationOfferRecord | null {
for (const message of asMessages(messages) ?? []) {
+43 -1
View File
@@ -313,7 +313,8 @@ export async function collectAgentPublicEvents(stream: ChunkStream | Iterable<Ch
}
}
flushPlan();
events.push({
const directCompleted = options.state ? rectificationDirectRunCompleted(options.state) : null;
events.push(directCompleted ?? {
type: "run.completed",
receipt: agentExecutionReceiptSchema.parse(options.receipt()),
...(options.state?.rectificationOffer ? { rectificationOffer: options.state.rectificationOffer } : {}),
@@ -387,10 +388,32 @@ type StreamAgentResponseOptions = EventOptions & {
// the single-calculation boundary. Gating on total attempts would make any
// transient failure permanently unrecoverable.
function contractReady(options: StreamAgentResponseOptions) {
// An explicit rectification turn does not need a chart calculation (BUG-1306).
if (options.state.rectificationDirectEntry) return true;
return options.state.jyotishSkillBound
&& (!options.requireTool || (options.state.consultationToolCompleted && options.state.consultationToolSuccessCount === 1));
}
/** Smalltalk completion for a direct rectification turn. No receipt: the schema forbids both. */
function rectificationDirectRunCompleted(state: ConsultationRuntimeState): ConsultationAgentPublicEvent | null {
const entry = state.rectificationDirectEntry;
if (!entry) return null;
return consultationAgentPublicEventSchema.parse({
type: "run.completed",
responseKind: "smalltalk",
...(entry.kind === "new" && state.rectificationOffer ? { rectificationOffer: state.rectificationOffer } : {}),
...(entry.kind === "new" || entry.kind === "resume"
? {
rectificationDirectOpen: {
sourceSessionId: entry.sourceSessionId,
mode: entry.kind,
...(entry.resumeSessionId ? { resumeSessionId: entry.resumeSessionId } : {}),
},
}
: {}),
});
}
function isSkillBindingAbortError(error: unknown) {
if (!(error instanceof Error)) return false;
if (error.message === SKILL_BINDING_FAILED) return true;
@@ -654,6 +677,8 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
stepCalledTool = false;
};
const outputText = async (text: string) => {
// Direct entry ends on the server sentence. Further model text is not the answer.
if (options.state.rectificationDirectEntry) return;
// Text the model writes before the contract is ready is not the answer: it
// is the model narrating its own in-progress or failed tool calls. Holding
// it meant a later successful call released that narration as the entire
@@ -1058,6 +1083,23 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
throw new Error("runtime_contract_incomplete");
}
}
const directCompleted = rectificationDirectRunCompleted(options.state);
if (directCompleted) {
const sentence = options.state.rectificationDirectEntry?.sentence ?? "";
fullOutput = sentence;
send(controller, { type: "answer.delta", text: sentence, replace: true });
emitted = true;
recordAnswerTelemetry();
settling = true;
const receipt = agentExecutionReceiptSchema.parse(options.receipt());
await options.onComplete?.(sentence, receipt, undefined, undefined);
settled = true;
settling = false;
await sideEvent;
send(controller, directCompleted);
if (!disconnected) controller.close();
return;
}
if (!deliveredDegraded) {
// The loop's own final step wrote the answer with the calculation
// in its context (BUG-1053); there is no second, blind compose.
+126 -18
View File
@@ -1,4 +1,9 @@
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,
type RectificationOfferRecord,
@@ -276,6 +281,18 @@ export type ConsultationRuntimeState = {
jyotishSkillBound: boolean;
/** Set when this turn's answer offered birth-time rectification (BUG-1248); persisted with the answer. */
rectificationOffer?: RectificationOfferRecord;
/**
* Set when this turn explicitly asked to rectify (BUG-1306). The stream
* replaces the answer with `sentence` and does not charge a reading.
* `new` opens a case; `resume` opens the one already in progress; `refused`
* only says why it cannot.
*/
rectificationDirectEntry?: {
kind: "new" | "resume" | "refused";
sentence: string;
sourceSessionId: string;
resumeSessionId?: string;
};
skillReferenceReadCount: number;
methodologySectionCount: number;
consultationToolStarted: boolean;
@@ -457,6 +474,13 @@ export type RectificationOfferHook = Readonly<{
decide: () => Promise<RectificationOfferDecision>;
alreadyOffered: boolean;
revisitQuestion: string;
/** This consultation. Direct entry opens from it (BUG-1306). */
sourceSessionId: string;
/**
* Called only for an explicit request while a case is already in progress.
* The rectification session to resume, or null when it cannot be read.
*/
loadResumableSessionId: () => Promise<string | null>;
now?: () => Date;
}>;
@@ -1039,40 +1063,124 @@ const evidenceLookupInputSchema = z.object({
}).strict();
const RECTIFICATION_OFFER_REFUSAL_NOTES: Readonly<Record<Exclude<RectificationOfferDecision, { ok: true }>["reason"], string>> = {
not_self: "问的不是用户本人的盘,生时校正暂时只支持本人。正文用一句话照实说明(例:「给家人校正暂时还不支持」),不出卡。",
product_disabled: "生时校正暂时没有开放。正文不要提校正。",
not_self: `问的不是用户本人的盘,生时校正暂时只支持本人。正文用这一句说明:「${RECTIFICATION_DIRECT_NOT_SELF_COPY}」,不出卡。`,
product_disabled: `出生时间核对这会儿做不了。正文必须用这一句说明:「${RECTIFICATION_DIRECT_UNAVAILABLE_COPY}」,不写内部原因。`,
case_in_progress: "用户已经有一次没做完的生时校正。正文用一句话告诉他可以在历史对话里接着做。",
pricing_unavailable: "生时校正这会儿提供不了。正文不要提校正。",
pricing_unavailable: `出生时间核对这会儿做不了。正文必须用这一句说明:「${RECTIFICATION_DIRECT_UNAVAILABLE_COPY}」,不写价格。`,
};
function offerRecord(
hook: RectificationOfferHook,
decision: Extract<RectificationOfferDecision, { ok: true }>,
status: RectificationOfferRecord["status"],
autoOpen = false,
): RectificationOfferRecord {
return {
status,
creditCost: decision.creditCost,
questionEstimate: decision.questionEstimate,
sourceQuestion: hook.revisitQuestion,
offeredAt: (hook.now?.() ?? new Date()).toISOString(),
// Only when true. Ordinary offers and refusals omit the key.
...(autoOpen ? { autoOpen: true } : {}),
};
}
/**
* The offer card tool (C3, BUG-1248). It only records the offer for this turn;
* the server decides whether a card may be shown and the card itself carries
* the question count and price. Never calculates, never opens a case.
* 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.
*/
async function finishExplicitRectificationRequest(
state: ConsultationRuntimeState,
hook: RectificationOfferHook,
decision: RectificationOfferDecision,
) {
if (decision.ok) {
state.rectificationOffer = offerRecord(hook, decision, "offered", true);
state.rectificationDirectEntry = {
kind: "new",
sentence: RECTIFICATION_DIRECT_NEW_COPY,
sourceSessionId: hook.sourceSessionId,
};
return {
offered: true as const,
directEntry: "new" as const,
note: `只写这一句过渡,不写解读:「${RECTIFICATION_DIRECT_NEW_COPY}」`,
};
}
if (decision.reason === "case_in_progress") {
let resumeSessionId: string | null = null;
try {
resumeSessionId = await hook.loadResumableSessionId();
} catch {
resumeSessionId = null;
}
if (resumeSessionId) {
state.rectificationDirectEntry = {
kind: "resume",
sentence: RECTIFICATION_DIRECT_RESUME_COPY,
sourceSessionId: hook.sourceSessionId,
resumeSessionId,
};
return {
offered: false as const,
reason: decision.reason,
directEntry: "resume" as const,
note: `只写这一句,不写解读:「${RECTIFICATION_DIRECT_RESUME_COPY}」`,
};
}
state.rectificationDirectEntry = {
kind: "refused",
sentence: RECTIFICATION_DIRECT_RESUME_ELSEWHERE_COPY,
sourceSessionId: hook.sourceSessionId,
};
return { offered: false as const, reason: decision.reason, note: RECTIFICATION_OFFER_REFUSAL_NOTES.case_in_progress };
}
const sentence = decision.reason === "not_self"
? RECTIFICATION_DIRECT_NOT_SELF_COPY
: RECTIFICATION_DIRECT_UNAVAILABLE_COPY;
state.rectificationDirectEntry = {
kind: "refused",
sentence,
sourceSessionId: hook.sourceSessionId,
};
return { offered: false as const, reason: decision.reason, note: RECTIFICATION_OFFER_REFUSAL_NOTES[decision.reason] };
}
/**
* The offer card tool (C3, BUG-1248; direct entry BUG-1306). The server decides
* 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 }>) {
return createTool({
id: RECTIFICATION_OFFER_TOOL_ID,
description: "Offer birth-time rectification under this answer. Call it before writing the answer, only when the user says a concrete past event does not fit the chart, says the birth time is an estimate or unsure, or asks to check the birth time; never for a disagreement with an interpretation. Pass userAsked=true only when the user asked for it in this turn. The result says whether a card will be shown and what to say in one sentence.",
inputSchema: z.object({ userAsked: z.boolean().optional() }).strict(),
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.",
inputSchema: z.object({
userAsked: z.boolean().optional(),
explicitRequest: z.boolean().optional(),
}).strict(),
execute: async (input) => {
const hook = ctx.rectificationOffer;
if (!hook) return { offered: false as const, reason: "unavailable", note: RECTIFICATION_OFFER_REFUSAL_NOTES.product_disabled };
if (ctx.state.rectificationOffer) {
return { offered: true as const, note: "提议卡已经在了。正文用一句话提一下就好,不写价格和题数。" };
if (input.explicitRequest === true && ctx.state.rectificationDirectEntry) {
return { offered: false as const, note: "已经在打开。不要再写解读。" };
}
if (hook.alreadyOffered && input.userAsked !== true) {
if (ctx.state.rectificationDirectEntry || ctx.state.rectificationOffer) {
if (ctx.state.rectificationOffer) {
return { offered: true as const, note: "提议卡已经在了。正文用一句话提一下就好,不写价格和题数。" };
}
return { offered: false as const, note: "已经在打开。不要再写解读。" };
}
if (hook.alreadyOffered && input.userAsked !== true && input.explicitRequest !== true) {
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 (!decision.ok) return { offered: false as const, reason: decision.reason, note: RECTIFICATION_OFFER_REFUSAL_NOTES[decision.reason] };
ctx.state.rectificationOffer = {
status: "offered",
creditCost: decision.creditCost,
questionEstimate: decision.questionEstimate,
sourceQuestion: hook.revisitQuestion,
offeredAt: (hook.now?.() ?? new Date()).toISOString(),
};
ctx.state.rectificationOffer = offerRecord(hook, decision, "offered");
return { offered: true as const, note: "你的回答下面会出现一张提议卡,写着大约几题、几点。正文最后一段必须有一句提到它(例:「如果想确认出生时间准不准,可以用下面的卡片,答几道过去经历的题核对一下。」),不写价格和题数,不催。" };
},
});
@@ -7,7 +7,7 @@ import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { buildEvidenceCard } from "../src/lib/consultation-evidence-card.ts";
import { assembleJudgmentTable, JUDGMENT_TABLE_LABELS } from "../src/lib/consultation-judgment-table.ts";
import { assembleJudgmentTable, judgmentTableModelView, JUDGMENT_TABLE_LABELS } from "../src/lib/consultation-judgment-table.ts";
import type { ConsultationDomain } from "../src/lib/consultation-domain-registry.ts";
import { createConsultationPlan } from "../src/lib/consultation-plan.ts";
import {
@@ -129,6 +129,47 @@ test("the judgment table copies leaves and does not turn the count into a verdic
assert.deepEqual((rahu.co_tenants as { value: string[] }).value, ["Mars"]);
const blocked = rows.find((row) => row.id === "jaimini");
assert.equal(blocked?.kind, "blocked");
const house = rows.find((row) => row.id === "house:10");
assert.equal(house?.reading, undefined);
assert.equal(lord.reading, JUDGMENT_TABLE_LABELS.careerSignificatorReading);
assert.equal(rahu.reading, JUDGMENT_TABLE_LABELS.careerSignificatorReading);
});
// Assertion change:
// 原值: 事业判断表的行星行没有呈现句;重复出现的行星只留 id、角色、引用和受冲计数。
// 新值: 事业的自然代表星、Jaimini 代表星、大运主星、Narayana 主星和宫主行带同一句呈现文字。婚恋表不带。重复行仍丢掉度数,但留下这句。行星的星座、宫位、旺弱不变。
// 原因: 清单句之后仍有 1 次按星性写方向。模型读的是事业宫主行。只加呈现文字,不改取值(TASK-consult-career-type-residual-20261009)。
test("career significator rows name strength only, and other domains stay unchanged", () => {
const career = assembleJudgmentTable({
...facts,
factors: [
...facts.factors,
{ id: "jaimini:AmK", kind: "planet", planet: "Saturn", citation: "jaimini", role: "AmK" },
{ id: "jaimini:DK", kind: "planet", planet: "Saturn", citation: "jaimini", role: "DK" },
{ id: "vimshottari:md", kind: "planet", planet: "Sun", citation: "vim", role: "Vimshottari MD" },
{ id: "narayana:lord", kind: "planet", planet: "Sun", citation: "narayana", role: "Narayana MD lord" },
],
});
const rows = career.rows as Array<Record<string, unknown>>;
for (const id of ["house:10:lord", "natural:Saturn", "jaimini:AmK", "jaimini:DK", "vimshottari:md", "narayana:lord"]) {
assert.equal(rows.find((row) => row.id === id)?.reading, JUDGMENT_TABLE_LABELS.careerSignificatorReading, id);
}
const lord = rows.find((row) => row.id === "house:10:lord") as Record<string, unknown>;
assert.equal(lord.planet, "Sun");
assert.equal((lord.d1_sign as { value: string }).value, "Aries");
assert.equal((lord.d1_house as { value: number }).value, 10);
assert.equal((lord.d1_dignity as { value: string }).value, "落陷(Debilitated)");
const marriage = assembleJudgmentTable({ ...facts, domain: "marriage" });
const marriageEncoded = JSON.stringify(marriage);
assert.equal(marriageEncoded.includes(JUDGMENT_TABLE_LABELS.careerSignificatorReading), false);
assert.equal((marriage.rows as Array<Record<string, unknown>>).find((row) => row.id === "house:10:lord")?.planet, "Sun");
const view = judgmentTableModelView(career) as { rows: Array<Record<string, unknown>> };
const repeat = view.rows.find((row) => row.id === "vimshottari:md");
assert.equal(repeat?.planet, "Sun");
assert.equal(repeat?.role, "Vimshottari MD");
assert.equal(repeat?.reading, JUDGMENT_TABLE_LABELS.careerSignificatorReading);
assert.equal(repeat?.d1_sign, undefined);
assert.equal(repeat?.d1_dignity, undefined);
});
test("a card without facts keeps the old section, and facts attach one table", () => {
@@ -219,6 +260,11 @@ test("engine facts make a model card without internal paths", () => {
assert.equal(encoded.includes("core_chart."), false, `${chart} ${domain}`);
assert.equal(encoded.includes("\"check\""), false, `${chart} ${domain}`);
assert.equal(encoded.includes("两个及以上"), false);
assert.equal(
encoded.includes(JUDGMENT_TABLE_LABELS.careerSignificatorReading),
domain === "career",
`${chart} ${domain}`,
);
assert.ok(encoded.length <= 20_000, `${chart} ${domain} ${encoded.length}`);
const rows = (card.domain_sections[domain as ConsultationDomain]?.judgment_table?.rows ?? []) as Array<Record<string, unknown>>;
const hasAspect = rows.some((row) => (
@@ -13,6 +13,7 @@ import test from "node:test";
import { fileURLToPath } from "node:url";
import {
CAREER_DIRECTION_SECTION_RULE,
CAREER_SIGNIFICATOR_STRENGTH_RULE,
CONDENSED_CHECKLIST_SOURCE,
CONSULT_BRIEF_CHECKLIST_SOURCE,
@@ -96,6 +97,27 @@ test("career significators stay strength and affliction, and the system prompt i
assert.equal(natal.length, 15_291);
});
test("career direction section only writes strength, resistance, timing and three events", () => {
const career = CONSULTATION_CONDENSED_CHECKLISTS.career!;
assert.equal(career[0]!.startsWith("事业题只讲盘上的力量、阻力和时间"), true);
assert.equal(career.filter((line) => line === CAREER_DIRECTION_SECTION_RULE).length, 1);
assert.equal(career.filter((line) => line === CAREER_SIGNIFICATOR_STRENGTH_RULE).length, 1);
assert.match(CAREER_DIRECTION_SECTION_RULE, /「方向」一节只写力量、阻力、时间与三件事(机会出现 \/ 成形 \/ 公开落地)/);
assert.match(CAREER_DIRECTION_SECTION_RULE, /职业类型与工作方式一律问用户/);
assert.match(CAREER_DIRECTION_SECTION_RULE, /机会出现(消息、邀约、初步接洽)/);
assert.match(CAREER_DIRECTION_SECTION_RULE, /成形(合同、合作、长期项目)/);
assert.match(CAREER_DIRECTION_SECTION_RULE, /公开落地(发布、到账、被看见、名声)/);
assert.equal(career.length, 8);
const mastra = readFileSync(new URL("../src/mastra/index.ts", import.meta.url), "utf8");
const natal = mastra.slice(
mastra.indexOf("const jyotishInstructions"),
mastra.indexOf("const generalJyotishInstructions"),
);
assert.equal(natal.includes(CAREER_DIRECTION_SECTION_RULE), false);
assert.equal(natal.includes("「方向」一节只写力量、阻力、时间"), false);
assert.equal(natal.length, 15_291);
});
test("each condensed list is 5–8 lines", () => {
// 原值:全部清单 5–8 行,且只有 career / marriage / wealth 三份;
// 新值:这三份仍 5–8 行;另有 TASK-consult-affliction-reading-20261001 定稿的 8 份(2–6 行,按任务书原文,不凑行数),
@@ -120,7 +142,10 @@ test("the writer's prompt carries the condensed list with the three layers and t
// 原值(2): career 必含「事业形态」
// 新值(2): 改为必含「不替用户断定他做哪一行」(CAREER_FIELD_ASK_RULE)
// 原因(2): 产品 2026-10-02「行业不猜,问用户」,删掉「先讲一生事业形态:公众型还是幕后型」(BUG-1176)
career: ["接触", "机会出现", "成形", "公开落地", "不替用户断定他做哪一行", "事业必成", "d9 段", "KP 精确宫头仍 blocked", "不按星的本性推断职业类型"],
// 原值: career 必含到「不按星的本性推断职业类型」为止
// 新值: 另必含「方向」一节只写力量、阻力、时间与三件事,以及「职业类型与工作方式一律问用户」
// 原因: TASK-consult-career-type-residual-20261009 H1。清单仍是 8 行,这句替换原来的「分三件事看」行,不进系统提示。
career: ["接触", "机会出现", "成形", "公开落地", "不替用户断定他做哪一行", "事业必成", "d9 段", "KP 精确宫头仍 blocked", "不按星的本性推断职业类型", "「方向」一节只写力量、阻力、时间与三件事", "职业类型与工作方式一律问用户"],
marriage: [
// 原值: "心动接触", "关系成对", "社会法律落地"
// 新值: 三件事用生活说法,并写明回答里不报层名
@@ -87,7 +87,13 @@ test("the consult route logs the record once per natal turn, without session or
assert.match(block, /agentVersion: "consultation-evidence-card-v2"/);
assert.doesNotMatch(block, /sessionId|userId/);
const natal = route.slice(route.indexOf("const agent = getJyotishAgent(selectedModel, agentContext);"));
assert.match(natal, /answerForTelemetry = output;[\s\S]*return settleRun\(\(\) => completeResponse\(/);
// 原值: /answerForTelemetry = output;[\s\S]*return settleRun\(\(\) => completeResponse\(/
// 新值: 非直达仍把 output 记入 answerForTelemetry,并经 settleAnswer 调用 completeResponse
// 原因: BUG-1306 直达轮不把模型正文当回答,结算改走 complete_consultation_free
assert.match(natal, /if \(!state\.rectificationDirectEntry\) answerForTelemetry = output;/);
assert.match(natal, /return settleAnswer\(/);
const settle = route.slice(route.indexOf("const settleAnswer ="), route.indexOf("if (usesPublicDailyGeneralAgent"));
assert.match(settle, /return settleRun\(\(\) => completeResponse\(/);
// Only a finished answer counts citations; a failed run logs the card size with no citations.
assert.doesNotMatch(natal, /onError: \(error, _emitted, output\)/);
});
@@ -33,3 +33,19 @@ test("a saved chart becomes a warm request and the caller does not wait", () =>
assert.equal(started, true);
assert.equal(finished, false);
});
test("a warm that cannot be scheduled does not break the save (BUG-1305)", () => {
// Default deps use Next `after`, which throws outside a request scope.
assert.equal(scheduleConsultPacketWarm(columns), false);
let fetched = false;
const scheduled = scheduleConsultPacketWarm(columns, {
after: () => { throw new Error("outside a request scope"); },
fetch: () => {
fetched = true;
return Promise.resolve(new Response(null));
},
apiBase: "http://127.0.0.1:9",
});
assert.equal(scheduled, false);
assert.equal(fetched, false);
});
@@ -0,0 +1,684 @@
// BUG-1306: an explicit request opens rectification directly. Fictional data only.
import assert from "node:assert/strict";
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import test from "node:test";
import { createElement } from "react";
import { renderToString } from "react-dom/server";
import { RectificationOfferCard } from "../src/components/rectification-in-chat-cards.tsx";
import { consultationAgentPublicEventSchema, createNdjsonParser, type AgentExecutionReceipt, type ConsultationAgentPublicEvent } from "../src/lib/consultation-agent-events.ts";
import { RECTIFICATION_OFFER_RULE } from "../src/lib/consultation-thinking-plan.ts";
import {
RECTIFICATION_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,
RECTIFICATION_OFFER_TOOL_ID,
attachOpenedOfferToFreeMessage,
bindOfferToCase,
offerForCase,
parseRectificationOfferRecord,
rectificationOfferCardCopy,
sourceFromSessionRows,
type RectificationOfferRecord,
} from "../src/lib/rectification-in-chat.ts";
import { CONTRACT_DEGRADED_NOTE, streamAgentResponse } from "../src/lib/stream-agent-response.ts";
import type { Pass4Mode } from "../src/lib/timing-output-guard.ts";
import {
consultationStepBudgetReceipt,
createConsultationRuntimeState,
createConsultationTools,
publicConsultationRuntimeSteps,
} from "../src/mastra/consultation-tools.ts";
const CASE_ID = "11111111-1111-4111-8111-111111111111";
const OTHER_CASE = "55555555-5555-4555-8555-555555555555";
const RECT_SESSION = "22222222-2222-4222-8222-222222222222";
const SOURCE_SESSION = "33333333-3333-4333-8333-333333333333";
const RESUME_SESSION = "44444444-4444-4444-8444-444444444444";
const REQUEST_ID = "66666666-6666-4666-8666-666666666666";
const DIRECT_SENTENCES = [
RECTIFICATION_DIRECT_NEW_COPY,
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: [{}] } } };
type OfferDecision =
| { ok: true; creditCost: number; questionEstimate: number }
| { ok: false; reason: "not_self" | "product_disabled" | "case_in_progress" | "pricing_unavailable" };
function offer(status: RectificationOfferRecord["status"] = "offered", extra: Partial<RectificationOfferRecord> = {}): RectificationOfferRecord {
return {
status,
creditCost: 3,
questionEstimate: 8,
sourceQuestion: "我和伴侣还能走下去吗",
offeredAt: "2026-10-07T00:00:00.000Z",
...extra,
};
}
function offerTool(options: {
alreadyOffered?: boolean;
decide?: () => Promise<OfferDecision>;
loadResumableSessionId?: () => Promise<string | null>;
} = {}) {
const state = createConsultationRuntimeState();
let decisions = 0;
let loads = 0;
const tools = createConsultationTools({
userId: "user-fictional",
sessionId: SOURCE_SESSION,
requestId: REQUEST_ID,
consultationMode: "verified_chart",
serverChart: {} as never,
state,
rectificationOffer: {
decide: async () => {
decisions += 1;
return options.decide ? options.decide() : { ok: true, creditCost: 3, questionEstimate: 8 };
},
alreadyOffered: options.alreadyOffered ?? false,
revisitQuestion: "我和伴侣还能走下去吗",
sourceSessionId: SOURCE_SESSION,
loadResumableSessionId: async () => {
loads += 1;
return options.loadResumableSessionId ? options.loadResumableSessionId() : null;
},
now: () => new Date("2026-10-07T00:00:00.000Z"),
},
}) as Record<string, { execute: (input: unknown, context?: unknown) => Promise<Record<string, unknown>> }>;
return { state, decisions: () => decisions, loads: () => loads, tool: tools[RECTIFICATION_OFFER_TOOL_ID]! };
}
function src(relative: string) {
return readFileSync(new URL(relative, import.meta.url), "utf8");
}
function walkSources(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (entry.name === "node_modules") continue;
const full = join(dir, entry.name);
if (entry.isDirectory()) out.push(...walkSources(full));
else if (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) out.push(full);
}
return out;
}
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]);
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, "可在历史对话里接着做。");
});
test("an opened offer with no case id binds, and a reload can find that case", () => {
const opened = [{
role: "assistant",
text: RECTIFICATION_DIRECT_NEW_COPY,
responseKind: "smalltalk",
rectificationOffer: offer("opened"),
}];
assert.equal(sourceFromSessionRows([{ id: SOURCE_SESSION, messages: opened }], CASE_ID), null);
const bound = bindOfferToCase(opened, { caseId: CASE_ID, rectificationSessionId: RECT_SESSION });
assert.ok(bound);
assert.equal((bound![0]!.rectificationOffer as RectificationOfferRecord).status, "opened");
assert.equal((bound![0]!.rectificationOffer as RectificationOfferRecord).caseId, CASE_ID);
assert.equal((bound![0]!.rectificationOffer as RectificationOfferRecord).rectificationSessionId, RECT_SESSION);
assert.equal(bindOfferToCase(bound, { caseId: CASE_ID, rectificationSessionId: RECT_SESSION }), null);
assert.ok(offerForCase(bound, CASE_ID));
assert.deepEqual(sourceFromSessionRows([{ id: SOURCE_SESSION, messages: bound }], CASE_ID), {
sessionId: SOURCE_SESSION,
question: "我和伴侣还能走下去吗",
returned: null,
});
});
test("binding leaves another case's opened record alone and still binds a later card", () => {
const messages = [
{ role: "assistant", text: "旧卡", rectificationOffer: offer("opened", { caseId: OTHER_CASE, rectificationSessionId: RECT_SESSION }) },
{ role: "assistant", text: RECTIFICATION_DIRECT_NEW_COPY, responseKind: "smalltalk", rectificationOffer: offer("opened") },
];
assert.equal(bindOfferToCase(
[{ role: "assistant", rectificationOffer: offer("opened", { caseId: OTHER_CASE, rectificationSessionId: RECT_SESSION }) }],
{ caseId: CASE_ID, rectificationSessionId: RECT_SESSION },
), null);
const bound = bindOfferToCase(messages, { caseId: CASE_ID, rectificationSessionId: RECT_SESSION });
assert.ok(bound);
assert.equal((bound![0]!.rectificationOffer as RectificationOfferRecord).caseId, OTHER_CASE);
assert.equal((bound![1]!.rectificationOffer as RectificationOfferRecord).caseId, CASE_ID);
assert.equal((bound![1]!.rectificationOffer as RectificationOfferRecord).status, "opened");
});
test("the free reply can carry the opened offer once, without clobbering a case id", () => {
const sentence = RECTIFICATION_DIRECT_NEW_COPY;
const card = offer("opened");
assert.equal(attachOpenedOfferToFreeMessage([], { sentence, offer: card }), "missing");
assert.equal(attachOpenedOfferToFreeMessage(
[{ role: "assistant", text: sentence, responseKind: "consultation" }],
{ sentence, offer: card },
), "missing");
const attached = attachOpenedOfferToFreeMessage(
[{ role: "user", text: "请帮我核对出生时间" }, { role: "assistant", text: sentence, responseKind: "smalltalk" }],
{ sentence, offer: card },
);
assert.ok(Array.isArray(attached));
assert.deepEqual(attached[1]!.rectificationOffer, card);
assert.equal(attachOpenedOfferToFreeMessage(attached, { sentence, offer: card }), "present");
const withCase = attachOpenedOfferToFreeMessage(
[{ role: "assistant", text: sentence, responseKind: "smalltalk", rectificationOffer: offer("opened", { caseId: CASE_ID, rectificationSessionId: RECT_SESSION }) }],
{ sentence, offer: card },
);
assert.equal(withCase, "present");
});
test("explicit pass opens once, records an offered auto-open offer, and does not open again", async () => {
const fresh = offerTool();
const result = await fresh.tool.execute({ explicitRequest: true, userAsked: true });
assert.equal(result.offered, true);
assert.equal(result.directEntry, "new");
assert.match(String(result.note), /好,我们开始核对出生时间/);
assert.deepEqual(fresh.state.rectificationOffer, offer("offered", { autoOpen: true }));
assert.equal(fresh.state.rectificationOffer?.status, "offered");
assert.equal(fresh.state.rectificationOffer?.autoOpen, true);
assert.equal(fresh.state.rectificationOffer?.caseId, undefined);
assert.deepEqual(fresh.state.rectificationDirectEntry, {
kind: "new",
sentence: RECTIFICATION_DIRECT_NEW_COPY,
sourceSessionId: SOURCE_SESSION,
});
assert.equal(fresh.loads(), 0);
assert.equal(fresh.decisions(), 1);
const again = await fresh.tool.execute({ explicitRequest: true });
assert.equal(again.offered, false);
assert.match(String(again.note), /已经在打开/);
assert.match(String(again.note), /不要再写解读/);
assert.equal(fresh.decisions(), 1);
assert.deepEqual(fresh.state.rectificationOffer, offer("offered", { autoOpen: true }));
});
test("explicitRequest bypasses an earlier offer, and userAsked alone still only shows a card", async () => {
const direct = offerTool({ alreadyOffered: true });
const opened = await direct.tool.execute({ explicitRequest: true });
assert.equal(opened.directEntry, "new");
assert.equal(direct.state.rectificationOffer?.status, "offered");
assert.equal(direct.state.rectificationOffer?.autoOpen, true);
assert.equal(direct.state.rectificationDirectEntry?.kind, "new");
const card = offerTool({ alreadyOffered: true });
const asked = await card.tool.execute({ userAsked: true });
assert.equal(asked.offered, true);
assert.equal(card.state.rectificationOffer?.status, "offered");
assert.equal(card.state.rectificationDirectEntry, undefined);
const held = offerTool();
await held.tool.execute({});
const second = await held.tool.execute({ explicitRequest: true });
assert.match(String(second.note), /提议卡已经在了/);
assert.equal(held.state.rectificationDirectEntry, undefined);
assert.equal(held.state.rectificationOffer?.status, "offered");
assert.equal(held.decisions(), 1);
});
test("an in-progress case resumes that session, or says to continue in history", async () => {
const resume = offerTool({
decide: async () => ({ ok: false, reason: "case_in_progress" }),
loadResumableSessionId: async () => RESUME_SESSION,
});
const resumed = await resume.tool.execute({ explicitRequest: true });
assert.equal(resumed.offered, false);
assert.equal(resumed.directEntry, "resume");
assert.equal(resume.state.rectificationOffer, undefined);
assert.deepEqual(resume.state.rectificationDirectEntry, {
kind: "resume",
sentence: RECTIFICATION_DIRECT_RESUME_COPY,
sourceSessionId: SOURCE_SESSION,
resumeSessionId: RESUME_SESSION,
});
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"); },
});
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), /历史对话/);
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.doesNotMatch(String(ordinary.note), /不要提校正/);
});
test("explicit refusals are one sentence, and none of the four notes says to hide rectification", async () => {
const unavailable = offerTool({ decide: async () => ({ ok: false, reason: "product_disabled" }) });
const disabled = await unavailable.tool.execute({ explicitRequest: true });
assert.equal(unavailable.state.rectificationDirectEntry?.kind, "refused");
assert.equal(unavailable.state.rectificationDirectEntry?.sentence, RECTIFICATION_DIRECT_UNAVAILABLE_COPY);
assert.equal(unavailable.state.rectificationOffer, undefined);
assert.match(String(disabled.note), /出生时间核对这会儿还做不了/);
assert.match(String(disabled.note), /不写内部原因/);
assert.doesNotMatch(String(disabled.note), /不要提校正/);
const unpriced = offerTool({ decide: async () => ({ ok: false, reason: "pricing_unavailable" }) });
const priced = await unpriced.tool.execute({ explicitRequest: true });
assert.equal(unpriced.state.rectificationDirectEntry?.sentence, RECTIFICATION_DIRECT_UNAVAILABLE_COPY);
assert.match(String(priced.note), /出生时间核对这会儿还做不了/);
assert.match(String(priced.note), /不写价格/);
assert.doesNotMatch(String(priced.note), /不要提校正/);
const family = offerTool({ decide: async () => ({ ok: false, reason: "not_self" }) });
const other = await family.tool.execute({ explicitRequest: true });
assert.equal(family.state.rectificationDirectEntry?.sentence, RECTIFICATION_DIRECT_NOT_SELF_COPY);
assert.match(String(other.note), /给家人校正暂时还不支持/);
assert.doesNotMatch(String(other.note), /不要提校正/);
const plainFamily = offerTool({ decide: async () => ({ ok: false, reason: "not_self" }) });
const plain = await plainFamily.tool.execute({});
assert.equal(plainFamily.state.rectificationDirectEntry, undefined);
assert.doesNotMatch(String(plain.note), /不要提校正/);
const plainPrice = offerTool({ decide: async () => ({ ok: false, reason: "pricing_unavailable" }) });
assert.doesNotMatch(String((await plainPrice.tool.execute({})).note), /不要提校正/);
const plainOff = offerTool({ decide: async () => ({ ok: false, reason: "product_disabled" }) });
assert.doesNotMatch(String((await plainOff.tool.execute({})).note), /不要提校正/);
assert.equal(plainPrice.state.rectificationDirectEntry, undefined);
assert.equal(plainOff.state.rectificationDirectEntry, undefined);
});
function receipt(state: ReturnType<typeof createConsultationRuntimeState>): AgentExecutionReceipt {
return {
runId: "run-direct",
runtime: "mastra-agentic",
skill: {
name: "jyotish-vedic-astrology",
loaded: state.jyotishSkillBound,
referenceReads: state.skillReferenceReadCount,
methodologySections: state.methodologySectionCount,
},
steps: publicConsultationRuntimeSteps(state),
stepBudget: consultationStepBudgetReceipt(state),
workflow: { route: "career", status: "ready", preciseTiming: "blocked", missingLayers: [] },
};
}
function visibleAnswer(events: readonly ConsultationAgentPublicEvent[]) {
let text = "";
for (const event of events) {
if (event.type !== "answer.delta") continue;
text = event.replace ? event.text : text + event.text;
}
return text;
}
async function readStream(response: Response) {
const events: ConsultationAgentPublicEvent[] = [];
const parser = createNdjsonParser((event) => events.push(event));
parser.finish(await response.text());
return events;
}
test("natal and window direct entry or explicit refusal skips the runtime-contract retry", async () => {
const modes: Pass4Mode[] = ["verified_chart", "declared_birth_window"];
const kinds = ["new", "resume", "refused"] as const;
for (const pass4Mode of modes) {
for (const kind of kinds) {
const state = createConsultationRuntimeState();
const sentence = kind === "new"
? RECTIFICATION_DIRECT_NEW_COPY
: kind === "resume"
? RECTIFICATION_DIRECT_RESUME_COPY
: RECTIFICATION_DIRECT_UNAVAILABLE_COPY;
let contractRetries = 0;
let answerRetries = 0;
let warmupRan = false;
let completedOutput = "";
const response = streamAgentResponse({
runId: "run-direct",
requestId: REQUEST_ID,
state,
requireTool: true,
pass4Mode,
stepScopedAnswer: true,
toolStatus: () => "ready",
receipt: () => receipt(state),
...(pass4Mode === "declared_birth_window"
? {
warmup: async (send: (event: ConsultationAgentPublicEvent) => void) => {
warmupRan = true;
send({ type: "activity", phase: "chart-calculation", label: "窗口预热" });
},
}
: {}),
stream: (async function* () {
yield { type: "text-delta", payload: { text: "我先看看盘。" } };
yield { type: "tool-call", payload: { toolCallId: "offer-1", toolName: "offer-birth-time-rectification" } };
state.rectificationDirectEntry = {
kind,
sentence,
sourceSessionId: SOURCE_SESSION,
...(kind === "resume" ? { resumeSessionId: RESUME_SESSION } : {}),
};
if (kind === "new") state.rectificationOffer = offer("offered", { autoOpen: true });
yield { type: "text-delta", payload: { text: "然后写一段解读。" } };
yield STOP_FINISH;
})(),
retry: async () => {
contractRetries += 1;
return (async function* () { yield STOP_FINISH; })();
},
retryForAnswer: async () => {
answerRetries += 1;
return (async function* () { yield STOP_FINISH; })();
},
onComplete: (output) => { completedOutput = output; },
});
const events = await readStream(response);
assert.equal(contractRetries, 0, `${pass4Mode} ${kind} retried the contract`);
assert.equal(answerRetries, 0, `${pass4Mode} ${kind} retried the answer`);
assert.equal(state.consultationToolCompleted, false);
assert.equal(visibleAnswer(events), sentence);
assert.equal(visibleAnswer(events).includes("我先看看盘"), false);
assert.equal(visibleAnswer(events).includes("然后写一段解读"), false);
assert.equal(visibleAnswer(events).includes(CONTRACT_DEGRADED_NOTE), false);
assert.equal(completedOutput, sentence);
if (pass4Mode === "declared_birth_window") assert.equal(warmupRan, true);
const completed = events.filter((event) => event.type === "run.completed");
assert.equal(completed.length, 1);
const done = completed[0]!;
assert.equal(done.type === "run.completed" && done.responseKind, "smalltalk");
assert.equal(done.type === "run.completed" && done.receipt, undefined);
if (kind === "new") {
assert.equal(done.type === "run.completed" && done.rectificationOffer?.status, "offered");
assert.equal(done.type === "run.completed" && done.rectificationOffer?.autoOpen, true);
assert.equal(done.type === "run.completed" && done.rectificationOffer?.caseId, undefined);
assert.deepEqual(done.type === "run.completed" ? done.rectificationDirectOpen : undefined, {
sourceSessionId: SOURCE_SESSION,
mode: "new",
});
} else if (kind === "resume") {
assert.equal(done.type === "run.completed" && done.rectificationOffer, undefined);
assert.deepEqual(done.type === "run.completed" ? done.rectificationDirectOpen : undefined, {
sourceSessionId: SOURCE_SESSION,
mode: "resume",
resumeSessionId: RESUME_SESSION,
});
} else {
assert.equal(done.type === "run.completed" && done.rectificationDirectOpen, undefined);
assert.equal(done.type === "run.completed" && done.rectificationOffer, undefined);
}
assert.equal(events.some((event) => event.type === "activity" && event.label === "正在补齐方法与计算步骤"), false);
}
}
});
test("a reading that already streamed is replaced by the direct-entry sentence", async () => {
for (const pass4Mode of ["verified_chart", "declared_birth_window"] as const) {
const state = createConsultationRuntimeState();
state.consultationToolCompleted = true;
state.consultationToolSuccessCount = 1;
let contractRetries = 0;
const response = streamAgentResponse({
runId: "run-direct",
requestId: REQUEST_ID,
state,
requireTool: true,
pass4Mode,
stepScopedAnswer: true,
toolStatus: () => "ready",
receipt: () => receipt(state),
stream: (async function* () {
yield { type: "text-delta", payload: { text: "事业方向的判断如下。" } };
yield { type: "step-finish", payload: { stepResult: { reason: "stop" } } };
state.rectificationDirectEntry = {
kind: "new",
sentence: RECTIFICATION_DIRECT_NEW_COPY,
sourceSessionId: SOURCE_SESSION,
};
state.rectificationOffer = offer("offered", { autoOpen: true });
yield STOP_FINISH;
})(),
retry: async () => {
contractRetries += 1;
return (async function* () { yield STOP_FINISH; })();
},
});
const events = await readStream(response);
assert.equal(contractRetries, 0);
assert.equal(events.some((event) => event.type === "answer.delta" && event.text.includes("事业方向的判断如下")), true);
assert.equal(events.some((event) => event.type === "answer.delta" && event.replace === true && event.text === RECTIFICATION_DIRECT_NEW_COPY), true);
assert.equal(visibleAnswer(events), RECTIFICATION_DIRECT_NEW_COPY);
}
});
test("an ordinary consultation still retries once when the calculation never finished", async () => {
const state = createConsultationRuntimeState();
let contractRetries = 0;
let answerRetries = 0;
const response = streamAgentResponse({
runId: "run-direct",
requestId: REQUEST_ID,
state,
requireTool: true,
pass4Mode: "verified_chart",
stepScopedAnswer: true,
toolStatus: () => "ready",
receipt: () => receipt(state),
stream: (async function* () {
yield { type: "text-delta", payload: { text: "还没排盘。" } };
yield STOP_FINISH;
})(),
retry: async () => {
contractRetries += 1;
state.consultationToolCompleted = true;
state.consultationToolSuccessCount = 1;
return (async function* () {
yield { type: "text-delta", payload: { text: "事业方向可以看。" } };
yield STOP_FINISH;
})();
},
retryForAnswer: async () => {
answerRetries += 1;
return (async function* () { yield STOP_FINISH; })();
},
});
const events = await readStream(response);
assert.equal(contractRetries, 1);
assert.equal(answerRetries, 0);
assert.equal(visibleAnswer(events), "事业方向可以看。");
assert.equal(visibleAnswer(events).includes(CONTRACT_DEGRADED_NOTE), false);
assert.equal(visibleAnswer(events).includes("还没排盘"), false);
const done = events.find((event) => event.type === "run.completed");
assert.equal(done?.type === "run.completed" && done.receipt !== undefined, true);
assert.equal(done?.type === "run.completed" && done.responseKind, undefined);
assert.equal(events.some((event) => event.type === "activity" && event.label === "正在补齐方法与计算步骤"), true);
});
test("the offer rule is defined once, and direct entry settles with the free completion", () => {
const definitions = walkSources(fileURLToPath(new URL("../src/", import.meta.url))).filter((file) => (
readFileSync(file, "utf8").includes("export const RECTIFICATION_OFFER_RULE")
));
assert.equal(definitions.length, 1);
assert.match(definitions[0]!, /consultation-thinking-plan\.ts$/);
assert.match(RECTIFICATION_OFFER_RULE, /不承诺能收窄出生时间/);
assert.match(RECTIFICATION_OFFER_RULE, /不在对话里收集经历做校正/);
assert.match(RECTIFICATION_OFFER_RULE, /explicitRequest=true/);
assert.match(RECTIFICATION_OFFER_RULE, /只写那一句过渡,不写解读/);
assert.match(RECTIFICATION_OFFER_RULE, /只是不认同某个解释,按纠正规则认下、改读,不提校正/);
assert.match(RECTIFICATION_OFFER_RULE, /userAsked=true/);
const tools = src("../src/mastra/consultation-tools.ts");
assert.doesNotMatch(tools, /不承诺能收窄出生时间/);
assert.doesNotMatch(tools, /不要提校正/);
assert.match(tools, /explicitRequest/);
assert.match(tools, /userAsked/);
const route = src("../src/app/api/consult/route.ts");
assert.match(route, /大运、子运只说「在哪一段附近」,不写起止年月日/);
assert.match(route, /这只是粗看。" \+ RECTIFICATION_OFFER_RULE;/);
assert.doesNotMatch(route, /请你帮我进行生时纠正/);
const direct = route.slice(
route.indexOf("async function completeDirectRectificationTurn"),
route.indexOf("let settlement:"),
);
assert.match(direct, /complete_consultation_free/);
assert.match(direct, /responseKind: "smalltalk"/);
assert.match(direct, /persistOpenedDirectOffer/);
assert.doesNotMatch(direct, /complete_consultation_response/);
assert.doesNotMatch(direct, /consultation_requests/);
const windowBlock = route.slice(
route.indexOf("if (shouldRunDeclaredWindowWorkflow(consultationMode))"),
route.indexOf("if (!prepared.serverChart)"),
);
const natalBlock = route.slice(
route.indexOf("if (!prepared.serverChart)"),
route.indexOf("const history = storedHistory;"),
);
assert.match(windowBlock, /settleAnswer/);
assert.match(natalBlock, /settleAnswer/);
assert.match(windowBlock, /warmup:/);
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"\)/);
const stream = src("../src/lib/stream-agent-response.ts");
assert.match(stream, /if \(options\.state\.rectificationDirectEntry\) return true;/);
assert.match(stream, /rectificationDirectRunCompleted\(options\.state\)/);
assert.doesNotMatch(stream, /请你帮我进行生时纠正/);
const hook = src("../src/hooks/use-consultation-run.ts");
assert.match(hook, /openRectificationFromChat\(rectificationDirectOpen\.sourceSessionId\)/);
assert.match(hook, /openRectificationSession\(rectificationDirectOpen\.resumeSessionId\)/);
assert.equal(hook.match(/openRectificationFromChat\(rectificationDirectOpen\.sourceSessionId\)/g)?.length, 1);
const directOpen = hook.slice(hook.indexOf("rectificationDirectOpen?.mode"), hook.indexOf("await persistSession(completedSession)"));
assert.match(directOpen, /if \(opened\) return/);
assert.doesNotMatch(directOpen, /status:\s*"opened"/);
assert.match(hook, /responseKind === "smalltalk" \? \{[\s\S]*?rectificationOffer/);
const page = src("../src/app/(app)/page.tsx");
assert.match(page, /openAccountDialog, openRectificationSession, openRectificationFromChat,/);
assert.doesNotMatch(page, /请你帮我进行生时纠正/);
const server = src("../src/lib/rectification-in-chat-server.ts");
assert.match(server, /return sourceFromSessionRows/);
assert.match(server, /attachOpenedOfferToFreeMessage/);
const persist = server.slice(server.indexOf("export async function persistOpenedDirectOffer"), server.indexOf("export async function bindRectificationSource"));
assert.doesNotMatch(persist, /consultation_requests/);
consultationAgentPublicEventSchema.parse({
type: "run.completed",
responseKind: "smalltalk",
rectificationOffer: offer("opened"),
rectificationDirectOpen: { sourceSessionId: SOURCE_SESSION, mode: "new" },
});
consultationAgentPublicEventSchema.parse({
type: "run.completed",
responseKind: "smalltalk",
rectificationOffer: offer("offered", { autoOpen: true }),
rectificationDirectOpen: { sourceSessionId: SOURCE_SESSION, mode: "new" },
});
const surface = src("../src/hooks/use-rectification-surface.ts");
const fromChat = surface.slice(surface.indexOf("async function openRectificationFromChat"), surface.indexOf("async function openRectificationSession"));
assert.match(fromChat, /return null/);
assert.match(fromChat, /return openRectificationCase\("homepage", null, sourceSessionId\)/);
});
function cardHtml(record: RectificationOfferRecord) {
return renderToString(createElement(RectificationOfferCard, { offer: record, disabled: false, onStart() {} }));
}
test("a direct-open offer still shows the ordinary card until the case is bound", () => {
const pending = offer("offered", { autoOpen: true });
const html = cardHtml(pending);
assert.match(html, /大约 8 题,会用掉 3 点/);
assert.match(html, new RegExp(RECTIFICATION_OFFER_START_LABEL));
assert.equal(html.includes(RECTIFICATION_OFFER_OPENED_COPY), false);
assert.equal(html.includes("继续核对出生时间"), false);
assert.match(html, new RegExp(rectificationOfferCardCopy(pending).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
const messages = [{ role: "assistant", text: RECTIFICATION_DIRECT_NEW_COPY, responseKind: "smalltalk", rectificationOffer: pending }];
const bound = bindOfferToCase(messages, { caseId: CASE_ID, rectificationSessionId: RECT_SESSION });
const opened = bound![0]!.rectificationOffer as RectificationOfferRecord;
assert.equal(opened.status, "opened");
assert.equal(opened.autoOpen, true);
assert.equal(opened.caseId, CASE_ID);
const openedHtml = cardHtml(opened);
assert.match(openedHtml, new RegExp(RECTIFICATION_OFFER_OPENED_COPY));
assert.match(openedHtml, /继续核对出生时间/);
assert.equal(openedHtml.includes(RECTIFICATION_OFFER_START_LABEL), false);
});
test("a failed direct open leaves the offer offered and clickable", () => {
const pending = offer("offered", { autoOpen: true });
const messages = [{ role: "assistant", text: RECTIFICATION_DIRECT_NEW_COPY, responseKind: "smalltalk", rectificationOffer: pending }];
const opened = null;
const next = opened ? bindOfferToCase(messages, { caseId: CASE_ID, rectificationSessionId: RECT_SESSION }) : null;
assert.equal(next, null);
assert.equal((messages[0]!.rectificationOffer as RectificationOfferRecord).status, "offered");
assert.equal((messages[0]!.rectificationOffer as RectificationOfferRecord).autoOpen, true);
const html = cardHtml(pending);
assert.equal(html.includes(RECTIFICATION_OFFER_OPENED_COPY), false);
assert.match(html, new RegExp(RECTIFICATION_OFFER_START_LABEL));
assert.equal(html.includes("disabled=\"\""), false);
assert.equal(html.includes("disabled=\"disabled\""), false);
});
test("stored offers offered, opened, and returned still parse; returned hides the card", () => {
for (const status of ["offered", "opened", "returned"] as const) {
const parsed = parseRectificationOfferRecord(offer(status));
assert.equal(parsed?.status, status);
assert.equal(parsed?.autoOpen, undefined);
assert.equal("autoOpen" in (parsed ?? {}), false);
}
const flagged = parseRectificationOfferRecord(offer("offered", { autoOpen: true }));
assert.equal(flagged?.status, "offered");
assert.equal(flagged?.autoOpen, true);
assert.equal(parseRectificationOfferRecord({ ...offer("opened"), extra: true }), null);
assert.equal(cardHtml(offer("returned")), "");
assert.match(cardHtml(offer("opened")), new RegExp(RECTIFICATION_OFFER_OPENED_COPY));
assert.match(cardHtml(offer("offered")), new RegExp(RECTIFICATION_OFFER_START_LABEL));
});
test("the free reply stores the direct-open offer still offered, with autoOpen", () => {
// The record is not opened yet. The name of the attacher is historical.
const sentence = RECTIFICATION_DIRECT_NEW_COPY;
const card = offer("offered", { autoOpen: true });
const attached = attachOpenedOfferToFreeMessage(
[{ role: "user", text: "请帮我核对出生时间" }, { role: "assistant", text: sentence, responseKind: "smalltalk" }],
{ sentence, offer: card },
);
assert.ok(Array.isArray(attached));
assert.deepEqual(attached[1]!.rectificationOffer, card);
assert.equal((attached[1]!.rectificationOffer as RectificationOfferRecord).status, "offered");
assert.equal(attachOpenedOfferToFreeMessage(attached, { sentence, offer: card }), "present");
});
@@ -128,6 +128,8 @@ function offerTool(options: { alreadyOffered?: boolean; decide?: () => Promise<{
decide: options.decide ?? (async () => ({ ok: true, creditCost: 3, questionEstimate: 8 })),
alreadyOffered: options.alreadyOffered ?? false,
revisitQuestion: "我和伴侣还能走下去吗",
sourceSessionId: SOURCE_SESSION,
loadResumableSessionId: async () => null,
now: () => new Date("2026-10-07T00:00:00.000Z"),
},
}) as Record<string, { execute: (input: unknown, context?: unknown) => Promise<Record<string, unknown>> }>;
@@ -277,6 +279,53 @@ test("the open route binds the offer only for a consultation source; the source
assert.doesNotMatch(server, /createAdminSupabaseClient/);
});
test("an auto-open offer shows the ordinary card until the case is bound, and a reload keeps autoOpen", async () => {
const pending = offer("offered", { autoOpen: true });
const waiting = [
{ role: "user" as const, text: "请帮我核对出生时间" },
{ role: "assistant" as const, text: "好,我们开始核对出生时间。", responseKind: "smalltalk" as const, rectificationOffer: pending },
];
const html = renderToString(createElement(ChatTranscript, transcriptProps(waiting, [])));
assert.match(html, /开始核对出生时间/);
assert.match(html, /大约 8 题,会用掉 3 点;开始答第一题时才扣。/);
assert.doesNotMatch(html, /出生时间核对已经开始/);
assert.doesNotMatch(html, /继续核对出生时间/);
const bound = bindOfferToCase(waiting, { caseId: CASE_ID, rectificationSessionId: RECT_SESSION })!;
const openedHtml = renderToString(createElement(ChatTranscript, transcriptProps(bound as ChatMessage[], [])));
assert.match(openedHtml, /出生时间核对已经开始/);
assert.match(openedHtml, /继续核对出生时间/);
assert.equal((bound[1]!.rectificationOffer as RectificationOfferRecord).status, "opened");
assert.equal((bound[1]!.rectificationOffer as RectificationOfferRecord).autoOpen, true);
const failed = waiting;
assert.equal((failed[1]!.rectificationOffer as RectificationOfferRecord).status, "offered");
const { readSessions } = await import("../src/lib/home-cloud-sync.ts");
const reloaded = readSessions([{
id: SOURCE_SESSION,
title: "虚构",
theme: "marriage",
messages: [{ role: "assistant", text: "还没打开", rectificationOffer: pending }],
}], null);
assert.equal(reloaded.sessions[0]!.messages[0]!.rectificationOffer?.status, "offered");
assert.equal(reloaded.sessions[0]!.messages[0]!.rectificationOffer?.autoOpen, true);
const old = readSessions([{
id: SOURCE_SESSION,
title: "虚构",
theme: "marriage",
messages: [
{ role: "assistant", text: "a", rectificationOffer: offer("offered") },
{ role: "assistant", text: "b", rectificationOffer: offer("opened", { caseId: CASE_ID, rectificationSessionId: RECT_SESSION }) },
{ role: "assistant", text: "c", rectificationOffer: offer("returned", { caseId: CASE_ID, rectificationSessionId: RECT_SESSION }) },
],
}], null);
assert.deepEqual(old.sessions[0]!.messages.map((message) => message.rectificationOffer?.status), ["offered", "opened", "returned"]);
assert.equal(old.sessions[0]!.messages[0]!.rectificationOffer?.autoOpen, undefined);
const returnedHtml = renderToString(createElement(ChatTranscript, transcriptProps(old.sessions[0]!.messages.slice(2) as ChatMessage[], [])));
assert.doesNotMatch(returnedHtml, /rectification-offer-card/);
});
test("stored sessions keep the offer and the divider when the browser reads them back (BUG-1248/1250)", async () => {
const { readSessions } = await import("../src/lib/home-cloud-sync.ts");
const bound = bindOfferToCase(conversation(), { caseId: CASE_ID, rectificationSessionId: RECT_SESSION })!;
+108 -29
View File
@@ -10,6 +10,7 @@ from __future__ import annotations
import hashlib
import json
import threading
import time
from datetime import date
from pathlib import Path
@@ -23,6 +24,38 @@ KEEP_SECONDS = 2 * 24 * 60 * 60
PacketBuilder = Callable[[dict[str, Any]], dict[str, Any]]
# One build per cache key. A later caller waits and reads the stored packet.
class _Flight:
def __init__(self) -> None:
self.done = threading.Event()
self.error: BaseException | None = None
_flights: dict[str, _Flight] = {}
_flights_guard = threading.Lock()
# Chart-save warm has its own cap. When it is full the warm is skipped.
# A user request does not take this slot and does not receive 429 from it.
WARM_CONCURRENCY_LIMIT = 1
_warm_guard = threading.Lock()
_warm_inflight = 0
def _acquire_warm_slot() -> bool:
global _warm_inflight
with _warm_guard:
if _warm_inflight >= WARM_CONCURRENCY_LIMIT:
return False
_warm_inflight += 1
return True
def _release_warm_slot() -> None:
global _warm_inflight
with _warm_guard:
if _warm_inflight > 0:
_warm_inflight -= 1
def cache_dir() -> Path:
path = ROOT / "scratch" / "local" / "consult_full_data_cache"
@@ -104,34 +137,63 @@ def prune_cache(now: float | None = None) -> int:
return removed
def _read_cached(path: Path, started: float, now: Callable[[], float]) -> dict[str, Any]:
packet = json.loads(path.read_text(encoding="utf-8"))
return {"packet": packet, "hit": True, "elapsed_s": now() - started, "slow": False}
def get_or_build(
identity: dict[str, Any],
builder: PacketBuilder | None = None,
*,
clock: Callable[[], float] | None = None,
) -> dict[str, Any]:
"""Return the packet. A miss calls builder once and stores the packet."""
"""Return the packet. Concurrent callers for one key share one build."""
prune_cache()
now = clock or time.perf_counter
started = now()
path = _cache_path(identity)
if path.is_file():
packet = json.loads(path.read_text(encoding="utf-8"))
return _read_cached(path, started, now)
key = cache_key(identity)
with _flights_guard:
flight = _flights.get(key)
owner = flight is None
if owner:
flight = _Flight()
_flights[key] = flight
assert flight is not None
if not owner:
flight.done.wait()
if flight.error is not None:
raise flight.error
if path.is_file():
return _read_cached(path, started, now)
raise RuntimeError("consult_full_data_unavailable")
try:
if path.is_file():
return _read_cached(path, started, now)
if builder is None:
builder = build_full_data_packet
packet = builder(identity)
temporary = path.with_suffix(".json.tmp")
temporary.write_text(json.dumps(packet, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
temporary.replace(path)
elapsed = now() - started
return {"packet": packet, "hit": True, "elapsed_s": elapsed, "slow": False}
if builder is None:
builder = build_full_data_packet
packet = builder(identity)
temporary = path.with_suffix(".json.tmp")
temporary.write_text(json.dumps(packet, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
temporary.replace(path)
elapsed = now() - started
return {
"packet": packet,
"hit": False,
"elapsed_s": elapsed,
"slow": elapsed > SLOW_MISS_SECONDS,
}
return {
"packet": packet,
"hit": False,
"elapsed_s": elapsed,
"slow": elapsed > SLOW_MISS_SECONDS,
}
except Exception as exc:
flight.error = exc
raise
finally:
flight.done.set()
with _flights_guard:
if _flights.get(key) is flight:
_flights.pop(key, None)
def _attach_graha_drishti(packet: dict[str, Any], reading: dict[str, Any]) -> dict[str, Any]:
@@ -186,22 +248,39 @@ def build_full_data_packet(identity: dict[str, Any]) -> dict[str, Any]:
def warm_consult_packet(body: dict[str, Any] | None) -> dict[str, Any]:
"""Build today's packet after a chart save. The response stays small."""
try:
cached = get_or_build(identity_from_body(dict(body or {})))
except Exception as exc:
"""Build today's packet after a chart save. The response stays small.
At most WARM_CONCURRENCY_LIMIT warms run at once. A full cap skips this
warm. The skip is not a user-request 429, and a failure here does not
fail the chart save (the save route does not wait on this response).
"""
if not _acquire_warm_slot():
return {
"success": False,
"success": True,
"endpoint": "consult_card_warm",
"error_type": type(exc).__name__,
"skipped": True,
"hit": False,
"elapsed_s": 0.0,
"slow": False,
}
return {
"success": True,
"endpoint": "consult_card_warm",
"hit": bool(cached["hit"]),
"elapsed_s": cached["elapsed_s"],
"slow": bool(cached["slow"]),
}
try:
try:
cached = get_or_build(identity_from_body(dict(body or {})))
except Exception as exc:
return {
"success": False,
"endpoint": "consult_card_warm",
"error_type": type(exc).__name__,
}
return {
"success": True,
"endpoint": "consult_card_warm",
"hit": bool(cached["hit"]),
"elapsed_s": cached["elapsed_s"],
"slow": bool(cached["slow"]),
}
finally:
_release_warm_slot()
def _requested_domains(body: dict[str, Any]) -> list[str]:
+123
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import json
import threading
import time
import uuid
from pathlib import Path
@@ -302,3 +303,125 @@ def test_warm_response_does_not_include_the_packet():
assert warmed["success"] is False
assert "packet" not in warmed
assert warmed["error_type"]
def _fictional_body(today: str, minute: int) -> dict:
return {
"year": 1902,
"month": 3,
"day": 3,
"hour": 1,
"minute": minute,
"second": 0,
"lat": 1,
"lon": 2,
"tz": 0,
"ayanamsa": "raman",
"node_mode": "mean",
"today": today,
}
def _drop_cache(identity: dict) -> None:
path = cache_dir() / f"{cache_key(identity)}.json"
path.unlink(missing_ok=True)
path.with_suffix(".json.tmp").unlink(missing_ok=True)
def test_one_warm_at_a_time_and_same_key_user_waits_for_that_build(monkeypatch):
"""H2: warms share a cap of 1; a question for the key being warmed builds once."""
calls = {"n": 0}
calls_lock = threading.Lock()
entered = threading.Event()
release = threading.Event()
def blocking(identity):
with calls_lock:
calls["n"] += 1
entered.set()
assert release.wait(timeout=5)
return {"marker": identity["reference_date"]}
monkeypatch.setattr("consult_full_data_cache.build_full_data_packet", blocking)
bodies = [_fictional_body(f"1904-01-0{index}", 11) for index in (1, 2, 3)]
identities = [identity_from_body(body) for body in bodies]
for identity in identities:
_drop_cache(identity)
results: list[dict | None] = [None, None, None]
def run_warm(index: int) -> None:
results[index] = warm_consult_packet(bodies[index])
threads = [threading.Thread(target=run_warm, args=(index,)) for index in range(3)]
try:
for thread in threads:
thread.start()
assert entered.wait(timeout=5)
deadline = time.time() + 3
while time.time() < deadline and sum(item is not None for item in results) < 2:
time.sleep(0.02)
assert calls["n"] == 1
assert sum(item is not None and item.get("skipped") is True for item in results) == 2
for item in results:
if item is not None:
assert "packet" not in item
same = _fictional_body("1904-02-02", 12)
same_identity = identity_from_body(same)
_drop_cache(same_identity)
# The cap is still held by the first warm. A same-key question must join
# that pattern on its own key: start a warm, then the question.
release.set()
for thread in threads:
thread.join(timeout=5)
assert calls["n"] == 1
built = [item for item in results if item and item.get("skipped") is not True]
assert len(built) == 1
assert built[0]["success"] is True
assert built[0]["hit"] is False
finally:
release.set()
for thread in threads:
thread.join(timeout=5)
for identity in identities:
_drop_cache(identity)
entered.clear()
release.clear()
calls["n"] = 0
warm_box: dict = {}
user_box: dict = {}
same = _fictional_body("1904-02-02", 12)
same_identity = identity_from_body(same)
_drop_cache(same_identity)
def run_same_warm() -> None:
warm_box["result"] = warm_consult_packet(same)
def run_user() -> None:
user_box["result"] = get_or_build(same_identity)
warm_thread = threading.Thread(target=run_same_warm)
user_thread = threading.Thread(target=run_user)
try:
warm_thread.start()
assert entered.wait(timeout=5)
user_thread.start()
time.sleep(0.3)
assert calls["n"] == 1
assert user_thread.is_alive()
release.set()
warm_thread.join(timeout=5)
user_thread.join(timeout=5)
assert calls["n"] == 1
assert warm_box["result"]["success"] is True
assert warm_box["result"].get("skipped") is not True
assert "packet" not in warm_box["result"]
assert user_box["result"]["hit"] is True
assert user_box["result"]["packet"]["marker"] == "1904-02-02"
finally:
release.set()
warm_thread.join(timeout=5)
user_thread.join(timeout=5)
_drop_cache(same_identity)