diff --git a/CHANGELOG.md b/CHANGELOG.md index db017cd4..45fbeaad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,8 @@ ## 2026-10-12 — 生时校正开场说清填的时间离换段有多近(未推送,未上线) - 开场两句中间多一句:填的时间离最近会换的地方差几分钟,两边不一样的是父母、工作、住处、婚恋、学业、子女、钱或身体里实际不同的那几件。不到 1 分钟就说不到 1 分钟。这段时间里都不换,就说附近这些盘都不换,先从经历开始。只知道大概时段时不加这一句。 -- 不说秒,不说记录时间错了或不准,不说已确认。先问这几件事的开关默认关,所以这句先不承诺接下来先问。打分、闸门和题库都没改。Skill 仍是 10.0.33。 +- 本命上升也换时,这一句先说「这里连本命上升都换」,再写两边不一样的事。没有别的事时整句停在这里。只有本命上升在换,不说接下来先问。仍是一句。 +- 开场扫描和盘型段扫描按资料里的岁差算。没填岁差仍是 Raman。不说秒,不说记录时间错了或不准,不说已确认。先问这几件事的开关默认关,所以这句先不承诺接下来先问。打分、闸门和题库都没改。Skill 仍是 10.0.33。设置里「生时校正目前仍固定按 Raman」那句还没改。 ## 2026-10-09 — 事业判断表旁边注明不按星性写方向(已合入 staging;回测仍有 1 次,记为已知残余) diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index c3174a82..fa6ff232 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -17561,14 +17561,15 @@ ## BUG-1307 | 生时校正开场不说记录时间离最近分界多近,首轮选题面向整个窗口,用户觉得步骤多、目的不明 -- 状态:fixed-pending-verify(只上了开场呈现。选题开关默认关:v5 77 例持久回放没有跑,±10 / ±30 真值段保留和 D9、D10 头段命中都没有新数字,红线未核。部署和真机清单完成前不标 resolved) +- 状态: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 域;对外不说秒级、不说「记录时间是错的」。 - 修复:新模块只读 `window_scan` 的换段行,给出记录分钟前后最近的换段、两侧不同的层和前后星座。开场由服务端把这一句放在原来两句中间。申报时段、扫描失败不加这一句。窗口里没有换段,用固定的一句「附近这些盘都不换」。选题开关 `RECTIFICATION_NEAREST_BOUNDARY_PROBES=on` 才把对应领域的题排到增益序前面;默认关,开场因此不说「接下来先问这几件」。2 / 5 / 10 分钟都没有选定。Skill 仍是 10.0.33,没有改 OpeningPolicy 正文。 -- 验证:公开 AA 盘定向测试覆盖段内、紧贴换段、窗口内无换段;分盘接口默认响应不增加这个字段。前端开场单测覆盖这三句和开关默认关。77 例回放未跑。真机清单 `docs/testing/rectification-nearest-boundary-opening-20261012.md` 未走。 +- 修复(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)。 - 复发自:无。 @@ -17587,13 +17588,14 @@ ## BUG-1309 | 分盘段扫描端点写死 Raman:非 Raman 用户的盘型段与档位按 Raman 算,引擎打分按用户岁差 -- 状态:open +- 状态: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 引入段扫描端点时只按默认岁差实现,任务书未要求透传岁差。 -- 修复:见 `docs/tasks/TASK-rectification-1012-batch-fix-20261012.md` F1 / F1b(端点收岁差并进缓存键,两处调用传用户岁差,缺省 raman,v5 回放逐字节不变)。 +- 修复:端点收可选 `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 一节。 diff --git a/docs/tasks/PROGRESS-rectification-nearest-boundary-opening-20261012.md b/docs/tasks/PROGRESS-rectification-nearest-boundary-opening-20261012.md index 1696c0c9..d2f8ce13 100644 --- a/docs/tasks/PROGRESS-rectification-nearest-boundary-opening-20261012.md +++ b/docs/tasks/PROGRESS-rectification-nearest-boundary-opening-20261012.md @@ -86,3 +86,35 @@ Python 用 Anaconda `C:\Users\74082\anaconda3\python.exe`,`PYTHONHASHSEED=0` ## 真机 `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 例回放。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 6bc8ac7b..e07e0202 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -124,10 +124,10 @@ | `TASK-rectification-mobile-timeline-readout-20260917.md` | `PROGRESS-rectification-mobile-timeline-readout-20260917.md` | 手机截图:时间轴读数第四项被裁成「已…」(nowrap + inset 内边距,BUG-918);「跳到最新」浮层压住选项 C(BUG-919)。灰卡与「再说一件」矛盾归修复单 BUG-915/917 | 已验收 | `e8e98bbd`(合入时重放为本分支提交,BUG-918/919)。时间轴相关 71 条 + 全量同上;手机上隐藏「已对照 N 件」符合 DESIGN §10;桌面是否也移除待产品拍板。真机清单 `docs/testing/rectification-mobile-timeline-readout-20260917.md` | | `TASK-mobile-viewport-scroll-lock-20260917.md` | `PROGRESS-mobile-viewport-scroll-lock-20260917.md` | iPhone 上键盘收起 / 刷新后整页上移、顶栏点不到(BUG-920):iOS 不支持 `interactive-widget`,键盘弹出时 Safari 滚动 window,`html/body overflow: hidden` 让用户拉不回来,reload 又还原 `scrollY`;代码里无任何 window 级复位。补 `ViewportScrollLock`(`scrollRestoration=manual` + `visualViewport` 复位)。与今天两单无关,既有缺陷。**T3 顶栏积分块「有点扁」(BUG-921)**:两枚芯片 44px 塞在 46px 顶栏里、字号/内边距/圆角各一套、积分块强制 64px 最小宽度;统一尺寸并把手机顶栏放到 52px | 已合入(无独立验收记录) | `1b508d5e`(BUG-920/921 resolved;已部署 staging,run 2715);真机待核 | | `TASK-rectification-session-composer-guard-20260917.md` | `PROGRESS-rectification-session-composer-guard-20260917.md` | 校正会话激活但校正面未开(刷新揭幕超时 / 打开失败不重试 / 删除与 popstate 回退落到列表第一条)时露出可用的普通输入框,问题发到 `/api/consult` 回 404「咨询会话不存在」;`send()` 无会话类型守卫;服务端无独立错误码。关联 BUG-505。BUG 段 924 起 | 已验收 | `6d81062b`(Claude 验收:T1–T5 全通过;409 `session_not_consultation` 在扣点前、不扣点;回退路径多修了归档一条;page.tsx 1830→1830。残留 P3:列表只剩校正会话时 `activeSession` 为空,输入框可用但发送静默无效 → BUG-935 在 fix 单);真机三条欠 | -| `TASK-rectification-explicit-entry-20261012.md` | `PROGRESS-rectification-explicit-entry-20261012.md` | **明说要校正就直接进入(BUG-1306)**:模型判断意图(禁正则),服务端判定后直接开案并绑定来源对话;首轮排盘合同对此轮豁免;拒绝必须一句话说明(推翻 BUG-1248 静默两条);普通对话不得假装校正、申报时段不写子运起止。**先做** | 已部署 staging `b8bdc003`,真机欠 | — | -| `TASK-rectification-nearest-boundary-opening-20261012.md` | `PROGRESS-rectification-nearest-boundary-opening-20261012.md` | **最近分界开场(BUG-1307)**:开场说记录时间离最近换段多近、两侧哪些事不同。选题开关默认关(77 例未跑)。不改打分 / 闸门 / 4 件 3 域。未改 page.tsx | 待验收 | 分支 `codex/rectification-nearest-boundary-opening-20261012`,未推送 | +| `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-offer-opened-state-20261012.md` | **10-12 三单验收修复**:entry `6a799582` 通过(合入待产品);boundary `b285011a` 未通过——F1 开场与段汇总岁差写死 Raman(BUG-1307 / 新 BUG-1309)、F2 本命上升换段不说;F3 提议在开案成功后才标 opened(BUG-1306 跟进,本分支待验收);F4 研究报告补编码问题与暂停结论(BUG-1308) | F3 待验收(`codex/rectification-offer-opened-state-20261012`);F1/F2/F4 仍待领取 | — | +| `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 待验收 | 本分支,未推送 | ### 聊天主链路与首页 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 8c755c07..e3dbdbc5 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -66,7 +66,7 @@ This file adapts the full visual analysis in `CLAUDE_DESIGN.md` to the shipped J 开场一轮只有两段字:正文讲做法(要把出生时间缩小到更准的范围、现在先在哪段时间里找;你说几件大事和大概年月,我拿去和星盘对照),下面是问题块里的题干(六个例子 + 一个带年月的回答示例)。题干只出现一次:正文里和题干逐句相同或几乎相同的句子才会被去掉;只是开头几个字一样、内容不同的句子保留(以前按前 12 个字判定,把正文里的例子句删掉了)。 -2026-10-12(BUG-1307):服务端已经量过换段时,正文在原来两句中间多一句,说明填的时间离最近会换的地方差几分钟、两边哪几件事不同。没有新组件、没有新动效。只知道大概时段、这段时间里不换、或扫描没做成时,口径见 VOICE 同一节。选题顺序的开关默认关,开场不承诺「接下来先问这几件」。 +2026-10-12(BUG-1307):服务端已经量过换段时,正文在原来两句中间多一句,说明填的时间离最近会换的地方差几分钟、两边哪几件事不同。本命上升也换时,同一句先说「这里连本命上升都换」,再写其余会变的事;没有其余的事时,这一句就停在「这里连本命上升都换。」,不另起一句。没有新组件、没有新动效。句数上限仍是 3。只知道大概时段、这段时间里不换、或扫描没做成时,口径见 VOICE 同一节。选题顺序的开关默认关,开场不承诺「接下来先问这几件」。只有本命上升在换时也不说这句。 > 2026-10-01 修订:校正回复结算后不再显示步骤回执(BUG-1137),本段只适用于写作中的步骤行。 diff --git a/frontend/docs/VOICE.md b/frontend/docs/VOICE.md index cd2021bf..b57d92f2 100644 --- a/frontend/docs/VOICE.md +++ b/frontend/docs/VOICE.md @@ -138,11 +138,12 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 开场可以在这两句中间再加一句(2026-10-12,BUG-1307)。句数上限仍是 3,没有放宽。这一句由服务端按扫描结果原样放进去,模型不现编: - 记录了钟点,而且这段时间里有换段:`你填的时间离最近会换的地方不到 1 分钟,两边不一样的是父母、工作。` 不足 1 分钟写「不到 1 分钟」,满 1 分钟写「差 N 分钟」。两边只用这些词:婚恋、工作、住处、学业、子女、父母、钱、身体。没有对应的事,就不写「两边不一样」。 +- 本命上升也在这次换段里:同一句先写「这里连本命上升都换」,再写两边不一样的事。例如「你填的时间离最近会换的地方差 4 分钟,这里连本命上升都换,两边不一样的是工作、父母。」没有别的事时是「你填的时间离最近会换的地方不到 1 分钟,这里连本命上升都换。」仍是一句,不另起一句。只有本命上升在换时,不说「接下来先问这几件」。 - 选题开关默认关。关着的时候这一句到句号为止,不说「接下来先问这几件」。开关打开、离得近、又有这几件事时,句号才换成「,接下来先问这几件。」 - 这段时间里都不换:`你填的时间附近这些盘都不换,先从经历开始。` - 只知道大概时段,或扫描没做成:不加这一句,仍是原来两句。 -这一句不用:秒、分盘、盘面、大运、区间、精确到秒、记录时间错了、记录时间不准、已确认。 +这一句不用:秒、分盘、盘面、大运、区间、精确到秒、记录时间错了、记录时间不准、已确认、D1。 回答上方的步骤名(完成 / 进行中)一律大白话,同一个名字只出现一次: @@ -309,7 +310,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 | 坏 | 好 | 为什么 | |---|---|---| | Rahu 大运为 [具体时间已省略] 至 [具体时间已省略]。 | Rahu 大运为 2013 年 11 月中下旬至 2031 年 11 月中下旬。按范围看,不写成单一分钟。 | 出生范围用户要拿到区间说法;算出来的边界不是要删的字。 | -| 眼下按 04:45–05:15 来核对,用你记得的经历对照大运和盘面变化。最后给区间和代表分钟,不给精确到秒。(题干:先说你最容易想起的一两件,年月大概就行。) | 我们来把你的出生时间缩小到更准的范围,现在先在 04:45–05:15 之间找。做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照。(题干:先说一两件你记得的大事,比如上大学、第一份工作、搬到别的城市、谈恋爱或结婚、家里添丁、生病住院。说个大概年月就行,例如「2015 年夏天换了工作」。)服务端量到换段时,可以在这两句中间加一句,例如「你填的时间离最近会换的地方差 5 分钟,两边不一样的是父母、工作。」 | 开场正文用大白话讲做法,不用大运、盘面、代表分钟、精确到秒这类还没解释过的词,不写年份、不提问;例子和回答示例放在题干里,只出现一次(BUG-1049)。2026-10-12 起中间那句仍算在三句里面,不说秒,开关关着时不说接下来先问(BUG-1307)。 | +| 眼下按 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 年前后,你有没有换过工作? | 题干不写选项字面;选项由服务端画在同一条消息里。 | diff --git a/frontend/src/lib/rectification-agentic/opening-boundary.ts b/frontend/src/lib/rectification-agentic/opening-boundary.ts index b1819668..58546e73 100644 --- a/frontend/src/lib/rectification-agentic/opening-boundary.ts +++ b/frontend/src/lib/rectification-agentic/opening-boundary.ts @@ -2,6 +2,7 @@ * 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, @@ -25,18 +26,22 @@ export function openingFactsFromBoundaryPayload( if (typeof distance !== "number" || !Number.isInteger(distance) || distance < 0 || distance > 720) { return { kind: "unavailable" }; } - const layers = Array.isArray(row.layers) - ? row.layers.flatMap((item) => { - const layer = item && typeof item === "object" ? (item as { layer?: unknown }).layer : null; - return typeof layer === "string" && LAYERS.has(layer) ? [layer] : []; - }) - : []; + 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, }; @@ -83,6 +88,7 @@ export async function fetchOpeningBoundary(input: { target_charts: ["D1"], boundary_charts: BOUNDARY_CHARTS, recorded_time: recorded, + ayanamsa: resolveAyanamsa(input.snapshot), }), }); if (!response.ok) return { kind: "unavailable" }; diff --git a/frontend/src/lib/rectification-agentic/user-copy.ts b/frontend/src/lib/rectification-agentic/user-copy.ts index 09ab9770..3a4ee115 100644 --- a/frontend/src/lib/rectification-agentic/user-copy.ts +++ b/frontend/src/lib/rectification-agentic/user-copy.ts @@ -78,12 +78,15 @@ export function withFirstDatedCollectInvite(stem: string): string { * 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. + * 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 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; }>; @@ -100,9 +103,10 @@ export function openingBoundarySentence(facts: OpeningBoundaryFacts | null | und 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}${sides}${tail}`; + return `你填的时间离最近会换的地方${place}${natal}${sides}${tail}`; } export function openingSpokenBody( diff --git a/frontend/src/lib/rectification-agentic/v9/segment-scan.ts b/frontend/src/lib/rectification-agentic/v9/segment-scan.ts index 6806351e..3924d15b 100644 --- a/frontend/src/lib/rectification-agentic/v9/segment-scan.ts +++ b/frontend/src/lib/rectification-agentic/v9/segment-scan.ts @@ -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) diff --git a/frontend/tests/rectification-nearest-boundary-opening-20261012.test.ts b/frontend/tests/rectification-nearest-boundary-opening-20261012.test.ts index 50de66af..7bdfeedc 100644 --- a/frontend/tests/rectification-nearest-boundary-opening-20261012.test.ts +++ b/frontend/tests/rectification-nearest-boundary-opening-20261012.test.ts @@ -8,7 +8,8 @@ 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 { openingFactsFromBoundaryPayload, resolveOpeningBoundaryFacts } from "../src/lib/rectification-agentic/opening-boundary.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, @@ -61,6 +62,55 @@ test("near, under a minute, stable, and only the birth chart", () => { 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({ @@ -136,3 +186,51 @@ test("period_only and a missing clock do not scan", async () => { assert.match(withSentence, /原样放在这两句之间/); assert.match(brief, /正文不用大运/); }); + +test("opening and segment scans send the snapshot ayanamsa", async (t) => { + const bodies: Array> = []; + 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); +}); diff --git a/scripts/rectification_varga_segments_api.py b/scripts/rectification_varga_segments_api.py index 3331dac9..fddb2e70 100644 --- a/scripts/rectification_varga_segments_api.py +++ b/scripts/rectification_varga_segments_api.py @@ -15,6 +15,23 @@ 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): @@ -47,11 +64,12 @@ def scan_varga_segments(body: dict[str, Any], *, cache_dir: Path | None = None) 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) @@ -78,7 +96,7 @@ def scan_varga_segments(body: dict[str, Any], *, cache_dir: Path | None = None) moment = start + timedelta(minutes=offset) chart = domain_calculation_service.compute_chart({ "lat": profile["lat"], "lon": profile["lon"], "tz": profile["tz"], - "ayanamsa": "raman", "node_mode": "mean", + "ayanamsa": ayanamsa, "node_mode": "mean", "year": moment.year, "month": moment.month, "day": moment.day, "hour": moment.hour, "minute": moment.minute, }) @@ -106,7 +124,7 @@ 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) diff --git a/tests/test_rectification_nearest_boundary.py b/tests/test_rectification_nearest_boundary.py index 8d6ef019..5c994655 100644 --- a/tests/test_rectification_nearest_boundary.py +++ b/tests/test_rectification_nearest_boundary.py @@ -183,3 +183,66 @@ def test_api_boundary_is_optional_and_matches_window_scan(tmp_path): 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"