diff --git a/CHANGELOG.md b/CHANGELOG.md index f3643395..7b7a3c7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # 印度占星 Skill 更新日志 +## 2026-10-21 — 英文版不再因个别漏译整份消失 + +- 个人报告的英文版改为在源头写英文,不再因为个别没译出的字把整份英文丢掉。 +- 已经生成的报告需要重新生成,才看得到英文版。 +- Skill 版本不变(BUG-1410、BUG-1411)。 + ## 2026-10-21 — 合盘时间线写出起止,删掉的模型改用默认模型 - 关系时间线每段标题写出起止年月。第一段从本月开始时,起点写「现在」或 Now。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index f8989e4b..574c778e 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -37016,3 +37016,38 @@ - 复发自:无。 - 修复版本:分支 `codex/synastry-staging-fix-20261021`。 +## BUG-1410 | 个人报告英文版因漏译汉字整份被丢掉(BUG-1272 复发) + +- 状态:resolved(本分支回归已过;合入 staging 后按 PROGRESS 做真机复测) +- 首次发现:2026-10-21 +- 最近更新:2026-10-21 +- 影响面:个人报告英文版。约四成生成时整份英文被丢掉,页面只剩中文。 +- 用户现象:揭幕后切到 English,没有英文正文。 +- 触发条件:英文渲染里还留着汉字。Rath 起运裁到第 5 级、Panchanga 综合为凶、计都在起运宫或子运走 Table 14、月土合相不超过 10 度、完全 Kaal Sarp,都会碰上当时没有英文句子的那一行。这些情况合起来大约四成盘。 +- 根因:英文版不是按语言写出来的,而是先写出中文,再靠末尾词表逐词替换。词表没盖住的句子留下汉字,`_english_edition` 整份撤回。英文孪生字段多数已经算好,完整数据版没有用。BUG-1272 没拦住,有四点: + 1. 当时进测试的只有 3 张固定虚构盘,泄漏按盘触发,这 3 张都没碰上。 + 2. `test_reference_edition_shows_both_and_english_terms_cover_the_rath_lines` 断言的是旧阅读版词表 `PHRASES_EN`。完整数据版英文不走这张表,所以测试一直绿,生产路径却漏。 + 3. `tests/test_report_english_edition.py` 不在快速门清单里,staging 门禁不跑它。 + 4. BUG-1272 的修法是继续往末尾词表加条目。新句子或新盘况会再漏。 +- 修复:英文分支改用已有的英文孪生字段,缺的补上英文句子(Rath、Panchanga 综合性质、Dosha 摘要、瑜伽强度、Jaimini 职责、Vimsopaka 标题)。结构化附录的瑜伽表和 Dosha 摘要在英文里也改读 `combination_en`、`strength_en`、`effects_en`、`summary_en`;这些字段里还有汉字就留空,不退回中文。汉字判定和 `[source text retained]` 标记没有放宽。 +- 验证:`tests/test_report_english_han_sources.py` 5 条。`tests/test_report_english_sweep_smoke.py` 12 张覆盖第 5 级、凶、计都反向、Table 14、Punarphoo、完全 Kaal Sarp,进程池 4,199.6 秒,全绿。`tests/test_report_english_sweep.py` 200 张,进程池 4,3679 秒,全绿。把英文 Rath 行临时改回中文后,smoke 12/12 为 `han_leak`,测试变红,已改回。 +- 防复发:smoke 在快速门 `CORE_PYTEST_TARGETS` 里。200 张不进快速门。不要再只靠末尾词表补洞。 +- 相关记录:BUG-1272、BUG-1411。issue #89。 +- 复发自:BUG-1272。旧测试和快速门都还在,覆盖面太窄,没有失效。 +- 修复版本:分支 `codex/report-english-han-leak-20261021`。 + +## BUG-1411 | 个人报告英文把中文拆碎后粘成一个词,并把「第 N 级」译成 house + +- 状态:resolved(本分支回归已过;合入 staging 后按 PROGRESS 做真机复测) +- 首次发现:2026-10-21 +- 最近更新:2026-10-21 +- 影响面:个人报告英文版里的 Rath、瑜伽组合、Dosha 摘要、Jaimini 职责和若干小标题。 +- 用户现象:英文句子粘在一起,例如 `inperiod start`、`lordandstrong`、`weakafflicted`,或者出现 `house 5 level`。汉字门可能仍放行,因为汉字已经被拆掉了。 +- 触发条件:英文路径仍输出中文,末尾词表按单字替换(「在」换成 in、「与」换成 and、「第」换成 `house `、「宫」换成空)。另外一条英文模板写成 `weak/afflicted`,已有的清理会把 `weak/` 收成 `weak`,变成 `weakafflicted`。 +- 根因:和 BUG-1410 同一条路径。短词先替换,长句被拆碎。结构化附录的瑜伽表一直打印中文 `combination`,再经逐词替换。 +- 修复:英文 Rath 用 `level N` 和英文级别名。附录瑜伽表和 Dosha 摘要在英文下改用英文孪生字段。`bvr_bandhubhisthyaktha_precise` 的英文模板改为 `weak or afflicted`。中文模板和中文字段没有改。没有往末尾词表加条目。 +- 验证:同上 200 张盘的粘连黑名单为 0。改前 16 张虚构盘里,这些粘连词每张盘都有若干条(见 PROGRESS)。 +- 防复发:200 张盘测试的黑名单。不要把「第 N 级」再送进会把「第」换成 `house ` 的词表。 +- 相关记录:BUG-1410、BUG-1272、BUG-1218。issue #89。 +- 复发自:无。与 BUG-1272 同一类末尾替换,单独记吃空格和错译。 +- 修复版本:分支 `codex/report-english-han-leak-20261021`。 diff --git a/docs/tasks/PROGRESS-report-english-han-leak-20261021.md b/docs/tasks/PROGRESS-report-english-han-leak-20261021.md new file mode 100644 index 00000000..712b1e47 --- /dev/null +++ b/docs/tasks/PROGRESS-report-english-han-leak-20261021.md @@ -0,0 +1,94 @@ +# 进度:个人报告英文版不再整份丢掉 + +结论:英文版改在源头写英文。200 张固定虚构盘的英文都回来了,汉字为 0,粘连词为 0。中文版在同一随机种子下与 `bd3ce0e7b` 逐字节相同。 + +## 改了哪些源头 + +| 源头 | 英文现在用什么 | +| --- | --- | +| Rath 段 | `rath_summary_line_en` / `rath_report_lines_en` / `_ad_direction_lines_en`。级别写成 `level N` | +| 瑜伽强度 | 先用 `strength_en`,没有再查 `STRENGTH_EN`。Punarphoo 仍是 `observational`。`部分减弱` 补了 `partly weakened` | +| Panchanga | 新增 `overall_quality_en`:Auspicious (Subha) / Mixed / Inauspicious (Asubha)。中文三档没改 | +| Dosha | Mangal、Kaal Sarp、Pitra、Sade Sati 新增 `summary_en`。英文结果表用它 | +| Jaimini | 新增 `KARAKA_DOMAINS_EN` 和 `domain_en`。中文职责没改 | +| 标题 | 英文用 Vimsopaka 英文标题、Jaimini 英文标题。中文标题没改 | +| 结构化附录瑜伽表 | 英文改读 `combination_en` / `strength_en` / `effects_en`。还有汉字就留空 | +| 一条英文模板 | `bvr_bandhubhisthyaktha_precise` 的英文从 `weak/afflicted` 改为 `weak or afflicted`。中文模板没改 | + +第一轮改完 Rath、Dosha 和瑜伽强度之后,粘连词还在。原因是结构化附录仍在打印中文组合,以及英文模板里的 `weak/afflicted` 会被原有清理收成 `weakafflicted`。这两处都改在生产者,没有改词表。改完之后 200 张盘的粘连词为 0。 + +## 粘连词:改前 16 张,改后 200 张 + +改前是同一生成器的 0–15 号,在改代码之前渲染的英文。7 张当时整份被丢掉(有汉字)。下表是丢掉之前英文正文里的次数。改后 200 张(进程池)这些词都是 0。 + +| 词 | 改前 16 张里出现的盘数 | 改后 200 张 | +| --- | --- | --- | +| scoreandtimeline | 16 | 0 | +| marriagequality | 16 | 0 | +| whetherinunderway | 16 | 0 | +| withSaturn | 16 | 0 | +| careerplanetAmK | 16 | 0 | +| careerdirection | 16 | 0 | +| supportingstrongdegree | 16 | 0 | +| beneficfactor count | 16 | 0 | +| comparativelystrong | 16 | 0 | +| house seven-round | 16 | 0 | +| weakafflicted | 14 | 0 | +| lordandstrong | 12 | 0 | +| planetenterssign | 9 | 0 | +| occupiesplanetcount | 7 | 0 | +| debilitatedsign | 7 | 0 | +| [source text retained | 7 | 0 | +| lordstrongand | 6 | 0 | +| partialreducedweak | 6 | 0 | +| house 5 level | 5 | 0 | +| Dashasignpresent | 4 | 0 | +| inperiod start | 3 | 0 | +| planetsdistributed | 3 | 0 | +| planetsinbetween | 3 | 0 | +| beneficlocated | 2 | 0 | +| aturninperiod | 1 | 0 | +| atpresent | 0 | 0 | + +## 测试耗时(Windows) + +| 检查 | 命令 | 结果 | +| --- | --- | --- | +| 200 张,进程池 4 | `pytest tests/test_report_english_sweep.py` | 3679 秒,退出码 0 | +| smoke 12 张,进程池 4 | `pytest tests/test_report_english_sweep_smoke.py` | 199.6 秒,退出码 0 | +| 变异:英文 Rath 行改回中文后再跑 smoke | 同上 | 183.8 秒,12/12 `han_leak`,退出码 1。已改回 | +| 单张渲染 | 同一生成器的一张盘 | 约 43 秒 | + +串行没有整文件再跑一遍。按单张约 43–70 秒估算,smoke 串行大约 9–14 分钟,200 张串行大约 2.5–4 小时。进程池的 200 张已经超过 10 分钟,按任务书让步第 4 条记下实测时间,没有减少盘数。 + +smoke 在这台 Windows 上是 199 秒,长过快速门单文件 90 秒。时间主要耗在屏蔽 PyJHora 之后,年度回放子进程空等。没有改这个等待,也没有把 200 张放进快速门。 + +## 中文逐字节 + +30 张:0–27、32、34。覆盖第 5 级(4)、凶(5)、计都反向(1)、Table 14(5)、Punarphoo(34)、完全 Kaal Sarp(32)。 + +同一 `PYTHONHASHSEED=0` 下,本分支和 `bd3ce0e7b` 的中文完整数据版 SHA-256 全部相同(30/30)。 + +不固定哈希种子时,王者格那一行的配对会在进程之间变(同一张盘一次是金星、一次是土星)。这在改代码之前就有。固定种子后两边一致,所以不是这次英文改动造成的。 + +## 编号和基线 + +- 本分支上的 `docs/BUG_HISTORY.md` 原来最大是 BUG-1403。`origin/staging` 已有 BUG-1409(#90)。本单用 BUG-1410、BUG-1411。1404–1408 仍空着。 +- 实现基线是任务书指定的 `9812f317`。其后 staging 又多了 10 个提交,其中 #87 改了 `pl9_reader_export.py` 的上升状态格。本分支没有变基。合入时两边应各自保留。 +- T1–T7 在同一次提交。T5 的粘连和附录瑜伽表是同一处英文路径,没有拆成第二次提交。 + +## 真机复测 + +合入并部署到 staging 之后: + +1. 登录 staging。 +2. 新建一份个人报告。 +3. 揭幕之后切到 English。 +4. 全文搜不到汉字。第 5 级、凶、计都反向、Table 14、Punarphoo、完全 Kaal Sarp 这几类句子读得通(一份报告未必同时有这六类,有哪类就核对哪类)。 +5. 导出英文版。 +6. 已经生成过的旧报告需要重新生成,才有英文版。 + +## 环境 + +- 快速门、全量 pytest、前端判定没有在这台机器上跑,留给验收。 +- 没有登录 staging,真机步骤还没做。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 22cdbd2c..1dfa419e 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -514,4 +514,4 @@ | `TASK-other-subject-birth-time-edit-20261021.md` | — | 其他人的出生时间可以修改(去掉 `chart_profiles_guard_birth` 的填报时间不可改;推翻 people-archive P1 §验收 2 该条;active 锁不变) | 排队(等 #86 合入) | `codex/other-subject-birth-time-edit-20261021`;动表;不进循环 | | `TASK-chart-ascendant-slash-20261021.md` | — | #87 上升状态格「不适用」改「/」+ 悬停 / 点按说明;报告上升行同改并在表下加说明(推翻 10-15 口径) | 待领取(等 #86 合入) | `codex/chart-ascendant-slash-20261021` | | `TASK-report-export-dialog-polish-20261021.md` | — | #90 导出弹窗:英文不可用说明改顶部浮层(每次打开一次);选中态描边 / 圆点 / 勾选框用主题色、底色中性(推翻 DESIGN「Selected states use canvas-muted + ink」,BUG-1078 主语保留) | 待领取(可与 #89 并行) | `codex/report-export-dialog-polish-20261021` | -| `TASK-report-english-han-leak-20261021.md` | `PROGRESS-report-english-han-leak-20261021.md` | #89 个人报告英文版约 4 成整份丢弃:漏译汉字源头补英文孪生字段、修吃空格;200 张固定种子回归 + 快速门 smoke(复发自 BUG-1272);中文逐字节不变 | 待领取(最先做;#91/#92 在后) | `codex/report-english-han-leak-20261021` | +| `TASK-report-english-han-leak-20261021.md` | `PROGRESS-report-english-han-leak-20261021.md` | #89 个人报告英文版约 4 成整份丢弃:漏译汉字源头补英文孪生字段、修吃空格;200 张固定种子回归 + 快速门 smoke(复发自 BUG-1272);中文逐字节不变 | 待验收 | `codex/report-english-han-leak-20261021` | diff --git a/references/yoga_rules.json b/references/yoga_rules.json index e0bd3b82..d266d99e 100644 --- a/references/yoga_rules.json +++ b/references/yoga_rules.json @@ -11475,7 +11475,7 @@ "type": "custom", "expr": "\n# PyJHora BVR-195: 4th lord associated with malefics or inimical/debilitated.\n# PyJHora uses house_strength <= _ENEMY (1) for 'inimical', not <= _NEUTRAL (2).\nlord_4 = lord(4)\nif lord_4 not in ctx.planets:\n False\nelse:\n malefic_assoc = any(m in ctx.planets and associated(lord_4, m) for m in MALEFICS if m != lord_4)\n inimical_or_debil = debil(lord_4) or (house_strength(lord_4) is not None and house_strength(lord_4) <= 1) or is_shashtiamsa_evil(lord_4)\n malefic_assoc or inimical_or_debil\n", "combo_template": "4宫主受凶星关联或处于弱/不利状态", - "combo_template_en": "4th lord associated with malefics or weak/afflicted" + "combo_template_en": "4th lord associated with malefics or weak or afflicted" }, "effects": [ "被亲属遗弃", diff --git a/scripts/jaimini.py b/scripts/jaimini.py index e6fb909a..351733de 100644 --- a/scripts/jaimini.py +++ b/scripts/jaimini.py @@ -75,6 +75,16 @@ KARAKA_DOMAINS = { 'Darakaraka': '配偶特质、伴侣关系、婚姻质量', 'Pitrukaraka': '父亲、祖先业力、传统传承', } +KARAKA_DOMAINS_EN = { + 'Atmakaraka': 'soul aim, core self, highest aim', + 'Amatyakaraka': 'career direction, main advisor, delegated authority', + 'Bhratrikaraka': 'siblings, courage, appetite for risk', + 'Matrikaraka': 'mother, home base, emotional security', + 'Putrakaraka': 'children, creativity, students, crafted results', + 'Gnatikaraka': 'rivals, illness, obstacles, capacity for change', + 'Darakaraka': 'spouse traits, partnership, marriage quality', + 'Pitrukaraka': 'father, ancestral pattern, inherited tradition', +} ARUDHA_NAMES = { 1: 'Arudha Lagna (AL)', 2: 'Dhana Pada (A2)', 3: 'Vikrama Pada (A3)', @@ -119,6 +129,7 @@ def calc_chara_karaka_7(planet_degrees: Dict[str, float]) -> Dict: 'degree_in_sign': round(deg, 4), 'rank': rank, 'domain': KARAKA_DOMAINS.get(karaka, ''), + 'domain_en': KARAKA_DOMAINS_EN.get(karaka, ''), 'cn_name': KARAKA_CN.get(karaka, karaka), } ak = results.get('Atmakaraka', {}) @@ -160,6 +171,7 @@ def calc_chara_karaka_8(planet_degrees: Dict[str, float]) -> Dict: 'rahu_reverse_applied': pname == 'Rahu', 'rank': rank, 'domain': KARAKA_DOMAINS.get(karaka, ''), + 'domain_en': KARAKA_DOMAINS_EN.get(karaka, ''), 'cn_name': KARAKA_CN.get(karaka, karaka), } return {'karaka_table_8': results} diff --git a/scripts/muhurta.py b/scripts/muhurta.py index 02dd5e22..4289ba35 100644 --- a/scripts/muhurta.py +++ b/scripts/muhurta.py @@ -2612,10 +2612,13 @@ def calc_panchanga(sun_lon: float, moon_lon: float, weekday: int, if score_pct >= 0.75: overall = '吉(Subha)' + overall_en = 'Auspicious (Subha)' elif score_pct >= 0.5: overall = '中(Mixed)' + overall_en = 'Mixed' else: overall = '凶(Asubha)' + overall_en = 'Inauspicious (Asubha)' # 特殊凶时段检查 warnings = [] @@ -2637,6 +2640,7 @@ def calc_panchanga(sun_lon: float, moon_lon: float, weekday: int, 'hora': hora, 'overall_score': round(score_pct, 2), 'overall_quality': overall, + 'overall_quality_en': overall_en, 'warnings': warnings, 'auspicious_count': sum(1 for _, q in elements if q == 'subha'), 'total_elements': len(elements), diff --git a/scripts/narayana_legacy_label.py b/scripts/narayana_legacy_label.py index b749267b..e9835d6e 100644 --- a/scripts/narayana_legacy_label.py +++ b/scripts/narayana_legacy_label.py @@ -192,6 +192,7 @@ def rath_report_lines_zh(rath: Any) -> List[str]: AD_DIRECTION_WORD_ZH = {"zodiacal": "顺行", "reverse": "逆行"} +AD_DIRECTION_WORD_EN = {"zodiacal": "zodiacal", "reverse": "retrograde"} def _ad_direction_lines_zh(direction: Any) -> List[str]: @@ -217,3 +218,77 @@ def rath_summary_line_zh(rath: Any) -> Optional[str]: return (f"- 并列:{RATH_LABEL_ZH}:起运宫 {rath.get('seed_sign')}" f"(第 {rath.get('strength_level_decided')} 级:{rath.get('strength_level_text')}){running}") + +def rath_summary_line_en(rath: Any) -> Optional[str]: + """English twin of `rath_summary_line_zh`. Chinese text is not reused.""" + if not isinstance(rath, dict) or not rath: + return None + if not rath.get("seed_sign"): + return f"- Alongside: {RATH_LABEL_EN}: start sign undecided, {rath.get('reason') or rath.get('status')}" + md = (rath.get("current_dasha") or {}).get("md") or {} + running = f"; current mahadasha {md.get('sign')}, lord {md.get('lord') or 'undecided'}" if md else "" + level = rath.get("strength_level_decided") + level_text = rath.get("strength_level_text_en") or STRENGTH_LEVEL_EN.get(level) or "" + return (f"- Alongside: {RATH_LABEL_EN}: period start {rath.get('seed_sign')}" + f" (level {level}: {level_text}){running}") + + +def _ad_direction_lines_en(direction: Any) -> List[str]: + """English twin of `_ad_direction_lines_zh`, including the book-divergence note.""" + if not isinstance(direction, dict) or not direction.get("rule_text_en"): + return [] + others = "; ".join( + f"{row.get('rule_text_en')} -> {AD_DIRECTION_WORD_EN.get(row.get('direction'), '-')}" + for row in direction.get("book_divergence") or [] if isinstance(row, dict)) + line = (f"- sub-period direction: {direction['rule_text_en']}, " + f"{AD_DIRECTION_WORD_EN.get(direction.get('direction'), '-')}") + return [line + (f" (the book itself disagrees, not applied: {others})" if others else "")] + + +def rath_report_lines_en(rath: Any) -> List[str]: + """English twin of `rath_report_lines_zh` for the full-data English edition.""" + summary = rath_summary_line_en(rath) + if summary is None: + return [] + lines = [summary] + if not rath.get("mahadasha"): + return lines + direction = rath.get("direction_text_en") or DIRECTION_EN.get(rath.get("direction")) + if direction: + note = f"; {rath['method_note_en']}" if rath.get("method_note_en") else "" + lines.append(f"- order: {direction}{note}") + lines.extend(["", + "| Rath MD | Sign | Lord | Start age | End age | Base years | Adjustment | Final |", + "|---------|------|------|-----------|---------|------------|------------|-------|"]) + for index, row in enumerate(rath["mahadasha"], start=1): + lines.append("| {index} | {sign} | {lord} | {start} | {end} | {base} | {adjustment} | {years} |".format( + index=index, sign=row.get("sign"), lord=row.get("lord") or "undecided", + start=row.get("start_age"), end=row.get("end_age"), + base="—" if row.get("base_years") is None else row.get("base_years"), + adjustment="—" if row.get("dignity_adjustment") is None else row.get("dignity_adjustment"), + years="—" if row.get("years") is None else row.get("years"))) + seen = set() + for row in rath["mahadasha"]: + sign = row.get("sign") + basis = row.get("dual_lord_basis_en") + if sign in ("Scorpio", "Aquarius") and basis and sign not in seen: + seen.add(sign) + lines.append(f"- dual lords {sign}: {basis}") + md = (rath.get("current_dasha") or {}).get("md") + rule = rath.get("current_md_antardasha_rule") or {} + if md and rath.get("current_md_antardasha"): + compared = rule.get("compared") or ["-", "-"] + lines.extend([ + "", + f"- sub-periods of the current mahadasha {md.get('sign')}: compare {compared[0]} and {compared[-1]}, " + f"stronger {rule.get('stronger_sign')} (level {rule.get('strength_level_decided')}), " + f"from {rule.get('start_sign')} where lord {rule.get('lord')} sits, 12 equal parts", + *_ad_direction_lines_en(rule.get("direction")), + "", + "| Rath AD | Sign | Start age | End age |", + "|---------|------|-----------|---------|", + ]) + for index, row in enumerate(rath["current_md_antardasha"], start=1): + lines.append(f"| {index} | {row.get('sign')} | {row.get('start_age')} | {row.get('end_age')} |") + return lines + diff --git a/scripts/pl9_full_data_export.py b/scripts/pl9_full_data_export.py index 2ed0d4fc..a44145eb 100644 --- a/scripts/pl9_full_data_export.py +++ b/scripts/pl9_full_data_export.py @@ -2070,14 +2070,43 @@ def _render_pl9_ai_density_raw_data_markdown_en(packet: dict) -> str: rows.append([name, item.get("sign"), item.get("degree_in_sign"), item.get("longitude")]) lines.extend(["## Sahams", "", table(["Name", "Sign", "Degree in sign", "Longitude"], rows), ""]) + english_report = _pl9_report_language(packet) == "en" + strength_en_map: dict[str, str] = {} + han_leaks = None + if english_report: + try: + from yoga_engine import STRENGTH_EN as strength_en_map + except ImportError: + from scripts.yoga_engine import STRENGTH_EN as strength_en_map + try: + from pl9_reader_english import HAN as han_leaks + except ImportError: + from scripts.pl9_reader_english import HAN as han_leaks + + def _english_plain(value: Any) -> str: + text = str(value or "").strip() + if not text or (han_leaks is not None and han_leaks.search(text)): + return "" + return text + yoga = dict_get(("advanced_systems", "yoga", "detected_yogas"), []) if isinstance(yoga, list): rows = [] for item in yoga: if isinstance(item, dict): - effects = [effect for effect in (clean_effect(e) for e in item.get("effects", [])) if effect] + if english_report: + effects_src = item.get("effects_en") if isinstance(item.get("effects_en"), list) else [] + effects = [text for text in (_english_plain(effect) for effect in effects_src) if text] + combination = _english_plain(item.get("combination_en")) or "-" + strength = _english_plain(item.get("strength_en")) + if not strength: + strength = _english_plain(strength_en_map.get(str(item.get("strength") or ""), "")) + else: + effects = [effect for effect in (clean_effect(e) for e in item.get("effects", [])) if effect] + combination = item.get("combination") + strength = item.get("strength") effects_text = "; ".join(effects) if effects else "-" - rows.append([item.get("name"), item.get("category"), item.get("strength"), item.get("combination"), effects_text]) + rows.append([item.get("name"), item.get("category"), strength, combination, effects_text]) lines.extend(["## Applicable Yogas", "", table(["Yoga", "Category", "Strength", "Combination", "Effects / notes"], rows), ""]) dosha = dict_get(("advanced_systems", "yogas_doshas"), {}) @@ -2092,10 +2121,14 @@ def _render_pl9_ai_density_raw_data_markdown_en(packet: dict) -> str: for key in ["mangal_dosha", "kaal_sarp_dosha", "pitra_dosha", "sade_sati"]: item = dosha.get(key) if isinstance(item, dict): + if english_report: + summary = _english_plain(item.get("summary_en")) or "-" + else: + summary = item.get("summary") or item.get("phase") or item.get("type") rows.append([ dosha_labels.get(key, key), item.get("present") or item.get("detected") or item.get("status"), - item.get("summary") or item.get("phase") or item.get("type"), + summary, ]) lines.extend(["## Dosha and Transit Summary", "", table(["Topic", "Detected / phase", "Summary"], rows), ""]) diff --git a/scripts/pl9_reader_export.py b/scripts/pl9_reader_export.py index 2f5eb1a1..f5e7883b 100644 --- a/scripts/pl9_reader_export.py +++ b/scripts/pl9_reader_export.py @@ -23,9 +23,9 @@ for the public reader rather than exposing empty or machine-key rows. from __future__ import annotations try: # scripts/ on sys.path - from narayana_legacy_label import LEGACY_NOTE_ZH + from narayana_legacy_label import LEGACY_NOTE_EN, LEGACY_NOTE_ZH except ImportError: # pragma: no cover - package import - from scripts.narayana_legacy_label import LEGACY_NOTE_ZH + from scripts.narayana_legacy_label import LEGACY_NOTE_EN, LEGACY_NOTE_ZH import re SIGNS: list[str] = [] @@ -974,11 +974,16 @@ def render_pl9_parity_markdown(packet: dict) -> str: '| Panchanga | Value |', '|-----------|-------|', ]) - for key, label in ( + panchanga_rows = ( + ('overall_quality_en', 'Overall quality'), + ('auspicious_count', 'Auspicious elements'), + ('total_elements', 'Total elements'), + ) if _lang == 'en' else ( ('overall_quality', '综合性质'), ('auspicious_count', '吉性要素数量'), ('total_elements', '要素总数'), - ): + ) + for key, label in panchanga_rows: value = panchanga.get(key) if value not in (None, ''): lines.append(f"| {_cell(label)} | {_cell(value)} |") @@ -1544,7 +1549,11 @@ def render_pl9_parity_markdown(packet: dict) -> str: arudha_rows = _dict(arudha_pack.get('padas')) or arudha_pack jaimini_special = _dict(jaimini.get('special_lagnas')) if karaka_table or arudha_rows or jaimini_special: - lines.extend(['### Jaimini Karaka、Arudha Pada 与特殊 Lagna', '']) + lines.extend([ + '### Jaimini Karaka, Arudha Pada and special Lagnas' if _lang == 'en' + else '### Jaimini Karaka、Arudha Pada 与特殊 Lagna', + '', + ]) if karaka_table: lines.extend([ '| Karaka | Planet | Degree | Ranking degree | Domain |', @@ -1553,11 +1562,15 @@ def render_pl9_parity_markdown(packet: dict) -> str: for name, row in karaka_table.items(): if not isinstance(row, dict): continue + karaka_name = name if _lang == 'en' else (row.get('cn_name') or name) + domain = row.get('domain_en') if _lang == 'en' else row.get('domain') + if _lang == 'en' and domain and re.search(r'[\u4e00-\u9fff]', str(domain)): + domain = '' lines.append( - f"| {_cell(row.get('cn_name') or name)} | {_cell(_token(row.get('planet')))} | " + f"| {_cell(karaka_name)} | {_cell(_token(row.get('planet')))} | " f"{_cell(_degree(row.get('degree_in_sign') or row.get('sign_degree')))} | " f"{_cell(_degree(row.get('effective_degree_in_sign', row.get('degree_in_sign'))))} | " - f"{_cell(row.get('domain'))} |" + f"{_cell(domain)} |" ) lines.append('') if arudha_rows or jaimini_special: @@ -1686,7 +1699,8 @@ def render_pl9_parity_markdown(packet: dict) -> str: vimsopaka = _dict(strengths.get('vimsopaka')) if vimsopaka: lines.extend([ - '### p30-p60 Vimsopaka / Pushkara / Vargottama 辅助强度', + '### Vimsopaka / Pushkara / Vargottama supporting strength' if _lang == 'en' + else '### p30-p60 Vimsopaka / Pushkara / Vargottama 辅助强度', '', '| Planet | Vimsopaka | Grade | Pushkara Navamsa | Pushkara Bhaga | Vargottama |', '|--------|-----------|-------|------------------|----------------|------------|', @@ -1923,7 +1937,10 @@ def render_pl9_parity_markdown(packet: dict) -> str: lines.extend([f'### p{page} {title_text}', '']) if 'Narayana' in title_text: # BUG-1215: the product Narayana is the legacy algorithm. - lines.extend([f'- 算法:{LEGACY_NOTE_ZH}', '']) + if _lang == 'en': + lines.extend([f'- Algorithm: {LEGACY_NOTE_EN}', '']) + else: + lines.extend([f'- 算法:{LEGACY_NOTE_ZH}', '']) lines.extend([ '| 序号 | 主星/星座 | 起始 | 结束 | 年数 |', '|------|-----------|------|------|------|', @@ -1999,24 +2016,25 @@ def render_pl9_parity_markdown(packet: dict) -> str: # Rath table already printed by the reference renderer. Display only. narayana_packet = _dict(timing.get('narayana_dasha')) if narayana_packet: - try: - from narayana_legacy_label import rath_report_lines_zh as _rath_lines - except ImportError: - from scripts.narayana_legacy_label import rath_report_lines_zh as _rath_lines - legacy_line = f'- 算法:{LEGACY_NOTE_ZH}' + if _lang == 'en': + try: + from narayana_legacy_label import rath_report_lines_en as _rath_lines + except ImportError: + from scripts.narayana_legacy_label import rath_report_lines_en as _rath_lines + legacy_line = f'- Algorithm: {LEGACY_NOTE_EN}' + alongside_prefix = '- Alongside:' + else: + try: + from narayana_legacy_label import rath_report_lines_zh as _rath_lines + except ImportError: + from scripts.narayana_legacy_label import rath_report_lines_zh as _rath_lines + legacy_line = f'- 算法:{LEGACY_NOTE_ZH}' + alongside_prefix = '- 并列:' if legacy_line not in lines: lines.extend(['### Narayana Dasha', '', legacy_line, '']) rath_view = narayana_packet.get('rath') rath_lines = [line for line in _rath_lines(rath_view) if line] - if _lang == 'en' and isinstance(rath_view, dict): - for row in _list(rath_view.get('mahadasha')): - if not isinstance(row, dict): - continue - zh_basis = row.get('dual_lord_basis') - en_basis = row.get('dual_lord_basis_en') - if zh_basis and en_basis: - rath_lines = [line.replace(str(zh_basis), str(en_basis)) for line in rath_lines] - if rath_lines and not any(str(line).startswith('- 并列:') for line in lines): + if rath_lines and not any(str(line).startswith(alongside_prefix) for line in lines): lines.extend(rath_lines) lines.append('') source_dasha_pages = _list(source_visible.get('dasha_pages')) @@ -2092,13 +2110,20 @@ def render_pl9_parity_markdown(packet: dict) -> str: # Customer tables retain computed boundaries and reference points; # print-layout rows deliberately blank repeated values. kantaka_rows = _list(timeline.get('kantaka_rows')) or _list(display_rows.get('kantaka')) + sade_summary = sade_sati.get('summary_en') if _lang == 'en' else sade_sati.get('summary') + if _lang == 'en' and sade_summary and re.search(r'[\u4e00-\u9fff]', str(sade_summary)): + sade_summary = '' lines.extend([ '### p121-p122 Sade Sati / Dhayya / Kantaka Saturn', '', - f"- Moon sign:{_cell(_token(sade_sati.get('moon_sign')))}", - f"- 当前阶段:{_cell(sade_sati.get('phase') or sade_sati.get('status'))}", - f"- 是否 active:{_cell(sade_sati.get('is_active'))}", - f"- 摘要:{_cell(sade_sati.get('summary'))}", + f"- Moon sign: {_cell(_token(sade_sati.get('moon_sign')))}" if _lang == 'en' + else f"- Moon sign:{_cell(_token(sade_sati.get('moon_sign')))}", + f"- Current phase: {_cell(sade_sati.get('phase') or sade_sati.get('status'))}" if _lang == 'en' + else f"- 当前阶段:{_cell(sade_sati.get('phase') or sade_sati.get('status'))}", + f"- Active: {_cell(sade_sati.get('is_active'))}" if _lang == 'en' + else f"- 是否 active:{_cell(sade_sati.get('is_active'))}", + f"- Summary: {_cell(sade_summary)}" if _lang == 'en' + else f"- 摘要:{_cell(sade_sati.get('summary'))}", '', '| Phase | Sign |', '|-------|------|', @@ -2994,8 +3019,12 @@ def render_pl9_parity_markdown(packet: dict) -> str: for fact_key in ('mars_house', 'mars_sign', 'sun_house', 'sun_sign', 'rahu_house', 'ketu_house', 'moon_sign', 'severity', 'type'): if row.get(fact_key) not in (None, ''): fact_bits.append(f"{fact_key}={_token(row.get(fact_key))}") + summary = row.get('summary_en') if _lang == 'en' else row.get('summary') + if _lang == 'en' and summary and re.search(r'[\u4e00-\u9fff]', str(summary)): + summary = '' + joiner = '; ' if _lang == 'en' else ';' lines.append( - f"| {_cell(label)} | {_cell(detected)} | {_cell(';'.join(map(str, fact_bits)) or '-')} | {_cell(row.get('summary'))} |" + f"| {_cell(label)} | {_cell(detected)} | {_cell(joiner.join(map(str, fact_bits)) or '-')} | {_cell(summary)} |" ) lines.append('') dosha_rows = _list(dosha_pack.get('dosha_rows')) @@ -3062,6 +3091,12 @@ def render_pl9_parity_markdown(packet: dict) -> str: for row in detected_yogas: if isinstance(row, dict): grouped_yogas[_yoga_group_label(row)].append(row) + strength_en_map = {} + if _lang == 'en': + try: + from yoga_engine import STRENGTH_EN as strength_en_map + except ImportError: + from scripts.yoga_engine import STRENGTH_EN as strength_en_map for group_title, group_rows in grouped_yogas.items(): if not group_rows: continue @@ -3074,15 +3109,20 @@ def render_pl9_parity_markdown(packet: dict) -> str: for row in group_rows: if _lang == 'en': effects = row.get('effects_en') if isinstance(row.get('effects_en'), list) else [] - if not effects and isinstance(row.get('effects'), list): - effects = row.get('effects') - yoga_name = row.get('name') or row.get('type') or row.get('name_cn') - combination = row.get('combination_en') or row.get('combination') or row.get('source_reference') or row.get('reason') + effects = [item for item in effects if not re.search(r'[\u4e00-\u9fff]', str(item))] + yoga_name = row.get('name') or row.get('type') + combination = row.get('combination_en') + if not combination or re.search(r'[\u4e00-\u9fff]', str(combination)): + combination = '' + strength = row.get('strength_en') or strength_en_map.get(str(row.get('strength') or '')) + if not strength or re.search(r'[\u4e00-\u9fff]', str(strength)): + strength = '' else: effects = row.get('effects') if isinstance(row.get('effects'), list) else [] yoga_name = row.get('name_cn') or row.get('name') or row.get('type') combination = row.get('combination') or row.get('source_reference') or row.get('reason') - effect_text = _public_report_text(_brief(effects, 3) or row.get('interpretation') or row.get('summary')) + strength = row.get('strength') + effect_text = _public_report_text(_brief(effects, 3) or (None if _lang == 'en' else row.get('interpretation') or row.get('summary'))) try: from pl9_language_terms import soften_lifespan_wording except ModuleNotFoundError: # pragma: no cover @@ -3090,7 +3130,7 @@ def render_pl9_parity_markdown(packet: dict) -> str: effect_text = soften_lifespan_wording(str(effect_text or ''), _lang) lines.append( f"| {_cell(yoga_name)} | {_cell(row.get('category') or row.get('subtype'))} | " - f"{_cell(row.get('strength'))} | {_cell(combination)} | " + f"{_cell(strength)} | {_cell(combination)} | " f"{_cell(effect_text)} |" ) lines.append('') diff --git a/scripts/run_quality_gate.py b/scripts/run_quality_gate.py index e9bba996..6c52d03a 100644 --- a/scripts/run_quality_gate.py +++ b/scripts/run_quality_gate.py @@ -135,6 +135,8 @@ CORE_PYTEST_TARGETS = [ "tests/test_serif_font_slices.py", # Chart, report, and consult entries must not import PyJHora (BUG-1273). "tests/test_runtime_jhora_import_scan.py", + # English edition han leak, 12-chart slice (issue #89 / BUG-1410). The 200-chart file stays off this list. + "tests/test_report_english_sweep_smoke.py", ] RUNTIME_TRUTH_PYTEST_TARGETS = [ diff --git a/scripts/yoga_engine.py b/scripts/yoga_engine.py index 584d748f..a9d56ecd 100644 --- a/scripts/yoga_engine.py +++ b/scripts/yoga_engine.py @@ -156,7 +156,10 @@ OFFSET_NAME_EN = {0: "same house", 1: "2nd", 2: "3rd", 3: "4th (Kendra)", 4: "5t 5: "6th", 6: "7th (Kendra)", 7: "8th", 8: "9th (Trine)", 9: "10th (Kendra)", 10: "11th", 11: "12th"} -STRENGTH_EN = {"强": "Strong", "中": "Moderate", "弱": "Weak", "待评估": "To be assessed", "观察": "Watch"} +STRENGTH_EN = { + "强": "Strong", "中": "Moderate", "弱": "Weak", "待评估": "To be assessed", "观察": "Watch", + "部分减弱": "partly weakened", +} def _combo(cond: Dict, default_zh: str, **values) -> BiText: diff --git a/scripts/yogas_doshas.py b/scripts/yogas_doshas.py index 2c38376b..47367901 100644 --- a/scripts/yogas_doshas.py +++ b/scripts/yogas_doshas.py @@ -576,6 +576,7 @@ def calc_mangal_dosha(planets_data: Dict, mars_house: int) -> Dict: bad_houses = [1, 2, 4, 7, 8, 12] if mars_house not in bad_houses: results['summary'] = '无Mangal Dosha(Mars不在煞宫)' + results['summary_en'] = 'No Mangal Dosha (Mars is not in a dosha house)' return results results['has_dosha'] = True @@ -605,6 +606,10 @@ def calc_mangal_dosha(planets_data: Dict, mars_house: int) -> Dict: results['mars_sign'] = mars_sign results['summary'] = (f"{'无' if not results['has_dosha'] else '有'}Mangal Dosha——" f"Mars在{mars_house}宫{mars_sign},严重程度:{results['severity']}") + present = 'No' if not results['has_dosha'] else 'Present' + results['summary_en'] = ( + f"{present} Mangal Dosha — Mars in house {mars_house}, {mars_sign}; severity: {results['severity']}" + ) return results @@ -626,6 +631,7 @@ def calc_kaal_sarp_dosha(planets_data: Dict) -> Dict: if not isinstance(rahu_data, dict) or 'house' not in rahu_data: results['summary'] = 'Rahu位置未知,无法判断Kaal Sarp Dosha' + results['summary_en'] = 'Rahu position unknown; Kaal Sarp Dosha cannot be judged' return results rahu_house = rahu_data['house'] @@ -655,14 +661,21 @@ def calc_kaal_sarp_dosha(planets_data: Dict) -> Dict: results['has_dosha'] = True results['type'] = 'full' results['summary'] = f'完全Kaal Sarp Dosha——所有{total_planets}颗行星在Rahu({rahu_house})—Ketu轴线之间' + results['summary_en'] = ( + f'Full Kaal Sarp Dosha — all {total_planets} planets lie between the Rahu ({rahu_house})-Ketu axis' + ) elif len(planets_in_axis) >= total_planets * 0.7: results['has_dosha'] = True results['type'] = 'partial' results['summary'] = f'部分Kaal Sarp Dosha——{len(planets_in_axis)}/{total_planets}颗行星在轴线之间' + results['summary_en'] = ( + f'Partial Kaal Sarp Dosha — {len(planets_in_axis)} of {total_planets} planets lie between the axis' + ) else: results['has_dosha'] = False results['type'] = 'none' results['summary'] = '无Kaal Sarp Dosha' + results['summary_en'] = 'No Kaal Sarp Dosha' results['planets_in_axis'] = planets_in_axis results['planets_outside'] = planets_outside @@ -682,13 +695,14 @@ def calc_pitra_dosha(planets_data: Dict, sun_house: int) -> Dict: 影响:父辈业力、祖先未解问题、子女健康/生育问题 """ - results = {'has_dosha': False, 'indicators': []} + results = {'has_dosha': False, 'indicators': [], 'indicators_en': []} sun_data = planets_data.get('Sun', {}) rahu_data = planets_data.get('Rahu', {}) if not isinstance(sun_data, dict) or 'house' not in sun_data: results['summary'] = 'Sun位置未知,无法判断Pitra Dosha' + results['summary_en'] = 'Sun position unknown; Pitra Dosha cannot be judged' return results sun_house = sun_data['house'] @@ -700,6 +714,9 @@ def calc_pitra_dosha(planets_data: Dict, sun_house: int) -> Dict: if abs(sun_house - rahu_house) <= 1 or (sun_house == rahu_house): results['has_dosha'] = True results['indicators'].append(f'Sun({sun_house}宫)与Rahu({rahu_house}宫)合相/相邻') + results['indicators_en'].append( + f'Sun (house {sun_house}) conjunct or adjacent to Rahu (house {rahu_house})' + ) # 检查Sun受克(土星/火星在Sun宫或相邻宫) for malefic in ['Saturn', 'Mars']: @@ -709,13 +726,18 @@ def calc_pitra_dosha(planets_data: Dict, sun_house: int) -> Dict: if abs(sun_house - m_house) <= 1: results['has_dosha'] = True results['indicators'].append(f'{malefic}({m_house}宫)克Sun({sun_house}宫)') + results['indicators_en'].append( + f'{malefic} (house {m_house}) afflicts Sun (house {sun_house})' + ) results['sun_house'] = sun_house results['sun_sign'] = sun_sign if not results['indicators']: results['summary'] = '无Pitra Dosha明显迹象' + results['summary_en'] = 'No clear indication of Pitra Dosha' else: results['summary'] = f'有Pitra Dosha迹象:{"; ".join(results["indicators"][:2])}' + results['summary_en'] = 'Indications of Pitra Dosha: ' + '; '.join(results['indicators_en'][:2]) return results @@ -737,6 +759,7 @@ def calc_sade_sati(moon_sign: str, current_year: int = 2026) -> Dict: if not moon_sign or moon_sign not in SIGNS: results['summary'] = 'Moon星座未知,无法判断Sade Sati' + results['summary_en'] = 'Moon sign unknown; Sade Sati cannot be judged' return results moon_idx = SIGNS.index(moon_sign) @@ -754,6 +777,12 @@ def calc_sade_sati(moon_sign: str, current_year: int = 2026) -> Dict: f'高峰期={results["phases"]["peak"]}(最困难),' f'下降期={results["phases"]["setting"]};' f'需结合Saturn实际位置判断是否在进行中') + results['summary_en'] = ( + f"Sade Sati phase frame: rising={results['phases']['rising']}, " + f"peak={results['phases']['peak']} (most difficult), " + f"setting={results['phases']['setting']}; " + "judge from Saturn's actual position whether it is underway" + ) return results diff --git a/tests/report_english_fictional.py b/tests/report_english_fictional.py new file mode 100644 index 00000000..7ad8faa5 --- /dev/null +++ b/tests/report_english_fictional.py @@ -0,0 +1,205 @@ +"""Fixed fictional charts for the English-edition sweep (issue #89). + +Not collected by pytest. Birth data stays inside the generator; callers report +only the chart index and leftover fragments. +""" +from __future__ import annotations + +import builtins +import importlib +import os +import random +import re +import sys +from pathlib import Path + +HAN = re.compile(r"[㐀-鿿豈-﫿]") +GLUE = ( + "scoreandtimeline", + "marriagequality", + "inperiod start", + "Dashasignpresent", + "aturninperiod", + "atpresent", + "planetsinbetween", + "whetherinunderway", + "withSaturn", + "careerplanetAmK", + "careerdirection", + "supportingstrongdegree", + "beneficfactor count", + "comparativelystrong", + "occupiesplanetcount", + "lordandstrong", + "lordstrongand", + "weakafflicted", + "partialreducedweak", + "debilitatedsign", + "planetenterssign", + "planetsdistributed", + "beneficlocated", + "house 5 level", + "house seven-round", + "condition planets", + "[source text retained", +) +SEED = 20261021 +TODAY = "2026-10-21" +CHART_COUNT = 200 + + +def fictional_case(index: int) -> dict: + """Chart ``index`` from the fixed sequence. Replaying 0..index keeps the seed stable.""" + rng = random.Random(SEED) + chosen = None + for i in range(index + 1): + year = rng.randint(1940, 2015) + month = rng.randint(1, 12) + day = rng.randint(1, 28) + hour = rng.randint(0, 23) + minute = rng.randint(0, 59) + lat = round(rng.uniform(-45, 60), 4) + lon = round(rng.uniform(-120, 150), 4) + tz = max(-12.0, min(14.0, round(lon / 7.5) * 0.5)) + chosen = { + "name": f"chart-{i:03d}", + "year": year, + "month": month, + "day": day, + "hour": hour, + "minute": minute, + "second": 0, + "lat": lat, + "lon": lon, + "tz": tz, + "node_mode": rng.choice(("mean", "true")), + "ayanamsa": rng.choice(("lahiri", "raman", "kp")), + "today": TODAY, + "transit_date": TODAY, + "target_year": 2026, + "age": 2026 - year, + "birth_time_accuracy": "confirmed", + } + return chosen + + +def block_jhora() -> None: + """Fail closed if this process imports PyJHora. Call once per process.""" + real_import = builtins.__import__ + + def guarded(name, globals=None, locals=None, fromlist=(), level=0): + if str(name).split(".", 1)[0] == "jhora": + raise ImportError("jhora blocked") + return real_import(name, globals, locals, fromlist, level) + + builtins.__import__ = guarded + real_import_module = importlib.import_module + + def guarded_module(name, package=None): + if str(name).split(".", 1)[0] == "jhora": + raise ImportError("jhora blocked") + return real_import_module(name, package) + + importlib.import_module = guarded_module + for key in list(sys.modules): + if key == "jhora" or key.startswith("jhora."): + del sys.modules[key] + + +def _prepare_process() -> None: + root = Path(__file__).resolve().parents[1] + for entry in (str(root), str(root / "scripts")): + if entry not in sys.path: + sys.path.insert(0, entry) + block_jhora() + + +def _residue(text: str) -> dict: + found = HAN.findall(text or "") + glue = [word for word in GLUE if word in (text or "")] + return {"han": len(found), "han_chars": "".join(sorted(set(found)))[:40], "glue": glue} + + +def render_english(index: int) -> dict: + """Build one fictional packet and run ``_english_edition``. No birth data in the result.""" + _prepare_process() + from scripts.jyotish_engine import build_professional_report_reference_packet, cmd_full_reading + from scripts.professional_report_reference import _english_edition, _export_args + + birth = fictional_case(index) + reading = cmd_full_reading(type("Args", (), dict(birth))()) + packet = dict(build_professional_report_reference_packet(reading, _export_args(dict(birth)), [])) + packet["report_version"] = "pl9_personal_long_report.v3" + edition = _english_edition(packet, True) + markdown = edition.get("markdown_en") or "" + flags = _flags_from_packet(packet) + applicability = edition.get("reader_dasha_applicability_en") or "" + body = _residue(markdown) + extra = _residue(applicability) + dump_dir = os.environ.get("REPORT_ENGLISH_SWEEP_DUMP") + if dump_dir and markdown: + path = Path(dump_dir) + path.mkdir(parents=True, exist_ok=True) + (path / f"chart-{index:03d}.md").write_text(markdown, encoding="utf-8") + return { + "i": index, + "status": "ok" if markdown and not edition.get("english_unavailable") else edition.get("english_unavailable") or "empty", + "han": body["han"] + extra["han"], + "han_chars": (body["han_chars"] + extra["han_chars"])[:40], + "glue": sorted(set(body["glue"] + extra["glue"])), + **flags, + } + + +def _flags_from_packet(packet: dict) -> dict: + rath = (((packet.get("worksheets") or {}).get("timing_and_predictive_systems") or {}).get("narayana_dasha") or {}).get("rath") or {} + rule = rath.get("current_md_antardasha_rule") or {} + direction = rule.get("direction") if isinstance(rule, dict) else {} + advanced = (packet.get("worksheets") or {}).get("advanced_systems") or {} + doshas = advanced.get("yogas_doshas") or {} + kaal = doshas.get("kaal_sarp_dosha") or {} + birth_sheet = (packet.get("worksheets") or {}).get("birth_and_parameters") or {} + pack = birth_sheet.get("panchanga") or {} + panchanga = pack.get("raw") if isinstance(pack, dict) and isinstance(pack.get("raw"), dict) else pack + yoga = advanced.get("yoga") if isinstance(advanced.get("yoga"), dict) else {} + rows = [row for row in (yoga.get("detected_yogas") or []) if isinstance(row, dict)] + return { + "level": rath.get("strength_level_decided"), + "direction": rath.get("direction"), + "ad_rule": direction.get("rule") if isinstance(direction, dict) else None, + "overall": panchanga.get("overall_quality") if isinstance(panchanga, dict) else None, + "kaal": kaal.get("type") if isinstance(kaal, dict) else None, + "punarphoo": any(str(row.get("strength") or "") == "观察" or "Punarphoo" in str(row.get("name") or "") for row in rows), + } + + +def chart_flags(index: int) -> dict: + """Class flags used to prove the smoke set covers L1–L6. No birth data.""" + _prepare_process() + from scripts.jyotish_engine import build_professional_report_reference_packet, cmd_full_reading + from scripts.professional_report_reference import _export_args + + birth = fictional_case(index) + reading = cmd_full_reading(type("Args", (), dict(birth))()) + packet = dict(build_professional_report_reference_packet(reading, _export_args(dict(birth)), [])) + rath = (((packet.get("worksheets") or {}).get("timing_and_predictive_systems") or {}).get("narayana_dasha") or {}).get("rath") or {} + rule = rath.get("current_md_antardasha_rule") or {} + direction = rule.get("direction") if isinstance(rule, dict) else {} + advanced = (packet.get("worksheets") or {}).get("advanced_systems") or {} + doshas = advanced.get("yogas_doshas") or {} + kaal = doshas.get("kaal_sarp_dosha") or {} + birth_sheet = (packet.get("worksheets") or {}).get("birth_and_parameters") or {} + pack = birth_sheet.get("panchanga") or {} + panchanga = pack.get("raw") if isinstance(pack, dict) and isinstance(pack.get("raw"), dict) else pack + yoga = advanced.get("yoga") if isinstance(advanced.get("yoga"), dict) else {} + names = [str(row.get("name") or "") for row in (yoga.get("detected_yogas") or []) if isinstance(row, dict)] + strengths = [str(row.get("strength") or "") for row in (yoga.get("detected_yogas") or []) if isinstance(row, dict)] + return { + "i": index, + "level": rath.get("strength_level_decided"), + "direction": rath.get("direction"), + "ad_rule": direction.get("rule") if isinstance(direction, dict) else None, + "overall": panchanga.get("overall_quality") if isinstance(panchanga, dict) else None, + "kaal": kaal.get("type") if isinstance(kaal, dict) else None, + "punarphoo": "观察" in strengths or any("Punarphoo" in name for name in names), + } diff --git a/tests/test_narayana_legacy_label.py b/tests/test_narayana_legacy_label.py index 564e08fa..75536d06 100644 --- a/tests/test_narayana_legacy_label.py +++ b/tests/test_narayana_legacy_label.py @@ -91,26 +91,29 @@ def test_full_reading_carries_the_rath_view_and_leaves_the_legacy_result_alone() def test_reference_edition_shows_both_and_english_terms_cover_the_rath_lines() -> None: - from scripts.narayana_legacy_label import rath_report_lines_zh - from scripts.pl9_reader_english_terms import PHRASES_EN, REGEX_EN + from scripts.narayana_legacy_label import rath_report_lines_en packet = _packet(CASES["day"]) - # 原值:reference 版同时有旧算法句和 Rath 表 - # 新值:full_data 版同时有这两段 - # 原因:用户看到的是完整数据版;Rath 并列表必须跟着走(BUG-1218) + # 原值:Rath 中文行经 PHRASES_EN 可译干净 + # 新值:完整数据版英文渲染的 Rath 段无汉字且用英文函数 + # 原因:完整数据版不走 PHRASES_EN,旧断言测的不是生产路径 zh = _pl9_export_markdown_for_edition(copy.deepcopy(packet), "full_data") legacy = zh.index(f"- 算法:{LEGACY_NOTE_ZH}") rath = zh.index("- 并列:Sanjay Rath 版(并列参考,未进打分与时间轴结论;按原书 p.43 正文(第七轮裁定)):起运宫 ") assert legacy < rath assert "| Rath MD | 星座 | 宫主 | 起始年龄 | 结束年龄 | 基本年数 | 修正 | 最终 |" in zh and "| Rath AD | 星座 | 起始年龄 | 结束年龄 |" in zh view = packet["worksheets"]["timing_and_predictive_systems"]["narayana_dasha"]["rath"] - for line in rath_report_lines_zh(view): - text = line - for pattern, target in REGEX_EN: - text = pattern.sub(target, text) - for source, target in PHRASES_EN: - text = text.replace(source, target) - assert not re.search(r"[一-鿿]", text), text + english_packet = copy.deepcopy(packet) + english_packet["report_language"] = "en" + en = _pl9_export_markdown_for_edition(english_packet, "full_data") + lines = [line for line in rath_report_lines_en(view) if line] + assert lines and lines[0].startswith("- Alongside:") + assert not re.search(r"[一-鿿]", "\n".join(lines)) + start = en.index("- Alongside:") + section = en[start:start + 2500] + assert "level " in section and "[source text retained" not in section + assert not re.search(r"[一-鿿]", section) + assert "house " not in section.split("\n", 1)[0] def test_consultation_modules_carry_the_rath_view() -> None: diff --git a/tests/test_report_english_han_sources.py b/tests/test_report_english_han_sources.py new file mode 100644 index 00000000..bbbde0f8 --- /dev/null +++ b/tests/test_report_english_han_sources.py @@ -0,0 +1,148 @@ +"""Source English for the leaks named in issue #89. Fictional inputs only.""" +from __future__ import annotations + +import re + +from scripts.muhurta import calc_panchanga +from scripts.narayana_legacy_label import ( + DIRECTION_EN, + STRENGTH_LEVEL_EN, + rath_report_lines_en, + rath_report_lines_zh, + rath_summary_line_zh, +) +from scripts.pl9_reader_export import render_pl9_parity_markdown +from scripts.yogas_doshas import calc_kaal_sarp_dosha, calc_sade_sati +from scripts.yoga_engine import STRENGTH_EN + +HAN = re.compile(r"[㐀-鿿豈-﫿]") + + +def _rath_view(*, level: int, direction: str, with_divergence: bool) -> dict: + rule_en = "Ketu in the MD sign: reversed (Table 14)" + direction_block = { + "rule_text": "大运星座有计都:反向(Table 14)", + "rule_text_en": rule_en, + "direction": "reverse", + "book_divergence": [ + { + "rule_text": "p.47(J.S. 1.1.34)看大运星座奇偶", + "rule_text_en": "p.47 (J.S. 1.1.34) by the odd/even of the MD sign", + "direction": "zodiacal", + } + ] if with_divergence else [], + } + return { + "seed_sign": "Libra", + "strength_level_decided": level, + "strength_level_text": {1: "占据星数", 5: "宫主星座内度数"}.get(level, f"级别{level}"), + "strength_level_text_en": STRENGTH_LEVEL_EN[level], + "direction": direction, + "direction_text": {key: "计都在起运宫:反向(Table 12)" for key in DIRECTION_EN}.get(direction, "中文顺序"), + "direction_text_en": DIRECTION_EN[direction], + "mahadasha": [{ + "sign": "Libra", "lord": "Venus", "start_age": 0, "end_age": 7, + "base_years": 7, "dignity_adjustment": 0, "years": 7, + }], + "current_dasha": {"md": {"sign": "Libra", "lord": "Venus"}}, + "current_md_antardasha": [{"sign": "Libra", "start_age": 0, "end_age": 0.5}], + "current_md_antardasha_rule": { + "compared": ["Libra", "Aries"], + "stronger_sign": "Libra", + "strength_level_decided": level, + "lord": "Venus", + "start_sign": "Libra", + "direction": direction_block, + }, + } + + +def test_rath_english_lines_cover_levels_tables_and_book_divergence() -> None: + before = rath_summary_line_zh({"seed_sign": "Libra", "strength_level_decided": 5, "strength_level_text": "宫主星座内度数"}) + for level in range(1, 8): + for direction in ("table10_general", "table11_saturn_zodiacal", "table12_ketu_reversed"): + view = _rath_view(level=level, direction=direction, with_divergence=True) + lines = "\n".join(rath_report_lines_en(view)) + assert not HAN.search(lines) + assert "house " not in lines + assert f"level {level}: {STRENGTH_LEVEL_EN[level]}" in lines + assert DIRECTION_EN[direction] in lines + assert "the book itself disagrees, not applied:" in lines + ketu = "\n".join(rath_report_lines_en(_rath_view(level=5, direction="table12_ketu_reversed", with_divergence=True))) + assert "level 5: lord's degree in sign" in ketu + assert "Ketu in the start sign: reversed (Table 12)" in ketu + assert rath_summary_line_zh({"seed_sign": "Libra", "strength_level_decided": 5, "strength_level_text": "宫主星座内度数"}) == before + zh = "\n".join(rath_report_lines_zh(_rath_view(level=5, direction="table12_ketu_reversed", with_divergence=True))) + assert "- 并列:" in zh and "计都在起运宫:反向(Table 12)" in zh + + +def test_panchanga_overall_quality_keeps_chinese_and_adds_english() -> None: + seen = {} + for sun in range(0, 360, 12): + for moon in range(0, 360, 15): + row = calc_panchanga(float(sun), float(moon), 3, 6.0) + seen[row["overall_quality"]] = row["overall_quality_en"] + assert row["overall_quality"] in {"吉(Subha)", "中(Mixed)", "凶(Asubha)"} + assert seen == { + "吉(Subha)": "Auspicious (Subha)", + "中(Mixed)": "Mixed", + "凶(Asubha)": "Inauspicious (Asubha)", + } + + +def _planets(houses: dict[str, int]) -> dict: + return {name: {"house": house, "sign": "Aries"} for name, house in houses.items()} + + +def test_kaal_sarp_english_summaries_count_planets_in_words() -> None: + names = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn") + full = _planets({name: 1 + (index % 6) for index, name in enumerate(names)}) + full["Rahu"] = {"house": 1, "sign": "Aries"} + full["Ketu"] = {"house": 7, "sign": "Libra"} + full_row = calc_kaal_sarp_dosha(full) + assert full_row["summary"].startswith("完全Kaal Sarp Dosha") + assert full_row["summary_en"] == "Full Kaal Sarp Dosha — all 7 planets lie between the Rahu (1)-Ketu axis" + assert not HAN.search(full_row["summary_en"]) + + partial = _planets({name: (1 if index < 5 else 8) for index, name in enumerate(names)}) + partial["Rahu"] = {"house": 1, "sign": "Aries"} + partial["Ketu"] = {"house": 7, "sign": "Libra"} + partial_row = calc_kaal_sarp_dosha(partial) + assert partial_row["summary"].startswith("部分Kaal Sarp Dosha") + assert partial_row["summary_en"] == "Partial Kaal Sarp Dosha — 5 of 7 planets lie between the axis" + assert "condition planets" not in partial_row["summary_en"] + + none = _planets({name: 8 for name in names}) + none["Rahu"] = {"house": 1, "sign": "Aries"} + none["Ketu"] = {"house": 7, "sign": "Libra"} + none_row = calc_kaal_sarp_dosha(none) + assert none_row["summary"] == "无Kaal Sarp Dosha" + assert none_row["summary_en"] == "No Kaal Sarp Dosha" + + +def test_sade_sati_english_summary_has_no_glued_words() -> None: + row = calc_sade_sati("Leo") + assert row["summary"].startswith("Sade Sati阶段框架:") + assert "judge from Saturn's actual position whether it is underway" in row["summary_en"] + assert not HAN.search(row["summary_en"]) + for glued in ("atpresent", "whetherinunderway", "withSaturn"): + assert glued not in row["summary_en"] + + +def test_punarphoo_strength_column_uses_english() -> None: + packet = { + "report_language": "en", + "worksheets": {"advanced_systems": {"yoga": {"detected_yogas": [{ + "name": "Punarphoo Yoga", + "category": "relationship_observation", + "strength": "观察", + "strength_en": "observational", + "combination": "月亮与土星合相", + "combination_en": "Moon conjunct Saturn", + "effects_en": ["Watch the relationship"], + }]}}}, + } + text = render_pl9_parity_markdown(packet) + assert "| Punarphoo Yoga | relationship_observation | observational | Moon conjunct Saturn |" in text + assert "观察" not in text + assert STRENGTH_EN["观察"] == "Watch" diff --git a/tests/test_report_english_sweep.py b/tests/test_report_english_sweep.py new file mode 100644 index 00000000..b7839bd8 --- /dev/null +++ b/tests/test_report_english_sweep.py @@ -0,0 +1,27 @@ +"""200 fixed fictional charts: the English edition must come back whole (issue #89). + +This file is not on the quick gate. Windows timing is recorded in +docs/tasks/PROGRESS-report-english-han-leak-20261021.md. Set +REPORT_ENGLISH_SWEEP_DUMP to a directory to write the English markdown for +the acceptance pass. Workers stay at 4. +""" +from __future__ import annotations + +import os +from concurrent.futures import ProcessPoolExecutor, as_completed + +from tests.report_english_fictional import CHART_COUNT, render_english + + +def test_two_hundred_fictional_charts_have_clean_english() -> None: + workers = min(4, os.cpu_count() or 1, CHART_COUNT) + failures = [] + with ProcessPoolExecutor(max_workers=workers) as pool: + futures = [pool.submit(render_english, index) for index in range(CHART_COUNT)] + for future in as_completed(futures): + row = future.result() + if row["status"] != "ok" or row["han"] or row["glue"]: + failures.append( + f"#{row['i']} status={row['status']} han={row['han']} chars={row['han_chars']} glue={row['glue']}" + ) + assert failures == [] diff --git a/tests/test_report_english_sweep_smoke.py b/tests/test_report_english_sweep_smoke.py new file mode 100644 index 00000000..3bc39894 --- /dev/null +++ b/tests/test_report_english_sweep_smoke.py @@ -0,0 +1,46 @@ +"""Smoke slice of the English sweep, on the quick gate (issue #89 / BUG-1410). + +Indices are from tests.report_english_fictional.fictional_case. Together they +hit Rath level 5, an inauspicious Panchanga, Ketu in the start sign, a Table 14 +sub-period, Punarphoo, and full Kaal Sarp. Replacing the English Rath lines +with the Chinese ones makes this file fail. +""" +from __future__ import annotations + +from concurrent.futures import ProcessPoolExecutor, as_completed + +from tests.report_english_fictional import render_english + +# Filled once the flag scan names a chart for every class. The test checks the +# flags, so a stale index fails instead of silently dropping a class. +# 1 table12, 4/10/12 level 5, 5 table14 + inauspicious, 6 table12, +# 9/12 partial Kaal Sarp, 11/15 table 11, 14 inauspicious, +# 32 full Kaal Sarp, 34 Punarphoo. Cheap positions matched the +# 0-15 packet Kaal Sarp types before these two were added. +SMOKE_INDEXES = (1, 4, 5, 6, 9, 10, 11, 12, 14, 15, 32, 34) + + +def test_smoke_charts_cover_every_leak_class_and_render_clean_english() -> None: + rows = [] + failures = [] + with ProcessPoolExecutor(max_workers=min(4, len(SMOKE_INDEXES))) as pool: + futures = [pool.submit(render_english, index) for index in SMOKE_INDEXES] + for future in as_completed(futures): + row = future.result() + rows.append(row) + if row["status"] != "ok" or row["han"] or row["glue"]: + failures.append( + f"#{row['i']} status={row['status']} han={row['han']} chars={row['han_chars']} glue={row['glue']}" + ) + levels = {row["level"] for row in rows} + directions = {row["direction"] for row in rows} + ad_rules = {row["ad_rule"] for row in rows} + overall = {row["overall"] for row in rows} + kaal = {row["kaal"] for row in rows} + assert 5 in levels + assert "table12_ketu_reversed" in directions + assert "rath_ketu_table14_v1" in ad_rules + assert "凶(Asubha)" in overall + assert any(row["punarphoo"] for row in rows) + assert "full" in kaal + assert failures == []