Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce4e98036d | ||
|
|
b9a2866a22 | ||
|
|
b8bdc003c3 | ||
|
|
9f1e8257ae |
@@ -1,5 +1,11 @@
|
||||
# BLOCKED
|
||||
|
||||
## TASK-rectification-explicit-entry-20261012:T0 日志、T5 模型(BUG-1306,2026-10-12)
|
||||
|
||||
- **T0**:本 worktree 没有 staging 主机,读不到 web 容器 `docker logs`,也没有数据库。无法核对 10-11 那一轮是否调用了 `offer-birth-time-rectification`。不阻塞实现。产品可照做的查法写在 `docs/tasks/PROGRESS-rectification-explicit-entry-20261012.md`,只看工具名、status,以及该会话 messages 里有没有 `rectificationOffer`。
|
||||
- **T5**:供应商 API key 在管理端数据库里加密保存,环境只留 `MODEL_PROVIDER_CONFIG_ENCRYPTION_KEY`。本机没有可用数据库,不能解密,也不调用模型。八个场景写成 `docs/testing/rectification-explicit-entry-20261012.md`。
|
||||
- **`next build`**:默认 Turbopack 拒绝 worktree 的 `frontend/node_modules` junction(指向文件系统根之外)。`npm run build -- --webpack` 编译与 TypeScript 已过,收集 page data 时 Windows 不能为 Skill 建符号链接(EPERM)。没有路由表,`/` 是否仍 Static、首屏 gzip 未测。没有 `npm install`,没有删 junction,没有改 next 配置。
|
||||
|
||||
## TASK-astrologer-rulings-batch5:Rath 双主星 p.43、名人生平回测、`next build`、真机(2026-10-04)
|
||||
|
||||
- **Rath 双主星按 p.43 (a)–(e)(任务书 T1,BUG-1227)**:照裁定实现后原书软件表双主星 8/10 → 9/10,但 Table 17 大运年数 12/12 → 10/12(天蝎 10 → 1、水瓶 8 → 1),低于验收底线,按红线 2 停下、未落地。原书标准盘 Table 17 / Chart 3 / p.43 (e) 例与同书 Table 23 软件判定互相冲突,需产品与占星师二选一(见进度记录 T1 节)。替代证据:试算脚本与三种读法对照表。
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-10-12 — 对话里明说要校正就直接进入(待验收)
|
||||
|
||||
- 用户这一轮明确要求做生时校正时,直接打开校正,不先写解读、不用点卡。推得不准时的提议仍走卡片。做不了会用一句话说明,不再沉默。普通对话不承诺收窄出生时间,申报时段不写大运、子运的起止年月日。
|
||||
|
||||
## 2026-10-12 — 生时校正开场说清填的时间离换段有多近(未推送,未上线)
|
||||
|
||||
- 开场两句中间多一句:填的时间离最近会换的地方差几分钟,两边不一样的是父母、工作、住处、婚恋、学业、子女、钱或身体里实际不同的那几件。不到 1 分钟就说不到 1 分钟。这段时间里都不换,就说附近这些盘都不换,先从经历开始。只知道大概时段时不加这一句。
|
||||
- 本命上升也换时,这一句先说「这里连本命上升都换」,再写两边不一样的事。没有别的事时整句停在这里。只有本命上升在换,不说接下来先问。仍是一句。
|
||||
- 开场扫描和盘型段扫描按资料里的岁差算。没填岁差仍是 Raman。不说秒,不说记录时间错了或不准,不说已确认。先问这几件事的开关默认关,所以这句先不承诺接下来先问。打分、闸门和题库都没改。Skill 仍是 10.0.33。设置里「生时校正目前仍固定按 Raman」那句还没改。
|
||||
|
||||
## 2026-10-09 — 事业判断表旁边注明不按星性写方向(已合入 staging;回测仍有 1 次,记为已知残余)
|
||||
|
||||
- 事业判断表里,自然代表星、Jaimini 代表星、大运主星和宫主这几行旁边加了一句:只看力量与受冲,不按这颗星的本性写职业类型、做事方式或方向。其他领域不加。行和取值没有改。系统提示没有改。
|
||||
|
||||
+28
-7
@@ -17538,8 +17538,8 @@
|
||||
|
||||
## BUG-1306 | 对话里明说「帮我做生时纠正」进不了校正:模型不出提议卡,反而在普通对话里自己「收窄出生时间」
|
||||
|
||||
- 状态:open(根因已定,修复单 `docs/tasks/TASK-rectification-explicit-entry-20261012.md` 待领取;T0 取证未完成)
|
||||
- 首次发现 / 最近更新:2026-10-11 / 2026-10-11
|
||||
- 状态:fixed-pending-verify(实现在 `codex/rectification-explicit-entry-20261012`,未 push;T0 无主机日志,T5 无模型凭据)
|
||||
- 首次发现 / 最近更新:2026-10-11 / 2026-10-12
|
||||
- 来源:产品在 staging(`de4cfeb8`)新对话第一句输入「请你帮我进行生时纠正」,回复截图转述。
|
||||
- 现象:回复显示「已完成 3 步」,正文说「收窄出生时间这件事能做」,要用户报有年月的经历,「对得上,区间就能往里收」;全文没有提到提议卡,界面上也没有卡片。正文还按「代表分钟」写出大运 / 子运起止年月。
|
||||
- 触发条件:申报出生时段的本人档案;首轮;用户明确要求做校正。
|
||||
@@ -17549,23 +17549,30 @@
|
||||
3. BUG-1248 上线前的模型实测只覆盖「不认同解释 / 过去的事对不上 + 时间是估的 / 家人 / 已提议又被拒」四种说法,没有「首轮直接点名要校正」。
|
||||
4. 服务端拒绝原因中 `product_disabled`、`pricing_unavailable` 要求正文「不要提校正」,用户看到的是没有回应。
|
||||
- 推断(未证实):正文主动承诺能收窄,不像收到「不要提校正」的拒绝,更像模型没有调用提议工具;无模型凭据、无 staging 运行日志,暂未取证。另:用户若照做在普通对话里报经历,这些经历不会进入校正引擎计分,正文「区间就能往里收」是做不到的承诺;按代表分钟写大运起止,与申报时段指令「精确应期一律不可用」不符。
|
||||
- 待做:取该轮观测记录确认是否调用了工具及服务端结果(任务书 T0;观测只打到 web 容器控制台,需 staging 主机日志)。
|
||||
- 待做:取该轮观测记录确认是否调用了工具及服务端结果(任务书 T0;观测只打到 web 容器控制台,需 staging 主机日志)。本轮没有主机和数据库,查法见进度记录,不阻塞修复。
|
||||
- 修复:不新建工具。`offer-birth-time-rectification` 增加 `explicitRequest`。这一轮明确要校正由模型填写;`userAsked` 仍是「提议过之后用户自己再要求才出卡」。服务端判定仍是 `decideRectificationOffer`(本人、开关、无进行中 case、有价)。通过则记下与提议卡同结构、状态直接为 `opened`、尚无 caseId 的记录,`run.completed` 带 `rectificationDirectOpen`,浏览器调用已有的 `openRectificationFromChat`。同一轮已有直达或已有提议时不再开第二次。`bindOfferToCase` 也会绑定这种还没有 caseId 的 `opened` 记录,刷新后按 caseId 找回。进行中的 case 不新开:查到 `agentic_rectification_cases.session_id` 就打开那次,查不到就说可以在历史对话里接着做。`product_disabled` 与 `pricing_unavailable` 对用户是同一句「出生时间核对这会儿还做不了。」家人仍是「给家人校正暂时还不支持」。四种拒绝说明都不再写「不要提校正」。没有明确要求时的拒绝仍走原来的排盘回答。直达和明确拒绝把首轮合同视为就绪,不跑「运行合同不完整」重试、不跑空答重试、不跑 Pass4,已流出的正文换成那一句。这一轮结算走 `complete_consultation_free`,不走 `complete_consultation_response`;新开时再把提议补到会话消息上,不改请求行。规则仍只定义在 `RECTIFICATION_OFFER_RULE`:不承诺收窄出生时间,不在对话里收集经历做校正。申报时段指令补「大运、子运只说在哪一段附近,不写起止年月日」。不改校正引擎、打分、闸门和数据库。
|
||||
- 验证:`npx tsc --noEmit` 0 错。`npm run lint` 0 error(125 条既有 warning)。定向 `rectification-explicit-entry-20261012` 与 `rectification-in-chat-20261007` 26 通过。合同套件 236 条里 235 通过;唯一失败是 Skill 符号链接 EPERM。`CI=1 npm test` 4922 条,通过 4780,失败 101,cancelled 0;失败在本轮没改的文件(无 Docker、符号链接、WSL、路径拼接、junction 下的依赖解析)。telemetry 合同按直达结算改了一条断言,改完 4 条通过。默认 `next build` 因 worktree `node_modules` junction 被 Turbopack 拒绝;`npm run build -- --webpack` 编译与 TypeScript 已过,收集 page data 时 Skill 符号链接 EPERM,没有路由表,`/` Static 与首屏 gzip 未测。T5 未跑模型。
|
||||
- 防复发:`frontend/tests/rectification-explicit-entry-20261012.test.ts` 锁住直达、同一轮不重复开案、无 caseId 的 opened 绑定与找回、四种拒绝说明、两条路径不重试、普通咨询仍重试一次、规则只定义一处、申报时段「在哪一段附近」、结算调用 `complete_consultation_free`。用户可见句长度 ≤20。真机清单 `docs/testing/rectification-explicit-entry-20261012.md`。
|
||||
- 相关记录:BUG-1247、BUG-1248(入口改为提议卡)、BUG-976/977(禁正则判意图)。
|
||||
- 复发自:无。
|
||||
- 修复版本:未修复。
|
||||
- 修复版本:本分支提交,未 push,未部署。
|
||||
- 决策日志(2026-10-12,产品):选 A——用户明确要求做生时校正时直接进入校正,不先写解读、不用点卡;意图由模型判断,禁正则 / 关键词。**推翻 BUG-1248 两处**:①明确要求的这一轮不再「只出卡、点卡才进」;②`product_disabled`、`pricing_unavailable`「正文不要提校正」改为必须一句话说明做不了,不得静默。普通对话模型不得承诺收窄出生时间、不得在对话里收集经历假装校正;申报时段下不得写大运 / 子运精确起止。服务端本人 / 开关 / 进行中 case / 价格判定不变。同日另立 BUG-1307(最近分界开场)、BUG-1308(父母静态题研究)。
|
||||
|
||||
## BUG-1307 | 生时校正开场不说记录时间离最近分界多近,首轮选题面向整个窗口,用户觉得步骤多、目的不明
|
||||
|
||||
- 状态:open(任务书 `docs/tasks/TASK-rectification-nearest-boundary-opening-20261012.md` 待领取)
|
||||
- 状态:fixed-pending-verify(开场呈现加上 F1 岁差与 F2 本命上升句。选题开关默认关:v5 77 例持久回放没有跑,±10 / ±30 真值段保留和 D9、D10 头段命中都没有新数字,红线未核。部署和真机清单完成前不标 resolved)
|
||||
- 首次发现 / 最近更新:2026-10-12 / 2026-10-12
|
||||
- 来源:产品拿上游 Skill 的一次私人样例校正比较,认为上游步骤短。上游先找记录时间最近的分盘换段点,只问两侧不同的层。
|
||||
- 现象:我方开场不讲记录时间与分界的关系;`window_scan` 有逐分钟 11 层换段数据,但不以记录时间为锚;记录时间只在交付时与区间比较(`reportedTimeOffsetCopy`)。
|
||||
- 根因:扫描数据从未以记录时间为锚组织,呈现与选题都面向整个窗口。
|
||||
- 决策:只改呈现与首轮选题顺序,不改打分、闸门、4 件 3 域;对外不说秒级、不说「记录时间是错的」。
|
||||
- 相关记录:BUG-1306、BUG-1115~1117(盘型口径)、BUG-1143/1144(按段选题)、BUG-691(记录优先)、BUG-1240(不说秒级)。
|
||||
- 修复:新模块只读 `window_scan` 的换段行,给出记录分钟前后最近的换段、两侧不同的层和前后星座。开场由服务端把这一句放在原来两句中间。申报时段、扫描失败不加这一句。窗口里没有换段,用固定的一句「附近这些盘都不换」。选题开关 `RECTIFICATION_NEAREST_BOUNDARY_PROBES=on` 才把对应领域的题排到增益序前面;默认关,开场因此不说「接下来先问这几件」。2 / 5 / 10 分钟都没有选定。Skill 仍是 10.0.33,没有改 OpeningPolicy 正文。
|
||||
- 修复(F1 / F2,2026-10-12,状态仍是 fixed-pending-verify,未部署):F1 段扫描端点收可选 `ayanamsa`,白名单与 `resolveAyanamsa` 的输出一致(raman、lahiri、kp、true_citra、true_pushya、fagan_bradley)。缺省或空白仍是 raman;别名归一后写入 profile、逐分钟排盘和返回体,并因此进缓存键。`djwhal_khul`、`sassanian` 及其他名字抛 `invalid_ayanamsa`,不退回 raman。版本字符串未改。`node_mode` 仍固定 mean,请求体不收这个字段。`fetchOpeningBoundary` 与 `scanCaseSegments` 传 `resolveAyanamsa(snapshot)`。F2 最近换段含 `d1` 时,开场同一句先写「这里连本命上升都换」,再写其余人生事;没有其余事就停在这一句。只有本命上升在换时不加「接下来先问这几件」。`topicsForLayers` 仍不给 D1 造主题词。设置提示 `AYANAMSA_SWITCH_HINT` 仍写生时校正固定 Raman,本轮未改那句。
|
||||
- 验证:公开 AA 盘定向测试覆盖段内、紧贴换段、窗口内无换段;分盘接口默认响应不增加这个字段。前端开场单测覆盖这三句和开关默认关。F1 另证:改前省略岁差的公开盘响应已逐字段对照,显式 raman 与省略相同,lahiri 换缓存键并换星座。77 例回放未跑。真机清单 `docs/testing/rectification-nearest-boundary-opening-20261012.md` 未走。打分文件未改。
|
||||
- 防复发:默认开场两句保持原文字;有换段句时服务端整段替换,不靠模型现编。开关关闭时排序函数返回原数组。打分、闸门、4 件 3 域未改。
|
||||
- 相关记录:BUG-1306、BUG-1115~1117(盘型口径)、BUG-1143/1144(按段选题)、BUG-691(记录优先)、BUG-1240(不说秒级)、BUG-621(历史 case 仍打开旧 Skill)。
|
||||
- 复发自:无。
|
||||
- 修复版本:未修复。
|
||||
- 修复版本:分支 `codex/rectification-nearest-boundary-opening-20261012`,未推送,未部署。进度 `docs/tasks/PROGRESS-rectification-nearest-boundary-opening-20261012.md`。
|
||||
|
||||
## BUG-1308 | 父母静态是非题(D12)对出生时间分界两侧有没有区分力未经评测
|
||||
|
||||
@@ -17577,3 +17584,17 @@
|
||||
- 相关记录:BUG-1306、BUG-1307、BUG-1141 / BUG-1240(同类研究均未过门)。
|
||||
- 复发自:无。
|
||||
- 修复版本:不适用(研究)。
|
||||
|
||||
## BUG-1309 | 分盘段扫描端点写死 Raman:非 Raman 用户的盘型段与档位按 Raman 算,引擎打分按用户岁差
|
||||
|
||||
- 状态:fixed-pending-verify(端点与两处调用已改。77 例持久回放没跑,不标 resolved。未部署)
|
||||
- 首次发现 / 最近更新:2026-10-12 / 2026-10-12
|
||||
- 来源:BUG-1307 分支验收(`b285011a`)时发现;同一端点在 staging 现存。
|
||||
- 现象:`/api/rectification/v5/varga-segments`(`scripts/rectification_varga_segments_api.py`)的 profile、逐分钟排盘与返回体都写死 `ayanamsa: "raman"`;调用方 `v9/segment-scan.ts` 不传岁差。校正引擎打分用 `resolveAyanamsa(snapshot)`。
|
||||
- 影响面:选了非 Raman 岁差的用户,交付卡 D9 / D10 段、档位与「不用校正」判断按 Raman 算,可能与打分所用岁差的段边界不一致(分界随岁差移动,私人样例一例 Raman 与 Lahiri 差约 7 分钟)。默认 Raman 用户不受影响。
|
||||
- 根因:BUG-1115 引入段扫描端点时只按默认岁差实现,任务书未要求透传岁差。
|
||||
- 修复:端点收可选 `ayanamsa`。白名单与前端 `resolveAyanamsa` 的输出一致;缺省或空白为 raman;别名归一进 profile、`compute_chart` 和返回体,缓存键因此随岁差变。未知名字(含 `djwhal_khul`、`sassanian`)抛 `invalid_ayanamsa`,不静默退回 raman。版本字符串未改。`node_mode` 仍是 mean。`fetchOpeningBoundary` 与 `scanCaseSegments` 发送 `resolveAyanamsa(snapshot)`,返回体岁差必须与该值一致。77 例持久回放未跑,不把缺省 raman 的本地对照说成那次回放。
|
||||
- 验证:公开 AA 盘上,省略岁差与改前捕获逐字段相同,显式 raman 与省略相同;同一窗口 lahiri 的缓存键和星座都不同。前端两处调用:lahiri 资料发 lahiri,未写岁差发 raman。
|
||||
- 相关记录:BUG-1115、BUG-1307、BUG-1306。
|
||||
- 复发自:无。
|
||||
- 修复版本:分支 `codex/rectification-nearest-boundary-opening-20261012`,未推送,未部署。进度见 `docs/tasks/PROGRESS-rectification-nearest-boundary-opening-20261012.md` 的 F1/F2 一节。
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# PROGRESS · 明说要校正就直接进入(BUG-1306,2026-10-12)
|
||||
|
||||
分支 `codex/rectification-explicit-entry-20261012`,基线 `e6baff95`。未 push,未部署。
|
||||
|
||||
## 结论
|
||||
|
||||
用户这一轮明确要求做生时校正时,直接打开校正:不先写解读,不用点卡。是否明确要求由模型填写 `explicitRequest`,不用关键词。推得不准时的主动提议仍走卡片。做不了时用一句话说明。普通对话的规则写明不承诺收窄出生时间,也不在对话里收集经历做校正。申报时段的大运、子运只说在哪一段附近。
|
||||
|
||||
## 实现
|
||||
|
||||
沿用现有工具 `offer-birth-time-rectification`,不新建工具。服务端判定仍是 `decideRectificationOffer`。通过后记录状态为 `opened`、还没有 caseId 的提议,浏览器调用已有的 `openRectificationFromChat`。已有进行中的 case 不新开,查到会话就接着打开。结算走 `complete_consultation_free`,新开时再把提议补到会话消息上,不改 `consultation_requests`。`Home()` 没有新增 `useState` / `useRef`。
|
||||
|
||||
## T0 给产品的查法
|
||||
|
||||
本 worktree 没有 staging 主机,读不到 web 容器日志,也没有数据库。不阻塞实现。
|
||||
|
||||
只查该时段 web 容器日志里的 `[agent-observability]`:
|
||||
|
||||
1. `toolCalls` 里有没有 `offer-birth-time-rectification`。
|
||||
2. 该次调用的 status(有没有放行、有没有 `offered`)。
|
||||
3. 该会话的 `messages` 里有没有 `rectificationOffer` 这个字段。
|
||||
|
||||
不要打开回答正文,不要查看出生资料。
|
||||
|
||||
## T5
|
||||
|
||||
环境里没有可用模型凭据。`MODEL_PROVIDER_CONFIG_ENCRYPTION_KEY` 与常见供应商 key、数据库连接变量都不在本机环境里(只看了是否存在,没有读取值)。供应商 key 在管理端数据库里加密保存,本机没有数据库,不能解密,也没有调用模型。八个场景写在 `docs/testing/rectification-explicit-entry-20261012.md`。
|
||||
|
||||
## 既有断言
|
||||
|
||||
| 原值 | 新值 | 原因 |
|
||||
| --- | --- | --- |
|
||||
| `rectification-in-chat-20261007.test.ts` 的断言原文没有改。 | 无。 | 测试辅助函数 `offerTool()` 补了钩子新必填字段 `sourceSessionId` 与 `loadResumableSessionId`,否则类型检查过不了。期望值没有改。 |
|
||||
| `consult-evidence-card-telemetry-20260927.test.ts`:本命片段匹配 `answerForTelemetry = output` 之后紧接着能看到 `return settleRun(() => completeResponse(`。 | 非直达仍是 `if (!state.rectificationDirectEntry) answerForTelemetry = output`,本命 `onComplete` 调用 `settleAnswer`;`settleAnswer` 在没有直达时 `return settleRun(() => completeResponse(`。 | BUG-1306:直达轮不把模型正文当回答,结算改走 `complete_consultation_free`。普通本命轮仍记 telemetry,仍走 `completeResponse`。 |
|
||||
|
||||
## 验证
|
||||
|
||||
| 命令 | 结果 |
|
||||
| --- | --- |
|
||||
| `npx tsc --noEmit`(frontend) | 0 错 |
|
||||
| `npm run lint` | 0 error,125 条 warning,都是既有未使用变量和 hook 依赖,没有新增 error |
|
||||
| `rectification-explicit-entry-20261012` + `rectification-in-chat-20261007` | 26 通过,0 失败 |
|
||||
| 合同套件(agentic runtime、stream recovery、birth accuracy、workflow contract、home shell、smalltalk、composer、entrypoint、rectification entry) | 236 条,235 通过。唯一失败:`consultation-agentic-runtime.test.ts`「personal Agent exposes the Jyotish Skill and named server tool」。Windows 建 Skill 符号链接 EPERM。该测试与 `skill-package-registry.ts`、`skill-binding.ts` 本轮没改 |
|
||||
| `Home()` useState / useRef | 增长合同通过,没有新增 |
|
||||
| `CI=1 npm test` | 4922 条,通过 4780,失败 101,cancelled 0。失败都在本轮没改的文件:无 Docker、Windows 不能建符号链接、WSL 不可用、路径被拼成 `G:\G:\`、`node_modules` junction 下 tailwind-merge / sonner 解析失败、迁移相对路径、临时目录 EBUSY。唯一由本轮源码形状引起的是 telemetry 那条,断言已按上表改过,该文件再跑 4 条通过。`consult-single-pass-answer-20260927.test.ts` 在 import 时因 Skill 符号链接 EPERM 整文件退出,路由断言没跑到 |
|
||||
| `next build` | 默认 Turbopack 失败:`node_modules` junction 指向文件系统根之外。`npm run build -- --webpack` 编译成功(7.8 分钟),TypeScript 通过(4.1 分钟)。收集 page data 时 Skill 符号链接 EPERM(`/api/consult`、`/api/reports`、`/api/rectification/agent`、`/api/birth-time-guide`)。没有路由表,因此没有证明 `/` 仍是 Static,也没有首屏 gzip。没有 `npm install`,没有删 junction,没有改 next 配置。 |
|
||||
|
||||
## 环境缺口
|
||||
|
||||
- T0:无 staging `docker logs`,无数据库。
|
||||
- T5:无模型凭据,未调用模型。
|
||||
- 定向套件里的 Skill 符号链接 EPERM 是本机 Windows 限制。
|
||||
- `frontend/node_modules` 是指向主仓的 junction。没有 `npm install`,没有删 junction。
|
||||
- `next build`:Turbopack 拒绝该 junction;webpack 编译与类型检查已过,收集 page data 时不能建 Skill 符号链接。`/` Static 与首屏 gzip 未测。
|
||||
@@ -0,0 +1,120 @@
|
||||
# PROGRESS · 生时校正开场说最近一次换段(2026-10-12,BUG-1307)
|
||||
|
||||
任务书:`TASK-rectification-nearest-boundary-opening-20261012.md`。分支 `codex/rectification-nearest-boundary-opening-20261012`,基线 `e6baff95`。只 commit,不 push。未部署。
|
||||
|
||||
## 一句话
|
||||
|
||||
开场会说填的时间离最近一次换段差几分钟、两边哪几件事不同。2 / 5 / 10 分钟都没有跑 77 例,阈值没有选定,选题开关默认关。打分没改。Skill 仍是 10.0.33。
|
||||
|
||||
## 阈值
|
||||
|
||||
没有采用。候选 2 / 5 / 10 分钟都没跑持久回放,不能说哪一档过了红线。
|
||||
|
||||
代码里的 `NEAREST_BOUNDARY_PROBE_THRESHOLD_MINUTES = 5` 只在开关打开之后才用,注释写明未采纳。它不是选定结果。
|
||||
|
||||
开关:环境变量 `RECTIFICATION_NEAREST_BOUNDARY_PROBES` 必须等于 `on` 才改变出题顺序,默认关。关着的时候,开场不说「接下来先问这几件」,避免承诺一件没有做的事。
|
||||
|
||||
## 77 例回放
|
||||
|
||||
没跑。持久回放要本地 Postgres 和逐例星历,这一轮不启动。因此红线未核,按任务书让步:只上呈现,选题留开关默认关。没有为了过门改打分。
|
||||
|
||||
修后基线(`docs/research/varga_resolution_persisted_onoff_after_fix_2026_10_01.json`,段序开,生产默认窗口不超过 61 分钟;`dataset_sha256` `ab365a3e3f4953d4b538f2c0889dc98ab89444a28340c3a7b29e2a864cdd687a`)这一轮没有重测:
|
||||
|
||||
| 窗口 | 提问数 | D9 头段命中 | D10 头段命中 | 真值段保留 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| ±10 开 | 355 | 71 | 67 | 76 |
|
||||
| ±30 开 | 418 | 51 | 55 | 76 |
|
||||
|
||||
红线是 ±10 / ±30 真值段保留都不低于 76 / 76,D9、D10 头段命中不低于上表。任一格下降就要停。
|
||||
|
||||
| 阈值 | 问题数 | ±10 真值段保留 | ±30 真值段保留 | ±10 D9 头段 | ±10 D10 头段 | ±30 D9 头段 | ±30 D10 头段 |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| 2 分钟 | 未跑 | 未跑 | 未跑 | 未跑 | 未跑 | 未跑 | 未跑 |
|
||||
| 5 分钟 | 未跑 | 未跑 | 未跑 | 未跑 | 未跑 | 未跑 | 未跑 |
|
||||
| 10 分钟 | 未跑 | 未跑 | 未跑 | 未跑 | 未跑 | 未跑 | 未跑 |
|
||||
|
||||
公开 AA 盘上傍晚 40 分钟里,离换段最远是 3 分钟。那是一张盘的测量,不是 77 例的门,也不拿来选阈值。
|
||||
|
||||
## 句数上限
|
||||
|
||||
| 项 | 原值 | 新值 | 原因 |
|
||||
| --- | --- | --- | --- |
|
||||
| `isAcceptableOpeningBody` 句数上限 | 3 | 3 | 新句放在原来两句中间,没有放宽上限 |
|
||||
|
||||
没有新句时,默认开场两句与改前逐字相同。
|
||||
|
||||
## 为什么没有 10.0.34
|
||||
|
||||
OpeningPolicy 仍要求正文两句大白话,旧版本目录没有改。开场正文由服务端在模型写完后整段换成 `openingSpokenBody`,不靠模型现编,也不靠 Skill 正文。历史 case 仍读 10.0.33(BUG-621)。`RECTIFICATION_SKILL_VERSION` 仍是 `10.0.33`。
|
||||
|
||||
## 呈现
|
||||
|
||||
- 新模块 `scripts/rectification/nearest_boundary.py` 只读 `window_scan` 的 `transitions`。真正换段是前后星座不同。展示层和星座没变的行忽略。距离按钟面环形分钟算,一样近时留更早的钟点。不改 `window_scan` 的返回形状。
|
||||
- 开场扫描走现有 `POST /api/rectification/v5/varga-segments`。`target_charts` 仍是调用方要的盘;只有带了 `boundary_charts` 和 `recorded_time` 才多一个 `nearest_boundary`。默认缓存键不变。`minutes.signs` 仍只含请求的目标盘。D12 仍不能当目标盘。
|
||||
- 人话对照:婚恋、工作、住处、学业、子女、父母、钱、身体。本命上升不说成一件事。客户端只用层名白名单重写主题,不采用接口里的主题字符串。
|
||||
- 申报方式 `period_only`、没有记录钟点、或扫描失败:原来两句,不挡开场。
|
||||
- 窗口内没有换段:`你填的时间附近这些盘都不换,先从经历开始。`
|
||||
- 有换段:`你填的时间离最近会换的地方不到 1 分钟,两边不一样的是父母、工作。` 或 `差 N 分钟`。不足 1 分钟说「不到 1 分钟」。
|
||||
|
||||
## 选题开关
|
||||
|
||||
打开之后,现有增益排序不变,再把领域落在这条分界上的题排到前面,其余保持刚才的顺序。不新增题型,不改分数。问完这些再接原来的顺序。`period_only`、没有记录钟点、找不到换段、距离大于未采纳的 5 分钟、或没有领域时,不改顺序。
|
||||
|
||||
开关关闭时,排序函数返回原来的数组,同输入同分、同顺序。
|
||||
|
||||
## 没改的
|
||||
|
||||
`decision_policy.py`、闸门常量、4 件 3 域、采用门、交付卡档位、`window_scan` 输出、`page.tsx`、`use-rectification-surface.ts`、咨询入口三个文件、`scripts/jyotish_api_server.py`、数据库、`.gitea`、BUG-1306、BUG-1308、Skill 10.0.33 正文。Home() 状态数没增加。
|
||||
|
||||
## 测试
|
||||
|
||||
Python 用 Anaconda `C:\Users\74082\anaconda3\python.exe`,`PYTHONHASHSEED=0`。本仓没有 `.venv`,没有往仓库里 pip install。
|
||||
|
||||
| 项 | 结果 |
|
||||
| --- | --- |
|
||||
| `tests/test_rectification_nearest_boundary.py` | 4 项通过。公开 AA 盘覆盖段内、紧贴换段、窗口内无换段。默认分盘响应没有 `nearest_boundary`;带上新字段后与 `window_scan` 的最近换段一致 |
|
||||
| `tsc --noEmit` | 通过,退出码 0 |
|
||||
| `npm run lint` | 0 error,125 条 warning,都在原有文件上 |
|
||||
| 开场 / 文案合同 | `rectification-nearest-boundary-opening-20261012.test.ts`、`rectification-opening-plain-20260926.test.ts`、`agent-voice-copy-contract.test.ts` 共 31 项通过 |
|
||||
| 快速门 `--profile quick` | 编译、JSON、能力清单、片段审计、解释源清单、BPHS 不变量通过。pytest 1067 passed、1 skipped、1 failed。失败的是 `tests/test_rectification_v5_services.py` 里全天 block_scan 必须在 15 秒内完成,整门里用了 38.0 秒。这个文件没改。单独再跑通过。算这台机器在长套件里的负载,不算本单回归。门禁因此停在 pytest,没有接着跑它自己的 npm test / lint / build |
|
||||
| `CI=1` `npm test` | 4915 项,通过 4774,失败 100,cancelled 0。失败都落在没改过的文件上,见下 |
|
||||
|
||||
这 100 条是 Windows 基线,不是本单回归:没有 Docker 起不来 Postgres;没有创建符号链接的权限(Skill 运行时别名 `EPERM`);没有 WSL 的 bash;部分路径被拼成 `G:\G:\...` 因而找不到目录;迁移文件在这台机器上读出来的是路径而不是正文;一条打字拒绝测试的子进程退出码是 null(超时或被杀掉),没有进到本单改过的出题参数。本单新增的开场测试在这 4774 条里面。
|
||||
|
||||
`next build`:失败。Turbopack 报 `frontend/node_modules` 这个 junction 指到文件系统根外面。这是已知环境缺口,没有改 Next 配置,也没有删 junction。首屏 gzip 和 `/` 是否仍为 Static 因此没有在这台机器上量。
|
||||
|
||||
## 真机
|
||||
|
||||
`docs/testing/rectification-nearest-boundary-opening-20261012.md`。公开盘两档:19:01 是「不到 1 分钟」,19:04 是「差 3 分钟」。公开记录 19:15 在默认前后 15 分钟窗口里是「差 2 分钟」。窗口内不换那一句,界面选不出那么窄的窗,由自动化测试覆盖。
|
||||
|
||||
## F1 / F2(2026-10-12,修复单,未推送,未部署)
|
||||
|
||||
任务书 `TASK-rectification-1012-batch-fix-20261012.md` 的 F1、F2。做在本分支上。不标 resolved。77 例持久回放(`scripts/research/varga_resolution_persisted_replay.py`)没跑,也不把下面的对照说成那次回放通过。
|
||||
|
||||
### 证明
|
||||
|
||||
改函数之前,用测试里已有的公开盘请求(不带 `ayanamsa`)跑了一次 `scan_varga_segments`,响应写在仓库外:`D:\Users\74082\AppData\Local\Temp\rectification-varga-segments-omitted-ayanamsa-prechange.json`。改完之后同一请求与这份捕获逐字段相同。显式 `"raman"` 与省略相同,缓存键仍是 `58c936a2ccdbb5e339c79f9c43b990d556b627230cd5d5d200f3fcaaea85fad4`。同一窗口的 `lahiri` 换了缓存键,也换了星座。版本字符串仍是 `rectification-varga-segments-v1`。`node_mode` 仍是 mean,请求体不收这个字段。
|
||||
|
||||
打分、`decision_policy`、闸门、`scripts/jyotish_api_server.py` 都没改。
|
||||
|
||||
### 开场句
|
||||
|
||||
最近换段的层里有 `d1` 时,同一句先写「这里连本命上升都换」,再写其余人生事。没有其余事时整句停在这里。只有本命上升在换,不加「接下来先问这几件」。句数上限仍是 3。不换、不到 1 分钟、附近这些盘都不换、只知道大概时段,都保持原来的句子。
|
||||
|
||||
### 留下的一句
|
||||
|
||||
`frontend/src/lib/ayanamsa.ts` 的 `AYANAMSA_SWITCH_HINT` 仍写「生时校正目前仍固定按 Raman,之后的版本才会跟着这项设置走。」修复单没有授权改这句。`frontend/tests/ayanamsa.test.ts` 没改。设置页这句和现在的扫描行为不一致,留给后面的单。
|
||||
|
||||
### 既有断言
|
||||
|
||||
没有改既有断言,所以没有「原值 / 新值 / 原因」三栏。新测试加在 `tests/test_rectification_nearest_boundary.py` 和 `frontend/tests/rectification-nearest-boundary-opening-20261012.test.ts`。
|
||||
|
||||
### 这轮测试
|
||||
|
||||
| 项 | 结果 |
|
||||
| --- | --- |
|
||||
| `pytest tests/test_rectification_nearest_boundary.py tests/test_varga_segments_api.py -q --tb=line` | 22 通过(5 + 17),失败 0 |
|
||||
| `npx tsx --test tests/rectification-nearest-boundary-opening-20261012.test.ts` | 7 通过,失败 0,cancelled 0 |
|
||||
| `npx tsc --noEmit` | 通过,退出码 0 |
|
||||
|
||||
没跑 `next build`,没跑全量 `npm test`,没跑 77 例回放。
|
||||
@@ -124,9 +124,10 @@
|
||||
| `TASK-rectification-mobile-timeline-readout-20260917.md` | `PROGRESS-rectification-mobile-timeline-readout-20260917.md` | 手机截图:时间轴读数第四项被裁成「已…」(nowrap + inset 内边距,BUG-918);「跳到最新」浮层压住选项 C(BUG-919)。灰卡与「再说一件」矛盾归修复单 BUG-915/917 | 已验收 | `e8e98bbd`(合入时重放为本分支提交,BUG-918/919)。时间轴相关 71 条 + 全量同上;手机上隐藏「已对照 N 件」符合 DESIGN §10;桌面是否也移除待产品拍板。真机清单 `docs/testing/rectification-mobile-timeline-readout-20260917.md` |
|
||||
| `TASK-mobile-viewport-scroll-lock-20260917.md` | `PROGRESS-mobile-viewport-scroll-lock-20260917.md` | iPhone 上键盘收起 / 刷新后整页上移、顶栏点不到(BUG-920):iOS 不支持 `interactive-widget`,键盘弹出时 Safari 滚动 window,`html/body overflow: hidden` 让用户拉不回来,reload 又还原 `scrollY`;代码里无任何 window 级复位。补 `ViewportScrollLock`(`scrollRestoration=manual` + `visualViewport` 复位)。与今天两单无关,既有缺陷。**T3 顶栏积分块「有点扁」(BUG-921)**:两枚芯片 44px 塞在 46px 顶栏里、字号/内边距/圆角各一套、积分块强制 64px 最小宽度;统一尺寸并把手机顶栏放到 52px | 已合入(无独立验收记录) | `1b508d5e`(BUG-920/921 resolved;已部署 staging,run 2715);真机待核 |
|
||||
| `TASK-rectification-session-composer-guard-20260917.md` | `PROGRESS-rectification-session-composer-guard-20260917.md` | 校正会话激活但校正面未开(刷新揭幕超时 / 打开失败不重试 / 删除与 popstate 回退落到列表第一条)时露出可用的普通输入框,问题发到 `/api/consult` 回 404「咨询会话不存在」;`send()` 无会话类型守卫;服务端无独立错误码。关联 BUG-505。BUG 段 924 起 | 已验收 | `6d81062b`(Claude 验收:T1–T5 全通过;409 `session_not_consultation` 在扣点前、不扣点;回退路径多修了归档一条;page.tsx 1830→1830。残留 P3:列表只剩校正会话时 `activeSession` 为空,输入框可用但发送静默无效 → BUG-935 在 fix 单);真机三条欠 |
|
||||
| `TASK-rectification-explicit-entry-20261012.md` | — | **明说要校正就直接进入(BUG-1306)**:模型判断意图(禁正则),服务端判定后直接开案并绑定来源对话;首轮排盘合同对此轮豁免;拒绝必须一句话说明(推翻 BUG-1248 静默两条);普通对话不得假装校正、申报时段不写子运起止。**先做** | 待领取 | — |
|
||||
| `TASK-rectification-nearest-boundary-opening-20261012.md` | — | **最近分界开场(BUG-1307)**:开场说记录时间离最近分盘换段点多近、两侧哪些层不同,近分界时首轮只问该分界;不改打分 / 闸门 / 4 件 3 域,77 例回放不降;改 page.tsx / 校正开场组件的部分须在 BUG-1306 合入后 | 待领取 | — |
|
||||
| `TASK-rectification-explicit-entry-20261012.md` | `PROGRESS-rectification-explicit-entry-20261012.md` | **明说要校正就直接进入(BUG-1306)**:模型判断意图(禁正则),服务端判定后直接开案并绑定来源对话;首轮排盘合同对此轮豁免;拒绝必须一句话说明(推翻 BUG-1248 静默两条);普通对话不得假装校正、申报时段不写子运起止。**先做** | 待验收 | — |
|
||||
| `TASK-rectification-nearest-boundary-opening-20261012.md` | `PROGRESS-rectification-nearest-boundary-opening-20261012.md` | **最近分界开场(BUG-1307)**:开场说记录时间离最近换段多近、两侧哪些事不同。F1 段扫描按用户岁差(BUG-1309)、F2 本命上升换段要先说「这里连本命上升都换」,都在本分支,待验收。选题开关默认关(77 例未跑)。不改打分 / 闸门 / 4 件 3 域。未改 page.tsx | 待验收 | 分支 `codex/rectification-nearest-boundary-opening-20261012`,未推送 |
|
||||
| `TASK-rectification-d12-parent-probes-research-20261012.md` | — | **父母静态是非题(D12)研究(BUG-1308)**:公开案例 ≥60、预登记、乱序与正对照,过门才立实现单;纯研究可并行 | 待领取 | — |
|
||||
| `TASK-rectification-1012-batch-fix-20261012.md` | `PROGRESS-rectification-nearest-boundary-opening-20261012.md` | **10-12 三单验收修复**:F1 开场与段汇总按用户岁差(BUG-1307 / BUG-1309)、F2 本命上升换段开场句,在分支 `codex/rectification-nearest-boundary-opening-20261012`,待验收。F3 打开失败卡片仍说已开始(BUG-1306)未做。F4 研究报告补编码问题与暂停结论(BUG-1308)未做 | F1/F2 待验收 | 本分支,未推送 |
|
||||
|
||||
### 聊天主链路与首页
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# TASK · 生时校正 10-12 三单验收修复(2026-10-12)
|
||||
|
||||
- 基线:`origin/staging` @ `e6baff95`(三条功能分支都从这里分出)。
|
||||
- 受影响分支:
|
||||
- `codex/rectification-explicit-entry-20261012` @ `6a799582`(BUG-1306):**验收通过**,本单 F3 是合入后的跟进项。
|
||||
- `codex/rectification-nearest-boundary-opening-20261012` @ `b285011a`(BUG-1307):**未通过**,F1 修完才能合入。
|
||||
- `codex/rectification-d12-parent-probes-research-20261012` @ `11fee0a3`(BUG-1308):研究结论成立,F4 补齐文档后可合入。
|
||||
- 串行顺序:explicit-entry 先合入 staging(由产品点头)→ 把 F1/F2 做在 nearest-boundary 分支上,然后在 explicit-entry 之上变基(试合只冲突 `CHANGELOG.md`、`docs/tasks/README.md`,都是文档)→ F3 从合入后的 staging 新开分支。F4 与上述并行。
|
||||
- 合入 staging 一律等产品明说,执行方只推各自的功能分支。
|
||||
|
||||
## 1. 验收实证
|
||||
|
||||
| 分支 | 门禁 | 结论 |
|
||||
| --- | --- | --- |
|
||||
| explicit-entry | tsc 0;lint 0 error / 125 warning(同基线);npm test 5013 条,失败 25(24 条同基线,另 1 条 `consult-evidence-lookup-20260927`「a lookup does not reset the answer clock」是负载下计时抖动,单独跑 3/3 通过);build `/` Static;gzip +0.02%;page.tsx 未增长 | 通过;T0 日志取证、T5 模型实测为环境缺口 |
|
||||
| nearest-boundary | tsc 0;lint 同基线;npm test 5006 条,失败 25 逐名同基线;build Static;gzip 0%;Python 定向 136 passed;打分 / decision_policy / 闸门 / api_server diff 为空 | 门禁过,**F1 缺陷不过** |
|
||||
| d12 research | 15 测试过;结果 JSON 两次复跑逐字节一致(除 Python 版本号);预登记 `ad89e410` 早于打分 `11fee0a3`,其后 0 改动;冻结身份 / 隐私 94 项绿 | 结论「欠力」成立;下一步与编码问题未写进报告 |
|
||||
|
||||
## 2. 缺陷与根因
|
||||
|
||||
**F1(P1,BUG-1307)开场分界按 Raman 算,引擎按用户岁差算。**
|
||||
`scripts/rectification_varga_segments_api.py` 三处写死 `"ayanamsa": "raman"`(profile、逐分钟 `compute_chart`、payload);`frontend/src/lib/rectification-agentic/opening-boundary.ts::fetchOpeningBoundary` 请求体不带岁差。校正引擎打分用 `resolveAyanamsa(snapshot)`(`v9/engine-client.ts`)。分界随岁差移动:私人样例一例里 Raman 与 Lahiri 的分界相差约 7 分钟,分界落在记录时间两侧。结果:非 Raman 用户开场说的「差几分钟、两边哪里不同」与引擎实际扫描矛盾;选题开关打开后更会自相矛盾。
|
||||
任务书 `TASK-rectification-nearest-boundary-opening-20261012` 没有写岁差要求,是任务书漏写,不是执行方违规。
|
||||
|
||||
**F1b(P2,新 BUG-1309)同一端点给盘型段汇总用时也写死 Raman。**
|
||||
`v9/segment-scan.ts`(BUG-1115 那批)调同一个 `/api/rectification/v5/varga-segments`,同样不传岁差。非 Raman 用户的交付卡 D9 / D10 段与档位按 Raman 算,引擎打分按用户岁差。这是 staging 现存问题,F1 改端点时一并改。
|
||||
|
||||
**F2(P2,BUG-1307)本命上升也换段时开场不说。**
|
||||
开场只列「婚恋、住处、学业、子女、父母、钱、身体」;D1 也换时这是最大的变化,却不提。
|
||||
|
||||
**F3(P3,BUG-1306 跟进)打开失败时卡片仍说「已经开始」。**
|
||||
提议记录在服务器结算时就写成 `opened`,早于浏览器真正开案;开案失败(断网、冲突)时对话显示「出生时间核对已经开始。」。点卡片「继续核对」可重开,不卡死。
|
||||
|
||||
**F4(文档,BUG-1308)研究报告缺下一步与编码问题。**
|
||||
6 例计入中 3 例编码站不住:奥巴马「母亲照料更多=是」引文只讲上哪所小学;伊梅尔达·马科斯「父亲体制内=是」引文是自己执业律师;索菲亚·罗兰「父亲体制内=是」引文是国家铁路临时工作(预登记规定短期任职不算主业,赫本父亲名誉领事却记「不清楚」,尺度不一)。另李小龙「父亲指导学业=是」依据是教太极(未计入)。维基百科计入率约 8%,凑满预登记的 60 例需 700+ AA 例,现有数据做不到;岁差改判 36–42%(门槛 20%)。
|
||||
|
||||
## 3. 决策记录
|
||||
|
||||
1. F1:开场与段汇总一律用 `resolveAyanamsa(snapshot)`,与打分同源;岁差进缓存键。不新增「强制选岁差」(产品 10-03 已拒)。
|
||||
2. F2(Claude 定口径):D1 也换段时,开场先说「这里连本命上升都换」,再说其余会变的事;其余不变。
|
||||
3. F3:结算时提议记录写 `opening`(或保持 `offered` 并带 `autoOpen` 标记),浏览器开案成功、来源绑定后才改 `opened`;失败时卡片按 `offered` 显示,可点开。具体状态名由执行方定,须向后兼容已存的 `offered` / `opened` / `returned`。
|
||||
4. F4:本方向**暂停**,不立实现单。报告写明:继续的唯一路线是换数据源(传记 / 访谈)+ 双人独立编码 + 重新预登记;且岁差敏感 36–42% 本身使过门可能性很低。BUG-1308 状态改 `closed_by_design`(研究欠力,方向暂停)。
|
||||
|
||||
## 4. 硬红线
|
||||
|
||||
1. 不改打分、`decision_policy`、闸门、4 件 3 域、`scripts/jyotish_api_server.py`;不改迁移。
|
||||
2. F1 后 v5 77 例(全 Raman)的段汇总与回放结果必须与改前逐字节一致——岁差参数缺省值仍为 raman。
|
||||
3. 用户文案不得出现秒级、「记录时间是错的 / 不准」。
|
||||
4. 改既有断言写「原值 / 新值 / 原因」三栏;文档同提交更新(BUG_HISTORY、CHANGELOG、VOICE、DESIGN)。
|
||||
5. 不得写入真实用户出生资料。
|
||||
|
||||
## 5. 任务分解
|
||||
|
||||
| 项 | 内容 | 验收 |
|
||||
| --- | --- | --- |
|
||||
| F1 | 端点收 `ayanamsa`(白名单,与 `resolveAyanamsa` 取值一致,缺省 raman),进 profile、`compute_chart`、payload、缓存键;`fetchOpeningBoundary` 与 `segment-scan.ts` 传 `resolveAyanamsa(snapshot)` | Python 测试:同窗口 raman / lahiri 分界不同且缓存键不同;缺省等于显式 raman;前端测试:两处调用的请求体带用户岁差;v5 回放逐字节一致 |
|
||||
| F2 | D1 换段时开场句式 | 前端测试:D1 换 / 不换两种文案;VOICE 记录 |
|
||||
| F3 | 提议状态在开案成功后才转 `opened` | 前端测试:开案失败后卡片可再开、文案不说「已经开始」;旧会话三种状态照常显示 |
|
||||
| F4 | 报告与 BUG-1308 补「编码问题清单 + 下一步二选一结论 + 暂停」 | 文档;`tests/test_repo_privacy_markers.py` 过 |
|
||||
|
||||
## 6. 让步顺序
|
||||
|
||||
F1 不可让。F2 可延到下一轮(写进 BUG-1307 已知限制)。F3 可单独排期。F4 不可让(不写清下一步,研究等于没收口)。
|
||||
|
||||
## 7. 开工前置
|
||||
|
||||
```
|
||||
git fetch origin --prune
|
||||
git fetch origin +refs/heads/codex/rectification-nearest-boundary-opening-20261012:refs/remotes/origin/codex/rectification-nearest-boundary-opening-20261012
|
||||
git fetch origin +refs/heads/codex/rectification-d12-parent-probes-research-20261012:refs/remotes/origin/codex/rectification-d12-parent-probes-research-20261012
|
||||
git status -sb
|
||||
```
|
||||
|
||||
在原 worktree 续做(或按同名分支新建 worktree);F3 待 explicit-entry 合入后从 `origin/staging` 开 `codex/rectification-offer-opened-state-20261012`。
|
||||
|
||||
## 8. BUG 编号
|
||||
|
||||
F1、F2 记入 BUG-1307;F3 记入 BUG-1306;F4 记入 BUG-1308;F1b 新建 **BUG-1309**(开工时核对最大号,写作时 1308)。
|
||||
@@ -0,0 +1,16 @@
|
||||
# 真机清单 · 明说要校正就直接进入(2026-10-12)
|
||||
|
||||
对应 BUG-1306,任务书 `docs/tasks/TASK-rectification-explicit-entry-20261012.md` 的 T5。在 staging 上用自己的账号走。deepseek-v4-flash 一遍,产品默认模型再一遍。每个场景至少两次。回答原文和出生资料不记进仓库。本人档案除第 6 步外都用本人。申报时段用第 8 步;其余有分钟的档案即可。
|
||||
|
||||
| 步 | 怎么做 | 通过标准 |
|
||||
| --- | --- | --- |
|
||||
| 1 | 新对话第一句:「请你帮我进行生时纠正」 | 直接打开校正。这一轮不写解读,不用点卡。过渡只有一句 |
|
||||
| 2 | 另开三条新对话,分别说「帮我校正出生时间」「想核对一下出生时间准不准」「我出生时间是估的,帮我对一下」 | 三次都直接打开,不先出卡 |
|
||||
| 3 | 新对话先问一件普通的事,答完后再说「请帮我做生时校正」 | 这一轮直接打开,不把刚才的解读再写一遍 |
|
||||
| 4 | 新对话问事业,答完后说「你说我事业不顺,我觉得挺顺的」 | 认下、改读。不进入校正,下面没有提议卡 |
|
||||
| 5 | 接着说一件过去的事和盘对不上,并说出生时间是估的,但不要说「帮我校正」 | 回答下面出现提议卡。不直接打开校正 |
|
||||
| 6 | 换到一个家人档案,第一句就说「请帮我校正出生时间」 | 一句「给家人校正暂时还不支持」。不打开校正 |
|
||||
| 7 | 在校正开关关闭,或价格暂时不可用时,本人说「请帮我做生时校正」 | 一句「出生时间核对这会儿还做不了。」不沉默,不写价格,不写内部原因。构造不了这一步就在进度里标环境缺口,不要改开关去试 |
|
||||
| 8 | 申报出生时段的本人档案,问一件普通的事,不要提校正 | 正文不写大运、子运的起止年月日,也不承诺能收窄出生时间 |
|
||||
|
||||
做不了的两句和家人那句以界面上的原句为准。点数:直接进入的那一轮不按解读扣咨询点;校正本身仍按原来的开案扣费。
|
||||
@@ -0,0 +1,52 @@
|
||||
# 生时校正:开场说最近一次换段 · 真机清单(2026-10-12,BUG-1307)
|
||||
|
||||
用公开盘,不要用真实用户的出生资料。盘是 Steve Jobs,1955-02-24,旧金山(纬度 37.77、经度 -122.42、西八区)。下面的钟点是为了走两种开场故意填的,不是在改这份公开记录。
|
||||
|
||||
校正开场按这张盘的候选窗口扫描,岁差固定 Raman、交点固定平均。星盘页如果选了别的岁差,这一句仍按 Raman 算。出生证界面写「前后 2 分钟」,校正实际开窗是前后 15 分钟。开场里「现在先在哪一段之间找」就是这个窗口。
|
||||
|
||||
选题开关默认关。中间那句说到句号为止,不出现「接下来先问这几件」。第一题仍按原来的顺序,不会因为提到学业或父母就提前。
|
||||
|
||||
扫描接口没连上时,开场仍是原来的两句。那是退路。先确认校正服务可用,再照下面走。
|
||||
|
||||
## 1. 紧贴换段
|
||||
|
||||
1. 新建这个人,申报方式选「有出生证或医院记录,精确到分钟」,钟点填 19:01。
|
||||
2. 明确说要做生时校正,进入校正。
|
||||
3. 开场正文三句。第一句里的窗口是 18:46–19:16。中间这一句逐字是:
|
||||
|
||||
你填的时间离最近会换的地方不到 1 分钟,两边不一样的是学业、父母。
|
||||
|
||||
4. 不应出现:秒、分盘、盘面、大运、区间、精确到秒、记录时间错了、记录时间不准、已确认、接下来先问这几件。
|
||||
5. 接着答一两件带年月的经历。下一题按原来的收集顺序来,不因为这句提到学业、父母就被换掉。
|
||||
|
||||
## 2. 这张盘上离得最远的一档
|
||||
|
||||
同一人,钟点改成 19:04,申报仍用出生证(窗口同样是前后 15 分钟,18:49–19:19)。「家人记得大概时间」再选「差不多准」也是这 15 分钟。
|
||||
|
||||
中间这一句逐字是:
|
||||
|
||||
你填的时间离最近会换的地方差 3 分钟,两边不一样的是学业、父母。
|
||||
|
||||
这张盘在这段傍晚里,离换段最远就是 3 分钟。这里的「远离」是量到的最大间隔,不是一个很宽的空档。
|
||||
|
||||
## 3. 顺手核对公开记录钟点
|
||||
|
||||
钟点用公开记录 19:15,出生证或「差不多准」都是 19:00–19:30。中间这一句逐字是:
|
||||
|
||||
你填的时间离最近会换的地方差 2 分钟,两边不一样的是婚恋、住处、学业、子女、父母、钱、身体。
|
||||
|
||||
没有「工作」。本命上升也换,这句话不提。
|
||||
|
||||
## 4. 只知道大概时段
|
||||
|
||||
申报方式选「只知道大概时段,或完全不知道」。开场不加中间那句,仍是原来两句:先说在哪一段里找,再说你讲几件大事和大概年月。
|
||||
|
||||
## 5. 窗口里看不到换段
|
||||
|
||||
这句话是:
|
||||
|
||||
你填的时间附近这些盘都不换,先从经历开始。
|
||||
|
||||
校正开窗至少前后 15 分钟,而这张盘几分钟就换一次,界面上选不出这么窄的窗口。这一句由自动化测试覆盖:只扫描 19:01–19:06、记录钟点 19:04,结果是窗口内没有换段,文案就是上面这一句。
|
||||
|
||||
有任何一步和上面不一样,记下是第几步,以及开场实际写了什么。不要把真实用户的出生时间写进记录。
|
||||
+5
-1
@@ -11,6 +11,8 @@
|
||||
|
||||
不新增等待动画;打开校正沿用既有一次揭幕。
|
||||
|
||||
**明确要求则直接打开(2026-10-12,BUG-1306)**:用户这一轮明说要做校正时,不再先出卡再点。回答只留一句过渡,界面沿用既有一次揭幕直接打开校正面。没有新的按钮、输入框、等待动画或 Home 状态。推得不准时的主动提议仍用 `.rectification-offer-card`。做不了时只显示一句话,不静默,也不在对话里假装校正。
|
||||
|
||||
## 用量页分段计时(2026-10-05,在途)
|
||||
|
||||
`/admin/usage` 在原表格上多一列「分段」。格子里四行:分类耗时、第 0 步、第 1 步、第 1 步开始到第一个正文字符。每步带推理、输出、输入、缓存输入 token。供应商没返回的数显示「—」。不新开页面,不改表,数字来自 `usage_ledger.metadata` 里已有的 JSON。没有新的等待动画或输入框。
|
||||
@@ -62,7 +64,9 @@ This file adapts the full visual analysis in `CLAUDE_DESIGN.md` to the shipped J
|
||||
|
||||
## 校正开场与步骤回执(2026-09-26,BUG-1049 / BUG-1050)
|
||||
|
||||
开场一轮只有两段字:正文两句讲做法(要把出生时间缩小到更准的范围、现在先在哪段时间里找;你说几件大事和大概年月,我拿去和星盘对照),下面是问题块里的题干(六个例子 + 一个带年月的回答示例)。题干只出现一次:正文里和题干逐句相同或几乎相同的句子才会被去掉;只是开头几个字一样、内容不同的句子保留(以前按前 12 个字判定,把正文里的例子句删掉了)。
|
||||
开场一轮只有两段字:正文讲做法(要把出生时间缩小到更准的范围、现在先在哪段时间里找;你说几件大事和大概年月,我拿去和星盘对照),下面是问题块里的题干(六个例子 + 一个带年月的回答示例)。题干只出现一次:正文里和题干逐句相同或几乎相同的句子才会被去掉;只是开头几个字一样、内容不同的句子保留(以前按前 12 个字判定,把正文里的例子句删掉了)。
|
||||
|
||||
2026-10-12(BUG-1307):服务端已经量过换段时,正文在原来两句中间多一句,说明填的时间离最近会换的地方差几分钟、两边哪几件事不同。本命上升也换时,同一句先说「这里连本命上升都换」,再写其余会变的事;没有其余的事时,这一句就停在「这里连本命上升都换。」,不另起一句。没有新组件、没有新动效。句数上限仍是 3。只知道大概时段、这段时间里不换、或扫描没做成时,口径见 VOICE 同一节。选题顺序的开关默认关,开场不承诺「接下来先问这几件」。只有本命上升在换时也不说这句。
|
||||
|
||||
> 2026-10-01 修订:校正回复结算后不再显示步骤回执(BUG-1137),本段只适用于写作中的步骤行。
|
||||
|
||||
|
||||
+16
-4
@@ -23,10 +23,12 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
|
||||
|
||||
首页开场语下面可以有一行今日趋势(今日星语卡片的 trend,每日生成);还没生成时写「今天的星语还没写出来。」,没有出生分钟时不写。入口按钮下面平时不写字,只在用户需要动手时写一句:有没做完的校正写「上次那次校正还没完成,可以在历史对话里接着做。」;当前人物不是本人写「生时校正暂时只支持本人。」。不再写「不确定出生时间时,用记得住的经历一步步缩小范围」「上次已经校正完,可以拿最新资料再来一次」,也不要用「·」把两句不相干的提示拼成一行。
|
||||
|
||||
## 生时校正从对话进入(2026-10-07,BUG-1247~1251)
|
||||
## 生时校正从对话进入(2026-10-07,BUG-1247~1251;2026-10-12,BUG-1306)
|
||||
|
||||
- **何时提议**:用户说推得不准时先分清读法问题和时间问题。只是不认同某个解释,按纠正规则认下、改读,不提校正;说出过去的具体事情和盘对不上、说出生时间是估的或记不准、或明确想核对出生时间,agent 才调用提议工具。同一次对话提议过、用户没接,不再主动提;规则只定义在 `consultation-thinking-plan.ts` 的 `RECTIFICATION_OFFER_RULE`。不用关键词判断。
|
||||
- **正文怎么提**:一句,放在最后一段(例:「如果想确认出生时间准不准,可以用下面的卡片,答几道过去经历的题核对一下。」),不写价格和题数——卡片上写。给家人档案时说「给家人校正暂时还不支持」,不出卡。
|
||||
- **何时提议**:用户说推得不准时先分清读法问题和时间问题。只是不认同某个解释,按纠正规则认下、改读,不提校正。不承诺能收窄出生时间,不在对话里收集经历做校正。说出过去的具体事情和盘对不上、说出生时间是估的或记不准、但没有明确要做校正,才调用提议工具。同一次对话提议过、用户没接,不再主动提;用户自己再要求时传 `userAsked=true`。规则只定义在 `consultation-thinking-plan.ts` 的 `RECTIFICATION_OFFER_RULE`。不用关键词判断。
|
||||
- **明确要求则直接进入**:用户这一轮明确要做校正,由模型判断,不用关键词。服务端放行后直接打开校正:不先写解读,不用点卡。过渡只有一句「好,我们开始核对出生时间。」已经有一次没做完的,直接接着做那一次,不新开。
|
||||
- **做不了必须说一句**:开关关着,或暂时没有价格,都说「出生时间核对这会儿还做不了。」不写内部原因,不写价格,不得沉默。家人档案说「给家人校正暂时还不支持」,不开案。有进行中的校正但这次打不开原来的会话,说「可在历史对话里接着做。」
|
||||
- **正文怎么提**(没有明确要求、只是时间可能不准):一句,放在最后一段(例:「如果想确认出生时间准不准,可以用下面的卡片,答几道过去经历的题核对一下。」),不写价格和题数——卡片上写。
|
||||
- **卡片**:「可以用几道过去经历的题,核对一下你的出生时间。大约 N 题,会用掉 M 点;开始答第一题时才扣。」按钮「开始核对出生时间」;已开始「出生时间核对已经开始。」/「继续核对出生时间」。点数来自 `feature_pricing`,不写死。
|
||||
- **回来**:分隔线「出生时间改为 HH:MM(生时校正)」/「出生时间没有改」;之前的回答上方「按校正前的时间」;按钮「按新时间重新看:「原问题」」。校正面按钮「回到刚才的对话」。不再说「新建对话」。
|
||||
|
||||
@@ -133,6 +135,16 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
|
||||
- 题干里的「2015 年夏天换了工作」是固定示例,不是从生日推出来的;「不得用生日推年份」照旧适用于其他题干。
|
||||
- 开场不再说「最后给区间和代表分钟,不给精确到秒」。「这只是代表性候选,不是已确认的唯一出生分钟。」仍写在交付卡上。
|
||||
|
||||
开场可以在这两句中间再加一句(2026-10-12,BUG-1307)。句数上限仍是 3,没有放宽。这一句由服务端按扫描结果原样放进去,模型不现编:
|
||||
|
||||
- 记录了钟点,而且这段时间里有换段:`你填的时间离最近会换的地方不到 1 分钟,两边不一样的是父母、工作。` 不足 1 分钟写「不到 1 分钟」,满 1 分钟写「差 N 分钟」。两边只用这些词:婚恋、工作、住处、学业、子女、父母、钱、身体。没有对应的事,就不写「两边不一样」。
|
||||
- 本命上升也在这次换段里:同一句先写「这里连本命上升都换」,再写两边不一样的事。例如「你填的时间离最近会换的地方差 4 分钟,这里连本命上升都换,两边不一样的是工作、父母。」没有别的事时是「你填的时间离最近会换的地方不到 1 分钟,这里连本命上升都换。」仍是一句,不另起一句。只有本命上升在换时,不说「接下来先问这几件」。
|
||||
- 选题开关默认关。关着的时候这一句到句号为止,不说「接下来先问这几件」。开关打开、离得近、又有这几件事时,句号才换成「,接下来先问这几件。」
|
||||
- 这段时间里都不换:`你填的时间附近这些盘都不换,先从经历开始。`
|
||||
- 只知道大概时段,或扫描没做成:不加这一句,仍是原来两句。
|
||||
|
||||
这一句不用:秒、分盘、盘面、大运、区间、精确到秒、记录时间错了、记录时间不准、已确认、D1。
|
||||
|
||||
回答上方的步骤名(完成 / 进行中)一律大白话,同一个名字只出现一次:
|
||||
|
||||
| 工具 | 完成名(旧 → 新) | 进行中句(新) |
|
||||
@@ -298,7 +310,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
|
||||
| 坏 | 好 | 为什么 |
|
||||
|---|---|---|
|
||||
| Rahu 大运为 [具体时间已省略] 至 [具体时间已省略]。 | Rahu 大运为 2013 年 11 月中下旬至 2031 年 11 月中下旬。按范围看,不写成单一分钟。 | 出生范围用户要拿到区间说法;算出来的边界不是要删的字。 |
|
||||
| 眼下按 04:45–05:15 来核对,用你记得的经历对照大运和盘面变化。最后给区间和代表分钟,不给精确到秒。(题干:先说你最容易想起的一两件,年月大概就行。) | 我们来把你的出生时间缩小到更准的范围,现在先在 04:45–05:15 之间找。做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照。(题干:先说一两件你记得的大事,比如上大学、第一份工作、搬到别的城市、谈恋爱或结婚、家里添丁、生病住院。说个大概年月就行,例如「2015 年夏天换了工作」。) | 开场正文两句大白话讲做法,不用大运、盘面、代表分钟、精确到秒这类还没解释过的词,不写年份、不提问;例子和回答示例放在题干里,只出现一次(BUG-1049)。 |
|
||||
| 眼下按 04:45–05:15 来核对,用你记得的经历对照大运和盘面变化。最后给区间和代表分钟,不给精确到秒。(题干:先说你最容易想起的一两件,年月大概就行。) | 我们来把你的出生时间缩小到更准的范围,现在先在 04:45–05:15 之间找。做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照。(题干:先说一两件你记得的大事,比如上大学、第一份工作、搬到别的城市、谈恋爱或结婚、家里添丁、生病住院。说个大概年月就行,例如「2015 年夏天换了工作」。)服务端量到换段时,可以在这两句中间加一句,例如「你填的时间离最近会换的地方差 5 分钟,两边不一样的是父母、工作。」 | 开场正文用大白话讲做法,不用大运、盘面、代表分钟、精确到秒这类还没解释过的词,不写年份、不提问;例子和回答示例放在题干里,只出现一次(BUG-1049)。2026-10-12 起中间那句仍算在三句里面,不说秒,开关关着时不说接下来先问。本命上升也换时,这一句先写「这里连本命上升都换」,再写其余的事;只有本命上升在换时仍不说接下来先问(BUG-1307)。 |
|
||||
| (服务端探针 2023 感情)2023 年前后,你有没有一段认真开始或结束的关系?逐字复读模板。 | 你刚才说到工作这块已经比较清楚了。2023 年前后,有没有一段认真开始或结束的关系? | 接着用户上一句,同年份同家族,不复读模板。 |
|
||||
| (服务端探针 2016 升学)2018 年你是哪年上的大学? | 2016 年前后,你是哪年上的大学? | 不得改年份。 |
|
||||
| (服务端探针 2019 入职)2019 年前后,你有没有换过工作?A. 明确发生且时间吻合 | 2019 年前后,你有没有换过工作? | 题干不写选项字面;选项由服务端画在同一条消息里。 |
|
||||
|
||||
@@ -735,7 +735,7 @@ export default function Home() {
|
||||
startNewChat,
|
||||
continueInNewChat,
|
||||
refreshAccount,
|
||||
openAccountDialog, openRectificationSession,
|
||||
openAccountDialog, openRectificationSession, openRectificationFromChat,
|
||||
onboardingPaywallShown, setOnboardingPaywallOpen,
|
||||
});
|
||||
const jumpToLatestVisible = !rectificationSurfaceOpen
|
||||
|
||||
@@ -15,7 +15,8 @@ import { recordModerationEvent } from "@/lib/moderation/log";
|
||||
import { consultationDomainSchema } from "@/lib/consultation-domain-registry";
|
||||
import { parseAgentReply } from "@/lib/agent-reply";
|
||||
import { revisitQuestionForOffer, sessionHasRectificationOffer } from "@/lib/rectification-in-chat";
|
||||
import { decideRectificationOffer } from "@/lib/rectification-in-chat-server";
|
||||
import { decideRectificationOffer, persistOpenedDirectOffer } from "@/lib/rectification-in-chat-server";
|
||||
import { getRectificationEntrySummary } from "@/lib/rectification-agentic/v9/case-service";
|
||||
import { createConsultationReplyMetadata } from "@/lib/consultation-reply-metadata";
|
||||
import {
|
||||
ConsultationPlanValidationError,
|
||||
@@ -244,7 +245,7 @@ function generalNoMinuteInstruction(hasPublicDaily: boolean) {
|
||||
}
|
||||
|
||||
function declaredWindowInstruction() {
|
||||
return "当前是声明出生窗口咨询,没有单一出生分钟。如需个人结构结论,必须调用 run-jyotish-window-consultation。探针时刻不是出生时间,不得把 00:00、时段中点、中午或任一探针写成出生分钟。只能根据稳定层作方向性回答;上升或宫位若在窗口内变化,列出可能星座,不得写成「你的上升是 X」。精确应期一律不可用。这只是粗看。" + RECTIFICATION_OFFER_RULE;
|
||||
return "当前是声明出生窗口咨询,没有单一出生分钟。如需个人结构结论,必须调用 run-jyotish-window-consultation。探针时刻不是出生时间,不得把 00:00、时段中点、中午或任一探针写成出生分钟。只能根据稳定层作方向性回答;上升或宫位若在窗口内变化,列出可能星座,不得写成「你的上升是 X」。精确应期一律不可用。大运、子运只说「在哪一段附近」,不写起止年月日。这只是粗看。" + RECTIFICATION_OFFER_RULE;
|
||||
}
|
||||
|
||||
function usesPublicDailyGeneralAgent(
|
||||
@@ -902,6 +903,55 @@ export async function POST(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit rectification (BUG-1306): one short sentence, same free settlement
|
||||
// as smalltalk, so a turn with no reading does not spend a consultation credit.
|
||||
// The RPC message is only role/text/responseKind. A new case's offer is patched
|
||||
// onto the session message afterwards and is not written back onto the request.
|
||||
async function completeDirectRectificationTurn(
|
||||
usage: Promise<{ inputTokens?: number; outputTokens?: number }>,
|
||||
): Promise<AgentSettlementResult> {
|
||||
const entry = latencyState?.rectificationDirectEntry;
|
||||
const offer = latencyState?.rectificationOffer;
|
||||
if (!entry) throw new Error("direct_entry_missing");
|
||||
try {
|
||||
const actualUsage = await usagePayload(usage);
|
||||
const responseMessage = { role: "assistant" as const, text: entry.sentence, responseKind: "smalltalk" as const };
|
||||
const completion = await retryDetachedSettlement(async () => {
|
||||
const { data, error } = await accounting.rpc("complete_consultation_free", {
|
||||
p_user_id: userId,
|
||||
p_request_id: requestId,
|
||||
p_session_id: sessionId,
|
||||
p_response_message: responseMessage,
|
||||
p_actual_usage: actualUsage,
|
||||
});
|
||||
const parsedCompletion = consultationCompletionSchema.safeParse(first(data ?? []));
|
||||
if (error || !parsedCompletion.success) {
|
||||
throw new CreditRpcError(error?.message || "invalid_free_completion");
|
||||
}
|
||||
if (!parsedCompletion.data.success && parsedCompletion.data.error_code !== "request_cancelled") {
|
||||
throw new CreditRpcError(parsedCompletion.data.error_code || "free_completion_rejected");
|
||||
}
|
||||
if (parsedCompletion.data.success && entry.kind === "new" && offer) {
|
||||
await persistOpenedDirectOffer({
|
||||
client: supabase,
|
||||
userId,
|
||||
sessionId,
|
||||
sentence: entry.sentence,
|
||||
offer,
|
||||
});
|
||||
}
|
||||
return parsedCompletion.data;
|
||||
});
|
||||
if (!completion.success && completion.error_code !== "request_cancelled") {
|
||||
throw new CreditRpcError(completion.error_code || "free_completion_rejected");
|
||||
}
|
||||
return "completed";
|
||||
} catch (error) {
|
||||
await cancel();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
let settlement: Promise<AgentSettlementResult> | null = null;
|
||||
function settleResult(action: () => Promise<AgentSettlementResult>) {
|
||||
settlement ??= action();
|
||||
@@ -966,6 +1016,21 @@ export async function POST(request: Request) {
|
||||
}),
|
||||
alreadyOffered: sessionHasRectificationOffer(chatSession?.messages),
|
||||
revisitQuestion: revisitQuestionForOffer(chatSession?.messages, visibleQuestion, requestId),
|
||||
sourceSessionId: sessionId,
|
||||
loadResumableSessionId: async () => {
|
||||
const summary = await getRectificationEntrySummary(accounting, userId);
|
||||
const caseId = summary.latestResumable?.caseId;
|
||||
if (!caseId) return null;
|
||||
const { data, error } = await accounting
|
||||
.from("agentic_rectification_cases")
|
||||
.select("session_id")
|
||||
.eq("user_id", userId)
|
||||
.eq("id", caseId)
|
||||
.maybeSingle();
|
||||
if (error || !data) return null;
|
||||
const resumableSessionId = (data as { session_id?: unknown }).session_id;
|
||||
return typeof resumableSessionId === "string" && resumableSessionId ? resumableSessionId : null;
|
||||
},
|
||||
};
|
||||
const hooks = createConsultationRuntimeHooks(state);
|
||||
const usages: Promise<Usage>[] = [];
|
||||
@@ -1203,6 +1268,28 @@ export async function POST(request: Request) {
|
||||
}
|
||||
: { route: "pending", status: "blocked", preciseTiming: "blocked", missingLayers: [] };
|
||||
|
||||
const settleAnswer = (
|
||||
output: string,
|
||||
agentExecutionReceipt: AgentExecutionReceipt,
|
||||
techniqueTruth: string,
|
||||
workflow: WorkflowReceipt,
|
||||
thinkingText?: string,
|
||||
thinkingSections?: PublicThinkingSection[],
|
||||
) => {
|
||||
if (state.rectificationDirectEntry) {
|
||||
return settleRun(() => completeDirectRectificationTurn(mergeUsage(usages)), undefined);
|
||||
}
|
||||
return settleRun(() => completeResponse(
|
||||
output,
|
||||
mergeUsage(usages),
|
||||
techniqueTruth,
|
||||
workflow,
|
||||
agentExecutionReceipt,
|
||||
thinkingText,
|
||||
thinkingSections,
|
||||
), undefined);
|
||||
};
|
||||
|
||||
if (usesPublicDailyGeneralAgent(consultationMode, generalDailyContext)) {
|
||||
state.workflowReceipt = workflowReceipt;
|
||||
const agent = getGeneralJyotishAgent(selectedModel);
|
||||
@@ -1260,15 +1347,14 @@ export async function POST(request: Request) {
|
||||
headers: { "x-jyotish-birth-time-mode": consultationMode },
|
||||
onFirstActivity: markFirstActivity,
|
||||
onFirstOutput: markFirstText,
|
||||
onComplete: (output, agentExecutionReceipt, thinkingText, thinkingSections) => settleRun(() => completeResponse(
|
||||
onComplete: (output, agentExecutionReceipt, thinkingText, thinkingSections) => settleAnswer(
|
||||
output,
|
||||
mergeUsage(usages),
|
||||
agentExecutionReceipt,
|
||||
generalDailyContext ? "public-panchanga-only" : "not-applicable",
|
||||
workflowReceipt,
|
||||
agentExecutionReceipt,
|
||||
thinkingText,
|
||||
thinkingSections,
|
||||
), undefined),
|
||||
),
|
||||
onError: (error) => settleRun(
|
||||
cancel,
|
||||
toAgentObservabilityErrorCode(error),
|
||||
@@ -1393,15 +1479,14 @@ export async function POST(request: Request) {
|
||||
headers: { "x-jyotish-birth-time-mode": consultationMode },
|
||||
onFirstActivity: markFirstActivity,
|
||||
onFirstOutput: markFirstText,
|
||||
onComplete: (output, agentExecutionReceipt, thinkingText, thinkingSections) => settleRun(() => completeResponse(
|
||||
onComplete: (output, agentExecutionReceipt, thinkingText, thinkingSections) => settleAnswer(
|
||||
output,
|
||||
mergeUsage(usages),
|
||||
agentExecutionReceipt,
|
||||
state.techniqueTruth ?? "declared-window",
|
||||
state.workflowReceipt ?? workflowReceipt,
|
||||
agentExecutionReceipt,
|
||||
thinkingText,
|
||||
thinkingSections,
|
||||
), undefined),
|
||||
),
|
||||
onError: (error) => settleRun(
|
||||
cancel,
|
||||
toAgentObservabilityErrorCode(error),
|
||||
@@ -1506,16 +1591,15 @@ export async function POST(request: Request) {
|
||||
onFirstActivity: markFirstActivity,
|
||||
onFirstOutput: markFirstText,
|
||||
onComplete: (output, agentExecutionReceipt, thinkingText, thinkingSections) => {
|
||||
answerForTelemetry = output;
|
||||
return settleRun(() => completeResponse(
|
||||
if (!state.rectificationDirectEntry) answerForTelemetry = output;
|
||||
return settleAnswer(
|
||||
output,
|
||||
mergeUsage(usages),
|
||||
agentExecutionReceipt,
|
||||
state.techniqueTruth ?? "unknown",
|
||||
state.workflowReceipt ?? workflowReceipt,
|
||||
agentExecutionReceipt,
|
||||
thinkingText,
|
||||
thinkingSections,
|
||||
), undefined);
|
||||
);
|
||||
},
|
||||
onError: (error) => settleRun(
|
||||
cancel,
|
||||
|
||||
@@ -155,6 +155,8 @@ export type ConsultationRunParams = {
|
||||
onboardingPaywallShown?: MutableRefObject<boolean>;
|
||||
setOnboardingPaywallOpen?: (open: boolean) => void;
|
||||
openRectificationSession: (exactSessionId: string) => Promise<void> | void;
|
||||
/** Direct entry from this consultation (BUG-1306). Same opener as the offer card. */
|
||||
openRectificationFromChat: (sourceSessionId: string) => Promise<void> | void;
|
||||
};
|
||||
|
||||
export function useConsultationRun(params: ConsultationRunParams) {
|
||||
@@ -215,6 +217,7 @@ export function useConsultationRun(params: ConsultationRunParams) {
|
||||
onboardingPaywallShown,
|
||||
setOnboardingPaywallOpen,
|
||||
openRectificationSession,
|
||||
openRectificationFromChat,
|
||||
} = params;
|
||||
// First refusal for credits on this page → redeem paywall; later ones → billing (P4, BUG-1080).
|
||||
const openRedeemPaywallOnce = () => tryOpenRedeemPaywall({
|
||||
@@ -874,6 +877,7 @@ export function useConsultationRun(params: ConsultationRunParams) {
|
||||
};
|
||||
let agentExecutionReceipt: AgentExecutionReceipt | undefined;
|
||||
let rectificationOffer: RectificationOfferRecord | undefined;
|
||||
let rectificationDirectOpen: Extract<ConsultationAgentPublicEvent, { type: "run.completed" }>["rectificationDirectOpen"];
|
||||
let runCompleted = false;
|
||||
let truncatedFailure: Extract<ConsultationAgentPublicEvent, { type: "run.failed" }> | undefined;
|
||||
const reader = response.body.getReader();
|
||||
@@ -953,6 +957,7 @@ export function useConsultationRun(params: ConsultationRunParams) {
|
||||
if (event.type === "run.completed") {
|
||||
runCompleted = true;
|
||||
rectificationOffer = event.rectificationOffer;
|
||||
rectificationDirectOpen = event.rectificationDirectOpen;
|
||||
if (event.responseKind === "smalltalk") {
|
||||
responseKind = "smalltalk";
|
||||
} else if (event.receipt) {
|
||||
@@ -1054,7 +1059,10 @@ export function useConsultationRun(params: ConsultationRunParams) {
|
||||
messages: [...userSession.messages, {
|
||||
role: "assistant",
|
||||
text: reply.text,
|
||||
...(responseKind === "smalltalk" ? { responseKind } : {
|
||||
...(responseKind === "smalltalk" ? {
|
||||
responseKind,
|
||||
...(rectificationOffer ? { rectificationOffer } : {}),
|
||||
} : {
|
||||
...(streamedThinking.trim() ? { thinkingText: streamedThinking.trim().slice(0, 4000) } : {}),
|
||||
...(thinkingSections.length ? { thinkingSections } : {}),
|
||||
techniqueTruth,
|
||||
@@ -1066,6 +1074,11 @@ export function useConsultationRun(params: ConsultationRunParams) {
|
||||
updatedAt: timestamp(),
|
||||
};
|
||||
updateSession(sessionId, () => completedSession);
|
||||
if (rectificationDirectOpen?.mode === "resume" && rectificationDirectOpen.resumeSessionId) {
|
||||
void openRectificationSession(rectificationDirectOpen.resumeSessionId);
|
||||
} else if (rectificationDirectOpen?.mode === "new") {
|
||||
void openRectificationFromChat(rectificationDirectOpen.sourceSessionId);
|
||||
}
|
||||
try {
|
||||
await persistSession(completedSession);
|
||||
} catch (error) {
|
||||
|
||||
@@ -139,12 +139,21 @@ const sessionTitleSchema = z.object({
|
||||
type: z.literal("session.title"),
|
||||
title: z.string().trim().min(1).max(48),
|
||||
}).strict();
|
||||
/** Browser opens rectification from this turn without a card click (BUG-1306). */
|
||||
const rectificationDirectOpenSchema = z.object({
|
||||
sourceSessionId: z.string().uuid(),
|
||||
mode: z.enum(["new", "resume"]),
|
||||
resumeSessionId: z.string().uuid().optional(),
|
||||
}).strict();
|
||||
|
||||
const runCompletedSchema = z.object({
|
||||
type: z.literal("run.completed"),
|
||||
receipt: agentExecutionReceiptSchema.optional(),
|
||||
responseKind: z.literal("smalltalk").optional(),
|
||||
/** The answer offered birth-time rectification (BUG-1248); the card hangs under it. */
|
||||
rectificationOffer: rectificationOfferRecordSchema.optional(),
|
||||
/** Present for a direct open or resume. A refusal is smalltalk and omits this. */
|
||||
rectificationDirectOpen: rectificationDirectOpenSchema.optional(),
|
||||
}).strict();
|
||||
// A failure is the case the receipt is most needed for, so it carries the same
|
||||
// allowlisted receipt a completed run does. It stays optional because the
|
||||
|
||||
@@ -455,11 +455,11 @@ export function natalAnswerShapeInstruction(): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* C2 (TASK-rectification-in-chat-step1-20261006, BUG-1248): when "推得不准"
|
||||
* may be the birth time, the agent offers rectification through the offer
|
||||
* tool; it is never decided from keywords. Defined here once.
|
||||
* C2 (BUG-1248) plus direct entry when this turn explicitly asks (BUG-1306).
|
||||
* Never decided from keywords. Defined here once; the offer tool names the
|
||||
* same parameters and does not restate this rule.
|
||||
*/
|
||||
export const RECTIFICATION_OFFER_RULE = "出生时间核对:用户说你推得不准时,先分清是读法问题还是时间问题。只是不认同某个解释,按纠正规则认下、改读,不提校正。用户说出过去某件具体的事和盘对不上、说自己的出生时间是估的或记不准、或明确想核对出生时间,才在写回答之前调用 offer-birth-time-rectification,再按工具结果在正文里用一句话提一下(不写价格和题数,卡片上有)。同一次对话提议过、用户没接,就不再主动提;用户自己再要求时调用并传 userAsked=true。工具说不能提供时(比如问的是家人),照工具说明用一句话说清楚。";
|
||||
export const RECTIFICATION_OFFER_RULE = "出生时间核对:用户说你推得不准时,先分清是读法问题还是时间问题。只是不认同某个解释,按纠正规则认下、改读,不提校正。不承诺能收窄出生时间,不在对话里收集经历做校正。用户这一轮明确要做校正,就在写回答之前调用 offer-birth-time-rectification 并传 explicitRequest=true;工具通过后只写那一句过渡,不写解读。用户说出过去某件具体的事和盘对不上、说自己的出生时间是估的或记不准、但没有明确要做校正,才在写回答之前调用 offer-birth-time-rectification,再按工具结果在正文里用一句话提一下(不写价格和题数,卡片上有)。同一次对话提议过、用户没接,就不再主动提;用户自己再要求时调用并传 userAsked=true。工具说不能提供时(比如问的是家人),照工具说明用一句话说清楚。";
|
||||
|
||||
/** D4 / BUG-1166: the user corrects an earlier reading; acknowledge it, never defend the reading. */
|
||||
export const FOLLOW_UP_CORRECTION_RULE = "用户纠正了前面的判断(比如说父亲其实不常在身边):先认下这件事,再只说盘上真正对得上的那几条、原来哪句读偏了;盘本身分不出的,直说「盘本身分不出这一点」,不把普通配置说成必然;不为原判断辩护。";
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Nearest divisional-chart change, from window-scan transitions only.
|
||||
* Question order is a switch and stays off unless the env flag is "on".
|
||||
*/
|
||||
|
||||
export const CHART_LAYERS = ["d9", "d10", "d4", "d5", "d24", "d7", "d12", "d2", "d11", "d30", "d1"] as const;
|
||||
|
||||
const LAYER_DOMAIN: Readonly<Record<string, string>> = {
|
||||
d9: "relationship",
|
||||
d10: "career",
|
||||
d4: "relocation",
|
||||
d5: "education",
|
||||
d24: "education",
|
||||
d7: "family",
|
||||
d12: "family",
|
||||
d2: "finance",
|
||||
d11: "finance",
|
||||
d30: "health_pressure",
|
||||
};
|
||||
|
||||
const LAYER_TOPIC: Readonly<Record<string, string>> = {
|
||||
d9: "婚恋",
|
||||
d10: "工作",
|
||||
d4: "住处",
|
||||
d5: "学业",
|
||||
d24: "学业",
|
||||
d7: "子女",
|
||||
d12: "父母",
|
||||
d2: "钱",
|
||||
d11: "钱",
|
||||
d30: "身体",
|
||||
};
|
||||
|
||||
/** Not adopted. The 77-case gate was not cleared, so the switch stays off. */
|
||||
export const NEAREST_BOUNDARY_PROBE_THRESHOLD_MINUTES = 5;
|
||||
|
||||
export type BoundaryLayer = Readonly<{
|
||||
layer: string;
|
||||
fromSign: string;
|
||||
toSign: string;
|
||||
topic: string | null;
|
||||
domain: string | null;
|
||||
}>;
|
||||
|
||||
export type NearestBoundary = Readonly<{
|
||||
found: boolean;
|
||||
reason: "unavailable" | "no_transition" | null;
|
||||
at: string | null;
|
||||
distanceMinutes: number | null;
|
||||
layers: readonly BoundaryLayer[];
|
||||
topics: readonly string[];
|
||||
domains: readonly string[];
|
||||
}>;
|
||||
|
||||
const EMPTY: NearestBoundary = {
|
||||
found: false, reason: "unavailable", at: null, distanceMinutes: null, layers: [], topics: [], domains: [],
|
||||
};
|
||||
|
||||
function clock(value: string | null | undefined): number | null {
|
||||
const raw = value?.trim().slice(0, 5) ?? "";
|
||||
if (!/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(raw)) return null;
|
||||
return Number(raw.slice(0, 2)) * 60 + Number(raw.slice(3, 5));
|
||||
}
|
||||
|
||||
function distance(left: number, right: number): number {
|
||||
const span = Math.abs(left - right);
|
||||
return Math.min(span, 1440 - span);
|
||||
}
|
||||
|
||||
export function nearestBoundaryFromTransitions(
|
||||
transitions: readonly Readonly<{ layer?: string; at?: string; from_sign?: string; to_sign?: string }>[] | null | undefined,
|
||||
recordedTime: string | null | undefined,
|
||||
): NearestBoundary {
|
||||
const recorded = clock(recordedTime);
|
||||
if (recorded === null || !transitions) return EMPTY;
|
||||
const changes: { at: number; layer: string; fromSign: string; toSign: string }[] = [];
|
||||
for (const row of transitions) {
|
||||
const layer = row.layer ?? "";
|
||||
if (!CHART_LAYERS.includes(layer as (typeof CHART_LAYERS)[number])) continue;
|
||||
const before = row.from_sign;
|
||||
const after = row.to_sign;
|
||||
if (!before || !after || before === after) continue;
|
||||
const at = clock(row.at);
|
||||
if (at === null) continue;
|
||||
changes.push({ at, layer, fromSign: before, toSign: after });
|
||||
}
|
||||
if (!changes.length) return { ...EMPTY, reason: "no_transition" };
|
||||
const bestAt = changes.reduce((best, row) => {
|
||||
const next = distance(row.at, recorded);
|
||||
const current = distance(best, recorded);
|
||||
return next < current || (next === current && row.at < best) ? row.at : best;
|
||||
}, changes[0]!.at);
|
||||
const layers: BoundaryLayer[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const layer of CHART_LAYERS) {
|
||||
const row = changes.find((item) => item.at === bestAt && item.layer === layer);
|
||||
if (!row || seen.has(layer)) continue;
|
||||
seen.add(layer);
|
||||
layers.push({
|
||||
layer,
|
||||
fromSign: row.fromSign,
|
||||
toSign: row.toSign,
|
||||
topic: LAYER_TOPIC[layer] ?? null,
|
||||
domain: LAYER_DOMAIN[layer] ?? null,
|
||||
});
|
||||
}
|
||||
const topics = topicsForLayers(layers.map((row) => row.layer));
|
||||
const domains: string[] = [];
|
||||
for (const row of layers) {
|
||||
if (row.domain && !domains.includes(row.domain)) domains.push(row.domain);
|
||||
}
|
||||
return {
|
||||
found: true,
|
||||
reason: null,
|
||||
at: `${String(Math.floor(bestAt / 60)).padStart(2, "0")}:${String(bestAt % 60).padStart(2, "0")}`,
|
||||
distanceMinutes: distance(bestAt, recorded),
|
||||
layers,
|
||||
topics,
|
||||
domains,
|
||||
};
|
||||
}
|
||||
|
||||
/** Spoken topics, in chart order. Unknown layers and D1 add nothing. */
|
||||
export function topicsForLayers(layers: readonly string[]): string[] {
|
||||
const topics: string[] = [];
|
||||
for (const layer of CHART_LAYERS) {
|
||||
if (!layers.includes(layer)) continue;
|
||||
const topic = LAYER_TOPIC[layer];
|
||||
if (topic && !topics.includes(topic)) topics.push(topic);
|
||||
}
|
||||
return topics;
|
||||
}
|
||||
|
||||
export function nearestBoundaryProbesEnabled(flag: string | undefined = process.env.RECTIFICATION_NEAREST_BOUNDARY_PROBES): boolean {
|
||||
return flag === "on";
|
||||
}
|
||||
|
||||
/** Boundary-domain questions first; everything else keeps its incoming order. */
|
||||
export function orderByBoundaryDomains<T>(
|
||||
probes: readonly T[],
|
||||
domains: readonly string[],
|
||||
domainOf: (probe: T) => string,
|
||||
): readonly T[] {
|
||||
if (!domains.length || probes.length < 2) return probes;
|
||||
const wanted = new Set(domains);
|
||||
const first: T[] = [];
|
||||
const rest: T[] = [];
|
||||
for (const probe of probes) {
|
||||
const domain = domainOf(probe);
|
||||
(domain && wanted.has(domain) ? first : rest).push(probe);
|
||||
}
|
||||
if (!first.length || !rest.length) return probes;
|
||||
return [...first, ...rest];
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Opening scan for the nearest chart change. Failures keep the original
|
||||
* two-sentence opening. Question order stays on its own switch.
|
||||
*/
|
||||
import { resolveAyanamsa } from "../ayanamsa.ts";
|
||||
import { requestCandidateIntervals, type DatedCandidateRange } from "./core/candidate-window.ts";
|
||||
import {
|
||||
CHART_LAYERS,
|
||||
NEAREST_BOUNDARY_PROBE_THRESHOLD_MINUTES,
|
||||
nearestBoundaryProbesEnabled,
|
||||
topicsForLayers,
|
||||
} from "./nearest-boundary.ts";
|
||||
import type { OpeningBoundaryFacts } from "./user-copy.ts";
|
||||
|
||||
const BOUNDARY_CHARTS = ["D1", "D2", "D4", "D5", "D7", "D9", "D10", "D11", "D12", "D24", "D30"] as const;
|
||||
const LAYERS = new Set<string>(CHART_LAYERS);
|
||||
|
||||
export function openingFactsFromBoundaryPayload(
|
||||
payload: unknown,
|
||||
askFirst: boolean,
|
||||
): OpeningBoundaryFacts & { at?: string | null } {
|
||||
if (!payload || typeof payload !== "object") return { kind: "unavailable" };
|
||||
const row = payload as { found?: unknown; reason?: unknown; distance_minutes?: unknown; layers?: unknown };
|
||||
if (row.found !== true) return row.reason === "no_transition" ? { kind: "stable" } : { kind: "unavailable" };
|
||||
const distance = row.distance_minutes;
|
||||
if (typeof distance !== "number" || !Number.isInteger(distance) || distance < 0 || distance > 720) {
|
||||
return { kind: "unavailable" };
|
||||
}
|
||||
const rawLayers = Array.isArray(row.layers) ? row.layers : [];
|
||||
const layers = rawLayers.flatMap((item) => {
|
||||
const layer = item && typeof item === "object" ? (item as { layer?: unknown }).layer : null;
|
||||
return typeof layer === "string" && LAYERS.has(layer) ? [layer] : [];
|
||||
});
|
||||
const d1Changed = rawLayers.some((item) => {
|
||||
const layer = item && typeof item === "object" ? (item as { layer?: unknown }).layer : null;
|
||||
return layer === "d1";
|
||||
});
|
||||
const topics = topicsForLayers(layers);
|
||||
const at = (payload as { at?: unknown }).at;
|
||||
return {
|
||||
kind: "near",
|
||||
distanceMinutes: distance,
|
||||
topics,
|
||||
d1Changed,
|
||||
...(typeof at === "string" ? { at: at.slice(0, 5) } : {}),
|
||||
askFirst: askFirst && topics.length > 0 && distance <= NEAREST_BOUNDARY_PROBE_THRESHOLD_MINUTES,
|
||||
};
|
||||
}
|
||||
|
||||
function preferBoundary(
|
||||
left: OpeningBoundaryFacts & { at?: string | null },
|
||||
right: OpeningBoundaryFacts & { at?: string | null },
|
||||
): OpeningBoundaryFacts & { at?: string | null } {
|
||||
if (left.kind !== "near") return right.kind === "near" ? right : left;
|
||||
if (right.kind !== "near") return left;
|
||||
const distance = (left.distanceMinutes ?? 9999) - (right.distanceMinutes ?? 9999);
|
||||
if (distance !== 0) return distance < 0 ? left : right;
|
||||
return (left.at ?? "99:99") <= (right.at ?? "99:99") ? left : right;
|
||||
}
|
||||
|
||||
export async function fetchOpeningBoundary(input: {
|
||||
snapshot: Readonly<Record<string, unknown>>;
|
||||
range: DatedCandidateRange;
|
||||
recordedTime: string;
|
||||
}): Promise<OpeningBoundaryFacts> {
|
||||
const recorded = input.recordedTime.trim().slice(0, 5);
|
||||
if (!/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(recorded)) return { kind: "unavailable" };
|
||||
const latitude = input.snapshot.latitude;
|
||||
const longitude = input.snapshot.longitude;
|
||||
const timezone = input.snapshot.timezone_offset;
|
||||
if (typeof latitude !== "number" || typeof longitude !== "number" || typeof timezone !== "number") {
|
||||
return { kind: "unavailable" };
|
||||
}
|
||||
const intervals = requestCandidateIntervals(input.snapshot, input.range);
|
||||
const askFirst = nearestBoundaryProbesEnabled();
|
||||
let best: OpeningBoundaryFacts = { kind: "unavailable" };
|
||||
for (const interval of intervals) {
|
||||
const response = await fetch(`${process.env.JYOTISH_API_BASE?.trim() || "http://127.0.0.1:5200"}/api/rectification/v5/varga-segments`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
signal: AbortSignal.timeout(60000),
|
||||
body: JSON.stringify({
|
||||
start_at: interval.start_at,
|
||||
end_at: interval.end_at,
|
||||
latitude,
|
||||
longitude,
|
||||
timezone_offset: timezone,
|
||||
target_charts: ["D1"],
|
||||
boundary_charts: BOUNDARY_CHARTS,
|
||||
recorded_time: recorded,
|
||||
ayanamsa: resolveAyanamsa(input.snapshot),
|
||||
}),
|
||||
});
|
||||
if (!response.ok) return { kind: "unavailable" };
|
||||
const data = await response.json() as { nearest_boundary?: unknown };
|
||||
best = preferBoundary(best, openingFactsFromBoundaryPayload(data.nearest_boundary, askFirst));
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export async function resolveOpeningBoundaryFacts(input: {
|
||||
birthTimeSource?: string | null;
|
||||
reportedBirthTime?: string | null;
|
||||
candidateRange?: { start_time?: string | null; end_time?: string | null } | null;
|
||||
loadCompute: () => Promise<{ baselineBirthSnapshot: Readonly<Record<string, unknown>>; candidateRange: DatedCandidateRange }>;
|
||||
}): Promise<OpeningBoundaryFacts> {
|
||||
if (input.birthTimeSource === "period_only") return { kind: "period_only" };
|
||||
const reported = input.reportedBirthTime?.trim() ?? "";
|
||||
const range = input.candidateRange;
|
||||
if (!input.birthTimeSource || !reported || !range?.start_time || !range.end_time) return { kind: "unavailable" };
|
||||
try {
|
||||
const compute = await input.loadCompute();
|
||||
return await fetchOpeningBoundary({
|
||||
snapshot: compute.baselineBirthSnapshot,
|
||||
range: compute.candidateRange,
|
||||
recordedTime: reported,
|
||||
});
|
||||
} catch {
|
||||
return { kind: "unavailable" };
|
||||
}
|
||||
}
|
||||
@@ -76,19 +76,53 @@ export function withFirstDatedCollectInvite(stem: string): string {
|
||||
* Opening body in plain words (BUG-1049, product 2026-09-26): what we are doing
|
||||
* and how, no question, no years, no jargon. The examples live in the stem
|
||||
* right below it, so the body does not list them again.
|
||||
*
|
||||
* BUG-1307: when the server already measured a chart change, one extra sentence
|
||||
* sits between those two. The 3-sentence cap is unchanged. A natal-ascendant
|
||||
* change is named in that same sentence, before the other life topics.
|
||||
*/
|
||||
export function openingSpokenBody(range: readonly [string, string] | null, productDelivery?: ProductDelivery | null): string {
|
||||
export type OpeningBoundaryFacts = Readonly<{
|
||||
kind: "period_only" | "unavailable" | "stable" | "near";
|
||||
distanceMinutes?: number | null;
|
||||
topics?: readonly string[];
|
||||
/** Natal ascendant (layer d1) changed. Not a spoken life topic. */
|
||||
d1Changed?: boolean;
|
||||
/** Only when the question-order switch is on and this boundary is inside it. */
|
||||
askFirst?: boolean;
|
||||
}>;
|
||||
|
||||
const OPENING_BOUNDARY_TOPICS = ["婚恋", "工作", "住处", "学业", "子女", "父母", "钱", "身体"] as const;
|
||||
|
||||
export function openingBoundarySentence(facts: OpeningBoundaryFacts | null | undefined): string | null {
|
||||
if (!facts || facts.kind === "period_only" || facts.kind === "unavailable") return null;
|
||||
if (facts.kind === "stable") return "你填的时间附近这些盘都不换,先从经历开始。";
|
||||
const distance = facts.distanceMinutes;
|
||||
if (typeof distance !== "number" || !Number.isInteger(distance) || distance < 0) return null;
|
||||
const topics: string[] = [];
|
||||
for (const topic of facts.topics ?? []) {
|
||||
if ((OPENING_BOUNDARY_TOPICS as readonly string[]).includes(topic) && !topics.includes(topic)) topics.push(topic);
|
||||
}
|
||||
const place = distance < 1 ? "不到 1 分钟" : `差 ${distance} 分钟`;
|
||||
const natal = facts.d1Changed ? ",这里连本命上升都换" : "";
|
||||
const sides = topics.length ? `,两边不一样的是${topics.join("、")}` : "";
|
||||
const tail = facts.askFirst && topics.length ? ",接下来先问这几件。" : "。";
|
||||
return `你填的时间离最近会换的地方${place}${natal}${sides}${tail}`;
|
||||
}
|
||||
|
||||
export function openingSpokenBody(
|
||||
range: readonly [string, string] | null,
|
||||
productDelivery?: ProductDelivery | null,
|
||||
facts?: OpeningBoundaryFacts | null,
|
||||
): string {
|
||||
const window = formatClockRange(range);
|
||||
if (productDelivery) return [
|
||||
`我们先判断现在的出生时间能不能选定用来解读的星盘${window ? `,核对窗口是 ${window}` : ""}。`,
|
||||
"做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照。",
|
||||
].join("");
|
||||
return [
|
||||
window
|
||||
const head = productDelivery
|
||||
? `我们先判断现在的出生时间能不能选定用来解读的星盘${window ? `,核对窗口是 ${window}` : ""}。`
|
||||
: window
|
||||
? `我们来把你的出生时间缩小到更准的范围,现在先在 ${window} 之间找。`
|
||||
: "我们来把你的出生时间缩小到更准的范围。",
|
||||
"做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照。",
|
||||
].join("");
|
||||
: "我们来把你的出生时间缩小到更准的范围。";
|
||||
const tail = "做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照。";
|
||||
const extra = openingBoundarySentence(facts);
|
||||
return extra ? `${head}${extra}${tail}` : `${head}${tail}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -119,12 +153,15 @@ export function isAcceptableOpeningBody(
|
||||
body: string,
|
||||
range: readonly [string, string] | null = null,
|
||||
productDelivery?: ProductDelivery | null,
|
||||
facts?: OpeningBoundaryFacts | null,
|
||||
): boolean {
|
||||
const spoken = body.trim();
|
||||
if (productDelivery && (!spoken.includes("用来解读的星盘") || /缩小|更准的范围|最可能|已确认/.test(spoken))) return false;
|
||||
if (!spoken || OPENING_YEAR.test(spoken)) return false;
|
||||
if (/[??]/.test(spoken)) return false;
|
||||
if (OPENING_BODY_JARGON.some((word) => spoken.includes(word))) return false;
|
||||
const required = openingBoundarySentence(facts);
|
||||
if (required && !spoken.includes(required)) return false;
|
||||
const sentences = splitAfterSentencePunctuation(spoken, "。!?").map((part) => part.trim()).filter(Boolean);
|
||||
if (sentences.length === 0 || sentences.length > 3) return false;
|
||||
if (!spoken.includes("星盘") || !spoken.includes("年月")) return false;
|
||||
@@ -653,6 +690,10 @@ export function listUserVisibleCopy(): string[] {
|
||||
FIRST_DATED_COLLECT_INVITE,
|
||||
GENERIC_COLLECT_QUESTION,
|
||||
openingSpokenBody(["04:45", "05:15"]),
|
||||
openingSpokenBody(["04:45", "05:15"], null, { kind: "near", distanceMinutes: 0, topics: ["父母", "工作"] }),
|
||||
openingSpokenBody(["04:45", "05:15"], null, { kind: "near", distanceMinutes: 5, topics: ["婚恋"], askFirst: true }),
|
||||
openingSpokenBody(["04:45", "05:15"], null, { kind: "stable" }),
|
||||
openingSpokenBody(["04:45", "05:15"], null, { kind: "period_only" }),
|
||||
RECTIFICATION_USER_COPY.uncertaintyStop,
|
||||
RECTIFICATION_USER_COPY.tiedFirstStop,
|
||||
RECTIFICATION_USER_COPY.probePoolExhaustedStop,
|
||||
|
||||
@@ -292,6 +292,8 @@ export async function handleRectificationTypedMessage(
|
||||
...catalog,
|
||||
birthDate,
|
||||
candidatesSeparated: false,
|
||||
reportedTime: dossier.case.reportedBirthTime,
|
||||
birthTimeSource: dossier.case.birthTimeSource,
|
||||
});
|
||||
const persisted = await persistServerOwnedFocus({
|
||||
accounting,
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* enclosing `lastAttemptError`).
|
||||
*/
|
||||
import { resolveRectificationStepBudget } from "./step-budget";
|
||||
import { insertV9SkillRunReceipt, loadV9CaseDossier, type V9CaseDossier } from "./tool-service";
|
||||
import { insertV9SkillRunReceipt, loadV9CaseCompute, loadV9CaseDossier, type V9CaseDossier } from "./tool-service";
|
||||
import { loadCaseDecisionInputs } from "./decision-inputs.ts";
|
||||
import { RECTIFICATION_AGENT_TOOLS } from "./public-receipt";
|
||||
import { agentGenerationSettings, promptCacheUsage } from "../../agent-generation-settings.ts";
|
||||
@@ -20,10 +20,13 @@ import { parseAgentChoiceCopy, isPersistedFocusId } from "./choice-card";
|
||||
import {
|
||||
RECTIFICATION_USER_COPY,
|
||||
isAcceptableOpeningBody,
|
||||
openingBoundarySentence,
|
||||
openingRangeFromCandidateRange,
|
||||
openingSpokenBody,
|
||||
stripRangeProgressClaims,
|
||||
type OpeningBoundaryFacts,
|
||||
} from "../user-copy";
|
||||
import { resolveOpeningBoundaryFacts } from "../opening-boundary.ts";
|
||||
import { attemptTimeoutForRemainingBudget, canStartRetryAttempt } from "../../rectification-run-budget.ts";
|
||||
import { stripQuestionSentences, stripVerbalWindowChange } from "./collect-prompt";
|
||||
import { focusSpokenPrompt } from "./turn-question";
|
||||
@@ -120,6 +123,15 @@ export async function streamV9Attempt(
|
||||
return failedAttempt(attemptId, "skill_not_loaded");
|
||||
}
|
||||
|
||||
let openingFacts: OpeningBoundaryFacts | null = null;
|
||||
if (action === "opening") {
|
||||
openingFacts = await resolveOpeningBoundaryFacts({
|
||||
birthTimeSource: dossier.case.birthTimeSource,
|
||||
reportedBirthTime: dossier.case.reportedBirthTime,
|
||||
candidateRange: dossier.case.candidateRange,
|
||||
loadCompute: () => loadV9CaseCompute(accounting, userId, caseId),
|
||||
});
|
||||
}
|
||||
const messages = buildAgentMessages(
|
||||
options,
|
||||
attemptNumber,
|
||||
@@ -127,6 +139,7 @@ export async function streamV9Attempt(
|
||||
skillInstructions,
|
||||
birthTimeClue,
|
||||
previousErrorCode,
|
||||
openingBoundarySentence(openingFacts),
|
||||
);
|
||||
const maxSteps = resolveRectificationStepBudget(action);
|
||||
const abortController = new AbortController();
|
||||
@@ -613,8 +626,9 @@ export async function streamV9Attempt(
|
||||
if (action === "opening") {
|
||||
const openingRange = openingRangeFromCandidateRange(latestDossier.case.candidateRange);
|
||||
const productDelivery = productDeliveryForDomain(latestDossier.case.rectificationDomain);
|
||||
if (!isAcceptableOpeningBody(answerText, openingRange, productDelivery)) {
|
||||
answerText = openingSpokenBody(openingRange, productDelivery);
|
||||
const required = openingBoundarySentence(openingFacts);
|
||||
if (required || !isAcceptableOpeningBody(answerText, openingRange, productDelivery, openingFacts)) {
|
||||
answerText = openingSpokenBody(openingRange, productDelivery, openingFacts);
|
||||
}
|
||||
}
|
||||
const latestInference = previousInferenceFromReceipt(
|
||||
|
||||
@@ -20,7 +20,11 @@ function clockWindow(range: { start_time?: string | null; end_time?: string | nu
|
||||
return start && end ? `${start}–${end}` : null;
|
||||
}
|
||||
|
||||
export function buildOpeningBrief(dossier: V9CaseDossier, birthTimeClue?: string | null): string {
|
||||
export function buildOpeningBrief(
|
||||
dossier: V9CaseDossier,
|
||||
birthTimeClue?: string | null,
|
||||
boundarySentence?: string | null,
|
||||
): string {
|
||||
const confirmed = dossier.evidence.filter((item) => item.status === "confirmed");
|
||||
const pending = dossier.evidence.filter((item) => item.status === "draft" || item.status === "pending_confirmation");
|
||||
const domains = [...new Set(confirmed.map((item) => item.domain))].slice(0, 6);
|
||||
@@ -36,6 +40,9 @@ export function buildOpeningBrief(dossier: V9CaseDossier, birthTimeClue?: string
|
||||
const clue = typeof birthTimeClue === "string" && birthTimeClue.trim()
|
||||
? birthTimeClue.trim()
|
||||
: "";
|
||||
const boundary = typeof boundarySentence === "string" && boundarySentence.trim()
|
||||
? boundarySentence.trim()
|
||||
: "";
|
||||
return [
|
||||
"【服务端 opening brief】",
|
||||
`Case 状态:${dossier.case.status}。`,
|
||||
@@ -45,7 +52,7 @@ export function buildOpeningBrief(dossier: V9CaseDossier, birthTimeClue?: string
|
||||
...(clue
|
||||
? [`家人或本人关于出生时段的线索(仅旁白建议,不得改搜索窗口):${clue}`]
|
||||
: []),
|
||||
`做法要点:正文两句大白话,照这个意思写:「${openingGoal}」「做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照。」正文不用大运、盘面、分盘、候选、区间、代表分钟、精确到秒这类词,不写具体年份,不要提问,不列举例子,不要求先准备材料。一条消息可以报多件,想到几件说几件。先用 rectification-set-focus 的 spokenPrompt 写出当前采集题,写「先说一两件你记得的大事」即可:开场题干由服务端固定,会列出${OPENING_COLLECT_DOMAINS.join("、")},并给一个带大概年月的回答示例,正文不要重复这些例子。`,
|
||||
`做法要点:正文两句大白话,照这个意思写:「${openingGoal}」「做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照。」${boundary ? `服务端另有一句,原样放在这两句之间:「${boundary}」` : ""}正文不用大运、盘面、分盘、候选、区间、代表分钟、精确到秒这类词,不写具体年份,不要提问,不列举例子,不要求先准备材料。一条消息可以报多件,想到几件说几件。先用 rectification-set-focus 的 spokenPrompt 写出当前采集题,写「先说一两件你记得的大事」即可:开场题干由服务端固定,会列出${OPENING_COLLECT_DOMAINS.join("、")},并给一个带大概年月的回答示例,正文不要重复这些例子。`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
@@ -56,6 +63,7 @@ export function buildAgentMessages(
|
||||
skillInstructions: string,
|
||||
birthTimeClue: string | null = null,
|
||||
previousErrorCode: string | null = null,
|
||||
boundarySentence: string | null = null,
|
||||
): unknown[] {
|
||||
const timeContext = options.timeContext
|
||||
?? `服务端当前时间(权威):${new Date().toISOString()}。涉及“现在、今天、今年、未来几个月”等相对时间时,以此为准。`;
|
||||
@@ -76,7 +84,7 @@ export function buildAgentMessages(
|
||||
if (options.action === "opening") {
|
||||
return [bootstrap, {
|
||||
role: "user",
|
||||
content: [timeContext, caseContext, buildOpeningBrief(dossier, birthTimeClue)].join("\n"),
|
||||
content: [timeContext, caseContext, buildOpeningBrief(dossier, birthTimeClue, boundarySentence)].join("\n"),
|
||||
}];
|
||||
}
|
||||
return [bootstrap, {
|
||||
|
||||
@@ -1094,6 +1094,7 @@ export async function persistNextInterviewAfterChoice(input: {
|
||||
reportedBirthTime: liveDossier.case.reportedBirthTime,
|
||||
candidateRange: liveDossier.case.candidateRange,
|
||||
}),
|
||||
birthTimeSource: liveDossier.case.birthTimeSource,
|
||||
};
|
||||
const roundFollowup = input.continueTieBreakRound === true
|
||||
? tieBreakPersonalityFollowup(planInput)
|
||||
@@ -1903,6 +1904,7 @@ export async function persistNextInterviewIfIdle(input: {
|
||||
reportedBirthTime: dossier.case.reportedBirthTime,
|
||||
candidateRange: dossier.case.candidateRange,
|
||||
}),
|
||||
birthTimeSource: dossier.case.birthTimeSource,
|
||||
});
|
||||
const followup = interviewToPersist(plan);
|
||||
if (shouldSkipFollowupPersist({
|
||||
|
||||
@@ -723,6 +723,8 @@ function discriminatorProbeIfFollowupCanAsk(input: {
|
||||
...(input.contrastPacket ? { contrastPacket: input.contrastPacket } : {}),
|
||||
...(input.askedKeys ? { askedProbeKeys: input.askedKeys } : {}),
|
||||
...(input.birthDate ? { birthDate: input.birthDate } : {}),
|
||||
reportedTime: input.dossier.case.reportedBirthTime,
|
||||
birthTimeSource: input.dossier.case.birthTimeSource,
|
||||
candidatesSeparated: dossierCandidatesSeparated(input.dossier),
|
||||
});
|
||||
const dropped = mergeDroppedProbes(input.inspected.dropped, plan.dropped_probes);
|
||||
@@ -925,6 +927,8 @@ export function decideFromDossier(
|
||||
remainingCandidateCount: catalog.remainingCandidateCount,
|
||||
remainingCredibleRange: catalog.remainingCredibleRange,
|
||||
guidedWindows: catalog.guidedCollectWindows,
|
||||
windowScan: catalog.windowScan,
|
||||
birthTimeSource: dossier.case.birthTimeSource,
|
||||
...followupCaseArgs({
|
||||
stage: dossier.case.stage,
|
||||
blockScan: dossier.case.blockScan,
|
||||
@@ -1054,6 +1058,8 @@ export function decideAfterInferenceChange(input: {
|
||||
remainingCandidateCount: catalog.remainingCandidateCount,
|
||||
remainingCredibleRange: catalog.remainingCredibleRange,
|
||||
guidedWindows: catalog.guidedCollectWindows,
|
||||
windowScan: catalog.windowScan,
|
||||
birthTimeSource: input.dossier.case.birthTimeSource,
|
||||
...followupCaseArgs({
|
||||
stage: input.dossier.case.stage,
|
||||
blockScan: input.dossier.case.blockScan,
|
||||
|
||||
@@ -151,6 +151,7 @@ export function stepStateFromCaseDossier(
|
||||
accepted: Boolean(dossier.case.acceptedTime),
|
||||
candidatesSeparated: decision.separation.sufficient,
|
||||
holdoutValidation: decision.holdoutValidation,
|
||||
birthTimeSource: dossier.case.birthTimeSource,
|
||||
...followupCaseArgs({
|
||||
stage: dossier.case.stage,
|
||||
blockScan: dossier.case.blockScan,
|
||||
@@ -191,6 +192,7 @@ export function nextUserActionFromDossier(
|
||||
accepted,
|
||||
candidatesSeparated: decision.separation.sufficient,
|
||||
holdoutValidation: decision.holdoutValidation,
|
||||
birthTimeSource: dossier.case.birthTimeSource,
|
||||
...followupCaseArgs({
|
||||
stage: dossier.case.stage,
|
||||
blockScan: dossier.case.blockScan,
|
||||
|
||||
@@ -91,6 +91,12 @@ import {
|
||||
} from "./choice-card.ts";
|
||||
import type { ProbeExplainCandidate } from "./probe-explain.ts";
|
||||
import { segmentProbeGains } from "../core/segment-probe-order.ts";
|
||||
import {
|
||||
NEAREST_BOUNDARY_PROBE_THRESHOLD_MINUTES,
|
||||
nearestBoundaryFromTransitions,
|
||||
nearestBoundaryProbesEnabled,
|
||||
orderByBoundaryDomains,
|
||||
} from "../nearest-boundary.ts";
|
||||
import { overlayChoicePromptFromSpoken } from "./turn-narration.ts";
|
||||
import {
|
||||
GENERIC_COLLECT_QUESTION,
|
||||
@@ -1272,6 +1278,29 @@ function emptyRankedCatalog(): {
|
||||
return { locked: [], personality: [], yearless: [], dropped: [] };
|
||||
}
|
||||
|
||||
/** Gain order stays put unless the nearest-boundary switch is explicitly on. */
|
||||
export function orderDiscriminatorsForNearestBoundary<T extends {
|
||||
eventProbe?: { domain?: string | null } | null;
|
||||
contrastProbe?: { domain?: string | null } | null;
|
||||
}>(
|
||||
rows: readonly T[],
|
||||
input: {
|
||||
windowScan?: { transitions?: readonly { layer?: string; at?: string; from_sign?: string; to_sign?: string }[] } | null;
|
||||
reportedTime?: string | null;
|
||||
birthTimeSource?: string | null;
|
||||
probeFlag?: string;
|
||||
},
|
||||
): readonly T[] {
|
||||
if (!nearestBoundaryProbesEnabled(input.probeFlag) || input.birthTimeSource === "period_only" || !input.reportedTime) {
|
||||
return rows;
|
||||
}
|
||||
const boundary = nearestBoundaryFromTransitions(input.windowScan?.transitions, input.reportedTime);
|
||||
if (!boundary.found || boundary.distanceMinutes == null
|
||||
|| boundary.distanceMinutes > NEAREST_BOUNDARY_PROBE_THRESHOLD_MINUTES
|
||||
|| !boundary.domains.length) return rows;
|
||||
return orderByBoundaryDomains(rows, boundary.domains, (row) => row.eventProbe?.domain ?? row.contrastProbe?.domain ?? "");
|
||||
}
|
||||
|
||||
function rankRenderableDiscriminators(input: {
|
||||
eventProbes: readonly DiscriminatingEventProbe[];
|
||||
contrastProbes: readonly CandidateDiscriminatorProbe[];
|
||||
@@ -1282,6 +1311,9 @@ function rankRenderableDiscriminators(input: {
|
||||
evidence?: readonly MethodFollowupEvidence[];
|
||||
birthDate?: string | null;
|
||||
inferenceState?: InferenceState | null;
|
||||
windowScan?: WindowScan | null;
|
||||
reportedTime?: string | null;
|
||||
birthTimeSource?: string | null;
|
||||
}): { locked: RankedDiscriminator[]; personality: RankedDiscriminator[]; yearless: RankedDiscriminator[]; dropped: DroppedProbe[] } {
|
||||
const top = input.topCandidateTimes ?? [];
|
||||
const provided = new Set((input.providedDomains ?? []).map((item) => canonicalCollectDomain(item)));
|
||||
@@ -1355,10 +1387,15 @@ function rankRenderableDiscriminators(input: {
|
||||
}
|
||||
return right.score - left.score || (right.eventProbe?.information_gain ?? right.contrastProbe?.informationGain ?? 0) - (left.eventProbe?.information_gain ?? left.contrastProbe?.informationGain ?? 0);
|
||||
});
|
||||
const ordered = orderDiscriminatorsForNearestBoundary(sorted, {
|
||||
windowScan: input.windowScan,
|
||||
reportedTime: input.reportedTime,
|
||||
birthTimeSource: input.birthTimeSource,
|
||||
});
|
||||
return {
|
||||
locked: sorted.filter((row) => discriminatorLocksScoringPeriod(row)),
|
||||
personality: sorted.filter((row) => isYearlessPersonalityRow(row)),
|
||||
yearless: sorted.filter((row) => !discriminatorLocksScoringPeriod(row) && !isYearlessPersonalityRow(row)),
|
||||
locked: ordered.filter((row) => discriminatorLocksScoringPeriod(row)),
|
||||
personality: ordered.filter((row) => isYearlessPersonalityRow(row)),
|
||||
yearless: ordered.filter((row) => !discriminatorLocksScoringPeriod(row) && !isYearlessPersonalityRow(row)),
|
||||
dropped,
|
||||
};
|
||||
}
|
||||
@@ -2274,6 +2311,7 @@ export function buildMethodFollowupPlan(input: {
|
||||
caseStage?: RectificationCaseStage;
|
||||
blockScan?: BlockScanPayload | null;
|
||||
reportedTime?: string | null;
|
||||
birthTimeSource?: string | null;
|
||||
candidateRange?: import("../core/candidate-window.ts").DatedCandidateRange | null;
|
||||
remainingLayers?: readonly string[];
|
||||
remainingSplitTimes?: readonly [string, string] | null;
|
||||
@@ -2454,6 +2492,9 @@ export function buildMethodFollowupPlan(input: {
|
||||
evidence: input.evidence,
|
||||
birthDate: input.birthDate,
|
||||
inferenceState: input.inferenceState,
|
||||
windowScan: input.windowScan,
|
||||
reportedTime: input.reportedTime,
|
||||
birthTimeSource: input.birthTimeSource,
|
||||
})
|
||||
: emptyRankedCatalog();
|
||||
const rankedDiscriminators = rankedCatalog.locked;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveAyanamsa } from "../../ayanamsa.ts";
|
||||
import { requestCandidateIntervals, type DatedCandidateRange } from "../core/candidate-window.ts";
|
||||
import type { SegmentMinute, TargetChart } from "../core/segment-summary.ts";
|
||||
import { parseSegmentMinutes } from "./segment-contract.ts";
|
||||
@@ -15,7 +16,8 @@ export async function scanCaseSegments(input: {
|
||||
method: "POST", headers: { "Content-Type": "application/json" }, signal: AbortSignal.timeout(60000),
|
||||
body: JSON.stringify({ start_at: interval.start_at, end_at: interval.end_at,
|
||||
latitude: input.snapshot.latitude, longitude: input.snapshot.longitude,
|
||||
timezone_offset: input.snapshot.timezone_offset, target_charts: input.targets }),
|
||||
timezone_offset: input.snapshot.timezone_offset, target_charts: input.targets,
|
||||
ayanamsa: resolveAyanamsa(input.snapshot) }),
|
||||
});
|
||||
if (!response.ok) throw new Error("segment_scan_unavailable");
|
||||
const data = await response.json();
|
||||
@@ -23,7 +25,7 @@ export async function scanCaseSegments(input: {
|
||||
const start = Date.parse(`${interval.start_at}:00Z`);
|
||||
const expectedWidth = (Date.parse(`${interval.end_at}:00Z`) - start) / 60000 + 1;
|
||||
if (!minutes || data.start_at !== interval.start_at || data.end_at !== interval.end_at
|
||||
|| data.ayanamsa !== "raman" || data.node_mode !== "mean" || data.step_minutes !== 1
|
||||
|| data.ayanamsa !== resolveAyanamsa(input.snapshot) || data.node_mode !== "mean" || data.step_minutes !== 1
|
||||
|| data.window_minutes !== expectedWidth || minutes.length !== expectedWidth
|
||||
|| minutes.some((minute, index) => minute.offset !== index
|
||||
|| `${minute.date}T${minute.time}` !== new Date(start + index * 60000).toISOString().slice(0, 16)
|
||||
|
||||
@@ -7,11 +7,13 @@ import { getRectificationEntrySummary } from "@/lib/rectification-agentic/v9/cas
|
||||
import { loadV9CaseDossier } from "@/lib/rectification-agentic/v9/tool-service";
|
||||
import {
|
||||
appendRectificationReturn,
|
||||
attachOpenedOfferToFreeMessage,
|
||||
bindOfferToCase,
|
||||
decideRectificationOfferWith,
|
||||
offerForCase,
|
||||
sourceFromSessionRows,
|
||||
type RectificationOfferDecision,
|
||||
type RectificationOfferRecord,
|
||||
type RectificationReturnRecord,
|
||||
type RectificationSource,
|
||||
} from "@/lib/rectification-in-chat";
|
||||
@@ -61,6 +63,35 @@ async function writeMessages(client: SupabaseClient, userId: string, sessionId:
|
||||
return !error;
|
||||
}
|
||||
|
||||
/**
|
||||
* After a free direct-entry reply is appended, hang the opened offer on that
|
||||
* message. Idempotent: an offer already on the message (including one a later
|
||||
* bind gave a case id) is left as it is. Does not touch consultation_requests.
|
||||
*/
|
||||
export async function persistOpenedDirectOffer(input: {
|
||||
client: SupabaseClient;
|
||||
userId: string;
|
||||
sessionId: string;
|
||||
sentence: string;
|
||||
offer: RectificationOfferRecord;
|
||||
}): Promise<void> {
|
||||
const { data, error } = await input.client
|
||||
.from("chat_sessions")
|
||||
.select("messages")
|
||||
.eq("id", input.sessionId)
|
||||
.eq("user_id", input.userId)
|
||||
.maybeSingle();
|
||||
if (error || !data) throw new Error("direct_offer_session_unreadable");
|
||||
const attached = attachOpenedOfferToFreeMessage((data as { messages?: unknown }).messages, {
|
||||
sentence: input.sentence,
|
||||
offer: input.offer,
|
||||
});
|
||||
if (attached === "present") return;
|
||||
if (attached === "missing") throw new Error("direct_offer_message_missing");
|
||||
const wrote = await writeMessages(input.client, input.userId, input.sessionId, attached);
|
||||
if (!wrote) throw new Error("direct_offer_write_failed");
|
||||
}
|
||||
|
||||
/** C4: after a case opens from a consultation, bind the offer card to it. Best effort. */
|
||||
export async function bindRectificationSource(input: {
|
||||
client: SupabaseClient;
|
||||
|
||||
@@ -70,6 +70,18 @@ export const RECTIFICATION_OFFER_OPENED_COPY = "出生时间核对已经开始
|
||||
export const RECTIFICATION_RETURN_BUTTON_LABEL = "回到刚才的对话";
|
||||
export const RECTIFICATION_BEFORE_CHANGE_LABEL = "按校正前的时间";
|
||||
|
||||
/**
|
||||
* User-visible sentences for a turn that enters rectification directly
|
||||
* (BUG-1306). `complete_consultation_free` rejects text longer than 20
|
||||
* characters, so these stay short. Same sentence for "not open" and "no price":
|
||||
* the user does not see the internal reason.
|
||||
*/
|
||||
export const RECTIFICATION_DIRECT_NEW_COPY = "好,我们开始核对出生时间。";
|
||||
export const RECTIFICATION_DIRECT_RESUME_COPY = "核对还没做完,我们接着做。";
|
||||
export const RECTIFICATION_DIRECT_UNAVAILABLE_COPY = "出生时间核对这会儿还做不了。";
|
||||
export const RECTIFICATION_DIRECT_NOT_SELF_COPY = "给家人校正暂时还不支持";
|
||||
export const RECTIFICATION_DIRECT_RESUME_ELSEWHERE_COPY = "可在历史对话里接着做。";
|
||||
|
||||
export function rectificationReturnDividerText(record: Pick<RectificationReturnRecord, "adopted" | "clock">): string {
|
||||
return record.adopted && record.clock
|
||||
? `出生时间改为 ${record.clock}(生时校正)`
|
||||
@@ -136,7 +148,10 @@ export function bindOfferToCase(
|
||||
}
|
||||
for (let index = stored.length - 1; index >= 0; index -= 1) {
|
||||
const offer = parseRectificationOfferRecord(stored[index]!.rectificationOffer);
|
||||
if (!offer || offer.status !== "offered") continue;
|
||||
if (!offer || offer.caseId) continue;
|
||||
// A direct open records `opened` before the case id exists (BUG-1306).
|
||||
// An offer already bound to another case is left alone.
|
||||
if (offer.status !== "offered" && offer.status !== "opened") continue;
|
||||
const next = [...stored];
|
||||
next[index] = {
|
||||
...stored[index],
|
||||
@@ -147,6 +162,29 @@ export function bindOfferToCase(
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* After `complete_consultation_free` appends the one-sentence reply, hang the
|
||||
* opened offer on that message so a reload can still find the source. Returns
|
||||
* the next array, "present" when that message already has an offer (do not
|
||||
* overwrite a later case id), or "missing" when the free reply is not there yet.
|
||||
*/
|
||||
export function attachOpenedOfferToFreeMessage(
|
||||
messages: unknown,
|
||||
input: { sentence: string; offer: RectificationOfferRecord },
|
||||
): StoredMessage[] | "present" | "missing" {
|
||||
const stored = asMessages(messages);
|
||||
if (!stored) return "missing";
|
||||
for (let index = stored.length - 1; index >= 0; index -= 1) {
|
||||
const message = stored[index]!;
|
||||
if (message.role !== "assistant" || message.text !== input.sentence || message.responseKind !== "smalltalk") continue;
|
||||
if (parseRectificationOfferRecord(message.rectificationOffer)) return "present";
|
||||
const next = [...stored];
|
||||
next[index] = { ...message, rectificationOffer: input.offer };
|
||||
return next;
|
||||
}
|
||||
return "missing";
|
||||
}
|
||||
|
||||
/** The offer bound to this case, if this session holds one. */
|
||||
export function offerForCase(messages: unknown, caseId: string): RectificationOfferRecord | null {
|
||||
for (const message of asMessages(messages) ?? []) {
|
||||
|
||||
@@ -313,7 +313,8 @@ export async function collectAgentPublicEvents(stream: ChunkStream | Iterable<Ch
|
||||
}
|
||||
}
|
||||
flushPlan();
|
||||
events.push({
|
||||
const directCompleted = options.state ? rectificationDirectRunCompleted(options.state) : null;
|
||||
events.push(directCompleted ?? {
|
||||
type: "run.completed",
|
||||
receipt: agentExecutionReceiptSchema.parse(options.receipt()),
|
||||
...(options.state?.rectificationOffer ? { rectificationOffer: options.state.rectificationOffer } : {}),
|
||||
@@ -387,10 +388,32 @@ type StreamAgentResponseOptions = EventOptions & {
|
||||
// the single-calculation boundary. Gating on total attempts would make any
|
||||
// transient failure permanently unrecoverable.
|
||||
function contractReady(options: StreamAgentResponseOptions) {
|
||||
// An explicit rectification turn does not need a chart calculation (BUG-1306).
|
||||
if (options.state.rectificationDirectEntry) return true;
|
||||
return options.state.jyotishSkillBound
|
||||
&& (!options.requireTool || (options.state.consultationToolCompleted && options.state.consultationToolSuccessCount === 1));
|
||||
}
|
||||
|
||||
/** Smalltalk completion for a direct rectification turn. No receipt: the schema forbids both. */
|
||||
function rectificationDirectRunCompleted(state: ConsultationRuntimeState): ConsultationAgentPublicEvent | null {
|
||||
const entry = state.rectificationDirectEntry;
|
||||
if (!entry) return null;
|
||||
return consultationAgentPublicEventSchema.parse({
|
||||
type: "run.completed",
|
||||
responseKind: "smalltalk",
|
||||
...(entry.kind === "new" && state.rectificationOffer ? { rectificationOffer: state.rectificationOffer } : {}),
|
||||
...(entry.kind === "new" || entry.kind === "resume"
|
||||
? {
|
||||
rectificationDirectOpen: {
|
||||
sourceSessionId: entry.sourceSessionId,
|
||||
mode: entry.kind,
|
||||
...(entry.resumeSessionId ? { resumeSessionId: entry.resumeSessionId } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
function isSkillBindingAbortError(error: unknown) {
|
||||
if (!(error instanceof Error)) return false;
|
||||
if (error.message === SKILL_BINDING_FAILED) return true;
|
||||
@@ -654,6 +677,8 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
|
||||
stepCalledTool = false;
|
||||
};
|
||||
const outputText = async (text: string) => {
|
||||
// Direct entry ends on the server sentence. Further model text is not the answer.
|
||||
if (options.state.rectificationDirectEntry) return;
|
||||
// Text the model writes before the contract is ready is not the answer: it
|
||||
// is the model narrating its own in-progress or failed tool calls. Holding
|
||||
// it meant a later successful call released that narration as the entire
|
||||
@@ -1058,6 +1083,23 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
|
||||
throw new Error("runtime_contract_incomplete");
|
||||
}
|
||||
}
|
||||
const directCompleted = rectificationDirectRunCompleted(options.state);
|
||||
if (directCompleted) {
|
||||
const sentence = options.state.rectificationDirectEntry?.sentence ?? "";
|
||||
fullOutput = sentence;
|
||||
send(controller, { type: "answer.delta", text: sentence, replace: true });
|
||||
emitted = true;
|
||||
recordAnswerTelemetry();
|
||||
settling = true;
|
||||
const receipt = agentExecutionReceiptSchema.parse(options.receipt());
|
||||
await options.onComplete?.(sentence, receipt, undefined, undefined);
|
||||
settled = true;
|
||||
settling = false;
|
||||
await sideEvent;
|
||||
send(controller, directCompleted);
|
||||
if (!disconnected) controller.close();
|
||||
return;
|
||||
}
|
||||
if (!deliveredDegraded) {
|
||||
// The loop's own final step wrote the answer with the calculation
|
||||
// in its context (BUG-1053); there is no second, blind compose.
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import {
|
||||
RECTIFICATION_DIRECT_NEW_COPY,
|
||||
RECTIFICATION_DIRECT_NOT_SELF_COPY,
|
||||
RECTIFICATION_DIRECT_RESUME_COPY,
|
||||
RECTIFICATION_DIRECT_RESUME_ELSEWHERE_COPY,
|
||||
RECTIFICATION_DIRECT_UNAVAILABLE_COPY,
|
||||
RECTIFICATION_OFFER_TOOL_ID,
|
||||
type RectificationOfferDecision,
|
||||
type RectificationOfferRecord,
|
||||
@@ -276,6 +281,18 @@ export type ConsultationRuntimeState = {
|
||||
jyotishSkillBound: boolean;
|
||||
/** Set when this turn's answer offered birth-time rectification (BUG-1248); persisted with the answer. */
|
||||
rectificationOffer?: RectificationOfferRecord;
|
||||
/**
|
||||
* Set when this turn explicitly asked to rectify (BUG-1306). The stream
|
||||
* replaces the answer with `sentence` and does not charge a reading.
|
||||
* `new` opens a case; `resume` opens the one already in progress; `refused`
|
||||
* only says why it cannot.
|
||||
*/
|
||||
rectificationDirectEntry?: {
|
||||
kind: "new" | "resume" | "refused";
|
||||
sentence: string;
|
||||
sourceSessionId: string;
|
||||
resumeSessionId?: string;
|
||||
};
|
||||
skillReferenceReadCount: number;
|
||||
methodologySectionCount: number;
|
||||
consultationToolStarted: boolean;
|
||||
@@ -457,6 +474,13 @@ export type RectificationOfferHook = Readonly<{
|
||||
decide: () => Promise<RectificationOfferDecision>;
|
||||
alreadyOffered: boolean;
|
||||
revisitQuestion: string;
|
||||
/** This consultation. Direct entry opens from it (BUG-1306). */
|
||||
sourceSessionId: string;
|
||||
/**
|
||||
* Called only for an explicit request while a case is already in progress.
|
||||
* The rectification session to resume, or null when it cannot be read.
|
||||
*/
|
||||
loadResumableSessionId: () => Promise<string | null>;
|
||||
now?: () => Date;
|
||||
}>;
|
||||
|
||||
@@ -1039,40 +1063,120 @@ const evidenceLookupInputSchema = z.object({
|
||||
}).strict();
|
||||
|
||||
const RECTIFICATION_OFFER_REFUSAL_NOTES: Readonly<Record<Exclude<RectificationOfferDecision, { ok: true }>["reason"], string>> = {
|
||||
not_self: "问的不是用户本人的盘,生时校正暂时只支持本人。正文用一句话照实说明(例:「给家人校正暂时还不支持」),不出卡。",
|
||||
product_disabled: "生时校正暂时没有开放。正文不要提校正。",
|
||||
not_self: `问的不是用户本人的盘,生时校正暂时只支持本人。正文用这一句说明:「${RECTIFICATION_DIRECT_NOT_SELF_COPY}」,不出卡。`,
|
||||
product_disabled: `出生时间核对这会儿做不了。正文必须用这一句说明:「${RECTIFICATION_DIRECT_UNAVAILABLE_COPY}」,不写内部原因。`,
|
||||
case_in_progress: "用户已经有一次没做完的生时校正。正文用一句话告诉他可以在历史对话里接着做。",
|
||||
pricing_unavailable: "生时校正这会儿提供不了。正文不要提校正。",
|
||||
pricing_unavailable: `出生时间核对这会儿做不了。正文必须用这一句说明:「${RECTIFICATION_DIRECT_UNAVAILABLE_COPY}」,不写价格。`,
|
||||
};
|
||||
|
||||
function offerRecord(
|
||||
hook: RectificationOfferHook,
|
||||
decision: Extract<RectificationOfferDecision, { ok: true }>,
|
||||
status: RectificationOfferRecord["status"],
|
||||
): RectificationOfferRecord {
|
||||
return {
|
||||
status,
|
||||
creditCost: decision.creditCost,
|
||||
questionEstimate: decision.questionEstimate,
|
||||
sourceQuestion: hook.revisitQuestion,
|
||||
offeredAt: (hook.now?.() ?? new Date()).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The offer card tool (C3, BUG-1248). It only records the offer for this turn;
|
||||
* the server decides whether a card may be shown and the card itself carries
|
||||
* the question count and price. Never calculates, never opens a case.
|
||||
* Explicit request (BUG-1306): the server already decided. A pass records an
|
||||
* opened offer with no case id yet and tells the stream to open. A case
|
||||
* already in progress resumes that case. Anything else is one sentence.
|
||||
*/
|
||||
async function finishExplicitRectificationRequest(
|
||||
state: ConsultationRuntimeState,
|
||||
hook: RectificationOfferHook,
|
||||
decision: RectificationOfferDecision,
|
||||
) {
|
||||
if (decision.ok) {
|
||||
state.rectificationOffer = offerRecord(hook, decision, "opened");
|
||||
state.rectificationDirectEntry = {
|
||||
kind: "new",
|
||||
sentence: RECTIFICATION_DIRECT_NEW_COPY,
|
||||
sourceSessionId: hook.sourceSessionId,
|
||||
};
|
||||
return {
|
||||
offered: true as const,
|
||||
directEntry: "new" as const,
|
||||
note: `只写这一句过渡,不写解读:「${RECTIFICATION_DIRECT_NEW_COPY}」`,
|
||||
};
|
||||
}
|
||||
if (decision.reason === "case_in_progress") {
|
||||
let resumeSessionId: string | null = null;
|
||||
try {
|
||||
resumeSessionId = await hook.loadResumableSessionId();
|
||||
} catch {
|
||||
resumeSessionId = null;
|
||||
}
|
||||
if (resumeSessionId) {
|
||||
state.rectificationDirectEntry = {
|
||||
kind: "resume",
|
||||
sentence: RECTIFICATION_DIRECT_RESUME_COPY,
|
||||
sourceSessionId: hook.sourceSessionId,
|
||||
resumeSessionId,
|
||||
};
|
||||
return {
|
||||
offered: false as const,
|
||||
reason: decision.reason,
|
||||
directEntry: "resume" as const,
|
||||
note: `只写这一句,不写解读:「${RECTIFICATION_DIRECT_RESUME_COPY}」`,
|
||||
};
|
||||
}
|
||||
state.rectificationDirectEntry = {
|
||||
kind: "refused",
|
||||
sentence: RECTIFICATION_DIRECT_RESUME_ELSEWHERE_COPY,
|
||||
sourceSessionId: hook.sourceSessionId,
|
||||
};
|
||||
return { offered: false as const, reason: decision.reason, note: RECTIFICATION_OFFER_REFUSAL_NOTES.case_in_progress };
|
||||
}
|
||||
const sentence = decision.reason === "not_self"
|
||||
? RECTIFICATION_DIRECT_NOT_SELF_COPY
|
||||
: RECTIFICATION_DIRECT_UNAVAILABLE_COPY;
|
||||
state.rectificationDirectEntry = {
|
||||
kind: "refused",
|
||||
sentence,
|
||||
sourceSessionId: hook.sourceSessionId,
|
||||
};
|
||||
return { offered: false as const, reason: decision.reason, note: RECTIFICATION_OFFER_REFUSAL_NOTES[decision.reason] };
|
||||
}
|
||||
|
||||
/**
|
||||
* The offer card tool (C3, BUG-1248; direct entry BUG-1306). The server decides
|
||||
* whether a card may be shown or this turn opens rectification. Never calculates.
|
||||
* The rule text lives only in RECTIFICATION_OFFER_RULE.
|
||||
*/
|
||||
function createRectificationOfferTool(ctx: Readonly<{ state: ConsultationRuntimeState; rectificationOffer?: RectificationOfferHook }>) {
|
||||
return createTool({
|
||||
id: RECTIFICATION_OFFER_TOOL_ID,
|
||||
description: "Offer birth-time rectification under this answer. Call it before writing the answer, only when the user says a concrete past event does not fit the chart, says the birth time is an estimate or unsure, or asks to check the birth time; never for a disagreement with an interpretation. Pass userAsked=true only when the user asked for it in this turn. The result says whether a card will be shown and what to say in one sentence.",
|
||||
inputSchema: z.object({ userAsked: z.boolean().optional() }).strict(),
|
||||
description: "Offer birth-time rectification under this answer, or open it when explicitRequest is true. Call before writing the answer. Pass explicitRequest=true only when the user explicitly asks to do rectification this turn; then write only the one transition sentence in the result and no reading. Pass userAsked=true only when the user asks again after an earlier offer in this conversation. Never call it for a disagreement with an interpretation. Do not promise to narrow the birth time or collect life events here. The result says whether a card will be shown and what one sentence to write.",
|
||||
inputSchema: z.object({
|
||||
userAsked: z.boolean().optional(),
|
||||
explicitRequest: z.boolean().optional(),
|
||||
}).strict(),
|
||||
execute: async (input) => {
|
||||
const hook = ctx.rectificationOffer;
|
||||
if (!hook) return { offered: false as const, reason: "unavailable", note: RECTIFICATION_OFFER_REFUSAL_NOTES.product_disabled };
|
||||
if (ctx.state.rectificationOffer) {
|
||||
return { offered: true as const, note: "提议卡已经在了。正文用一句话提一下就好,不写价格和题数。" };
|
||||
if (input.explicitRequest === true && ctx.state.rectificationDirectEntry) {
|
||||
return { offered: false as const, note: "已经在打开。不要再写解读。" };
|
||||
}
|
||||
if (hook.alreadyOffered && input.userAsked !== true) {
|
||||
if (ctx.state.rectificationDirectEntry || ctx.state.rectificationOffer) {
|
||||
if (ctx.state.rectificationOffer) {
|
||||
return { offered: true as const, note: "提议卡已经在了。正文用一句话提一下就好,不写价格和题数。" };
|
||||
}
|
||||
return { offered: false as const, note: "已经在打开。不要再写解读。" };
|
||||
}
|
||||
if (hook.alreadyOffered && input.userAsked !== true && input.explicitRequest !== true) {
|
||||
return { offered: false as const, reason: "already_offered_in_session", note: "这次对话里已经提议过一次,用户没接。不再主动提校正,正常回答。" };
|
||||
}
|
||||
const decision = await hook.decide();
|
||||
if (input.explicitRequest === true) return finishExplicitRectificationRequest(ctx.state, hook, decision);
|
||||
if (!decision.ok) return { offered: false as const, reason: decision.reason, note: RECTIFICATION_OFFER_REFUSAL_NOTES[decision.reason] };
|
||||
ctx.state.rectificationOffer = {
|
||||
status: "offered",
|
||||
creditCost: decision.creditCost,
|
||||
questionEstimate: decision.questionEstimate,
|
||||
sourceQuestion: hook.revisitQuestion,
|
||||
offeredAt: (hook.now?.() ?? new Date()).toISOString(),
|
||||
};
|
||||
ctx.state.rectificationOffer = offerRecord(hook, decision, "offered");
|
||||
return { offered: true as const, note: "你的回答下面会出现一张提议卡,写着大约几题、几点。正文最后一段必须有一句提到它(例:「如果想确认出生时间准不准,可以用下面的卡片,答几道过去经历的题核对一下。」),不写价格和题数,不催。" };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -307,6 +307,7 @@ export function safeCaseProjection(
|
||||
reportedBirthTime: parsed.case.reportedBirthTime,
|
||||
candidateRange: parsed.case.candidateRange,
|
||||
}),
|
||||
birthTimeSource: parsed.case.birthTimeSource,
|
||||
});
|
||||
const latestProjection = latest && parsed.case.stage !== "block_scan"
|
||||
? latestResultToolProjection(latest, decision, productDeliveryForDomain(parsed.case.rectificationDomain))
|
||||
@@ -772,6 +773,7 @@ function followupPlanForParsed(
|
||||
reportedBirthTime: parsed.case.reportedBirthTime,
|
||||
candidateRange: parsed.case.candidateRange,
|
||||
}),
|
||||
birthTimeSource: parsed.case.birthTimeSource,
|
||||
}),
|
||||
contrastPacket: catalog.contrastPacket,
|
||||
sessionOutcome: decision.sessionOutcome,
|
||||
|
||||
@@ -87,7 +87,13 @@ test("the consult route logs the record once per natal turn, without session or
|
||||
assert.match(block, /agentVersion: "consultation-evidence-card-v2"/);
|
||||
assert.doesNotMatch(block, /sessionId|userId/);
|
||||
const natal = route.slice(route.indexOf("const agent = getJyotishAgent(selectedModel, agentContext);"));
|
||||
assert.match(natal, /answerForTelemetry = output;[\s\S]*return settleRun\(\(\) => completeResponse\(/);
|
||||
// 原值: /answerForTelemetry = output;[\s\S]*return settleRun\(\(\) => completeResponse\(/
|
||||
// 新值: 非直达仍把 output 记入 answerForTelemetry,并经 settleAnswer 调用 completeResponse
|
||||
// 原因: BUG-1306 直达轮不把模型正文当回答,结算改走 complete_consultation_free
|
||||
assert.match(natal, /if \(!state\.rectificationDirectEntry\) answerForTelemetry = output;/);
|
||||
assert.match(natal, /return settleAnswer\(/);
|
||||
const settle = route.slice(route.indexOf("const settleAnswer ="), route.indexOf("if (usesPublicDailyGeneralAgent"));
|
||||
assert.match(settle, /return settleRun\(\(\) => completeResponse\(/);
|
||||
// Only a finished answer counts citations; a failed run logs the card size with no citations.
|
||||
assert.doesNotMatch(natal, /onError: \(error, _emitted, output\)/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,589 @@
|
||||
// BUG-1306: an explicit request opens rectification directly. Fictional data only.
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
|
||||
import { consultationAgentPublicEventSchema, createNdjsonParser, type AgentExecutionReceipt, type ConsultationAgentPublicEvent } from "../src/lib/consultation-agent-events.ts";
|
||||
import { RECTIFICATION_OFFER_RULE } from "../src/lib/consultation-thinking-plan.ts";
|
||||
import {
|
||||
RECTIFICATION_DIRECT_NEW_COPY,
|
||||
RECTIFICATION_DIRECT_NOT_SELF_COPY,
|
||||
RECTIFICATION_DIRECT_RESUME_COPY,
|
||||
RECTIFICATION_DIRECT_RESUME_ELSEWHERE_COPY,
|
||||
RECTIFICATION_DIRECT_UNAVAILABLE_COPY,
|
||||
RECTIFICATION_OFFER_TOOL_ID,
|
||||
attachOpenedOfferToFreeMessage,
|
||||
bindOfferToCase,
|
||||
offerForCase,
|
||||
sourceFromSessionRows,
|
||||
type RectificationOfferRecord,
|
||||
} from "../src/lib/rectification-in-chat.ts";
|
||||
import { CONTRACT_DEGRADED_NOTE, streamAgentResponse } from "../src/lib/stream-agent-response.ts";
|
||||
import type { Pass4Mode } from "../src/lib/timing-output-guard.ts";
|
||||
import {
|
||||
consultationStepBudgetReceipt,
|
||||
createConsultationRuntimeState,
|
||||
createConsultationTools,
|
||||
publicConsultationRuntimeSteps,
|
||||
} from "../src/mastra/consultation-tools.ts";
|
||||
|
||||
const CASE_ID = "11111111-1111-4111-8111-111111111111";
|
||||
const OTHER_CASE = "55555555-5555-4555-8555-555555555555";
|
||||
const RECT_SESSION = "22222222-2222-4222-8222-222222222222";
|
||||
const SOURCE_SESSION = "33333333-3333-4333-8333-333333333333";
|
||||
const RESUME_SESSION = "44444444-4444-4444-8444-444444444444";
|
||||
const REQUEST_ID = "66666666-6666-4666-8666-666666666666";
|
||||
|
||||
const DIRECT_SENTENCES = [
|
||||
RECTIFICATION_DIRECT_NEW_COPY,
|
||||
RECTIFICATION_DIRECT_RESUME_COPY,
|
||||
RECTIFICATION_DIRECT_UNAVAILABLE_COPY,
|
||||
RECTIFICATION_DIRECT_NOT_SELF_COPY,
|
||||
RECTIFICATION_DIRECT_RESUME_ELSEWHERE_COPY,
|
||||
] as const;
|
||||
|
||||
const STOP_FINISH = { type: "finish", payload: { stepResult: { reason: "stop" }, output: { usage: {}, steps: [{}] } } };
|
||||
|
||||
type OfferDecision =
|
||||
| { ok: true; creditCost: number; questionEstimate: number }
|
||||
| { ok: false; reason: "not_self" | "product_disabled" | "case_in_progress" | "pricing_unavailable" };
|
||||
|
||||
function offer(status: RectificationOfferRecord["status"] = "offered", extra: Partial<RectificationOfferRecord> = {}): RectificationOfferRecord {
|
||||
return {
|
||||
status,
|
||||
creditCost: 3,
|
||||
questionEstimate: 8,
|
||||
sourceQuestion: "我和伴侣还能走下去吗",
|
||||
offeredAt: "2026-10-07T00:00:00.000Z",
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function offerTool(options: {
|
||||
alreadyOffered?: boolean;
|
||||
decide?: () => Promise<OfferDecision>;
|
||||
loadResumableSessionId?: () => Promise<string | null>;
|
||||
} = {}) {
|
||||
const state = createConsultationRuntimeState();
|
||||
let decisions = 0;
|
||||
let loads = 0;
|
||||
const tools = createConsultationTools({
|
||||
userId: "user-fictional",
|
||||
sessionId: SOURCE_SESSION,
|
||||
requestId: REQUEST_ID,
|
||||
consultationMode: "verified_chart",
|
||||
serverChart: {} as never,
|
||||
state,
|
||||
rectificationOffer: {
|
||||
decide: async () => {
|
||||
decisions += 1;
|
||||
return options.decide ? options.decide() : { ok: true, creditCost: 3, questionEstimate: 8 };
|
||||
},
|
||||
alreadyOffered: options.alreadyOffered ?? false,
|
||||
revisitQuestion: "我和伴侣还能走下去吗",
|
||||
sourceSessionId: SOURCE_SESSION,
|
||||
loadResumableSessionId: async () => {
|
||||
loads += 1;
|
||||
return options.loadResumableSessionId ? options.loadResumableSessionId() : null;
|
||||
},
|
||||
now: () => new Date("2026-10-07T00:00:00.000Z"),
|
||||
},
|
||||
}) as Record<string, { execute: (input: unknown, context?: unknown) => Promise<Record<string, unknown>> }>;
|
||||
return { state, decisions: () => decisions, loads: () => loads, tool: tools[RECTIFICATION_OFFER_TOOL_ID]! };
|
||||
}
|
||||
|
||||
function src(relative: string) {
|
||||
return readFileSync(new URL(relative, import.meta.url), "utf8");
|
||||
}
|
||||
|
||||
function walkSources(dir: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.name === "node_modules") continue;
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) out.push(...walkSources(full));
|
||||
else if (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
test("direct-entry sentences stay within the free-completion length", () => {
|
||||
assert.deepEqual(DIRECT_SENTENCES.map((sentence) => Array.from(sentence).length), [13, 13, 14, 11, 11]);
|
||||
for (const sentence of DIRECT_SENTENCES) assert.ok(Array.from(sentence).length <= 20);
|
||||
assert.equal(RECTIFICATION_DIRECT_NEW_COPY, "好,我们开始核对出生时间。");
|
||||
assert.equal(RECTIFICATION_DIRECT_RESUME_COPY, "核对还没做完,我们接着做。");
|
||||
assert.equal(RECTIFICATION_DIRECT_UNAVAILABLE_COPY, "出生时间核对这会儿还做不了。");
|
||||
assert.equal(RECTIFICATION_DIRECT_NOT_SELF_COPY, "给家人校正暂时还不支持");
|
||||
assert.equal(RECTIFICATION_DIRECT_RESUME_ELSEWHERE_COPY, "可在历史对话里接着做。");
|
||||
});
|
||||
|
||||
test("an opened offer with no case id binds, and a reload can find that case", () => {
|
||||
const opened = [{
|
||||
role: "assistant",
|
||||
text: RECTIFICATION_DIRECT_NEW_COPY,
|
||||
responseKind: "smalltalk",
|
||||
rectificationOffer: offer("opened"),
|
||||
}];
|
||||
assert.equal(sourceFromSessionRows([{ id: SOURCE_SESSION, messages: opened }], CASE_ID), null);
|
||||
const bound = bindOfferToCase(opened, { caseId: CASE_ID, rectificationSessionId: RECT_SESSION });
|
||||
assert.ok(bound);
|
||||
assert.equal((bound![0]!.rectificationOffer as RectificationOfferRecord).status, "opened");
|
||||
assert.equal((bound![0]!.rectificationOffer as RectificationOfferRecord).caseId, CASE_ID);
|
||||
assert.equal((bound![0]!.rectificationOffer as RectificationOfferRecord).rectificationSessionId, RECT_SESSION);
|
||||
assert.equal(bindOfferToCase(bound, { caseId: CASE_ID, rectificationSessionId: RECT_SESSION }), null);
|
||||
assert.ok(offerForCase(bound, CASE_ID));
|
||||
assert.deepEqual(sourceFromSessionRows([{ id: SOURCE_SESSION, messages: bound }], CASE_ID), {
|
||||
sessionId: SOURCE_SESSION,
|
||||
question: "我和伴侣还能走下去吗",
|
||||
returned: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("binding leaves another case's opened record alone and still binds a later card", () => {
|
||||
const messages = [
|
||||
{ role: "assistant", text: "旧卡", rectificationOffer: offer("opened", { caseId: OTHER_CASE, rectificationSessionId: RECT_SESSION }) },
|
||||
{ role: "assistant", text: RECTIFICATION_DIRECT_NEW_COPY, responseKind: "smalltalk", rectificationOffer: offer("opened") },
|
||||
];
|
||||
assert.equal(bindOfferToCase(
|
||||
[{ role: "assistant", rectificationOffer: offer("opened", { caseId: OTHER_CASE, rectificationSessionId: RECT_SESSION }) }],
|
||||
{ caseId: CASE_ID, rectificationSessionId: RECT_SESSION },
|
||||
), null);
|
||||
const bound = bindOfferToCase(messages, { caseId: CASE_ID, rectificationSessionId: RECT_SESSION });
|
||||
assert.ok(bound);
|
||||
assert.equal((bound![0]!.rectificationOffer as RectificationOfferRecord).caseId, OTHER_CASE);
|
||||
assert.equal((bound![1]!.rectificationOffer as RectificationOfferRecord).caseId, CASE_ID);
|
||||
assert.equal((bound![1]!.rectificationOffer as RectificationOfferRecord).status, "opened");
|
||||
});
|
||||
|
||||
test("the free reply can carry the opened offer once, without clobbering a case id", () => {
|
||||
const sentence = RECTIFICATION_DIRECT_NEW_COPY;
|
||||
const card = offer("opened");
|
||||
assert.equal(attachOpenedOfferToFreeMessage([], { sentence, offer: card }), "missing");
|
||||
assert.equal(attachOpenedOfferToFreeMessage(
|
||||
[{ role: "assistant", text: sentence, responseKind: "consultation" }],
|
||||
{ sentence, offer: card },
|
||||
), "missing");
|
||||
const attached = attachOpenedOfferToFreeMessage(
|
||||
[{ role: "user", text: "请帮我核对出生时间" }, { role: "assistant", text: sentence, responseKind: "smalltalk" }],
|
||||
{ sentence, offer: card },
|
||||
);
|
||||
assert.ok(Array.isArray(attached));
|
||||
assert.deepEqual(attached[1]!.rectificationOffer, card);
|
||||
assert.equal(attachOpenedOfferToFreeMessage(attached, { sentence, offer: card }), "present");
|
||||
const withCase = attachOpenedOfferToFreeMessage(
|
||||
[{ role: "assistant", text: sentence, responseKind: "smalltalk", rectificationOffer: offer("opened", { caseId: CASE_ID, rectificationSessionId: RECT_SESSION }) }],
|
||||
{ sentence, offer: card },
|
||||
);
|
||||
assert.equal(withCase, "present");
|
||||
});
|
||||
|
||||
test("explicit pass opens once, records an opened offer, and does not open again", async () => {
|
||||
const fresh = offerTool();
|
||||
const result = await fresh.tool.execute({ explicitRequest: true, userAsked: true });
|
||||
assert.equal(result.offered, true);
|
||||
assert.equal(result.directEntry, "new");
|
||||
assert.match(String(result.note), /好,我们开始核对出生时间/);
|
||||
assert.deepEqual(fresh.state.rectificationOffer, offer("opened"));
|
||||
assert.equal(fresh.state.rectificationOffer?.caseId, undefined);
|
||||
assert.deepEqual(fresh.state.rectificationDirectEntry, {
|
||||
kind: "new",
|
||||
sentence: RECTIFICATION_DIRECT_NEW_COPY,
|
||||
sourceSessionId: SOURCE_SESSION,
|
||||
});
|
||||
assert.equal(fresh.loads(), 0);
|
||||
assert.equal(fresh.decisions(), 1);
|
||||
const again = await fresh.tool.execute({ explicitRequest: true });
|
||||
assert.equal(again.offered, false);
|
||||
assert.match(String(again.note), /已经在打开/);
|
||||
assert.match(String(again.note), /不要再写解读/);
|
||||
assert.equal(fresh.decisions(), 1);
|
||||
assert.deepEqual(fresh.state.rectificationOffer, offer("opened"));
|
||||
});
|
||||
|
||||
test("explicitRequest bypasses an earlier offer, and userAsked alone still only shows a card", async () => {
|
||||
const direct = offerTool({ alreadyOffered: true });
|
||||
const opened = await direct.tool.execute({ explicitRequest: true });
|
||||
assert.equal(opened.directEntry, "new");
|
||||
assert.equal(direct.state.rectificationOffer?.status, "opened");
|
||||
assert.equal(direct.state.rectificationDirectEntry?.kind, "new");
|
||||
|
||||
const card = offerTool({ alreadyOffered: true });
|
||||
const asked = await card.tool.execute({ userAsked: true });
|
||||
assert.equal(asked.offered, true);
|
||||
assert.equal(card.state.rectificationOffer?.status, "offered");
|
||||
assert.equal(card.state.rectificationDirectEntry, undefined);
|
||||
|
||||
const held = offerTool();
|
||||
await held.tool.execute({});
|
||||
const second = await held.tool.execute({ explicitRequest: true });
|
||||
assert.match(String(second.note), /提议卡已经在了/);
|
||||
assert.equal(held.state.rectificationDirectEntry, undefined);
|
||||
assert.equal(held.state.rectificationOffer?.status, "offered");
|
||||
assert.equal(held.decisions(), 1);
|
||||
});
|
||||
|
||||
test("an in-progress case resumes that session, or says to continue in history", async () => {
|
||||
const resume = offerTool({
|
||||
decide: async () => ({ ok: false, reason: "case_in_progress" }),
|
||||
loadResumableSessionId: async () => RESUME_SESSION,
|
||||
});
|
||||
const resumed = await resume.tool.execute({ explicitRequest: true });
|
||||
assert.equal(resumed.offered, false);
|
||||
assert.equal(resumed.directEntry, "resume");
|
||||
assert.equal(resume.state.rectificationOffer, undefined);
|
||||
assert.deepEqual(resume.state.rectificationDirectEntry, {
|
||||
kind: "resume",
|
||||
sentence: RECTIFICATION_DIRECT_RESUME_COPY,
|
||||
sourceSessionId: SOURCE_SESSION,
|
||||
resumeSessionId: RESUME_SESSION,
|
||||
});
|
||||
assert.equal(resume.loads(), 1);
|
||||
|
||||
const missing = offerTool({ decide: async () => ({ ok: false, reason: "case_in_progress" }) });
|
||||
const refused = await missing.tool.execute({ explicitRequest: true });
|
||||
assert.equal(refused.directEntry, undefined);
|
||||
assert.equal(missing.state.rectificationDirectEntry?.kind, "refused");
|
||||
assert.equal(missing.state.rectificationDirectEntry?.sentence, RECTIFICATION_DIRECT_RESUME_ELSEWHERE_COPY);
|
||||
assert.equal(missing.state.rectificationOffer, undefined);
|
||||
assert.match(String(refused.note), /历史对话/);
|
||||
assert.doesNotMatch(String(refused.note), /不要提校正/);
|
||||
|
||||
const thrown = offerTool({
|
||||
decide: async () => ({ ok: false, reason: "case_in_progress" }),
|
||||
loadResumableSessionId: async () => { throw new Error("db down"); },
|
||||
});
|
||||
const fallback = await thrown.tool.execute({ explicitRequest: true });
|
||||
assert.equal(thrown.state.rectificationDirectEntry?.kind, "refused");
|
||||
assert.equal(thrown.state.rectificationDirectEntry?.sentence, RECTIFICATION_DIRECT_RESUME_ELSEWHERE_COPY);
|
||||
assert.match(String(fallback.note), /历史对话/);
|
||||
|
||||
const quiet = offerTool({ decide: async () => ({ ok: false, reason: "case_in_progress" }) });
|
||||
const ordinary = await quiet.tool.execute({});
|
||||
assert.equal(quiet.loads(), 0);
|
||||
assert.equal(quiet.state.rectificationDirectEntry, undefined);
|
||||
assert.match(String(ordinary.note), /历史对话/);
|
||||
assert.doesNotMatch(String(ordinary.note), /不要提校正/);
|
||||
});
|
||||
|
||||
test("explicit refusals are one sentence, and none of the four notes says to hide rectification", async () => {
|
||||
const unavailable = offerTool({ decide: async () => ({ ok: false, reason: "product_disabled" }) });
|
||||
const disabled = await unavailable.tool.execute({ explicitRequest: true });
|
||||
assert.equal(unavailable.state.rectificationDirectEntry?.kind, "refused");
|
||||
assert.equal(unavailable.state.rectificationDirectEntry?.sentence, RECTIFICATION_DIRECT_UNAVAILABLE_COPY);
|
||||
assert.equal(unavailable.state.rectificationOffer, undefined);
|
||||
assert.match(String(disabled.note), /出生时间核对这会儿还做不了/);
|
||||
assert.match(String(disabled.note), /不写内部原因/);
|
||||
assert.doesNotMatch(String(disabled.note), /不要提校正/);
|
||||
|
||||
const unpriced = offerTool({ decide: async () => ({ ok: false, reason: "pricing_unavailable" }) });
|
||||
const priced = await unpriced.tool.execute({ explicitRequest: true });
|
||||
assert.equal(unpriced.state.rectificationDirectEntry?.sentence, RECTIFICATION_DIRECT_UNAVAILABLE_COPY);
|
||||
assert.match(String(priced.note), /出生时间核对这会儿还做不了/);
|
||||
assert.match(String(priced.note), /不写价格/);
|
||||
assert.doesNotMatch(String(priced.note), /不要提校正/);
|
||||
|
||||
const family = offerTool({ decide: async () => ({ ok: false, reason: "not_self" }) });
|
||||
const other = await family.tool.execute({ explicitRequest: true });
|
||||
assert.equal(family.state.rectificationDirectEntry?.sentence, RECTIFICATION_DIRECT_NOT_SELF_COPY);
|
||||
assert.match(String(other.note), /给家人校正暂时还不支持/);
|
||||
assert.doesNotMatch(String(other.note), /不要提校正/);
|
||||
|
||||
const plainFamily = offerTool({ decide: async () => ({ ok: false, reason: "not_self" }) });
|
||||
const plain = await plainFamily.tool.execute({});
|
||||
assert.equal(plainFamily.state.rectificationDirectEntry, undefined);
|
||||
assert.doesNotMatch(String(plain.note), /不要提校正/);
|
||||
const plainPrice = offerTool({ decide: async () => ({ ok: false, reason: "pricing_unavailable" }) });
|
||||
assert.doesNotMatch(String((await plainPrice.tool.execute({})).note), /不要提校正/);
|
||||
const plainOff = offerTool({ decide: async () => ({ ok: false, reason: "product_disabled" }) });
|
||||
assert.doesNotMatch(String((await plainOff.tool.execute({})).note), /不要提校正/);
|
||||
assert.equal(plainPrice.state.rectificationDirectEntry, undefined);
|
||||
assert.equal(plainOff.state.rectificationDirectEntry, undefined);
|
||||
});
|
||||
|
||||
function receipt(state: ReturnType<typeof createConsultationRuntimeState>): AgentExecutionReceipt {
|
||||
return {
|
||||
runId: "run-direct",
|
||||
runtime: "mastra-agentic",
|
||||
skill: {
|
||||
name: "jyotish-vedic-astrology",
|
||||
loaded: state.jyotishSkillBound,
|
||||
referenceReads: state.skillReferenceReadCount,
|
||||
methodologySections: state.methodologySectionCount,
|
||||
},
|
||||
steps: publicConsultationRuntimeSteps(state),
|
||||
stepBudget: consultationStepBudgetReceipt(state),
|
||||
workflow: { route: "career", status: "ready", preciseTiming: "blocked", missingLayers: [] },
|
||||
};
|
||||
}
|
||||
|
||||
function visibleAnswer(events: readonly ConsultationAgentPublicEvent[]) {
|
||||
let text = "";
|
||||
for (const event of events) {
|
||||
if (event.type !== "answer.delta") continue;
|
||||
text = event.replace ? event.text : text + event.text;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
async function readStream(response: Response) {
|
||||
const events: ConsultationAgentPublicEvent[] = [];
|
||||
const parser = createNdjsonParser((event) => events.push(event));
|
||||
parser.finish(await response.text());
|
||||
return events;
|
||||
}
|
||||
|
||||
test("natal and window direct entry or explicit refusal skips the runtime-contract retry", async () => {
|
||||
const modes: Pass4Mode[] = ["verified_chart", "declared_birth_window"];
|
||||
const kinds = ["new", "resume", "refused"] as const;
|
||||
for (const pass4Mode of modes) {
|
||||
for (const kind of kinds) {
|
||||
const state = createConsultationRuntimeState();
|
||||
const sentence = kind === "new"
|
||||
? RECTIFICATION_DIRECT_NEW_COPY
|
||||
: kind === "resume"
|
||||
? RECTIFICATION_DIRECT_RESUME_COPY
|
||||
: RECTIFICATION_DIRECT_UNAVAILABLE_COPY;
|
||||
let contractRetries = 0;
|
||||
let answerRetries = 0;
|
||||
let warmupRan = false;
|
||||
let completedOutput = "";
|
||||
const response = streamAgentResponse({
|
||||
runId: "run-direct",
|
||||
requestId: REQUEST_ID,
|
||||
state,
|
||||
requireTool: true,
|
||||
pass4Mode,
|
||||
stepScopedAnswer: true,
|
||||
toolStatus: () => "ready",
|
||||
receipt: () => receipt(state),
|
||||
...(pass4Mode === "declared_birth_window"
|
||||
? {
|
||||
warmup: async (send: (event: ConsultationAgentPublicEvent) => void) => {
|
||||
warmupRan = true;
|
||||
send({ type: "activity", phase: "chart-calculation", label: "窗口预热" });
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
stream: (async function* () {
|
||||
yield { type: "text-delta", payload: { text: "我先看看盘。" } };
|
||||
yield { type: "tool-call", payload: { toolCallId: "offer-1", toolName: "offer-birth-time-rectification" } };
|
||||
state.rectificationDirectEntry = {
|
||||
kind,
|
||||
sentence,
|
||||
sourceSessionId: SOURCE_SESSION,
|
||||
...(kind === "resume" ? { resumeSessionId: RESUME_SESSION } : {}),
|
||||
};
|
||||
if (kind === "new") state.rectificationOffer = offer("opened");
|
||||
yield { type: "text-delta", payload: { text: "然后写一段解读。" } };
|
||||
yield STOP_FINISH;
|
||||
})(),
|
||||
retry: async () => {
|
||||
contractRetries += 1;
|
||||
return (async function* () { yield STOP_FINISH; })();
|
||||
},
|
||||
retryForAnswer: async () => {
|
||||
answerRetries += 1;
|
||||
return (async function* () { yield STOP_FINISH; })();
|
||||
},
|
||||
onComplete: (output) => { completedOutput = output; },
|
||||
});
|
||||
const events = await readStream(response);
|
||||
assert.equal(contractRetries, 0, `${pass4Mode} ${kind} retried the contract`);
|
||||
assert.equal(answerRetries, 0, `${pass4Mode} ${kind} retried the answer`);
|
||||
assert.equal(state.consultationToolCompleted, false);
|
||||
assert.equal(visibleAnswer(events), sentence);
|
||||
assert.equal(visibleAnswer(events).includes("我先看看盘"), false);
|
||||
assert.equal(visibleAnswer(events).includes("然后写一段解读"), false);
|
||||
assert.equal(visibleAnswer(events).includes(CONTRACT_DEGRADED_NOTE), false);
|
||||
assert.equal(completedOutput, sentence);
|
||||
if (pass4Mode === "declared_birth_window") assert.equal(warmupRan, true);
|
||||
const completed = events.filter((event) => event.type === "run.completed");
|
||||
assert.equal(completed.length, 1);
|
||||
const done = completed[0]!;
|
||||
assert.equal(done.type === "run.completed" && done.responseKind, "smalltalk");
|
||||
assert.equal(done.type === "run.completed" && done.receipt, undefined);
|
||||
if (kind === "new") {
|
||||
assert.equal(done.type === "run.completed" && done.rectificationOffer?.status, "opened");
|
||||
assert.equal(done.type === "run.completed" && done.rectificationOffer?.caseId, undefined);
|
||||
assert.deepEqual(done.type === "run.completed" ? done.rectificationDirectOpen : undefined, {
|
||||
sourceSessionId: SOURCE_SESSION,
|
||||
mode: "new",
|
||||
});
|
||||
} else if (kind === "resume") {
|
||||
assert.equal(done.type === "run.completed" && done.rectificationOffer, undefined);
|
||||
assert.deepEqual(done.type === "run.completed" ? done.rectificationDirectOpen : undefined, {
|
||||
sourceSessionId: SOURCE_SESSION,
|
||||
mode: "resume",
|
||||
resumeSessionId: RESUME_SESSION,
|
||||
});
|
||||
} else {
|
||||
assert.equal(done.type === "run.completed" && done.rectificationDirectOpen, undefined);
|
||||
assert.equal(done.type === "run.completed" && done.rectificationOffer, undefined);
|
||||
}
|
||||
assert.equal(events.some((event) => event.type === "activity" && event.label === "正在补齐方法与计算步骤"), false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("a reading that already streamed is replaced by the direct-entry sentence", async () => {
|
||||
for (const pass4Mode of ["verified_chart", "declared_birth_window"] as const) {
|
||||
const state = createConsultationRuntimeState();
|
||||
state.consultationToolCompleted = true;
|
||||
state.consultationToolSuccessCount = 1;
|
||||
let contractRetries = 0;
|
||||
const response = streamAgentResponse({
|
||||
runId: "run-direct",
|
||||
requestId: REQUEST_ID,
|
||||
state,
|
||||
requireTool: true,
|
||||
pass4Mode,
|
||||
stepScopedAnswer: true,
|
||||
toolStatus: () => "ready",
|
||||
receipt: () => receipt(state),
|
||||
stream: (async function* () {
|
||||
yield { type: "text-delta", payload: { text: "事业方向的判断如下。" } };
|
||||
yield { type: "step-finish", payload: { stepResult: { reason: "stop" } } };
|
||||
state.rectificationDirectEntry = {
|
||||
kind: "new",
|
||||
sentence: RECTIFICATION_DIRECT_NEW_COPY,
|
||||
sourceSessionId: SOURCE_SESSION,
|
||||
};
|
||||
state.rectificationOffer = offer("opened");
|
||||
yield STOP_FINISH;
|
||||
})(),
|
||||
retry: async () => {
|
||||
contractRetries += 1;
|
||||
return (async function* () { yield STOP_FINISH; })();
|
||||
},
|
||||
});
|
||||
const events = await readStream(response);
|
||||
assert.equal(contractRetries, 0);
|
||||
assert.equal(events.some((event) => event.type === "answer.delta" && event.text.includes("事业方向的判断如下")), true);
|
||||
assert.equal(events.some((event) => event.type === "answer.delta" && event.replace === true && event.text === RECTIFICATION_DIRECT_NEW_COPY), true);
|
||||
assert.equal(visibleAnswer(events), RECTIFICATION_DIRECT_NEW_COPY);
|
||||
}
|
||||
});
|
||||
|
||||
test("an ordinary consultation still retries once when the calculation never finished", async () => {
|
||||
const state = createConsultationRuntimeState();
|
||||
let contractRetries = 0;
|
||||
let answerRetries = 0;
|
||||
const response = streamAgentResponse({
|
||||
runId: "run-direct",
|
||||
requestId: REQUEST_ID,
|
||||
state,
|
||||
requireTool: true,
|
||||
pass4Mode: "verified_chart",
|
||||
stepScopedAnswer: true,
|
||||
toolStatus: () => "ready",
|
||||
receipt: () => receipt(state),
|
||||
stream: (async function* () {
|
||||
yield { type: "text-delta", payload: { text: "还没排盘。" } };
|
||||
yield STOP_FINISH;
|
||||
})(),
|
||||
retry: async () => {
|
||||
contractRetries += 1;
|
||||
state.consultationToolCompleted = true;
|
||||
state.consultationToolSuccessCount = 1;
|
||||
return (async function* () {
|
||||
yield { type: "text-delta", payload: { text: "事业方向可以看。" } };
|
||||
yield STOP_FINISH;
|
||||
})();
|
||||
},
|
||||
retryForAnswer: async () => {
|
||||
answerRetries += 1;
|
||||
return (async function* () { yield STOP_FINISH; })();
|
||||
},
|
||||
});
|
||||
const events = await readStream(response);
|
||||
assert.equal(contractRetries, 1);
|
||||
assert.equal(answerRetries, 0);
|
||||
assert.equal(visibleAnswer(events), "事业方向可以看。");
|
||||
assert.equal(visibleAnswer(events).includes(CONTRACT_DEGRADED_NOTE), false);
|
||||
assert.equal(visibleAnswer(events).includes("还没排盘"), false);
|
||||
const done = events.find((event) => event.type === "run.completed");
|
||||
assert.equal(done?.type === "run.completed" && done.receipt !== undefined, true);
|
||||
assert.equal(done?.type === "run.completed" && done.responseKind, undefined);
|
||||
assert.equal(events.some((event) => event.type === "activity" && event.label === "正在补齐方法与计算步骤"), true);
|
||||
});
|
||||
|
||||
test("the offer rule is defined once, and direct entry settles with the free completion", () => {
|
||||
const definitions = walkSources(fileURLToPath(new URL("../src/", import.meta.url))).filter((file) => (
|
||||
readFileSync(file, "utf8").includes("export const RECTIFICATION_OFFER_RULE")
|
||||
));
|
||||
assert.equal(definitions.length, 1);
|
||||
assert.match(definitions[0]!, /consultation-thinking-plan\.ts$/);
|
||||
assert.match(RECTIFICATION_OFFER_RULE, /不承诺能收窄出生时间/);
|
||||
assert.match(RECTIFICATION_OFFER_RULE, /不在对话里收集经历做校正/);
|
||||
assert.match(RECTIFICATION_OFFER_RULE, /explicitRequest=true/);
|
||||
assert.match(RECTIFICATION_OFFER_RULE, /只写那一句过渡,不写解读/);
|
||||
assert.match(RECTIFICATION_OFFER_RULE, /只是不认同某个解释,按纠正规则认下、改读,不提校正/);
|
||||
assert.match(RECTIFICATION_OFFER_RULE, /userAsked=true/);
|
||||
|
||||
const tools = src("../src/mastra/consultation-tools.ts");
|
||||
assert.doesNotMatch(tools, /不承诺能收窄出生时间/);
|
||||
assert.doesNotMatch(tools, /不要提校正/);
|
||||
assert.match(tools, /explicitRequest/);
|
||||
assert.match(tools, /userAsked/);
|
||||
|
||||
const route = src("../src/app/api/consult/route.ts");
|
||||
assert.match(route, /大运、子运只说「在哪一段附近」,不写起止年月日/);
|
||||
assert.match(route, /这只是粗看。" \+ RECTIFICATION_OFFER_RULE;/);
|
||||
assert.doesNotMatch(route, /请你帮我进行生时纠正/);
|
||||
const direct = route.slice(
|
||||
route.indexOf("async function completeDirectRectificationTurn"),
|
||||
route.indexOf("let settlement:"),
|
||||
);
|
||||
assert.match(direct, /complete_consultation_free/);
|
||||
assert.match(direct, /responseKind: "smalltalk"/);
|
||||
assert.match(direct, /persistOpenedDirectOffer/);
|
||||
assert.doesNotMatch(direct, /complete_consultation_response/);
|
||||
assert.doesNotMatch(direct, /consultation_requests/);
|
||||
const windowBlock = route.slice(
|
||||
route.indexOf("if (shouldRunDeclaredWindowWorkflow(consultationMode))"),
|
||||
route.indexOf("if (!prepared.serverChart)"),
|
||||
);
|
||||
const natalBlock = route.slice(
|
||||
route.indexOf("if (!prepared.serverChart)"),
|
||||
route.indexOf("const history = storedHistory;"),
|
||||
);
|
||||
assert.match(windowBlock, /settleAnswer/);
|
||||
assert.match(natalBlock, /settleAnswer/);
|
||||
assert.match(windowBlock, /warmup:/);
|
||||
assert.match(windowBlock, /precomputeWindowConsultation/);
|
||||
assert.match(windowBlock, /运行合同不完整/);
|
||||
assert.match(natalBlock, /运行合同不完整/);
|
||||
assert.match(route, /getRectificationEntrySummary\(accounting, userId\)/);
|
||||
assert.match(route, /\.from\("agentic_rectification_cases"\)/);
|
||||
assert.match(route, /\.select\("session_id"\)/);
|
||||
|
||||
const stream = src("../src/lib/stream-agent-response.ts");
|
||||
assert.match(stream, /if \(options\.state\.rectificationDirectEntry\) return true;/);
|
||||
assert.match(stream, /rectificationDirectRunCompleted\(options\.state\)/);
|
||||
assert.doesNotMatch(stream, /请你帮我进行生时纠正/);
|
||||
|
||||
const hook = src("../src/hooks/use-consultation-run.ts");
|
||||
assert.match(hook, /openRectificationFromChat\(rectificationDirectOpen\.sourceSessionId\)/);
|
||||
assert.match(hook, /openRectificationSession\(rectificationDirectOpen\.resumeSessionId\)/);
|
||||
assert.equal(hook.match(/openRectificationFromChat\(rectificationDirectOpen\.sourceSessionId\)/g)?.length, 1);
|
||||
assert.match(hook, /responseKind === "smalltalk" \? \{[\s\S]*?rectificationOffer/);
|
||||
|
||||
const page = src("../src/app/(app)/page.tsx");
|
||||
assert.match(page, /openAccountDialog, openRectificationSession, openRectificationFromChat,/);
|
||||
assert.doesNotMatch(page, /请你帮我进行生时纠正/);
|
||||
|
||||
const server = src("../src/lib/rectification-in-chat-server.ts");
|
||||
assert.match(server, /return sourceFromSessionRows/);
|
||||
assert.match(server, /attachOpenedOfferToFreeMessage/);
|
||||
const persist = server.slice(server.indexOf("export async function persistOpenedDirectOffer"), server.indexOf("export async function bindRectificationSource"));
|
||||
assert.doesNotMatch(persist, /consultation_requests/);
|
||||
|
||||
consultationAgentPublicEventSchema.parse({
|
||||
type: "run.completed",
|
||||
responseKind: "smalltalk",
|
||||
rectificationOffer: offer("opened"),
|
||||
rectificationDirectOpen: { sourceSessionId: SOURCE_SESSION, mode: "new" },
|
||||
});
|
||||
});
|
||||
@@ -128,6 +128,8 @@ function offerTool(options: { alreadyOffered?: boolean; decide?: () => Promise<{
|
||||
decide: options.decide ?? (async () => ({ ok: true, creditCost: 3, questionEstimate: 8 })),
|
||||
alreadyOffered: options.alreadyOffered ?? false,
|
||||
revisitQuestion: "我和伴侣还能走下去吗",
|
||||
sourceSessionId: SOURCE_SESSION,
|
||||
loadResumableSessionId: async () => null,
|
||||
now: () => new Date("2026-10-07T00:00:00.000Z"),
|
||||
},
|
||||
}) as Record<string, { execute: (input: unknown, context?: unknown) => Promise<Record<string, unknown>> }>;
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* BUG-1307: the opening says how close the recorded minute is to the nearest
|
||||
* chart change. Question order stays off unless the env flag is "on".
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { buildOpeningBrief } from "../src/lib/rectification-agentic/v9/agent-run-messages.ts";
|
||||
import { orderDiscriminatorsForNearestBoundary } from "../src/lib/rectification-agentic/v9/method-followup.ts";
|
||||
import { parseV9CaseDossier } from "../src/lib/rectification-agentic/v9/tool-service.ts";
|
||||
import { fetchOpeningBoundary, openingFactsFromBoundaryPayload, resolveOpeningBoundaryFacts } from "../src/lib/rectification-agentic/opening-boundary.ts";
|
||||
import { scanCaseSegments } from "../src/lib/rectification-agentic/v9/segment-scan.ts";
|
||||
import {
|
||||
nearestBoundaryFromTransitions,
|
||||
nearestBoundaryProbesEnabled,
|
||||
orderByBoundaryDomains,
|
||||
topicsForLayers,
|
||||
} from "../src/lib/rectification-agentic/nearest-boundary.ts";
|
||||
import {
|
||||
OPENING_BODY_JARGON,
|
||||
isAcceptableOpeningBody,
|
||||
openingBoundarySentence,
|
||||
openingSpokenBody,
|
||||
} from "../src/lib/rectification-agentic/user-copy.ts";
|
||||
import { dossierFixture } from "./rectification-v9-test-support.ts";
|
||||
|
||||
const RANGE = ["04:45", "05:15"] as const;
|
||||
const PLAIN = "我们来把你的出生时间缩小到更准的范围,现在先在 04:45–05:15 之间找。做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照。";
|
||||
const BANNED = ["秒", "记录时间错了", "记录时间不准", "已确认", ...OPENING_BODY_JARGON];
|
||||
|
||||
function assertPlain(text: string): void {
|
||||
for (const word of BANNED) assert.equal(text.includes(word), false, word);
|
||||
assert.doesNotMatch(text, /[??]/);
|
||||
assert.ok(text.split(/(?<=。)/).filter(Boolean).length <= 3, text);
|
||||
}
|
||||
|
||||
test("default opening stays two sentences", () => {
|
||||
assert.equal(openingSpokenBody(RANGE), PLAIN);
|
||||
assert.equal(openingSpokenBody(null), "我们来把你的出生时间缩小到更准的范围。做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照。");
|
||||
assert.equal(openingBoundarySentence({ kind: "period_only" }), null);
|
||||
assert.equal(openingBoundarySentence({ kind: "unavailable" }), null);
|
||||
assert.equal(openingSpokenBody(RANGE, null, { kind: "period_only" }), PLAIN);
|
||||
assert.equal(isAcceptableOpeningBody(PLAIN, RANGE), true);
|
||||
});
|
||||
|
||||
test("near, under a minute, stable, and only the birth chart", () => {
|
||||
const near = openingSpokenBody(RANGE, null, { kind: "near", distanceMinutes: 4, topics: ["父母", "工作", "<script>"] });
|
||||
assert.match(near, /差 4 分钟,两边不一样的是父母、工作。/);
|
||||
assert.doesNotMatch(near, /接下来先问这几件|script/);
|
||||
const close = openingSpokenBody(RANGE, null, { kind: "near", distanceMinutes: 0, topics: ["婚恋"], askFirst: true });
|
||||
assert.match(close, /不到 1 分钟,两边不一样的是婚恋,接下来先问这几件。/);
|
||||
const stable = openingBoundarySentence({ kind: "stable" });
|
||||
assert.equal(stable, "你填的时间附近这些盘都不换,先从经历开始。");
|
||||
const onlyBirth = openingSpokenBody(RANGE, null, { kind: "near", distanceMinutes: 2, topics: [] });
|
||||
assert.match(onlyBirth, /差 2 分钟。/);
|
||||
assert.doesNotMatch(onlyBirth, /两边不一样/);
|
||||
for (const text of [near, close, openingSpokenBody(RANGE, null, { kind: "stable" }), onlyBirth]) {
|
||||
assertPlain(text);
|
||||
assert.equal(isAcceptableOpeningBody(text, RANGE), true);
|
||||
assert.equal(isAcceptableOpeningBody(PLAIN, RANGE, null, { kind: "stable" }), false);
|
||||
}
|
||||
assert.equal(isAcceptableOpeningBody(`${near}再补一句。`, RANGE), false);
|
||||
});
|
||||
|
||||
test("d1 change is named in the same sentence, and no d1 change keeps today's sentence", () => {
|
||||
const changed = openingFactsFromBoundaryPayload({
|
||||
found: true,
|
||||
distance_minutes: 4,
|
||||
layers: [{ layer: "d1" }, { layer: "d12" }, { layer: "d10" }],
|
||||
}, false);
|
||||
assert.equal(changed.d1Changed, true);
|
||||
assert.deepEqual(changed.topics, ["工作", "父母"]);
|
||||
assert.equal(openingBoundarySentence(changed), "你填的时间离最近会换的地方差 4 分钟,这里连本命上升都换,两边不一样的是工作、父母。");
|
||||
const onlyBirth = openingFactsFromBoundaryPayload({
|
||||
found: true,
|
||||
distance_minutes: 0,
|
||||
layers: [{ layer: "d1" }],
|
||||
}, true);
|
||||
assert.equal(onlyBirth.d1Changed, true);
|
||||
assert.deepEqual(onlyBirth.topics, []);
|
||||
assert.equal(onlyBirth.askFirst, false);
|
||||
assert.equal(openingBoundarySentence(onlyBirth), "你填的时间离最近会换的地方不到 1 分钟,这里连本命上升都换。");
|
||||
assert.equal(
|
||||
openingBoundarySentence({ kind: "near", distanceMinutes: 2, topics: [], d1Changed: true, askFirst: true }),
|
||||
"你填的时间离最近会换的地方差 2 分钟,这里连本命上升都换。",
|
||||
);
|
||||
const asked = openingBoundarySentence({ kind: "near", distanceMinutes: 2, topics: ["婚恋"], d1Changed: true, askFirst: true });
|
||||
assert.equal(asked, "你填的时间离最近会换的地方差 2 分钟,这里连本命上升都换,两边不一样的是婚恋,接下来先问这几件。");
|
||||
const unchanged = openingFactsFromBoundaryPayload({
|
||||
found: true,
|
||||
distance_minutes: 4,
|
||||
layers: [{ layer: "d12" }, { layer: "d10" }],
|
||||
}, false);
|
||||
assert.equal(unchanged.d1Changed, false);
|
||||
assert.equal(openingBoundarySentence(unchanged), "你填的时间离最近会换的地方差 4 分钟,两边不一样的是工作、父母。");
|
||||
assert.equal(
|
||||
openingBoundarySentence({ kind: "near", distanceMinutes: 0, topics: ["婚恋"] }),
|
||||
"你填的时间离最近会换的地方不到 1 分钟,两边不一样的是婚恋。",
|
||||
);
|
||||
assert.equal(openingBoundarySentence({ kind: "stable" }), "你填的时间附近这些盘都不换,先从经历开始。");
|
||||
assert.equal(openingBoundarySentence({ kind: "period_only" }), null);
|
||||
assert.equal(openingBoundarySentence({ kind: "unavailable" }), null);
|
||||
const askedFacts = { kind: "near" as const, distanceMinutes: 2, topics: ["婚恋"], d1Changed: true, askFirst: true };
|
||||
for (const facts of [changed, onlyBirth, askedFacts, unchanged]) {
|
||||
const text = openingSpokenBody(RANGE, null, facts);
|
||||
assertPlain(text);
|
||||
assert.equal(isAcceptableOpeningBody(text, RANGE, null, facts), true);
|
||||
assert.equal(text.includes("D1"), false);
|
||||
assert.equal(text.split(/(?<=。)/).filter(Boolean).length, 3);
|
||||
}
|
||||
assert.match(openingSpokenBody(RANGE, null, changed), /这里连本命上升都换,两边不一样的是工作、父母。/);
|
||||
});
|
||||
|
||||
test("topics come from the layer allowlist, not the payload string", () => {
|
||||
assert.deepEqual(topicsForLayers(["d12", "d10", "d1", "kp1", "nope"]), ["工作", "父母"]);
|
||||
const facts = openingFactsFromBoundaryPayload({
|
||||
found: true,
|
||||
distance_minutes: 0,
|
||||
at: "19:15",
|
||||
topics: ["已确认秒"],
|
||||
layers: [
|
||||
{ layer: "d12", topic: "记录时间错了" },
|
||||
{ layer: "kp1", topic: "工作" },
|
||||
],
|
||||
}, false);
|
||||
assert.equal(facts.kind, "near");
|
||||
assert.deepEqual(facts.topics, ["父母"]);
|
||||
assert.equal(facts.askFirst, false);
|
||||
assert.equal(openingFactsFromBoundaryPayload({ found: false, reason: "no_transition" }, true).kind, "stable");
|
||||
});
|
||||
|
||||
test("question order is a switch and defaults off", () => {
|
||||
assert.equal(nearestBoundaryProbesEnabled(undefined), false);
|
||||
assert.equal(nearestBoundaryProbesEnabled("on"), true);
|
||||
const rows = [
|
||||
{ id: "career", eventProbe: { domain: "career" }, contrastProbe: null },
|
||||
{ id: "family", eventProbe: { domain: "family" }, contrastProbe: null },
|
||||
{ id: "money", eventProbe: null, contrastProbe: { domain: "finance" } },
|
||||
];
|
||||
const scan = { transitions: [
|
||||
{ layer: "d12", at: "19:16", from_sign: "白羊座", to_sign: "金牛座" },
|
||||
{ layer: "kp1", at: "19:15", from_sign: "白羊座", to_sign: "金牛座" },
|
||||
] };
|
||||
const boundary = nearestBoundaryFromTransitions(scan.transitions, "19:15");
|
||||
assert.equal(boundary.distanceMinutes, 1);
|
||||
assert.deepEqual(boundary.domains, ["family"]);
|
||||
const input = { windowScan: scan, reportedTime: "19:15", birthTimeSource: "approximate" };
|
||||
assert.equal(orderDiscriminatorsForNearestBoundary(rows, input), rows);
|
||||
const asked = orderDiscriminatorsForNearestBoundary(rows, { ...input, probeFlag: "on" });
|
||||
assert.deepEqual(asked.map((row) => row.id), ["family", "career", "money"]);
|
||||
assert.equal(orderDiscriminatorsForNearestBoundary(rows, { ...input, probeFlag: "on", birthTimeSource: "period_only" }), rows);
|
||||
assert.equal(orderDiscriminatorsForNearestBoundary(rows, {
|
||||
...input,
|
||||
probeFlag: "on",
|
||||
windowScan: { transitions: [{ layer: "d12", at: "19:30", from_sign: "白羊座", to_sign: "金牛座" }] },
|
||||
}), rows);
|
||||
const same = orderByBoundaryDomains(rows, ["family"], (row) => row.eventProbe?.domain ?? row.contrastProbe?.domain ?? "");
|
||||
assert.equal(same[0]?.id, "family");
|
||||
});
|
||||
|
||||
test("period_only and a missing clock do not scan", async () => {
|
||||
let called = 0;
|
||||
const loadCompute = async () => {
|
||||
called += 1;
|
||||
throw new Error("should not scan");
|
||||
};
|
||||
assert.equal((await resolveOpeningBoundaryFacts({
|
||||
birthTimeSource: "period_only",
|
||||
reportedBirthTime: "19:15",
|
||||
candidateRange: { start_time: "19:00", end_time: "19:30" },
|
||||
loadCompute,
|
||||
})).kind, "period_only");
|
||||
assert.equal((await resolveOpeningBoundaryFacts({
|
||||
birthTimeSource: null,
|
||||
reportedBirthTime: null,
|
||||
candidateRange: null,
|
||||
loadCompute,
|
||||
})).kind, "unavailable");
|
||||
assert.equal(called, 0);
|
||||
const dossier = parseV9CaseDossier(dossierFixture());
|
||||
assert.ok(dossier);
|
||||
const brief = buildOpeningBrief(dossier);
|
||||
assert.match(brief, /正文两句大白话/);
|
||||
assert.doesNotMatch(brief, /服务端另有一句/);
|
||||
const withSentence = buildOpeningBrief(dossier, null, "你填的时间附近这些盘都不换,先从经历开始。");
|
||||
assert.match(withSentence, /原样放在这两句之间/);
|
||||
assert.match(brief, /正文不用大运/);
|
||||
});
|
||||
|
||||
test("opening and segment scans send the snapshot ayanamsa", async (t) => {
|
||||
const bodies: Array<Record<string, unknown>> = [];
|
||||
t.mock.method(globalThis, "fetch", async (_endpoint: unknown, init?: RequestInit) => {
|
||||
const body = JSON.parse(String(init?.body)) as {
|
||||
start_at: string;
|
||||
end_at: string;
|
||||
ayanamsa?: string;
|
||||
boundary_charts?: unknown;
|
||||
};
|
||||
bodies.push(body);
|
||||
const start = Date.parse(`${body.start_at}:00Z`);
|
||||
const end = Date.parse(`${body.end_at}:00Z`);
|
||||
const width = (end - start) / 60000 + 1;
|
||||
const minutes = Array.from({ length: width }, (_, offset) => {
|
||||
const stamp = new Date(start + offset * 60000).toISOString().slice(0, 16);
|
||||
return { offset, date: stamp.slice(0, 10), time: stamp.slice(11), signs: { D1: 1, D9: 2, D10: 3 } };
|
||||
});
|
||||
return Response.json({
|
||||
start_at: body.start_at,
|
||||
end_at: body.end_at,
|
||||
ayanamsa: body.ayanamsa,
|
||||
node_mode: "mean",
|
||||
step_minutes: 1,
|
||||
window_minutes: width,
|
||||
minutes,
|
||||
nearest_boundary: { found: false, reason: "no_transition" },
|
||||
});
|
||||
});
|
||||
const base = {
|
||||
birth_date: "2000-06-14",
|
||||
latitude: 40,
|
||||
longitude: -74,
|
||||
timezone_offset: -4,
|
||||
reported_birth_time: "09:10",
|
||||
};
|
||||
const range = { start_time: "09:00", end_time: "09:06" };
|
||||
await fetchOpeningBoundary({ snapshot: { ...base, ayanamsa: "lahiri" }, range, recordedTime: "09:10" });
|
||||
await scanCaseSegments({ snapshot: { ...base, ayanamsa: "lahiri" }, range, targets: ["D1", "D9", "D10"] });
|
||||
await fetchOpeningBoundary({ snapshot: base, range, recordedTime: "09:10" });
|
||||
await scanCaseSegments({ snapshot: base, range, targets: ["D1", "D9", "D10"] });
|
||||
assert.equal(bodies.length, 4);
|
||||
assert.deepEqual(bodies.map((body) => body.ayanamsa), ["lahiri", "lahiri", "raman", "raman"]);
|
||||
assert.ok(Array.isArray(bodies[0]?.boundary_charts));
|
||||
assert.equal(bodies[1]?.boundary_charts, undefined);
|
||||
assert.equal(bodies[0]?.node_mode, undefined);
|
||||
assert.equal(bodies[1]?.node_mode, undefined);
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Nearest divisional-chart change around a recorded minute.
|
||||
|
||||
Reads ``window_scan`` transitions only. Does not score and does not change
|
||||
the scan payload.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
# Same domain map as event_probes.LAYER_DOMAIN. Topics are the opening words.
|
||||
CHART_LAYERS = ("d9", "d10", "d4", "d5", "d24", "d7", "d12", "d2", "d11", "d30", "d1")
|
||||
LAYER_DOMAIN = {
|
||||
"d9": "relationship",
|
||||
"d10": "career",
|
||||
"d4": "relocation",
|
||||
"d5": "education",
|
||||
"d24": "education",
|
||||
"d7": "family",
|
||||
"d12": "family",
|
||||
"d2": "finance",
|
||||
"d11": "finance",
|
||||
"d30": "health_pressure",
|
||||
}
|
||||
LAYER_TOPIC = {
|
||||
"d9": "婚恋",
|
||||
"d10": "工作",
|
||||
"d4": "住处",
|
||||
"d5": "学业",
|
||||
"d24": "学业",
|
||||
"d7": "子女",
|
||||
"d12": "父母",
|
||||
"d2": "钱",
|
||||
"d11": "钱",
|
||||
"d30": "身体",
|
||||
}
|
||||
|
||||
|
||||
def _clock(value: Any) -> int | None:
|
||||
raw = str(value or "")[:5]
|
||||
if len(raw) != 5 or raw[2] != ":" or not (raw[:2].isdigit() and raw[3:].isdigit()):
|
||||
return None
|
||||
hour, minute = int(raw[:2]), int(raw[3:])
|
||||
if hour > 23 or minute > 59:
|
||||
return None
|
||||
return hour * 60 + minute
|
||||
|
||||
|
||||
def _distance(left: int, right: int) -> int:
|
||||
span = abs(left - right)
|
||||
return min(span, 1440 - span)
|
||||
|
||||
|
||||
def nearest_chart_boundary(scan: dict[str, Any] | None, recorded_time: str | None) -> dict[str, Any]:
|
||||
"""Closest real sign change to ``recorded_time``.
|
||||
|
||||
Display-only layers and segment-start rows that do not change sign are
|
||||
ignored. An equal distance keeps the earlier clock.
|
||||
"""
|
||||
recorded = _clock(recorded_time)
|
||||
rows = scan.get("transitions") if isinstance(scan, dict) else None
|
||||
if recorded is None or not isinstance(rows, list):
|
||||
return {"found": False, "reason": "unavailable", "layers": [], "topics": [], "domains": []}
|
||||
changes: list[tuple[int, str, str, str]] = []
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
layer = str(row.get("layer") or "")
|
||||
if layer not in CHART_LAYERS:
|
||||
continue
|
||||
before, after = row.get("from_sign"), row.get("to_sign")
|
||||
if not isinstance(before, str) or not isinstance(after, str) or before == after:
|
||||
continue
|
||||
at = _clock(row.get("at"))
|
||||
if at is None:
|
||||
continue
|
||||
changes.append((at, layer, before, after))
|
||||
if not changes:
|
||||
return {"found": False, "reason": "no_transition", "layers": [], "topics": [], "domains": []}
|
||||
best_at = min(changes, key=lambda item: (_distance(item[0], recorded), item[0]))[0]
|
||||
order = {layer: index for index, layer in enumerate(CHART_LAYERS)}
|
||||
layers: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
for at, layer, before, after in sorted(changes, key=lambda item: order[item[1]]):
|
||||
if at != best_at or layer in seen:
|
||||
continue
|
||||
seen.add(layer)
|
||||
layers.append({
|
||||
"layer": layer,
|
||||
"from_sign": before,
|
||||
"to_sign": after,
|
||||
"topic": LAYER_TOPIC.get(layer),
|
||||
"domain": LAYER_DOMAIN.get(layer),
|
||||
})
|
||||
topics: list[str] = []
|
||||
domains: list[str] = []
|
||||
for row in layers:
|
||||
topic, domain = row["topic"], row["domain"]
|
||||
if isinstance(topic, str) and topic not in topics:
|
||||
topics.append(topic)
|
||||
if isinstance(domain, str) and domain not in domains:
|
||||
domains.append(domain)
|
||||
return {
|
||||
"found": True,
|
||||
"reason": None,
|
||||
"at": f"{best_at // 60:02d}:{best_at % 60:02d}",
|
||||
"distance_minutes": _distance(best_at, recorded),
|
||||
"layers": layers,
|
||||
"topics": topics,
|
||||
"domains": domains,
|
||||
}
|
||||
@@ -13,7 +13,25 @@ from typing import Any
|
||||
from scripts import domain_calculation_service, varga
|
||||
|
||||
TARGETS = ("D1", "D9", "D10")
|
||||
BOUNDARY_CHARTS = ("D1", "D2", "D4", "D5", "D7", "D9", "D10", "D11", "D12", "D24", "D30")
|
||||
VERSION = "rectification-varga-segments-v1"
|
||||
SEGMENT_AYANAMSAS = frozenset(("raman", "lahiri", "kp", "true_citra", "true_pushya", "fagan_bradley"))
|
||||
|
||||
|
||||
def _segment_ayanamsa(value: Any) -> str:
|
||||
"""Blank stays Raman. Unknown names raise; they do not fall back."""
|
||||
if value is None or (isinstance(value, str) and not value.strip()):
|
||||
return "raman"
|
||||
if not isinstance(value, str):
|
||||
raise ValueError("invalid_ayanamsa")
|
||||
key = value.strip().lower().replace("-", "_")
|
||||
if key in {"krishnamurti", "krishnamurti_paddhati", "krishnamurti/kp"}:
|
||||
key = "kp"
|
||||
elif key == "true_chitra":
|
||||
key = "true_citra"
|
||||
if key not in SEGMENT_AYANAMSAS:
|
||||
raise ValueError("invalid_ayanamsa")
|
||||
return key
|
||||
|
||||
|
||||
def _number(body, name, low, high):
|
||||
@@ -45,12 +63,17 @@ def scan_varga_segments(body: dict[str, Any], *, cache_dir: Path | None = None)
|
||||
or any(not isinstance(target, str) or target not in TARGETS for target in targets)
|
||||
or len(set(targets)) != len(targets)):
|
||||
raise ValueError("invalid_target_charts")
|
||||
boundary_charts, recorded_time = _boundary_request(body)
|
||||
ayanamsa = _segment_ayanamsa(body.get("ayanamsa"))
|
||||
profile = {
|
||||
"lat": _number(body, "latitude", -90, 90),
|
||||
"lon": _number(body, "longitude", -180, 180),
|
||||
"tz": _number(body, "timezone_offset", -14, 14),
|
||||
"ayanamsa": "raman", "node_mode": "mean",
|
||||
"ayanamsa": ayanamsa, "node_mode": "mean",
|
||||
}
|
||||
if boundary_charts:
|
||||
profile["boundary_charts"] = list(boundary_charts)
|
||||
profile["recorded_time"] = recorded_time
|
||||
key = hashlib.sha256(json.dumps({"version": VERSION, "start": str(start), "end": str(end), "targets": targets, **profile}, sort_keys=True).encode()).hexdigest()
|
||||
root = cache_dir if cache_dir is not None else Path(os.environ.get("JYOTISH_CACHE_DIR", str(Path(__file__).resolve().parents[1] / "scratch" / "local"))) / "varga_segments"
|
||||
path = root / f"{key}.json"
|
||||
@@ -63,22 +86,34 @@ def scan_varga_segments(body: dict[str, Any], *, cache_dir: Path | None = None)
|
||||
pass
|
||||
minutes = []
|
||||
segments = {target: [] for target in targets}
|
||||
boundary_signs: list[dict[str, int]] = []
|
||||
wanted = list(dict.fromkeys([*targets, *boundary_charts]))
|
||||
divisions = [int(chart[1:]) for chart in wanted if chart not in {"D1", "D11"}]
|
||||
d11_chart = None
|
||||
if "D11" in wanted:
|
||||
from scripts.active_rectification_event_engine import _d11_chart as d11_chart
|
||||
for offset in range(width):
|
||||
moment = start + timedelta(minutes=offset)
|
||||
chart = domain_calculation_service.compute_chart({
|
||||
**profile, "year": moment.year, "month": moment.month, "day": moment.day,
|
||||
"lat": profile["lat"], "lon": profile["lon"], "tz": profile["tz"],
|
||||
"ayanamsa": ayanamsa, "node_mode": "mean",
|
||||
"year": moment.year, "month": moment.month, "day": moment.day,
|
||||
"hour": moment.hour, "minute": moment.minute,
|
||||
})
|
||||
ascendant = float(chart["ascendant"]["lon"])
|
||||
signs = {"D1": int(ascendant // 30) % 12} if "D1" in targets else {}
|
||||
divisions = [int(target[1:]) for target in targets if target != "D1"]
|
||||
planets = {name: float(item["lon"]) for name, item in chart.get("planets", {}).items() if isinstance(item, dict) and isinstance(item.get("lon"), (int, float))}
|
||||
all_signs = {"D1": int(ascendant // 30) % 12} if "D1" in wanted else {}
|
||||
if divisions:
|
||||
planets = {name: float(item["lon"]) for name, item in chart.get("planets", {}).items() if isinstance(item, dict) and isinstance(item.get("lon"), (int, float))}
|
||||
computed = varga.calc_all_vargas(planets, ascendant, divisions=divisions)
|
||||
for value in computed.values():
|
||||
signs[f"D{value['_meta']['div']}"] = int(value["Ascendant"]["sign_idx"])
|
||||
all_signs[f"D{value['_meta']['div']}"] = int(value["Ascendant"]["sign_idx"])
|
||||
if "D11" in wanted:
|
||||
all_signs["D11"] = int(d11_chart(planets, ascendant)["Ascendant"]["sign_idx"])
|
||||
signs = {target: all_signs[target] for target in targets}
|
||||
stamp = moment.strftime("%Y-%m-%dT%H:%M")
|
||||
minutes.append({"offset": offset, "time": stamp[11:], "date": stamp[:10], "signs": signs})
|
||||
if boundary_charts:
|
||||
boundary_signs.append({chart: all_signs[chart] for chart in boundary_charts if chart in all_signs})
|
||||
for target in targets:
|
||||
rows = segments[target]
|
||||
sign = signs[target]
|
||||
@@ -89,7 +124,9 @@ def scan_varga_segments(body: dict[str, Any], *, cache_dir: Path | None = None)
|
||||
payload = {"version": VERSION, "window_key": key, "start_at": body["start_at"], "end_at": body["end_at"],
|
||||
"window_minutes": width, "target_charts": targets, "minutes": minutes,
|
||||
"segments": segments, "full_window_unique": {target: len({row['sign'] for row in rows}) == 1 for target, rows in segments.items()},
|
||||
"ayanamsa": "raman", "node_mode": "mean", "step_minutes": 1}
|
||||
"ayanamsa": ayanamsa, "node_mode": "mean", "step_minutes": 1}
|
||||
if boundary_charts:
|
||||
payload["nearest_boundary"] = _nearest_from_signs(boundary_signs, [row["time"] for row in minutes], recorded_time)
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=root, delete=False) as handle:
|
||||
json.dump(payload, handle, ensure_ascii=False, sort_keys=True)
|
||||
@@ -101,6 +138,47 @@ def scan_varga_segments(body: dict[str, Any], *, cache_dir: Path | None = None)
|
||||
return payload
|
||||
|
||||
|
||||
def _boundary_request(body: dict[str, Any]) -> tuple[tuple[str, ...], str | None]:
|
||||
if "boundary_charts" not in body:
|
||||
return (), None
|
||||
charts = body.get("boundary_charts")
|
||||
if (not isinstance(charts, list) or not charts
|
||||
or any(not isinstance(chart, str) or chart not in BOUNDARY_CHARTS for chart in charts)
|
||||
or len(set(charts)) != len(charts)):
|
||||
raise ValueError("invalid_boundary_charts")
|
||||
recorded = body.get("recorded_time")
|
||||
if not isinstance(recorded, str) or len(recorded) < 5 or recorded[2] != ":":
|
||||
raise ValueError("invalid_recorded_time")
|
||||
hour, minute = recorded[:2], recorded[3:5]
|
||||
if not (hour.isdigit() and minute.isdigit()):
|
||||
raise ValueError("invalid_recorded_time")
|
||||
hour_value, minute_value = int(hour), int(minute)
|
||||
if hour_value > 23 or minute_value > 59:
|
||||
raise ValueError("invalid_recorded_time")
|
||||
return tuple(charts), f"{hour_value:02d}:{minute_value:02d}"
|
||||
|
||||
|
||||
def _nearest_from_signs(signs_by_minute: list[dict[str, int]], times: list[str], recorded_time: str | None) -> dict[str, Any]:
|
||||
from scripts.jyotish_engine import SIGNS, SIGNS_CN
|
||||
from scripts.rectification.nearest_boundary import nearest_chart_boundary
|
||||
|
||||
transitions: list[dict[str, str]] = []
|
||||
previous: dict[str, int] | None = None
|
||||
for signs, clock in zip(signs_by_minute, times):
|
||||
if previous is not None:
|
||||
for chart, sign in signs.items():
|
||||
before = previous.get(chart)
|
||||
if isinstance(before, int) and isinstance(sign, int) and before != sign and 0 <= before <= 11 and 0 <= sign <= 11:
|
||||
transitions.append({
|
||||
"layer": chart.lower(),
|
||||
"at": clock,
|
||||
"from_sign": SIGNS_CN[SIGNS[before]],
|
||||
"to_sign": SIGNS_CN[SIGNS[sign]],
|
||||
})
|
||||
previous = signs
|
||||
return nearest_chart_boundary({"transitions": transitions}, recorded_time)
|
||||
|
||||
|
||||
def handle_varga_segments(handler, body):
|
||||
from scripts.offline_compute_mixins import BadRequest
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
"""Nearest chart-boundary reads window_scan transitions only.
|
||||
|
||||
Public AA chart: Steve Jobs, 1955-02-24 19:15, San Francisco
|
||||
(references/public_oracle_cases.json id steve_jobs_1955_aa).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from scripts import domain_calculation_service, varga
|
||||
from scripts.active_rectification_event_engine import _d11_chart
|
||||
from scripts.rectification.event_probes import LAYER_DOMAIN as EVENT_LAYER_DOMAIN
|
||||
from scripts.rectification.nearest_boundary import LAYER_DOMAIN, nearest_chart_boundary
|
||||
from scripts.rectification.refinement_packet import window_scan
|
||||
from scripts.rectification_varga_segments_api import BOUNDARY_CHARTS, scan_varga_segments
|
||||
|
||||
JOBS = {
|
||||
"year": 1955, "month": 2, "day": 24,
|
||||
"lat": 37.7749, "lon": -122.4194, "tz": -8.0,
|
||||
}
|
||||
DIVISIONS = [2, 4, 5, 7, 9, 10, 12, 24, 30]
|
||||
VARGA_CHARTS = ["D2", "D4", "D5", "D7", "D9", "D10", "D11", "D12", "D24", "D30"]
|
||||
|
||||
|
||||
def _features(start: datetime, count: int) -> list[dict]:
|
||||
rows = []
|
||||
for offset in range(count):
|
||||
moment = start + timedelta(minutes=offset)
|
||||
chart = domain_calculation_service.compute_chart({
|
||||
**JOBS,
|
||||
"year": moment.year, "month": moment.month, "day": moment.day,
|
||||
"hour": moment.hour, "minute": moment.minute,
|
||||
"ayanamsa": "raman", "node_mode": "mean",
|
||||
})
|
||||
ascendant = float(chart["ascendant"]["lon"])
|
||||
planets = {
|
||||
name: float(item["lon"])
|
||||
for name, item in chart.get("planets", {}).items()
|
||||
if isinstance(item, dict) and isinstance(item.get("lon"), (int, float))
|
||||
}
|
||||
computed = varga.calc_all_vargas(planets, ascendant, divisions=DIVISIONS)
|
||||
vargas = {f"D{value['_meta']['div']}": int(value["Ascendant"]["sign_idx"]) for value in computed.values()}
|
||||
vargas["D11"] = int(_d11_chart(planets, ascendant)["Ascendant"]["sign_idx"])
|
||||
rows.append({"feature": {
|
||||
"time": moment.strftime("%H:%M"),
|
||||
"ascendant_sign_index": int(ascendant // 30) % 12,
|
||||
"varga_ascendants": vargas,
|
||||
}})
|
||||
return rows
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def jobs_scan():
|
||||
built = {"static_contexts": _features(datetime(1955, 2, 24, 19, 0), 41)}
|
||||
scan = window_scan(built)
|
||||
return built, scan
|
||||
|
||||
|
||||
def test_domain_map_matches_event_probes():
|
||||
assert LAYER_DOMAIN == EVENT_LAYER_DOMAIN
|
||||
assert "d1" not in LAYER_DOMAIN
|
||||
|
||||
|
||||
def test_ignores_display_layers_and_keeps_the_earlier_tie():
|
||||
scan = {"transitions": [
|
||||
{"layer": "kp1", "at": "19:16", "from_sign": "白羊座", "to_sign": "金牛座"},
|
||||
{"layer": "hora", "at": "19:10", "from_sign": "白羊座", "to_sign": "金牛座"},
|
||||
{"layer": "d9", "at": "19:10", "from_sign": "狮子座", "to_sign": "狮子座"},
|
||||
{"layer": "d10", "at": "19:10", "from_sign": "白羊座", "to_sign": "金牛座"},
|
||||
{"layer": "d12", "at": "19:20", "from_sign": "双子座", "to_sign": "巨蟹座"},
|
||||
{"layer": "d5", "at": "19:20", "from_sign": "处女座", "to_sign": "天秤座"},
|
||||
{"layer": "d24", "at": "19:20", "from_sign": "天蝎座", "to_sign": "射手座"},
|
||||
], "scanned": True}
|
||||
before = copy.deepcopy(scan)
|
||||
tied = nearest_chart_boundary(scan, "19:15")
|
||||
assert scan == before
|
||||
assert tied["found"] is True
|
||||
assert tied["at"] == "19:10"
|
||||
assert tied["distance_minutes"] == 5
|
||||
assert [row["layer"] for row in tied["layers"]] == ["d10"]
|
||||
assert tied["topics"] == ["工作"]
|
||||
assert tied["domains"] == ["career"]
|
||||
both = nearest_chart_boundary(scan, "19:20")
|
||||
assert [row["layer"] for row in both["layers"]] == ["d5", "d24", "d12"]
|
||||
assert both["topics"] == ["学业", "父母"]
|
||||
assert both["distance_minutes"] == 0
|
||||
wrapped = nearest_chart_boundary(
|
||||
{"transitions": [{"layer": "d9", "at": "23:58", "from_sign": "白羊座", "to_sign": "金牛座"}]},
|
||||
"00:02",
|
||||
)
|
||||
assert wrapped["distance_minutes"] == 4
|
||||
assert nearest_chart_boundary({"transitions": []}, "19:15")["reason"] == "no_transition"
|
||||
assert nearest_chart_boundary(None, "19:15")["reason"] == "unavailable"
|
||||
|
||||
|
||||
def test_jobs_inside_flush_and_stable(jobs_scan):
|
||||
_built, scan = jobs_scan
|
||||
assert "nearest_boundary" not in scan
|
||||
changes = [
|
||||
row for row in scan["transitions"]
|
||||
if row.get("from_sign") and row.get("to_sign") and row["from_sign"] != row["to_sign"]
|
||||
and row["layer"] in {"d1", "d9", "d10", "d4", "d5", "d24", "d7", "d12", "d2", "d11", "d30"}
|
||||
]
|
||||
assert changes, "public AA window produced no chart change"
|
||||
flush_at = changes[0]["at"]
|
||||
flush = nearest_chart_boundary(scan, flush_at)
|
||||
assert flush["found"] is True
|
||||
assert flush["distance_minutes"] == 0
|
||||
assert flush["at"] == flush_at
|
||||
assert flush["layers"]
|
||||
assert all(row["from_sign"] != row["to_sign"] for row in flush["layers"])
|
||||
|
||||
change_clocks = {row["at"] for row in changes}
|
||||
inside_clock = None
|
||||
inside_distance = -1
|
||||
for minute in range(19 * 60, 19 * 60 + 41):
|
||||
clock = f"{minute // 60:02d}:{minute % 60:02d}"
|
||||
if clock in change_clocks:
|
||||
continue
|
||||
found = nearest_chart_boundary(scan, clock)
|
||||
distance = found["distance_minutes"]
|
||||
if found["found"] and distance > inside_distance:
|
||||
inside_clock, inside_distance = clock, distance
|
||||
assert inside_clock is not None
|
||||
assert inside_distance >= 1
|
||||
inside = nearest_chart_boundary(scan, inside_clock)
|
||||
assert inside["at"] != inside_clock
|
||||
|
||||
features = jobs_scan[0]["static_contexts"]
|
||||
stable_start = stable_count = best_start = best_count = 0
|
||||
previous = None
|
||||
for index, row in enumerate(features):
|
||||
signature = (
|
||||
row["feature"]["ascendant_sign_index"],
|
||||
tuple(sorted(row["feature"]["varga_ascendants"].items())),
|
||||
)
|
||||
if signature == previous:
|
||||
stable_count += 1
|
||||
else:
|
||||
stable_start, stable_count, previous = index, 1, signature
|
||||
if stable_count > best_count:
|
||||
best_start, best_count = stable_start, stable_count
|
||||
assert best_count >= 2
|
||||
stable_rows = features[best_start:best_start + best_count]
|
||||
stable_scan = window_scan({"static_contexts": stable_rows})
|
||||
recorded = stable_rows[best_count // 2]["feature"]["time"]
|
||||
stable = nearest_chart_boundary(stable_scan, recorded)
|
||||
assert stable["found"] is False
|
||||
assert stable["reason"] == "no_transition"
|
||||
print(
|
||||
f"JOBS_BOUNDARY flush={flush_at} layers={[row['layer'] for row in flush['layers']]} "
|
||||
f"inside={inside_clock} distance={inside_distance} at={inside['at']} "
|
||||
f"topics={inside['topics']} stable={stable_rows[0]['feature']['time']}-{stable_rows[-1]['feature']['time']} "
|
||||
f"recorded={recorded}"
|
||||
)
|
||||
|
||||
|
||||
def test_api_boundary_is_optional_and_matches_window_scan(tmp_path):
|
||||
start = datetime(1955, 2, 24, 19, 12)
|
||||
body = {
|
||||
"start_at": start.strftime("%Y-%m-%dT%H:%M"),
|
||||
"end_at": (start + timedelta(minutes=6)).strftime("%Y-%m-%dT%H:%M"),
|
||||
"latitude": JOBS["lat"], "longitude": JOBS["lon"], "timezone_offset": JOBS["tz"],
|
||||
"target_charts": ["D1", "D9", "D10"],
|
||||
}
|
||||
plain = scan_varga_segments(body, cache_dir=tmp_path)
|
||||
assert "nearest_boundary" not in plain
|
||||
assert set(plain["minutes"][0]["signs"]) == {"D1", "D9", "D10"}
|
||||
edged = scan_varga_segments({
|
||||
**body,
|
||||
"boundary_charts": list(BOUNDARY_CHARTS),
|
||||
"recorded_time": "19:15",
|
||||
}, cache_dir=tmp_path)
|
||||
assert plain["window_key"] != edged["window_key"]
|
||||
assert set(edged["minutes"][0]["signs"]) == {"D1", "D9", "D10"}
|
||||
assert "nearest_boundary" in edged
|
||||
scan = window_scan({"static_contexts": _features(start, 7)})
|
||||
assert nearest_chart_boundary(scan, "19:15") == edged["nearest_boundary"]
|
||||
with pytest.raises(ValueError, match="invalid_target_charts"):
|
||||
scan_varga_segments({**body, "target_charts": ["D12"]}, cache_dir=tmp_path)
|
||||
with pytest.raises(ValueError, match="invalid_boundary_charts"):
|
||||
scan_varga_segments({**body, "boundary_charts": ["D99"], "recorded_time": "19:15"}, cache_dir=tmp_path)
|
||||
|
||||
|
||||
# sha256 of the omitted-ayanamsa public-chart body, captured before this whitelist.
|
||||
OMITTED_AYANAMSA_WINDOW_KEY = "58c936a2ccdbb5e339c79f9c43b990d556b627230cd5d5d200f3fcaaea85fad4"
|
||||
|
||||
|
||||
def _public_segment_body() -> dict:
|
||||
start = datetime(1955, 2, 24, 19, 12)
|
||||
return {
|
||||
"start_at": start.strftime("%Y-%m-%dT%H:%M"),
|
||||
"end_at": (start + timedelta(minutes=6)).strftime("%Y-%m-%dT%H:%M"),
|
||||
"latitude": JOBS["lat"], "longitude": JOBS["lon"], "timezone_offset": JOBS["tz"],
|
||||
"target_charts": ["D1", "D9", "D10"],
|
||||
}
|
||||
|
||||
|
||||
def test_omitted_ayanamsa_matches_raman_and_lahiri_moves(tmp_path):
|
||||
from scripts.rectification_varga_segments_api import VERSION, _segment_ayanamsa
|
||||
|
||||
body = _public_segment_body()
|
||||
omitted = scan_varga_segments(body, cache_dir=tmp_path)
|
||||
assert omitted["version"] == VERSION == "rectification-varga-segments-v1"
|
||||
assert omitted["ayanamsa"] == "raman"
|
||||
assert omitted["node_mode"] == "mean"
|
||||
assert omitted["window_key"] == OMITTED_AYANAMSA_WINDOW_KEY
|
||||
assert "nearest_boundary" not in omitted
|
||||
for same in (
|
||||
{"ayanamsa": "raman"},
|
||||
{"ayanamsa": " Raman "},
|
||||
{"ayanamsa": " "},
|
||||
{"ayanamsa": None},
|
||||
{"node_mode": "true"},
|
||||
):
|
||||
assert scan_varga_segments({**body, **same}, cache_dir=tmp_path) == omitted
|
||||
|
||||
lahiri = scan_varga_segments({**body, "ayanamsa": "Lahiri"}, cache_dir=tmp_path)
|
||||
assert lahiri["ayanamsa"] == "lahiri"
|
||||
assert lahiri["node_mode"] == "mean"
|
||||
assert lahiri["window_key"] != omitted["window_key"]
|
||||
assert [row["signs"] for row in lahiri["minutes"]] != [row["signs"] for row in omitted["minutes"]]
|
||||
assert scan_varga_segments({**body, "ayanamsa": "lahiri"}, cache_dir=tmp_path) == lahiri
|
||||
|
||||
kp = scan_varga_segments({**body, "ayanamsa": "kp"}, cache_dir=tmp_path)
|
||||
assert kp["ayanamsa"] == "kp"
|
||||
assert kp["window_key"] not in {omitted["window_key"], lahiri["window_key"]}
|
||||
for alias in ("krishnamurti", "krishnamurti_paddhati", "krishnamurti/kp", "Krishnamurti-Paddhati"):
|
||||
assert scan_varga_segments({**body, "ayanamsa": alias}, cache_dir=tmp_path) == kp
|
||||
citra = scan_varga_segments({**body, "ayanamsa": "true_chitra"}, cache_dir=tmp_path)
|
||||
assert citra["ayanamsa"] == "true_citra"
|
||||
assert scan_varga_segments({**body, "ayanamsa": "true-citra"}, cache_dir=tmp_path) == citra
|
||||
pushya = scan_varga_segments({**body, "ayanamsa": "true_pushya"}, cache_dir=tmp_path)
|
||||
assert pushya["ayanamsa"] == "true_pushya"
|
||||
fagan = scan_varga_segments({**body, "ayanamsa": "fagan-bradley"}, cache_dir=tmp_path)
|
||||
assert fagan["ayanamsa"] == "fagan_bradley"
|
||||
assert len({omitted["window_key"], lahiri["window_key"], kp["window_key"], citra["window_key"], pushya["window_key"], fagan["window_key"]}) == 6
|
||||
|
||||
cached = {path.name for path in tmp_path.glob("*.json")}
|
||||
for bad in ("djwhal_khul", "sassanian", "nope", 1, True):
|
||||
with pytest.raises(ValueError, match="^invalid_ayanamsa$"):
|
||||
scan_varga_segments({**body, "ayanamsa": bad}, cache_dir=tmp_path)
|
||||
assert {path.name for path in tmp_path.glob("*.json")} == cached
|
||||
assert _segment_ayanamsa("true_chitra") == "true_citra"
|
||||
assert _segment_ayanamsa(None) == "raman"
|
||||
Reference in New Issue
Block a user