Compare commits

..
Author SHA1 Message Date
Claude 7e895b75ad Merge remote-tracking branch 'origin/staging' into HEAD
Independent Staging Quality Gate / publish (push) Blocked by required conditions
Independent Staging Quality Gate / validate-python (push) In progress
Independent Staging Quality Gate / validate-frontend (push) In progress
# Conflicts:
#	BLOCKED.md
#	CHANGELOG.md
#	docs/BUG_HISTORY.md
#	docs/research/pre_work_error_ledger.md
#	docs/tasks/README.md
2026-11-13 15:42:34 +08:00
ClaudeandClaude Opus 5.5 d136b76a46 Merge #241 chart-choice event questions and evidence decision (BUG-1875)
Independent Staging Quality Gate / validate-frontend (push) Successful in 5m12s
Independent Staging Quality Gate / validate-python (push) Successful in 6m43s
Independent Staging Quality Gate / publish (push) Successful in 3m15s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXozsiu43ob7B8UZCmDM
2026-11-13 14:55:08 +08:00
jesse-ux 831e6708a8 Record #211 validation and remaining environment limits
Independent Staging Quality Gate / validate-python (pull_request) Failing after 6m56s
Independent Staging Quality Gate / validate-frontend (pull_request) Failing after 9m36s
Independent Staging Quality Gate / publish (pull_request) Skipped
2026-11-13 11:21:37 +08:00
jesse-ux 079f3d06e3 Merge remote-tracking branch 'origin/staging' into codex/annual-tithi-yogas-20261112
# Conflicts:
#	CHANGELOG.md
#	docs/BUG_HISTORY.md
#	docs/tasks/README.md
2026-11-13 10:55:38 +08:00
jesse-ux b9343d7cc5 Add annual Tithi Pravesha residence and complete yoga evidence (#211) 2026-11-13 10:55:17 +08:00
ClaudeandClaude Opus 5.5 67045a12a9 docs(task): brief for annual Tithi Pravesh residence and natal yoga status words (#211, BUG-1857, 2026-11-12)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyn7Hj3LCW3BUs5bZ8
2026-11-13 10:28:02 +08:00
13 changed files with 325 additions and 21 deletions
+4
View File
@@ -10,6 +10,10 @@
- Docker Desktop 的 Linux 引擎返回 HTTP 500,容器用例无法在本机验证。目录预检扫描超时。生产模型对两张公开盘的实跑留给独立验收。
- 替代证据:假时钟、报告快照和离线回放通过,前端用同配置与 staging 对比。详见 [#240 进度](docs/tasks/PROGRESS-reading-timeout-20261113.md)。
## #211 · 年运验证及附录地点文案
- Windows 全量的年运 5 项和快速门 3 项失败在同机 staging 复现。Docker Desktop 报 500,前端改用无 Docker 的同机基线对照。所有名称和失败项一致,详情见 [#211 进度](docs/tasks/PROGRESS-annual-tithi-yogas-20261112.md)。
- 年运附录的固定文字仍将 Tithi Pravesha 描述为仅按出生地。任务书把前端例外限定在本命瑜伽材料组装处,地点文案修订待授权。
## #237 · Windows 全量门禁与预检
+3
View File
@@ -11,6 +11,9 @@
## 2026-11-13 — 选盘可分轮核对并保存到秒
- 新版选盘接口按关键换盘线逐处确认,最多三轮。仍会换座的分盘列为未定。短区间可保存段内秒级时刻,历史记录可读回。沿用当前 Skill 版本(BUG-1872)。
## 2026-11-13 — 年运独立计算加入现居地版本
- Tithi Pravesha 有现居地时同时给出出生地和现居地版,缺少现居地时显示说明。年运写作材料中的本命瑜伽携带各自的核对状态。沿用当前 Skill 版本(BUG-1857)。
## 2026-11-13 — 个人报告按区间内部的换座核对证据
+12
View File
@@ -25282,3 +25282,15 @@
- 验证及防复发:覆盖有与无可区分时段、D1 问题范围、日期跨轮、平分兜底、候选段内评分和三件事件门槛。见 [#241 进度](tasks/PROGRESS-chart-choice-questions-20261113.md)。
- 关联记录:#229、#238、#241、BUG-405、BUG-1703、BUG-1759、BUG-1872。
- 修复版本:`codex/chart-choice-questions-20261113`,待发布。
## BUG-1857 | 年运 Tithi Pravesha 缺现居地,本命瑜伽证据截断
- 状态:fixed-pending-verify(待独立验收)。
- 首次发现 / 最近更新:2026-11-12 / 2026-11-13。
- 影响面:年运计算包的 Tithi Pravesha 及本命段写作材料。
- 现象:填写现居地后,年运仍只有出生地版的 Tithi Pravesha。本命瑜伽超过八条时,后续瑜伽没有进入写作材料,已列行也缺少状态。
- 触发条件:年运选用现居城市,或原生瑜伽检测返回八条以上。
- 根因:Tithi Pravesha 原生模块只用出生地起盘。年运瑜伽投影在排序后截取八条,丢掉状态字段。
- 修复:沿用年盘的现居地输入,用同一个回归时刻起两张盘,缺少现居地时只保留出生地版。瑜伽保留原排序,逐行调用共享状态词映射并传到写作端。
- 验证及防复发:合成输入核对两地、出生地回退和上游数值。公开名人盘从引擎返回到写作入口逐条核对名称、数量和状态词。
- 关联记录:#211、BUG-1856、BUG-1839。详见 [执行进度](tasks/PROGRESS-annual-tithi-yogas-20261112.md)。
- 修复版本:`codex/annual-tithi-yogas-20261112`,待发布。
+4
View File
@@ -5,6 +5,10 @@ Purpose: read this file before substantial project work. It exists to stop repea
## 2026-11-13 · #240 Windows 报告预检
- 本轮复现目录扫描 90 秒超时。延长预检外层时限后,预检测试中的独立目录扫描仍在 120 秒超时。Python 和适配器诊断通过,Gitea 分支用官方 API 与 Git 核对。构建遇到指向工作树外的依赖联接时,改用从同锁文件安装结果复制到本工作树的依赖。见 [#240 进度](../tasks/PROGRESS-reading-timeout-20261113.md)。
## 2026-11-13 · #211 Windows 年运验证
- 并发运行多个前端构建与完整测试时,Windows 资源不足,Docker Desktop 返回 500,基线 Turbopack 报 OS 1450。停止重叠的默认并行测试后,基线构建复跑通过。随后用相同的并行度和 Docker 可用性重做前端对照。
- 确认上游 `8aaf6099` 在本机独立 Git 检出中存在。使用随仓星历和等价的位置口径重跑上游函数,避免将星历回退和旧岁差别名造成的差异归为现居地算法差异。
## 2026-11-13 · #237 Windows 报告预检
@@ -0,0 +1,22 @@
# #211 执行进度 · 年运现居地与本命瑜伽
Codex 在 `origin/staging` 的 `e5d6c99da` 上快进到任务书提交 `67045a12a` 后实施。BUG-1857 尚未使用,按任务书编号登记。
## 计算与写作材料
- 从本地上游检出验证 `8aaf60991c32a84ac30d6d7b6a62c4f166507bce` 的原始 `scripts/tithi_pravesh.py`。用出生地和出生时刻查找回归点。现居地沿用年盘的经纬度和偏移,在同一回归点另起一盘。两地各自标记来源,缺现居地时只给出生地版,并标明「未提供现居地」。保持独立年运视角和窗口打分原有边界。
- 生产仍使用本仓的求根和星历封装。对拍用本仓随附 Swiss Ephemeris 星历,在上游选 `apparent`,与本仓 `legacy` 别名一致。上游旧 `legacy` 的岁差换算会让上升相差约 0.00231°。既有求根仍用相位跨周期排除条件,现居地的相位按上游在精确回归时刻计算。出生地候选延用原有字段和计算,增加来源与上升。上游缺现居地时可复制出生地行,产品版按任务书只返回一份。
- 固定合成输入的现居地结果与指定上游对拍一致。JD UT `2461228.2685106047`,相位 `257.1125057401731°`,现居地上升 `235.60228037337504°`,Tithi `22`。详细输入在回归测试中。
- 保留 `_yogas` 原排序。结构观察排在命名瑜伽之后,同组按既有强度及类别排列。公开盘检测返回 41 条,写作材料从 8 条增至 41 条,其中 3 条为结构观察。状态词由 `yoga_status` 提供。前端改动在年运本命节的写作材料组装处,旧事实包缺状态词时沿用原文本。没有修改已有断言。
- 年运页附录有一条既有固定地点文案。任务书允许的前端改动限本命瑜伽写作材料,本轮留待另行确认修订该文案。
## 验证
- 新增 4 项通过,覆盖两地候选、缺现居地、公开盘瑜伽行和写作入口的数量与状态词。
- 年运前端定向 36 项通过,失败和取消均为 0。TypeScript 检查与 Next 构建通过,ESLint 为 0 错误、62 条既有警告。
- 首页保持静态。对照同机 staging 构建,首页 39 个资源的 gzip-9 大小一致,JS 515,860 字节、CSS 90,906 字节。
- 年运全量 217 项通过、5 项失败。同机 staging 定向复现全部 5 项,包括两项 PyJHora 年运进程超时、两项专业参考版年运表检查及一项年盘期间连续性检查。
- Python 快速门 1,463 项通过、2 项跳过、29 项子测试通过、3 项失败。失败均为 `test_upstream_technique_layers.py`,同机 staging 逐条复现。
- 合入 #237 后,新增回归、年运审计及隐私守卫合跑 70 项通过。
- Windows 前端全量首次运行遇到 Docker Desktop 500 和系统资源不足。改用相同的并行度并在测试进程中停用 Docker 后,分支和基线各收集 3,692 项,均为 3,611 通过、45 失败、36 跳过、0 取消。TAP 的全部名称及失败名称逐项相同。
- Windows 预检的 Python、远端可见性和适配器步骤通过。目录扫描与定向测试触及命令时限,见既有 [预检台账](../research/pre_work_error_ledger.md)。
+1
View File
@@ -36,6 +36,7 @@
| [#241 修订任务书](FIX-chart-choice-questions-1.md) | [执行进度](PROGRESS-chart-choice-questions-20261113.md) | 多轮选盘事件题与判边(BUG-1875) | 待验收 | `codex/chart-choice-questions-20261113` |
| [#240 任务书](TASK-reading-timeout-20261113.md) | [执行进度](PROGRESS-reading-timeout-20261113.md) | 报告解读超时与租期内重试(BUG-1874) | 待验收 | `codex/reading-timeout-20261113` |
| [#238 任务书](TASK-chart-choice-rounds-engine-20261113.md) | [执行进度](PROGRESS-chart-choice-rounds-engine-20261113.md) | 多轮选盘与秒级采纳(BUG-1872) | 待验收 | `codex/chart-choice-rounds-engine-20261113` |
| [#211 任务书](TASK-annual-tithi-yogas-20261112.md) | [执行进度](PROGRESS-annual-tithi-yogas-20261112.md) | 年运 Tithi Pravesha 现居地与本命瑜伽状态(BUG-1857) | 待验收 | `codex/annual-tithi-yogas-20261112` |
| [#237 任务书](TASK-reading-window-edge-20261113.md) | [执行进度](PROGRESS-reading-window-edge-20261113.md) | 个人报告区间端点换座判定(BUG-1871) | 待验收 | `codex/reading-window-edge-20261113` |
| [#220 修订任务书](FIX-reading-snapshot-binding-1.md) | [执行进度](PROGRESS-reading-snapshot-binding-20261112.md) | 个人报告生成快照、逐节拦截原因和离线验收包(BUG-1863) | 待验收 | `codex/reading-snapshot-binding-20261112` |
@@ -0,0 +1,91 @@
# TASK:年运报告的 Tithi Pravesh 按现居地计算,本命瑜伽加状态词并取消 8 条上限(2026-11-12)
- Gitea:#211
- 分支:`codex/annual-tithi-yogas-20261112`,从 staging `e5d6c99d`(#210 已合入)切出。
- Bug 编号:BUG-1857。开工时核对 `docs/BUG_HISTORY.md` 末尾;若已被占用,顺延,并在 PROGRESS 写明。
- 负责验收:Claude(jyotisha-de)
不得自行写“产品决定/裁定”;任务书没写的取舍一律选需拍板。
不得修改或删除既有断言来让测试通过;确需修改必须写原值/新值/原因,并在 PROGRESS 里逐条列出。
## 背景
### 1. Tithi Pravesh
- 占星师的 `scripts/tithi_pravesh.py` 第 46–60 行,在她仓库的提交 `8aaf60991c32a84ac30d6d7b6a62c4f166507bce`,仓库地址 `https://github.com/732642856/yinduzhanxing`:同时用出生地和现居地计算 Tithi Pravesh。
- 我们的 `scripts/annual_native/tithi_pravesh.py` 约第 42–44 行:只用出生地。
- 年运报告已经有现居地字段,即年盘返照地点的那一个。用户没填现居地时,退回出生地,并标明是退回的。
- 退回的逻辑见 `scripts/annual_service/package.py` 约 :58–60、:760–827,以及 `frontend/src/lib/annual-report-generate.ts` 约 :164–167。
### 2. 本命瑜伽
`scripts/annual_service/report_facts.py` 约 :152、:469–493 有两个问题:
- 只取前 8 条瑜伽;
- 不带状态词。
这违反两条裁定:
- 她第七轮的瑜伽状态映射:检测命中不等于成立,状态词用产品里已有的映射,见 `scripts/report_render/yoga_status_words.py`(#189)。
- 第五轮裁定 6:白话可以减少术语,不能减少信息。
## 要做的
1. **Tithi Pravesh:照搬她的计算。**
- 把她 `tithi_pravesh.py` 46–60 行的现居地计算方法,搬进我们的 `scripts/annual_native/tithi_pravesh.py`。
- 同时输出出生地版和现居地版两份结果,各自注明地点来源。
- 用户没填现居地时,只输出出生地版,并写明“未提供现居地”。
- 不改她的公式。算法和她的有任何不同,都列进 PROGRESS。
- Tithi Pravesh 仍不参与窗口打分,这是产品现有规则,保持不变。
2. **本命瑜伽。**
- 去掉 8 条上限,送给模型的证据里列出全部瑜伽。
- 每条带上状态词,用 `scripts/report_render/yoga_status_words.py` 的映射,不另造词。
- 排序沿用第五轮裁定里的瑜伽优先级。代码里如果已有排序函数,就复用;没有就选需拍板。
3. **页面。** 年运报告页面上瑜伽的显示,本单只做到“数据完整”:
- 页面原来显示几条,现在照样显示几条;
- 显示的那几条换成带状态词的版本;
- 显示条数的改动另开单,等原型图。
## 要改的文件
- `scripts/annual_native/tithi_pravesh.py`
- `scripts/annual_service/report_facts.py`:只改瑜伽那一段。
- `scripts/annual_service/package.py`:只在需要把现居地传给 Tithi Pravesh 时改。
- 新建 `tests/test_annual_tithi_yogas_20261112.py`
- `docs/BUG_HISTORY.md`:只在末尾追加一条。
- `docs/tasks/PROGRESS-annual-tithi-yogas-20261112.md`
## 不要碰
- 冻结文件(`scripts/yoga_engine.py` 只许 import),以及 `references/**`。
- `report_facts.py` 的审计表和 Narayana 部分。
- `frontend/**`。唯一例外:如果给模型的证据组装在前端,只改瑜伽那一段,并在 PROGRESS 写明。
## 验收标准(写进新测试)
1. 给出现居地时,Tithi Pravesh 输出两份结果,地点来源各自正确;不给时,只有出生地版,并带“未提供现居地”标记。
2. 用一个固定的合成输入,现居地版的结果与她 `tithi_pravesh.py` 同一输入的结果一致。把她的函数作为参照,算一次并把数值写进测试,不 import 她的仓库。
3. 一张瑜伽多于 8 条的公开名人盘,给模型的瑜伽条数等于引擎返回的条数,而且每条都有状态词。
4. 现有年运测试全部通过。
## 自测命令
```
python -m pytest -q tests/test_annual_tithi_yogas_20261112.py tests/ -k "annual or tithi"
python scripts/run_quality_gate.py --profile quick --skip-frontend-runtime
```
## 进度记录
写在 `docs/tasks/PROGRESS-annual-tithi-yogas-20261112.md`,内容包括:
- 和她算法的差异;
- 参照数值;
- 瑜伽条数改动前后的对比;
- 测试结果,以及在 staging 上也复现的基线失败。
## 何时停下来选“需拍板”
- 她的现居地算法依赖我们没有的输入。
- 瑜伽优先级排序在代码里找不到现成实现。
+1
View File
@@ -1394,6 +1394,7 @@ Yogini 还在取时,右栏用和 Chara 相同的轨道等待(`OrbitWaiting`
## 年运报告与开始遮罩(2026-11-01,issue #153 / #154)
- **本命瑜伽(BUG-1857):** 年运写作材料中的瑜伽按原有顺序提供完整清单及状态词,沿用本命段落的版式和篇幅。
- **年运附录(BUG-1855):** Narayana 标为条件参考。种子未决时在口径说明中列出候选数,每支单独一行。
- **入口**:「我的报告」最上面两个横条并排(手机上下排,2026-11-03 方案 A,见 Personal report centre):「年运」横条是 `Sun` 图标方块、标题、一行「这一年每段怎么安排,几个要留意的时间点」,右边描边按钮「打开年运」(手机「打开」),直接打开 `/reports/annual`。没有单独的入口页。个人报告横条上不写份数。
- **地址**:`/reports/annual`(`?report=<id>` 打开某一份),放在 `/reports` 下,侧栏仍亮「我的报告」。沿用二级页外壳,标题「年运」,标题旁是人物切换。正文一栏 `min(720px, 100%)` 居中。
+8 -2
View File
@@ -419,9 +419,15 @@ function natalEvidence(facts: unknown): Row {
// 新值:structural_observation 行单列在 结构观察,不进 格局
// 原因:产品 2026-11-07 同步占星师 Yoga 规则批次;结构观察只记录宫主关联、位置或尊贵,不是已成立的格局。
格局: yogas.filter((yoga) => yoga.claim_kind !== "structural_observation")
.map((yoga) => `${str(yoga.name_cn) || str(yoga.name)}:${str(yoga.combination)}`),
.map((yoga) => {
const state = str(yoga.state_text);
return `${str(yoga.name_cn) || str(yoga.name)}:${str(yoga.combination)}${state ? `(${state})` : ""}`;
}),
结构观察: yogas.filter((yoga) => yoga.claim_kind === "structural_observation")
.map((yoga) => `${str(yoga.name_cn) || str(yoga.name)}:${str(yoga.combination)}(不单独构成 Yoga,不据此推断结果)`),
.map((yoga) => {
const state = str(yoga.state_text);
return `${str(yoga.name_cn) || str(yoga.name)}:${str(yoga.combination)}(${state ? `${state},` : ""}不单独构成 Yoga,不据此推断结果)`;
}),
};
}
+30 -4
View File
@@ -10,8 +10,10 @@ from typing import Any
try:
from annual_native.sidereal_bodies import sun_moon_longitudes
from ayanamsa_utils import configure_ephemeris_path
except ImportError: # pragma: no cover
from scripts.annual_native.sidereal_bodies import sun_moon_longitudes
from scripts.ayanamsa_utils import configure_ephemeris_path
SCHEMA = "jyotish.tithi_pravesh.v1"
_NAMES = (
@@ -22,6 +24,9 @@ _NAMES = (
def build_tithi_pravesh(payload: dict[str, Any]) -> dict[str, Any]:
import swisseph as swe
configure_ephemeris_path(swe)
birth = dict(payload["birth"])
settings = dict(payload.get("settings") or {})
target_year = int(payload["target_year"])
@@ -41,8 +46,22 @@ def build_tithi_pravesh(payload: dict[str, Any]) -> dict[str, Any]:
)
birth_place = _place(birth, tz)
birth_rows = [
_chart_row(jd, birth_place, node_mode, target_phase, ayanamsa) for jd in candidate_jds
_chart_row(jd, birth_place, node_mode, target_phase, ayanamsa, location_source="birth_place")
for jd in candidate_jds
]
# The upstream location calculation at 8aaf6099 casts the same roots at
# the residence's coordinates and offset. The report retains its natal-only
# fallback when the residence is absent.
current_location = settings.get("current_location")
current_rows = []
current_location_reason = "current_location_missing"
if isinstance(current_location, dict) and current_location:
residence = _place(current_location, 0.0)
current_rows = [
_chart_row(jd, residence, node_mode, target_phase, ayanamsa, location_source="current_location")
for jd in candidate_jds
]
current_location_reason = None if current_rows else "no_tithi_pravesh_candidate"
return {
"schema": SCHEMA,
"status": "partial_verified" if birth_rows else "blocked",
@@ -52,6 +71,8 @@ def build_tithi_pravesh(payload: dict[str, Any]) -> dict[str, Any]:
"natal_sun_longitude": birth_sun,
"natal_moon_longitude": birth_moon,
"rows": birth_rows,
"current_location_rows": current_rows,
"current_location_reason": current_location_reason,
}
@@ -101,7 +122,7 @@ def _phase_delta(jd, target_phase, ayanamsa, node_mode) -> float:
return ((moon - sun - target_phase + 180.0) % 360.0) - 180.0
def _chart_row(jd, place, node_mode, target_phase, ayanamsa) -> dict[str, Any]:
def _chart_row(jd, place, node_mode, target_phase, ayanamsa, *, location_source: str) -> dict[str, Any]:
try:
from jyotish_engine import compute_chart_data
except ImportError:
@@ -117,17 +138,22 @@ def _chart_row(jd, place, node_mode, target_phase, ayanamsa) -> dict[str, Any]:
planets = chart.get("planets") if isinstance(chart.get("planets"), dict) else {}
sun = float(planets["Sun"]["degree_raw"]) % 360.0
moon = float(planets["Moon"]["degree_raw"]) % 360.0
if location_source == "current_location":
sun, moon = sun_moon_longitudes(jd, node_mode=node_mode, ayanamsa_name=ayanamsa)
reported_planets = planets if location_source == "current_location" else ("Sun", "Moon")
phase = (moon - sun) % 360.0
return {
"jd_ut": jd,
"local_datetime": local.strftime("%Y-%m-%d %H:%M:%S"),
"location_source": location_source,
"ascendant": chart.get("ascendant"),
"tithi": _tithi_payload(sun, moon),
"phase_degrees": phase,
"phase_delta_degrees": ((phase - target_phase + 180.0) % 360.0) - 180.0,
"planet_longitudes": {
name: planets[name].get("degree_raw")
for name in ("Sun", "Moon")
if isinstance(planets.get(name), dict)
for name in reported_planets
if isinstance(planets.get(name), dict) and planets[name].get("degree_raw") is not None
},
"status": "partial_verified",
}
+22 -7
View File
@@ -533,7 +533,7 @@ def build_annual_package(request: dict[str, Any], *, detail: str = "full") -> di
"annual_native.tithi_pravesh.build_tithi_pravesh",
reason=None if tithi else "report_range_blocked",
start_rule="Sun and Moon return to the natal Sun-Moon phase near the birthday (own start rule, not the solar return)",
location_rule="birth place, birth UTC offset (the producer's own rule; not the Tajika return-location option)",
location_rule="birth place and, if supplied, the current city used for the solar return",
years=tithi,
),
},
@@ -630,7 +630,7 @@ def _return_location_parameters(ctx: _Context) -> dict[str, Any]:
"timezone_id": location.get("timezone_id"),
"utc_offset_hours": location.get("utc_offset_hours"),
"note": "solar return, year chart, monthly charts and annual Shadbala cast for the current city; "
"natal chart, dashas and Tithi Pravesha stay at the birth place",
"natal chart and dashas stay at the birth place; Tithi Pravesha uses both locations",
}
return {
"mode": "birth_place",
@@ -1439,12 +1439,9 @@ def _av_block(natal: dict, lagna_idx: int, ingresses: list[dict], start_position
# ── independent annual view ─────────────────────────────────────────────────
def _tithi_pravesha(ctx: _Context, year: int, range_start, range_end) -> dict[str, Any]:
producer = "annual_native.tithi_pravesh.build_tithi_pravesh"
raw = _module("annual_native.tithi_pravesh").build_tithi_pravesh(ctx.pack_payload(year))
zone = timezone(timedelta(hours=ctx.tz_hours))
def _tithi_candidates(source_rows, zone, range_start, range_end) -> list[dict[str, Any]]:
rows = []
for row in raw.get("rows") or []:
for row in source_rows or []:
if not isinstance(row, dict) or not isinstance(row.get("jd_ut"), (int, float)):
continue
moment = _utc_from_jd(row["jd_ut"])
@@ -1455,15 +1452,33 @@ def _tithi_pravesha(ctx: _Context, year: int, range_start, range_end) -> dict[st
"phase_degrees": row.get("phase_degrees"),
"phase_delta_degrees": row.get("phase_delta_degrees"),
"planet_longitudes": row.get("planet_longitudes"),
"location_source": row.get("location_source"),
"ascendant": row.get("ascendant"),
})
return rows
def _tithi_pravesha(ctx: _Context, year: int, range_start, range_end) -> dict[str, Any]:
producer = "annual_native.tithi_pravesh.build_tithi_pravesh"
raw = _module("annual_native.tithi_pravesh").build_tithi_pravesh(ctx.pack_payload(year))
zone = timezone(timedelta(hours=ctx.tz_hours))
rows = _tithi_candidates(raw.get("rows"), zone, range_start, range_end)
if raw.get("status") == "blocked" or not rows:
return {"target_year": year, **block("blocked", producer, reason=str(raw.get("reason") or "no_candidate"))}
if raw.get("current_location_rows"):
current_zone = timezone(timedelta(hours=ctx.current_city_offset(year)))
residence = {"current_location_candidates": _tithi_candidates(
raw["current_location_rows"], current_zone, range_start, range_end,
)}
else:
residence = {"current_location_note": "未提供现居地"}
return {
"target_year": year,
**block("computed", producer, producer_status=raw.get("status")),
"natal_phase_degrees": raw.get("natal_phase_degrees"),
"candidate_count": len(rows),
"candidates": rows,
**residence,
"candidate_note": "every candidate the producer found is listed; none is chosen here",
}
+19 -8
View File
@@ -149,7 +149,6 @@ DOMAIN_SAHAMS = {
"learning": ("vidya_saham", "jnana_saham"),
}
YEAR_SAHAMS = ("punya_saham",)
MAX_YOGAS = 8
def _jd(boundary: Any) -> float | None:
@@ -472,6 +471,11 @@ def _varga_cue(domain: str, division: int, asc_lon: float, lons: dict[str, float
def _yogas(planets: dict, lagna_sign: str) -> dict[str, Any]:
try:
from report_render.yoga_status_words import yoga_status
except ModuleNotFoundError: # pragma: no cover - package import
from scripts.report_render.yoga_status_words import yoga_status
try:
rows = ap._module("yoga_engine").detect_yogas(planets, lagna_sign) or []
except Exception as exc: # noqa: BLE001 - a yoga failure is a blocked block
@@ -484,16 +488,23 @@ def _yogas(planets: dict, lagna_sign: str) -> dict[str, Any]:
# 新值:structural_observation 排在所有命名 Yoga 之后
# 原因:产品 2026-11-07 同步占星师 Yoga 规则批次;结构观察不是命名 Yoga,不占格局名额。
return (int(row.get("claim_kind") == "structural_observation"), strength, first)
picked = sorted((row for row in rows if isinstance(row, dict) and row.get("name")), key=rank)[:MAX_YOGAS]
ordered = sorted((row for row in rows if isinstance(row, dict) and row.get("name")), key=rank)
# Structure only: name, category and the combination. The producer's
# effect phrases are not facts the report may repeat. A structural
# observation carries its kind verbatim so the report lists it apart.
return ap.block("computed", "yoga_engine.detect_yogas", total=len(rows), rows=[
{"name": row.get("name"), "name_cn": row.get("name_cn"), "category": row.get("category"),
"combination": row.get("combination"), "strength": row.get("strength"),
**({"claim_kind": row["claim_kind"]} if row.get("claim_kind") == "structural_observation" else {})}
for row in picked
])
evidence = []
for row in ordered:
formation = row.get("formation") or row.get("formation_assessment")
state, state_text = yoga_status({**row, "formation": formation})
if not (row.get("status") or row.get("claim_status") or formation):
state, state_text = yoga_status({"status": "computed"})
evidence.append({
"name": row.get("name"), "name_cn": row.get("name_cn"), "category": row.get("category"),
"combination": row.get("combination"), "strength": row.get("strength"),
"state": state, "state_text": state_text,
**({"claim_kind": row["claim_kind"]} if row.get("claim_kind") == "structural_observation" else {}),
})
return ap.block("computed", "yoga_engine.detect_yogas", total=len(rows), rows=evidence)
def _role(functional: dict, planet: str | None) -> str:
+108
View File
@@ -0,0 +1,108 @@
"""Annual Tithi Pravesha location and natal yoga evidence (#211)."""
import json
import subprocess
from collections import Counter
from datetime import UTC, datetime
from pathlib import Path
import pytest
from scripts.annual_native.tithi_pravesh import build_tithi_pravesh
from scripts.annual_service import api, package, report_facts
from scripts.report_render.yoga_status_words import YOGA_STATUS_MAPPING
from tests.test_annual_package import _body
FRONTEND = Path(__file__).resolve().parents[1] / "frontend"
TSX = FRONTEND / "node_modules" / "tsx" / "dist" / "cli.mjs"
def _context(location=None):
return package._Context(api._request(_body(
ayanamsa="lahiri", **({"return_location": location} if location else {}),
)))
def _tithi_view(context):
return package._tithi_pravesha(context, 2026, datetime(2026, 1, 1, tzinfo=UTC), datetime(2027, 1, 1, tzinfo=UTC))
def test_annual_tithi_pravesh_casts_both_locations_at_the_same_root():
ctx = _context({"mode": "current_city", "latitude": 51.5074, "longitude": -0.1278, "timezone_id": "Europe/London"})
raw = build_tithi_pravesh(ctx.pack_payload(2026))
birth, = raw["rows"]
city, = raw["current_location_rows"]
assert birth["location_source"] == "birth_place" and city["location_source"] == "current_location"
assert birth["jd_ut"] == city["jd_ut"]
assert birth["ascendant"]["sign"] != city["ascendant"]["sign"]
# Upstream 8aaf6099, with the product's bundled Swiss Ephemeris files and
# the apparent frame matching the product engine's legacy alias.
assert city["jd_ut"] == pytest.approx(2461228.2685106047, rel=0, abs=1e-8)
assert city["local_datetime"] == "2026-07-06 19:26:39"
assert city["phase_degrees"] == pytest.approx(257.1125057401731, rel=0, abs=1e-8)
assert city["ascendant"]["degree_raw"] == pytest.approx(235.60228037337504, rel=0, abs=1e-8)
assert city["tithi"]["tithi_num"] == 22
view = _tithi_view(ctx)
assert view["candidate_count"] == 1 and "current_location_note" not in view
birth_view, = view["candidates"]
city_view, = view["current_location_candidates"]
assert birth_view["location_source"] == "birth_place" and city_view["location_source"] == "current_location"
assert birth_view["instant"]["jd_ut"] == city_view["instant"]["jd_ut"]
assert birth_view["instant"]["utc_offset"] == "+08:00" and city_view["instant"]["utc_offset"] == "+01:00"
def test_annual_tithi_pravesh_omits_the_residence_view_when_missing():
ctx = _context()
raw = build_tithi_pravesh(ctx.pack_payload(2026))
assert len(raw["rows"]) == 1 and not raw["current_location_rows"]
assert raw["current_location_reason"] == "current_location_missing"
view = _tithi_view(ctx)
assert view["candidate_count"] == 1 and "current_location_candidates" not in view
assert view["current_location_note"] == "未提供现居地"
@pytest.fixture(scope="module")
def natal_yogas():
profiles = json.loads(Path(__file__).with_name("celebrity_cases.json").read_text(encoding="utf-8"))
birth = next(row for row in profiles if row["id"] == "jobs")
chart, *_ = package._engine().compute_chart_data(
*(birth[key] for key in ("year", "month", "day", "hour", "minute", "lat", "lon", "tz")),
second=birth["second"], ayanamsa_name="lahiri",
)
planets, sign = chart["planets"], chart["ascendant"]["sign"]
detected = package._module("yoga_engine").detect_yogas(planets, sign)
return detected, report_facts._yogas(planets, sign)
def test_annual_yoga_evidence_keeps_all_detected_rows_and_canonical_status(natal_yogas):
detected, evidence = natal_yogas
assert evidence["total"] == len(evidence["rows"]) == len(detected) > 8
assert Counter(row["name"] for row in evidence["rows"]) == Counter(row["name"] for row in detected)
assert all(row["state_text"] == YOGA_STATUS_MAPPING[row["state"]]["text"] for row in evidence["rows"])
assert any(row["state"] == "candidate" for row in evidence["rows"])
@pytest.mark.skipif(not TSX.is_file(), reason="frontend dependencies are required for the writer contract")
def test_annual_model_evidence_includes_every_yoga_and_its_status(natal_yogas):
_, yogas = natal_yogas
program = """
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { sectionEvidence } from "./src/lib/annual-report-sections.ts";
import { EMPTY_ANNUAL_SITUATION } from "./src/lib/annual-report-shared.ts";
const yogas = JSON.parse(readFileSync(0, "utf8"));
const entry = { id: "person", title: "本命", kind: "person", disposition: "write" };
const { 本命 } = sectionEvidence({ natal_base: { yogas } }, entry, EMPTY_ANNUAL_SITUATION);
const observations = yogas.rows.filter((row) => row.claim_kind === "structural_observation");
const named = yogas.rows.filter((row) => row.claim_kind !== "structural_observation");
assert.equal(本命.格局.length + 本命.结构观察.length, yogas.total);
for (const [input, output] of [[named, 本命.格局], [observations, 本命.结构观察]]) {
input.forEach((row, index) => {
assert.ok(output[index].startsWith(row.name_cn || row.name));
assert.ok(output[index].includes(row.state_text));
});
}
"""
subprocess.run(["node", str(TSX), "-e", program], cwd=FRONTEND,
input=json.dumps(yogas, ensure_ascii=False).encode("utf-8"), check=True, timeout=45)