Compare commits

...
Author SHA1 Message Date
ClaudeandClaude Opus 5.5 004cf9d88f docs(accept): BUG-1817 acceptance-fix note
Independent Staging Quality Gate / validate-frontend (push) Successful in 5m39s
Independent Staging Quality Gate / validate-python (push) Successful in 7m6s
Independent Staging Quality Gate / publish (push) In progress
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyn7Hj3LCW3BUs5bZ8
2026-11-09 14:45:07 +08:00
ClaudeandClaude Opus 5.5 6c774a4f28 fix(accept): #181 uses the astrologer's round-7 §5 P0.2 yoga status mapping
One shared table in yoga-status-words.ts (YOGA_STATUS_MAPPING / yogaStatus)
that the 解读依据 line uses now and #182's model wording must import:
candidate / support_only_candidate, computed, formed_under_source,
conditions_not_met, blocked / not_available_yet. Only formation.status can
say 成立 or 必要条件未满足; a hits-only name is 当前无法确认 with the gap named.
The grey line never clips a status text. Tests: one per table row plus the
hits-only case (13 in the file).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyn7Hj3LCW3BUs5bZ8
2026-11-09 14:44:57 +08:00
ClaudeandClaude Opus 5.5 35c87349e3 fix(accept): #174 ingress to_sign/direction and per-time outside-window flags
- to_sign was taken from the JD instead of a longitude, and direction read a
  direct 0° Aries ingress as retrograde; both now come from the step's
  longitudes.
- shadow_entry / shadow_exit / both stations carry outside_requested_window
  as the brief requires; the window end uses the end day's UTC offset.
