Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28113fa0b6 | ||
|
|
86cafe6845 | ||
|
|
65b61ed523 | ||
|
|
e3f3a1fcde | ||
|
|
9abf2da5f9 | ||
|
|
b64b590a99 | ||
|
|
a855512ecc | ||
|
|
c40f23c20e | ||
|
|
45ec6e55a1 | ||
|
|
137ed6f408 |
@@ -1,5 +1,19 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-10-06 — 普通对话首轮改成几段聊天,不再交汇报骨架(未上线)
|
||||
|
||||
- 问事业、财运、父母这类首轮,以前会按固定小标题往下写,最后再交一份「这周可以做的一件事」。现在首轮是几段人话:先给结论,再写最关键的依据,相关时带上时间。问到几个人就分段说,段首点名,不写小标题(BUG-1244)。
|
||||
- 行动不再每轮都要。盘上确实指向一件相关的具体事情时,顺口说一句;用户问该怎么办时,那一轮可以多说几条。提示词里的示范删掉了会被逐字照抄的生活建议(BUG-1245)。
|
||||
- 同一件事仍只说一遍。追问仍是直接回答,讲清楚为准。思考栏不再出现「这周可以做什么」;没有小标题的回答,写作行是「组织回答」。
|
||||
- Skill 版本不变;不改数据库;不改模型、答题时钟和计费。
|
||||
|
||||
## 2026-10-05 — 普通对话不再白等校正闸的外部快照,用量页能看分段时间(未上线)
|
||||
|
||||
- 问父母、问年运这类普通对话,每个领域以前会在校正闸里再等一次外部星历快照,大约多 4 秒,失败了同一天还会再等。现在咨询路径复用当天已经拿到的官方结果;没有的话不再干等,记成「咨询里先不做」,同一天不重复等。生时校正自己的核对不变。顶层那一次外部核对仍会做(BUG-1231)。
|
||||
- 用量页多一列「分段」:分类耗时,第 0 步和第 1 步各自的耗时与推理、输出、输入、缓存输入 token,以及从第 1 步开始到写出第一个正文字的时间。供应商没返回的数显示为「—」。不改数据库表。对话提示词和数据卡没改(BUG-1232)。
|
||||
- 本机没有装 VedAstro SDK,任务书里「大约 4.6 秒降到 1 秒以内」没有在这台机器上重测。当前测试环境部署的仍是改前版本,真实分段要等这次上线后在用量页看。
|
||||
- Skill 版本不变;不改数据库结构;不改生时校正打分。
|
||||
|
||||
## 2026-10-04 — 门禁日志改成摘要,失败时还能看到是哪一项(未上线)
|
||||
|
||||
- 测试环境的质量门禁以前把七项检查的全部明细打在同一步里,日志有四万行以上,失败页面打不开(BUG-1230)。
|
||||
|
||||
+61
-1
@@ -16628,6 +16628,36 @@
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-1231 | 咨询链校正闸同步白等 VedAstro 官方快照,失败还不缓存
|
||||
|
||||
- 状态:resolved(分支 `codex/consult-latency-quickwins-20261005`)
|
||||
- 首次发现 / 最近更新:2026-10-04 / 2026-10-05
|
||||
- 来源:10-04 只读审计。任务书 `docs/tasks/TASK-consult-latency-quickwins-20261005.md`。写任务书时,在装了 vedastro SDK 的沙箱网络下按产品路径测得每个领域约 4.6 秒,第二轮仍是 4.6 秒。这组秒数不是本轮在这台机器上重测的。
|
||||
- 影响面:普通对话引擎里校正闸附带的 `rectification.vedastro_gateway`。顶层前台 VedAstro(BUG-301)不改。直接调用 `/api/rectification_gate`、请求里没有咨询标记时,仍走原来的官方快照。
|
||||
- 现象:同一份咨询响应里,顶层 `vedastro_gateway` 可以已经是 `official_verified`(BUG-727 的当日缓存),校正闸里的 `official_closure_reason` 仍是 `official_raw_response_missing`。校正闸每次再起一个最多 4 秒的子进程。失败结果不进正缓存,同一天每一轮都再等。屏蔽外网时引擎每个领域约 0.75 秒,这 4 秒是额外等待。生产机器是不是也在等,本轮没能在 staging 上确认(进度记录 T0)。
|
||||
- 根因:咨询工作流把「外部证据可以后做」这个标记只传给顶层前台任务。校正闸仍调用原来的官方快照。出生时间进校正闸时被规范成浮点,和正缓存的键对不上,所以当天已经有的官方结果也复用不到。
|
||||
- 修复:只在校正闸、且请求带了这个标记时,改走咨询专用路径。先用原始请求里的出生数据、岁差、交点、UTC 日期查正缓存,命中已验证结果就复用;否则查当天的负缓存;都没有就记 `official_closure_reason = deferred_in_consultation`,不起子进程,也不标成已验证。负缓存单独存放,只认当天的 UTC 日期。没有这个标记的校正请求仍调用原来的 gateway。
|
||||
- 验证:`tests/test_vedastro_consultation_snapshot.py`。咨询路径不调用快照子进程,也不调用原来的 gateway。负缓存同日命中、次日失效,邮箱不进缓存。当天已验证快照优先于负缓存,整数小时和规范后的浮点小时靠原始请求对齐。没有标记的校正面,返回包与现场结果逐项相同。增长合同通过,没有新增类方法。本机没有 vedastro SDK,4.6 秒降到 1 秒以内没有重测。v5 打分文件没有改动,本轮没有重跑 77 例。
|
||||
- 防复发:上述测试。开关只放在校正闸里,不放进顶层 gateway,避免把 BUG-301 的前台核对也跳掉。
|
||||
- 相关记录:BUG-727(当日正缓存)、BUG-301(顶层前台照旧,本单不推翻)。
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-1232 | 普通对话看不到第 0/1 步耗时和推理 token,分类耗时进不了用量
|
||||
|
||||
- 状态:resolved(分支 `codex/consult-latency-quickwins-20261005`)
|
||||
- 首次发现 / 最近更新:2026-10-04 / 2026-10-05
|
||||
- 来源:10-04 只读审计。任务书同上。审计写答题步大约 1 万推理 token、大约 45 秒,但线上记录里没有逐步耗时和推理 token,后面的「限制推理强度」「精简说明」对不上数。这组数字不是本轮实测。
|
||||
- 影响面:`[agent-observability]` 日志,以及用量账本里已有的 JSON 字段。不改表,不加迁移。不改提示词和数据卡。
|
||||
- 现象:已有首字节、工具耗时、答案首输出、整轮耗时、总 token。没有第 0 步和第 1 步各自的耗时,也没有推理、输出、输入、缓存输入 token。分类耗时没有进用量记录。没有「第 1 步开始到第一个正文字」的时间。
|
||||
- 根因:流式过程没有按步骤记下供应商返回的 usage。用量记录和用量页都没有这些字段。
|
||||
- 修复:流式过程记下第 0 步和第 1 步的耗时和供应商 usage。供应商没返回的数写空,不估算。`answer.reasoning_ms` 从第 1 步开始计到第一个非空白正文字;第 0 步的旁白不计。分类耗时同时写入可观测事件和用量记录。步骤数字写入用量记录的 `modelSteps` 和 `answer.reasoning_ms`。用量页多一列「分段」。各领域工具耗时保持原字段。正文、提示词、出生资料、用户标识不写入这些字段。
|
||||
- 验证:`frontend/tests/consult-latency-timing-20261005.test.ts` 4 项通过。模拟步骤的日志和用量字段齐全,不含植入的正文。流式一轮把计时留在运行状态上,公开回执和响应正文里没有这些字段,也没有那段正文。`tsc --noEmit` 0 错。`npm run lint` 0 error,既有 warning 未动。用量页本轮没有登录打开。
|
||||
- 防复发:上述测试锁住对话路由、用量列表查询和页面列名。可观测结构是严格对象,多出来的正文字段进不了事件。
|
||||
- 相关记录:10-04 咨询耗时审计(任务书事故实证第 2 条)。
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-1240 | 秒级 / 纳迪校准从未经过检验
|
||||
|
||||
- 状态:closed_by_design(2026-10-05 离线研究:预先登记的 N1、N2、N3、N5 都不过门;换岁差时第 5 层和等分 D150 的变化超过 20%,对外不说秒级)
|
||||
@@ -16642,4 +16672,34 @@
|
||||
- 防复发:再提秒级或纳迪校准时,先复跑这份预登记;不得用看过结果之后新加的规则判过门;不得重调 BUG-1091 已关闭的权重。
|
||||
- 相关记录:BUG-560、BUG-1090、BUG-1091、BUG-1105、BUG-1141。
|
||||
- 复发自:无
|
||||
- 修复版本:研究分支 `codex/rectification-nadi-seconds-research-20261005`(未推送、未部署)
|
||||
- 修复版本:研究分支 `codex/rectification-nadi-seconds-research-20261005`,2026-10-05 Claude 验收后合入 staging(只有研究脚本与文档,没有运行时改动)
|
||||
|
||||
## BUG-1244 | 普通对话首轮按固定小标题汇报,读起来不像聊天
|
||||
|
||||
- 状态:fixed-pending-verify(分支 `codex/consult-conversational-answer-20261006`;部署和真机清单完成前不标 resolved)
|
||||
- 首次发现 / 最近更新:2026-10-06 / 2026-10-06
|
||||
- 来源:产品看普通对话首轮,反馈像机器在汇报。任务书 `docs/tasks/TASK-consult-conversational-answer-20261006.md`。
|
||||
- 影响面:普通咨询本命 / 无出生分钟 / 申报时段的首轮写作指令、续写提示、思考计划里的「这周可以做什么」、没有标题时的写作行。不改证据卡、工具、模型、输出长度上限、答题时钟、计费。每日星语和「深入看今日」的骨架不动。生时校正的口气不动。
|
||||
- 现象:首轮按「开场 → 按对象的小标题 → 依据节 → 时间节 → 行动节」往下写。行动清单放在固定的最后一节。问到几个人时,段首是二级标题。申报时段还会要求先写「先回答你的问题」这个标题。
|
||||
- 根因:2026-10-01 的形状(BUG-1132)把「按对象分段」做成了二级标题,并保留三个固定节。2026-09-27 的「行动只在最后一节出现一次」(BUG-1073 的标题部分)仍要求交那一节。模型按标题填空,读起来是汇报。
|
||||
- 修复:形状只在 `consultation-thinking-plan.ts` 定义一处。首轮不写二级标题;问到几个人就分段落,段首点名。行动不强制,盘上有具体指向才顺口一句,用户问怎么办时那一轮可以给几条。首轮通常三到六段,不设硬截断。追问仍讲清楚为准;换领域或要求完整看时回到同样的段落形状。思考计划去掉行动节。正文没有二级标题时,思考节在流式中不保持进行中,结算后一起完成。写作行走「组织回答」。历史消息里已经写出的标题仍能拆开。`REPORT_HEADING` 只留在拆旧消息和思考节的 heading 字段里,不再拼进提示词。
|
||||
- 验证:合同测试见进度记录。本机没有模型凭据,四道离线题没有真跑。真机八步见 `docs/testing/consult-conversational-answer-20261006.md`。staging 尚未部署这次改动。
|
||||
- 防复发:`consultation-thinking-plan.test.ts`、`consultation-voice-contract.test.ts`、`consultation-run-timeline.test.ts` 锁住「不写 ## 节名」「段首点名」「行动不强制」「三到六段」,以及无标题首轮的思考节与写作行。改断言写了原值 / 新值 / 原因。
|
||||
- 相关记录:BUG-1073(同一件事只说一遍,保留)、BUG-1132(10-01 的按对象标题与三个固定节,本记录推翻其标题部分)。
|
||||
- 复发自:无。这是产品改口径,不是 1073 / 1132 的同一缺陷复发。
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-1245 | 提示词里的行动示范被回答逐字照抄
|
||||
|
||||
- 状态:fixed-pending-verify(分支 `codex/consult-conversational-answer-20261006`;部署和真机清单完成前不标 resolved)
|
||||
- 首次发现 / 最近更新:2026-10-06 / 2026-10-06
|
||||
- 来源:同上任务书。产品指出示范里的具体做法会出现在真实回答里。
|
||||
- 影响面:`product-voice.ts` 的 Good 示范。不改计算,不改证据卡。
|
||||
- 现象:不同人的回答里出现同一套可以照抄的生活建议,例如书面确认、这周去看小毛病、给妈妈打电话先问近况,以及「这周」行动节的标题。
|
||||
- 根因:Good 示范把具体行动句和固定标题写进了提示词。模型把示范当答案抄。
|
||||
- 修复:父母题和事业题的 Good 示范改成段落,段首点名,括号里只留依据占位。删掉全部具体行动句。另加一条只示意结构、不给可抄句子的汇报体 Bad。提示词里不再出现那三句旧行动,也不再出现「## 这周」。
|
||||
- 验证:`consultation-voice-contract.test.ts` 的「pinned examples carry no copyable action and no weekly heading (BUG-1245)」。真机第 3 步换人再问,看建议是否还是同一模子。本机没有模型凭据,没有对照跑新旧提示词。
|
||||
- 防复发:上述测试锁住三句旧行动和「## 这周」不得出现在提示词里。
|
||||
- 相关记录:BUG-1165、BUG-1168、BUG-1182(示范被照抄的前几轮)、BUG-1244。
|
||||
- 复发自:BUG-1165 的「示范被当成规则照抄」。旧测试锁的是读法和职业形态,没有锁行动句,所以这次换了一类句子仍能抄出去。
|
||||
- 修复版本:待发布
|
||||
|
||||
+13276
-13276
File diff suppressed because it is too large
Load Diff
@@ -121,3 +121,22 @@ python scripts/research/nadi_seconds_run.py
|
||||
```
|
||||
|
||||
预登记、库、跑数脚本、数据集的 sha256 写在结果 JSON 开头和结尾。本轮分别是 `dc012dd2b287b28ac2eefb75d5b9050e7788cb09d0064639878a37753ee8c2d3`、`ba170f3a0476fc41b3be8ac9f035d2f82575006753eb14992ccf124132be0f94`、`3c1af919408c637eead6e43252f6696beec73cb66480d8138a9075d3a8fb8ed6`、`ab365a3e3f4953d4b538f2c0889dc98ab89444a28340c3a7b29e2a864cdd687a`。同一命令再跑一次,两份结果的 SHA-256 都是 `4f1569c3e8794e0d2c35d244d32b3896e47cf8a1f7d03d16ce01d3f07b51ec69`,逐字节一致。
|
||||
|
||||
## 10. 验收补充(Claude 2026-10-05,事后,不参与过门)
|
||||
|
||||
**复跑**:在 Linux 上用 Python 3.13 跑同一条命令,耗时约 10 分钟。
|
||||
|
||||
- 结果与执行方提交的 JSON 逐字段比对,差异为 0。
|
||||
- 执行方的文件是在 Windows 上写出的,换行符是 CRLF,所以原来记的 SHA-256 `4f1569c3…` 只在 Windows 上成立。
|
||||
- 已把入库文件换成 LF 版本。两份文件去掉 CR 之后,SHA-256 都是 `d64006f3430f5d62c084c51ba3245ac8fe9b90ef45f2b2f09ff3eda40c23789b`。
|
||||
- 预登记、库、跑数脚本、数据集四个输入的哈希,与上文 §9 记录的一致。
|
||||
|
||||
**N4 补充**:预登记里的岁差对比都以 Raman 为起点,而 Raman 和其他三种相差约 1.4°。下面补测三种彼此很接近的岁差。脚本与 N4 相同,记录时间相同,样本是至少有一件日精度事件的 73 例。
|
||||
|
||||
| 换岁差 | 月亮相差(角分,中位) | 第 4 层事件变化 | 第 5 层事件变化 | 等分 D150 换段 |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| Lahiri → KP | 5.81 | 0.893 | 0.885 | 29/73 |
|
||||
| Lahiri → True Chitra | 0.83 | 0.570 | 0.939 | 4/73 |
|
||||
| KP → True Chitra | 4.98 | 0.910 | 0.848 | 25/73 |
|
||||
|
||||
月亮只差不到 1 角分(Lahiri 与 True Chitra),第 5 层主星就有 94% 的日事件跟着换。可见即使是同一派系里的两种岁差算法,Prana 层的结论也对不上。§1 的文案禁令不依赖 Raman 这个特例,结论不变。
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# PROGRESS:普通对话首轮改成聊天 — 2026-10-06
|
||||
|
||||
基线:`origin/staging` `86cafe68`(任务书,只有文档)。形状文件上一次产品提交仍是 `7806c72f` / `6a02e5e9`,没有更新的提交,没有变基。分支 `codex/consult-conversational-answer-20261006`,工作树 `.worktrees/consult-conversational-answer-20261006`。
|
||||
|
||||
未推 staging,未部署。BUG-1244、BUG-1245 状态是 `fixed-pending-verify`。Skill 版本不变。
|
||||
|
||||
## 做了什么
|
||||
|
||||
1. 首轮形状只留在 `frontend/src/lib/consultation-thinking-plan.ts`。`product-voice.ts`、`skill-binding.ts`、`index.ts`、`route.ts` 引用它,不再各写一套标题。首轮不写二级标题;问到几个人就分段落、段首点名;行动不强制;通常三到六段,不设硬截断。追问仍讲清楚为准。换领域或要求完整看时,回到同样的段落形状(BUG-1244)。
|
||||
2. Good 示范改成段落。删掉三句会被照抄的行动(书面确认、这周去看小毛病、给妈妈打电话先问近况),也删掉「## 这周」。另加一条只示意结构的汇报体 Bad(BUG-1245)。
|
||||
3. 思考计划去掉「这周可以做什么」。正文没有二级标题时,流式中没有任何一节停在进行中,结算后一起完成。写作行是「组织回答」/「正在组织回答…」。历史消息里的旧标题仍能拆开。
|
||||
4. 文案:`frontend/docs/VOICE.md` 第七条、首轮只说一遍、对照表;`frontend/DESIGN.md` 写作行。真机清单 `docs/testing/consult-conversational-answer-20261006.md`。
|
||||
|
||||
## 提示词里还留下的旧标题(允许的兼容命中)
|
||||
|
||||
`git grep -n "这周可以做的一件事" frontend/src` 只剩:
|
||||
|
||||
- `frontend/src/lib/consultation-thinking-plan.ts:20`:`REPORT_HEADING.action`。给旧回答拆段、缩短标题用,不拼进新提示词。
|
||||
|
||||
`git grep -n "骨架不可省略" frontend/src`:无。
|
||||
|
||||
`git grep -n "REPORT_HEADING" frontend/src` 都在 `consultation-thinking-plan.ts`,没有出现在模板字符串里:
|
||||
|
||||
| 行 | 用途 |
|
||||
| --- | --- |
|
||||
| 17–21 | 常量。注释写明只给 2026-10-06 之前的回答用 |
|
||||
| 95–97 | `consultationReportHeadings()` 返回这三个字符串,给旧回答对标题 |
|
||||
| 140、152 | 思考节 `heading` 字段(schema 必填),值是「盘上依据」。无标题的新回答不靠它推进进度 |
|
||||
| 160 | 时间节的 `heading` 字段,值是「时间怎么看」 |
|
||||
|
||||
`frontend/src` 的 `*.ts` / `*.tsx` 里没有 `## 盘上依据`、`## 时间怎么看`、`## 这周可以做的一件事`。仓库根 `SKILL.md` 里也没有这三句。历史文档、旧任务书、旧真机记录里的标题没有改。
|
||||
|
||||
## 断言三栏(本单改过的)
|
||||
|
||||
| 位置 | 原值 | 新值 | 原因 |
|
||||
| --- | --- | --- | --- |
|
||||
| 思考计划节 | 四节,含「这周可以做什么」 | 三节:question / support / timing | T3,BUG-1244 |
|
||||
| 无标题时的进度 | 口语开场还没有标题时首节 active | 整篇没有二级标题时没有 active;结算后全部 done | 首轮不再写标题,避免一直转圈 |
|
||||
| 时间节出现后的状态 | done, done, done, active | done, done, done | 行动节已删,时间节是最后一节 |
|
||||
| 续写 | 要求补三个固定二级标题 | 认出最后一个已有标题,接着用段落写,不再点名三个节名 | D1 |
|
||||
| 本命列表 | 并列点必须是 Markdown 列表 | 默认段落;用户要求列举,或三个以上时间窗,才用列表。每日星语仍用列表 | D5 |
|
||||
| 首轮指令 | 要求 `## 盘上依据` 等三个标题 | 不要求任何 `##` 节名;含段首点名、行动不强制、三到六段 | D1–D3 |
|
||||
| 口吻合同 | 「骨架不可省略」、不设字数上限 | 通常三到六段、不设硬截断;合同里没有「骨架不可省略」 | D1/D3 |
|
||||
| 技能骨架句 | Level 2 模板就是回答形状;行动出现一次 | Level 2 只是依据来源;六步宫位和 Yoga 表不贴在标题下 | D1/D2/D6 |
|
||||
| 长度句 | There is no length cap | usually three to six paragraphs;There is no hard cutoff | D3,只约束首轮 |
|
||||
| 行动句 | 一到三条破折号,只在最后一节 | 行动不强制 | D2 |
|
||||
| 父母 / 事业示范 | 带 `##` 和行动清单 | 段落、段首点名、括号只放依据占位 | D1/D4,BUG-1245 |
|
||||
| 寒暄合同 | opener-plus-skeleton | paragraph shape | D1。测试名保留 |
|
||||
| 写作提示(两份路由测试) | 提示里含三个固定 `##` 标题 | 含段首点名、行动不强制、三到六段,且不含 `##` + 旧节名 | D1–D3。夹具正文仍带旧标题,只测续写和放出 |
|
||||
|
||||
测试名没有删。新增两条:无标题首轮的思考节与写作行(BUG-1244);示范不含可抄行动和「## 这周」(BUG-1245)。
|
||||
|
||||
## 验证
|
||||
|
||||
| 项 | 结果 |
|
||||
| --- | --- |
|
||||
| `tsc --noEmit` | 0 错 |
|
||||
| `npm run lint` | 0 error,126 warning。本单改过的文件里,只有 `consultation-thinking-plan.ts` 的 `_domains` 未使用,这是原来的参数。没有为清 warning 改业务代码 |
|
||||
| 定向测试 | `consultation-thinking-plan`、`consultation-voice-contract`、`consultation-smalltalk`、`consultation-run-timeline`、`consult-career-yoga-functional-20261002`、`consult-no-presupposition-20261001`:79 项,79 通过,0 失败,0 cancelled |
|
||||
| `npm test` | 4851 项,4706 通过,145 失败,0 cancelled,0 skipped。退出码 1 |
|
||||
| 隐私标记 | `tests/test_repo_privacy_markers.py` 通过。进度和 Bug 历史没有写入具体出生资料 |
|
||||
| `next build --webpack` | 编译成功(2.9 分钟),TypeScript 检查通过。收集页面数据时在 `/api/birth-time-guide` 失败:现场技能符号链接 EPERM。没有打出路由表,因此没有 `/` 是否 Static、也没有首屏 gzip。这是本机工作树的已知环境缺口,不是这轮文案改动的编译错误 |
|
||||
|
||||
145 条失败里,和本单相关的文件是整文件加载失败,不是新断言没对上:`consult-answer-the-question-20260927.test.ts`、`consult-single-pass-answer-20260927.test.ts` 在导入 `skill-binding` 时抛 `SkillPackageRegistryError`,Windows 不允许为现场技能创建符号链接(EPERM)。同一原因还打红了 `consultation-workflow-request`、`consult-condensed-checklist`、`consult-evidence-lookup`、`consult-gender-card`、`skill-binding.test.ts` 等。本机工作树里的 `skills/jyotish-vedic-astrology/SKILL.md` 是 14 字节的链接文本(`../../SKILL.md`),不是真正的符号链接,所以「方法来自现场技能树」那一组也会空。数据库、备份、部署脚本失败是这台机器上原有的 Docker / 环境缺口。本轮没有在干净的 `86cafe68` 上再跑一遍全量做逐条对照。
|
||||
|
||||
写作提示那两条断言没有在测试进程里跑到。静态核对:`frontend/src` 和仓库根 `SKILL.md` 都不含 `## 盘上依据`、`## 时间怎么看`、`## 这周可以做的一件事`。
|
||||
|
||||
## T5 长度与离线
|
||||
|
||||
没有模型凭据,四道题(父母、事业、财运、伴侣)没有真跑,不把估计写成实测。新形状要求首轮通常三到六段,不设硬截断。相对旧的约 1,500 字、同一判断说三遍的父母样本,新指令去掉了固定节和行动清单。真机八步在 `docs/testing/consult-conversational-answer-20261006.md`。浏览器登录验收没有做。
|
||||
|
||||
## 构建
|
||||
|
||||
`npx next build --webpack`(Next.js 16.3.1):webpack 编译成功,接着的 TypeScript 检查也通过。收集页面数据时失败,原因与测试里的技能链接相同:`SkillPackageRegistryError`,无法为 `skills/jyotish-vedic-astrology/SKILL.md` 创建符号链接(EPERM)。路由表没有打出来。`/` 是否仍为 Static、首屏 gzip 相对基线的变化,本机没有数。没有改用 Turbopack:工作树的 `node_modules` 是联接,Turbopack 会直接拒绝。
|
||||
@@ -0,0 +1,151 @@
|
||||
# PROGRESS:普通对话耗时两项快修 — 2026-10-05
|
||||
|
||||
基线:任务书写成时已部署的是 `1252de3e`。开工时 `origin/staging` 为 `853772bc`(相对已部署提交只有文档)。执行中又快进到 `4c64733f`(再加一份纳迪研究任务书,产品代码与 `853772bc` 相同)。分支 `codex/consult-latency-quickwins-20261005`,工作树 `.worktrees/consult-latency-quickwins-20261005`。测试基线工作树停在 `853772bc`,没有改它。
|
||||
|
||||
未推送,未部署。用量页没有登录打开。修复版本:待发布。
|
||||
|
||||
## 做了什么
|
||||
|
||||
1. 咨询路径上,校正闸不再为官方快照干等。请求里已有「外部证据可以后做」这个标记时:先复用当天已经验证的官方快照;没有就查当天的失败记录;都没有就记 `deferred_in_consultation`,不起 4 秒子进程,也不标成已验证。直接做生时校正、请求里没有这个标记时,仍走原来的官方快照。顶层那一次前台核对仍做(BUG-301 不推翻)。BUG-1231。
|
||||
2. 普通对话补上分段计时。分类耗时、第 0 步、第 1 步的耗时和供应商 token,以及第 1 步开始到第一个正文字的时间,写入运行日志和用量账本里已有的 JSON 字段。用量页多一列「分段」。不改表。BUG-1232。
|
||||
3. 记录:BUG-1231、BUG-1232、CHANGELOG、本文件、状态板改为「已实现待验收」。
|
||||
|
||||
推理强度、精简说明、第 0 步关闭思考,按产品决定不在本单。
|
||||
|
||||
## T0 环境缺口
|
||||
|
||||
本轮没有登录态,也没有测试账号。公开接口测到:
|
||||
|
||||
| 检查 | 结果 |
|
||||
| --- | --- |
|
||||
| `GET https://staging.jyotisha.chat/api/health` | 200。`deployment.gitCommit` = `1252de3e6363f3097668efa1b1d5d47186c9c2d0` |
|
||||
| `POST https://staging.jyotisha.chat/api/consultation_workflow` | 404。公网边缘没有这条 Python 引擎路由 |
|
||||
| 本机 `vedastro` 模块 | 未安装 |
|
||||
|
||||
任务书里的 4.6 秒是沙箱网络下测的。当前测试环境跑的仍是改前版本。要确认生产机器是不是也在等这 4 秒,请在这次上线之前看现在的 `/admin/usage` 或容器日志:工具耗时是否接近「领域数 × 4 秒以上」。这次上线之后,同一处不应再出现这段等待;新的「分段」列用来看分类、第 0 步、第 1 步。
|
||||
|
||||
## T1 耗时
|
||||
|
||||
本机没有 VedAstro SDK,产品路径的冷热耗时没有重测。下表「改前」用任务书里的沙箱数字,「改后」是本轮测试能证明的行为。
|
||||
|
||||
| 场景 | 改前 | 改后 |
|
||||
| --- | --- | --- |
|
||||
| 装了 SDK、按产品路径,每个领域 | 约 4.6 秒,第二轮仍约 4.6 秒 | 本机测不了秒数。测试证明:不起快照子进程,状态是 `deferred_in_consultation`,不是已验证 |
|
||||
| 同一天再问,上次是超时或失败 | 再等约 4.6 秒(失败不进缓存) | 负缓存同日命中,文件不重写,子进程不调用 |
|
||||
| 下一个 UTC 日 | 任务书未单列 | 当天的负缓存失效,重新记 `deferred_in_consultation` |
|
||||
| 当天已经有已验证的官方快照 | 顶层能用缓存,校正闸仍去等 | 校正闸复用同一份缓存。整数小时和进闸后变成的浮点小时,用原始请求对齐,避免对不上键 |
|
||||
| 生时校正自己的请求(没有咨询标记) | 走原来的官方快照 | 仍走原来的 gateway,返回字段与现场结果逐项相同 |
|
||||
| 顶层前台 VedAstro | 照旧(BUG-301) | 照旧。标记只作用在校正闸,不作用在顶层 gateway |
|
||||
|
||||
3 位公开名人 × 父母 / 年运、冷热各两轮:环境缺口,没有秒数。
|
||||
|
||||
## T2 产品在哪里看
|
||||
|
||||
用量页 `/admin/usage` 的「分段」列,旧记录没有这些数时显示「—」。数字来自用量账本已有的 JSON 字段,没有新表、没有迁移。同一组数也在服务端日志 `[agent-observability]` 里。本轮没有登录,页面没有点开;列的文字由单元测试锁住。
|
||||
|
||||
| 你看到的 | 含义 | 没有数时 |
|
||||
| --- | --- | --- |
|
||||
| 分类 180 ms | 分类这一步的耗时 | 分类 — |
|
||||
| 第 0 步 … · 推理 · 出 · 入 · 缓存入 | 决定要不要排盘的那一步。耗时是毫秒;后面四个是供应商返回的 token | 该项为 — |
|
||||
| 第 1 步 … | 写答案的那一步,字段同上 | 同上 |
|
||||
| 写到正文 450 ms | 第 1 步开始,到第一个正文字出现。第 0 步的旁白不计 | 写到正文 — |
|
||||
| 各领域工具耗时 | 原有字段,本单没有改含义 | 原样 |
|
||||
|
||||
日志和用量记录里的对应名字:`classification.durationMs`、`modelSteps`(只保留第 0 步和第 1 步)、`answer.reasoning_ms`、工具调用上原有的 `durationMs`。供应商没返回的 token 是空值,不估算。这些字段里没有提示词、答案正文、出生资料、用户标识。
|
||||
|
||||
## 开工预检
|
||||
|
||||
读了 `docs/research/pre_work_error_ledger.md`。本单不涉及碎片目录或镜像仓,没有再读两份碎片清扫。`scripts/pre_work_check.py --remote-timeout 8 --command-timeout 45` 的结果:
|
||||
|
||||
| 项 | 结果 |
|
||||
| --- | --- |
|
||||
| Python 运行时 | 通过 |
|
||||
| 外部引擎适配器 | 通过 |
|
||||
| 远端可见 | 通过,远端是 `https://git.copse.top/root/Jyotisha.git` |
|
||||
| 碎片扫描 | 90 秒超时 |
|
||||
| 聚焦测试 | 45 秒超时 |
|
||||
| 检查当时的分支 | 比 `origin/staging` 落后 1 个提交;随后已快进到 `4c64733f` |
|
||||
|
||||
碎片扫描和聚焦测试超时是这次预检命令自己的时限,不是远端同步失败。没有新的镜像路径要记。
|
||||
|
||||
磁盘:开工时 G: 约 557 GB 空闲,D: 约 135 GB,C: 约 24 GB。
|
||||
|
||||
## 已完成的检查
|
||||
|
||||
| 项 | 结果 |
|
||||
| --- | --- |
|
||||
| 新增 Python 测试 + 增长合同 | 11 项通过 |
|
||||
| 两个新 Python 文件的 ruff | 通过。`jyotish_api_server.py` 里原有的 ruff 问题没有顺手改 |
|
||||
| 前端计时测试 | 4 项通过,0 失败,0 cancelled |
|
||||
| `tsc --noEmit` | 0 错 |
|
||||
| `npm run lint` | 0 error,126 条既有 warning,没有为消 warning 改业务代码 |
|
||||
| v5 打分文件 | 工作区没有改动。没有重跑 77 例 |
|
||||
| 生时校正打分 | 没改 |
|
||||
|
||||
全量 `pytest tests`、英文对照、隐私测试、全量 `npm test`、快速门:见下面「套件对照」。对照完成前,不把本单说成套件已通过。
|
||||
|
||||
## 套件对照
|
||||
|
||||
前端全量 `npm test`(本机,不设 CI)。基线工作树 `853772bc`,本分支在快进后的产品代码上。两边都是退出码 1,cancelled 0,skipped 0。
|
||||
|
||||
| | tests | pass | fail | cancelled |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 开工基线 | 4844 | 4700 | 144 | 0 |
|
||||
| 本分支 | 4848 | 4703 | 145 | 0 |
|
||||
|
||||
多出来的 4 个测试是本单的计时测试,全量里 4 个都通过。144 条旧失败名字与基线逐条相同。多出来的那 1 条是 `database roles have no cluster privileges`:全量并行时 Docker 里的 Postgres 正在关闭,`psql` 报 `the database system is shutting down`。这条测试不读本单改过的文件。单独重跑该文件:2 项通过,0 失败。不是这次改动引进的。
|
||||
|
||||
英文对照 `tests/test_report_english_dictionary.py` 与隐私 `tests/test_repo_privacy_markers.py`:一起跑,退出码 0。
|
||||
|
||||
`tsc --noEmit`:0 错。`npm run lint`:0 error,126 条既有 warning。
|
||||
|
||||
全量 `pytest tests`(`--tb=line`,退出码以失败名单为准;日志里的短摘要计数行没有落盘)。用量页没有用浏览器点开。
|
||||
|
||||
| | 失败条数 | 与对方相比多出来的名字 |
|
||||
| --- | --- | --- |
|
||||
| 开工基线 `853772bc` | 130 | — |
|
||||
| 本分支 | 131 | `tests/test_consultation_native_layers.py::test_the_new_layer_leaves_every_existing_output_unchanged` |
|
||||
|
||||
其余 130 条名字与基线相同。多出来的这一条是既有的盘缓存抖动:缓存命中读回的 VedAstro 请求清单键序和现场重算不一样。这条测试会先跑掉新层再比整份输出,并且会剥掉 `vedastro_gateway`。它用的请求没有咨询标记,不走本单的校正闸开关。全量里失败一次。单独再跑:第一次失败,紧接着再跑一次通过。快速门里又失败一次,失败后用同一比较再跑,差异条数是 0。不记成新 bug。
|
||||
|
||||
v5 打分文件没有改动,没有重跑 77 例。
|
||||
|
||||
快速门 `scripts/run_quality_gate.py --profile quick`:退出码 1。1056 通过,1 跳过,1 失败。唯一失败仍是上面那条盘缓存测试。失败后立刻用同一比较再跑一遍,两边剥掉易变字段后差异条数是 0。这条失败跟着缓存时序走,不跟着本单的校正闸开关走。门禁跑完后,5 份待处理的 oracle 模板只多了回车换行,已还原,不进提交。
|
||||
|
||||
`next build`:这个工作树的 `frontend/node_modules` 是指到主检出的联接,Turbopack 拒绝「联接指向项目根以外」。改用门禁同一条 webpack 构建(`next build --webpack`):编译 3.6 分钟通过,TypeScript 2.5 分钟通过。收集页面数据时停在 `/api/consult`,原因是 Windows 不允许为 Skill 运行别名建符号链接(`EPERM`,`SKILL.md` → 临时目录)。路由表没有打出来,所以没能在这台机器上确认 `/` 仍是 Static。首页源文件没改。没有为了构建去改符号链接,也没有在工作树里重装依赖。
|
||||
|
||||
## Claude 验收(2026-10-05,Linux,Node 22)
|
||||
|
||||
**结论:通过,快进合入 staging。** 环境是 Linux,Node v22.14,Python 3.13。基线工作树是 `4c64733f`,被验分支是 `137ed6f4`。
|
||||
|
||||
| 项 | 基线 `4c64733f` | 本分支 `137ed6f4` | 判定 |
|
||||
| --- | --- | --- | --- |
|
||||
| Python 全量 `pytest tests`(-n 4) | 62 失败 | 62 失败,名单逐条相同 | 通过 |
|
||||
| 新增 Python 测试 + 增长合同 | — | 11 passed | 通过 |
|
||||
| 快速门 Python 段 | — | `pytest` ✓;唯一红是 npm test 步,即下一行的同一批环境失败 | 通过 |
|
||||
| 前端 `npm test` | 4932 / 24 fail / 0 cancelled | 4936 / 24 fail / 0 cancelled,失败名单逐条相同,新增 4 条全过 | 通过 |
|
||||
| `tsc --noEmit` / `npm run lint` | — | 0 错 / 0 error、126 warning | 通过 |
|
||||
| `next build`(Turbopack,硬链 node_modules) | `/` ○ Static | `/` ○ Static | 通过 |
|
||||
| 首屏 gzip(index.html 引用 JS+CSS,gzip -9 求和) | 636,225 B | 636,225 B(0%) | 通过 |
|
||||
|
||||
24 条前端失败与 62 条 Python 失败,两边名单相同,属于本机无 Docker / 无模型凭据等环境项。执行方在 Windows 上看到的 Docker 关停与盘缓存抖动,这次在 Linux 上没有出现。
|
||||
|
||||
**T1 实测**(补上执行方的环境缺口):在隔离 venv 里装 VedAstro SDK,走产品路径(`defer_optional_external_evidence: true`)。3 位名人 × 父母 / 年运,跑两轮共 12 次,每次都用全新的缓存目录。
|
||||
|
||||
| | 每领域耗时中位 | 校正闸 `official_closure_reason` |
|
||||
| --- | --- | --- |
|
||||
| 改前 `4c64733f` | 4.47 s(首次 5.89 s) | `official_raw_response_missing` |
|
||||
| 改后 `137ed6f4` | 1.66 s(首次 2.02 s) | `deferred_in_consultation` |
|
||||
|
||||
- 每个领域省下约 2.8 s,降幅约 63%。
|
||||
- 任务书写的「≤1.0 s」**没有达到**。我用 cProfile 拆过剩下的 1.66 s:
|
||||
- 约 1.33 s 是顶层前台 VedAstro 的等待上限(`vedastro_foreground.finish → _join_foreground_vedastro`);
|
||||
- 引擎本体约 0.07 s(盘缓存是热的)。
|
||||
- 顶层这次等待是决策记录第 3 条(BUG-301)明确要保留的。任务书的 1.0 s 目标,是照着「外网全屏蔽」时的 0.75 s 定的,定得不对。这是任务书的问题,不是实现的问题。
|
||||
- 本沙箱里顶层 VedAstro 拿不到已验证的结果,所以「复用当日已验证快照」这条路径没有被线上式请求走到,只有单元测试 `test_same_day_verified_snapshot_is_reused_ahead_of_the_negative_cache` 覆盖。
|
||||
|
||||
**审查意见(不阻断,留给后续)**:
|
||||
|
||||
1. 咨询路径从来不启动快照子进程,所以负缓存里存的只会是固定的 `deferred_in_consultation` 包,起不到「记住失败」的作用。而且它每人每天在 `api_scratch` 里写一个文件,没有清理。以后可以简化成不写盘。
|
||||
2. `modelSteps` 默认第 1 步就是写答案的那一步。如果模型连续调用两次工具,第 1 步其实是第二次工具调用,这时 `answer.reasoning_ms` 记为空值。读数据时要注意这一点。
|
||||
3. 部署后由产品在 `/admin/usage` 的「分段」列看一轮真实数字。这一列本轮没有在浏览器里点开过。
|
||||
@@ -45,3 +45,22 @@ Python 快速门用 `--profile quick --skip-frontend-runtime`。这个工作树
|
||||
## 让步
|
||||
|
||||
N0、N1、N2、N3、N4、N5 都做了。N3 的 ±30 / ±60 只报告。N6 没有另做图。
|
||||
|
||||
## Claude 验收(2026-10-05)
|
||||
|
||||
**结论:通过,合入 staging。** BUG-1240 维持 `closed_by_design`,不立实现单。
|
||||
|
||||
| 项 | 结果 |
|
||||
| --- | --- |
|
||||
| 预登记先于结果 | `57480db3` 只含预登记 JSON。它之后 `scripts/` 与 `tests/` 没有改动,最后一次提交只有文档和结果 |
|
||||
| 预登记与报告一致 | 过门条件(N1 六项全过、N2 下界 > 0、N3 ±10 下界 > 0 且真值 ≥ 76/77、N5 四项全过)和种子,与 §1–§7 逐条对上 |
|
||||
| 复跑 | Linux 复跑,内容差异 0;字节差异只来自 Windows 换行,已换成 LF 入库(见研究报告 §10) |
|
||||
| 定向测试 | 新测试加开工那五个文件,131 passed |
|
||||
| 快速门 `--profile quick --skip-frontend-runtime` | 通过(pytest ✓)。本单没有前端改动 |
|
||||
| N3 基线 0.325 与已发表 0.64 的差异 | 已核实是口径不同:已发表的是 `probe_supply_after_six.top1_hit`,看的是真值所在簇的代表分钟是否唯一领先;本单看的是全部并列都在记录时间 1 分钟内。比较的是同一次回放,结论成立 |
|
||||
| 补充 | Lahiri / KP / True Chitra 两两互换的 N4 表,写在报告 §10,结论不变 |
|
||||
|
||||
两处不阻断的意见:
|
||||
|
||||
1. 跑数脚本写结果时没有固定换行符,以后在 Windows 上复跑会得到 CRLF。下次动这个脚本时,写文件改用 `newline="\n"`。这次没改,因为一改脚本哈希就和预登记记录对不上。
|
||||
2. N3a 的并列数中位是 8 个,有 23 例超过 25 个。第 5 层是二值规则,挑不出唯一的一秒,这本身就是「秒级」在方法上做不到的一个直接证据。
|
||||
|
||||
@@ -421,5 +421,7 @@
|
||||
| `TASK-astrologer-rulings-batch5-20261004.md` | `PROGRESS-astrologer-rulings-batch5-20261004.md` | 占星师第六轮:Rath 双主星按 p.43(a)–(e)、罗计尊贵按 Rath Table 9(仅 Narayana 内)、子运方向 p.51 例外与书内分歧标注、Wadiyar 两对标软件特例;年主选不出时不再用 Muntha 主星顶替(对齐上游 03bea6ed);Rath 替换校正 v5 重试算(研究)(BUG 从 1223 起) | 已实现待验收(T2–T5 完成;T1 按红线停下 blocked;T6 研究分支已跑) | 分支 `codex/astrologer-rulings-batch5-20261004`(未推送;BUG-1223~1227;校正分数不变、未升版本);研究分支 `codex/narayana-rath-rectification-trial-20261004`(不合入) |
|
||||
| `TASK-astrologer-rulings-batch6-20261004.md` | `PROGRESS-astrologer-rulings-batch6-20261004.md` | 第七轮裁定(共享仓书面回复,产品采用;问 1 选 A):Rath 版双主星按 p.43 (a)–(e)(BUG-1227 解除 blocked,推翻第五批红线 2 的 Table 17 年数底线);第 5 级宫主度数只倒算计都(p.71 脚注 42);罗计旺陷 ±1 年;同宫两主比经度;BUG 从 1228 起 | 待验收 | 分支 `codex/astrologer-rulings-batch6-20261004`(BUG-1227~1229;校正分数文件未改、未升版本) |
|
||||
| `TASK-gate-log-volume-20261004.md` | `PROGRESS-gate-log-volume-20261004.md` | 门禁 validate 单步日志 4.4 万行 / 2 MB 网页打不开(run 3170):快速门只打摘要(失败给末 200 行 + 日志文件)、前端测试门禁上 dot + 失败汇总(本机仍 TAP)、拆分 validate 为 7 个 step(产品授权改 workflow,只限拆分与重定向);检查一项不少 | **已实现待验收**(BUG-1230;未推送、未部署;Gitea 各 step 页面是否打得开留待推送后由产品确认) | 分支 `codex/gate-log-volume-20261004` |
|
||||
| `TASK-consult-latency-quickwins-20261005.md` | `PROGRESS-consult-latency-quickwins-20261005.md` | 普通对话耗时两项快修:咨询链校正闸不再同步白等 VedAstro 官方快照子进程(每域约 4 s,复用顶层缓存 + 负缓存,不推翻 BUG-301);补分段计时(第 0/1 步耗时、推理 token、分类耗时进日志与 usage)。推理强度/精简说明待模型 key 另单(BUG 从 1231 起) | 待领取 | 分支 `codex/consult-latency-quickwins-20261005` |
|
||||
| `TASK-rectification-nadi-seconds-research-20261005.md` | `PROGRESS-rectification-nadi-seconds-research-20261005.md` | **「纳迪秒级校准」可证伪检验(离线)**:竞品宣传「问前事到天 → 秒级」。本仓主链只用三层小运,Sookshma / Prana 与 D150 从未进评价集;v5 真值 52/77 是整 5 分钟(秒级无真值可对)。N0 五层小运 + D150 底座(前三层与主链对账 0 差)、N1 拟合率 vs 安慰剂日期(核心)、N2 留一件预测、N3 六题后区间内再细分能否提头名、N4 岁差 / 坐标 / 时间扰动的噪声地板、N5 D150 结构层(原文比对 blocked)、N6 结论 + 对外口径草稿。规则先登记再跑;不改生产代码;不得重调 BUG-1091 已关的权重 | 待验收 | 分支 `codex/rectification-nadi-seconds-research-20261005`(BUG-1240 `closed_by_design`;未推送、未部署) |
|
||||
| `TASK-consult-latency-quickwins-20261005.md` | `PROGRESS-consult-latency-quickwins-20261005.md` | 普通对话耗时两项快修:咨询链校正闸不再同步白等 VedAstro 官方快照子进程(每域约 4 s,复用顶层缓存 + 负缓存,不推翻 BUG-301);补分段计时(第 0/1 步耗时、推理 token、分类耗时进日志与 usage)。推理强度/精简说明待模型 key 另单(BUG-1231、BUG-1232) | 已验收(Claude 10-05 Linux:Python 62 / 前端 24 失败与基线逐名相同,Static、gzip 0%;装 SDK 实测每领域 4.47→1.66 s,剩余 1.33 s 是保留的顶层前台等待;/admin/usage 真机欠) | 分支 `codex/consult-latency-quickwins-20261005` |
|
||||
| `TASK-rectification-nadi-seconds-research-20261005.md` | `PROGRESS-rectification-nadi-seconds-research-20261005.md` | **「纳迪秒级校准」可证伪检验(离线)**:竞品宣传「问前事到天 → 秒级」。本仓主链只用三层小运,Sookshma / Prana 与 D150 从未进评价集;v5 真值 52/77 是整 5 分钟(秒级无真值可对)。N0 五层小运 + D150 底座(前三层与主链对账 0 差)、N1 拟合率 vs 安慰剂日期(核心)、N2 留一件预测、N3 六题后区间内再细分能否提头名、N4 岁差 / 坐标 / 时间扰动的噪声地板、N5 D150 结构层(原文比对 blocked)、N6 结论 + 对外口径草稿。规则先登记再跑;不改生产代码;不得重调 BUG-1091 已关的权重 | 已验收(Claude 10-05:N1/N2/N3/N5 不过门、N4 触发文案禁令;Linux 复跑内容 0 差,结果改 LF;补 Lahiri/KP/True Chitra 对照,月亮差 0.83′ 第 5 层仍换 94%)→ 不立实现单 | 分支 `codex/rectification-nadi-seconds-research-20261005`(BUG-1240 `closed_by_design`) |
|
||||
| `TASK-rectification-delivery-dup-adopt-20261006.md` | `PROGRESS-rectification-delivery-dup-adopt-20261006.md` | **校正交付一次两段回答 + 点「改用…盘解读」被拒 `adoption_not_allowed`**(10-06 真机):收尾补位按文字子串防重,遇采用旁白 Agent 改写版失效(BUG-1153 复发);accept/GET 调 `decideFromDossier` 不带出生日期,未成年探针复活致判定与出卡时相反(BUG-598/680 同族);卡片按钮不看公开 can_adopt、拒绝码直出英文。不放宽采用门,判定入参统一 + 结构防重 + 按钮判据 + 中文提示(BUG-1241~1243) | 待领取 | — |
|
||||
| `TASK-consult-conversational-answer-20261006.md` | `PROGRESS-consult-conversational-answer-20261006.md` | **普通对话首轮去汇报骨架,改成聊天**(10-06 产品反馈「像机器在汇报」):首轮取消全部 `##`(三个固定节 + 按对象标题,多人改分段落点名)、行动不强制(盘上有具体指向才顺口一句,问「怎么办」再展开)、首轮三到六段;示范删可抄的行动句(实测被逐字照抄);思考栏去「这周可以做什么」。推翻 10-01 D2 与 D8 首轮部分(BUG-1244~1245) | 待验收 | 分支 `codex/consult-conversational-answer-20261006`(未推 staging,未部署) |
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
# TASK · 普通对话首轮去汇报骨架,改成聊天(2026-10-06)
|
||||
|
||||
- 基线:`origin/staging` @ `65b61ed5`(形状最近一次改动:`7806c72f` / `6a02e5e9`)
|
||||
- 执行分支:`codex/consult-conversational-answer-20261006`,worktree `.worktrees/consult-conversational-answer-20261006`
|
||||
- BUG 编号起点:**BUG-1244**(`docs/BUG_HISTORY.md` 现最大 1240,1241~1243 已被 `TASK-rectification-delivery-dup-adopt-20261006` 预留;开工时再核对一次)
|
||||
- 串行:本单只改普通对话形状,与同日校正单(BUG-1241~1243)不碰同一文件;开工前 `git log origin/staging -3 -- frontend/src/mastra/product-voice.ts frontend/src/lib/consultation-thinking-plan.ts`,若有新提交先 rebase 再动手。
|
||||
|
||||
## 1. 事故实证
|
||||
|
||||
产品 10-06 真机(普通对话,事业 / 财富类首轮)读到的结尾:
|
||||
|
||||
```
|
||||
## 这周可以做的一件事
|
||||
- 把这周口头答应的事各补一条书面确认,特别是涉及钱、时间和谁负责哪一部分的。
|
||||
- 挑一件你在做、但一直没名分的事,写清它占多少时间、带来多少钱,按这个数去谈一次条件,先别谈头衔。
|
||||
- 记一张两栏清单:这周新来的机会,一栏只算接触,一栏有具体条件;月底只看第二栏。
|
||||
```
|
||||
|
||||
产品原话:「这种固定的输出能不能去掉,能不能做成人和 agent 平常聊天的那种;现在输出的答案格式有些固定,语气不像一个人,像一个机器在做汇报。」
|
||||
|
||||
源码(行号按符号定位,以基线为准):
|
||||
|
||||
| 现象 | 位置 |
|
||||
| --- | --- |
|
||||
| 首轮固定写「开场 → 按对象 H2 → `## 盘上依据` → `## 时间怎么看` → `## 这周可以做的一件事`」,「骨架不可省略」 | `frontend/src/lib/consultation-thinking-plan.ts` 的 `REPORT_HEADING`、`NATAL_ANSWER_SHAPE_SUMMARY`、`natalAnswerShapeBody()`、`followUpAnswerShapeInstruction()` 末两句;`frontend/src/mastra/product-voice.ts` 的 `ANSWER SHAPE` 第 4 条与 `natalSpokenReportContract` 第 3–5 条;`frontend/src/mastra/skill-binding.ts` 的 `NATAL_REPORT_SKELETON`;`frontend/src/mastra/index.ts` 引 `NATAL_ANSWER_SHAPE_SUMMARY` / `consultationSpokenHeadingRule("natal")`;`frontend/src/app/api/consult/route.ts` 里 `legacyNatalInstruction`(「骨架不可省略」) |
|
||||
| 第一条行动和提示词示范逐字相同 | `product-voice.ts` 单对象事业题 Good 示范:「口头答应的事,补一份书面确认。」 |
|
||||
| 每轮都要交行动清单 | `product-voice.ts` PERSONA 末句「每次收尾只给一到三件能做的事」;HOPE DISCIPLINE「每次都要说清这段时间能做什么」 |
|
||||
| 并列点必须写成加粗标签列表 | `consultationSpokenHeadingRule("natal")` 第二句(「must be Markdown bullet lists. Bold a short label…」) |
|
||||
| 首轮写成长篇 | 10-01 D8 取消字数上限(`TASK-consult-plain-answer-20261001`) |
|
||||
| 思考栏有「这周可以做什么」一节,进度靠正文 H2 推进 | `natalConsultationThinkingPlan()` 的 `action` 节;`applyThinkingSectionProgress()` 按 `^## ` 判断完成 |
|
||||
| 申报时段路线开头固定 `## 先回答你的问题` | `WINDOW_OPEN_HEADING` + `consultationSpokenHeadingRule("window")` |
|
||||
|
||||
追问轮(`followUpAnswerShapeInstruction`)已经是无标题、无行动清单的直答,不是本次问题来源;但它「换领域时回到完整形状:……二级标题」那句要跟着改。
|
||||
|
||||
## 2. 根因
|
||||
|
||||
1. **形状是报告模板**:首轮被规定成固定章节,每节有固定名字和固定位置,读起来就是一份汇报。09-27(BUG-1073)和 10-01(BUG-1132)两轮都在这个骨架里调内容,骨架本身没动过。
|
||||
2. **示范带了可抄的具体内容**:Good 示范里写了真实的行动句,模型照抄或照着改写,所以不同的人收到的行动都像一个模子出来的(「书面确认」「两栏清单」)。
|
||||
3. **行动是硬性必交项**:PERSONA 和第 5 条合起来要求每次首轮都交一到三条,盘上没有指向具体事情时,模型只能编一些放谁身上都成立的职场建议。
|
||||
4. **加粗标签列表 + 不限长度**放大了汇报感。
|
||||
|
||||
## 3. 决策记录(产品 2026-10-06 授权)
|
||||
|
||||
产品当面选择(三问均选推荐项):
|
||||
|
||||
- **D1 去掉全部小标题。** 首轮正文不写任何 `##`:三个固定标题(盘上依据 / 时间怎么看 / 这周可以做的一件事)取消,**按对象分段的标题也取消**。问到几个人(妈妈、爸爸……)时仍然分开说,改成分段落、段首用人话点名(「你妈妈这边……」「爸爸那边……」),不揉成一段。申报时段路线的 `## 先回答你的问题` 一并取消。
|
||||
- **推翻**:10-01 `TASK-consult-plain-answer-20261001` D2(按对象 H2 分段 + 三个固定标题);09-27 `TASK-consult-answer-the-question-20260927` D8 中「行动只在最后一节出现一次」的标题部分;VOICE.md 第七条原则与 §「首轮只说一遍」里的标题骨架。BUG-1073「同一件事只说一遍」**保留**。
|
||||
- **D2 行动建议不强制。** 只有盘上确实指向一件和问题相关的具体事情时,才在话里顺口说一句(不列清单、不放固定位置、全篇最多一处);用户问「我该怎么办 / 怎么做」时再展开,那一轮可以给几条。
|
||||
- **推翻**:PERSONA「每次收尾只给一到三件能做的事」;`ANSWER SHAPE` 第 4 条;`natalSpokenReportContract` 第 5 条。
|
||||
- HOPE DISCIPLINE **保留其意**:不顺就说不顺、说清这段时间拿来干什么、不编月份、禁空话清单——但用一句话说进正文,不变成行动清单。
|
||||
- **D3 首轮写几段话,细节等追问。** 首轮讲清结论、最关键的一两条依据、相关时有时间,通常三到六段;不为显得周全把各方面写满。不设硬截断,不改输出 token 上限和答题时钟。
|
||||
- **推翻**:10-01 D8「不限字数,讲清楚为准」的首轮部分(追问轮仍「讲清楚为准」)。
|
||||
- **D4(架构,Claude 定)示范只演示口气,不带可抄的内容。** Good 示范里不得出现任何具体行动句或生活建议;依据一律用占位写法。
|
||||
- **D5(架构,Claude 定)并列点不再强制列表。** 普通对话首轮默认写段落;只有用户要求列举、或确有三个以上候选时间窗时才用列表,且不用「加粗标签 + 一句」的格式。今日运势(daily)形状本单不动。
|
||||
- **D6(架构,Claude 定)括号依据收敛**:每段最多一处括号依据;10-01 的「人话自检」(删掉括号仍能读懂)、禁格局名、禁拟人大运、禁星名当形容词、空宫规则、名词规则、受冲规则(`AFFLICTION_RANGE_RULE`)、事业不猜行业(`CAREER_FIELD_ASK_RULE`)、「请用户说实际情况全篇最多一处」**全部保留**,只是不再依附标题。
|
||||
- **不变**:工具每轮照调;技法审计表 / 统一参数仍在证据面板,不进正文;纯寒暄快路径;追问轮形状;Part B、MEVG、置信度边界;今日运势与「深入看今日」的骨架;校正 Agent 口气。
|
||||
|
||||
## 4. 硬红线
|
||||
|
||||
1. 不改证据卡、工具调用、模型选择、输出 token 上限、答题时钟、计费。
|
||||
2. 不改 `frontend/src/app/(app)/page.tsx`(冻结文件);不改数据库与迁移。
|
||||
3. 形状仍只定义一处:`consultation-thinking-plan.ts` 里的一组常量 / 函数,其余提示词引用它,不再各抄一份。
|
||||
4. 不得用正则判断用户是否在问「怎么办」(沿用 BUG-976/977 口径),这交给模型阅读。
|
||||
5. 改既有断言必须写「原值 / 新值 / 原因」三栏;测试总数不得低于基线(删掉的标题断言要换成新形状的断言,不是直接删)。
|
||||
6. 历史消息里已有的 `##` 标题照常渲染;`shortenAnswerHeading` 等兼容旧消息的代码保留。
|
||||
|
||||
## 5. 任务分解
|
||||
|
||||
### T1 新形状(BUG-1244)
|
||||
|
||||
改 `consultation-thinking-plan.ts` 的单一定义,并让 `product-voice.ts`、`skill-binding.ts`、`index.ts`、`route.ts` 引用它:
|
||||
|
||||
- 删除 `REPORT_HEADING` 在提示词里的用法;`NATAL_ANSWER_SHAPE_SUMMARY` / `natalAnswerShapeBody()` 改写为 D1–D3、D5、D6 的意思:无标题、多对象分段落段首点名、行动按 D2、首轮三到六段、段落优先、每段最多一处括号。
|
||||
- `product-voice.ts`:PERSONA 末句改成 D2 的说法;`ANSWER SHAPE` 第 2–4 条按 D1/D2 重写;`natalSpokenReportContract` 的 1–5 条改成无标题版本,删「骨架不可省略」。
|
||||
- `skill-binding.ts` `NATAL_REPORT_SKELETON`:Level 2 模板只作为依据来源;六步宫位 / Yoga 表不再「放在 ## 盘上依据 下」,正文只在括号里引用需要的那一两条。
|
||||
- `route.ts` `legacyNatalInstruction`:删「骨架不可省略」。
|
||||
- `followUpAnswerShapeInstruction()`:换领域或要求完整看时「回到首轮形状」,不再列二级标题。
|
||||
- `consultationSpokenHeadingRule("natal")`:删强制列表那句,换成 D5;`"window"`:删 `## 先回答你的问题` 要求(`WINDOW_OPEN_HEADING` 常量若只剩思考栏在用可保留作思考栏标题)。
|
||||
- 新增一条明确禁令:首轮正文不写 `##`、不写「这周可以做」「盘上依据」「时间怎么看」这类节名,也不用加粗短语冒充小标题。
|
||||
|
||||
**验收**:`git grep -n "这周可以做的一件事\|骨架不可省略" frontend/src` 只允许出现在兼容旧消息的代码与注释里(逐条列进进度记录);`git grep -n "REPORT_HEADING" frontend/src` 不出现在任何提示词字符串拼接中;新增合同测试断言首轮指令不含 `##` 节名要求、含「多对象分段落」「行动不强制」「三到六段」三层意思。
|
||||
|
||||
### T2 示范改写(BUG-1245)
|
||||
|
||||
- `product-voice.ts` 里所有 Good 示范:去掉 `##`,改成段落;删除全部具体行动句(含「口头答应的事,补一份书面确认」「身体的小毛病这周就去看」「给妈妈打电话时先问她最近怎样」);父母示范改为分段落、段首点名。
|
||||
- 新增一条 Bad 示范:带三个固定标题 + 三条行动列表的汇报体(只写结构示意,括注为什么不行)。
|
||||
- 新增合同测试:提示词全文不含上面三句旧行动示范,也不含「## 这周」。
|
||||
|
||||
**验收**:上述测试通过;示范里没有任何一句可以原样出现在真实回答里的生活建议。
|
||||
|
||||
### T3 思考栏去掉对标题的依赖
|
||||
|
||||
- `natalConsultationThinkingPlan()`:删 `action` 节;`support` / `timing` 节去掉对正文 H2 的依赖(`heading` 字段若为 schema 必填,就让首轮进度按现有「无标题 → 结束时全部完成」路径走)。
|
||||
- `applyThinkingSectionProgress()`:正文没有 H2 时,不得出现某一节一直卡在 active、结算后仍未完成;写一条回归测试覆盖「整篇无 H2 的首轮」流式中与结算后两个状态。
|
||||
- 写作行:首轮无标题时走 `write-open`「组织回答」那一条(`consultation-run-timeline.ts` 已有该分支,补测试即可)。
|
||||
|
||||
**验收**:`consultation-thinking-plan.test.ts`、`consultation-run-timeline.test.ts` 改动三栏说明齐全;思考栏不再出现「这周可以做什么」。
|
||||
|
||||
### T4 文档与记录
|
||||
|
||||
- `frontend/docs/VOICE.md`:第七条原则、「首轮只说一遍」一节、对照表那一行改成新形状,写明 10-06 推翻 10-01 D2 / D8 首轮部分及原因。
|
||||
- `frontend/DESIGN.md` 写作行那段(现写「首轮依次是……这周可以做的一件事」)改成首轮只有「组织回答」。
|
||||
- `docs/BUG_HISTORY.md`:BUG-1244(首轮汇报骨架,关联 BUG-1073 / 1132)、BUG-1245(示范行动被照抄);状态在部署 + 真机前写 `fixed-pending-verify` 一类,不写 `resolved`。
|
||||
- `CHANGELOG.md`:一句话标题 + 要点;Skill 版本不 bump(未改 skill)。
|
||||
- `docs/testing/consult-conversational-answer-20261006.md`:真机清单(见 T5)。
|
||||
- `docs/tasks/README.md` 状态板这一行改状态。
|
||||
|
||||
### T5 效果验证
|
||||
|
||||
- **长度核对**:说明新形状下首轮典型长度的估计(有模型凭据就实测),记进进度记录。
|
||||
- **离线**(有模型凭据才做,没有写成环境缺口):用公开名人 golden 卡或虚构资料,新旧提示词各跑:①「我和父母关系如何」②「我的事业接下来怎么走」③「我今年财运怎么样」④「我和伴侣还能走下去吗」。逐条检查:无 `##`、无行动清单、行动最多一句、段落数、有无照抄示范。原文存进度记录。
|
||||
- **真机清单**(给产品,staging,deepseek-v4-flash 与另一个模型各走一遍):
|
||||
1. 问「我今年财运怎么样」:没有任何小标题,没有「这周可以做的一件事」,读起来是几段话;如果提了建议,只有一句、嵌在话里。
|
||||
2. 问「我和父母关系如何」:妈妈、爸爸分开两段说,段首点名,没有标题。
|
||||
3. 同一个问题换个账号或换个人再问:建议(如有)不再是「书面确认 / 两栏清单」这类同一模子的句子。
|
||||
4. 追问「那我该怎么做」:这一轮可以给几条具体建议,第一句就答。
|
||||
5. 追问「那我爸呢」:只讲爸爸,第一句就答,没有标题。
|
||||
6. 打「你好」:仍是一句寒暄。
|
||||
7. 未填出生分钟的档案问事业:不点个人大运、不写月份,也没有标题。
|
||||
8. 思考栏:首轮不出现「这周可以做什么」,结束后没有卡住的转圈项。
|
||||
|
||||
## 6. 让步顺序
|
||||
|
||||
T1 + T2 必须同一次交付(只改一边会出现新旧两套形状打架)> T3 > T4 > T5 离线部分。
|
||||
|
||||
## 7. 开工前置命令
|
||||
|
||||
```bash
|
||||
cd /workspace/Jyotisha
|
||||
git status -sb
|
||||
git fetch origin --prune
|
||||
git worktree add -b codex/consult-conversational-answer-20261006 .worktrees/consult-conversational-answer-20261006 origin/staging
|
||||
cd .worktrees/consult-conversational-answer-20261006/frontend && npm ci
|
||||
./node_modules/.bin/tsc --noEmit && npm run lint && npm test 2>&1 | tail -20 # 记下测试总数与失败名单作基线(Node 22)
|
||||
grep -o "BUG-1[0-9]\{3\}" ../docs/BUG_HISTORY.md | sort -n -t- -k2 | tail -1
|
||||
```
|
||||
|
||||
交付门:`tsc --noEmit` 0 错;`npm run lint` 0 error;`npm test` 失败名单与基线逐条一致、总数不少于基线;`next build` 后 `/` 仍 Static、首屏 gzip ±2%。推 `codex/consult-conversational-answer-20261006` 后回报,由 Claude 验收后再推 staging。
|
||||
@@ -0,0 +1,110 @@
|
||||
# TASK · 校正交付「一次两段回答」与「点采用被拒 adoption_not_allowed」(2026-10-06)
|
||||
|
||||
- 基线:`origin/staging` @ `e3f3a1fc`
|
||||
- 执行分支:`codex/rectification-delivery-dup-adopt-20261006`,worktree `.worktrees/rectification-delivery-dup-adopt-20261006`
|
||||
- BUG 编号起点:**BUG-1241**(本单用 1241~1243)。开工时核对 `docs/BUG_HISTORY.md` 最大号,并扫全部 `docs/tasks/TASK-*.md` 正文里的预留号;撞号顺延,在 PROGRESS 写明。
|
||||
- 模式:任务书模式;执行方实现,Claude 事后验收。
|
||||
|
||||
## 1. 事故实证(产品 10-06 11:45 staging 真机,iPhone)
|
||||
|
||||
盘型口径(segment-v1)校正,窗口 04:45–05:15(31 分钟),答完 4 道选择题后:
|
||||
|
||||
1. **一次出了两条助手消息**,内容是同一件事:
|
||||
- 第 1 条:「已记录,范围没变。因为再问下去也分不开 04:53 和 05:00,所以这一轮到这里停住。…采用后没有还能核对的前事,之后新建对话即按此时间排盘,对不上可改选。我按你说的经历认真分析过了,下面是这次的结果。」(采用旁白 Agent 改写版 + `adoptCue`)
|
||||
- 第 2 条:「D1 上升:巨蟹(确定);D9 上升:天蝎(分不开),备选:射手、天秤;D10 上升:狮子(分不开),备选:巨蟹、处女。分钟范围 04:45–05:15。用了 4 道选择题。这个窗口按现在的方法缩不下去。」(`deliveryTurnNarration` 确定性原文,逐字一致)
|
||||
2. 盘型卡显示「采用后按 1997-08-08 05:03 排盘」和按钮 **「改用 D1 + D9 + D10 盘解读」**;点击后卡下红框显示英文 **`adoption_not_allowed`**。
|
||||
|
||||
## 2. 根因
|
||||
|
||||
### 2.1 两段回答(BUG-1241,BUG-1153 复发)
|
||||
|
||||
- 答题回复走 `persistNextInterviewAfterChoice`(`answer-choice.ts`),交付轮用 `input.narrateAdopt(facts, fallback)` 把确定性交付话交给采用旁白 Agent 改写,第 1 条就是改写后的文本。
|
||||
- 之后某条收尾路径又调 `persistNextInterviewIfIdle`,但**没有传 `narrateAdopt`**,于是拿到确定性 `fallback`(`adoptHostNarration` 在盘型口径下只返回 `deliveryAdoptNarration`,正是第 2 条),再交给 `persistExhaustionGateTurn` 落库。可能的调用方有三处,执行方须用回放确认是哪一处:`turn-exit.ts` 的 `finalizeSuccessfulTurnExit`、`agent-run-finish.ts`、`answer-choice.ts` 的出口修复(日志 `missing_question_and_adopt_carrier`)。
|
||||
- `persistExhaustionGateTurn` 的防重(BUG-1153)是 `narrationAlreadyInLastAssistant`:**按去空白后的子串比对**上一条助手消息。确定性原文被模型改写后不再是子串,防重失效,第 2 条落库。
|
||||
- 为什么 BUG-1153 的测试没拦住:当时上一条是确定性原文;后来交付轮接入了采用旁白 Agent 改写,测试没有覆盖「上一条是改写版」。
|
||||
|
||||
### 2.2 点采用被拒(BUG-1242,BUG-598 / BUG-680 同族)
|
||||
|
||||
- 出卡时:答题路径 `decideFromDossier(dossier, { birthDate, snapshotCurrent })` 带了出生日期。`inspectDiscriminatorProbes` / `discriminatorProbeIfFollowupCanAsk` / `nakshatraProbeIfFollowupCanAsk` 用 `probeBelowAdultFloor`(`adult-floor.ts`)筛掉未成年年份的探针,探针池为空后命中 `tied_first` 交付,公开 `can_adopt=true`,出了卡。
|
||||
- 点采用时:`candidates/accept/route.ts` 调 `decideFromDossier(dossier, { currentEvidenceFingerprint })`,**没有 `birthDate`**。`probeBelowAdultFloor` 在没有出生日期时一律返回 false,未成年探针重新算成「还能问的题」,于是 `tied_first` 分支的 `!probe` 不成立,判成不可采用,返回 409 `adoption_not_allowed`。
|
||||
- GET 也没有 `birthDate`:`case-dossier-response.ts` 的 `decideFromDossier`,以及 `interview-state.ts` 里三处。推测用户刷新后卡片会消失或回到采集状态,也就是 BUG-680「POST 交付、GET 回采集」的症状。
|
||||
- BUG-598 只把出生日期接到了答题与 inspect 回退两条路径,GET 和 accept 两条漏了。BUG-680 防复发条款要求的「POST/GET 对拍测试」没有覆盖 `birthDate` 这个入参。
|
||||
- **嫌疑须先证实(T0)**:`snapshotCurrent` 也只在答题路径传入,同样会让两边结论不同。执行方须用回放确认真正起作用的是哪一个入参(或两个都起作用),再动手修。
|
||||
|
||||
### 2.3 卡片按钮不看公开 can_adopt,拒绝码直出英文(BUG-1243)
|
||||
|
||||
- `rectification-segment-delivery.tsx` 只要 `summary.adoption_minute` 和 `representative_candidate_id` 都在,就渲染采用按钮,不看公开 `can_adopt` / `session_outcome`。旧分钟卡 `rectification-range-delivery.tsx` 的采用按钮同样不看。所以出现了「卡上能点、服务器必拒」。
|
||||
- accept 路由 409 返回 `{ error: "adoption_not_allowed" }`,没有中文。`rectification-chat-accept-run.ts` 把 `payload.error` 原样抛出,显示出来就是英文代号。
|
||||
|
||||
## 3. 决策记录
|
||||
|
||||
产品 10-06 同意按以下方案出任务书。本单**没有**推翻既有产品决策。
|
||||
|
||||
1. **不放宽采用门**。修法是让 GET 和 accept 与答题路径用同一组判定入参(以出生日期成年下限为准,它是 BUG-399 / 417 / 598 确立的产品规则),不得删掉 `can_adopt` 校验、不得让 accept 跳过 `decideFromDossier`。
|
||||
2. **防重改为结构判断,不再比文字**:本轮答题回复已经承载交付(交付轮已落库,或 `markDeliveryTurn` / `alreadyDelivered` 已记),收尾补位就不再写。可以保留文本比对作第二道保险,但不得只靠它。不得删掉 BUG-1149 让收尾轮次能落库的修复(没有回复承载时,收尾句仍须落库)。
|
||||
3. **按钮只在公开 `can_adopt=true` 时出现**,判据与 accept 路由一致:`ADOPT_OUTCOMES`,盘型卡与旧分钟卡都改。`DELIVERY_OUTCOMES` 下不能采用时照常出卡(BUG-681),但不出采用按钮。
|
||||
4. 拒绝提示用中文,口径对照 `frontend/docs/VOICE.md`,建议为「这次的结果还不能采用,已刷新到最新状态」。被拒后客户端重拉一次快照,让卡片和题回到服务端真实状态,不留一个点不动的按钮。
|
||||
|
||||
## 4. 硬红线
|
||||
|
||||
1. 不改引擎、不改 `decision_policy`、不改 `ADOPT_OUTCOMES` / `DELIVERY_OUTCOMES` 集合成员、不改数据库结构与迁移。
|
||||
2. 不得让公开 `can_adopt` 在 `collect_evidence` / `discriminate_candidates` / `validate_holdout` 放行(BUG-497 防复发)。
|
||||
3. `scripts/jyotish_api_server.py`、`frontend/src/app/page.tsx` 不得增长。
|
||||
4. 改任何既有断言须写「原值 / 新值 / 原因」三栏;测试总数不低于基线。
|
||||
5. 不得改采用旁白 Agent 的提示词来「对齐」确定性原文(那是绕开问题,不是修)。
|
||||
6. 隐私:回放 fixture 用虚构或公开名人资料,不得写入产品本次会话的出生资料。
|
||||
|
||||
## 5. 任务分解
|
||||
|
||||
### T0 复现(先做,结论写进 PROGRESS 再动 T1~T3)
|
||||
|
||||
- 用本机 PG17 真库 harness(参照 `frontend/tests/research/rectification-segment-persisted-replay.ts`;无 Docker 的做法见 BLOCKED / 既有 PROGRESS),构造一个盘型口径案例。条件:出生年份使至少一道剩余区分探针落在成年下限以下;窗口约 ±15;最后两名后验并列(`tied_first`)。
|
||||
- 依次走「答完最后一题 → 列出所有助手消息 → 调 accept → GET 案例」,记录:助手消息条数与文本来源、答题时 / accept 时 / GET 时的 `session_outcome` 与 `can_adopt`、哪条路径写了第 2 条消息。
|
||||
- 分别只补 `birthDate`、只补 `snapshotCurrent`,看哪一个让三处结论一致。
|
||||
- **验收**:PROGRESS 有修前复现记录:两条消息,accept 409 `adoption_not_allowed`,GET 的 outcome 与答题时不同(若不同)。
|
||||
|
||||
### T1 判定入参统一(BUG-1242)
|
||||
|
||||
- 抽一个共享的「取案例判定入参」函数,从出生快照读 `birthDate`。accept 路由、`case-dossier-response.ts`、`interview-state.ts` 的三处、`agent-run-attempt.ts` 等所有 `decideFromDossier` 调用方都用它,不再各自拼参。T0 证实 `snapshotCurrent` 也起作用的话,一并处理。
|
||||
- 读出生快照失败时:accept 返回 503(暂时无法采用),不得退化成无 `birthDate` 判定后给出相反结论。
|
||||
- **验收**:新增对拍测试:同一份 dossier 的答题路径、GET、accept 三处的 `session_outcome` / `can_adopt` 逐字段相等,用表驱动覆盖「有 / 无未成年探针」×「并列 / 不并列」;T0 场景修后 accept 成功、GET 仍是交付态。再加一条源码合同测试:新增的 `decideFromDossier` 调用必须经过共享入参函数,防止漏传。
|
||||
|
||||
### T2 收尾补位不重复(BUG-1241)
|
||||
|
||||
- `persistExhaustionGateTurn` 写入前先看结构状态:最近一条助手消息已经是本结果的交付轮(交付标记 / `result_id` / 请求号可证),就跳过。改写版与原文都要能识别。确认 T0 找到的那条调用方是否该传 `narrateAdopt`:要么所有落库路径共用同一段文本,要么根本不该再写。二者选一,在 PROGRESS 说明。
|
||||
- **验收**:T0 场景修后助手消息恰好 1 条(实时、GET 快照、刷新三路径一致)。保留 BUG-1149 / 1153 既有测试且通过。新增「上一条是 Agent 改写版」用例。没有回复承载时,收尾句仍落库 1 条。
|
||||
|
||||
### T3 卡片与提示(BUG-1243)
|
||||
|
||||
- 盘型卡、旧分钟卡的采用按钮加公开 `can_adopt` 判据,`RectificationCandidateResult` 上已有的字段够用就不新增字段。不能采用时卡片照出,但不出按钮,也不出「采用后按…排盘」这一行。
|
||||
- accept 路由 `adoption_not_allowed` 返回中文 `error` + `code: "adoption_not_allowed"`。客户端遇到 409 时显示中文、重拉快照。
|
||||
- 同一提交更新 `frontend/DESIGN.md` 校正状态表(不能采用时卡片的样子)。
|
||||
- **验收**:组件测试覆盖 outcome × can_adopt 表(按钮出现当且仅当 `ADOPT_OUTCOMES` 且 can_adopt);DOM 不出现 `adoption_not_allowed` 字样。
|
||||
|
||||
### T4 记录
|
||||
|
||||
- `docs/BUG_HISTORY.md` 新增 BUG-1241(复发自 BUG-1153)、BUG-1242(复发自 BUG-598,关联 BUG-680)、BUG-1243(关联 BUG-681 / 497);写明旧测试为何没拦住。
|
||||
- `CHANGELOG.md` 一条;`docs/testing/rectification-delivery-dup-adopt-20261006.md` 真机清单(走到并列交付 → 只有一条交付消息 → 点采用成功 → 刷新卡片仍在且显示已采用)。
|
||||
- `docs/tasks/README.md` 状态板改行。
|
||||
|
||||
## 6. 验收口径
|
||||
|
||||
- `tsc --noEmit` 0;`npm run lint` 0 error;`npm test` 失败清单与基线逐名一致,总数不降;`next build` 后 `/` Static,首屏 gzip ±2%。
|
||||
- 本机 PG17 真库走 T0 场景修后全通(假数据库测试不算,见 BUG-1149/1150 教训)。
|
||||
- 推 staging 前按镜像 COPY 清单构建一次(ERR-113);推前等上一轮部署结束。
|
||||
- 部署后 `/api/health` 的 `deployment.gitCommit` = 本单 staging SHA。
|
||||
|
||||
## 7. 让步顺序
|
||||
|
||||
时间不够时按此顺序保:T0 → T1(按钮能点就必须能采用,这是 P0)→ T3 中文提示与按钮判据 → T2 去重 → T4。T2 若来不及,须在 PROGRESS 写 blocked,不得只靠调文本比对糊过去。
|
||||
|
||||
## 8. 开工前置命令
|
||||
|
||||
```bash
|
||||
git -C /workspace/Jyotisha status -sb | head -1
|
||||
git -C /workspace/Jyotisha fetch origin --prune
|
||||
git -C /workspace/Jyotisha worktree add -b codex/rectification-delivery-dup-adopt-20261006 \
|
||||
.worktrees/rectification-delivery-dup-adopt-20261006 origin/staging
|
||||
grep -o "^## BUG-[0-9]*" docs/BUG_HISTORY.md | sort -t- -k2 -n | tail -1
|
||||
grep -rho "BUG-12[4-9][0-9]" docs/tasks | sort -u
|
||||
```
|
||||
@@ -0,0 +1,14 @@
|
||||
# 真机清单 · 普通对话首轮改成聊天(2026-10-06)
|
||||
|
||||
对应任务书 `docs/tasks/TASK-consult-conversational-answer-20261006.md`(BUG-1244、BUG-1245)。在 staging 上,用自己的账号走。`deepseek-v4-flash` 走一遍,再换一个模型走一遍。每一步只看结构和有没有照抄,不把回答原文记进仓库。
|
||||
|
||||
| 步 | 怎么做 | 通过标准 |
|
||||
| --- | --- | --- |
|
||||
| 1 | 新开对话,问「我今年财运怎么样」 | 没有任何小标题,没有「这周可以做的一件事」,读起来是几段话。如果提了建议,只有一句,嵌在话里 |
|
||||
| 2 | 新开对话,问「我和父母关系如何」 | 妈妈、爸爸分开两段说,段首点名,没有标题 |
|
||||
| 3 | 同一个问题换个账号或换个人再问 | 建议(如有)不再是「书面确认 / 两栏清单」这类同一模子的句子 |
|
||||
| 4 | 在第 1 步或第 2 步的对话里追问「那我该怎么做」 | 这一轮可以给几条具体建议,第一句就答 |
|
||||
| 5 | 在父母那次对话里追问「那我爸呢」 | 只讲爸爸,第一句就答,没有标题 |
|
||||
| 6 | 新开对话,打「你好」 | 仍是一句寒暄 |
|
||||
| 7 | 用没填出生分钟的人物档案问事业 | 不点个人大运,不写月份,也没有标题 |
|
||||
| 8 | 盯第 1 步回答上方的进度栏,直到写完 | 首轮不出现「这周可以做什么」。结束后没有卡住的转圈项 |
|
||||
+8
-3
@@ -1,5 +1,10 @@
|
||||
# Jyotisha Web Design System
|
||||
|
||||
## 用量页分段计时(2026-10-05,在途)
|
||||
|
||||
`/admin/usage` 在原表格上多一列「分段」。格子里四行:分类耗时、第 0 步、第 1 步、第 1 步开始到第一个正文字符。每步带推理、输出、输入、缓存输入 token。供应商没返回的数显示「—」。不新开页面,不改表,数字来自 `usage_ledger.metadata` 里已有的 JSON。没有新的等待动画或输入框。
|
||||
|
||||
|
||||
This file adapts the full visual analysis in `CLAUDE_DESIGN.md` to the shipped Jyotisha application. `CLAUDE_DESIGN.md` remains the upstream reference; this file is the implementation contract.
|
||||
|
||||
## 校正按盘型交付(2026-09-30,在途,BUG-1115~1117)
|
||||
@@ -18,11 +23,11 @@ This file adapts the full visual analysis in `CLAUDE_DESIGN.md` to the shipped J
|
||||
|
||||
咨询转校正只传已持久来源session引用;服务端核owner、consultation类型与主题allowlist决定婚恋D1+D9、事业D1+D10、其它D1+D9+D10。浏览器不传domain、不以每题focus或用户文本猜领域;新案一次固定,resume不漂移。旧nullable domain即使重计分也不生成新段卡,原native raw/signature审计仍保留;exact-session身份链不升级旧案。来源失败复用原错误区域,无新等待或入口。当前DB opening权限阻塞,浏览器生命周期未验,不称上线。
|
||||
|
||||
## 普通咨询:步骤栏第一节与正文三标题(2026-09-27,TASK-consult-answer-the-question,BUG-1071~1073)
|
||||
## 普通咨询:步骤栏与写作行(2026-09-27,BUG-1071~1073;2026-10-06 首轮无标题,BUG-1244)
|
||||
|
||||
步骤栏(思考计划)第一节标题从「先抓住你真正在问的事」改成「先回答你问的这件事」;首轮它的唯一步骤是「用盘上的反差回答这个问题」,追问轮是「直接回答这句话」。这一节没有自己的正文标题(口语开场没有 H2),它和「盘里支持这个判断的地方」共用一个 heading:第一个正文 H2 出现时两节一起收口;追问轮整篇没有 H2,四节都在结算时收口。无新组件、无新动效。
|
||||
步骤栏(思考计划)第一节标题仍是「先回答你问的这件事」;首轮它的唯一步骤是「用盘上的反差回答这个问题」,追问轮是「直接回答这句话」。2026-10-06 起首轮正文没有二级标题,思考计划里不再有「这周可以做什么」。正文没有二级标题时,各节在流式中保持未开始,结算后一起收口,不留一节一直转圈。历史回答里已经写出的标题仍对上「盘里支持这个判断的地方」「时间怎么看」。无新组件、无新动效。
|
||||
|
||||
写作行(`write-<标题>`)跟着正文标题走:首轮依次是「盘里支持这个判断的地方」「时间怎么看」「这周可以做的一件事」,不再出现「正在写先回答你的问题」;追问轮没有标题,写作行是「组织回答」那一条。`shortenAnswerHeading` 里「先回答你的问题 → 先回答」的缩写保留,给历史消息用。
|
||||
写作行:首轮没有标题时只有「组织回答」那一条。进行中写「正在组织回答…」,写完是「组织回答」。历史消息里已经写出的标题仍按标题拆写作行。`shortenAnswerHeading` 里「先回答你的问题 → 先回答」的缩写保留,给历史消息用。
|
||||
|
||||
## 校正回答没有「重新生成」;范围句结算时出现一次(2026-09-27,BUG-1055~1058)
|
||||
|
||||
|
||||
+11
-11
File diff suppressed because one or more lines are too long
@@ -2,6 +2,68 @@ import { NextResponse } from "next/server";
|
||||
import { requirePermission } from "@/lib/admin/auth";
|
||||
import { pageOffset, queryAdminRows } from "@/lib/admin/database";
|
||||
import { adminErrorResponse, invalidQueryResponse, parseListQuery } from "@/lib/admin/http";
|
||||
export const runtime="nodejs";
|
||||
type Row={id:string;user_id:string;email:string|null;request_id:string;feature_key:string;source:string;requested_model_id:string|null;actual_model_id:string|null;model_config_version:number|null;input_tokens:number;output_tokens:number;cost_microusd:string;duration_ms:number|null;created_at:Date;cache_read_tokens:string|null;total_count:string};
|
||||
export async function GET(request:Request){try{await requirePermission("billing.orders.read");const p=parseListQuery(request);if(!p.success)return invalidQueryResponse(p.error.flatten());const q=p.data.q?`%${p.data.q}%`:null;const rows=await queryAdminRows<Row>(`select l.id,l.user_id,u.email,l.request_id,l.feature_key,l.source,l.requested_model_id,l.actual_model_id,l.model_config_version,l.input_tokens,l.output_tokens,l.cost_microusd::text,l.duration_ms,l.created_at,l.metadata->'cache'->>'readTokens' as cache_read_tokens,count(*) over()::text total_count from public.usage_ledger l left join identity.users u on u.id=l.user_id where ($1::text is null or u.email ilike $1 or l.request_id ilike $1 or l.actual_model_id ilike $1) and ($2::text is null or l.source=$2 or l.feature_key=$2) order by l.created_at desc limit $3 offset $4`,[q,p.data.status??null,p.data.pageSize,pageOffset(p.data.page,p.data.pageSize)]);return NextResponse.json({data:rows.map(r=>({id:r.id,userId:r.user_id,email:r.email,requestId:r.request_id,featureKey:r.feature_key,source:r.source,requestedModelId:r.requested_model_id,actualModelId:r.actual_model_id,modelConfigVersion:r.model_config_version,inputTokens:r.input_tokens,outputTokens:r.output_tokens,costMicrousd:Number(r.cost_microusd),durationMs:r.duration_ms,cacheReadTokens:r.cache_read_tokens==null?null:Number(r.cache_read_tokens),createdAt:r.created_at.toISOString()})),total:Number(rows[0]?.total_count??0)});}catch(e){return adminErrorResponse(e)}}
|
||||
import { usageTimingFromLedger } from "@/lib/consultation-step-timing";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
email: string | null;
|
||||
request_id: string;
|
||||
feature_key: string;
|
||||
source: string;
|
||||
requested_model_id: string | null;
|
||||
actual_model_id: string | null;
|
||||
model_config_version: number | null;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
cost_microusd: string;
|
||||
duration_ms: number | null;
|
||||
created_at: Date;
|
||||
cache_read_tokens: string | null;
|
||||
model_steps: unknown;
|
||||
classification_duration_ms: string | null;
|
||||
answer_reasoning_ms: string | null;
|
||||
total_count: string;
|
||||
};
|
||||
|
||||
const LIST_SQL = `select l.id,l.user_id,u.email,l.request_id,l.feature_key,l.source,l.requested_model_id,l.actual_model_id,l.model_config_version,l.input_tokens,l.output_tokens,l.cost_microusd::text,l.duration_ms,l.created_at,l.metadata->'cache'->>'readTokens' as cache_read_tokens,l.metadata->'modelSteps' as model_steps,l.metadata->'classification'->>'durationMs' as classification_duration_ms,l.metadata->'answer'->>'reasoning_ms' as answer_reasoning_ms,count(*) over()::text total_count from public.usage_ledger l left join identity.users u on u.id=l.user_id where ($1::text is null or u.email ilike $1 or l.request_id ilike $1 or l.actual_model_id ilike $1) and ($2::text is null or l.source=$2 or l.feature_key=$2) order by l.created_at desc limit $3 offset $4`;
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
await requirePermission("billing.orders.read");
|
||||
const parsed = parseListQuery(request);
|
||||
if (!parsed.success) return invalidQueryResponse(parsed.error.flatten());
|
||||
const q = parsed.data.q ? `%${parsed.data.q}%` : null;
|
||||
const rows = await queryAdminRows<Row>(LIST_SQL, [
|
||||
q,
|
||||
parsed.data.status ?? null,
|
||||
parsed.data.pageSize,
|
||||
pageOffset(parsed.data.page, parsed.data.pageSize),
|
||||
]);
|
||||
return NextResponse.json({
|
||||
data: rows.map((row) => ({
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
email: row.email,
|
||||
requestId: row.request_id,
|
||||
featureKey: row.feature_key,
|
||||
source: row.source,
|
||||
requestedModelId: row.requested_model_id,
|
||||
actualModelId: row.actual_model_id,
|
||||
modelConfigVersion: row.model_config_version,
|
||||
inputTokens: row.input_tokens,
|
||||
outputTokens: row.output_tokens,
|
||||
costMicrousd: Number(row.cost_microusd),
|
||||
durationMs: row.duration_ms,
|
||||
cacheReadTokens: row.cache_read_tokens == null ? null : Number(row.cache_read_tokens),
|
||||
timing: usageTimingFromLedger(row.model_steps, row.classification_duration_ms, row.answer_reasoning_ms),
|
||||
createdAt: row.created_at.toISOString(),
|
||||
})),
|
||||
total: Number(rows[0]?.total_count ?? 0),
|
||||
});
|
||||
} catch (error) {
|
||||
return adminErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
import { streamTextResponse } from "@/lib/stream-text-response";
|
||||
import { classifyConsultationTurn, type SmalltalkUsage } from "@/lib/consultation-smalltalk";
|
||||
import { consultationUsageMetadata } from "@/lib/consultation-step-timing";
|
||||
import { streamSmalltalkResponse } from "@/lib/stream-smalltalk-response";
|
||||
import { streamFirstResponse } from "@/lib/stream-first-response";
|
||||
import { consultationPublicActivityEvent, streamAgentResponse } from "@/lib/stream-agent-response";
|
||||
@@ -662,6 +663,8 @@ export async function POST(request: Request) {
|
||||
const usageStartedAt = Date.now();
|
||||
let classificationUsage: SmalltalkUsage = {};
|
||||
let classificationOutcome: string | undefined;
|
||||
let classificationDurationMs: number | null = null;
|
||||
let latencyState: ReturnType<typeof createConsultationRuntimeState> | null = null;
|
||||
const turn = parsed.data.entrypoint === undefined
|
||||
? await classifyConsultationTurn({
|
||||
model: selectedModel,
|
||||
@@ -673,6 +676,7 @@ export async function POST(request: Request) {
|
||||
if (!observation.late) {
|
||||
classificationUsage = observation.usage ?? {};
|
||||
classificationOutcome = observation.outcome;
|
||||
classificationDurationMs = observation.durationMs;
|
||||
}
|
||||
const inputTokens = observation.usage?.inputTokens ?? 0;
|
||||
const outputTokens = observation.usage?.outputTokens ?? 0;
|
||||
@@ -682,6 +686,7 @@ export async function POST(request: Request) {
|
||||
modelVersion: String(selectedModel.configVersion),
|
||||
policyVersion: "consultation-smalltalk-v1",
|
||||
toolCalls: [],
|
||||
classification: { durationMs: observation.durationMs },
|
||||
contractPhases: [{
|
||||
phase: observation.late ? "classification.late_usage" : `classification.${observation.outcome}`,
|
||||
durationMs: observation.durationMs,
|
||||
@@ -789,12 +794,17 @@ export async function POST(request: Request) {
|
||||
durationMs: Date.now() - usageStartedAt,
|
||||
metadata: {
|
||||
...(cache ? { cache: { ...cache, hit: cache.readTokens > 0 } } : {}),
|
||||
...(classificationOutcome ? { classification: {
|
||||
outcome: classificationOutcome,
|
||||
...(classificationOutcome || classificationDurationMs !== null ? { classification: {
|
||||
...(classificationOutcome ? { outcome: classificationOutcome } : {}),
|
||||
usageKnown: classificationUsage.inputTokens !== undefined || classificationUsage.outputTokens !== undefined,
|
||||
inputTokens: classificationUsage.inputTokens ?? null,
|
||||
outputTokens: classificationUsage.outputTokens ?? null,
|
||||
durationMs: classificationDurationMs,
|
||||
} } : {}),
|
||||
...(latencyState?.modelStepTimings === undefined ? {} : consultationUsageMetadata({
|
||||
modelSteps: latencyState.modelStepTimings,
|
||||
answerReasoningMs: latencyState.answerReasoningMs,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -939,6 +949,7 @@ export async function POST(request: Request) {
|
||||
// can produce, otherwise a run that exhausts its steps also truncates the
|
||||
// evidence of having done so.
|
||||
const state = createConsultationRuntimeState({ plannedSteps: AGENT_MAX_STEPS });
|
||||
latencyState = state;
|
||||
const hooks = createConsultationRuntimeHooks(state);
|
||||
const usages: Promise<Usage>[] = [];
|
||||
const agentStartedAt = Date.now();
|
||||
@@ -1012,6 +1023,7 @@ export async function POST(request: Request) {
|
||||
],
|
||||
retryCount: Math.max(0, usages.length - 1),
|
||||
...consultationModelStepTelemetry(state),
|
||||
...(classificationDurationMs === null ? {} : { classification: { durationMs: classificationDurationMs } }),
|
||||
...(errorCode === undefined ? {} : { errorCode }),
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
@@ -1590,7 +1602,7 @@ export async function POST(request: Request) {
|
||||
// / follow-up split as the agentic path (BUG-1071), one statement of it.
|
||||
const legacyNatalInstruction = hasPriorAssistantAnswer(history, { summaryText: historyWindow.summaryText })
|
||||
? followUpAnswerShapeInstruction()
|
||||
: `${natalAnswerShapeBody()}骨架不可省略;文末技法审计表由产品界面折叠展示,不写进正文。星盘事实只使用系统里已经注入的计算结果,不要复述内部字段、JSON 或再跑一遍咨询流程。`;
|
||||
: `${natalAnswerShapeBody()}文末技法审计表由产品界面折叠展示,不写进正文。星盘事实只使用系统里已经注入的计算结果,不要复述内部字段、JSON 或再跑一遍咨询流程。`;
|
||||
|
||||
const cacheBoundary = cachedSystemMessage("【上下文缓存边界】后续内容为本轮请求输入。", selectedModel.model);
|
||||
const legacyHistory = history.map((message) => message.role === "user"
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { useState } from "react";
|
||||
|
||||
import { adminRequestJson, type AdminIdentity } from "@/lib/admin/providers";
|
||||
import { formatUsageTiming, type UsageTimingView } from "@/lib/consultation-step-timing";
|
||||
import { formatAdminDate, ResourceTable } from "./resource-table";
|
||||
|
||||
const { Text } = Typography;
|
||||
@@ -416,6 +417,7 @@ type Usage = {
|
||||
costMicrousd: number;
|
||||
durationMs: number | null;
|
||||
cacheReadTokens: number | null;
|
||||
timing: UsageTimingView | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
@@ -468,6 +470,13 @@ export function UsageResource() {
|
||||
dataIndex: "durationMs",
|
||||
render: (value) => (value == null ? "—" : `${value} ms`),
|
||||
},
|
||||
{
|
||||
title: "分段",
|
||||
dataIndex: "timing",
|
||||
render: (value: UsageTimingView | null) => (
|
||||
<Text style={{ whiteSpace: "pre-line" }}>{formatUsageTiming(value)}</Text>
|
||||
),
|
||||
},
|
||||
{ title: "时间", dataIndex: "createdAt", render: formatAdminDate },
|
||||
];
|
||||
return (
|
||||
|
||||
@@ -99,6 +99,27 @@ export function settlementTelemetryOutcome(
|
||||
};
|
||||
}
|
||||
|
||||
const nullableDurationSchema = durationMsSchema.nullable();
|
||||
const nullableTokenSchema = tokenCountSchema.nullable();
|
||||
|
||||
/** Step 0 decides the chart tool. Step 1 writes the answer. Tokens stay null when the provider omits them. */
|
||||
export const consultationModelStepTimingSchema = z.object({
|
||||
index: z.union([z.literal(0), z.literal(1)]),
|
||||
durationMs: nullableDurationSchema,
|
||||
reasoningTokens: nullableTokenSchema,
|
||||
outputTokens: nullableTokenSchema,
|
||||
inputTokens: nullableTokenSchema,
|
||||
cachedInputTokens: nullableTokenSchema,
|
||||
}).strict().readonly();
|
||||
|
||||
export const consultationAnswerTimingSchema = z.object({
|
||||
reasoning_ms: nullableDurationSchema,
|
||||
}).strict().readonly();
|
||||
|
||||
export const consultationClassificationTimingSchema = z.object({
|
||||
durationMs: nullableDurationSchema,
|
||||
}).strict().readonly();
|
||||
|
||||
export const agentObservabilityToolCallSchema = z.object({
|
||||
name: machineCodeSchema,
|
||||
durationMs: durationMsSchema,
|
||||
@@ -153,6 +174,9 @@ export const agentObservabilityEventSchema = z.object({
|
||||
|
||||
toolCalls: z.array(agentObservabilityToolCallSchema).max(64).optional(),
|
||||
contractPhases: z.array(agentObservabilityContractPhaseSchema).max(64).optional(),
|
||||
classification: consultationClassificationTimingSchema.optional(),
|
||||
modelSteps: z.array(consultationModelStepTimingSchema).max(2).optional(),
|
||||
answer: consultationAnswerTimingSchema.optional(),
|
||||
retryCount: z.number().int().min(0).max(100).optional(),
|
||||
errorCode: machineCodeSchema.optional(),
|
||||
// Why the model stopped, and how many model steps the run consumed across
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* Per-step clocks for one ordinary consultation.
|
||||
*
|
||||
* Numbers and statuses only. Provider usage is copied when the provider sent
|
||||
* the field; a missing field stays null and is never estimated. Answer text,
|
||||
* prompts, and birth data are not fields here.
|
||||
*/
|
||||
|
||||
export type ConsultationModelStepTiming = {
|
||||
index: 0 | 1;
|
||||
durationMs: number | null;
|
||||
reasoningTokens: number | null;
|
||||
outputTokens: number | null;
|
||||
inputTokens: number | null;
|
||||
cachedInputTokens: number | null;
|
||||
};
|
||||
|
||||
export type ConsultationLatencyRecord = {
|
||||
steps: Array<ConsultationModelStepTiming | undefined>;
|
||||
openIndex: number | null;
|
||||
openStartedAt: number | null;
|
||||
nextIndex: number;
|
||||
step1StartedAt: number | null;
|
||||
answerReasoningMs: number | null;
|
||||
};
|
||||
|
||||
export type UsageTimingView = {
|
||||
classificationDurationMs: number | null;
|
||||
modelSteps: ConsultationModelStepTiming[];
|
||||
answerReasoningMs: number | null;
|
||||
};
|
||||
|
||||
type UsageRecord = Record<string, unknown>;
|
||||
|
||||
export function createConsultationLatencyRecord(): ConsultationLatencyRecord {
|
||||
return {
|
||||
steps: [],
|
||||
openIndex: null,
|
||||
openStartedAt: null,
|
||||
nextIndex: 0,
|
||||
step1StartedAt: null,
|
||||
answerReasoningMs: null,
|
||||
};
|
||||
}
|
||||
|
||||
function tokenOrNull(value: unknown): number | null {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return null;
|
||||
const truncated = Math.trunc(value);
|
||||
return truncated > 1_000_000_000 ? null : truncated;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): UsageRecord | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? value as UsageRecord
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Read the provider's cached-input count. Absent means null, including when only a sibling field exists. */
|
||||
export function cachedInputTokensFromUsage(usage: UsageRecord): number | null {
|
||||
if ("cachedInputTokens" in usage) return tokenOrNull(usage.cachedInputTokens);
|
||||
const details = asRecord(usage.inputTokenDetails);
|
||||
if (details && "cacheReadTokens" in details) return tokenOrNull(details.cacheReadTokens);
|
||||
const promptDetails = asRecord(usage.prompt_tokens_details);
|
||||
if (promptDetails && "cached_tokens" in promptDetails) return tokenOrNull(promptDetails.cached_tokens);
|
||||
return null;
|
||||
}
|
||||
|
||||
function usageFromChunk(chunk: { payload?: unknown }): UsageRecord {
|
||||
const payload = asRecord(chunk.payload);
|
||||
const output = asRecord(payload?.output);
|
||||
return asRecord(output?.usage) ?? asRecord(payload?.usage) ?? {};
|
||||
}
|
||||
|
||||
function emptyStep(index: 0 | 1): ConsultationModelStepTiming {
|
||||
return {
|
||||
index,
|
||||
durationMs: null,
|
||||
reasoningTokens: null,
|
||||
outputTokens: null,
|
||||
inputTokens: null,
|
||||
cachedInputTokens: null,
|
||||
};
|
||||
}
|
||||
|
||||
function closeOpen(record: ConsultationLatencyRecord, now: number, usage: UsageRecord): void {
|
||||
const index = record.openIndex;
|
||||
const startedAt = record.openStartedAt;
|
||||
record.openIndex = null;
|
||||
record.openStartedAt = null;
|
||||
if (index === null) return;
|
||||
record.nextIndex = index + 1;
|
||||
if (index !== 0 && index !== 1) return;
|
||||
const durationMs = startedAt === null ? null : Math.max(0, Math.trunc(now - startedAt));
|
||||
record.steps[index] = {
|
||||
index,
|
||||
durationMs: durationMs !== null && durationMs > 7 * 24 * 60 * 60 * 1000 ? null : durationMs,
|
||||
reasoningTokens: tokenOrNull(usage.reasoningTokens),
|
||||
outputTokens: tokenOrNull(usage.outputTokens),
|
||||
inputTokens: tokenOrNull(usage.inputTokens),
|
||||
cachedInputTokens: cachedInputTokensFromUsage(usage),
|
||||
};
|
||||
}
|
||||
|
||||
/** Fold one model-loop chunk into step 0 / step 1. Text payloads are ignored. */
|
||||
export function observeConsultationModelChunk(
|
||||
record: ConsultationLatencyRecord,
|
||||
chunk: { type?: string; payload?: unknown },
|
||||
now: number,
|
||||
): void {
|
||||
if (chunk.type === "step-start") {
|
||||
if (record.openIndex !== null) closeOpen(record, now, {});
|
||||
const index = record.nextIndex;
|
||||
record.openIndex = index;
|
||||
record.openStartedAt = now;
|
||||
if (index === 1) record.step1StartedAt = now;
|
||||
return;
|
||||
}
|
||||
if (chunk.type === "step-finish") {
|
||||
if (record.openIndex === null) {
|
||||
record.openIndex = record.nextIndex;
|
||||
record.openStartedAt = null;
|
||||
}
|
||||
closeOpen(record, now, usageFromChunk(chunk));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* First answer-body character of step 1. Step 0 narration does not count,
|
||||
* and a later continuation does not move the clock backward or forward.
|
||||
*/
|
||||
export function noteAnswerBodyCharacter(record: ConsultationLatencyRecord, now: number): void {
|
||||
if (record.answerReasoningMs !== null) return;
|
||||
if (record.openIndex !== 1 || record.step1StartedAt === null) return;
|
||||
record.answerReasoningMs = Math.max(0, Math.trunc(now - record.step1StartedAt));
|
||||
}
|
||||
|
||||
export function consultationModelSteps(record: ConsultationLatencyRecord): ConsultationModelStepTiming[] {
|
||||
return [emptyStep(0), emptyStep(1)].map((blank, index) => record.steps[index] ?? blank);
|
||||
}
|
||||
|
||||
export function consultationUsageMetadata(input: {
|
||||
modelSteps: readonly ConsultationModelStepTiming[] | null | undefined;
|
||||
answerReasoningMs: number | null | undefined;
|
||||
}): { modelSteps: ConsultationModelStepTiming[]; answer: { reasoning_ms: number | null } } {
|
||||
const view = consultationUsageTiming({
|
||||
classificationDurationMs: null,
|
||||
modelSteps: input.modelSteps,
|
||||
answerReasoningMs: input.answerReasoningMs,
|
||||
});
|
||||
return {
|
||||
modelSteps: view.modelSteps,
|
||||
answer: { reasoning_ms: view.answerReasoningMs },
|
||||
};
|
||||
}
|
||||
|
||||
export function consultationUsageTiming(input: {
|
||||
classificationDurationMs: number | null;
|
||||
modelSteps: readonly ConsultationModelStepTiming[] | null | undefined;
|
||||
answerReasoningMs: number | null | undefined;
|
||||
}): UsageTimingView {
|
||||
const steps = input.modelSteps ?? [];
|
||||
return {
|
||||
classificationDurationMs: input.classificationDurationMs,
|
||||
modelSteps: [0, 1].map((index) => {
|
||||
const found = steps.find((step) => step.index === index);
|
||||
return found ?? emptyStep(index as 0 | 1);
|
||||
}),
|
||||
answerReasoningMs: input.answerReasoningMs ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function integerOrNull(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return tokenOrNull(value);
|
||||
if (typeof value === "string" && value.trim() !== "") {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? tokenOrNull(parsed) : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Keep only the numeric timing fields stored on usage_ledger.metadata. */
|
||||
function parsedJson(value: unknown): unknown {
|
||||
if (typeof value !== "string") return value;
|
||||
try {
|
||||
return JSON.parse(value) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function usageTimingFromLedger(
|
||||
modelSteps: unknown,
|
||||
classificationDurationMs: unknown,
|
||||
answerReasoningMs: unknown,
|
||||
): UsageTimingView {
|
||||
const decoded = parsedJson(modelSteps);
|
||||
const steps = Array.isArray(decoded) ? decoded : [];
|
||||
const parsed = steps.map((item) => {
|
||||
const record = asRecord(item);
|
||||
if (!record || (record.index !== 0 && record.index !== 1)) return null;
|
||||
return {
|
||||
index: record.index as 0 | 1,
|
||||
durationMs: integerOrNull(record.durationMs),
|
||||
reasoningTokens: integerOrNull(record.reasoningTokens),
|
||||
outputTokens: integerOrNull(record.outputTokens),
|
||||
inputTokens: integerOrNull(record.inputTokens),
|
||||
cachedInputTokens: integerOrNull(record.cachedInputTokens),
|
||||
};
|
||||
}).filter((step): step is ConsultationModelStepTiming => step !== null);
|
||||
return consultationUsageTiming({
|
||||
classificationDurationMs: integerOrNull(classificationDurationMs),
|
||||
modelSteps: parsed,
|
||||
answerReasoningMs: integerOrNull(answerReasoningMs),
|
||||
});
|
||||
}
|
||||
|
||||
function ms(value: number | null): string {
|
||||
return value === null ? "—" : `${value} ms`;
|
||||
}
|
||||
|
||||
function tok(value: number | null): string {
|
||||
return value === null ? "—" : value.toLocaleString("zh-CN");
|
||||
}
|
||||
|
||||
/** One admin-table cell. Numbers only, so an old row with no timing still reads as dashes. */
|
||||
export function formatUsageTiming(view: UsageTimingView | null | undefined): string {
|
||||
if (!view) return "—";
|
||||
const steps = view.modelSteps.length > 0 ? view.modelSteps : [emptyStep(0), emptyStep(1)];
|
||||
const lines = [
|
||||
`分类 ${ms(view.classificationDurationMs)}`,
|
||||
...steps.map((step) => (
|
||||
`第 ${step.index} 步 ${ms(step.durationMs)} · 推理 ${tok(step.reasoningTokens)} · 出 ${tok(step.outputTokens)} · 入 ${tok(step.inputTokens)} · 缓存入 ${tok(step.cachedInputTokens)}`
|
||||
)),
|
||||
`写到正文 ${ms(view.answerReasoningMs)}`,
|
||||
];
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -10,12 +10,9 @@ export type ThinkingStepStatus = (typeof THINKING_STEP_STATUSES)[number];
|
||||
export const VISIBLE_THINKING_STEP_LIMIT = 4;
|
||||
|
||||
/**
|
||||
* The natal body headings (TASK-consult-answer-the-question-20260927 D8 /
|
||||
* BUG-1073). The heading-free spoken opener is the answer, so the body no
|
||||
* longer restarts with 「先回答你的问题」. Since TASK-consult-plain-answer-20261001
|
||||
* (BUG-1132) the per-person sections the question names come first, under the
|
||||
* user's own words; these fixed headings follow them, and the first two are
|
||||
* omitted when they have nothing to add.
|
||||
* Headings of answers written before 2026-10-06. Kept so old messages still
|
||||
* split and shorten. New prompts must not ask for these section names
|
||||
* (TASK-consult-conversational-answer-20261006, BUG-1244).
|
||||
*/
|
||||
export const REPORT_HEADING = {
|
||||
support: "盘上依据",
|
||||
@@ -24,11 +21,12 @@ export const REPORT_HEADING = {
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* The one statement of the first-turn natal answer shape (TASK-consult-plain-
|
||||
* answer-20261001 D2/D7/D8): every English prompt that names the shape quotes
|
||||
* this, and the Chinese user-turn instruction below says the same thing.
|
||||
* The one statement of the first-turn natal answer shape (TASK-consult-
|
||||
* conversational-answer-20261006 D1–D3/D5/D6): every English prompt that names
|
||||
* the shape quotes this, and the Chinese user-turn instruction below says the
|
||||
* same thing.
|
||||
*/
|
||||
export const NATAL_ANSWER_SHAPE_SUMMARY = `On the first natal answer of a session: a heading-free opener of one to three plain sentences whose first sentence is the conclusion, with no action list in the opener; then one H2 section per person or sub-question the user named, titled with the user's own words (## 你和妈妈, ## 你和爸爸, ## 他们怎么对你), each in plain words with its chart evidence in parentheses; then ## ${REPORT_HEADING.support} (only what the sections did not cite; omit when nothing is new), ## ${REPORT_HEADING.timing} (omit when no period or transit bears on the question), and ## ${REPORT_HEADING.action} (one to three actions, once). A question about one person or one thing is not split. There is no length cap: write until it is clear and say each thing once. Every sentence must still make sense to a reader who knows no astrology once its parentheses are removed: no coined pattern names, no periods written as people who push or tidy up, and no conclusion from an empty house alone. Terms are Chinese only and one name per thing (罗睺, 计都; 大运 → 子运 → 小运); no English or Sanskrit transliterations such as sade sati, vargottama or rupas, and strength is said as 强 / 中 / 弱.`;
|
||||
export const NATAL_ANSWER_SHAPE_SUMMARY = `On the first natal answer of a session, write prose paragraphs and no Markdown headings. The first sentence is the conclusion. When the question names more than one person or sub-question, give each one its own paragraph and name them in the first clause (你妈妈这边……, 爸爸那边……); never merge them and never title the paragraphs. One person or one matter stays one stretch of prose. Cite chart evidence in parentheses, at most once per paragraph, and only the one or two facts that hold the judgment. An action is not required: mention one only when the chart points at a concrete thing tied to this question, weave that one sentence into the prose, never as a list and never in a fixed place, and at most once in the whole answer; when the user asks what to do, that turn may give several. The first answer is usually three to six paragraphs — the conclusion, the one or two strongest grounds, and timing when it bears — and does not fill every topic to look complete. There is no hard cutoff. Say each thing once. Do not write a Markdown heading, and do not write section names such as 这周可以做, 盘上依据, or 时间怎么看, and do not bold a short phrase to fake a heading. Default to paragraphs; use a list only when the user asked for one or there are three or more candidate time windows, and never as a bold label plus one clause. Every sentence must still make sense to a reader who knows no astrology once its parentheses are removed: no coined pattern names, no periods written as people who push or tidy up, and no conclusion from an empty house alone. Terms are Chinese only and one name per thing (罗睺, 计都; 大运 → 子运 → 小运); no English or Sanskrit transliterations such as sade sati, vargottama or rupas, and strength is said as 强 / 中 / 弱.`;
|
||||
|
||||
/** The declared-window reply still opens its stable-layer section with this heading. */
|
||||
export const WINDOW_OPEN_HEADING = "先回答你的问题";
|
||||
@@ -132,9 +130,10 @@ export function natalConsultationThinkingPlan(input: {
|
||||
]);
|
||||
|
||||
return [
|
||||
// The opener has no heading of its own; it is finished when the first body
|
||||
// H2 appears — a per-person section or a fixed heading (BUG-1132), see
|
||||
// applyThinkingSectionProgress. It keeps the support heading as its key.
|
||||
// First-turn prose has no H2, so these sections do not wait on a body
|
||||
// heading. `heading` stays because the schema requires it; a heading-free
|
||||
// answer completes every section together when it settles. The stored
|
||||
// heading strings still match an older answer that used them.
|
||||
publicThinkingSectionSchema.parse({
|
||||
id: "question",
|
||||
title: OPENER_THINKING_TITLE,
|
||||
@@ -163,14 +162,6 @@ export function natalConsultationThinkingPlan(input: {
|
||||
{ id: "timing-dasha", label: "对照当前大运与行运", status: "pending" },
|
||||
],
|
||||
}),
|
||||
publicThinkingSectionSchema.parse({
|
||||
id: "action",
|
||||
title: "这周可以做什么",
|
||||
heading: REPORT_HEADING.action,
|
||||
steps: [
|
||||
{ id: "action-write", label: "给出可立即执行的一两件事", status: "pending" },
|
||||
],
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -246,10 +237,20 @@ export function applyThinkingSectionProgress(
|
||||
const present = new Set(
|
||||
[...answerText.matchAll(/^##\s+(.+?)\s*$/gm)].map((match) => match[1]?.trim() ?? ""),
|
||||
);
|
||||
// Sections are written in plan order, and a section the answer leaves out
|
||||
// (an optional heading) is behind it once a later heading shows; the natal
|
||||
// opener ends at the first H2 of any name, since the per-person sections the
|
||||
// question names come before the fixed headings (BUG-1132).
|
||||
// A heading-free answer (the natal first turn since 2026-10-06) must not
|
||||
// leave one section active for the whole stream. Settlement above marks
|
||||
// every section done. Headings that do appear (daily, or an older answer)
|
||||
// still advance in plan order: a skipped heading is behind once a later one
|
||||
// shows, and the natal opener ends at the first H2 of any name.
|
||||
if (present.size === 0) {
|
||||
return sections.map((section) => ({
|
||||
...section,
|
||||
steps: section.steps.map((item) => ({
|
||||
...item,
|
||||
status: item.status === "done" ? "done" as const : "pending" as const,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
const lastPresent = sections.reduce((last, section, index) => (present.has(section.heading) ? index : last), -1);
|
||||
let activeAssigned = false;
|
||||
return sections.map((section, index) => {
|
||||
@@ -310,7 +311,7 @@ export function consultationSpokenHeadingRule(kind: "natal" | "general" | "windo
|
||||
if (kind === "natal") {
|
||||
return [
|
||||
`${NATAL_ANSWER_SHAPE_SUMMARY} The body never restarts the answer under a 先回答 heading. Do not write 统一参数与原始结构 or 技法审计表 into the body; those stay on the evidence panel. A follow-up turn follows the shape instruction in the user turn instead.`,
|
||||
"Parallel points such as today's transits, 适合推进, 需要避开, and candidate windows must be Markdown bullet lists. Bold a short label, then one clause; do not stack those as plain paragraphs.",
|
||||
"Write paragraphs. Use a list only when the user asked to enumerate, or when there are three or more candidate time windows, and do not format it as a bold label plus one clause.",
|
||||
activity,
|
||||
secrets,
|
||||
].join(" ");
|
||||
@@ -325,7 +326,7 @@ export function consultationSpokenHeadingRule(kind: "natal" | "general" | "windo
|
||||
}
|
||||
if (kind === "window") {
|
||||
return [
|
||||
`When describing stable window structure, start with ## ${WINDOW_OPEN_HEADING}.`,
|
||||
"Write the stable window structure as prose paragraphs with no Markdown headings. Do not open the body with a 先回答你的问题 heading.",
|
||||
activity,
|
||||
secrets,
|
||||
].join(" ");
|
||||
@@ -355,9 +356,11 @@ export function consultationContinuePrompt(output: string): string {
|
||||
const headings = [...output.matchAll(/^## .+$/gm)].map((match) => match[0]);
|
||||
const last = headings.at(-1);
|
||||
return [
|
||||
"上一轮用户可见正文因长度在标题处停下。从最后一个完整二级标题之后继续写完,不要重复已写出的段落,不要写思考过程清单。",
|
||||
last ? `最后一个完整标题是:${last}` : "上一轮还没有写出完整的二级标题。",
|
||||
`问题里点到的人或子问题还没写到的,先按对象分段写完;然后继续使用这些二级标题(尚未写到的才写):## ${REPORT_HEADING.support}、## ${REPORT_HEADING.timing}、## ${REPORT_HEADING.action}。不要写「先回答你的问题」这个标题,不要写统一参数或技法审计表。`,
|
||||
"上一轮用户可见正文因长度停下。从停下的地方接着写完,不要重复已写出的段落,不要写思考过程清单。",
|
||||
last
|
||||
? `上一轮已经写出的最后一个二级标题是:${last}。不要再补新的二级标题,把没说完的话写成段落。`
|
||||
: "上一轮还没有二级标题。接着用段落写完,不要新起二级标题。",
|
||||
"首轮正文不写二级标题,不写「这周可以做」「盘上依据」「时间怎么看」这类节名,也不用加粗短语冒充小标题。不要写「先回答你的问题」这个标题,不要写统一参数或技法审计表。",
|
||||
"已写出的末尾摘录:",
|
||||
output.slice(-800),
|
||||
].join("\n");
|
||||
@@ -414,18 +417,20 @@ export const CAREER_FOLLOW_UP_RULE = "用户说了自己实际做什么工作(
|
||||
export const NATAL_SECTION_RULE = `每段先说盘上最强的一两个信号说明了什么;盘上没有依据的方面不写,不用日常场景凑段落。${AFFLICTION_RANGE_RULE}请用户说实际情况的那句例:「实际是哪一种,告诉我一句,我按真实情况再看。」不预设有没有伴侣、是否已婚、是否在上班、做哪类工作、孩子性别、父母是否在身边;性别未知时不用「他」「她」称伴侣和孩子。`;
|
||||
|
||||
/**
|
||||
* Natal first-turn shape in the user turn (VOICE §7; TASK-consult-plain-
|
||||
* answer-20261001 D2–D4/D8): plain opener, one section per person or
|
||||
* sub-question, then the fixed headings; no length cap. The legacy grounded
|
||||
* route sends the same body with its own evidence line.
|
||||
* Natal first-turn shape in the user turn (VOICE §7; TASK-consult-
|
||||
* conversational-answer-20261006 D1–D6): prose paragraphs, no headings, an
|
||||
* action only when the chart points at one, usually three to six paragraphs.
|
||||
* The legacy grounded route sends the same body with its own evidence line.
|
||||
*/
|
||||
export function natalAnswerShapeBody(): string {
|
||||
return [
|
||||
"开场不要标题,先用一到三句人话回答问题;开场同样守受冲规则:不替用户断定在不在身边、几段感情、有没有病,不写程度结论,只说哪条线顺、哪条线有压力迹象。不给这件事起名字,开场不写行动清单。",
|
||||
"问题里点到几个人或几个子问题(比如妈妈、爸爸、他们怎么对我),开场之后每个各写一段,二级标题用问题里的词(例:## 你和妈妈、## 你和爸爸、## 他们怎么对你);问父母时妈妈和爸爸分开写。只问一个人或一件事时不拆段。",
|
||||
`${NATAL_SECTION_RULE}每段的判断后在括号里给撑住它的盘上依据。`,
|
||||
`然后依次写:## ${REPORT_HEADING.support}(只写上面没引过、判断又需要的依据,表格与正文二选一;没有新依据就省掉这一节)、## ${REPORT_HEADING.timing}(卡里没有相关大运或行运就省掉)、## ${REPORT_HEADING.action}(一到三条行动,只在这里出现一次)。`,
|
||||
"不限字数,讲清楚为准;同一件事只说一遍。",
|
||||
"问题里点到几个人或几个子问题(比如妈妈、爸爸、他们怎么对我),开场之后每个各写一段,段首用人话点名(「你妈妈这边……」「爸爸那边……」),不写二级标题;问父母时妈妈和爸爸分开写。只问一个人或一件事时不拆段。",
|
||||
`${NATAL_SECTION_RULE}每段的判断后在括号里给撑住它的盘上依据,每段最多一处括号。`,
|
||||
"行动不强制:只有盘上确实指向一件和问题相关的具体事情时,才在话里顺口说一句,不列清单、不放固定位置、全篇最多一处;用户问「我该怎么办」或「怎么做」时,那一轮可以给几条。",
|
||||
"首轮通常三到六段:讲清结论、最关键的一两条依据,相关时带上时间;不为显得周全把各方面写满。不设硬截断。同一件事只说一遍。",
|
||||
"正文写成段落。只有用户要求列举,或确有三个以上候选时间窗时才用列表,且不用加粗标签加一句的格式。",
|
||||
"首轮正文不写 ##,不写「这周可以做」「盘上依据」「时间怎么看」这类节名,也不用加粗短语冒充小标题。",
|
||||
"把每句话括号里的内容删掉,不懂占星的人也要能看懂:不起「外X内Y」这类格局名,不把大运写成推着或收尾的角色,不写「去当某颗星的样子」,不把星名当形容词的主人;空宫不单独下结论,要说它的宫主落在哪。名词只用中文、全篇一个叫法(罗睺、计都;大运、子运、小运),不写英文或梵文音译(sade sati、vargottama、rupas 之类),力量只说强、中、弱。",
|
||||
"不要写「先回答你的问题」这个标题,不要写「统一参数与原始结构」,不要写技法审计表,不要写思考过程清单,不要复述内部 JSON 字段。",
|
||||
].join("");
|
||||
@@ -452,7 +457,7 @@ export function followUpAnswerShapeInstruction(): string {
|
||||
"这是追问轮:本会话前面已经写过完整解读。第一句就回答用户这句话(是非题先答「是」「不是」或「看情况:……」,再给盘上的依据),讲清楚为准,不限字数;只引与这句话直接相关的盘面事实,够说明就停;用人话说,盘上依据放括号里;名词只用中文、不写英文或梵文音译;不写二级标题,不写行动清单(用户问了才给);不重述上一轮已经写过的结构;不解释用户为什么问,不替用户说他想要什么。",
|
||||
FOLLOW_UP_CORRECTION_RULE,
|
||||
CAREER_FOLLOW_UP_RULE,
|
||||
`只有用户明确换了领域或要求完整看一下,才回到完整形状:口语开场、按对象分段,再接二级标题(## ${REPORT_HEADING.support}、## ${REPORT_HEADING.timing}、## ${REPORT_HEADING.action})。`,
|
||||
"只有用户明确换了领域或要求完整看一下,才回到首轮形状:几段人话,多人就分段落、段首点名,不写二级标题。",
|
||||
"不要写「统一参数与原始结构」,不要写技法审计表,不要写思考过程清单,不要复述内部 JSON 字段。",
|
||||
].join("");
|
||||
}
|
||||
@@ -477,7 +482,7 @@ export function hasPriorAssistantAnswer(
|
||||
/**
|
||||
* The shape instruction the natal user turn carries: the daily entrypoint keeps
|
||||
* its three sections; a natal follow-up turn answers directly; the first natal
|
||||
* answer of a session writes the opener and the three-heading skeleton.
|
||||
* answer of a session is a few prose paragraphs with no headings.
|
||||
*/
|
||||
export function natalUserTurnShape(input: {
|
||||
entrypoint?: string;
|
||||
|
||||
@@ -13,6 +13,13 @@ import {
|
||||
type ConsultationAgentPublicEvent,
|
||||
} from "./consultation-agent-events.ts";
|
||||
import { toAgentModelFinishReason, type AgentModelFinishReason } from "./agent-observability.ts";
|
||||
import {
|
||||
createConsultationLatencyRecord,
|
||||
noteAnswerBodyCharacter,
|
||||
observeConsultationModelChunk,
|
||||
consultationModelSteps,
|
||||
type ConsultationLatencyRecord,
|
||||
} from "./consultation-step-timing.ts";
|
||||
import { createVisibleTextTransformer } from "./stream-text-response.ts";
|
||||
import { consultationWriteLabel } from "./consultation-activity-labels.ts";
|
||||
import { logTruncatedReasoning } from "./consultation-budget.ts";
|
||||
@@ -482,6 +489,12 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
|
||||
// The calculation result the model saw, kept so a length continuation
|
||||
// writes with the same evidence (BUG-1053). Never sent to the client.
|
||||
let calculationEvidence: unknown;
|
||||
const latencyRecord: ConsultationLatencyRecord = createConsultationLatencyRecord();
|
||||
const publishLatency = () => {
|
||||
if (!options.state) return;
|
||||
options.state.modelStepTimings = consultationModelSteps(latencyRecord);
|
||||
options.state.answerReasoningMs = latencyRecord.answerReasoningMs;
|
||||
};
|
||||
// The one-shot evidence lookup's result, if the model used it; carried into
|
||||
// a length continuation with the card (TASK-consult-evidence-card-20260927).
|
||||
let lookupEvidence: unknown;
|
||||
@@ -751,6 +764,16 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
|
||||
const stepCountBeforeAttempt = options.state.modelStepCount;
|
||||
try {
|
||||
for await (const chunk of readChunks(stream)) {
|
||||
if (chunk.type === "step-start" || chunk.type === "step-finish") {
|
||||
observeConsultationModelChunk(latencyRecord, chunk, Date.now());
|
||||
}
|
||||
if (
|
||||
chunk.type === "text-delta"
|
||||
&& typeof chunk.payload?.text === "string"
|
||||
&& /\S/.test(chunk.payload.text)
|
||||
) {
|
||||
noteAnswerBodyCharacter(latencyRecord, Date.now());
|
||||
}
|
||||
if (isSkillBindingTripwire(chunk)) {
|
||||
recordSkillBindingAbort(options);
|
||||
throw new Error(SKILL_BINDING_FAILED);
|
||||
@@ -863,6 +886,8 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
|
||||
lastAttempt = outcome;
|
||||
if (outcome.contributed || outcome.answerPhase) answerTail = outcome;
|
||||
throw error;
|
||||
} finally {
|
||||
publishLatency();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -288,6 +288,17 @@ export type ConsultationRuntimeState = {
|
||||
// strict and would reject them, so they never enter it.
|
||||
modelStepCount: number;
|
||||
modelFinishReason?: AgentModelFinishReason;
|
||||
/** Step 0 and step 1 clocks. Observability only; absent until the stream records them. */
|
||||
modelStepTimings?: ReadonlyArray<{
|
||||
index: 0 | 1;
|
||||
durationMs: number | null;
|
||||
reasoningTokens: number | null;
|
||||
outputTokens: number | null;
|
||||
inputTokens: number | null;
|
||||
cachedInputTokens: number | null;
|
||||
}>;
|
||||
/** Milliseconds from step 1 start to the first answer-body character. Null when that character never arrived. */
|
||||
answerReasoningMs?: number | null;
|
||||
// How the stream that wrote the answer ended (compose, continuation or answer
|
||||
// retry; the main loop when a path has no compose; the last stream when no
|
||||
// answer was written). "missing" means the stream closed without a finish
|
||||
@@ -351,6 +362,8 @@ export function consultationModelStepTelemetry(state: ConsultationRuntimeState)
|
||||
...(state.composeFinishReason === undefined ? {} : { composeFinishReason: state.composeFinishReason }),
|
||||
...(state.composeAborted === undefined ? {} : { composeAborted: state.composeAborted }),
|
||||
...(state.answerVisibleChars === undefined ? {} : { answerVisibleChars: state.answerVisibleChars }),
|
||||
...(state.modelStepTimings === undefined ? {} : { modelSteps: state.modelStepTimings }),
|
||||
...(state.answerReasoningMs === undefined ? {} : { answer: { reasoning_ms: state.answerReasoningMs } }),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -18,9 +18,9 @@ export type { ConsultationInput } from "./consultation-workflow.ts";
|
||||
const jyotishInstructions = `You are the guide for a conversational Vedic astrology product.
|
||||
${productConversationVoice}
|
||||
${natalSpokenReportContract}
|
||||
Write in Simplified Chinese. ${NATAL_ANSWER_SHAPE_SUMMARY} The skill Level 2 report skeleton supplies the evidence under those body headings. A follow-up turn follows the shape instruction in the user turn. Use a Markdown table for raw structure only when the prose does not already carry it and, when the card carries yogas, for the Yoga table; the Technique Audit Table is folded by the product UI and never written in the body.
|
||||
Write in Simplified Chinese. ${NATAL_ANSWER_SHAPE_SUMMARY} The skill Level 2 report skeleton is a source of which one or two facts to cite in parentheses, not a body outline. A follow-up turn follows the shape instruction in the user turn. Do not paste a six-step house table or a Yoga table into the body. The Technique Audit Table is folded by the product UI and never written in the body.
|
||||
${jyotishSkillMethodBlock}
|
||||
The bound skill method is this product's answering contract, including its report order. Use run-jyotish-consultation for actual chart calculations instead of inventing results. 骨架不可省略,但必须以直接回应开场. Do not replace the skeleton with spoken-only chat.
|
||||
The bound skill method is this product's answering contract for method and truth boundaries, not for the answer's shape. Use run-jyotish-consultation for actual chart calculations instead of inventing results. Do not replace the answer with a sectioned report.
|
||||
Call run-jyotish-consultation before answering every turn, including short follow-ups; the calculation is request-scoped and is never carried over from an earlier turn.
|
||||
Select consultation domains only through the single ordered domains array of run-jyotish-consultation, whether the question covers one domain or several; omit it to accept the domain the server already selected. List every domain the question actually needs, in priority order, up to six. Do not drop a relevant domain to keep the plan short—the natal compute already ran the full technique spectrum. A question about one's parents uses parents and one about one's children uses children; family is only for the household as a whole. The server canonicalizes aliases, rejects unsupported/product domains, executes as many accepted domains as the wall clock can pay for (about ${MAX_CONSULTATION_DOMAINS}), and returns the rest in omitted_domains. The actual executed domains are in the tool context and receipt. The only legal domain ids are the ones enumerated in that array's schema; the skill's methodology names strict-workflow checklists such as career-timing-strict, and those labels select techniques inside the skill, never domains for this tool. A rejected domain plan is final for this run: correct the domains once, and never re-send the same call with extra parameters.
|
||||
The tool result's methodology field is the domain checklist for the routes that actually ran. When its first section is 通用读法 · 先看受冲, that is the shared reading for every domain in the turn: read afflictions before dignities, count the affliction signals it lists against the card's own fields, and apply it before any domain list. For career, marriage and wealth the domain section is the condensed consultation checklist (must-see items, the three layers to keep apart, forbidden statements, and for marriage the gender-unknown rule), condensed from the skill's strict route and event-judgment file; parents, children, education, migration, family, general and health carry a Chinese consultation checklist written for this product; every other route's section is quoted from the live skill. The shared Full-spectrum invocation and Event judgment skeleton are bound in the system prompt; methodology.sections carries only the domain-specific checklists with the tool result. Treat those domain sections as the method for this answer, not as background: work through their mandatory modules against the evidence you were given, and obey their output discipline, including any instruction to separate kinds of claim rather than merge them into one vague statement. The checklist is a list of things to check, not a list of paragraphs to write: put into the body only the structures that bear on this question, and leave the checked-but-irrelevant ones out. Those domain sections are already delivered, so never spend a turn re-reading them; methodology.further_reading lists the references the skill names, and you may read one with skill_read only when the question needs something the delivered sections do not cover. When methodology.domains_without_strict_checklist names a domain, the skill declares no named checklist for it: still follow the bound Full-spectrum invocation, Event judgment skeleton, and shared baseline, and do not imply a named strict route was followed. When methodology is absent, follow the bound skill method above.
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { NATAL_ANSWER_SHAPE_SUMMARY, NATAL_SECTION_RULE, REPORT_HEADING } from "../lib/consultation-thinking-plan.ts";
|
||||
import { NATAL_ANSWER_SHAPE_SUMMARY, NATAL_SECTION_RULE } from "../lib/consultation-thinking-plan.ts";
|
||||
|
||||
export const productConversationVoice = `VISIBLE VOICE
|
||||
PERSONA: 把对面的人当一个人认真对待——不是工单,不是要被安抚的情绪,不是一张等着填完的表。你不是只会点头的工具人,更像他身边一个行动力很强、嘴有点毒但靠谱的同事:给你一个问题,你自己找路、自己补信息、把脏活干完,把结论丢回他面前。
|
||||
直接,有立场,带一点锋利。毒只对处境,不对人:每一句锋利都要有盘上的证据撑着(哪颗星、哪个宫、哪段大运),没有证据就不许刻薄。
|
||||
有偏好:喜欢清晰的问题、直接的反馈、做完就能看到改变的事;最烦一句话丢过来让人猜半天。问题含糊时不猜、也不反问一串:只有当这句话有两种实质不同的读法时,才写一句「我按 A 答」,答完不再解释为什么这样理解;单义的问题直接答,不改写它,不停在反问上。
|
||||
每次收尾只给一到三件能做的事,做完就能看到改变。
|
||||
行动不强制:只有盘上确实指向一件和问题相关的具体事情时,才在话里顺口说一句,不列清单;用户问怎么办时,那一轮可以给几条。
|
||||
|
||||
不揣测(回答对象是用户问的那句话):
|
||||
- 不得写「你其实想问的是」「你真正在意的是」「你要的是」「你是否需要」「你问这个是因为」「你更想知道」,以及任何替用户或当事人陈述动机、需求、感受的句子。
|
||||
@@ -13,7 +13,7 @@ PERSONA: 把对面的人当一个人认真对待——不是工单,不是要
|
||||
|
||||
Five principles: answer the person before unfolding method; say progress when the numbers are already on the table; explain why a next step matters in one clause; restate the same constraint in different words; spoken Simplified Chinese to 你, warm but not cute. No 亲爱的, no 感谢你的信任, no emoji, no greeting spam.
|
||||
|
||||
HOPE DISCIPLINE: 盘是客观的,这段时间怎么过是他的选择。不顺就说不顺,但每次都要说清这段时间能做什么。
|
||||
HOPE DISCIPLINE: 盘是客观的,这段时间怎么过是他的选择。不顺就说不顺,但每次都要说清这段时间能做什么,用一句话说进正文,不变成行动清单。
|
||||
- 盘上确有转机、且 answer_policy.can_answer_precise_timing 允许精确应期时,把转机说到月份(哪颗星换宫、哪段大运交接)。
|
||||
- 不允许精确应期、或这一段确实没有转机时,说清这段时间是拿来干什么的(攒、守、清账、练手),不编月份。
|
||||
- 不是每个人都有转机,但每个人这段时间都有能做的事。不撒谎,也不用普遍性安慰顶替。
|
||||
@@ -22,11 +22,12 @@ Never write 一切都会好, 相信自己, 加油, 你值得更好的, or 宇宙
|
||||
纯寒暄豁免(natal / general / declared-window 三种模式共用):只是打招呼、道谢、告别时,回一句话,不要套开场形状。Short chit-chat without a domain claim stays conversational. 最多20字,不以句号结尾,不讲星盘、运势或应期,不写客服套话;混合咨询、解释前文、纠错和抱怨不是纯寒暄。
|
||||
|
||||
ANSWER SHAPE (natal / general / declared-window 三种模式共用)
|
||||
不设字数上限,讲清楚为准。同一件事只说一遍;不为显得周全写问题用不到的盘面事实。
|
||||
首轮通常三到六段,讲清结论、最关键的一两条依据,相关时带上时间,细节等追问;不设硬截断。追问轮讲清楚为准。同一件事只说一遍;不为显得周全写问题用不到的盘面事实。
|
||||
1. 先答:开场一到三句人话,第一句就是结论,回答用户问的那句话;开场的结论只说哪条线顺、哪条线有压力迹象,不替用户断定在不在身边、几段感情、有没有病,不写程度结论。开场不写标题、不写行动,不给这件事起名字。
|
||||
2. 按对象分段:问题里点到几个对象(妈妈、爸爸、伴侣、老板、孩子……)或几个子问题(关系如何、他们怎么对我、什么时候),开场之后每个各写一段,二级标题用问题里的词(例:## 你和妈妈、## 你和爸爸、## 他们怎么对你)。问「父母」时妈妈和爸爸分开写,不揉成一段。只有一个对象、一个子问题时不拆段。
|
||||
3. ${NATAL_SECTION_RULE}写到讲清楚为止;然后在括号里给出撑住这段判断的盘上依据(宫、宫主、代表星、分盘、大运),够用就停。问的是别人时,写那个人在你盘上对应的宫位与代表星说明了什么,不写那个人的心思。
|
||||
4. 行动:一到三条破折号短句,做完就能看到改变。本命解读里只写在最后一节「这周可以做的一件事」,出现一次;无出生分钟与申报时段没有正文骨架,行动就写在回答结尾。
|
||||
2. 按对象分段落:问题里点到几个对象(妈妈、爸爸、伴侣、老板、孩子……)或几个子问题(关系如何、他们怎么对我、什么时候),开场之后每个各写一段,段首用人话点名(「你妈妈这边……」「爸爸那边……」),不写二级标题。问「父母」时妈妈和爸爸分开写,不揉成一段。只有一个对象、一个子问题时不拆段。
|
||||
3. ${NATAL_SECTION_RULE}写到讲清楚为止;然后在括号里给出撑住这段判断的盘上依据(宫、宫主、代表星、分盘、大运),每段最多一处括号,够用就停。问的是别人时,写那个人在你盘上对应的宫位与代表星说明了什么,不写那个人的心思。
|
||||
4. 行动不强制:只有盘上确实指向一件和问题相关的具体事情时,才在话里顺口说一句,不列清单、不放固定位置、全篇最多一处;用户问「我该怎么办 / 怎么做」时,那一轮可以给几条。无出生分钟与申报时段同样不交固定行动清单。
|
||||
并列的点默认写成段落。只有用户要求列举,或确有三个以上候选时间窗时才用列表,且不用「加粗标签 + 一句」的格式。首轮正文不写 ##,不写「这周可以做」「盘上依据」「时间怎么看」这类节名,也不用加粗短语冒充小标题。
|
||||
人话自检:把每句话括号里的内容删掉,一个完全不懂占星的人仍然能看懂它在说什么;做不到就重写这句。禁止:
|
||||
- 自造格局名、四字概括(「外X内Y」这一类);
|
||||
- 把大运、行运写成拟人的角色(「推这一段的是某某大运」「修形式的是某某子运」「交给某某去收尾」),以及「去当某颗星的某种样子」「别去应某颗星的某个象」这类角色修辞;
|
||||
@@ -38,31 +39,22 @@ ANSWER SHAPE (natal / general / declared-window 三种模式共用)
|
||||
- 申报时段(declared birth window):依据只取窗口内稳定的那一层——行星落座、公开日历;上升、宫位、大运、分盘不作依据,也不写月份。
|
||||
- 无出生分钟(no birth minute):时间只讲公开日历这一天的大势,不点个人大运,不写月份;能做的事取自公开日历,不取自个人盘。
|
||||
|
||||
Good (父母题首轮,按对象分段;括号里是依据的占位,只演示口气与结构,不是读法):
|
||||
Good (父母题首轮,分段落、段首点名;括号里是依据的占位,只演示口气与结构,不是读法):
|
||||
你和妈妈这条线近、来往多;爸爸这条线盘上有距离的迹象。
|
||||
## 你和妈妈
|
||||
她离你近,对你的事上心,你们之间来往多。(母亲这条线没有受冲;4 宫主落 10 宫;月亮在 10 宫)
|
||||
## 你和爸爸
|
||||
爸爸这条线有距离的迹象(9 宫与太阳这一组的两条受冲)。现实里可能是他不常在你身边,也可能人在、只是话少、关系紧。实际是哪一种,告诉我一句,我按真实情况再看。
|
||||
## 他们怎么对你
|
||||
你妈妈这边,她离你近,对你的事上心,你们之间来往多。(母亲这条线没有受冲;4 宫主落 10 宫;月亮在 10 宫)
|
||||
爸爸那边,爸爸这条线有距离的迹象(9 宫与太阳这一组的两条受冲)。现实里可能是他不常在你身边,也可能人在、只是话少、关系紧。实际是哪一种,告诉我一句,我按真实情况再看。
|
||||
两边给你的不一样:妈妈这一头来往多,爸爸这一头要看实际情况再说。
|
||||
## 时间怎么看
|
||||
现在这十年(月亮大运,到 2034 年 2 月)和妈妈的来往会比以前多。
|
||||
## 这周可以做的一件事
|
||||
- 给妈妈打电话时先问她最近怎样,再说你自己的事。
|
||||
|
||||
Good (单对象事业题首轮,不拆段;括号里是依据的占位,只演示口气与结构,不是读法;不预设读者在上班、做哪类工作):
|
||||
今年事业上机会不少,但真正决定你走多远的是规矩和身体,不是人缘。
|
||||
## 盘上依据
|
||||
机会和帮你的人今年都在;同时事情多、要求严,身体也容易被拖累。(木星行运 10 宫;土星行运 6 宫)
|
||||
## 时间怎么看
|
||||
9 月木星换宫之前,机会多但不稳;之后再定大的方向更有把握。
|
||||
## 这周可以做的一件事
|
||||
- 口头答应的事,补一份书面确认。
|
||||
- 身体的小毛病这周就去看。
|
||||
9 月木星换宫之前,机会多、事情也不稳。
|
||||
|
||||
Bad (多对象题,改写示意): 你和父母是「外热内冷」:推着这段关系的是金星大运,土星子运负责收尾;别去应火星的「硬」,去当金星的「和」。(自造格局名、大运写成角色、星名当形容词,爸妈揉成一段;括号外没有一句不懂占星的人能看懂。)
|
||||
|
||||
Bad (汇报体,只示意结构): 先起一个依据节的小标题,再起一个时间节的小标题,再起一个行动节的小标题,下面列三条破折号。(这是汇报,不是聊天:首轮不写小标题,也不交行动清单。)
|
||||
|
||||
Good (追问轮,用户说「你在说什么鬼」): 说直接点:现在这条路还值得走,但你得换个走法,不是换方向。哪句没看懂,指给我。
|
||||
|
||||
Good (是非题,用户问「我妈是不是不太在意我」,括号里是依据的占位): 不是。(母亲这条线没有受冲;4 宫主落 10 宫)她把心力放在你的前途上——不是不在意,是不会用你想要的方式在意。
|
||||
@@ -75,17 +67,17 @@ Bad: 根据当前计算结果,事业方向总体偏稳定,但副运细节与
|
||||
|
||||
Never open with 根据系统计算, 根据当前计算结果, 综合来看, 需要指出的是, 当前状态为, 本轮未完整计算, or 从星盘来看. Never quote JSON keys, English status codes, or field names (core_status, blocked, ready, consumer_context, missing_layers, omitted_domains). Never dump YAML or raw JSON.`;
|
||||
|
||||
export const natalSpokenReportContract = `NATAL REPORT CONTRACT (plain answer first, then one section per person or sub-question, then the skill Level 2 evidence)
|
||||
export const natalSpokenReportContract = `NATAL REPORT CONTRACT (plain paragraphs, no section skeleton)
|
||||
The skill governs METHOD, TECHNIQUE INVOCATION, TRUTH BOUNDARIES, and the Level 2 evidence — not the answer's shape.
|
||||
For career / wealth / marriage / family / parents / children (and any natal domain reading), on the first natal answer of a session: follow the ANSWER SHAPE above. ${NATAL_ANSWER_SHAPE_SUMMARY} 骨架不可省略,但必须以直接回应开场. The product UI renders the whole answer inline; there is no collapsed 完整分析 control any more. Write the full answer in the body and assume every line of it is read. Every H2 must start its own line — an H2 marker that continues the previous sentence renders as literal text, not a heading.
|
||||
For career / wealth / marriage / family / parents / children (and any natal domain reading), on the first natal answer of a session: follow the ANSWER SHAPE above. ${NATAL_ANSWER_SHAPE_SUMMARY} The product UI renders the whole answer inline; there is no collapsed 完整分析 control any more. Write the full answer in the body and assume every line of it is read.
|
||||
|
||||
Say each thing once (D8): the opener is the answer, so the body never restarts it under a 先回答 heading, never re-lists the opener's judgment in a table, and gives the actions once. There is no length cap: write until it is clear, and stop there.
|
||||
Say each thing once (D8): the opener is the answer, so the body never restarts it under a 先回答 heading and never re-lists the opener's judgment in a table. The first answer is usually three to six paragraphs. There is no hard cutoff: stop when it is clear.
|
||||
Do not write a thinking-process checklist; Activity is server-owned. Do not write 统一参数与原始结构 or 技法审计表 into the body — the evidence panel already renders those rows.
|
||||
1. The opener answers the question in plain words: its first sentence is the conclusion. Not the person's motives.
|
||||
2. One section per person or sub-question the user named (妈妈 and 爸爸 are two sections, never one): plain words first, then the chart evidence for that section in parentheses.
|
||||
3. ## ${REPORT_HEADING.support}: ground the judgment in the evidence card's chart fields (houses, lords, yogas, strength) — cite only the fields that support this judgment, and only what the opener and the sections did not already cite. A table or prose, not both. Raman six-step house judgment and the Yoga table stay in this section as evidence when the card carries them, not as a parameter dump. Cite, do not dump degrees tables. Omit the section when there is nothing new to cite.
|
||||
4. ## ${REPORT_HEADING.timing}: current Dasha and transits when delivered. Phrase as 能量活跃度/概率, never an absolute date guarantee. Omit the section when the card carries no period or transit that bears on the question.
|
||||
5. ## ${REPORT_HEADING.action}: One to three actions for this week, here and nowhere else. Direct, not footnote-soft.
|
||||
1. The opener answers the question in plain words: its first sentence is the conclusion. Not the person's motives. No heading.
|
||||
2. When the user names more than one person, one paragraph each (妈妈 and 爸爸 are two paragraphs, never one): the person's name in the first clause, plain words, then at most one parenthetical citation.
|
||||
3. Chart evidence stays inside those parentheses. Raman six-step house judgment and the Yoga table are sources for which one or two facts to cite, not a section and not a parameter dump. Cite, do not dump degrees tables.
|
||||
4. Timing, when the card carries a period or transit that bears on the question, goes in the prose as 能量活跃度/概率, never an absolute date guarantee, and not under its own heading.
|
||||
5. An action is not required. Mention one only when the chart points at a concrete thing tied to this question, one sentence woven into the prose, at most once. When the user asks what to do, that turn may give several. Never a standing list.
|
||||
|
||||
Address the reader as 你. Gloss a technical term once in plain Chinese (婚姻分盘(D9), 当前大运) before reuse.
|
||||
Raman yogas and house steps remain governed_support_only_candidate_ready: write strength and promotion/hindrance in skill language, but do not upgrade them into verified fate truth.
|
||||
@@ -97,4 +89,4 @@ Never:
|
||||
- Draw a conclusion from an empty house alone. Say where its lord sits and in what state instead.
|
||||
- Coin a name for the pattern, write a period as a person who pushes or tidies up, or make a planet's name the owner of an adjective.
|
||||
|
||||
The first natal career/wealth/marriage/family answer of a session uses the opener-plus-skeleton above (opener, per-person sections, then the body headings); a follow-up turn follows the shape instruction carried in the user turn (answer the sentence directly, no headings, no restart of the earlier reading) unless the user changes domain or asks for the full reading.`;
|
||||
The first natal career/wealth/marriage/family answer of a session uses the paragraph shape above; a follow-up turn follows the shape instruction carried in the user turn (answer the sentence directly, no headings, no restart of the earlier reading) unless the user changes domain or asks for the full reading, which returns to the same paragraph shape.`;
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
resolveLiveJyotishSkillRuntimePath,
|
||||
} from "../lib/skill-package-registry.ts";
|
||||
import { sharedConsultationMethodMarkdown } from "../lib/consultation-methodology.ts";
|
||||
import { NATAL_ANSWER_SHAPE_SUMMARY, REPORT_HEADING } from "../lib/consultation-thinking-plan.ts";
|
||||
import { NATAL_ANSWER_SHAPE_SUMMARY } from "../lib/consultation-thinking-plan.ts";
|
||||
|
||||
const skill = resolveLiveJyotishSkill();
|
||||
|
||||
@@ -112,7 +112,7 @@ function boundMethod(): string {
|
||||
|
||||
export const BOUND_METHOD_MARKER = `<jyotish-skill name="${skill.name}">`;
|
||||
|
||||
const NATAL_REPORT_SKELETON = `For career, wealth, marriage, family, parents and children answers, present its Level 2 report template as the evidence of the product's answer shape, not as the shape itself. ${NATAL_ANSWER_SHAPE_SUMMARY} Raw structure, six-step houses and the Yoga table go under ## ${REPORT_HEADING.support}; the actions appear once; the Technique Audit Table stays on the evidence panel, and a follow-up turn follows the shape instruction in the user turn instead. `;
|
||||
const NATAL_REPORT_SKELETON = `For career, wealth, marriage, family, parents and children answers, present its Level 2 report template as a source of evidence, not as the answer's shape. ${NATAL_ANSWER_SHAPE_SUMMARY} Six-step houses and the Yoga table are sources for the one or two facts cited in parentheses; they are not pasted under a heading. The Technique Audit Table stays on the evidence panel, and a follow-up turn follows the shape instruction in the user turn instead. `;
|
||||
|
||||
function methodBlock(reportSkeleton: string) {
|
||||
return `The jyotish-vedic-astrology skill is already loaded. Its runtime method is quoted below from the live skill the operator maintains; there is no activation step, no hashed package, and no tool that loads it. Follow this method and its truth boundaries. ${reportSkeleton}Construction notes, CLI indexes, and case catalogs stay in the skill tree and are not part of this block.
|
||||
|
||||
@@ -106,8 +106,15 @@ test("the first turn of a session keeps the opener-plus-skeleton shape (control)
|
||||
assert.deepEqual(run.terminal.map((event) => event.type), ["run.completed"]);
|
||||
assert.equal(run.workflowRuns, 1);
|
||||
const writerPrompt = JSON.stringify(run.prompts[1]);
|
||||
// ① The first-turn shape: three body headings, no 先回答 heading, actions once.
|
||||
for (const heading of Object.values(REPORT_HEADING)) assert.ok(writerPrompt.includes(`## ${heading}`));
|
||||
// 原值: 写作提示含三个固定 ## 标题
|
||||
// 新值: 含「段首用人话点名」「行动不强制」「三到六段」,且不要求这些 ## 节名
|
||||
// 原因: TASK-consult-conversational-answer-20261006 D1–D3(BUG-1244)
|
||||
assert.match(writerPrompt, /段首用人话点名/);
|
||||
assert.match(writerPrompt, /行动不强制/);
|
||||
assert.match(writerPrompt, /三到六段/);
|
||||
for (const heading of Object.values(REPORT_HEADING)) {
|
||||
assert.equal(writerPrompt.includes(`## ${heading}`), false, heading);
|
||||
}
|
||||
assert.doesNotMatch(writerPrompt, /## 先回答你的问题/);
|
||||
assert.match(writerPrompt, /开场不写行动清单/);
|
||||
assert.doesNotMatch(writerPrompt, /这是追问轮/);
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import { agentObservabilityEventSchema } from "../src/lib/agent-observability.ts";
|
||||
import { streamAgentResponse } from "../src/lib/stream-agent-response.ts";
|
||||
import {
|
||||
consultationModelStepTelemetry,
|
||||
consultationStepBudgetReceipt,
|
||||
createConsultationRuntimeState,
|
||||
} from "../src/mastra/consultation-tools.ts";
|
||||
import {
|
||||
consultationModelSteps,
|
||||
consultationUsageMetadata,
|
||||
createConsultationLatencyRecord,
|
||||
formatUsageTiming,
|
||||
noteAnswerBodyCharacter,
|
||||
observeConsultationModelChunk,
|
||||
usageTimingFromLedger,
|
||||
} from "../src/lib/consultation-step-timing.ts";
|
||||
|
||||
const SECRET = "这段正文不该出现在计时里";
|
||||
|
||||
test("simulated step results fill the timing fields and leave the answer text out", () => {
|
||||
const record = createConsultationLatencyRecord();
|
||||
observeConsultationModelChunk(record, { type: "step-start" }, 1_000);
|
||||
observeConsultationModelChunk(record, {
|
||||
type: "step-finish",
|
||||
payload: {
|
||||
output: {
|
||||
text: SECRET,
|
||||
usage: { inputTokens: 11, outputTokens: 4, reasoningTokens: 9, cachedInputTokens: 2 },
|
||||
},
|
||||
},
|
||||
}, 1_600);
|
||||
observeConsultationModelChunk(record, { type: "step-start" }, 5_000);
|
||||
noteAnswerBodyCharacter(record, 5_450);
|
||||
observeConsultationModelChunk(record, {
|
||||
type: "step-finish",
|
||||
payload: {
|
||||
output: {
|
||||
text: SECRET,
|
||||
usage: { inputTokens: 20, outputTokens: 30, reasoningTokens: 100 },
|
||||
},
|
||||
},
|
||||
}, 9_000);
|
||||
|
||||
const modelSteps = consultationModelSteps(record);
|
||||
const usageMetadata = {
|
||||
classification: {
|
||||
outcome: "consult",
|
||||
usageKnown: true,
|
||||
inputTokens: 3,
|
||||
outputTokens: 1,
|
||||
durationMs: 180,
|
||||
},
|
||||
...consultationUsageMetadata({
|
||||
modelSteps,
|
||||
answerReasoningMs: record.answerReasoningMs,
|
||||
}),
|
||||
};
|
||||
const event = agentObservabilityEventSchema.parse({
|
||||
requestId: "req-timing",
|
||||
classification: { durationMs: 180 },
|
||||
modelSteps,
|
||||
answer: { reasoning_ms: record.answerReasoningMs },
|
||||
toolCalls: [{ name: "run-jyotish-consultation", durationMs: 700, status: "completed" }],
|
||||
});
|
||||
|
||||
assert.equal(event.classification?.durationMs, 180);
|
||||
assert.deepEqual(event.modelSteps?.[0], {
|
||||
index: 0,
|
||||
durationMs: 600,
|
||||
reasoningTokens: 9,
|
||||
outputTokens: 4,
|
||||
inputTokens: 11,
|
||||
cachedInputTokens: 2,
|
||||
});
|
||||
assert.deepEqual(event.modelSteps?.[1], {
|
||||
index: 1,
|
||||
durationMs: 4_000,
|
||||
reasoningTokens: 100,
|
||||
outputTokens: 30,
|
||||
inputTokens: 20,
|
||||
cachedInputTokens: null,
|
||||
});
|
||||
assert.equal(event.answer?.reasoning_ms, 450);
|
||||
assert.equal(event.toolCalls?.[0]?.durationMs, 700);
|
||||
assert.equal(usageMetadata.classification.durationMs, 180);
|
||||
assert.equal(usageMetadata.answer.reasoning_ms, 450);
|
||||
assert.equal(JSON.stringify({ event, usageMetadata }).includes(SECRET), false);
|
||||
|
||||
const ledger = usageTimingFromLedger(
|
||||
JSON.stringify(usageMetadata.modelSteps),
|
||||
"180",
|
||||
"450",
|
||||
);
|
||||
assert.equal(ledger.modelSteps[1]?.reasoningTokens, 100);
|
||||
const shown = formatUsageTiming(ledger);
|
||||
assert.match(shown, /分类 180 ms/);
|
||||
assert.match(shown, /第 1 步 4000 ms/);
|
||||
assert.match(shown, /写到正文 450 ms/);
|
||||
assert.equal(shown.includes(SECRET), false);
|
||||
});
|
||||
|
||||
test("a missing provider usage field stays null and step 0 text is not the answer clock", () => {
|
||||
const record = createConsultationLatencyRecord();
|
||||
observeConsultationModelChunk(record, { type: "step-finish" }, 2_000);
|
||||
noteAnswerBodyCharacter(record, 2_100);
|
||||
observeConsultationModelChunk(record, {
|
||||
type: "step-finish",
|
||||
payload: { output: { usage: { outputTokens: 6 } } },
|
||||
}, 3_000);
|
||||
const steps = consultationModelSteps(record);
|
||||
assert.equal(steps[0]?.durationMs, null);
|
||||
assert.equal(steps[0]?.inputTokens, null);
|
||||
assert.equal(steps[1]?.durationMs, null);
|
||||
assert.equal(steps[1]?.outputTokens, 6);
|
||||
assert.equal(steps[1]?.reasoningTokens, null);
|
||||
assert.equal(steps[1]?.cachedInputTokens, null);
|
||||
assert.equal(record.answerReasoningMs, null);
|
||||
});
|
||||
|
||||
test("the answer stream stores step timings on the run and not in the public receipt", async () => {
|
||||
const state = createConsultationRuntimeState();
|
||||
state.consultationToolCallCount = 1;
|
||||
state.consultationToolSuccessCount = 1;
|
||||
state.consultationToolCompleted = true;
|
||||
state.workflowReceipt = { route: "career", status: "ready", preciseTiming: "blocked", missingLayers: [] };
|
||||
const sentence = "事业方向的判断如下。";
|
||||
async function* chunks() {
|
||||
yield { type: "step-start" };
|
||||
yield {
|
||||
type: "step-finish",
|
||||
payload: {
|
||||
stepResult: { reason: "tool-calls" },
|
||||
output: { usage: { inputTokens: 8, outputTokens: 1, reasoningTokens: 4, cachedInputTokens: 0 }, text: sentence },
|
||||
},
|
||||
};
|
||||
yield { type: "step-start" };
|
||||
yield { type: "text-delta", payload: { text: sentence } };
|
||||
yield {
|
||||
type: "step-finish",
|
||||
payload: {
|
||||
stepResult: { reason: "stop" },
|
||||
output: { usage: { reasoningTokens: 12 }, text: sentence },
|
||||
},
|
||||
};
|
||||
yield { type: "finish", payload: { stepResult: { reason: "stop" }, output: { usage: {}, steps: [{}, {}] } } };
|
||||
}
|
||||
const response = streamAgentResponse({
|
||||
runId: "run",
|
||||
requestId: "req",
|
||||
state,
|
||||
stream: chunks(),
|
||||
requireTool: true,
|
||||
toolStatus: () => "ready",
|
||||
receipt: () => ({
|
||||
runId: "run",
|
||||
runtime: "mastra-agentic" as const,
|
||||
skill: {
|
||||
name: "jyotish-vedic-astrology" as const,
|
||||
loaded: state.jyotishSkillBound,
|
||||
referenceReads: state.skillReferenceReadCount,
|
||||
methodologySections: state.methodologySectionCount,
|
||||
},
|
||||
steps: state.steps,
|
||||
stepBudget: consultationStepBudgetReceipt(state),
|
||||
workflow: state.workflowReceipt!,
|
||||
}),
|
||||
});
|
||||
const body = await response.text();
|
||||
const telemetry = consultationModelStepTelemetry(state);
|
||||
const event = agentObservabilityEventSchema.parse({ requestId: "req-stream", ...telemetry });
|
||||
assert.equal(event.modelSteps?.[0]?.inputTokens, 8);
|
||||
assert.equal(event.modelSteps?.[0]?.cachedInputTokens, 0);
|
||||
assert.equal(event.modelSteps?.[0]?.reasoningTokens, 4);
|
||||
assert.equal(event.modelSteps?.[1]?.reasoningTokens, 12);
|
||||
assert.equal(event.modelSteps?.[1]?.inputTokens, null);
|
||||
assert.equal(typeof event.answer?.reasoning_ms, "number");
|
||||
assert.equal(JSON.stringify(event).includes(sentence), false);
|
||||
assert.equal(body.includes("modelSteps"), false);
|
||||
assert.equal(body.includes("reasoning_ms"), false);
|
||||
});
|
||||
|
||||
test("the consult route and the usage list keep the same timing fields", () => {
|
||||
const route = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8");
|
||||
const list = readFileSync(new URL("../src/app/api/admin/usage/route.ts", import.meta.url), "utf8");
|
||||
const page = readFileSync(new URL("../src/components/admin/billing-operations-resources.tsx", import.meta.url), "utf8");
|
||||
assert.match(route, /classification: \{ durationMs: observation\.durationMs \}/);
|
||||
assert.match(route, /durationMs: classificationDurationMs/);
|
||||
assert.match(route, /consultationUsageMetadata/);
|
||||
assert.match(list, /metadata->'cache'->>'readTokens'/);
|
||||
assert.match(list, /metadata->'modelSteps'/);
|
||||
assert.match(list, /metadata->'classification'->>'durationMs'/);
|
||||
assert.match(list, /metadata->'answer'->>'reasoning_ms'/);
|
||||
assert.match(page, /分段/);
|
||||
assert.doesNotMatch(list, /alter table|create table/i);
|
||||
});
|
||||
@@ -280,7 +280,15 @@ test("the model call that writes the answer has the calculation result in its pr
|
||||
test("the writing instructions travel with the user turn the loop sees", async () => {
|
||||
const run = await runNatal([calcCall(), { parts: pieces(ANSWER), finish: "stop" }]);
|
||||
const writerPrompt = JSON.stringify(run.prompts[1]);
|
||||
for (const heading of Object.values(REPORT_HEADING)) assert.ok(writerPrompt.includes(`## ${heading}`));
|
||||
// 原值: 写作提示含三个固定 ## 标题
|
||||
// 新值: 含「段首用人话点名」「行动不强制」「三到六段」,且不要求这些 ## 节名
|
||||
// 原因: TASK-consult-conversational-answer-20261006 D1–D3(BUG-1244)。夹具正文仍带旧标题,只用来测续写与放出,不是新形状。
|
||||
assert.match(writerPrompt, /段首用人话点名/);
|
||||
assert.match(writerPrompt, /行动不强制/);
|
||||
assert.match(writerPrompt, /三到六段/);
|
||||
for (const heading of Object.values(REPORT_HEADING)) {
|
||||
assert.equal(writerPrompt.includes(`## ${heading}`), false, heading);
|
||||
}
|
||||
assert.match(writerPrompt, /开场不要标题/);
|
||||
// The removed compose prompt claimed a finished calculation it never saw.
|
||||
assert.doesNotMatch(writerPrompt, /服务器计算已经完成。不要再调用排盘工具/);
|
||||
|
||||
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { chartCalculationProgressLabel } from "../src/lib/consultation-activity-labels.ts";
|
||||
import type { ConsultationAgentPublicEvent } from "../src/lib/consultation-agent-events.ts";
|
||||
import { generalConsultationThinkingPlan, natalConsultationThinkingPlan } from "../src/lib/consultation-thinking-plan.ts";
|
||||
import {
|
||||
consultationTimelineFromSettled,
|
||||
@@ -157,6 +158,40 @@ test("a finished run leaves no outline row and nothing live", () => {
|
||||
assert.equal(state.rows.some((row) => row.kind === "think"), false);
|
||||
});
|
||||
|
||||
test("a heading-free first turn uses the open writing row and settles as 组织回答 (BUG-1244)", () => {
|
||||
const text = "你和妈妈这条线近,来往多。\n爸爸那边有距离的迹象。\n";
|
||||
const prelude: ConsultationAgentPublicEvent[] = [
|
||||
{ type: "skill.started", name: "jyotish-vedic-astrology" },
|
||||
{ type: "skill.completed", name: "jyotish-vedic-astrology" },
|
||||
{ type: "tool.started", callId: "tool-1", tool: "run-jyotish-consultation", label: "正在计算本命盘…" },
|
||||
{ type: "answer.delta", text },
|
||||
];
|
||||
const streaming = reduceConsultationTimelineEvents(prelude);
|
||||
const liveWrite = streaming.rows.filter((row) => row.kind === "write");
|
||||
assert.deepEqual(liveWrite.map((row) => row.id), ["write-open"]);
|
||||
assert.equal(liveWrite[0]?.status, "live");
|
||||
assert.match(liveWrite[0]?.label ?? "", /正在组织回答/);
|
||||
const settled = reduceConsultationTimelineEvents([
|
||||
...prelude,
|
||||
{
|
||||
type: "run.completed",
|
||||
receipt: {
|
||||
runId: "run-1",
|
||||
runtime: "mastra-agentic",
|
||||
skill: { name: "jyotish-vedic-astrology", loaded: true, referenceReads: 0, methodologySections: 0 },
|
||||
steps: [],
|
||||
workflow: { route: "parents", status: "ready", preciseTiming: "blocked", missingLayers: [] },
|
||||
},
|
||||
},
|
||||
]);
|
||||
const doneWrite = settled.rows.filter((row) => row.kind === "write");
|
||||
assert.deepEqual(doneWrite.map((row) => row.id), ["write-open"]);
|
||||
assert.equal(doneWrite[0]?.status, "done");
|
||||
assert.equal(doneWrite[0]?.label, "组织回答");
|
||||
assert.equal(settled.rows.some((row) => (row.label ?? "").includes("这周可以做什么")), false);
|
||||
assert.equal(settled.rows.every((row) => row.status === "done"), true);
|
||||
});
|
||||
|
||||
test("a stored natal answer rebuilds the same rows as live: no outline rows", () => {
|
||||
const plan = natalConsultationThinkingPlan({ domains: ["parents"] });
|
||||
const rows = consultationTimelineFromSettled({
|
||||
|
||||
@@ -232,10 +232,10 @@ test("route gates all special entries before a single selected-model classifier
|
||||
test("all three agent voices share the exemption, natal domain skeleton stays mandatory", () => {
|
||||
assert.match(productConversationVoice, /只是打招呼、道谢、告别时,回一句话,不要套开场形状/);
|
||||
assert.doesNotMatch(natalSpokenReportContract, /Short chit-chat/);
|
||||
// 原值: /One natal career\/wealth\/marriage\/family question still uses the opener-plus-skeleton/
|
||||
// 新值: 首轮仍用开场 + 骨架;追问轮按用户轮里的指令直接回答(骨架对首轮仍是强制的)
|
||||
// 原因: TASK-consult-answer-the-question-20260927 D3 / BUG-1071:追问轮不再重开整套骨架
|
||||
assert.match(natalSpokenReportContract, /The first natal career\/wealth\/marriage\/family answer of a session uses the opener-plus-skeleton above/);
|
||||
// 原值: /The first natal career\/wealth\/marriage\/family answer of a session uses the opener-plus-skeleton above/
|
||||
// 新值: 首轮用段落形状;追问轮仍按用户轮里的指令直接回答
|
||||
// 原因: TASK-consult-conversational-answer-20261006 D1(BUG-1244):首轮骨架取消,追问轮形状保留
|
||||
assert.match(natalSpokenReportContract, /The first natal career\/wealth\/marriage\/family answer of a session uses the paragraph shape above/);
|
||||
assert.match(natalSpokenReportContract, /a follow-up turn follows the shape instruction carried in the user turn/);
|
||||
const index = source("../src/mastra/index.ts");
|
||||
assert.ok((index.match(/\$\{productConversationVoice\}/g) ?? []).length >= 3);
|
||||
|
||||
@@ -39,15 +39,15 @@ test("multi-domain thinking plan uses Chinese product labels and hides tool ids"
|
||||
mustUseLayers: ["D1", "run-jyotish-consultation", "D10", "skill_read"],
|
||||
});
|
||||
const encoded = JSON.stringify(sections);
|
||||
// 原值: 四节 heading = [question, support, timing, action]
|
||||
// 新值: 开场节的 heading 与「盘里支持」共用(开场没有自己的标题,第一个正文 H2 出现即算写完);正文三节
|
||||
// 原因: TASK-consult-answer-the-question-20260927 D5/D8(BUG-1072/1073):删「先回答你的问题」节,开场节标题改「先回答你问的这件事」
|
||||
// 原值: 四节 heading = [support, support, timing, action],含「这周可以做什么」
|
||||
// 新值: 三节 heading = [support, support, timing],不再有 action 节
|
||||
// 原因: TASK-consult-conversational-answer-20261006 T3(BUG-1244):思考栏去掉「这周可以做什么」;heading 字段仍是 schema 必填,留给旧答案对上标题
|
||||
assert.deepEqual(sections.map((section) => section.heading), [
|
||||
REPORT_HEADING.support,
|
||||
REPORT_HEADING.support,
|
||||
REPORT_HEADING.timing,
|
||||
REPORT_HEADING.action,
|
||||
]);
|
||||
assert.equal(JSON.stringify(sections).includes("这周可以做什么"), false);
|
||||
assert.equal(sections[0]?.title, OPENER_THINKING_TITLE);
|
||||
assert.equal(sections[0]?.title, "先回答你问的这件事");
|
||||
assert.equal(JSON.stringify(sections).includes("先抓住你真正在问的事"), false);
|
||||
@@ -115,18 +115,29 @@ test("settled answers mark leftover thinking sections done", () => {
|
||||
|
||||
test("progress marks the current heading active and completed ones done", () => {
|
||||
const sections = natalConsultationThinkingPlan({ domains: ["career"] });
|
||||
// 原值: 写出 `## 先回答你的问题` → 首节 done、第二节 active
|
||||
// 新值: 口语开场还没有标题时首节 active;写出 `## 盘里支持` 后开场节与依据节都 done、时间节 active
|
||||
// 原因: 开场没有自己的 H2,它与依据节共用标题(D8)
|
||||
// 原值: 口语开场还没有标题时首节 active
|
||||
// 新值: 整篇没有二级标题时没有任何一节是 active(避免首轮一直卡在转圈)
|
||||
// 原因: TASK-consult-conversational-answer-20261006 T3(BUG-1244):首轮正文不写标题,进度改走结算时全部完成
|
||||
const opening = applyThinkingSectionProgress(sections, "外松内紧。\n");
|
||||
assert.equal(opening[0]?.steps[0]?.status, "active");
|
||||
assert.equal(opening[1]?.steps[0]?.status, "pending");
|
||||
assert.equal(opening.some((section) => section.steps.some((step) => step.status === "active")), false);
|
||||
assert.equal(opening[0]?.steps[0]?.status, "pending");
|
||||
const progressed = applyThinkingSectionProgress(sections, `外松内紧。\n## ${REPORT_HEADING.support}\n事业宫被土星压着。\n`);
|
||||
assert.equal(progressed[0]?.steps[0]?.status, "done");
|
||||
assert.equal(progressed[1]?.steps[0]?.status, "done");
|
||||
assert.equal(progressed[2]?.steps[0]?.status, "active");
|
||||
});
|
||||
|
||||
test("a heading-free first turn stays unstuck while streaming and finishes every section when settled (BUG-1244)", () => {
|
||||
const sections = natalConsultationThinkingPlan({ domains: ["career"] });
|
||||
const text = "你今年事业上机会不少,同时事情多、要求严。\n现在这十年偏向慢慢攒。\n";
|
||||
const streaming = applyThinkingSectionProgress(sections, text);
|
||||
assert.equal(streaming.some((section) => section.steps.some((step) => step.status === "active")), false);
|
||||
assert.equal(streaming.every((section) => section.steps.every((step) => step.status === "pending")), true);
|
||||
const settled = applyThinkingSectionProgress(sections, text, { settled: true });
|
||||
assert.equal(settled.every((section) => section.steps.every((step) => step.status === "done")), true);
|
||||
assert.equal(JSON.stringify(sections).includes("这周可以做什么"), false);
|
||||
});
|
||||
|
||||
test("the opener section closes at the first per-person heading and skipped optional headings close behind later ones (BUG-1132)", () => {
|
||||
const sections = natalConsultationThinkingPlan({ domains: ["parents"] });
|
||||
const opener = "你和父母不疏远,但亲近主要落在为你的前途操心上。\n";
|
||||
@@ -135,29 +146,46 @@ test("the opener section closes at the first per-person heading and skipped opti
|
||||
assert.equal(onPerson[1]?.steps[0]?.status, "active");
|
||||
// 盘上依据 omitted: once 时间怎么看 shows, the support section is behind it.
|
||||
const onTiming = applyThinkingSectionProgress(sections, `${opener}## 你和妈妈\n上心。\n## ${REPORT_HEADING.timing}\n这十年。\n`);
|
||||
assert.deepEqual(onTiming.map((section) => section.steps[0]?.status), ["done", "done", "done", "active"]);
|
||||
// 原值: ["done", "done", "done", "active"](时间节之后还有行动节)
|
||||
// 新值: ["done", "done", "done"](行动节已删,时间节是最后一节)
|
||||
// 原因: TASK-consult-conversational-answer-20261006 T3(BUG-1244)
|
||||
assert.deepEqual(onTiming.map((section) => section.steps[0]?.status), ["done", "done", "done"]);
|
||||
});
|
||||
|
||||
test("continue prompt asks to resume after the last complete heading", () => {
|
||||
const prompt = consultationContinuePrompt("## 事业\n方向稳定。\n");
|
||||
assert.match(prompt, /最后一个完整标题是:## 事业/);
|
||||
// 续写的标题清单跟着正文三标题走,不再让续写补出「先回答你的问题」(D8)。
|
||||
// 原值: /最后一个完整标题是:## 事业/,并要求续写补三个固定二级标题
|
||||
// 新值: 认出已写出的最后一个二级标题,但要求改用段落续写,不再点名三个固定标题
|
||||
// 原因: TASK-consult-conversational-answer-20261006 D1(BUG-1244):首轮不再有固定节名
|
||||
assert.match(prompt, /最后一个二级标题是:## 事业/);
|
||||
assert.doesNotMatch(prompt, /## 先回答你的问题、/);
|
||||
assert.match(prompt, /不要写「先回答你的问题」这个标题/);
|
||||
assert.match(prompt, new RegExp(`## ${REPORT_HEADING.support}、## ${REPORT_HEADING.timing}、## ${REPORT_HEADING.action}`));
|
||||
assert.doesNotMatch(prompt, new RegExp(`## ${REPORT_HEADING.support}、## ${REPORT_HEADING.timing}`));
|
||||
assert.match(prompt, /不要再补新的二级标题/);
|
||||
assert.match(prompt, /不要重复已写出的段落/);
|
||||
assert.match(prompt, /不要写思考过程清单/);
|
||||
});
|
||||
|
||||
test("natal spoken answers must use markdown lists for parallel points", () => {
|
||||
assert.match(consultationSpokenHeadingRule("natal"), /Markdown bullet lists/);
|
||||
test("natal spoken answers default to paragraphs, not bold-label lists (BUG-1244)", () => {
|
||||
// 原值: /Markdown bullet lists/(并列点必须是加粗标签列表)
|
||||
// 新值: 默认写段落;只有用户要求列举或有三个以上时间窗才用列表
|
||||
// 原因: TASK-consult-conversational-answer-20261006 D5
|
||||
const natal = consultationSpokenHeadingRule("natal");
|
||||
assert.match(natal, /Write paragraphs/);
|
||||
assert.match(natal, /three or more candidate time windows/);
|
||||
assert.doesNotMatch(natal, /Markdown bullet lists/);
|
||||
assert.match(consultationSpokenHeadingRule("daily"), /Markdown bullet lists/);
|
||||
assert.doesNotMatch(consultationSpokenHeadingRule("window"), /start with ## /);
|
||||
});
|
||||
|
||||
test("think plan flattens natal sections into v2 steps", () => {
|
||||
const steps = thinkPlanSteps(natalConsultationThinkingPlan({
|
||||
domains: ["career", "wealth"],
|
||||
}));
|
||||
assert.deepEqual(steps.map((step) => step.id), ["question", "support", "timing", "action"]);
|
||||
// 原值: ["question", "support", "timing", "action"]
|
||||
// 新值: ["question", "support", "timing"]
|
||||
// 原因: TASK-consult-conversational-answer-20261006 T3(BUG-1244):删掉「这周可以做什么」
|
||||
assert.deepEqual(steps.map((step) => step.id), ["question", "support", "timing"]);
|
||||
});
|
||||
|
||||
test("section prompt asks for one heading and forbids another calculation", () => {
|
||||
@@ -169,24 +197,28 @@ test("section prompt asks for one heading and forbids another calculation", () =
|
||||
const natal = natalAnswerShapeInstruction();
|
||||
assert.match(natal, /只用结果里的盘面事实/);
|
||||
assert.match(natal, /开场不要标题/);
|
||||
// 原值: 一次写完四个标题(含 ## 先回答你的问题)
|
||||
// 新值: 三个标题;开场不写行动、行动只在最后一节出现一次、全文不超过 900 字、不写「先回答你的问题」
|
||||
// 原因: TASK-consult-answer-the-question-20260927 D8 / BUG-1073
|
||||
for (const heading of Object.values(REPORT_HEADING)) assert.ok(natal.includes(`## ${heading}`));
|
||||
// 原值: 三个固定标题都写进首轮指令(## 盘上依据 / 时间怎么看 / 这周可以做的一件事)
|
||||
// 新值: 首轮指令不要求任何 ## 节名;多人分段落、行动不强制、通常三到六段
|
||||
// 原因: TASK-consult-conversational-answer-20261006 D1–D3/D5(BUG-1244)
|
||||
assert.doesNotMatch(natal, /## (盘上依据|时间怎么看|这周|你和妈妈)/);
|
||||
assert.match(natal, /不写 ##/);
|
||||
assert.match(natal, /段首用人话点名/);
|
||||
assert.match(natal, /行动不强制/);
|
||||
assert.match(natal, /三到六段/);
|
||||
assert.equal(Object.values(REPORT_HEADING).length, 3);
|
||||
// 原值: assert.match(natal, /这三个二级标题/)
|
||||
// 新值: 先按对象分段,再「依次写」三个固定标题(前两个可省)
|
||||
// 原因: TASK-consult-plain-answer-20261001 D2(BUG-1132):问到几个人就分几段,固定标题跟在后面
|
||||
// 原值: assert.match(natal, /## 你和妈妈、## 你和爸爸、## 他们怎么对你/) 与 /然后依次写:/
|
||||
// 新值: 段首点名,不写二级标题;仍要求父母分开写
|
||||
// 原因: 同上 D1:按对象的二级标题一并取消
|
||||
assert.match(natal, /问题里点到几个人或几个子问题/);
|
||||
assert.match(natal, /## 你和妈妈、## 你和爸爸、## 他们怎么对你/);
|
||||
assert.match(natal, /「你妈妈这边……」「爸爸那边……」/);
|
||||
assert.match(natal, /问父母时妈妈和爸爸分开写/);
|
||||
assert.match(natal, /然后依次写:/);
|
||||
assert.doesNotMatch(natal, /然后依次写:/);
|
||||
assert.match(natal, /开场不写行动清单/);
|
||||
assert.match(natal, /只在这里出现一次/);
|
||||
// 原值: assert.match(natal, /全文不超过 900 字/)
|
||||
// 新值: 不限字数、讲清楚为准,且不得出现任何「不超过 N 字」
|
||||
// 原因: TASK-consult-plain-answer-20261001 D8:产品取消字数上限(推翻 BUG-1073 的 900 字)
|
||||
assert.match(natal, /不限字数,讲清楚为准;同一件事只说一遍/);
|
||||
assert.match(natal, /全篇最多一处/);
|
||||
// 原值: assert.match(natal, /不限字数,讲清楚为准;同一件事只说一遍/)
|
||||
// 新值: 首轮通常三到六段、不设硬截断;仍不得出现「不超过 N 字」
|
||||
// 原因: TASK-consult-conversational-answer-20261006 D3:推翻 10-01「首轮不限字数」
|
||||
assert.match(natal, /不设硬截断。同一件事只说一遍/);
|
||||
assert.doesNotMatch(natal, /不超过 \d+ 字/);
|
||||
assert.match(natal, /不要写「先回答你的问题」这个标题/);
|
||||
// 原值: assert.match(natal, /反差只写盘上的结构/)
|
||||
@@ -294,7 +326,10 @@ test("a follow-up turn answers directly and the thinking bar says so (TASK-consu
|
||||
const plan = natalConsultationThinkingPlan({ domains: ["parents"], followUp: true });
|
||||
assert.equal(plan[0]?.title, OPENER_THINKING_TITLE);
|
||||
assert.equal(plan[0]?.steps[0]?.label, "直接回答这句话");
|
||||
assert.deepEqual(thinkPlanSteps(plan).map((step) => step.id), ["question", "support", "timing", "action"]);
|
||||
// 原值: ["question", "support", "timing", "action"]
|
||||
// 新值: ["question", "support", "timing"]
|
||||
// 原因: TASK-consult-conversational-answer-20261006 T3(BUG-1244)
|
||||
assert.deepEqual(thinkPlanSteps(plan).map((step) => step.id), ["question", "support", "timing"]);
|
||||
const settled = applyThinkingSectionProgress(plan, "不是。4 宫主落 10 宫,她把心力投在你的前途上。", { settled: true });
|
||||
assert.equal(settled.every((section) => section.steps.every((step) => step.status === "done")), true);
|
||||
});
|
||||
|
||||
@@ -38,18 +38,23 @@ test("product voice uses a spoken opener then the skill Level 2 report skeleton"
|
||||
// 新值(2): 两句都不得再出现;改锁「不设字数上限,讲清楚为准」
|
||||
// 原因(2): TASK-consult-plain-answer-20261001 D1/D8:扮演象修辞下线,产品取消字数上限
|
||||
assert.doesNotMatch(voice, /扮演|≤ 400|Spoken-layer total/);
|
||||
assert.match(voice, /不设字数上限,讲清楚为准/);
|
||||
assert.match(voice, /骨架不可省略,但必须以直接回应开场/);
|
||||
// 原值: assert.match(voice, /不设字数上限,讲清楚为准/) 与 /骨架不可省略,但必须以直接回应开场/
|
||||
// 新值: 首轮通常三到六段、不设硬截断;合同里不再有「骨架不可省略」
|
||||
// 原因: TASK-consult-conversational-answer-20261006 D1/D3(BUG-1244)
|
||||
assert.match(voice, /首轮通常三到六段/);
|
||||
assert.match(voice, /不设硬截断/);
|
||||
assert.doesNotMatch(voice, /骨架不可省略/);
|
||||
|
||||
assert.match(mastra, /productConversationVoice/);
|
||||
assert.match(mastra, /natalSpokenReportContract/);
|
||||
assert.match(mastra, /skill Level 2 report skeleton/);
|
||||
assert.match(mastra, /Do not paste the Technique Audit Table into the answer body/);
|
||||
assert.match(binding, /Follow this method and its truth boundaries/);
|
||||
// 原值: assert.match(binding, /present its Level 2 report template in the chat body after a 3-6 sentence spoken reply/)
|
||||
// 新值: Level 2 模板是回答形状的依据层,形状引用 NATAL_ANSWER_SHAPE_SUMMARY
|
||||
// 原因: TASK-consult-plain-answer-20261001 D2/D7:先答、按对象分段,Level 2 内容落在「盘上依据」
|
||||
assert.match(binding, /present its Level 2 report template as the evidence of the product's answer shape, not as the shape itself\. \$\{NATAL_ANSWER_SHAPE_SUMMARY\}/);
|
||||
// 原值: assert.match(binding, /present its Level 2 report template as the evidence of the product's answer shape, not as the shape itself/)
|
||||
// 新值: Level 2 模板只是依据来源,六步宫位和 Yoga 表不贴进正文标题下
|
||||
// 原因: TASK-consult-conversational-answer-20261006 D1/D6(BUG-1244)
|
||||
assert.match(binding, /present its Level 2 report template as a source of evidence, not as the answer's shape\. \$\{NATAL_ANSWER_SHAPE_SUMMARY\}/);
|
||||
assert.match(binding, /they are not pasted under a heading/);
|
||||
assert.match(mastra, /The bound skill method is this product's answering contract/);
|
||||
assert.match(mastra, /Do not use a restricted technique/);
|
||||
// 原值: assert.match(mastra, /must_use_layers is the executed shortlist/)
|
||||
@@ -64,11 +69,11 @@ test("product voice uses a spoken opener then the skill Level 2 report skeleton"
|
||||
// 新值: 正文只有三个标题,「先回答你的问题」不再出现在合同里;开场不带行动、行动只在最后一节、首轮 ≤ 900 字
|
||||
// 原因: TASK-consult-answer-the-question-20260927 D8 / BUG-1073:开场就是回答,同一判断不再说三遍
|
||||
assert.doesNotMatch(voice, /## 先回答你的问题/);
|
||||
// 原值: assert.match(voice, /## 盘里支持这个判断的地方, then ## 时间怎么看, then ## 这周可以做的一件事/)
|
||||
// 新值: 按对象分段之后才是三个固定标题,标题取自 REPORT_HEADING(「盘里支持这个判断的地方」改名「盘上依据」),前两个可省
|
||||
// 原因: TASK-consult-plain-answer-20261001 D2/D7(BUG-1132)
|
||||
assert.match(voice, /One section per person or sub-question the user named \(妈妈 and 爸爸 are two sections, never one\)/);
|
||||
assert.match(voice, /## \$\{REPORT_HEADING\.support\}[\s\S]*Omit the section when there is nothing new to cite[\s\S]*## \$\{REPORT_HEADING\.timing\}[\s\S]*## \$\{REPORT_HEADING\.action\}/);
|
||||
// 原值: assert.match(voice, /## ${REPORT_HEADING.support}…## ${REPORT_HEADING.timing}…## ${REPORT_HEADING.action}/)
|
||||
// 新值: 多人各写一段、段首点名;合同不再要求三个固定 ## 标题
|
||||
// 原因: TASK-consult-conversational-answer-20261006 D1(BUG-1244)
|
||||
assert.match(voice, /When the user names more than one person, one paragraph each \(妈妈 and 爸爸 are two paragraphs, never one\)/);
|
||||
assert.doesNotMatch(voice, /## \$\{REPORT_HEADING/);
|
||||
// 原值: assert.match(voice, /The opener carries no action list/)
|
||||
// 新值: 「开场不写行动」写在共享 ANSWER SHAPE 第 1 步与形状摘要里
|
||||
// 原因: TASK-consult-plain-answer-20261001 D2:合同不再复述开场细节,引用唯一定义
|
||||
@@ -78,12 +83,19 @@ test("product voice uses a spoken opener then the skill Level 2 report skeleton"
|
||||
// 新值: 没有字数上限,写到讲清楚为止
|
||||
// 原因: TASK-consult-plain-answer-20261001 D8:产品取消字数上限(推翻 BUG-1073 的 900 字,保留「只说一遍」)
|
||||
assert.doesNotMatch(voice, /900/);
|
||||
assert.match(voice, /There is no length cap: write until it is clear, and stop there/);
|
||||
// 原值: /cite only the fields that support this judgment, and only what the opener did not already say/
|
||||
// 新值: 「只写开场与对象段都没引过的」
|
||||
// 原因: TASK-consult-plain-answer-20261001 D2:对象段已经在括号里给过依据
|
||||
assert.match(voice, /cite only the fields that support this judgment, and only what the opener and the sections did not already cite/);
|
||||
assert.match(voice, /One to three actions for this week, here and nowhere else/);
|
||||
// 原值: assert.match(voice, /There is no length cap: write until it is clear, and stop there/)
|
||||
// 新值: 首轮通常三到六段,不设硬截断
|
||||
// 原因: TASK-consult-conversational-answer-20261006 D3(BUG-1244)
|
||||
assert.match(voice, /The first answer is usually three to six paragraphs/);
|
||||
assert.match(voice, /There is no hard cutoff/);
|
||||
// 原值: /cite only the fields that support this judgment, and only what the opener and the sections did not already cite/
|
||||
// 新值: 依据留在括号里,六步宫位和 Yoga 表是来源,不是一节
|
||||
// 原因: TASK-consult-conversational-answer-20261006 D6(BUG-1244)
|
||||
assert.match(voice, /Raman six-step house judgment and the Yoga table are sources/);
|
||||
// 原值: assert.match(voice, /One to three actions for this week, here and nowhere else/)
|
||||
// 新值: 行动不强制,盘上指向具体事情时才顺口一句
|
||||
// 原因: TASK-consult-conversational-answer-20261006 D2(BUG-1244)
|
||||
assert.match(voice, /An action is not required/);
|
||||
assert.doesNotMatch(voice, /One natal career\/wealth\/marriage\/family question still uses the opener-plus-skeleton above/);
|
||||
assert.match(voice, /a follow-up turn follows the shape instruction carried in the user turn/);
|
||||
// 原值: assert.match(mastra, /each thing said once, first-turn total ≤ 900 characters/)
|
||||
@@ -96,10 +108,10 @@ test("product voice uses a spoken opener then the skill Level 2 report skeleton"
|
||||
// 新值: 同一句改由引用的 NATAL_ANSWER_SHAPE_SUMMARY 携带(见下方 shape-summary 测试)
|
||||
// 原因: TASK-consult-plain-answer-20261001 D7:形状只定义一处
|
||||
assert.doesNotMatch(binding, /反差|扮演|≤ 400/);
|
||||
// 原值: assert.match(binding, /the actions, once/)
|
||||
// 新值: assert.match(binding, /the actions appear once/)
|
||||
// 原因: TASK-consult-plain-answer-20261001 D7:骨架句改写,语义不变
|
||||
assert.match(binding, /the actions appear once/);
|
||||
// 原值: assert.match(binding, /the actions appear once/)
|
||||
// 新值: 骨架句不再要求行动出现一次;行动是否写由形状摘要决定
|
||||
// 原因: TASK-consult-conversational-answer-20261006 D2(BUG-1244)
|
||||
assert.doesNotMatch(binding, /the actions appear once/);
|
||||
assert.match(mastra, /consultationSpokenHeadingRule\("natal"\)/);
|
||||
assert.match(workflow, /template: "skill_level_2"/);
|
||||
assert.match(workflow, /raman_six_step/);
|
||||
@@ -156,17 +168,29 @@ test("consult user turn no longer dumps tool JSON that the model would parrot",
|
||||
test("the answer is a shape — conclusion first, one section per person, plain words checked, no length cap (BUG-1132)", () => {
|
||||
assert.match(voice, /ANSWER SHAPE \(natal \/ general \/ declared-window 三种模式共用\)/);
|
||||
assert.doesNotMatch(voice, /OPENER SHAPE|表面 A,底下 B|谁在推|命名成一个格局|外松内紧/);
|
||||
assert.match(voice, /不设字数上限,讲清楚为准。同一件事只说一遍/);
|
||||
// 原值: assert.match(voice, /不设字数上限,讲清楚为准。同一件事只说一遍/)
|
||||
// 新值: 首轮三到六段、不设硬截断;追问轮仍讲清楚为准;同一件事只说一遍
|
||||
// 原因: TASK-consult-conversational-answer-20261006 D3(BUG-1244)
|
||||
assert.match(voice, /首轮通常三到六段,讲清结论、最关键的一两条依据,相关时带上时间,细节等追问;不设硬截断。追问轮讲清楚为准。同一件事只说一遍/);
|
||||
assert.match(voice, /开场一到三句人话,第一句就是结论/);
|
||||
assert.match(voice, /按对象分段:问题里点到几个对象/);
|
||||
// 原值: /按对象分段:问题里点到几个对象/
|
||||
// 新值: /按对象分段落:问题里点到几个对象/,段首点名、不写二级标题
|
||||
// 原因: TASK-consult-conversational-answer-20261006 D1(BUG-1244)
|
||||
assert.match(voice, /按对象分段落:问题里点到几个对象/);
|
||||
assert.match(voice, /段首用人话点名/);
|
||||
assert.match(voice, /问「父母」时妈妈和爸爸分开写,不揉成一段。只有一个对象、一个子问题时不拆段/);
|
||||
assert.match(voice, /然后在括号里给出撑住这段判断的盘上依据/);
|
||||
assert.match(voice, /每段最多一处括号/);
|
||||
assert.match(voice, /人话自检:把每句话括号里的内容删掉,一个完全不懂占星的人仍然能看懂它在说什么/);
|
||||
for (const banned of ["自造格局名、四字概括", "把大运、行运写成拟人的角色", "把星名当形容词的主人", "括号外每段最多一个占星术语"]) {
|
||||
assert.ok(voice.includes(banned), banned);
|
||||
}
|
||||
assert.match(voice, /推断边界:空宫不单独下结论——要说这个宫,就说它的宫主落在哪、什么状态/);
|
||||
assert.match(voice, /一到三条破折号短句/);
|
||||
// 原值: assert.match(voice, /一到三条破折号短句/)
|
||||
// 新值: 行动不强制
|
||||
// 原因: TASK-consult-conversational-answer-20261006 D2(BUG-1244)
|
||||
assert.match(voice, /行动不强制:只有盘上确实指向一件和问题相关的具体事情时/);
|
||||
assert.doesNotMatch(voice, /一到三条破折号短句/);
|
||||
// 降级路线:申报时段与无出生分钟都不编月份;形状照给。
|
||||
assert.match(voice, /申报时段(declared birth window):依据只取窗口内稳定的那一层[\s\S]*?也不写月份。/);
|
||||
assert.match(voice, /无出生分钟(no birth minute):时间只讲公开日历这一天的大势,不点个人大运,不写月份/);
|
||||
@@ -177,14 +201,18 @@ test("one shape definition: contract, thinking plan, user turn and agent prompts
|
||||
const { NATAL_ANSWER_SHAPE_SUMMARY, REPORT_HEADING, consultationSpokenHeadingRule, natalAnswerShapeBody } = await import("../src/lib/consultation-thinking-plan.ts");
|
||||
const { natalSpokenReportContract } = await import("../src/mastra/product-voice.ts");
|
||||
assert.equal(REPORT_HEADING.support, "盘上依据");
|
||||
// 原值: 形状摘要、合同、首轮正文都含 `## ${heading}`
|
||||
// 新值: 三处都引用同一份无标题摘要,且不要求这些 ## 节名
|
||||
// 原因: TASK-consult-conversational-answer-20261006 D1/D7(BUG-1244)
|
||||
for (const heading of Object.values(REPORT_HEADING)) {
|
||||
assert.ok(NATAL_ANSWER_SHAPE_SUMMARY.includes(`## ${heading}`), heading);
|
||||
assert.ok(natalSpokenReportContract.includes(`## ${heading}`), heading);
|
||||
assert.ok(natalAnswerShapeBody().includes(`## ${heading}`), heading);
|
||||
assert.equal(NATAL_ANSWER_SHAPE_SUMMARY.includes(`## ${heading}`), false, heading);
|
||||
assert.equal(natalSpokenReportContract.includes(`## ${heading}`), false, heading);
|
||||
assert.equal(natalAnswerShapeBody().includes(`## ${heading}`), false, heading);
|
||||
}
|
||||
assert.ok(natalSpokenReportContract.includes(NATAL_ANSWER_SHAPE_SUMMARY));
|
||||
assert.ok(consultationSpokenHeadingRule("natal").includes(NATAL_ANSWER_SHAPE_SUMMARY));
|
||||
assert.match(NATAL_ANSWER_SHAPE_SUMMARY, /There is no length cap/);
|
||||
assert.match(NATAL_ANSWER_SHAPE_SUMMARY, /usually three to six paragraphs/);
|
||||
assert.match(NATAL_ANSWER_SHAPE_SUMMARY, /no hard cutoff/);
|
||||
assert.match(NATAL_ANSWER_SHAPE_SUMMARY, /no conclusion from an empty house alone/);
|
||||
for (const text of [NATAL_ANSWER_SHAPE_SUMMARY, natalSpokenReportContract, natalAnswerShapeBody(), consultationSpokenHeadingRule("natal")]) {
|
||||
assert.doesNotMatch(text, /扮演|谁在推|命名成一个格局|外松内紧|≤ 400|≤ 900|不超过 \d+ 字/);
|
||||
@@ -220,14 +248,20 @@ test("the pinned Good examples split by person and stay plain; the coined-patter
|
||||
// 原值: indexOf("Good (父母题首轮,按对象分段;盘上数据是示意)") / indexOf("Good (单对象事业题首轮,不拆段;盘上数据是示意)")
|
||||
// 新值: 两个标签改为「括号里是依据的占位,只演示口气与结构,不是读法」,事业例再加「不预设读者在上班、做哪类工作」
|
||||
// 原因: TASK-consult-no-presupposition-and-backtest-20261001 D3/D7(BUG-1165/1168):示例不得携带读法规则与职业形态
|
||||
const parentsStart = voice.indexOf("Good (父母题首轮,按对象分段;括号里是依据的占位,只演示口气与结构,不是读法)");
|
||||
// 原值: indexOf("Good (父母题首轮,按对象分段;…)")
|
||||
// 新值: 标签改为「分段落、段首点名」
|
||||
// 原因: TASK-consult-conversational-answer-20261006 D1/D4(BUG-1245)
|
||||
const parentsStart = voice.indexOf("Good (父母题首轮,分段落、段首点名;括号里是依据的占位,只演示口气与结构,不是读法)");
|
||||
const careerStart = voice.indexOf("Good (单对象事业题首轮,不拆段;括号里是依据的占位,只演示口气与结构,不是读法;不预设读者在上班、做哪类工作)");
|
||||
const badStart = voice.indexOf("Bad (多对象题,改写示意)");
|
||||
assert.ok(parentsStart > -1 && careerStart > parentsStart && badStart > careerStart);
|
||||
const parents = voice.slice(parentsStart, careerStart);
|
||||
for (const heading of ["## 你和妈妈", "## 你和爸爸", "## 他们怎么对你", "## 时间怎么看", "## 这周可以做的一件事"]) {
|
||||
assert.ok(parents.includes(heading), heading);
|
||||
}
|
||||
// 原值: 父母示范含 ## 你和妈妈 / 你和爸爸 / 他们怎么对你 / 时间怎么看 / 这周可以做的一件事
|
||||
// 新值: 段首点名,示范里没有任何 ##
|
||||
// 原因: TASK-consult-conversational-answer-20261006 D1/D4(BUG-1245)
|
||||
assert.match(parents, /你妈妈这边/);
|
||||
assert.match(parents, /爸爸那边/);
|
||||
assert.doesNotMatch(parents, /^## /m);
|
||||
// 原值: assert.match(parents, /(4 宫母亲宫的宫主水星落在 10 宫事业宫;代表母亲的月亮也在 10 宫)/)
|
||||
// 新值: 括号只放占位(4 宫主落 10 宫;月亮在 10 宫);爸爸段演示「受冲先说、用可能、段末请确认」;不再有「分量重」「问爸爸的意见」
|
||||
// 原因: TASK-consult-no-presupposition-and-backtest-20261001 D1/D3(BUG-1164/1165):旧例预设父亲在场,回测里缺席的父亲被照抄成「话少、分量重」
|
||||
@@ -242,7 +276,11 @@ test("the pinned Good examples split by person and stay plain; the coined-patter
|
||||
assert.doesNotMatch(parents, /分量重|标杆|爸爸的意见|靠做事/);
|
||||
const career = voice.slice(careerStart, badStart);
|
||||
assert.doesNotMatch(career, /## 你和/);
|
||||
assert.match(career, /## 盘上依据/);
|
||||
// 原值: assert.match(career, /## 盘上依据/)
|
||||
// 新值: 单对象示范也是段落,不写 ## 盘上依据
|
||||
// 原因: TASK-consult-conversational-answer-20261006 D1(BUG-1245)
|
||||
assert.doesNotMatch(career, /^## /m);
|
||||
assert.match(career, /(木星行运 10 宫;土星行运 6 宫)/);
|
||||
for (const good of [parents, career]) {
|
||||
assert.doesNotMatch(good, /一切都会好|相信自己|加油|宇宙自有安排|扮演|格局/);
|
||||
// No coined 外X内Y pattern names and no empty-house verdicts in the pinned examples.
|
||||
@@ -277,12 +315,12 @@ test("the voice answers the sentence asked: no motive guessing, yes/no first, co
|
||||
// 新值(2): 反差形状下线;「问别人时写宫位与代表星,不写心思」移到对象段规则里
|
||||
// 原因(2): TASK-consult-plain-answer-20261001 D1(BUG-1070 的目的保留)
|
||||
assert.match(voice, /问的是别人时,写那个人在你盘上对应的宫位与代表星说明了什么,不写那个人的心思/);
|
||||
// Actions stay in the opener only where there is no body skeleton.
|
||||
// 原值(2): /本命解读里这三条不写在开场,只写在正文最后一节「这周可以做的一件事」/、/…行动就写在开场结尾/
|
||||
// 新值(2): /本命解读里只写在最后一节「这周可以做的一件事」,出现一次/、/…行动就写在回答结尾/
|
||||
// 原因(2): TASK-consult-plain-answer-20261001 D2:开场之后还有对象段,「开场结尾」改「回答结尾」
|
||||
assert.match(voice, /本命解读里只写在最后一节「这周可以做的一件事」,出现一次/);
|
||||
assert.match(voice, /无出生分钟与申报时段没有正文骨架,行动就写在回答结尾/);
|
||||
// 原值: /本命解读里只写在最后一节「这周可以做的一件事」,出现一次/、/行动就写在回答结尾/
|
||||
// 新值: 行动不强制,无出生分钟与申报时段也不交固定清单
|
||||
// 原因: TASK-consult-conversational-answer-20261006 D2(BUG-1244)
|
||||
assert.match(voice, /行动不强制:只有盘上确实指向一件和问题相关的具体事情时/);
|
||||
assert.match(voice, /无出生分钟与申报时段同样不交固定行动清单/);
|
||||
assert.doesNotMatch(voice, /这周可以做的一件事/);
|
||||
// Yes/no Good and Bad pair, fictional chart.
|
||||
assert.match(voice, /Good \(是非题,用户问「我妈是不是不太在意我」/);
|
||||
// 原值: assert.match(voice, /不是。4 宫主水星逆行落 10 宫/)
|
||||
@@ -304,6 +342,27 @@ test("the voice answers the sentence asked: no motive guessing, yes/no first, co
|
||||
assert.match(voice, /Draw a conclusion from an empty house alone\. Say where its lord sits and in what state instead/);
|
||||
});
|
||||
|
||||
test("pinned examples carry no copyable action and no weekly heading (BUG-1245)", () => {
|
||||
const banned = [
|
||||
"口头答应的事,补一份书面确认",
|
||||
"身体的小毛病这周就去看",
|
||||
"给妈妈打电话时先问她最近怎样",
|
||||
];
|
||||
for (const phrase of banned) assert.equal(voice.includes(phrase), false, phrase);
|
||||
assert.equal(voice.includes("## 这周"), false);
|
||||
assert.match(voice, /Bad \(汇报体,只示意结构\)/);
|
||||
const goodParents = voice.slice(
|
||||
voice.indexOf("Good (父母题首轮"),
|
||||
voice.indexOf("Good (单对象事业题首轮"),
|
||||
);
|
||||
const goodCareer = voice.slice(
|
||||
voice.indexOf("Good (单对象事业题首轮"),
|
||||
voice.indexOf("Bad (多对象题"),
|
||||
);
|
||||
assert.doesNotMatch(`${goodParents}\n${goodCareer}`, /^## /m);
|
||||
assert.doesNotMatch(`${goodParents}\n${goodCareer}`, /^- /m);
|
||||
});
|
||||
|
||||
test("the voice pins Chinese-only terms and one spelling per thing (BUG-1148)", () => {
|
||||
assert.match(voice, /名词只用中文,同一个东西全篇一个叫法/);
|
||||
for (const term of ["克图", "凯图", "中运", "sade sati", "vargottama", "rupas"]) {
|
||||
|
||||
@@ -2291,13 +2291,21 @@ def execute_consultation_workflow(
|
||||
elif step == 'run_rectification_gate':
|
||||
chart_planets = chart.get('planets') if isinstance(chart, dict) else {}
|
||||
chart_ascendant = chart.get('ascendant') if isinstance(chart, dict) else {}
|
||||
rectification = handler._compute_rectification_gate({
|
||||
gate_body = {
|
||||
**birth_payload,
|
||||
'planets': chart_planets if isinstance(chart_planets, dict) else {},
|
||||
'ascendant': chart_ascendant if isinstance(chart_ascendant, dict) else {},
|
||||
'declared_accuracy': body.get('declared_accuracy', body.get('accuracy', 'minute')),
|
||||
'time_source': body.get('time_source', 'family_clear'),
|
||||
})
|
||||
}
|
||||
# Consult already set this flag for the foreground gateway. The gate
|
||||
# must see it too, or it starts a second official snapshot that cannot
|
||||
# finish inside the request. Rectification HTTP calls do not set it.
|
||||
if defer_optional_external_evidence:
|
||||
from scripts.vedastro_consultation_snapshot import snapshot_identity_from_request
|
||||
gate_body['defer_optional_external_evidence'] = True
|
||||
gate_body['vedastro_snapshot_identity'] = snapshot_identity_from_request(body)
|
||||
rectification = handler._compute_rectification_gate(gate_body)
|
||||
executed_steps.append('run_rectification_gate')
|
||||
elif step == 'compute_chart':
|
||||
if not computed_chart:
|
||||
@@ -8471,7 +8479,11 @@ class JyotishAPIHandler(BaseHTTPRequestHandler, VedastroEvidenceMixin, SynastryM
|
||||
next_action = '保留原始出生记录来源;重要预测仍建议用 Dasha/Transit/案例验证交叉确认。'
|
||||
|
||||
try:
|
||||
vedastro_gateway = self._compute_vedastro_gateway_run(body)
|
||||
if body.get('defer_optional_external_evidence'):
|
||||
from scripts.vedastro_consultation_snapshot import consult_rectification_vedastro_gateway
|
||||
vedastro_gateway = consult_rectification_vedastro_gateway(body)
|
||||
else:
|
||||
vedastro_gateway = self._compute_vedastro_gateway_run(body)
|
||||
except Exception as exc: # Keep rectification available when the external observation is down.
|
||||
vedastro_gateway = {
|
||||
'scope': 'vedastro_gateway_run',
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
"""Consult-only VedAstro snapshot for the rectification gate.
|
||||
|
||||
The foreground gateway (BUG-301) still runs. This module is the other call,
|
||||
inside the consultation rectification gate, which used to start the same
|
||||
4-second official snapshot runner and then drop the result.
|
||||
|
||||
When the request already asked to defer optional external evidence:
|
||||
|
||||
- reuse a BUG-727 snapshot for the same birth, ayanamsa, node, and UTC date;
|
||||
- otherwise return a negative-cache hit from earlier today;
|
||||
- otherwise do not start the runner. Record
|
||||
``official_closure_reason=deferred_in_consultation`` until the end of
|
||||
that UTC day.
|
||||
|
||||
The rectification HTTP route does not set the flag, so it still calls the
|
||||
real gateway. Negative entries live in their own directory and are never
|
||||
served through the 7-day stale window.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from scripts.vedastro_snapshot_cache import (
|
||||
annotate_stale_gateway,
|
||||
lookup_snapshot,
|
||||
official_snapshot_reference_date,
|
||||
snapshot_cache_key,
|
||||
)
|
||||
except ModuleNotFoundError: # pragma: no cover - script execution path
|
||||
from vedastro_snapshot_cache import (
|
||||
annotate_stale_gateway,
|
||||
lookup_snapshot,
|
||||
official_snapshot_reference_date,
|
||||
snapshot_cache_key,
|
||||
)
|
||||
|
||||
NEGATIVE_CACHE_SCHEMA = "vedastro_snapshot_negative_cache.v1"
|
||||
DEFERRED_IN_CONSULTATION = "deferred_in_consultation"
|
||||
_SHA256_NAME = re.compile(r"^[0-9a-f]{64}\.json$")
|
||||
_IDENTITY_FIELDS = (
|
||||
"year",
|
||||
"month",
|
||||
"day",
|
||||
"hour",
|
||||
"minute",
|
||||
"second",
|
||||
"lat",
|
||||
"lon",
|
||||
"tz",
|
||||
"ayanamsa",
|
||||
"ayanamsa_name",
|
||||
"ayanamsa_policy",
|
||||
"node_mode",
|
||||
"nodeMode",
|
||||
"reference_date",
|
||||
"today",
|
||||
"transit_date",
|
||||
"current_date",
|
||||
"entrypoint",
|
||||
"consult_entrypoint",
|
||||
)
|
||||
_IDENTITY_KEYS = {
|
||||
"name",
|
||||
"email",
|
||||
"user_id",
|
||||
"userid",
|
||||
"user_email",
|
||||
"session_id",
|
||||
"sessionid",
|
||||
"full_name",
|
||||
"display_name",
|
||||
}
|
||||
|
||||
|
||||
def snapshot_identity_from_request(body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""Fields the BUG-727 key reads, copied without normalizing numbers.
|
||||
|
||||
The consultation birth payload turns hour ``5`` into ``5.0``. Those are
|
||||
different cache keys, so the gate must look up the request's own values.
|
||||
"""
|
||||
payload = body if isinstance(body, dict) else {}
|
||||
return {key: payload[key] for key in _IDENTITY_FIELDS if key in payload}
|
||||
|
||||
|
||||
def negative_cache_dir() -> Path:
|
||||
raw = str(os.environ.get("JYOTISH_VEDASTRO_SNAPSHOT_NEGATIVE_CACHE_DIR") or "").strip()
|
||||
path = Path(raw) if raw else Path(__file__).resolve().parents[1] / "scratch" / "local" / "vedastro_snapshot_negative_cache"
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def _negative_path(cache_key: str) -> Path:
|
||||
if not re.fullmatch(r"[0-9a-f]{64}", cache_key):
|
||||
raise ValueError("vedastro negative cache key must be sha256 hex")
|
||||
return negative_cache_dir() / f"{cache_key}.json"
|
||||
|
||||
|
||||
def _strip_identity(value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
key: _strip_identity(item)
|
||||
for key, item in value.items()
|
||||
if str(key).strip().lower() not in _IDENTITY_KEYS
|
||||
}
|
||||
if isinstance(value, list):
|
||||
return [_strip_identity(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def _cache_body(body: dict[str, Any]) -> dict[str, Any]:
|
||||
identity = body.get("vedastro_snapshot_identity")
|
||||
if isinstance(identity, dict):
|
||||
return identity
|
||||
return body
|
||||
|
||||
|
||||
def _deferred_packet() -> dict[str, Any]:
|
||||
return {
|
||||
"scope": "vedastro_gateway_run",
|
||||
"status": "official_blocked",
|
||||
"official_closure_state": "official_blocked",
|
||||
"official_closure_reason": DEFERRED_IN_CONSULTATION,
|
||||
}
|
||||
|
||||
|
||||
def lookup_negative_snapshot(
|
||||
body: dict[str, Any] | None,
|
||||
*,
|
||||
today: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Same-day negative hit only. Tomorrow is a different key, so it misses."""
|
||||
payload = body if isinstance(body, dict) else {}
|
||||
served = (today or official_snapshot_reference_date(payload))[:10]
|
||||
path = _negative_path(snapshot_cache_key(payload, reference_date=served))
|
||||
if not _SHA256_NAME.match(path.name) or not path.is_file():
|
||||
return None
|
||||
try:
|
||||
record = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
if not isinstance(record, dict) or record.get("schema") != NEGATIVE_CACHE_SCHEMA:
|
||||
return None
|
||||
if str(record.get("reference_date") or "")[:10] != served:
|
||||
return None
|
||||
gateway = record.get("gateway")
|
||||
return dict(gateway) if isinstance(gateway, dict) else None
|
||||
|
||||
|
||||
def store_negative_snapshot(
|
||||
body: dict[str, Any] | None,
|
||||
gateway: dict[str, Any],
|
||||
*,
|
||||
today: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
if not isinstance(gateway, dict):
|
||||
return None
|
||||
state = gateway.get("official_closure_state") or gateway.get("status")
|
||||
if state == "official_verified":
|
||||
return None
|
||||
payload = body if isinstance(body, dict) else {}
|
||||
served = (today or official_snapshot_reference_date(payload))[:10]
|
||||
try:
|
||||
datetime.strptime(served, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
return None
|
||||
cache_key = snapshot_cache_key(payload, reference_date=served)
|
||||
record = {
|
||||
"schema": NEGATIVE_CACHE_SCHEMA,
|
||||
"cache_key": cache_key,
|
||||
"reference_date": served,
|
||||
"stored_at": datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"gateway": _strip_identity(gateway),
|
||||
}
|
||||
path = _negative_path(cache_key)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
encoded = json.dumps(record, ensure_ascii=False, sort_keys=True)
|
||||
fd, tmp_name = tempfile.mkstemp(prefix=f"{cache_key}.", suffix=".tmp", dir=str(path.parent))
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
handle.write(encoded)
|
||||
os.replace(tmp_name, path)
|
||||
except Exception:
|
||||
with contextlib.suppress(OSError):
|
||||
os.unlink(tmp_name)
|
||||
raise
|
||||
return record
|
||||
|
||||
|
||||
def _verified_gateway(hit: dict[str, Any]) -> dict[str, Any] | None:
|
||||
record = hit.get("record") if isinstance(hit, dict) else None
|
||||
if not isinstance(record, dict):
|
||||
return None
|
||||
gateway = record.get("gateway")
|
||||
if not isinstance(gateway, dict):
|
||||
return None
|
||||
state = gateway.get("official_closure_state") or gateway.get("status")
|
||||
if state != "official_verified":
|
||||
return None
|
||||
if hit.get("freshness") == "stale":
|
||||
return annotate_stale_gateway(
|
||||
gateway,
|
||||
reference_date=str(record.get("reference_date") or ""),
|
||||
served_on_utc_date=str(hit.get("served_on_utc_date") or ""),
|
||||
)
|
||||
return dict(gateway)
|
||||
|
||||
|
||||
def consult_rectification_vedastro_gateway(body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""Official snapshot for the consultation rectification gate. Never starts the runner."""
|
||||
payload = body if isinstance(body, dict) else {}
|
||||
cache_body = _cache_body(payload)
|
||||
today = official_snapshot_reference_date(cache_body)
|
||||
reused = _verified_gateway(lookup_snapshot(cache_body, today=today) or {})
|
||||
if reused is not None:
|
||||
return reused
|
||||
cached = lookup_negative_snapshot(cache_body, today=today)
|
||||
if cached is not None:
|
||||
return cached
|
||||
packet = _deferred_packet()
|
||||
with contextlib.suppress(OSError):
|
||||
store_negative_snapshot(cache_body, packet, today=today)
|
||||
return packet
|
||||
@@ -0,0 +1,199 @@
|
||||
"""Consult rectification gate reuses or defers the official VedAstro snapshot."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from scripts.jyotish_api_server import JyotishAPIHandler
|
||||
from scripts.vedastro_consultation_snapshot import (
|
||||
consult_rectification_vedastro_gateway,
|
||||
negative_cache_dir,
|
||||
snapshot_identity_from_request,
|
||||
store_negative_snapshot,
|
||||
)
|
||||
from scripts.vedastro_snapshot_cache import snapshot_cache_dir, store_snapshot
|
||||
|
||||
|
||||
def _handler() -> JyotishAPIHandler:
|
||||
return object.__new__(JyotishAPIHandler)
|
||||
|
||||
|
||||
def _planets() -> dict:
|
||||
return {
|
||||
"Sun": {"lon": 80.0},
|
||||
"Moon": {"lon": 123.0},
|
||||
"Mars": {"lon": 210.0},
|
||||
"Mercury": {"lon": 75.0},
|
||||
"Jupiter": {"lon": 15.0},
|
||||
"Venus": {"lon": 102.0},
|
||||
"Saturn": {"lon": 330.0},
|
||||
"Rahu": {"lon": 5.0},
|
||||
"Ketu": {"lon": 185.0},
|
||||
}
|
||||
|
||||
|
||||
def _body(**extra) -> dict:
|
||||
payload = {
|
||||
"year": 1990,
|
||||
"month": 1,
|
||||
"day": 1,
|
||||
"hour": 5,
|
||||
"minute": 0,
|
||||
"second": 0,
|
||||
"lat": 19.0,
|
||||
"lon": 72.8,
|
||||
"tz": 5.5,
|
||||
"ayanamsa": "raman",
|
||||
"node_mode": "mean",
|
||||
"reference_date": "2026-10-05",
|
||||
"today": "2026-10-05",
|
||||
"current_date": "2026-10-05",
|
||||
"planets": _planets(),
|
||||
"ascendant": {"lon": 15.0},
|
||||
"declared_accuracy": "minute",
|
||||
"time_source": "family_clear",
|
||||
}
|
||||
payload.update(extra)
|
||||
return payload
|
||||
|
||||
|
||||
def _verified(request_id: str = "same-day") -> dict:
|
||||
return {
|
||||
"scope": "vedastro_gateway_run",
|
||||
"status": "official_verified",
|
||||
"official_closure_state": "official_verified",
|
||||
"official_closure_reason": "official_raw_response_present",
|
||||
"official_raw_response": {"request_id": request_id, "natal": {"sun": "Leo"}},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def caches(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("JYOTISH_VEDASTRO_SNAPSHOT_CACHE_DIR", str(tmp_path / "positive"))
|
||||
monkeypatch.setenv("JYOTISH_VEDASTRO_SNAPSHOT_NEGATIVE_CACHE_DIR", str(tmp_path / "negative"))
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _forbid_runner(monkeypatch) -> list[str]:
|
||||
calls: list[str] = []
|
||||
|
||||
def runner(*_args, **_kwargs):
|
||||
calls.append("runner")
|
||||
raise AssertionError("snapshot runner must not start on the consult gate")
|
||||
|
||||
def report(*_args, **_kwargs):
|
||||
calls.append("report")
|
||||
raise AssertionError("build_report must not start on the consult gate")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"scripts.vedastro_service_adapter._try_official_capability_runner_snapshot_bundle",
|
||||
runner,
|
||||
)
|
||||
monkeypatch.setattr("scripts.vedastro_user_entrypoint.build_report", report)
|
||||
return calls
|
||||
|
||||
|
||||
def test_consult_gate_does_not_call_the_snapshot_runner(caches, monkeypatch) -> None:
|
||||
calls = _forbid_runner(monkeypatch)
|
||||
handler = _handler()
|
||||
|
||||
def live_gateway(_body):
|
||||
calls.append("gateway")
|
||||
raise AssertionError("consult gate must not call the live gateway")
|
||||
|
||||
handler._compute_vedastro_gateway_run = live_gateway # type: ignore[method-assign]
|
||||
result = handler._compute_rectification_gate({
|
||||
**_body(),
|
||||
"defer_optional_external_evidence": True,
|
||||
})
|
||||
gateway = result["vedastro_gateway"]
|
||||
assert calls == []
|
||||
assert gateway["official_closure_state"] == "official_blocked"
|
||||
assert gateway["official_closure_reason"] == "deferred_in_consultation"
|
||||
assert gateway["status"] != "official_verified"
|
||||
assert "official_raw_response" not in gateway
|
||||
assert result["endpoint"] == "rectification_gate"
|
||||
|
||||
|
||||
def test_negative_cache_hits_the_same_utc_day_and_misses_the_next(caches, monkeypatch) -> None:
|
||||
calls = _forbid_runner(monkeypatch)
|
||||
stored = store_negative_snapshot(_body(), {
|
||||
"scope": "vedastro_gateway_run",
|
||||
"status": "official_blocked",
|
||||
"official_closure_state": "official_blocked",
|
||||
"official_closure_reason": "foreground_optional_evidence_timeout",
|
||||
"email": "hidden@example.com",
|
||||
}, today="2026-10-05")
|
||||
assert stored is not None
|
||||
assert "email" not in json.dumps(stored["gateway"])
|
||||
|
||||
same_day = consult_rectification_vedastro_gateway({
|
||||
**_body(),
|
||||
"defer_optional_external_evidence": True,
|
||||
})
|
||||
assert same_day["official_closure_reason"] == "foreground_optional_evidence_timeout"
|
||||
before = sorted(path.read_bytes() for path in negative_cache_dir().glob("*.json"))
|
||||
again = consult_rectification_vedastro_gateway({
|
||||
**_body(),
|
||||
"defer_optional_external_evidence": True,
|
||||
})
|
||||
after = sorted(path.read_bytes() for path in negative_cache_dir().glob("*.json"))
|
||||
assert again["official_closure_reason"] == "foreground_optional_evidence_timeout"
|
||||
assert before == after
|
||||
|
||||
next_day = consult_rectification_vedastro_gateway({
|
||||
**_body(reference_date="2026-10-06", today="2026-10-06", current_date="2026-10-06"),
|
||||
"defer_optional_external_evidence": True,
|
||||
})
|
||||
assert next_day["official_closure_reason"] == "deferred_in_consultation"
|
||||
assert calls == []
|
||||
assert negative_cache_dir().resolve() != snapshot_cache_dir().resolve()
|
||||
|
||||
|
||||
def test_same_day_verified_snapshot_is_reused_ahead_of_the_negative_cache(caches, monkeypatch) -> None:
|
||||
calls = _forbid_runner(monkeypatch)
|
||||
request = _body()
|
||||
store_snapshot(request, _verified("cached-official"))
|
||||
store_negative_snapshot(request, {
|
||||
"status": "official_blocked",
|
||||
"official_closure_state": "official_blocked",
|
||||
"official_closure_reason": "foreground_optional_evidence_timeout",
|
||||
}, today="2026-10-05")
|
||||
normalized = {
|
||||
**request,
|
||||
"hour": 5.0,
|
||||
"minute": 0.0,
|
||||
"defer_optional_external_evidence": True,
|
||||
"vedastro_snapshot_identity": snapshot_identity_from_request(request),
|
||||
}
|
||||
reused = consult_rectification_vedastro_gateway(normalized)
|
||||
assert reused["official_closure_state"] == "official_verified"
|
||||
assert reused["official_raw_response"]["request_id"] == "cached-official"
|
||||
missed = consult_rectification_vedastro_gateway({
|
||||
**request,
|
||||
"hour": 5.0,
|
||||
"defer_optional_external_evidence": True,
|
||||
})
|
||||
assert missed["official_closure_reason"] == "deferred_in_consultation"
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_rectification_surface_without_the_flag_still_uses_the_live_gateway(caches) -> None:
|
||||
handler = _handler()
|
||||
seen: list[dict] = []
|
||||
|
||||
def live_gateway(body):
|
||||
seen.append(dict(body))
|
||||
return _verified("rectification-surface")
|
||||
|
||||
handler._compute_vedastro_gateway_run = live_gateway # type: ignore[method-assign]
|
||||
result = handler._compute_rectification_gate(_body())
|
||||
assert len(seen) == 1
|
||||
assert "defer_optional_external_evidence" not in seen[0]
|
||||
gateway = result["vedastro_gateway"]
|
||||
assert gateway["official_closure_state"] == "official_verified"
|
||||
assert gateway["official_closure_reason"] == "official_raw_response_present"
|
||||
assert gateway["official_raw_response"]["request_id"] == "rectification-surface"
|
||||
assert result["endpoint"] == "rectification_gate"
|
||||
assert result["success"] is True
|
||||
Reference in New Issue
Block a user