- 3 regression tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyn7Hj3LCW3BUs5bZ8
2026-11-09 14:42:27 +08:00
Claude 7505c841b2 Merge remote-tracking branch 'origin/codex/basis-candidate-status-20261109' into codex/accept-grok-batch2-1109
# Conflicts:
#	docs/BUG_HISTORY.md
2026-11-09 14:41:30 +08:00
Claude c19baf033b Merge remote-tracking branch 'origin/codex/ondemand-transit-window-20261109' into codex/accept-grok-batch2-1109 2026-11-09 14:41:25 +08:00
jesse-uxandGrok (agent loop) 09a67aad5a fix(consult): 解读依据的格局行按卡上状态说话,不再写成成立 (#181, BUG-1824)
- 新增 yoga-status-words.ts:conditions_not_met → 不成立;candidate → 候选;
  computed → 未确认;其他 → 未标状态;结构观察不进这一行
- yogaLine 逐个名字到 card.base.natal.yogas_detail.rows 取状态词并按状态分组
  (候选:A、B;未确认:C),没有可列的写「本题没有检测到相关格局」
- 「格局」兜底文案改为「盘上检测到的格局已列出各自状态」(三栏注释)
- 数据卡、回执 schema、行状态判定、页面组件都不动

Tests: 新增 consult-basis-candidate-status-1824.test.ts(8 条);
consult-technique-evidence-1793 / consult-answer-basis-receipt-20261108 /
consult-answer-mode-ui-20261108 继续通过;tsc 0 错,lint 0 error。

Co-Authored-By: Grok (agent loop)
2026-11-09 14:33:47 +08:00
jesse-uxandGrok (agent loop) 78c34c09ab feat(ondemand): transit window — ingress, stations, retrograde loops, target longitude (#174, BUG-1817)
按 #173 的合同加第二个按需工具,只做后端:

- scripts/ondemand_calc/transit_window.py:四类事件(ingress / station /
  retrograde_loop / target_longitude),黄经与日速度来自
  _calc_sidereal_planets_for_jd(先配星历路径),口径由调用方显式传
- 事件二分到 ≤1 分钟;逆行往返给两个停滞、retrograde_span、影子期进出,
  区间外每侧最多 400 天并标 outside_requested_window
- 节点不报停滞:partial + node_station_not_supported 的 gap
- 位置口径 mean 阻塞;缺项返回 blocked 信封并点名,不编任何日期
- registry 加两行注册

Tests: tests/test_ondemand_transit_window_20261109.py 17 条;连同 transit_date、
contract、api growth 共 47 条全过。2026 水星逆行停滞与直接 SwissEph 速度二分
相差 1.41 分钟。

Co-Authored-By: Grok (agent loop)
2026-11-09 14:18:39 +08:00
ClaudeandClaude Opus 5.5 21b1fe1aae docs(task): brief for basis-candidate-status (2026-11-09)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyn7Hj3LCW3BUs5bZ8
2026-11-09 14:03:27 +08:00
ClaudeandClaude Opus 5.5 636b24f081 docs(task): brief for ondemand-transit-window (2026-11-09)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyn7Hj3LCW3BUs5bZ8
2026-11-09 14:03:27 +08:00
12 changed files with 1463 additions and 4 deletions
+29
View File
@@ -24518,3 +24518,32 @@
- 防复发:上述测试;合同 docstring 写明后续 #174–#178 的注册步骤。
- 关联记录:占星师 2026-11-07 Q9、2026-11-08 第五轮第 9 条;FIX-ondemand-transit-date-1(产品拍板 C 方案)
- 修复版本:分支 codex/ondemand-transit-date-20261109
## BUG-1817 | 没有「一段时间内的行运变化」:换座、停滞、逆行往返、目标经度都算不出来
- 状态:fixed-pending-verify(分支 codex/ondemand-transit-window-20261109)
- 首次发现 / 最近更新:2026-11-07(占星师 Q9)/ 2026-11-09(后端修复)
- 影响面:新增 scripts/ondemand_calc/transit_window.py;scripts/ondemand_calc/registry.py 加两行注册。
- 现象:只能问「某一天行运如何」,问「接下来多久换座 / 什么时候停滞 / 这次逆行什么时候往返 / 什么时候到某个度数」都得自己猜日期。
- 根因:按需计算骨架里只有单日期工具;现成的 ephemeris_events 按 UT 正午逐日采样且不认 node_mode、没有罗睺计都;transit_trigger.find_exact_transit_date 只返回第一次到达、岁差写死;cmd_transit 的 --retrograde-calendar / --find-conjunction 依赖不存在的 transit_search.py。
- 修复:新增 transit_window 工具(ingress / station / retrograde_loop / target_longitude),黄经与日速度来自 jyotish_engine._calc_sidereal_planets_for_jd,口径由调用方显式传;事件二分到 ≤1 分钟;逆行往返给两个停滞、retrograde_span、影子期进出,允许区间外 400 天搜索并标 outside_requested_window;目标经度按方向列出每一次到达;节点不报停滞(partial + gap);失败只留缺口不编日期。
- 验证:tests/test_ondemand_transit_window_20261109.py(17 条)与 transit_date / contract / api growth 合计 47 条全过;2026 水星逆行两个停滞与直接 SwissEph 速度二分的差为 1.41 分钟。
- 防复发:上述测试;合同 docstring 的注册步骤已含本工具。
- 验收修正(Claude 2026-11-09,codex/accept-grok-batch2-1109):换座的 to_sign 原来把 JD 当黄经取星座(每次换座的新星座都是错的),direction 把 0° 白羊处的顺行换座判成 retrograde,两处都改为按粗扫步的黄经计算;逆行往返里两个停滞与影子期进出各自标 outside_requested_window;区间终点按终点那天的 UTC 偏移换算。补 3 条回归测试(共 20 条)。
- 关联记录:占星师 2026-11-07 Q9、2026-11-08 第五轮第 9 条;#173(共用骨架)
- 修复版本:分支 codex/ondemand-transit-window-20261109
## BUG-1824 | 「解读依据」把候选格局显示成「成立」
- 状态:fixed-pending-verify(分支 codex/basis-candidate-status-20261109)
- 首次发现 / 最近更新:2026-11-09(第七轮验收包,页面阶段)/ 2026-11-09(修复)
- 影响面:frontend/src/lib/consultation-technique-evidence.ts 的 yogaLine;frontend/src/lib/consult-answer-basis.ts 的「格局」兜底文案;新增 frontend/src/lib/yoga-status-words.ts。
- 现象:简明解读的「解读依据」里,「格局」一行写「成立:Sunaphaa Yoga、Vosi Yoga」,而卡上这些格局的状态是 candidate / computed,卡上的规则写明 none is confirmed。
- 触发条件:任何带数据卡的简明解读回答。
- 根因:yogaLine 只读领域段的 yogas.hits(检测器匹配到的规则名,BUG-1157),一律写成「成立:」;真正的状态在 card.base.natal.yogas_detail.rows[] 的 status / formation.status 里,从来没被这一行读过。
- 修复:新增 yoga-status-words.ts(conditions_not_met → 不成立;candidate → 候选;computed → 未确认;其他 → 未标状态;结构观察不进这一行);yogaLine 改为按名字到 yogas_detail.rows 取状态词并按状态分组(候选:A、B;未确认:C),没有可列的写「本题没有检测到相关格局」;兜底文案由「盘上成立和落空的格局已核对」改为「盘上检测到的格局已列出各自状态」。行状态(已算 / 有条件)与数据卡事实记录不动。
- 验证:frontend/tests/consult-basis-candidate-status-1824.test.ts(8 条:三种状态词、formed_under_source 不写成成立、结构观察不出现、未标状态、灰字与兜底都不含「成立:」和「已确认」、计算状态与解读状态分开、空 hits 文案、词表本身不提供「成立」);consult-technique-evidence-1793(2)、consult-answer-basis-receipt-20261108(4)、consult-answer-mode-ui-20261108(14)继续通过。
- 防复发:上述测试;词表单独成文件,后续 #182 复用同一套词。
- 验收修正(Claude 2026-11-09,codex/accept-grok-batch2-1109):占星师第七轮回复 §5 P0.2 给出了确切映射,初版词表(候选 / 未确认 / 不成立 / 未标状态,formed_under_source 显示为候选)与之不同,验收时替换为原表:candidate / support_only_candidate → 命中候选规则,尚未完整确认;computed(未完成来源条件治理)→ 检测器检出,待核实;formed_under_source → 按所选来源的形成条件成立;强度和结果另评;conditions_not_met → 所选来源的必要条件未满足;blocked / not_available_yet / 卡上无状态行 / 状态值不在表内 → 当前无法确认,缺:<缺项>。只有 formation.status(完整条件检查)能给出「成立」或「必要条件未满足」;只在 hits 里出现的名字一律落到「当前无法确认」。灰字按「名字:状态文字」分组、组间用「。」,放不下的整项写「另 N 项未列」,状态文字不被 80 字截断。新增 5 行映射各一条与「只在 hits 里的格局不显示成立」一条回归测试(共 13 条)。#182(模型侧措辞)必须 import 同一个 yoga-status-words.ts 的 YOGA_STATUS_MAPPING / yogaStatus。
- 关联记录:BUG-1793、BUG-1157、#182
- 修复版本:分支 codex/basis-candidate-status-20261109
@@ -0,0 +1,45 @@
# PROGRESS · 「解读依据」候选格局不再写成「成立」(#181,BUG-1824,2026-11-09)
- 分支:`codex/basis-candidate-status-20261109`(基线 `afac4ea9`)
- 任务书:`docs/tasks/TASK-basis-candidate-status-20261109.md`
- Bug:`docs/BUG_HISTORY.md` 末尾追加 BUG-1824
## 改了什么
| 文件 | 内容 |
| --- | --- |
| `frontend/src/lib/yoga-status-words.ts`(新) | `YOGA_STATUS_WORDS` 与 `yogaStatusWord(row)`:`formation.status = conditions_not_met` → 不成立;`status = candidate` → 候选;`status = computed` → 未确认;其他 → 未标状态。另导出 `isStructuralObservation(row)` |
| `frontend/src/lib/consultation-technique-evidence.ts` | 只改 `yogaLine` 与一行 import:仍取领域段 `yogas.hits` 的前 3 个名字,逐个到 `card.base.natal.yogas_detail.rows` 按 `name` 找状态词;结构观察跳过;按状态词分组(组按首次出现排、组内保持原顺序);找不到行 → 未标状态;没有可列的 → 「本题没有检测到相关格局」 |
| `frontend/src/lib/consult-answer-basis.ts` | 「格局」的兜底文案改为「盘上检测到的格局已列出各自状态」,改动处写三栏注释(原因:BUG-1824:候选不得显示为成立) |
没动:数据卡(`yogas.hits` / `yogas_detail` 事实记录一字未改)、回执 schema、`display_status` 取值、页面组件、答题提示词、`references/**`、`scripts/**`。
## 状态词表
| 卡上的值 | 显示 |
| --- | --- |
| `formation.status = "conditions_not_met"` | 不成立 |
| `status = "candidate"` | 候选 |
| `status = "computed"` | 未确认 |
| 其他 / 缺失 | 未标状态 |
`formed_under_source` 不单独显示为「成立」——卡上规则是 `none is confirmed`,按 `status` 显示。
词表里没有「成立」「已确认」这两个词(「不成立」是否定式,是任务书要求的)。
## 自测
| 命令 | 结果 |
| --- | --- |
| `node ... --test tests/consult-basis-candidate-status-1824.test.ts` | 8 过 / 0 败 |
| `tests/consult-technique-evidence-1793.test.ts` | 2 过 / 0 败 |
| `tests/consult-answer-basis-receipt-20261108.test.tsx` | 4 过 / 0 败 |
| `tests/consult-answer-mode-ui-20261108.test.tsx` | 14 过 / 0 败 |
| `tsc --noEmit -p frontend` | 0 错 |
| `npm run lint` | 0 error |
新测试覆盖任务书的 7 条:三种状态词各对各;`formed_under_source` 显示候选、不写成立;结构观察不出现;卡上没有对应行的名字 → 未标状态;灰字与兜底都不含「成立:」「已确认」;`consultAnswerBasisRows` 走一遍时「格局」行 `status === "done"` 且 `result` 是分组灰字(计算状态与解读状态分开);空 `hits` → 「本题没有检测到相关格局」。既有断言没有改动,所以没有三栏注释。
## 说明
- 「格局」行要显示成 `已算`,测试里的审计行用 `interpretation_status: "supporting"`(引擎把 yoga 行标为 observation/supporting 时 `display_status` 才是 firm)。这是既有判定,本单没有改。
- 页面组件 `consult-answer-basis.tsx` 不用改:它只渲染 `result` 与状态标签。
@@ -0,0 +1,45 @@
# PROGRESS · 按需计算(二)一段时间内的行运变化(#174,BUG-1817,2026-11-09)
- 分支:`codex/ondemand-transit-window-20261109`(基线 `afac4ea9`)
- 任务书:`docs/tasks/TASK-ondemand-transit-window-20261109.md`
- Bug:`docs/BUG_HISTORY.md` 新增 BUG-1817
## 做了什么
| 文件 | 内容 |
| --- | --- |
| `scripts/ondemand_calc/transit_window.py`(新) | `TOOL = 'transit_window'`,`run(body)`。四类事件:换座 `ingress`、停滞 `station`、逆行往返 `retrograde_loop`、到达目标经度 `target_longitude` |
| `scripts/ondemand_calc/registry.py` | 加了 `from . import transit_window` 与 `TOOLS['transit_window']` 两行(其余未动) |
| `tests/test_ondemand_transit_window_20261109.py`(新) | 17 条:本单专有 5 条 + 通用 12 条 |
| `docs/BUG_HISTORY.md` | 末尾追加 BUG-1817 |
口径与数值都照任务书:黄经与日速度全部来自 `jyotish_engine._calc_sidereal_planets_for_jd`(调用前先 `_configure_ephemeris_path()`),岁差/交点/位置口径由调用方传;`position_mode=mean` 返回 `blocked` + `position_mode_unsupported`;缺参数返回 `blocked` 信封并点名缺项(HTTP 仍是 200,按合同)。没有改合同、没有改 API 服务器、没有建前端目录。
## 实测(验收第 1 条的对照)
2026 年水星逆行(区间 2026-01-01 至 2026-03-31,lahiri / mean):
| 项 | 本工具 | 直接用 SwissEph 速度二分 | 差 |
| --- | --- | --- | ---: |
| 逆行停滞 | 2026-02-26T06:49Z | 2026-02-26T06:49Z | **1.41 分钟** |
| 顺行停滞 | 2026-03-20T19:32Z | 2026-03-20T19:32Z | **1.41 分钟** |
逆行段:`retrograde_span = [314.265716, 328.340901]`,影子期 `shadow_entry 2026-02-11T22:11Z` 早于逆行停滞、`shadow_exit 2026-04-09T11:44Z` 晚于顺行停滞,符合要求。
目标经度 320°(区间放宽到 2026-04-30 才够三次):2026-02-15T16:04Z 直行、2026-03-10T02:26Z 逆行、2026-04-01T14:43Z 直行。
收敛:每个事件二分到 ≤ 1/1440 天(1 分钟);粗扫月亮 6 小时、其余 1 天;逆行往返每侧最多 400 天,写在 `raw.precision`。
## 自测
| 命令 | 结果 |
| --- | --- |
| `pytest tests/test_ondemand_transit_window_20261109.py tests/test_ondemand_transit_date_20261109.py tests/test_ondemand_calc_contract_20261109.py tests/test_api_server_growth_contract.py` | **47 过 / 0 败**(本单 17 条) |
覆盖本单专有 5 条:停滞对照 ≤ 2 分钟;目标经度三次方向 direct/retrograde/direct;影子期先后;lahiri 与 raman 换座时刻不同且回显;罗睺 mean 与 true 换座时刻不同;伦敦跨夏令时那天的偏移为 0/1。通用 12 条:缺项点名(start_date / end_date / 地点 / 时区 / 岁差 / events)、超 366 天与倒序日期、未知事件与未知行星、`position_mode=mean` 阻塞、`profile_source` 标记、指纹稳定与敏感、引擎失败不给日期、节点不报停滞(`partial` + gap)、`natal_point` 缺失阻塞、注册表分派。
## 偏离与取舍
- **目标经度测试的区间**:任务书示例区间 1–3 月只够两次到齐(第二次逆行的第三次要等 4 月顺行恢复),所以那条测试把 `end_date` 放到 2026-04-30,并在测试里注明原因。没有改实现。
- **换座判定的实现**:按「星座编号变化 + 二分找第一次变化」实现,顺行与逆行(退回原星座)都成立;跨过 30° 边界的就是换座,不需要另外判断方向。
- **`ayanamsa_degrees_at_start`**:由引擎同一次调用返回的 ayanamsa 值,6 位小数。
- 遇到 `events` 里同时要 `station` 和 `retrograde_loop` 的节点星:`gaps` 记两条 `node_station_not_supported:<星>`(一次请求一条),状态 `partial`——任务书要求「不计算并写 gap」,按星记足够定位。
@@ -0,0 +1,106 @@
# TASK:「解读依据」把候选格局显示成“成立”(#181,BUG-1824,2026-11-09)
- Gitea:#181
- 分支:`codex/basis-candidate-status-20261109`(基于 staging `afac4ea92f293a4d2bc81f62563487d26f92e4d2`)
- Bug 编号:BUG-1824(开工时核对 `docs/BUG_HISTORY.md` 的最大编号;1809–1830 已为这一批预留)
- 负责验收:Claude(jyotisha-2a)
- 范围:只改「解读依据」里「格局」这一行的灰字和兜底文案。
不得自行写“产品决定/裁定”;任务书没写的取舍一律选需拍板。
## 现象(第七轮验收包,页面阶段)
简明解读下的「解读依据」里,「格局」一行显示「成立:Sunaphaa Yoga、Vosi Yoga」「成立:Dharidhra Yoga…」。数据卡上这些格局的状态都是 `candidate`(或 `computed`),卡上的规则写明 `none is confirmed`。列表把“计算已执行”说成了“解读成立”。
## 根因(afac4ea9 实测,行号以符号为准)
- `frontend/src/lib/consultation-technique-evidence.ts` 的 `yogaLine(card)` 只读领域段的 `yogas.hits`(名字列表),一律写成 `成立:<名字>`;没有名字时写 `本题没有成立的格局`。
- `hits` 是“检测器在本盘匹配到的规则名”(`consultation-evidence-card.ts` 约 :2401,BUG-1157),不是“成立”。
- 每个格局真正的状态在同一张卡的 `card.base.natal.yogas_detail.rows[]` 里(`consultation-evidence-card.ts` 约 :2007–2028 从引擎层 `yoga_conditions` 原样拷贝):
- `status`:`candidate` | `computed` | `structural_observation`(引擎 `scripts/consultation_native_layers.py` 的 `status_rule`);
- `formation.status`:`formed_under_source` | `candidate` | `conditions_not_met`(`formation_rule`);
- `claim_kind`:`structural_observation` 时不是命名格局。
- `frontend/src/lib/consult-answer-basis.ts` 的 `BASIS_ENTRIES` 里「格局」的兜底文案是 `盘上成立和落空的格局已核对`,同样说过头。
- 行状态(`已算` / `有条件`)来自 `display_status` / 两轴状态,是**计算**状态;这部分是对的,不改。
## 要做的
### 1. 新建状态词表 `frontend/src/lib/yoga-status-words.ts`
一个导出的常量和一个函数,供本单使用(#182 以后也会复用,所以单独成文件):
| 卡上的值 | 列表显示 |
| --- | --- |
| `formation.status = "conditions_not_met"` | 不成立 |
| `status = "candidate"` | 候选 |
| `status = "computed"` | 未确认 |
| 其他 / 缺失 | 未标状态 |
- 判定顺序:先看 `formation.status === "conditions_not_met"`,否则看 `status`。
- `formed_under_source` **不**单独显示为“成立”:卡上规则是 `none is confirmed`,照 `status` 显示(候选 / 未确认)。
- 函数签名建议:`yogaStatusWord(row: Record<string, unknown>): "不成立" | "候选" | "未确认" | "未标状态"`。
- 词表里**不得**出现“成立”“已确认”这类词。
### 2. 改 `yogaLine(card)`(`consultation-technique-evidence.ts`)
- 仍取领域段 `yogas.hits` 的名字(顺序不变,最多 3 个)。
- 每个名字到 `card.base.natal.yogas_detail.rows` 里按 `name` 找对应行,用 `yogaStatusWord` 得到状态词。
- `claim_kind === "structural_observation"` 或 `status === "structural_observation"` 的名字不进这一行(与 `annual-report-sections.ts` 把结构观察单列、不进「格局」的做法一致)。
- 找不到对应行的名字显示“未标状态”。
- 按状态词分组,组内保持原顺序,组按第一次出现的顺序排:`候选:A、B;未确认:C`。
- 没有可列的名字时写 `本题没有检测到相关格局`。
- `LINES` 表、其他技法的灰字、`techniqueDisplayStatus`、`withTechniqueEvidence` 的结构都不改。
### 3. 改兜底文案(`consult-answer-basis.ts`)
`BASIS_ENTRIES` 里「格局」的 `result` 由 `盘上成立和落空的格局已核对` 改为 `盘上检测到的格局已列出各自状态`。改动处写“原值 / 新值 / 原因”三栏注释,原因写 `BUG-1824:候选不得显示为成立`。文件头注释里对应描述同步改一句。其余条目不改。
### 4. 测试 `frontend/tests/consult-basis-candidate-status-1824.test.ts`
用合成卡(不写真人资料),至少覆盖:
1. 卡上状态分别为 `candidate`、`computed`、`formation.status = conditions_not_met` 的三个格局,`yogaLine` 产出 `候选:…;未确认:…;不成立:…`,三个词各对各;
2. `formed_under_source` + `status = candidate` 显示“候选”;
3. 结构观察不出现在这一行;
4. 卡上没有对应行的名字显示“未标状态”;
5. 生成的灰字和兜底文案里都不含“成立:”这个子串,也不含“已确认”;
6. 经 `consultAnswerBasisRows` 走一遍:「格局」行的 `status` 仍是 `done`(已算),`result` 是第 1 条的灰字——计算状态和解读状态分开;
7. 空 `hits` 时灰字为 `本题没有检测到相关格局`。
既有测试 `frontend/tests/consult-technique-evidence-1793.test.ts`、`consult-answer-basis-receipt-20261108.test.tsx`、`consult-answer-mode-ui-20261108.test.tsx` 必须照样通过;若其中有断言写了旧文案,改时写三栏注释,不得删断言。
### 5. 记录
- `docs/BUG_HISTORY.md` 末尾追加 BUG-1824(状态、现象、触发条件、根因、修复、验证、防复发、关联 BUG-1793 / BUG-1157 / #182)。只在末尾追加,不用 sed 改整个文件。
- `docs/tasks/PROGRESS-basis-candidate-status-20261109.md`。
## 允许改的文件(只有这些)
- 新建:`frontend/src/lib/yoga-status-words.ts`、`frontend/tests/consult-basis-candidate-status-1824.test.ts`、`docs/tasks/PROGRESS-basis-candidate-status-20261109.md`
- 修改:`frontend/src/lib/consultation-technique-evidence.ts`(只改 `yogaLine` 和必要的 import)、`frontend/src/lib/consult-answer-basis.ts`(只改「格局」兜底文案与头注释一句)、`docs/BUG_HISTORY.md`(末尾追加)
- 页面组件 `frontend/src/components/consult-answer-basis.tsx` 预计不用改;若确实要改,停下选“需拍板”。
## 不要碰
- `references/**`(占星师方法文件),一字不改;包括 `references/product_runtime_contract.md`。
- `frontend/src/lib/consult-preset-prompts.ts`、`frontend/src/lib/consultation-methodology.ts`、`frontend/src/mastra/product-voice.ts`:不改答题规则。
- `frontend/src/lib/consultation-evidence-card.ts`:数据卡的事实记录一字不改(`yogas.hits`、`yogas_detail` 的字段和内容都不动;“把状态词写进卡和产品规则”归 #182)。该文件也是 #170 / #172 的范围。
- `frontend/src/lib/consultation-answer-fact-guard.ts`:拦截检查归 #182 / #183。
- `frontend/src/lib/consultation-agent-events.ts`(回执 schema,`display_status` 的取值不改)、`frontend/src/app/api/consult/route.ts`。
- `frontend/src/mastra/consultation-tools.ts`(#171)、`frontend/src/mastra/consultation-workflow.ts` 与 `frontend/src/lib/consultation-route-service.ts`(#185)。
- `scripts/**` 全部,特别是冻结文件:`scripts/jyotish_api_server.py`、`scripts/jyotish_engine.py`、`scripts/domain_calculation_service.py`、`scripts/dasha_analyzer.py`、`scripts/narayana_dasha.py`、`scripts/shadbala.py`、`scripts/ashtakavarga.py`、`scripts/jaimini.py`、`scripts/varga.py`、`scripts/functional_benefics.py`、`scripts/divisional_charts_extended.py`;以及 `scripts/consultation_native_layers.py`(引擎的状态值不改)。
- 既有断言不得弱化。
## 自测命令(Windows / PowerShell)
- `frontend\node_modules\.bin\tsc --noEmit -p frontend`
- 在 `frontend` 下运行 `npm run lint`
- 在 `frontend` 下运行 `node node_modules/tsx/dist/cli.mjs --test tests/consult-basis-candidate-status-1824.test.ts tests/consult-technique-evidence-1793.test.ts tests/consult-answer-basis-receipt-20261108.test.tsx tests/consult-answer-mode-ui-20261108.test.tsx`
前端全量测试、Python 快速门和 eslint 由 Claude 在验收时跑。
## 何时停下来选“需拍板”
- 卡上出现上表以外的状态值,且不能归入“未标状态”时。
- 要改数据卡、回执 schema、答题规则或页面组件才能做到时。
- 任务书没写到的任何文案或取舍。
@@ -0,0 +1,157 @@
# TASK:按需计算——一段时间内的行运变化:换座、停滞、逆行往返、到达目标经度(#174,BUG-1817,2026-11-09)
- Gitea:#174
- 分支:`codex/ondemand-transit-window-20261109`(基于 staging `afac4ea92f293a4d2bc81f62563487d26f92e4d2`)
- Bug 编号:BUG-1817(开工时核对 `docs/BUG_HISTORY.md` 的最大编号;1809–1830 已为这一批预留)
- 负责验收:Claude(jyotisha-2a)
- 范围:**只做后端**。新增一个工具文件,在注册表里加一行。不做前端接入,不建 `frontend/src/mastra/ondemand-tools/`。
不得自行写“产品决定/裁定”;任务书没写的取舍一律选需拍板。
## 背景
占星师 2026-11-07 Q9:“一段时间内换位、逆行、精确经过:`transit --months`、`--retrograde-calendar`、`--find-conjunction`、`--target-longitude`。”
2026-11-08 第五轮第 9 条:“行运窗口:支持换座、停滞、逆行往返和目标经度。”
#173 已在 staging 建好共用模块 `scripts/ondemand_calc/`。**开工前先读完**:
- `scripts/ondemand_calc/contract.py` 顶部 docstring(信封字段、错误规则、加工具的三步);
- `scripts/ondemand_calc/registry.py`;
- `scripts/ondemand_calc/transit_date.py`(第一个工具,本单照它的写法);
- `docs/tasks/PROGRESS-ondemand-transit-date-20261109.md`「给 #174–#178 的注册步骤」;
- `tests/test_ondemand_transit_date_20261109.py`(测试写法、星历路径配置)。
## 现状(afac4ea9 实测,行号以符号为准)
- 调用入口已经存在:`scripts/jyotish_api_server.py` 的 `/api/ondemand_calc` 薄路由 → `ondemand_calc.registry.ondemand_calc_api(body)` → `TOOLS[body["tool"]](body)`。**本单不需要、也不许改 API 服务器。**
- 缺参数时合同的做法是**返回 `calculation_status = "blocked"` 的信封**(`contract.blocked_envelope`),HTTP 仍是 200,`gaps` 点名缺项。原任务书里“缺一项就 400”作废,以合同为准。
- `contract.validate_common(body)` 只认单个 `date` 字段;本工具是区间,处理方法见下文“输入”。
- `contract.resolve_profile`:岁差必填;`node_mode` / `position_mode` 缺省时用产品默认值 `mean` / `apparent`,并写 `profile_source = "product_default_explicit"`(显式传了写 `caller_supplied`)。**本单照用,不改。** 节点 / 位置口径何时从本命请求显式带过来,归 #185。
- `position_mode = "mean"` 必须返回 `blocked`,`gaps` 写 `{"field": "position_mode", "reason": "position_mode_unsupported"}`,照 `transit_date.run` 的写法。
- 计算用引擎函数 `jyotish_engine._calc_sidereal_planets_for_jd(jd, node_mode=..., include_ketu=True, ayanamsa_name=...)`,调用前先 `engine._configure_ephemeris_path()`,和 `transit_date.py` 完全一样。这样数值与指定日期行运一致,岁差和交点口径都真正生效。
- **不要用**以下现成函数做最终时刻(可以在测试里当对照):
- `scripts/ephemeris_events.py` 的 `build_ephemeris_events`:按 UT 正午逐日采样,`node_mode` 只接收不使用,没有罗睺 / 计都;
- `scripts/transit_trigger.py` 的 `find_exact_transit_date`:只返回第一次到达,默认岁差是 lahiri;
- `scripts/jyotish_engine.py` 的 `cmd_transit`、`--retrograde-calendar`、`--find-conjunction`:依赖的 `scripts/transit_search.py` 不存在。
## 要做的
### 文件
1. 新建 `scripts/ondemand_calc/transit_window.py`,`TOOL = 'transit_window'`,实现 `run(body: dict) -> dict`。
2. 在 `scripts/ondemand_calc/registry.py`:
- 顶部 `from . import transit_date` 旁边加一行 `from . import transit_window`;
- `TOOLS` 里加一行 `'transit_window': transit_window.run,`,上方写一行注释列出必填参数(照 `transit_date` 那行注释的格式)。
- registry.py 其他内容不改。
3. 新测试 `tests/test_ondemand_transit_window_20261109.py`。
4. `docs/tasks/PROGRESS-ondemand-transit-window-20261109.md`。
5. `docs/BUG_HISTORY.md` 末尾追加 BUG-1817(只在文件末尾追加,不改其他条目)。
除上面 5 个文件外不改任何文件。
### 输入(`body`)
| 字段 | 必填 | 说明 |
| --- | --- | --- |
| `tool` | 是 | `"transit_window"` |
| `start_date`、`end_date` | 是 | `YYYY-MM-DD`,当地日期;`end_date >= start_date`;跨度 ≤ 366 天(本文件内常量 `MAX_WINDOW_DAYS = 366`,**不改** `contract.MAX_RANGE_DAYS`) |
| `lat`、`lon`、`tz` 或 `timezone_id` | 是 | 与 `transit_date` 相同(可放在 `place` 里) |
| `ayanamsa` | 是 | 与 `transit_date` 相同 |
| `node_mode`、`position_mode` | 否 | 交给 `resolve_profile`,缺省用产品默认值并如实标记 |
| `planets` | 否 | 九曜子集;不给时为九曜全集 |
| `events` | 是 | `ingress` / `station` / `retrograde_loop` / `target_longitude` 的非空子集 |
| `target` | `events` 含 `target_longitude` 时必填 | `{"planet": "...", "longitude": 0–360}` 或 `{"planet": "...", "natal_point": "<星名或 Ascendant>"}` |
| `natal` | 用 `natal_point` 时必填 | `{"points": {"Moon": 123.456, ...}}`,恒星黄经,由调用方给出;本工具**不自己排本命盘** |
校验顺序与写法:
- 先自己校验 `start_date` / `end_date`(缺失、格式、先后、跨度),出错抛 `OnDemandInputError(..., field='start_date' 或 'end_date')`;
- 再用 `contract.validate_common({**body, 'date': body['start_date']})` 校验地点和时区,用 `contract.resolve_profile` 取口径(`profile.tz` 因此是起始日的偏移);
- 所有 `OnDemandInputError` 都转成 `contract.blocked_envelope(tool=TOOL, body=body, error=error)`;
- `requested` 必须是调用方原样的 `body`(不得把上面临时加的 `date` 写进 `requested` 或指纹)。
### 计算
- 每个事件的时刻用二分法精确到 **≤ 1 分钟**(区间宽度 ≤ 1/1440 天)。
- 粗扫步长:月亮 ≤ 6 小时,其余 ≤ 1 天;在 `raw.precision` 里写明步长和收敛阈值。
- 当地时间:给了 `timezone_id` 时,**按事件当天**用 `contract.offset_hours(day, None, timezone_id)` 换算(夏令时生效);只给 `tz` 时用固定偏移。
- **换座(ingress)**:恒星黄经跨过 30° 整数倍的时刻;逆行导致的退回原星座也是一次换座,标 `direction: "retrograde"`。罗睺 / 计都按 `profile.node_mode`(mean 或 true)计算,不得忽略。
- **停滞(station)**:日速度(引擎返回的 `speed`)变号的时刻,标 `station_type: "retrograde"`(顺转逆)或 `"direct"`(逆转顺)。只对 Mercury、Venus、Mars、Jupiter、Saturn 计算;太阳、月亮没有停滞。罗睺 / 计都请求 `station` 或 `retrograde_loop` 时,不计算,在 `gaps` 写 `{"field": "planets", "reason": "node_station_not_supported:<星名>"}`,状态 `partial`(其他请求正常时)。
- **逆行往返(retrograde_loop)**:每一段逆行给出:
- `station_retrograde`、`station_direct` 两个停滞时刻及当时黄经;
- `retrograde_span`:`[station_direct 黄经, station_retrograde 黄经]`;
- `shadow_entry`:逆行前首次到达 `station_direct` 黄经的时刻;`shadow_exit`:顺行恢复后最后一次离开 `station_retrograde` 黄经的时刻。
- 只要两个停滞中有一个落在请求区间内就列出这一段;为找齐停滞和影子期,可以向区间外搜索,但每侧最多 400 天;落在请求区间外的时刻标 `outside_requested_window: true`。400 天内找不到的那一项为 `null`,并在 `gaps` 写 `loop_incomplete:<星名>:<字段>`。
- **到达目标经度(target_longitude)**:列出区间内**每一次**精确到达(逆行可能造成三次),每次标 `direction: "direct"` 或 `"retrograde"`。`natal_point` 在 `natal.points` 里找不到时 `blocked`,`gaps` 写 `{"field": "target.natal_point", "reason": "natal_point_missing:<名>"}`,不得用行运位置或别的点顶替。
### 输出
用 `contract.build_envelope`,信封字段与合同一致,不加顶层字段,`profile` 不加字段。`raw` 形状:
```
{
"start_date": "...", "end_date": "...",
"ayanamsa_degrees_at_start": <float>,
"precision": {"coarse_step_hours": {...}, "convergence_minutes": 1, "search_margin_days": 400},
"events": [
{"type": "ingress" | "station" | "target_longitude", "planet": "...",
"utc": "YYYY-MM-DDTHH:MMZ", "local": "YYYY-MM-DDTHH:MM", "utc_offset_hours": <float>,
"jd_ut": <float>, "longitude": <float, 6 位>, "sign": "...", "degree_in_sign": <float>,
...该类型自己的字段(from_sign / to_sign / direction / station_type / target_longitude)}
],
"retrograde_loops": [ {...见上...} ]
}
```
- `events` 按 `jd_ut` 升序。
- 计算失败(引擎抛错、星历不可用)时:`blocked`,`raw == {}`,`gaps` 写 `engine_failed:<异常类名>` 或 `ephemeris_unavailable`;信封中除 `requested` 外不得出现任何日期或时刻字符串(`computed_at_utc` 除外,与 `transit_date` 一致)。
- 部分行星失败、其余成功:`partial`,成功的照列,失败的在 `gaps` 点名。
## 本单专有验收(写进新测试)
1. 2026 年水星的一次逆行(公开星历):
- 两个停滞时刻与直接用 SwissEph 求速度过零的时刻相差 ≤ 2 分钟(测试里自己用 `swe.calc_ut` + `FLG_SIDEREAL | FLG_SPEED` 二分作对照);
- 以逆行段中间的某个黄经为目标,三次到达都列出,方向依次为 direct / retrograde / direct;
- `shadow_entry` 早于 `station_retrograde`,`shadow_exit` 晚于 `station_direct`。
2. lahiri 与 raman 下,同一次换座的时刻不同,且各自 `profile.ayanamsa` 如实回显。
3. 罗睺 / 计都在 `node_mode = mean` 与 `true` 下,同一次换座的时刻不同。
4. 用 `timezone_id = "Europe/London"` 跨夏令时切换日(2026-03-29)的区间:切换前后事件的 `utc_offset_hours` 分别为 0 和 1。
5. 与 `transit_date` 的一致性:在某个换座时刻,用 `transit_date` 算同一时刻(同口径),该行星黄经与 30° 边界相差 ≤ 0.01°。
## 通用验收(写进新测试)
1. 缺 `start_date` / `end_date` / 地点 / 时区 / 岁差 / `events` 中任一项,返回 `blocked` 且 `gaps[0].field` 点名缺项;跨度 > 366 天、`end_date < start_date` 同样 `blocked`。
2. `position_mode = "mean"` 返回 `blocked` + `position_mode_unsupported`。
3. 省略 `node_mode` / `position_mode` 时 `profile.profile_source == "product_default_explicit"`;都显式传时为 `caller_supplied`。
4. 同一输入的 `input_fingerprint` 稳定;任一参数(日期、地点、岁差、交点、planets、events、target)改变后 fingerprint 改变。
5. 引擎抛错(monkeypatch `_calc_sidereal_planets_for_jd`)时返回 `blocked`、`raw == {}`,除 `requested` 与 `computed_at_utc` 外信封里没有日期字符串。
6. `ondemand_calc_api({"tool": "transit_window", ...})` 能分派到本工具;`transit_date` 原有测试不受影响。
7. 测试只用公开日期和合成坐标(不写任何真人出生资料)。
## 不要碰
- `references/**`(占星师方法文件),一字不改;包括 `references/product_runtime_contract.md`。
- `frontend/**` 全部,特别是 `frontend/src/lib/consult-preset-prompts.ts`、`frontend/src/lib/consultation-methodology.ts`;本单不建 `frontend/src/mastra/ondemand-tools/`。
- `scripts/ondemand_calc/contract.py`、`scripts/ondemand_calc/transit_date.py`、`scripts/ondemand_calc/muhurta_search.py`、`scripts/ondemand_calc/__init__.py`;`registry.py` 只许加上文那两行。合同不够用就停下选“需拍板”,不得改合同。
- 冻结文件只许 import 调用,不许改:`scripts/jyotish_api_server.py`、`scripts/jyotish_engine.py`、`scripts/domain_calculation_service.py`、`scripts/dasha_analyzer.py`、`scripts/narayana_dasha.py`、`scripts/shadbala.py`、`scripts/ashtakavarga.py`、`scripts/jaimini.py`、`scripts/varga.py`、`scripts/functional_benefics.py`、`scripts/divisional_charts_extended.py`。
- `scripts/ephemeris_events.py`、`scripts/transit_trigger.py`:不改现有行为;不补 `scripts/transit_search.py`。
- 其他未完成单子占用的文件:`frontend/src/mastra/consultation-tools.ts`(#171)、`frontend/src/lib/consultation-evidence-card.ts`(#170 / #172)、`frontend/src/mastra/consultation-workflow.ts` 与 `frontend/src/lib/consultation-route-service.ts`(#185)。
- 其他按需工具(#175–#178)的文件名:`solar_return.py`、`varga.py`(在 ondemand_calc 内)、`dasha_date.py`、`prashna.py`,不要预建。
- 既有测试的断言不得弱化;若必须改,写“原值 / 新值 / 原因”三栏注释,原因写 `占星师 Q9 / 第五轮 9:按需计算显式传口径`。
## 自测命令(Windows / PowerShell)
- `.venv\Scripts\python -m pytest tests/test_ondemand_transit_window_20261109.py tests/test_ondemand_transit_date_20261109.py tests/test_ondemand_calc_contract_20261109.py tests/test_api_server_growth_contract.py -q`
Python 快速门、前端全量测试和 eslint 由 Claude 在验收时跑。
## 进度记录
`docs/tasks/PROGRESS-ondemand-transit-window-20261109.md`:做了什么、跑了哪些命令、通过/失败数、偏离与原因;附上验收第 1 条的实测时刻差(分钟)。
## 何时停下来选“需拍板”
- `contract.py` 的信封或校验表达不了本工具的输入输出,必须改合同时。
- 要做到显式口径,就必须改冻结文件时。
- 某颗星的停滞在 2026 年的测试区间内二分不能收敛到 ≤ 2 分钟(速度长期接近 0),需要决定精度口径时。
- 任务书没写到的任何取舍。
+4 -1
View File
@@ -55,7 +55,10 @@ const BASIS_ENTRIES: readonly BasisEntry[] = [
name: "功能吉凶",
result: (profile) => `按 ${FUNCTIONAL_PROFILE_LABELS[profile]} 判断每颗星在这张盘里起的作用`,
},
{ match: /^Yogas?\b|^格局/i, name: "格局", result: () => "盘上成立和落空的格局已核对" },
// 原值:`盘上成立和落空的格局已核对`
// 新值:`盘上检测到的格局已列出各自状态`
// 原因:BUG-1824:候选不得显示为成立(卡上规则是 none is confirmed)
{ match: /^Yogas?\b|^格局/i, name: "格局", result: () => "盘上检测到的格局已列出各自状态" },
{ match: /^Shadbala\b/i, name: "行星力量", result: () => "每颗星的强弱已算" },
{ match: /^Ashtakavarga$/i, name: "宫位点数", result: () => "每个宫得到的点数已算" },
{ match: /^Arudha\b/i, name: "Arudha 点位", result: () => "事业、婚姻相关的点位落在哪里" },
@@ -11,6 +11,7 @@
*/
import type { TechniqueAuditRow } from "./consultation-agent-events.ts";
import type { EvidenceCard } from "./consultation-evidence-card.ts";
import { isStructuralObservation, yogaStatus } from "./yoga-status-words.ts";
type Rec = Record<string, unknown>;
const rec = (value: unknown): Rec => (value && typeof value === "object" && !Array.isArray(value) ? value as Rec : {});
@@ -136,11 +137,45 @@ function savLine(card: EvidenceCard) {
});
}
// 原值:`成立:${hits}` / `本题没有成立的格局`
// 新值:按卡上 yogas_detail 的状态,用占星师第七轮 §5 P0.2 的映射表(yoga-status-words.ts)分组写
// 「名字、名字:状态文字」,组间用「。」;没有可列的写「本题没有检测到相关格局」
// 原因:BUG-1824:候选不得显示为成立;hits 列表不能推断成立,状态文字不得被截断
function yogaLine(card: EvidenceCard) {
const detail = rec(rec(rec(card.base).natal).yogas_detail);
const rows = Array.isArray(detail.rows) ? detail.rows.map(rec) : [];
const byName = new Map<string, Rec>();
for (const row of rows) {
const name = str(row.name);
if (name && !byName.has(name)) byName.set(name, row);
}
const notEvaluated = new Set(
(Array.isArray(detail.not_evaluated) ? detail.not_evaluated.map(rec) : []).map((row) => str(row.name)).filter(Boolean),
);
return firstOf(card, (section) => {
if (!("yogas" in section)) return undefined;
const hits = list(rec(section.yogas).hits);
return hits.length ? `成立:${hits.slice(0, 3).join("、")}` : "本题没有成立的格局";
const hits = list(rec(section.yogas).hits).slice(0, 3);
const groups = new Map<string, string[]>();
for (const name of hits) {
const row = byName.get(name);
if (row && isStructuralObservation(row)) continue;
const { text } = yogaStatus(row, { notEvaluated: notEvaluated.has(name) });
const bucket = groups.get(text) ?? [];
bucket.push(name);
groups.set(text, bucket);
}
if (!groups.size) return "本题没有检测到相关格局";
// 逐个名字往里放,放不下就写“另 N 项未列”,绝不把状态文字截半(截半会只剩“……成立”)。
const entries = [...groups].flatMap(([text, names]) => names.map((name) => [text, name] as const));
const render = (shown: number) => {
const kept = new Map<string, string[]>();
for (const [text, name] of entries.slice(0, shown)) kept.set(text, [...(kept.get(text) ?? []), name]);
const body = [...kept].map(([text, names]) => `${names.join("、")}:${text}`).join("。");
return shown < entries.length ? `${body}。另 ${entries.length - shown} 项未列` : body;
};
let shown = 1;
while (shown < entries.length && render(shown + 1).length <= MAX) shown += 1;
return render(shown);
});
}
+76
View File
@@ -0,0 +1,76 @@
/**
* BUG-1824 / 占星师第七轮回复 §5 P0.2(2026-11-09):Yoga 状态的唯一映射表。
* 「解读依据」页面(#181)和答题模型的状态措辞(#182)必须 import 同一张表,
* 不能一个把 candidate 升成“成立”、另一个把 computed 降成“非成立”。
*
* | 原始证据 | 推荐文字 |
* |---|---|
* | candidate / support_only_candidate | 命中候选规则,尚未完整确认 |
* | computed,未完成来源条件治理 | 检测器检出,待核实 |
* | formed_under_source | 按所选来源的形成条件成立;强度和结果另评 |
* | conditions_not_met | 所选来源的必要条件未满足 |
* | blocked / not_available_yet | 当前无法确认,列明缺项 |
*
* 规则(同一处原文):用 hits 列表不能推断“成立”;没有完整条件检查不能写“落空 / 非成立”。
* 所以:只有 `formation.status` 这一完整条件检查的结果能给出“成立”或“必要条件未满足”;
* 卡上找不到状态行、或状态值不在表内时,一律落到 blocked(当前无法确认),并写明缺什么。
*
* 验收时(Claude 2026-11-09)按占星师原表替换了 #181 初版词表
* (初版:候选 / 未确认 / 不成立 / 未标状态,formed_under_source 显示为候选)。
*/
export type YogaStatusKey = "candidate" | "computed" | "formed_under_source" | "conditions_not_met" | "blocked";
/** 占星师原表,文字逐字照抄;`evidence` 是卡上可能出现的原始值。 */
export const YOGA_STATUS_MAPPING: Readonly<Record<YogaStatusKey, { readonly evidence: readonly string[]; readonly text: string }>> = {
candidate: { evidence: ["candidate", "support_only_candidate"], text: "命中候选规则,尚未完整确认" },
computed: { evidence: ["computed"], text: "检测器检出,待核实" },
formed_under_source: { evidence: ["formed_under_source"], text: "按所选来源的形成条件成立;强度和结果另评" },
conditions_not_met: { evidence: ["conditions_not_met"], text: "所选来源的必要条件未满足" },
blocked: { evidence: ["blocked", "not_available_yet"], text: "当前无法确认,列明缺项" },
};
/** blocked 行实际显示的文字:把“列明缺项”换成具体缺项。 */
export const YOGA_BLOCKED_PREFIX = "当前无法确认,缺:";
export const YOGA_GAP_NO_ROW = "卡上没有这条格局的状态记录";
export const YOGA_GAP_NOT_EVALUATED = "引擎未能评估这条规则";
type Rec = Record<string, unknown>;
const rec = (value: unknown): Rec => (value && typeof value === "object" && !Array.isArray(value) ? value as Rec : {});
const text = (value: unknown) => (typeof value === "string" && value.trim() ? value.trim() : undefined);
export type YogaStatus = { key: YogaStatusKey; text: string };
function blocked(gap: string): YogaStatus {
return { key: "blocked", text: `${YOGA_BLOCKED_PREFIX}${gap}` };
}
/**
* 一个格局行(`card.base.natal.yogas_detail.rows[]`)的状态。`row` 缺失 = 只在 hits 里出现、卡上没有状态行。
* `notEvaluated` 为 true 表示该名字在 `yogas_detail.not_evaluated` 里。
*/
export function yogaStatus(row: Rec | undefined, options: { notEvaluated?: boolean } = {}): YogaStatus {
if (!row) return blocked(options.notEvaluated ? YOGA_GAP_NOT_EVALUATED : YOGA_GAP_NO_ROW);
const formation = text(rec(row.formation).status);
// 只有完整条件检查的结果才能说“成立”或“必要条件未满足”。
if (formation === "formed_under_source") return { key: "formed_under_source", text: YOGA_STATUS_MAPPING.formed_under_source.text };
if (formation === "conditions_not_met") return { key: "conditions_not_met", text: YOGA_STATUS_MAPPING.conditions_not_met.text };
const status = text(row.status);
const claim = text(row.claim_status);
for (const key of ["candidate", "computed", "blocked"] as const) {
if (status && YOGA_STATUS_MAPPING[key].evidence.includes(status)) {
return key === "blocked" ? blocked(`状态为 ${status}`) : { key, text: YOGA_STATUS_MAPPING[key].text };
}
}
if ((claim && YOGA_STATUS_MAPPING.candidate.evidence.includes(claim)) || formation === "candidate") {
return { key: "candidate", text: YOGA_STATUS_MAPPING.candidate.text };
}
if (options.notEvaluated) return blocked(YOGA_GAP_NOT_EVALUATED);
return blocked(status ? `状态值 ${status} 不在映射表内` : "卡上这条格局没有状态值");
}
/** 卡上这一行是不是“结构观察”(不是命名格局),不进「格局」这一行。 */
export function isStructuralObservation(row: Rec): boolean {
return row.claim_kind === "structural_observation" || row.status === "structural_observation";
}
@@ -0,0 +1,162 @@
/**
* BUG-1824:「解读依据」的「格局」一行不得把候选格局写成「成立」。
* 合成卡,不含任何真人资料。
*
* 验收(Claude 2026-11-09):状态文字改用占星师第七轮回复 §5 P0.2 的映射表。
* 原值 / 新值 / 原因(适用于下面每条被改写的期望值):
* 原值:候选 / 未确认 / 不成立 / 未标状态,formed_under_source 显示为「候选」
* 新值:命中候选规则,尚未完整确认 / 检测器检出,待核实 / 按所选来源的形成条件成立;强度和结果另评 /
* 所选来源的必要条件未满足 / 当前无法确认,缺:…
* 原因:占星师第七轮 §5 P0.2:模型和页面必须使用同一状态映射(#182 import 同一模块)
*/
import assert from "node:assert/strict";
import test from "node:test";
import type { TechniqueAuditRow } from "../src/lib/consultation-agent-events.ts";
import { consultAnswerBasisRows } from "../src/lib/consult-answer-basis.ts";
import type { EvidenceCard } from "../src/lib/consultation-evidence-card.ts";
import { withTechniqueEvidence } from "../src/lib/consultation-technique-evidence.ts";
import {
YOGA_BLOCKED_PREFIX, YOGA_GAP_NO_ROW, YOGA_GAP_NOT_EVALUATED, YOGA_STATUS_MAPPING, yogaStatus,
} from "../src/lib/yoga-status-words.ts";
type Row = Record<string, unknown>;
const CANDIDATE = "命中候选规则,尚未完整确认";
const COMPUTED = "检测器检出,待核实";
const FORMED = "按所选来源的形成条件成立;强度和结果另评";
const NOT_MET = "所选来源的必要条件未满足";
function cardWith(rows: Row[], hits: string[], extra: Row = {}): EvidenceCard {
return {
domains: ["career"],
domain_sections: { career: { yogas: { hits } } },
base: { natal: { yogas_detail: { rows, ...extra } } },
} as unknown as EvidenceCard;
}
function yogaResult(card: EvidenceCard): string | undefined {
const audit: TechniqueAuditRow[] = [{
technique: "Yogas",
calculation_status: "executed",
interpretation_status: "observation_only",
} as unknown as TechniqueAuditRow];
const enriched = withTechniqueEvidence(audit, card) ?? [];
const rows = consultAnswerBasisRows(enriched);
return rows.find((row) => row.name === "格局")?.result;
}
test("the mapping table is the astrologer's round-7 §5 P0.2 table, word for word", () => {
assert.deepEqual(
Object.fromEntries(Object.entries(YOGA_STATUS_MAPPING).map(([key, row]) => [key, [row.evidence, row.text]])),
{
candidate: [["candidate", "support_only_candidate"], CANDIDATE],
computed: [["computed"], COMPUTED],
formed_under_source: [["formed_under_source"], FORMED],
conditions_not_met: [["conditions_not_met"], NOT_MET],
blocked: [["blocked", "not_available_yet"], "当前无法确认,列明缺项"],
},
);
});
test("row 1: candidate and support_only_candidate read 命中候选规则,尚未完整确认", () => {
assert.equal(yogaResult(cardWith([{ name: "Sunaphaa Yoga", status: "candidate" }], ["Sunaphaa Yoga"])), `Sunaphaa Yoga:${CANDIDATE}`);
assert.equal(yogaStatus({ name: "X", status: "support_only_candidate" }).text, CANDIDATE);
assert.equal(yogaStatus({ name: "X", claim_status: "support_only_candidate" }).text, CANDIDATE);
});
test("row 2: computed without a completed condition check reads 检测器检出,待核实", () => {
assert.equal(yogaResult(cardWith([{ name: "Vosi Yoga", status: "computed" }], ["Vosi Yoga"])), `Vosi Yoga:${COMPUTED}`);
assert.equal(yogaStatus({ name: "X", status: "computed", formation: { status: "candidate" } }).text, COMPUTED);
});
test("row 3: formed_under_source reads the source-bound 成立 with strength and outcome left open", () => {
const line = yogaResult(cardWith([
{ name: "Amala Yoga", status: "candidate", formation: { status: "formed_under_source" } },
], ["Amala Yoga"]));
assert.equal(line, `Amala Yoga:${FORMED}`);
});
test("row 4: conditions_not_met reads 所选来源的必要条件未满足, never 落空 / 非成立 / 不成立", () => {
const line = yogaResult(cardWith([
{ name: "Dharidhra Yoga", status: "candidate", formation: { status: "conditions_not_met" } },
], ["Dharidhra Yoga"])) ?? "";
assert.equal(line, `Dharidhra Yoga:${NOT_MET}`);
assert.doesNotMatch(line, /落空|非成立|不成立/u);
});
test("row 5: blocked / not_available_yet reads 当前无法确认 and names what is missing", () => {
for (const status of ["blocked", "not_available_yet"]) {
const result = yogaStatus({ name: "X", status });
assert.equal(result.key, "blocked");
assert.equal(result.text, `${YOGA_BLOCKED_PREFIX}状态为 ${status}`);
}
const line = yogaResult(cardWith([], ["Lakshmi Yoga"], { not_evaluated: [{ name: "Lakshmi Yoga", evaluation_status: "evaluation_failed" }] }));
assert.equal(line, `Lakshmi Yoga:${YOGA_BLOCKED_PREFIX}${YOGA_GAP_NOT_EVALUATED}`);
});
test("a yoga that appears only in the hits list never renders 成立", () => {
const line = yogaResult(cardWith([], ["Gaja Kesari Yoga", "Budha-Aditya Yoga"])) ?? "";
assert.equal(line, `Gaja Kesari Yoga、Budha-Aditya Yoga:${YOGA_BLOCKED_PREFIX}${YOGA_GAP_NO_ROW}`);
assert.doesNotMatch(line, /成立/u);
// 状态值不在表内、或没有状态值的行同样落到 blocked,不猜。
assert.equal(yogaStatus({ name: "X", status: "confirmed" }).key, "blocked");
assert.equal(yogaStatus({ name: "X" }).key, "blocked");
});
test("mixed statuses group in first-seen order and the status text is never clipped", () => {
const line = yogaResult(cardWith([
{ name: "Sunaphaa Yoga", status: "candidate" },
{ name: "Vosi Yoga", status: "computed" },
{ name: "Gaja Kesari Yoga", status: "candidate", formation: { status: "formed_under_source" } },
], ["Sunaphaa Yoga", "Vosi Yoga", "Gaja Kesari Yoga"])) ?? "";
assert.ok(line.length <= 80, line);
assert.doesNotMatch(line, /…/u);
assert.ok(line.startsWith(`Sunaphaa Yoga:${CANDIDATE}。Vosi Yoga:${COMPUTED}`), line);
// 放不下的整项挪到“另 N 项未列”,而不是把 FORMED 截成“……成立”。
if (!line.includes(FORMED)) assert.match(line, /另 1 项未列$/u);
assert.doesNotMatch(line.replace(FORMED, ""), /成立/u);
});
test("structural observations stay out of the 格局 row", () => {
const card = cardWith([
{ name: "Neecha Bhanga witness", status: "structural_observation" },
{ name: "Sunaphaa Yoga", status: "candidate" },
], ["Neecha Bhanga witness", "Sunaphaa Yoga"]);
assert.equal(yogaResult(card), `Sunaphaa Yoga:${CANDIDATE}`);
});
test("neither the grey line nor the fallback copy claims 成立: or 已确认", () => {
const card = cardWith([
{ name: "Sunaphaa Yoga", status: "candidate" },
{ name: "Vosi Yoga", status: "computed" },
], ["Sunaphaa Yoga", "Vosi Yoga"]);
const line = yogaResult(card) ?? "";
const fallback = consultAnswerBasisRows([{
technique: "Yogas",
calculation_status: "executed",
} as unknown as TechniqueAuditRow]).find((row) => row.name === "格局")?.result ?? "";
for (const text of [line, fallback]) {
assert.doesNotMatch(text, /成立:/u);
assert.doesNotMatch(text, /已确认/u);
}
assert.equal(fallback, "盘上检测到的格局已列出各自状态");
});
test("the 格局 row keeps its calculation status while the grey line carries the reading status", () => {
const card = cardWith([{ name: "Sunaphaa Yoga", status: "candidate" }], ["Sunaphaa Yoga"]);
const enriched = withTechniqueEvidence([{
technique: "Yogas",
calculation_status: "executed",
interpretation_status: "supporting",
} as unknown as TechniqueAuditRow], card) ?? [];
const row = consultAnswerBasisRows(enriched).find((item) => item.name === "格局");
assert.ok(row);
assert.equal(row.status, "done");
assert.equal(row.result, `Sunaphaa Yoga:${CANDIDATE}`);
});
test("no hits at all says nothing was detected, not that nothing formed", () => {
const card = cardWith([{ name: "Sunaphaa Yoga", status: "candidate" }], []);
assert.equal(yogaResult(card), "本题没有检测到相关格局");
});
+4 -1
View File
@@ -7,7 +7,7 @@ from __future__ import annotations
from typing import Any, Callable, Dict
from . import transit_date
from . import transit_date, transit_window
from .contract import OnDemandInputError, blocked_envelope
# 工具名 → run(body) -> envelope
@@ -15,6 +15,9 @@ TOOLS: Dict[str, Callable[[Dict[str, Any]], Dict[str, Any]]] = {
# 指定日期的行运:必填 date、地点(lat/lon)、时区(tz 或 timezone_id)、ayanamsa;
# node_mode / position_mode 缺省用产品默认值(mean / apparent)。
'transit_date': transit_date.run,
# 一段时间的行运变化:必填 start_date、end_date、地点、时区、ayanamsa、events;
# planets / target / natal 按事件类型选填。
'transit_window': transit_window.run,
}
__all__ = ['TOOLS', 'OnDemandInputError', 'ondemand_calc_api']
+506
View File
@@ -0,0 +1,506 @@
"""按需计算:一段时间内的行运变化(#174,BUG-1817)。
占星师 2026-11-07 Q9 / 2026-11-08 第五轮第 9 条:行运窗口要支持**换座、停滞、逆行往返、目标经度**。
数值一律来自引擎的恒星黄经(`jyotish_engine._calc_sidereal_planets_for_jd`),岁差、交点、
位置口径由调用方显式给出;本工具不排本命盘,`natal_point` 要调用方把恒星黄经传进来。
时刻用二分收敛到 ≤ 1 分钟。换座、停滞、目标经度的判定都只看恒星黄经与日速度,不另起算法。
"""
from __future__ import annotations
import sys
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
from . import contract
from .contract import OnDemandInputError
_SCRIPTS_DIR = Path(__file__).resolve().parents[1]
if str(_SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPTS_DIR))
TOOL = 'transit_window'
MAX_WINDOW_DAYS = 366
SEARCH_MARGIN_DAYS = 400
CONVERGENCE_MINUTES = 1
CONVERGENCE_DAYS = CONVERGENCE_MINUTES / 1440.0
PLANETS = ('Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn', 'Rahu', 'Ketu')
STATION_PLANETS = ('Mercury', 'Venus', 'Mars', 'Jupiter', 'Saturn')
EVENT_TYPES = ('ingress', 'station', 'retrograde_loop', 'target_longitude')
COARSE_STEP_HOURS = {'Moon': 6.0, 'default': 24.0}
def _engine():
try:
from scripts import jyotish_engine as engine
except ModuleNotFoundError: # pragma: no cover - 直接跑 scripts/
import jyotish_engine as engine # type: ignore[no-redef]
return engine
def _jd_from_datetime(moment: datetime) -> float:
import swisseph as swe
return swe.julday(
moment.year, moment.month, moment.day,
moment.hour + moment.minute / 60.0 + moment.second / 3600.0,
)
def _local_midnight_jd(day: datetime, tz: float) -> float:
"""当地 00:00 对应的 JD(UT)。"""
return _jd_from_datetime(datetime(day.year, day.month, day.day) - timedelta(hours=tz))
class _Sky:
"""一个区间内按需取恒星黄经与日速度,调用方给的口径固定不变。"""
def __init__(self, engine: Any, profile: Dict[str, Any], planets: Sequence[str]) -> None:
self.engine = engine
self.profile = profile
self.planets = tuple(planets)
if hasattr(engine, '_configure_ephemeris_path'):
engine._configure_ephemeris_path() # noqa: SLF001
self._cache: Dict[float, Dict[str, Dict[str, float]]] = {}
def at(self, jd: float) -> Dict[str, Dict[str, float]]:
key = round(jd, 8)
if key in self._cache:
return self._cache[key]
data, _ayanamsa = self.engine._calc_sidereal_planets_for_jd( # noqa: SLF001
jd,
node_mode=self.profile['node_mode'],
include_ketu=True,
ayanamsa_name=self.profile['ayanamsa'],
)
snapshot: Dict[str, Dict[str, float]] = {}
for name in self.planets:
row = data.get(name)
if not row:
continue
snapshot[name] = {
'lon': float(row.get('degree_raw', row.get('degree', 0.0))),
'speed': float(row.get('speed') or 0.0),
'sign': str(row.get('sign') or ''),
}
self._cache[key] = snapshot
return snapshot
def ayanamsa(self, jd: float) -> float:
data, ayanamsa = self.engine._calc_sidereal_planets_for_jd( # noqa: SLF001
jd,
node_mode=self.profile['node_mode'],
include_ketu=True,
ayanamsa_name=self.profile['ayanamsa'],
)
del data
return float(ayanamsa or 0.0)
def lon(self, planet: str, jd: float) -> Optional[float]:
row = self.at(jd).get(planet)
return None if row is None else row['lon']
def _bisect(predicate: Callable[[float], bool], low: float, high: float,
tolerance: float = CONVERGENCE_DAYS) -> float:
"""在 [low, high] 上找 predicate 从 False 变 True 的位置。"""
lo, hi = low, high
while hi - lo > tolerance:
middle = (lo + hi) / 2.0
if predicate(middle):
hi = middle
else:
lo = middle
return (lo + hi) / 2.0
def _signed_delta(value: float, reference: float) -> float:
"""value - reference,按 0–360 取最短方向(正为顺行方向)。"""
return ((value - reference + 180.0) % 360.0) - 180.0
def _crossed(before: float, after: float, boundary: float) -> bool:
"""从 before 到 after(正向时间)是否跨过 boundary(模 360)。顺行逆行都算。"""
if before == after:
return False
delta = _signed_delta(after, before)
if delta == 0.0:
return False
if delta > 0.0:
return 0.0 < (boundary - before) % 360.0 <= delta
return 0.0 < (before - boundary) % 360.0 <= -delta
def _sign_boundaries(before: float, after: float) -> List[float]:
"""一步之内跨过的所有 30° 整数倍,按行进方向排序。"""
out: List[float] = []
forward = (after - before) % 360.0
if forward == 0.0:
return out
step = 30.0 if forward < 180.0 else -30.0
boundary = ((before // 30.0) + (1 if step > 0 else 0)) * 30.0
travelled = 0.0
guard = 0
while travelled < forward - 1e-9 and guard < 24:
guard += 1
if step > 0:
distance = (boundary - before) % 360.0
if distance > forward:
break
out.append(boundary % 360.0)
travelled = distance
boundary += 30.0
else:
distance = (before - boundary) % 360.0
if distance > (before - after) % 360.0:
break
out.append(boundary % 360.0)
travelled = distance
boundary -= 30.0
return out
def _sign_name(engine: Any, longitude: float) -> str:
signs = getattr(engine, 'SIGNS', None) or []
if not signs:
return ''
return str(signs[int(longitude % 360.0 / 30.0) % 12])
def _utc_stamp(jd: float) -> str:
import swisseph as swe
year, month, day, hour = swe.revjul(jd)
minutes = int(round(hour * 60.0))
return f'{year:04d}-{month:02d}-{day:02d}T{(minutes // 60) % 24:02d}:{minutes % 60:02d}Z'
def _local_parts(jd: float, tz: float) -> Tuple[str, int, int]:
"""当地日期与时分。"""
import swisseph as swe
year, month, day, hour = swe.revjul(jd + tz / 24.0)
minutes = int(round(hour * 60.0))
return f'{year:04d}-{month:02d}-{day:02d}', (minutes // 60) % 24, minutes % 60
def _offset_for_jd(jd: float, tz_fixed: Optional[float], timezone_id: Optional[str]) -> float:
if not timezone_id:
return float(tz_fixed or 0.0)
import swisseph as swe
year, month, day, _hour = swe.revjul(jd + (tz_fixed or 0.0) / 24.0)
return contract.offset_hours(datetime(year, month, day), None, timezone_id)
def _event(engine: Any, sky: _Sky, planet: str, jd: float, kind: str,
tz_fixed: Optional[float], timezone_id: Optional[str]) -> Dict[str, Any]:
row = sky.at(jd).get(planet) or {}
longitude = float(row.get('lon') or 0.0)
offset = _offset_for_jd(jd, tz_fixed, timezone_id)
date_text, hour, minute = _local_parts(jd, offset)
return {
'type': kind,
'planet': planet,
'utc': _utc_stamp(jd),
'local': f'{date_text}T{hour:02d}:{minute:02d}',
'utc_offset_hours': round(offset, 4),
'jd_ut': round(jd, 8),
'longitude': round(longitude, 6),
'sign': _sign_name(engine, longitude),
'degree_in_sign': round(longitude % 30.0, 4),
}
def _sign_index(longitude: float) -> int:
return int(longitude % 360.0 / 30.0) % 12
def _ingress_events(engine: Any, sky: _Sky, planet: str, start_jd: float, end_jd: float,
tz_fixed: Optional[float], timezone_id: Optional[str]) -> List[Dict[str, Any]]:
step = COARSE_STEP_HOURS.get(planet, COARSE_STEP_HOURS['default']) / 24.0
events: List[Dict[str, Any]] = []
jd = start_jd
before = sky.lon(planet, jd)
while jd < end_jd:
nxt = min(jd + step, end_jd)
after = sky.lon(planet, nxt)
if before is None or after is None:
before, jd = after, nxt
continue
if _sign_index(before) != _sign_index(after):
start_sign = _sign_index(before)
at = _bisect(lambda x: _sign_index(sky.lon(planet, x) or before) != start_sign, jd, nxt)
event = _event(engine, sky, planet, at, 'ingress', tz_fixed, timezone_id)
event['from_sign'] = _sign_name(engine, before)
# 验收修正(Claude 2026-11-09):原来 to_sign 用 JD 当黄经取星座、direction 在 0° 白羊处判反;
# 改为按粗扫步末端的黄经取星座,方向按最短角差的正负。
event['to_sign'] = _sign_name(engine, after)
event['direction'] = 'direct' if _signed_delta(after, before) > 0.0 else 'retrograde'
events.append(event)
before, jd = after, nxt
return events
def _station_events(engine: Any, sky: _Sky, planet: str, start_jd: float, end_jd: float,
tz_fixed: Optional[float], timezone_id: Optional[str]) -> List[Dict[str, Any]]:
step = COARSE_STEP_HOURS['default'] / 24.0
events: List[Dict[str, Any]] = []
jd = start_jd
before_row = sky.at(jd).get(planet)
while jd < end_jd:
nxt = min(jd + step, end_jd)
after_row = sky.at(nxt).get(planet)
if not before_row or not after_row:
before_row, jd = after_row, nxt
continue
before_speed = before_row['speed']
after_speed = after_row['speed']
if before_speed == 0.0 or (before_speed < 0) != (after_speed < 0):
at = _bisect(lambda x: (sky.at(x).get(planet, {}).get('speed', 0.0) < 0) != (before_speed < 0), jd, nxt)
event = _event(engine, sky, planet, at, 'station', tz_fixed, timezone_id)
event['station_type'] = 'retrograde' if before_speed >= 0 else 'direct'
event['speed'] = round(float(sky.at(at).get(planet, {}).get('speed', 0.0)), 6)
events.append(event)
before_row, jd = after_row, nxt
return events
def _target_events(engine: Any, sky: _Sky, planet: str, target: float, start_jd: float, end_jd: float,
tz_fixed: Optional[float], timezone_id: Optional[str]) -> List[Dict[str, Any]]:
step = COARSE_STEP_HOURS.get(planet, COARSE_STEP_HOURS['default']) / 24.0
events: List[Dict[str, Any]] = []
jd = start_jd
before = sky.lon(planet, jd)
while jd < end_jd:
nxt = min(jd + step, end_jd)
after = sky.lon(planet, nxt)
if before is None or after is None:
before, jd = after, nxt
continue
if before != after and _crossed(before, after, target):
at = _bisect(lambda x: _crossed(before, sky.lon(planet, x) or before, target), jd, nxt)
event = _event(engine, sky, planet, at, 'target_longitude', tz_fixed, timezone_id)
event['target_longitude'] = round(float(target), 6)
event['direction'] = 'direct' if (after - before) % 360.0 < 180.0 else 'retrograde'
events.append(event)
before, jd = after, nxt
return events
def _retrograde_loops(engine: Any, sky: _Sky, planet: str, start_jd: float, end_jd: float,
tz_fixed: Optional[float], timezone_id: Optional[str],
gaps: List[Dict[str, str]]) -> List[Dict[str, Any]]:
margin = SEARCH_MARGIN_DAYS
stations = _station_events(engine, sky, planet, start_jd - margin, end_jd + margin, tz_fixed, timezone_id)
loops: List[Dict[str, Any]] = []
for index, station in enumerate(stations):
if station.get('station_type') != 'retrograde':
continue
if index + 1 >= len(stations):
continue
direct = stations[index + 1]
if direct.get('station_type') != 'direct':
continue
retro_lon = float(station['longitude'])
direct_lon = float(direct['longitude'])
entry = _find_crossing(engine, sky, planet, direct_lon, station['jd_ut'] - margin * 1.0,
station['jd_ut'], tz_fixed, timezone_id, backwards=True)
exit_ = _find_crossing(engine, sky, planet, retro_lon, direct['jd_ut'],
direct['jd_ut'] + margin * 1.0, tz_fixed, timezone_id, backwards=False)
inside = (start_jd <= station['jd_ut'] <= end_jd) or (start_jd <= direct['jd_ut'] <= end_jd)
if not inside:
continue
for key, value in (('shadow_entry', entry), ('shadow_exit', exit_)):
if value is None:
gaps.append({'field': 'retrograde_loop', 'reason': f'loop_incomplete:{planet}:{key}'})
loops.append({
'planet': planet,
'station_retrograde': station,
'station_direct': direct,
'retrograde_span': [round(direct_lon, 6), round(retro_lon, 6)],
'shadow_entry': entry,
'shadow_exit': exit_,
'outside_requested_window': not (start_jd <= station['jd_ut'] <= end_jd),
})
return loops
def _find_crossing(engine: Any, sky: _Sky, planet: str, longitude: float, low: float, high: float,
tz_fixed: Optional[float], timezone_id: Optional[str],
*, backwards: bool) -> Optional[Dict[str, Any]]:
"""在 [low, high] 内找最后一次(backwards)或第一次(向前)到达 longitude 的时刻。"""
if high <= low:
return None
step = COARSE_STEP_HOURS['default'] / 24.0
found: Optional[float] = None
jd = low
before = sky.lon(planet, jd)
while jd < high:
nxt = min(jd + step, high)
after = sky.lon(planet, nxt)
if before is not None and after is not None and before != after and _crossed(before, after, longitude):
at = _bisect(lambda x: _crossed(before, sky.lon(planet, x) or before, longitude), jd, nxt)
# 向后找「最后一次」到达:从早到晚扫,保留最后一个;向前找「第一次」:命中即停。
found = at
if not backwards:
break
before, jd = after, nxt
if found is None:
return None
return _event(engine, sky, planet, found, 'shadow_crossing', tz_fixed, timezone_id)
def _validate(body: Dict[str, Any]) -> Dict[str, Any]:
if not isinstance(body, dict):
raise OnDemandInputError('body must be an object')
start = contract.parse_date(body.get('start_date'), 'start_date') if body.get('start_date') else None
if start is None:
raise OnDemandInputError('missing required field: start_date', 'start_date')
end = contract.parse_date(body.get('end_date'), 'end_date') if body.get('end_date') else None
if end is None:
raise OnDemandInputError('missing required field: end_date', 'end_date')
if end < start:
raise OnDemandInputError('end_date must be on or after start_date', 'end_date')
if (end - start).days + 1 > MAX_WINDOW_DAYS:
raise OnDemandInputError(f'window must be at most {MAX_WINDOW_DAYS} days', 'end_date')
events = body.get('events')
if not isinstance(events, list) or not events:
raise OnDemandInputError('missing required field: events', 'events')
unknown = [item for item in events if item not in EVENT_TYPES]
if unknown:
raise OnDemandInputError(f'unknown event type: {unknown[0]}', 'events')
planets = body.get('planets')
if planets is None:
planets = list(PLANETS)
if not isinstance(planets, list) or not planets:
raise OnDemandInputError('planets must be a non-empty list', 'planets')
bad = [item for item in planets if item not in PLANETS]
if bad:
raise OnDemandInputError(f'unknown planet: {bad[0]}', 'planets')
target = body.get('target')
if 'target_longitude' in events:
if not isinstance(target, dict):
raise OnDemandInputError('missing required field: target', 'target')
if str(target.get('planet')) not in PLANETS:
raise OnDemandInputError('target.planet must be one of the nine planets', 'target')
has_longitude = target.get('longitude') is not None
has_point = target.get('natal_point') is not None
if not has_longitude and not has_point:
raise OnDemandInputError('target needs longitude or natal_point', 'target')
if has_longitude:
try:
value = float(target['longitude'])
except (TypeError, ValueError) as error:
raise OnDemandInputError('target.longitude must be a number', 'target') from error
if not 0.0 <= value < 360.0:
raise OnDemandInputError('target.longitude must be 0–360', 'target')
if has_point and not isinstance(body.get('natal'), dict):
raise OnDemandInputError('target.natal_point needs natal.points', 'natal')
return {
'start': start,
'end': end,
'planets': list(dict.fromkeys(planets)),
'events': list(dict.fromkeys(events)),
'target': target,
}
def _resolve_target(body: Dict[str, Any], inputs: Dict[str, Any]) -> Tuple[Optional[float], Optional[Dict[str, str]]]:
target = inputs.get('target') or {}
if 'target_longitude' not in inputs['events']:
return None, None
if target.get('longitude') is not None:
return float(target['longitude']), None
name = str(target.get('natal_point'))
points = (body.get('natal') or {}).get('points') if isinstance(body.get('natal'), dict) else None
if not isinstance(points, dict) or name not in points:
return None, {'field': 'target.natal_point', 'reason': f'natal_point_missing:{name}'}
return float(points[name]), None
def run(body: Dict[str, Any]) -> Dict[str, Any]:
try:
inputs = _validate(body)
common = contract.validate_common({**body, 'date': body['start_date']})
profile = contract.resolve_profile({**body, 'date': body['start_date']}, common)
target_longitude, target_gap = _resolve_target(body, inputs)
except OnDemandInputError as error:
return contract.blocked_envelope(tool=TOOL, body=body, error=error)
requested = dict(body)
if target_gap is not None:
return contract.build_envelope(
tool=TOOL, requested=requested, profile=profile, status='blocked', raw={},
gaps=[target_gap],
)
if profile['position_mode'] not in contract.SUPPORTED_POSITION_MODES:
return contract.build_envelope(
tool=TOOL, requested=requested, profile=profile, status='blocked', raw={},
gaps=[{'field': 'position_mode', 'reason': 'position_mode_unsupported'}],
)
tz_fixed = profile['tz'] if not profile.get('timezone_id') else None
timezone_id = profile.get('timezone_id')
try:
engine = _engine()
sky = _Sky(engine, profile, inputs['planets'])
except Exception as error: # noqa: BLE001 - 星历不可用也只留缺口
return contract.build_envelope(
tool=TOOL, requested=requested, profile=profile, status='blocked', raw={},
gaps=[{'field': 'calculation', 'reason': f'engine_failed:{type(error).__name__}'}],
)
start_jd = _local_midnight_jd(inputs['start'], profile['tz'])
end_day = inputs['end'] + timedelta(days=1)
# 验收修正(Claude 2026-11-09):区间终点按终点那天的偏移换算(原来用起始日偏移,跨夏令时差 1 小时)。
end_offset = contract.offset_hours(end_day, None, timezone_id) if timezone_id else profile['tz']
end_jd = _local_midnight_jd(end_day, end_offset)
gaps: List[Dict[str, str]] = []
events: List[Dict[str, Any]] = []
loops: List[Dict[str, Any]] = []
try:
for planet in inputs['planets']:
if 'ingress' in inputs['events']:
events.extend(_ingress_events(engine, sky, planet, start_jd, end_jd, tz_fixed, timezone_id))
if 'station' in inputs['events'] or 'retrograde_loop' in inputs['events']:
if planet in ('Rahu', 'Ketu'):
if 'station' in inputs['events'] or 'retrograde_loop' in inputs['events']:
gaps.append({'field': 'planets', 'reason': f'node_station_not_supported:{planet}'})
elif planet in STATION_PLANETS:
if 'station' in inputs['events']:
events.extend(_station_events(engine, sky, planet, start_jd, end_jd, tz_fixed, timezone_id))
if 'retrograde_loop' in inputs['events']:
loops.extend(_retrograde_loops(engine, sky, planet, start_jd, end_jd, tz_fixed, timezone_id, gaps))
if 'target_longitude' in inputs['events'] and target_longitude is not None and planet == str((inputs['target'] or {}).get('planet')):
events.extend(_target_events(engine, sky, planet, target_longitude, start_jd, end_jd, tz_fixed, timezone_id))
except Exception as error: # noqa: BLE001 - 失败只留缺口,不编任何日期
return contract.build_envelope(
tool=TOOL, requested=requested, profile=profile, status='blocked', raw={},
gaps=[{'field': 'calculation', 'reason': f'engine_failed:{type(error).__name__}'}],
)
events.sort(key=lambda item: item['jd_ut'])
for loop in loops:
for key in ('station_retrograde', 'station_direct'):
stamp = loop.get(key) or {}
loop[f'{key}_outside_window'] = not (start_jd <= float(stamp.get('jd_ut', 0.0)) <= end_jd)
# 验收修正(Claude 2026-11-09):任务书要求落在请求区间外的每个时刻自己标 outside_requested_window。
for key in ('station_retrograde', 'station_direct', 'shadow_entry', 'shadow_exit'):
stamp = loop.get(key)
if isinstance(stamp, dict):
stamp['outside_requested_window'] = not (start_jd <= float(stamp['jd_ut']) <= end_jd)
raw = {
'start_date': inputs['start'].strftime('%Y-%m-%d'),
'end_date': inputs['end'].strftime('%Y-%m-%d'),
'ayanamsa_degrees_at_start': round(sky.ayanamsa(start_jd), 6),
'precision': {
'coarse_step_hours': {planet: COARSE_STEP_HOURS.get(planet, COARSE_STEP_HOURS['default']) for planet in inputs['planets']},
'convergence_minutes': CONVERGENCE_MINUTES,
'search_margin_days': SEARCH_MARGIN_DAYS,
},
'events': events,
'retrograde_loops': loops,
}
status = 'partial' if gaps else 'executed'
return contract.build_envelope(
tool=TOOL, requested=requested, profile=profile, status=status, raw=raw, gaps=gaps,
)
@@ -0,0 +1,292 @@
"""按需计算(二):一段时间内的行运变化(#174,BUG-1817)。
换座 / 停滞 / 逆行往返 / 目标经度,时刻二分到 ≤1 分钟,口径显式。
只用公开日期与合成坐标,不含任何个人资料。
"""
from __future__ import annotations
import sys
from datetime import datetime
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
for path in (str(ROOT), str(ROOT / 'scripts')):
if path not in sys.path:
sys.path.insert(0, path)
from scripts.ondemand_calc import ondemand_calc_api # noqa: E402
from scripts.ondemand_calc import transit_window # noqa: E402
BASE = {
'tool': 'transit_window',
'start_date': '2026-01-01',
'end_date': '2026-03-31',
'lat': 0.0,
'lon': 0.0,
'tz': 0.0,
'ayanamsa': 'lahiri',
'node_mode': 'mean',
'planets': ['Mercury'],
'events': ['ingress', 'station', 'retrograde_loop'],
}
def _body(**overrides):
body = dict(BASE)
body.update(overrides)
return body
def _jd(iso: str) -> float:
import swisseph as swe
moment = datetime.strptime(iso, '%Y-%m-%dT%H:%MZ')
return swe.julday(moment.year, moment.month, moment.day, moment.hour + moment.minute / 60.0)
def _reference_stations(planet: str, ayanamsa: str, start_iso: str, end_iso: str):
"""独立对照:直接用 SwissEph 的恒星黄经速度二分过零点。"""
import swisseph as swe
from ayanamsa_utils import apply_ayanamsa, sidereal_flags
apply_ayanamsa(ayanamsa)
flags = sidereal_flags(swe, ayanamsa) | getattr(swe, 'FLG_SPEED', 256)
planet_id = {'Mercury': swe.MERCURY, 'Venus': swe.VENUS, 'Mars': swe.MARS}[planet]
def speed(jd: float) -> float:
values, _ = swe.calc_ut(jd, planet_id, flags)
return values[3]
stations = []
jd = _jd(start_iso)
end = _jd(end_iso)
step = 0.5
before = speed(jd)
while jd < end:
nxt = min(jd + step, end)
after = speed(nxt)
if (before < 0) != (after < 0):
lo, hi = jd, nxt
while hi - lo > 1.0 / 1440.0:
middle = (lo + hi) / 2.0
if (speed(middle) < 0) == (before < 0):
lo = middle
else:
hi = middle
stations.append((lo + hi) / 2.0)
before, jd = after, nxt
return stations
def test_mercury_retrograde_stations_match_a_direct_swisseph_bisection() -> None:
envelope = ondemand_calc_api(_body())
assert envelope['calculation_status'] == 'executed'
stations = [event for event in envelope['raw']['events'] if event['type'] == 'station']
assert len(stations) == 2, stations
reference = _reference_stations('Mercury', 'lahiri', '2026-02-01T00:00Z', '2026-04-15T00:00Z')
assert len(reference) == 2
for event, expected in zip(stations, reference):
delta_minutes = abs(event['jd_ut'] - expected) * 24 * 60
assert delta_minutes <= 2.0, (event['utc'], delta_minutes)
def test_target_longitude_lists_all_three_passes_with_directions() -> None:
# 三次到齐要看到 4 月:第二次逆行、第三次要等顺行恢复(2026-04-01)。
envelope = ondemand_calc_api(_body(
end_date='2026-04-30',
events=['target_longitude'],
target={'planet': 'Mercury', 'longitude': 320.0},
))
events = [event for event in envelope['raw']['events'] if event['type'] == 'target_longitude']
assert len(events) == 3, events
assert [event['direction'] for event in events] == ['direct', 'retrograde', 'direct']
assert all(abs(event['longitude'] - 320.0) <= 0.01 for event in events)
def test_retrograde_loop_shadow_ordering_and_span() -> None:
envelope = ondemand_calc_api(_body())
loops = envelope['raw']['retrograde_loops']
assert len(loops) == 1
loop = loops[0]
assert loop['shadow_entry']['jd_ut'] < loop['station_retrograde']['jd_ut']
assert loop['shadow_exit']['jd_ut'] > loop['station_direct']['jd_ut']
low, high = loop['retrograde_span']
assert low < high
def test_ingress_times_differ_between_ayanamsas_and_echo_the_profile() -> None:
lahiri = ondemand_calc_api(_body(events=['ingress']))
raman = ondemand_calc_api(_body(events=['ingress'], ayanamsa='raman'))
assert lahiri['profile']['ayanamsa'] == 'lahiri'
assert raman['profile']['ayanamsa'] == 'raman'
lahiri_times = [event['utc'] for event in lahiri['raw']['events'] if event['type'] == 'ingress']
raman_times = [event['utc'] for event in raman['raw']['events'] if event['type'] == 'ingress']
assert lahiri_times and raman_times
assert lahiri_times[0] != raman_times[0]
def test_nodes_follow_the_node_mode() -> None:
# 罗睺约 19°/年,三个月里不会换座,所以用一整年。
year = {'start_date': '2026-01-01', 'end_date': '2026-12-31'}
mean = ondemand_calc_api(_body(planets=['Rahu'], events=['ingress'], node_mode='mean', **year))
true = ondemand_calc_api(_body(planets=['Rahu'], events=['ingress'], node_mode='true', **year))
mean_times = [event['utc'] for event in mean['raw']['events']]
true_times = [event['utc'] for event in true['raw']['events']]
assert mean_times and true_times
assert mean_times[0] != true_times[0]
def test_timezone_id_offset_follows_daylight_saving_inside_the_window() -> None:
envelope = ondemand_calc_api({
'tool': 'transit_window',
'start_date': '2026-03-27',
'end_date': '2026-03-30',
'lat': 51.5074,
'lon': -0.1278,
'timezone_id': 'Europe/London',
'ayanamsa': 'lahiri',
'planets': ['Sun'],
'events': ['ingress'],
})
assert envelope['calculation_status'] in {'executed', 'partial'}
offsets = {event['utc_offset_hours'] for event in envelope['raw']['events']}
assert offsets <= {0.0, 1.0}
assert envelope['raw']['precision']['convergence_minutes'] == 1
def test_longitude_at_an_ingress_agrees_with_the_single_date_tool() -> None:
window = ondemand_calc_api(_body(events=['ingress']))
ingress = next(event for event in window['raw']['events'] if event['type'] == 'ingress')
single = ondemand_calc_api({
'tool': 'transit_date',
'date': ingress['utc'][:10],
'time': ingress['utc'][11:16],
'lat': 0.0, 'lon': 0.0, 'tz': 0.0,
'ayanamsa': 'lahiri', 'node_mode': 'mean',
})
row = next(item for item in single['raw']['planets'] if item['planet'] == ingress['planet'])
boundary = round(row['longitude'] / 30.0) * 30.0
assert abs(row['longitude'] - boundary) <= 0.01
def test_missing_inputs_are_blocked_and_named() -> None:
cases = [
({'start_date': None}, 'start_date'),
({'end_date': None}, 'end_date'),
({'lat': None}, 'lat'),
({'tz': None}, 'tz'),
({'ayanamsa': None}, 'ayanamsa'),
({'events': None}, 'events'),
]
for change, field in cases:
envelope = ondemand_calc_api(_body(**change))
assert envelope['calculation_status'] == 'blocked', change
assert envelope['raw'] == {}
assert envelope['gaps'][0]['field'] == field, change
def test_window_limits_and_unknown_values_are_blocked() -> None:
too_long = ondemand_calc_api(_body(start_date='2026-01-01', end_date='2027-03-01'))
assert too_long['calculation_status'] == 'blocked'
assert too_long['gaps'][0]['field'] == 'end_date'
reversed_ = ondemand_calc_api(_body(start_date='2026-03-01', end_date='2026-01-01'))
assert reversed_['calculation_status'] == 'blocked'
unknown_event = ondemand_calc_api(_body(events=['not_an_event']))
assert unknown_event['calculation_status'] == 'blocked'
assert unknown_event['gaps'][0]['field'] == 'events'
unknown_planet = ondemand_calc_api(_body(planets=['NotAPlanet']))
assert unknown_planet['calculation_status'] == 'blocked'
assert unknown_planet['gaps'][0]['field'] == 'planets'
def test_mean_position_mode_is_blocked_with_the_named_gap() -> None:
envelope = ondemand_calc_api(_body(position_mode='mean'))
assert envelope['calculation_status'] == 'blocked'
assert envelope['gaps'] == [{'field': 'position_mode', 'reason': 'position_mode_unsupported'}]
def test_profile_source_marks_product_defaults() -> None:
default = ondemand_calc_api(_body())
assert default['profile']['profile_source'] == 'product_default_explicit'
explicit = ondemand_calc_api(_body(node_mode='mean', position_mode='apparent'))
assert explicit['profile']['profile_source'] == 'caller_supplied'
def test_fingerprint_is_stable_and_sensitive() -> None:
first = ondemand_calc_api(_body())
assert ondemand_calc_api(_body())['input_fingerprint'] == first['input_fingerprint']
seen = {first['input_fingerprint']}
for change in (
{'start_date': '2026-01-02'}, {'end_date': '2026-03-30'}, {'lat': 10.0}, {'ayanamsa': 'raman'},
{'node_mode': 'true'}, {'planets': ['Venus']}, {'events': ['ingress']},
):
envelope = ondemand_calc_api(_body(**change))
assert envelope['input_fingerprint'] not in seen, change
seen.add(envelope['input_fingerprint'])
def test_engine_failure_is_blocked_without_an_invented_date(monkeypatch) -> None:
def boom(*args, **kwargs):
raise RuntimeError('ephemeris broken')
monkeypatch.setattr(transit_window, '_Sky', lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError('broken')))
envelope = ondemand_calc_api(_body())
assert envelope['calculation_status'] == 'blocked'
assert envelope['raw'] == {}
assert envelope['gaps'][0]['field'] == 'calculation'
assert '2026-01-01' not in repr(envelope['raw'])
def test_nodes_do_not_get_station_events() -> None:
envelope = ondemand_calc_api(_body(planets=['Rahu'], events=['station']))
assert envelope['calculation_status'] == 'partial'
assert envelope['raw']['events'] == []
assert any('node_station_not_supported' in gap['reason'] for gap in envelope['gaps'])
def test_missing_natal_point_is_blocked_and_named() -> None:
envelope = ondemand_calc_api(_body(
events=['target_longitude'],
target={'planet': 'Mercury', 'natal_point': 'Moon'},
natal={'points': {'Sun': 10.0}},
))
assert envelope['calculation_status'] == 'blocked'
assert envelope['gaps'][0] == {'field': 'target.natal_point', 'reason': 'natal_point_missing:Moon'}
def test_registry_dispatches_to_the_new_tool() -> None:
envelope = ondemand_calc_api(_body(planets=['Sun'], events=['ingress'], start_date='2026-01-01', end_date='2026-01-05'))
assert envelope['tool'] == 'transit_window'
assert envelope['schema'] == 'jyotish.ondemand_calc.v1'
# 验收补测(Claude 2026-11-09):换座的 to_sign / direction,以及区间外时刻的逐项标记。
def test_ingress_names_the_new_sign_and_direction_across_zero_aries() -> None:
result = transit_window.run(_body(start_date='2026-04-01', end_date='2026-04-30', planets=['Sun'], events=['ingress']))
assert result['calculation_status'] == 'executed'
[event] = result['raw']['events']
assert (event['from_sign'], event['to_sign'], event['direction']) == ('Pisces', 'Aries', 'direct')
def test_retrograde_ingress_back_into_the_previous_sign_is_named() -> None:
result = transit_window.run(_body(start_date='2026-01-01', end_date='2026-12-31', events=['ingress']))
events = result['raw']['events']
assert events, result
signs = list(__import__('jyotish_engine').SIGNS)
for event in events:
step = 1 if event['direction'] == 'direct' else -1
assert signs.index(event['to_sign']) == (signs.index(event['from_sign']) + step) % 12, event
assert any(event['direction'] == 'retrograde' for event in events)
def test_each_time_outside_the_window_is_marked() -> None:
result = transit_window.run(_body(start_date='2026-01-01', end_date='2026-06-30', events=['retrograde_loop']))
loops = result['raw']['retrograde_loops']
assert len(loops) == 2
june = loops[1]
assert june['station_retrograde']['outside_requested_window'] is False
assert june['station_direct']['outside_requested_window'] is True
assert june['shadow_exit']['outside_requested_window'] is True
assert loops[0]['shadow_entry']['outside_requested_window'] is False