feat(report): English reader edition from the same packet; yoga combination English twins (E1/E2/E3 wiring)

- /api/professional_report_reference accepts languages [zh,en]; one packet, zh
  byte-identical, markdown_en + reader_dasha_applicability_en, or
  english_unavailable when a Chinese character would leak.
- pl9_reader_english renders the parity volume with report_language=en and a
  fixed term table; yoga_engine carries combination_en / effects_en / strength_en.
- 75 fictional charts: zero Han, same line count, same numbers outside yoga tables.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
This commit is contained in:
Jesse_Chen
2026-09-29 19:26:00 +08:00
co-authored by Claude Opus 5.5
parent 0c06ab1d78
commit 419e1f4d37
8 changed files with 833 additions and 53 deletions
+169
View File
@@ -0,0 +1,169 @@
"""English edition of the user-facing PL9 reader (TASK-report-english-edition-20260929).
The Chinese reader is ``pl9_reader_export._render_pl9_user_markdown``: it renders the
parity volume and then localises it with ordered string passes. The English
edition renders the same parity volume from the same packet with
``report_language = 'en'`` (planet and sign tokens stay English, yoga rows use the
engine's ``*_en`` twins), then:
1. drops the same audit-only lines and page references as the Chinese reader;
2. replaces the renderer's hard-coded Chinese labels with fixed English text
(``pl9_reader_english_terms``);
3. replaces engineering vocabulary with English public wording.
Nothing here computes. A Chinese character that survives is a missing entry,
and the caller treats the English edition as unavailable rather than shipping it.
"""
from __future__ import annotations
import copy
import re
try:
from scripts.pl9_reader_english_terms import PHRASES_EN, REGEX_EN
except ModuleNotFoundError: # pragma: no cover - direct scripts/ execution
from pl9_reader_english_terms import PHRASES_EN, REGEX_EN
HAN = re.compile(r"[㐀-鿿豈-﫿]")
def han_leaks(text: str) -> list[str]:
"""Distinct runs of Han characters, for the self-check and for tests."""
return sorted(set(re.findall(r"[㐀-鿿豈-﫿]+", text)))
def _english_packet(packet: dict) -> dict:
"""Deep copy with the language flag set and yoga rows switched to their English twins."""
english = copy.deepcopy(packet)
english['report_language'] = 'en'
advanced = ((english.get('worksheets') or {}).get('advanced_systems') or {})
yoga = advanced.get('yoga') if isinstance(advanced, dict) else None
if isinstance(yoga, dict):
for key in ('detected_yogas', 'yogas'):
rows = yoga.get(key)
if not isinstance(rows, list):
continue
for row in rows:
if not isinstance(row, dict):
continue
if 'combination_en' in row:
row['combination'] = row['combination_en']
if 'effects_en' in row:
row['effects'] = row['effects_en']
if 'strength_en' in row:
row['strength'] = row['strength_en']
row['name_cn'] = row.get('name') or row.get('name_cn')
return english
def _public_wording_en(text: str) -> str:
"""English counterpart of the Chinese reader's engineering-vocabulary pass."""
for raw, public in (
("parameter_sensitive / unverified", "to be checked"),
("pyjhora_behavior_only / not_multiengine_parity", "reference"),
("blocked_and_conflict_fields_cannot_generate_final_predictions", "data only"),
("parameter_sensitive", "reference"),
("partial_verified", "listed"),
("pyjhora_behavior_only", "reference"),
("restricted_or_unclosed", "to be checked"),
("blocked_external_callable", "external replay pending"),
("not_applicable", "not listed"),
("missing_in_local", "not listed separately"),
("functional_role_not_returned", "not listed separately"),
("external_mudda_start_boundary", "reference start"),
("dasha_beginning_dates", "start dates"),
("dasha_ending_dates", "end dates"),
("natural_benefic", "natural benefic"),
("natural_malefic", "natural malefic"),
("functional_benefic", "functional benefic"),
("functional_malefic", "functional malefic"),
("functional_neutral", "functional neutral"),
("observed_relationships", "formed"),
("observed_empty", "not formed"),
("external_replay_pending", "pending"),
("annual_tajika_pack", "annual structure"),
("kp_monthly_report_packet", "monthly focus"),
("source_path", "source"),
("{planets}", "the planets involved"),
("blocked", "not listed"),
):
text = text.replace(raw, public)
text = re.sub(r"\b[a-zA-Z_]+_profile\b", "calculation setting", text)
text = re.sub(r"\b[a-zA-Z_]+_status\b", "status", text)
text = re.sub(r"\b[a-zA-Z_]+_producer\b", "calculation", text)
text = re.sub(r"\b[a-zA-Z_]+_path\b", "source", text)
text = re.sub(r"\bproducer\b", "calculation", text, flags=re.I)
text = re.sub(r"\bschema\b", "structure", text, flags=re.I)
text = re.sub(r"\bprofile\b", "setting", text)
text = re.sub(r"\bProfile\b", "Setting", text)
text = re.sub(r"\baudit\b", "check", text)
return text
def _translate(text: str) -> str:
for pattern, replacement in REGEX_EN:
text = pattern.sub(replacement, text)
for raw, public in PHRASES_EN:
text = text.replace(raw, public)
return text
def render_pl9_user_markdown_en(packet: dict) -> str:
"""Render the English reader volume from the same packet the Chinese one uses."""
try:
from scripts import pl9_reader_export as reader
except ModuleNotFoundError: # pragma: no cover - direct scripts/ execution
import pl9_reader_export as reader
english = _english_packet(packet)
text = reader.render_pl9_parity_markdown(english)
title = reader._pl9_english_report_title(english)
text = re.sub(r"^#\s+.*$", f"# {title}", text, count=1, flags=re.MULTILINE)
text = re.sub(r"(PL9 p\d+(?:-p\d+)?[^)]*)", "", text)
text = re.sub(r"\s*\(PL9 p\d+(?:-p\d+)?[^)]*\)", "", text)
drop_fragments = (
"本册只编排原版",
"研究性证据",
"审计",
"producer",
"PyJHora",
"source_path",
"schema",
"blocked",
"parameter_sensitive",
"native producer",
"Report Layers",
"Worksheet Index",
"Report Pack Manifest",
"发布状态",
"质量检查",
"Chart Identity",
"不自动进入公开发布",
"对标主册",
)
lines: list[str] = []
for raw_line in text.splitlines():
if any(fragment in raw_line for fragment in drop_fragments):
continue
line = re.sub(r"p\d+-p\d+\s*", "", raw_line)
line = re.sub(r"p\d+\s*[·.-]\s*", "", line)
line = re.sub(r"^#+\s*p\d+\s*", lambda match: match.group(0).replace(match.group(0).split()[-1], ""), line)
line = line.replace(" · ", " ")
lines.append(line.rstrip())
text = "\n".join(lines)
text = _translate(text)
text = text.replace("PL9 p", "p. ").replace("PL9", "classical Jyotish")
text = _public_wording_en(text)
text = re.sub(r"(?m)^(#{2,6}\s*)p\d+(?:-p\d+)?\s*[·.-]?\s*", r"\1", text)
text = re.sub(r"(?m)^(#{1,6})\s+/\s+", r"\1 ", text)
text = re.sub(r"(?m)^(#{1,6}) {2,}", r"\1 ", text)
text = re.sub(r"^#\s+.*$", f"# {title}", text, count=1, flags=re.MULTILINE)
# Full-width punctuation from the shared renderer reads badly in English.
for wide, ascii_form in ((";", "; "), (",", ", "), (":", ": "), ("、", ", "), ("(", " ("), (")", ")"), ("—", " - ")):
text = text.replace(wide, ascii_form)
text = re.sub(r"(?<=\S) {2,}(?=\S)", " ", text)
text = re.sub(r"\( ", "(", text)
text = re.sub(r"[ \t]+\n", "\n", text)
text = re.sub(r"\n{3,}", "\n\n", text).strip() + "\n"
return text
+244
View File
@@ -0,0 +1,244 @@
"""Fixed English text for the Chinese labels in the PL9 reader volume.
Two sources of Chinese reach the reader: labels hard-coded in
``pl9_reader_export.render_pl9_parity_markdown`` and short texts the engine
modules write in Chinese (dosha summaries, Jaimini karaka roles, method notes,
aspect names). Both are listed here. Templated engine texts are matched with
regular expressions; everything else is an exact phrase.
Rules for entries: no Chinese in a value, and every number in the Chinese text
appears in the English text in the same order, so the two editions carry the
same numbers (tests/test_report_english_edition.py checks both).
"""
from __future__ import annotations
import re
_PHRASES: dict[str, str] = {
# ── chapter and section headings ──
"## 出生资料与图盘": "## Birth Data and Charts",
"## 行星、星宿与宫位解释": "## Planets, Nakshatras and Houses",
"## 强度、关系与 Ashtakavarga 技术页": "## Strength, Relationships and Ashtakavarga",
"## 标准 Dasha 表格页": "## Standard Dasha Tables",
"## 原版 Dasha 日期表(同案 PL9 数据)": "## Original Dasha Date Tables (same chart)",
"## 原版 Dasha 日期表": "## Original Dasha Date Tables",
"## Saturn 与 KP 支持页": "## Saturn and KP",
"## 年度图盘与 Tajika": "## Annual Charts and Tajika",
"## Bhavesh、Dosha 与 Yoga 解释页": "## Bhavesh, Dosha and Yoga",
"## 大运解释": "## Dasha Interpretation",
"出生资料补充": "Birth Particulars (supplement)",
"Birth Chart 行星原始位置": "Birth Chart Planetary Positions",
"行星主星与状态": "Planet Lords and Dignity",
"Sudarshan Chakra 资料": "Sudarshan Chakra",
"Upagraha / Sub-Planets 点位": "Upagraha / Sub-Planet Positions",
"特殊上升与派生点补充": "Special Lagnas and Points (supplement)",
"敏感点与特殊落点": "Sensitive Points and Special Placements",
"本命与分盘北印度图盘": "Natal and Divisional Charts (North Indian)",
" 数值位置表": " Position Table",
"Shadbala / Bhava Bala 摘要": "Shadbala / Bhava Bala Summary",
"Avastha 行星状态": "Avastha Planetary States",
"Bhava Bala 十二宫力量": "Bhava Bala Strength of the Twelve Houses",
"Planetary Friendship 矩阵源数据": "Planetary Friendship Matrix",
"相位矩阵": "Aspect Matrix",
"Jaimini Karaka、Arudha Pada 与特殊 Lagna": "Jaimini Karakas, Arudha Padas and Special Lagnas",
"Ashtakavarga 完整宫位分数": "Ashtakavarga Full House Scores",
"Vimsopaka / Pushkara / Vargottama 辅助强度": "Vimsopaka / Pushkara / Vargottama Supporting Strength",
"Ashtakavarga Pinda 与 Shodhya/Yoga Pinda 数值表。": "Ashtakavarga Pinda and Shodhya / Yoga Pinda values.",
"Ashtakavarga Shodhya / Yoga Pinda 原始值": "Ashtakavarga Shodhya / Yoga Pinda Raw Values",
"Chakra 图阵页": "Chakra Diagrams",
"以下表格保留同一命盘原版报告中的完整可见日期和星座边界,便于逐项读取与校对。":
"The tables below keep every visible date and sign boundary from the original report for this chart, for line-by-line reading and checking.",
"KP 原版细格": "KP Original Detail Grid",
"Varshaphala 基础资料与年度行星表": "Varshaphala Basics and Annual Planet Table",
"Varshaphala 年度结果解释": "Varshaphala Annual Results",
"Annual Dasha 结果解释": "Annual Dasha Results",
"Mudda / Patyayini 年度 Dasha 表": "Mudda / Patyayini Annual Dasha Table",
"Patyayini / Patyamsha ending-date 表": "Patyayini / Patyamsha Ending-Date Table",
"Tajika Yoga Presence 对照表": "Tajika Yoga Presence Comparison",
"Tajika Yoga Presence 表": "Tajika Yoga Presence Table",
"Sahams 点位表": "Saham Positions",
"公开 Panchavargiya 数值复核边界": "Public Panchavargiya Value Check",
"年度特殊点": "Annual Special Points",
"Tripataki Chakra 年盘落点": "Tripataki Chakra Annual Placements",
"年度月返照图盘数据": "Monthly Returns within the Year",
"生命周期年度返照图盘数据": "Lifetime Annual Return Charts",
"出生地八年年度总览": "Eight-Year Overview (birthplace)",
"现居地八年年度总览": "Eight-Year Overview (current residence)",
"年度北印度图盘": "Annual Charts (North Indian)",
" 年度 Varshaphala / Tajika 细项": " Annual Varshaphala / Tajika Details",
"年度行星资料": "Annual Planet Data",
"Annual Dasha 阶段表": "Annual Dasha Periods",
"年度行星扩展字段": "Annual Planet Extras",
"年度锚点": "Annual Anchors",
"年度行星资料": "Annual Planet Data",
"年度结果": "Annual Results",
"宫主(Bhavesh)落宫解释": "House Lords (Bhavesh) by Placement",
"Bhavesh 宫主逐宫解释": "Bhavesh House by House",
"Dosha 逐项事实与解释": "Dosha Facts and Notes",
"传统辅助建议": "Traditional Supportive Practices",
# ── table headers ──
"| 行星 | 星座主 | 星宿主 | 分主 | 分分主 | 尊贵状态 | 力量比 |":
"| Planet | Sign lord | Star lord | Sub lord | Sub-sub lord | Dignity | Strength ratio |",
"星座主、星宿主、分主、分分主:这颗行星所在星座、星宿与 KP 细分各自的主星。力量比:六维力量与所需力量之比,1 以上为达标。":
"Sign lord, star lord, sub lord and sub-sub lord: the rulers of the planet's sign, nakshatra and KP subdivisions. Strength ratio: Shadbala against the required strength; 1 or above meets it.",
"| 对象 | 落座 | 落座度数 | 黄经 | 整宫宫位 |": "| Body | Sign | Degree in sign | Longitude | Whole-sign house |",
"| House | Sign | Lord | Score | 关键因素 |": "| House | Sign | Lord | Score | Key factors |",
"| House | Sign | SAV | BAV/承接 | Full with Lagna |": "| House | Sign | SAV | BAV / carry | Full with Lagna |",
"| 序号 | 主运 | 起始 | 结束 | 年数 |": "| No. | Mahadasha | Start | End | Years |",
"| Vimshottari AD | 主运 | 子运 | 起始 | 结束 |": "| Vimshottari AD | Mahadasha | Antardasha | Start | End |",
"| Vimshottari PD | 主运 | 子运 | 次子运 | 起始 | 结束 |": "| Vimshottari PD | Mahadasha | Antardasha | Pratyantardasha | Start | End |",
"| 序号 | 主星/星座 | 起始 | 结束 | 年数 |": "| No. | Lord / sign | Start | End | Years |",
"| AD/child | 主层 | 子层 | 起始 | 结束 |": "| AD/child | Main period | Sub-period | Start | End |",
"| 主运 | 起始年龄 | 子段 | 起始 | 结束 |": "| Mahadasha | Starting age | Sub-period | Start | End |",
"| 项目 | 锚点 | 年度主题 |": "| Item | Anchor | Theme of the year |",
"| 阶段 | 主星 | 年盘宫位 | 日期边界 | 主题 |": "| Period | Lord | Annual house | Date range | Theme |",
"| balance profile | 主星 | 开始 | 结束 | 边界口径 |": "| Balance | Lord | Start | End | Boundary basis |",
"| 主层 | 子层 | 边界 | 边界语义 |": "| Main period | Sub-period | Boundary | Boundary meaning |",
"| Yoga | 结果 | 形成行星 | 相位运动(入相/分离) |": "| Yoga | Result | Forming planets | Motion (applying / separating) |",
"| Saham | 星座 | 星座内度数 |": "| Saham | Sign | Degree in sign |",
"| 项目 | 结果 |": "| Item | Result |",
"| 月份 | 本地时刻 | 年盘上升 | 上升度数 | 行星黄经(度) |": "| Month | Local time | Annual ascendant | Ascendant degree | Planet longitudes (deg) |",
"| 年龄 | 年份 | 年返时刻 | 上升 | 上升度数 | 行星黄经(度) |": "| Age | Year | Return time | Ascendant | Ascendant degree | Planet longitudes (deg) |",
"| 年份 | 年龄 | 年返时刻 | 年盘上升 |": "| Year | Age | Return time | Annual ascendant |",
"| 项目 | 数值 |": "| Item | Value |",
"| 行星 | 星座 | 落座度数 | 黄经 |": "| Planet | Sign | Degree in sign | Longitude |",
"| 点位 | 星座 | 落座度数 | 黄经 | 守护星 |": "| Point | Sign | Degree in sign | Longitude | Ruler |",
"| 月份 | 本地时刻 | 年盘上升 | 上升度数 |": "| Month | Local time | Annual ascendant | Ascendant degree |",
"| 对象 | 建议 | 宝石/次数 |": "| For | Suggestion | Gem / count |",
"| 公开复核状态 |": "| Public check status |",
"| 覆盖行星 |": "| Planets covered |",
"| 已匹配分量 |": "| Components matched |",
"| 已匹配总分 |": "| Totals matched |",
"| 可直接用于公式升级 |": "| Usable for formula upgrade |",
"| 年返上升 |": "| Annual ascendant |",
# ── labelled lines ──
"- 姓名:": "- Name: ",
"- 出生:": "- Born: ",
"- 计算口径:": "- Calculation settings: ",
"- Shadbala 方法:": "- Shadbala method: ",
"- 外部边界:": "- Method note: ",
"- 方法:": "- Method: ",
"- BAV 校验:": "- BAV check: ",
"- 当前阶段:": "- Current phase: ",
"- 是否 active:": "- Active: ",
"- 摘要:": "- Summary: ",
"- 年返上升:": "- Annual ascendant: ",
# ── birth-parameter row labels ──
"| 出生日期 |": "| Birth date |",
"| 出生时间 |": "| Birth time |",
"| 出生星期 |": "| Weekday |",
"| 出生地点 |": "| Birthplace |",
"| 城市 |": "| City |",
"| 省州 |": "| State / province |",
"| 国家 |": "| Country |",
"| 纬度 |": "| Latitude |",
"| 经度 |": "| Longitude |",
"| 时区 |": "| Time zone |",
"| 岁差名称 |": "| Ayanamsa name |",
"| 岁差值 |": "| Ayanamsa value |",
"| 岁差 |": "| Ayanamsa |",
"| 交点模式 |": "| Node mode |",
"| 宫位系统 |": "| House system |",
"| 综合性质 |": "| Overall quality |",
"| 吉性要素数量 |": "| Auspicious elements |",
"| 要素总数 |": "| Total elements |",
# ── annual labels and values ──
"Muntha 所在星座的守护星(当前本地口径)": "Ruler of the Muntha sign (current local method)",
"两种计算口径给出的年主不同,暂不合并": "The two methods give different year lords; not merged",
"(两者不同)": " (they differ)",
"太阳返照": "Solar return",
"另一口径": "Other method",
"入相": "applying",
"分离": "separating",
# ── public status words (renderer helpers) ──
"由生命周期图盘页承接": "carried by the lifetime chart pages",
"外部回放待校对": "external replay pending",
"外部行为参考": "reference rule",
"参数敏感": "parameter-sensitive",
"需补资料": "needs more data",
"已生成": "generated",
"已核验": "verified",
"需校对": "to be checked",
"不适用": "not applicable",
"已形成": "formed",
"未形成": "not formed",
"待补充": "pending",
"证据口径": "Evidence basis",
"证据标签": "Evidence basis",
"源数据未提供宝石或次数": "No gem or repetition count supplied",
"一致": "match",
"参考": "reference",
"未列": "not listed",
# ── engine texts (fixed) ──
"Shadbala六重力量(absolute Virupas; precise Sthana/Dig/Kala/Drik; bounded BPHS Chesta)":
"Shadbala six-fold strength, in Virupas, combining Sthana, Dig, Kala, Drik and BPHS Chesta",
"Chesta remains cross-engine method-conflicted; totals inherit that boundary.":
"Chesta Bala depends on the calculation method, so compare totals within the same method.",
"Shadbala六重力量": "Shadbala six-fold strength",
"Ashtakavarga八分法(BPHS/PVR书例校准v2.1)": "Ashtakavarga eight-fold method (calibrated to BPHS / PVR book examples, v2.1)",
"吉(Subha)": "Auspicious (Subha)",
"中(Mixed)": "Mixed",
"凶(Asubha)": "Inauspicious (Asubha)",
"无Kaal Sarp Dosha": "No Kaal Sarp Dosha",
"无Mangal Dosha(Mars不在煞宫)": "No Mangal Dosha (Mars is not in a dosha house)",
"无Pitra Dosha明显迹象": "No clear sign of Pitra Dosha",
"Rahu位置未知,无法判断Kaal Sarp Dosha": "Rahu's position is unknown; Kaal Sarp Dosha cannot be judged",
"Sun位置未知,无法判断Pitra Dosha": "The Sun's position is unknown; Pitra Dosha cannot be judged",
"Moon星座未知,无法判断Sade Sati": "The Moon's sign is unknown; Sade Sati cannot be judged",
"Mars在自身星座,部分缓和": "Mars in its own sign, partly mitigated",
"Mars擢升,完全解除": "Mars exalted, fully cancelled",
# Jaimini karakas (scripts/jaimini.py KARAKA_CN / KARAKA_DOMAINS)
"灵魂星AK": "Atmakaraka AK",
"事业星AmK": "Amatyakaraka AmK",
"兄弟星BK": "Bhratrikaraka BK",
"母亲星MK": "Matrikaraka MK",
"子女星PK": "Putrakaraka PK",
"障碍星GK": "Gnatikaraka GK",
"配偶星DK": "Darakaraka DK",
"父亲星PiK": "Pitrukaraka PiK",
"灵魂使命、核心自我、人生最高目标": "Soul purpose, core self, highest aim in life",
"事业方向、主要谋士、权力代理": "Career direction, chief adviser, agent of power",
"兄弟姐妹、勇气、冒险精神": "Siblings, courage, spirit of adventure",
"母亲、家庭根基、情感安全感": "Mother, family roots, emotional security",
"子女、创造力、学生、智能成果": "Children, creativity, students, intellectual output",
"竞争对手、疾病、障碍、转化力量": "Rivals, illness, obstacles, transforming force",
"配偶特质、伴侣关系、婚姻质量": "Spouse's nature, partnership, quality of marriage",
"父亲、祖先业力、传统传承": "Father, ancestral karma, tradition",
# planets and signs, if any Chinese form slipped through a table cell
"北交点/罗睺": "Rahu",
"南交点/计都": "Ketu",
"北交点": "Rahu",
"南交点": "Ketu",
}
PHRASES_EN: list[tuple[str, str]] = sorted(_PHRASES.items(), key=lambda item: len(item[0]), reverse=True)
# Templated engine texts. Applied before the phrase table.
REGEX_EN: list[tuple[re.Pattern[str], str]] = [
# scripts/pl9_reader_export.py: f"第{house}宫主:{lord}"
(re.compile(r"第(\d+)宫主:"), r"Lord of house \1: "),
# scripts/aspects.py / jyotish_engine.py: f"{n}宫相位"
(re.compile(r"(\d+)宫相位"), r"\1th-house aspect"),
# scripts/yogas_doshas.py Mangal Dosha summary
(re.compile(r"(无|有)Mangal Dosha——Mars在(\d+)宫([A-Za-z]+),严重程度:([A-Za-z_]+)"),
lambda m: f"{'No' if m.group(1) == '无' else 'Has'} Mangal Dosha: Mars in house {m.group(2)} {m.group(3)}, severity: {m.group(4)}"),
# Kaal Sarp summaries
(re.compile(r"完全Kaal Sarp Dosha——所有(\d+)颗行星在Rahu\((\d+)\)—Ketu轴线之间"),
r"Full Kaal Sarp Dosha: all \1 planets between the Rahu (\2)–Ketu axis"),
(re.compile(r"部分Kaal Sarp Dosha——(\d+)/(\d+)颗行星在轴线之间"),
r"Partial Kaal Sarp Dosha: \1/\2 planets between the axis"),
# Pitra Dosha indicators
(re.compile(r"([A-Za-z]+)\((\d+)宫\)克Sun\((\d+)宫\)"), r"\1 (house \2) afflicts the Sun (house \3)"),
(re.compile(r"Sun\((\d+)宫\)与Rahu\((\d+)宫\)合相/相邻"), r"Sun (house \1) conjunct or next to Rahu (house \2)"),
(re.compile(r"有Pitra Dosha迹象:"), "Signs of Pitra Dosha: "),
# Sade Sati framework summary
(re.compile(r"Sade Sati阶段框架:上升期=([A-Za-z]+),高峰期=([A-Za-z]+)(最困难),下降期=([A-Za-z]+);需结合Saturn实际位置判断是否在进行中"),
r"Sade Sati phases: rising = \1, peak = \2 (hardest), setting = \3; whether it is under way depends on Saturn's actual position"),
# Yoga strength words left over when a rule has no strength_en: whole table cells only.
(re.compile(r"(?<=\| )中强(?= \|)"), "Moderately strong"),
(re.compile(r"(?<=\| )中弱(?= \|)"), "Moderately weak"),
(re.compile(r"(?<=\| )强(?= \|)"), "Strong"),
(re.compile(r"(?<=\| )中(?= \|)"), "Moderate"),
(re.compile(r"(?<=\| )弱(?= \|)"), "Weak"),
]
+22 -6
View File
@@ -57,6 +57,12 @@ def _pl9_export_markdown_for_edition(packet: dict, edition: str) -> str:
_bind_engine_symbols()
normalized = str(edition or "").strip()
if normalized == "reader_main":
if _pl9_report_language(packet) == 'en':
try:
from scripts.pl9_reader_english import render_pl9_user_markdown_en
except ModuleNotFoundError: # pragma: no cover - direct scripts/ execution
from pl9_reader_english import render_pl9_user_markdown_en
return render_pl9_user_markdown_en(packet)
return _render_pl9_user_markdown(packet)
if normalized in {"pl9_parity", "pl9_fixed_204"}:
return render_pl9_parity_markdown(packet)
@@ -83,15 +89,18 @@ def _pl9_english_report_title(packet: dict) -> str:
return f"{name}'s Vedic Astrology Chart Report" if name else "Vedic Astrology Chart Report"
def _dignity_label(value) -> str:
def _dignity_label(value, language: str = 'zh') -> str:
"""Engine dignity labels are bilingual (``入旺(Exalted)``); the reader shows one name.
The glossary pass would otherwise translate the English half too and print
``入旺(入旺)``. Anything not in that shape passes through unchanged.
``入旺(入旺)``. Anything not in that shape passes through unchanged. The
English edition takes the English half.
"""
text = str(value or '').strip()
match = re.fullmatch(r'([^()()]+?)\s*[((][A-Za-z][A-Za-z \-]*[))]', text)
return match.group(1).strip() if match else text
match = re.fullmatch(r'([^()()]+?)\s*[((]([A-Za-z][A-Za-z \-]*)[))]', text)
if not match:
return text
return match.group(2).strip() if language == 'en' else match.group(1).strip()
def _pl9_report_language(packet: dict) -> str:
@@ -459,6 +468,7 @@ def _render_pl9_user_markdown(packet: dict) -> str:
def render_pl9_parity_markdown(packet: dict) -> str:
"""Render only PL9 page-family material admitted to the parity main volume."""
_bind_engine_symbols()
_lang = _pl9_report_language(packet)
def _unwrap(raw_value):
if isinstance(raw_value, dict) and 'result' in raw_value and 'response_envelope' in raw_value:
return raw_value.get('result')
@@ -538,6 +548,9 @@ def render_pl9_parity_markdown(packet: dict) -> str:
}
sign_map = globals().get('SIGNS_CN', {})
raw = str(value or '').strip()
if _lang == 'en':
return raw or '-'
return label_map.get(raw) or sign_map.get(raw) or raw or '-'
def _annual_field_text(field, *keys) -> str:
@@ -804,7 +817,7 @@ def render_pl9_parity_markdown(packet: dict) -> str:
lines.append(
f"| {_cell(_token(planet))} | {_cell(_token(row.get('rasi_lord')))} | "
f"{_cell(_token(row.get('nakshatra_lord')))} | {_cell(_token(row.get('sub_lord')))} | "
f"{_cell(_token(row.get('sub_sub_lord')))} | {_cell(_dignity_label(row.get('status')))} | {_cell(_num(row.get('sb'), 2))} |"
f"{_cell(_token(row.get('sub_sub_lord')))} | {_cell(_dignity_label(row.get('status'), _lang))} | {_cell(_num(row.get('sb'), 2))} |"
)
lines.extend([
'',
@@ -2367,7 +2380,7 @@ def render_pl9_parity_markdown(packet: dict) -> str:
placement = f"{_token(lord_row.get('sign'))} H{lord_row.get('house') or '-'} {_degree(lord_row.get('degree_in_sign'))}"
lines.append(
f"| {index} | {_cell(_token(house.get('cusp_sign') or house.get('sign')))} | {_cell(_token(lord))} | "
f"{_cell(placement)} | {_cell(_dignity_label(lord_row.get('status') or lord_row.get('dignity')))} | {_cell('active' if str(lord) in active_lords else 'background')} |"
f"{_cell(placement)} | {_cell(_dignity_label(lord_row.get('status') or lord_row.get('dignity'), _lang))} | {_cell('active' if str(lord) in active_lords else 'background')} |"
)
lines.append('')
bhavesh_pages = _list(bhavesh_pack.get('pages'))
@@ -2556,4 +2569,7 @@ def render_pl9_parity_markdown(packet: dict) -> str:
continue
seen_blocks.add(value)
lines.extend([_public_report_text(value), ''])
if _lang == 'en':
# The English edition does its own public-wording pass (pl9_reader_english).
return '\n'.join(lines)
return _sanitize_pl9_public_markdown('\n'.join(lines))
+56
View File
@@ -7,6 +7,7 @@ calculation, and delegates packet assembly/rendering to ``jyotish_engine``.
from __future__ import annotations
import copy
from datetime import datetime
from importlib import import_module
from types import SimpleNamespace
@@ -77,6 +78,54 @@ def _normalize_packs(value: Any) -> list[str]:
return selected
def _normalize_languages(value: Any) -> list[str]:
"""``languages`` is optional; only ``zh`` and ``en`` exist. Absent means today's zh-only reply."""
if value is None:
return []
if not isinstance(value, list) or any(not isinstance(item, str) for item in value):
raise ProfessionalReportReferenceInputError("languages must be an array of strings")
selected = []
for item in value:
language = item.strip().lower()
if language not in {"zh", "en"}:
raise ProfessionalReportReferenceInputError("languages may contain only zh and en")
if language not in selected:
selected.append(language)
return selected
def _english_edition(packet: dict, include_fact_tables: bool) -> dict[str, Any]:
"""Render the English reader from a deep copy of the same packet.
The English edition is secondary: any failure, or any Chinese character left
in it, drops it and reports why. The Chinese edition is never touched.
"""
try:
from scripts.pl9_reader_english import HAN, _public_wording_en
from scripts.pl9_reader_export import _pl9_export_markdown_for_edition
except ModuleNotFoundError: # pragma: no cover - direct scripts/ execution path
from pl9_reader_english import HAN, _public_wording_en
from pl9_reader_export import _pl9_export_markdown_for_edition
try:
english = copy.deepcopy(packet)
english["report_language"] = "en"
markdown = _pl9_export_markdown_for_edition(english, READER_MAIN_EDITION)
applicability = (
_public_wording_en(render_dasha_applicability(copy.deepcopy(packet), language="en"))
if include_fact_tables else None
)
except Exception: # noqa: BLE001 - the English edition must never fail the Chinese one
return {"english_unavailable": "render_error"}
if not isinstance(markdown, str) or not markdown.strip():
return {"english_unavailable": "empty"}
if HAN.search(markdown) or (applicability is not None and HAN.search(applicability)):
return {"english_unavailable": "han_leak"}
return {
"markdown_en": markdown,
**({"reader_dasha_applicability_en": applicability} if applicability is not None else {}),
}
def _load_engine():
try:
return import_module("scripts.jyotish_engine")
@@ -120,6 +169,9 @@ def build_professional_report_reference(handler, body: dict[str, Any], *, engine
output_format = _normalize_format(body.get("format"))
edition = _normalize_edition(body.get("edition"))
packs = _normalize_packs(body.get("packs"))
languages = _normalize_languages(body.get("languages"))
if languages and (output_format != "markdown" or edition != READER_MAIN_EDITION):
raise ProfessionalReportReferenceInputError("languages requires format markdown and edition reader_main")
birth = handler._high_rigor_birth_payload(body)
full_reading = handler._compute_full_reading_for_thematic(birth)
@@ -142,6 +194,9 @@ def build_professional_report_reference(handler, body: dict[str, Any], *, engine
from scripts.pl9_reader_export import _pl9_export_markdown_for_edition
except ModuleNotFoundError: # pragma: no cover - direct scripts/ execution path
from pl9_reader_export import _pl9_export_markdown_for_edition
# English renders from its own deep copy first, so nothing the Chinese
# renderer does to the packet can reach it (and vice versa).
english = _english_edition(packet, body.get("include_fact_tables") is True) if "en" in languages else {}
markdown = _pl9_export_markdown_for_edition(packet, READER_MAIN_EDITION)
else:
markdown = resolved_engine.render_pl9_markdown(packet)
@@ -153,6 +208,7 @@ def build_professional_report_reference(handler, body: dict[str, Any], *, engine
**({"fact_table_packet": report_density_packet(packet),
"reader_dasha_applicability": clean_reader_appendix_markdown(render_dasha_applicability(packet))}
if body.get("include_fact_tables") is True else {}),
**(english if edition == READER_MAIN_EDITION and "en" in languages else {}),
}
return {
"format": "json",
+132 -47
View File
@@ -78,6 +78,81 @@ HOUSE_STRENGTHS = {
}
# ── English twin for combination text (TASK-report-english-edition-20260929) ──
# The Chinese combination strings stay exactly as before; each one also carries
# an English rendering so the reader's English edition never has to translate
# Chinese back. `BiText` compares, hashes and formats as its Chinese text.
class BiText(str):
"""A Chinese string that also carries its English twin in ``.en``."""
en: str
def __new__(cls, zh, en=None):
obj = super().__new__(cls, zh)
obj.en = str(zh) if en is None else str(en)
return obj
def _en_text(value) -> str:
return value.en if isinstance(value, BiText) else str(value)
def _planet(p) -> BiText:
return BiText(PLANET_CN.get(p, p), p)
def _planets(ps) -> BiText:
ps = list(ps)
return BiText("、".join(PLANET_CN.get(p, p) for p in ps), ", ".join(str(p) for p in ps))
def _bijoin(sep: str, parts) -> BiText:
kept = [part for part in parts if part]
return BiText(sep.join(str(part) for part in kept), sep.join(_en_text(part) for part in kept))
DEFAULT_COMBO_EN = {
"{a}与{b}同在第{h}宫": "{a} and {b} together in house {h}",
"{p}在第{h}宫": "{p} in house {h}",
"{p}在第{h}宫(从{fp}的{rel})": "{p} in house {h} ({rel} from {fp})",
"{p}{dignity}在{sign}": "{p} {dignity} in {sign}",
"{count}颗行星满足条件": "{count} planets meet the condition",
"行星分布在{count}个宫位": "Planets spread over {count} houses",
"第{target}宫被夹击": "House {target} hemmed in",
"所有指定行星均落入{houses}": "All listed planets fall in houses {houses}",
"所有指定行星均落入候选宫位集合{houses}": "All listed planets fall in the house set {houses}",
"指定行星只占据{houses}": "Listed planets occupy only houses {houses}",
"指定行星只占据候选宫位集合{houses}": "Listed planets occupy only the house set {houses}",
"第{house}宫有{planets}": "House {house} holds {planets}",
"{planets}在{fp}的第{rel}宫": "{planets} in house {rel} from {fp}",
"{count}个目标宫位有指定行星": "{count} target houses hold listed planets",
"{a}与{b}在{sign}内相距{gap:.2f}°": "{a} and {b} within {gap:.2f}° in {sign}",
"{la}与{lb}互换星座": "{la} and {lb} exchange signs",
"{house}宫主{lord}在第{lh}宫": "Lord of house {house}, {lord}, in house {lh}",
"{la}与{lb}同在第{h}宫": "{la} and {lb} together in house {h}",
"自定义条件满足": "Custom condition met",
}
OFFSET_NAME_EN = {0: "same house", 1: "2nd", 2: "3rd", 3: "4th (Kendra)", 4: "5th (Trine)",
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"}
def _combo(cond: Dict, default_zh: str, **values) -> BiText:
"""Format the rule's combination template in Chinese (unchanged) and in English."""
zh = cond.get("combo_template", default_zh).format(**values)
en_template = cond.get("combo_template_en") if "combo_template" in cond else DEFAULT_COMBO_EN.get(default_zh)
en = None
if isinstance(en_template, str):
try:
en = en_template.format(**{key: (value.en if isinstance(value, BiText) else value) for key, value in values.items()})
except (KeyError, IndexError, ValueError):
en = None
return BiText(zh, en)
def _get_dignity_level(planet: str, sign: str) -> str:
"""判断行星在某星座的尊严等级"""
if EXALTATION.get(planet) == sign:
@@ -658,16 +733,23 @@ class YogaEngine:
for rule in self.rules:
matches = self._eval_rule(rule, ctx)
for m in matches:
combination = m.get("combination", "")
strength = m.get("strength", rule.get("strength", "中"))
entry = {
"name": rule["name"],
"name_cn": rule["name_cn"],
"combination": m.get("combination", ""),
"combination": str(combination),
"effects": rule.get("effects", []),
"strength": m.get("strength", rule.get("strength", "中")),
"strength": strength,
"source": rule.get("source", ""),
"bvr_id": rule.get("bvr_id"),
"category": rule.get("category", ""),
"rule_id": rule.get("id", ""),
# English twins for the reader's English edition; the Chinese
# fields above are unchanged.
"combination_en": _en_text(combination),
"effects_en": list(rule.get("effects_en") or rule.get("effects", [])),
"strength_en": rule.get("strength_en") if strength == rule.get("strength") and rule.get("strength_en") else STRENGTH_EN.get(str(strength), str(strength)),
}
dedup_key = rule.get("dedup_key") or rule["name"]
dedup_val = f"{dedup_key}:{m.get('combination', '')}"
@@ -713,17 +795,26 @@ class YogaEngine:
if p_in_h12 and not p_in_h2:
results.append({"name": "Voshi Yoga", "name_cn": "太阳前瑜伽",
"combination": f"{'+'.join(p_in_h12)}在太阳第12宫",
"combination_en": f"{'+'.join(p_in_h12)} in the 12th from the Sun",
"effects": ["口才出众", "善于表达", "受人尊敬", "知识渊博"],
"effects_en": ["Eloquent", "Expressive", "Respected", "Learned"],
"strength_en": "Moderate",
"strength": "中", "category": "solar_yoga", "source": "Parashara"})
if p_in_h2 and not p_in_h12:
results.append({"name": "Veshi Yoga", "name_cn": "太阳后瑜伽",
"combination": f"{'+'.join(p_in_h2)}在太阳第2宫",
"combination_en": f"{'+'.join(p_in_h2)} in the 2nd from the Sun",
"effects": ["财富充裕", "生活舒适", "性情愉快", "人缘好"],
"effects_en": ["Ample wealth", "Comfortable life", "Cheerful temperament", "Well liked"],
"strength_en": "Moderate",
"strength": "中", "category": "solar_yoga", "source": "Parashara"})
if p_in_h2 and p_in_h12:
results.append({"name": "Ubhayachari Yoga", "name_cn": "太阳双夹瑜伽",
"combination": "太阳两侧均有行星",
"combination_en": "Planets on both sides of the Sun",
"effects": ["性格坚毅", "口才与财富兼备", "受人爱戴", "社会影响力"],
"effects_en": ["Steadfast character", "Both eloquence and wealth", "Beloved by others", "Social influence"],
"strength_en": "Strong",
"strength": "强", "category": "solar_yoga", "source": "Parashara"})
# Lunar Yogas
@@ -735,17 +826,26 @@ class YogaEngine:
if p_in_h12 and not p_in_h2:
results.append({"name": "Anapha Yoga", "name_cn": "月后瑜伽",
"combination": f"{'+'.join(p_in_h12)}在月亮第12宫",
"combination_en": f"{'+'.join(p_in_h12)} in the 12th from the Moon",
"effects": ["体格健壮", "名声良好", "品德高尚", "行业领袖"],
"effects_en": ["Robust physique", "Good reputation", "Noble character", "Leader in one's field"],
"strength_en": "Moderate",
"strength": "中", "category": "lunar_yoga", "source": "Parashara"})
if p_in_h2 and not p_in_h12:
results.append({"name": "Sunapha Yoga", "name_cn": "月前瑜伽",
"combination": f"{'+'.join(p_in_h2)}在月亮第2宫",
"combination_en": f"{'+'.join(p_in_h2)} in the 2nd from the Moon",
"effects": ["自力更生", "财富充裕", "受人尊敬", "生活富足"],
"effects_en": ["Self-made", "Ample wealth", "Respected", "Prosperous life"],
"strength_en": "Moderate",
"strength": "中", "category": "lunar_yoga", "source": "Parashara"})
if p_in_h2 and p_in_h12:
results.append({"name": "Durudhura Yoga", "name_cn": "月双夹瑜伽",
"combination": "月亮两侧均有行星",
"combination_en": "Planets on both sides of the Moon",
"effects": ["享受丰富", "善于辞令", "性格坚定", "多元才华"],
"effects_en": ["Many comforts", "Articulate", "Firm character", "Versatile talents"],
"strength_en": "Strong",
"strength": "强", "category": "lunar_yoga", "source": "Parashara"})
return results
@@ -804,7 +904,7 @@ class YogaEngine:
for sm in sub_matches:
combo = match.get("combination", "")
sm_combo = sm.get("combination", "")
merged_combo = "; ".join(filter(None, [combo, sm_combo]))
merged_combo = _bijoin("; ", [combo, sm_combo])
new_results.append({
"combination": merged_combo,
"strength": sm.get("strength", match.get("strength")),
@@ -851,8 +951,7 @@ class YogaEngine:
h1 = ctx.house_of(a)
h2 = ctx.house_of(b)
if h1 is not None and h1 == h2:
tmpl = cond.get("combo_template", "{a}与{b}同在第{h}宫")
combo = tmpl.format(a=PLANET_CN.get(a, a), b=PLANET_CN.get(b, b), h=h1, a_raw=a, b_raw=b)
combo = _combo(cond, "{a}与{b}同在第{h}宫", a=_planet(a), b=_planet(b), h=h1, a_raw=a, b_raw=b)
results.append({"combination": combo, "strength": rule.get("strength", "中"),
"bindings": {"a": a, "b": b, "house": h1}})
return results
@@ -868,8 +967,7 @@ class YogaEngine:
continue
h = ctx.house_of(p)
if h is not None and h in houses:
tmpl = cond.get("combo_template", "{p}在第{h}宫")
combo = tmpl.format(p=PLANET_CN.get(p, p), h=h, p_raw=p)
combo = _combo(cond, "{p}在第{h}宫", p=_planet(p), h=h, p_raw=p)
results.append({"combination": combo, "strength": rule.get("strength", "中"),
"bindings": {"planet": p, "house": h}})
return results
@@ -924,10 +1022,9 @@ class YogaEngine:
continue
off = ctx.house_offset(h_base, h1)
if off in valid_offsets:
tmpl = cond.get("combo_template", "{p}在第{h}宫(从{fp}的{rel})")
rel_name = self._offset_name(off)
combo = tmpl.format(p=PLANET_CN.get(p, p), h=h1, fp=PLANET_CN.get(fp, fp),
rel=rel_name, off=off, p_raw=p, fp_raw=fp)
rel_name = BiText(self._offset_name(off), OFFSET_NAME_EN.get(off, f"{off + 1}th"))
combo = _combo(cond, "{p}在第{h}宫(从{fp}的{rel})", p=_planet(p), h=h1, fp=_planet(fp),
rel=rel_name, off=off, p_raw=p, fp_raw=fp)
results.append({"combination": combo, "strength": rule.get("strength", "中"),
"bindings": {"planet": p, "from": fp, "house": h1}})
return results
@@ -975,8 +1072,7 @@ class YogaEngine:
dignity_type = status
if ok:
sign = ctx.sign_of(p)
tmpl = cond.get("combo_template", "{p}{dignity}在{sign}")
combo = tmpl.format(p=PLANET_CN.get(p, p), dignity=dignity_type, sign=sign or "", p_raw=p)
combo = _combo(cond, "{p}{dignity}在{sign}", p=_planet(p), dignity=dignity_type, sign=sign or "", p_raw=p)
results.append({"combination": combo, "strength": rule.get("strength", "中"),
"bindings": {"planet": p, "sign": sign}})
return results
@@ -1002,8 +1098,7 @@ class YogaEngine:
ok = ok and count <= max_count
if ok:
tmpl = cond.get("combo_template", "{count}颗行星满足条件")
combo = tmpl.format(count=count, planets="、".join(PLANET_CN.get(p, p) for p in matched))
combo = _combo(cond, "{count}颗行星满足条件", count=count, planets=_planets(matched))
return [{"combination": combo, "strength": rule.get("strength", "中"),
"bindings": {"count": count, "matched": matched}}]
return []
@@ -1032,8 +1127,7 @@ class YogaEngine:
ok = ok and count <= max_count
if ok:
tmpl = cond.get("combo_template", "行星分布在{count}个宫位")
combo = tmpl.format(count=count)
combo = _combo(cond, "行星分布在{count}个宫位", count=count)
return [{"combination": combo, "strength": rule.get("strength", "中")}]
return []
@@ -1056,8 +1150,7 @@ class YogaEngine:
ok = prev_ok or next_ok
if ok:
tmpl = cond.get("combo_template", "第{target}宫被夹击")
combo = tmpl.format(target=target, prev=prev_h, next=next_h)
combo = _combo(cond, "第{target}宫被夹击", target=target, prev=prev_h, next=next_h)
return [{"combination": combo, "strength": rule.get("strength", "中")}]
return []
@@ -1070,8 +1163,7 @@ class YogaEngine:
occupied = [ctx.house_of(p) for p in refs]
ok = all(h in houses for h in occupied if h is not None) and len(occupied) == len(refs)
if ok:
tmpl = cond.get("combo_template", "所有指定行星均落入{houses}")
combo = tmpl.format(houses="/".join(str(h) for h in sorted(houses)), planets="、".join(PLANET_CN.get(p, p) for p in refs))
combo = _combo(cond, "所有指定行星均落入{houses}", houses="/".join(str(h) for h in sorted(houses)), planets=_planets(refs))
return [{"combination": combo, "strength": rule.get("strength", "中"),
"bindings": {"planets": refs, "houses": sorted(houses)}}]
return []
@@ -1087,8 +1179,7 @@ class YogaEngine:
occupied = [ctx.house_of(p) for p in refs]
ok = all(h in houses_set for h in occupied if h is not None) and len(occupied) == len(refs)
if ok:
tmpl = cond.get("combo_template", "所有指定行星均落入候选宫位集合{houses}")
combo = tmpl.format(houses="/".join(str(h) for h in sorted(houses_set)), planets="、".join(PLANET_CN.get(p, p) for p in refs))
combo = _combo(cond, "所有指定行星均落入候选宫位集合{houses}", houses="/".join(str(h) for h in sorted(houses_set)), planets=_planets(refs))
return [{"combination": combo, "strength": rule.get("strength", "中"),
"bindings": {"planets": refs, "houses": sorted(houses_set)}}]
return []
@@ -1099,8 +1190,7 @@ class YogaEngine:
houses = set(cond.get("houses", []))
occupied = {ctx.house_of(p) for p in refs if ctx.house_of(p) is not None}
if refs and occupied == houses:
tmpl = cond.get("combo_template", "指定行星只占据{houses}")
combo = tmpl.format(houses="/".join(str(h) for h in sorted(houses)), planets="、".join(PLANET_CN.get(p, p) for p in refs))
combo = _combo(cond, "指定行星只占据{houses}", houses="/".join(str(h) for h in sorted(houses)), planets=_planets(refs))
return [{"combination": combo, "strength": rule.get("strength", "中"),
"bindings": {"planets": refs, "houses": sorted(houses)}}]
return []
@@ -1113,8 +1203,7 @@ class YogaEngine:
for houses in house_sets:
houses_set = set(houses)
if refs and occupied == houses_set:
tmpl = cond.get("combo_template", "指定行星只占据候选宫位集合{houses}")
combo = tmpl.format(houses="/".join(str(h) for h in sorted(houses_set)), planets="、".join(PLANET_CN.get(p, p) for p in refs))
combo = _combo(cond, "指定行星只占据候选宫位集合{houses}", houses="/".join(str(h) for h in sorted(houses_set)), planets=_planets(refs))
return [{"combination": combo, "strength": rule.get("strength", "中"),
"bindings": {"planets": refs, "houses": sorted(houses_set)}}]
return []
@@ -1125,8 +1214,7 @@ class YogaEngine:
house = cond.get("house")
matched = [p for p in refs if ctx.house_of(p) == house]
if matched:
tmpl = cond.get("combo_template", "第{house}宫有{planets}")
combo = tmpl.format(house=house, planets="、".join(PLANET_CN.get(p, p) for p in matched))
combo = _combo(cond, "第{house}宫有{planets}", house=house, planets=_planets(matched))
return [{"combination": combo, "strength": rule.get("strength", "中"),
"bindings": {"matched": matched, "house": house}}]
return []
@@ -1147,10 +1235,9 @@ class YogaEngine:
target = ((base + rel_house - 2) % 12) + 1
matched = [p for p in refs if p not in exclude and p != fp and ctx.house_of(p) == target]
if matched:
tmpl = cond.get("combo_template", "{planets}在{fp}的第{rel}宫")
combo = tmpl.format(
planets="、".join(PLANET_CN.get(p, p) for p in matched),
fp=PLANET_CN.get(fp, fp),
combo = _combo(cond, "{planets}在{fp}的第{rel}宫",
planets=_planets(matched),
fp=_planet(fp),
rel=rel_house,
target=target,
)
@@ -1174,11 +1261,10 @@ class YogaEngine:
if max_count is not None:
ok = ok and count <= max_count
if ok:
tmpl = cond.get("combo_template", "{count}个目标宫位有指定行星")
detail = "; ".join(f"第{h}宫:" + "、".join(PLANET_CN.get(p, p) for p in ps)
for h, ps in occupied)
combo = tmpl.format(count=count, detail=detail,
houses="/".join(str(h) for h, _ in occupied))
detail = _bijoin("; ", [BiText(f"第{h}宫:" + _planets(ps), f"house {h}: " + _planets(ps).en)
for h, ps in occupied])
combo = _combo(cond, "{count}个目标宫位有指定行星", count=count, detail=detail,
houses="/".join(str(h) for h, _ in occupied))
return [{"combination": combo, "strength": rule.get("strength", "中"),
"bindings": {"occupied": occupied, "count": count}}]
return []
@@ -1202,8 +1288,7 @@ class YogaEngine:
if d1 is None or d2 is None:
continue
if abs(d1 - d2) < max_gap:
tmpl = cond.get("combo_template", "{a}与{b}在{sign}内相距{gap:.2f}°")
combo = tmpl.format(a=PLANET_CN.get(a, a), b=PLANET_CN.get(b, b), sign=s1,
combo = _combo(cond, "{a}与{b}在{sign}内相距{gap:.2f}°", a=_planet(a), b=_planet(b), sign=s1,
gap=abs(d1 - d2))
results.append({"combination": combo, "strength": rule.get("strength", "中")})
return results
@@ -1221,8 +1306,7 @@ class YogaEngine:
lord_of_sa = SIGN_LORDS.get(sa)
lord_of_sb = SIGN_LORDS.get(sb)
if lord_of_sa == lb and lord_of_sb == la:
tmpl = cond.get("combo_template", "{la}与{lb}互换星座")
combo = tmpl.format(la=PLANET_CN.get(la, la), lb=PLANET_CN.get(lb, lb))
combo = _combo(cond, "{la}与{lb}互换星座", la=_planet(la), lb=_planet(lb))
return [{"combination": combo, "strength": rule.get("strength", "强")}]
return []
@@ -1264,7 +1348,7 @@ class YogaEngine:
break
matched_combo_parts.append(matches[0].get("combination", ""))
if all_match and refs:
combo = "; ".join(filter(None, matched_combo_parts))
combo = _bijoin("; ", matched_combo_parts)
return [{"combination": combo, "strength": rule.get("strength", "中")}]
return []
@@ -1299,8 +1383,7 @@ class YogaEngine:
return []
lh = ctx.house_of(lord)
if lh is not None and lh in target_houses:
tmpl = cond.get("combo_template", "{house}宫主{lord}在第{lh}宫")
combo = tmpl.format(house=house, lord=PLANET_CN.get(lord, lord), lh=lh)
combo = _combo(cond, "{house}宫主{lord}在第{lh}宫", house=house, lord=_planet(lord), lh=lh)
return [{"combination": combo, "strength": rule.get("strength", "中"),
"bindings": {"lord": lord, "house": lh}}]
return []
@@ -1316,8 +1399,7 @@ class YogaEngine:
ha = ctx.house_of(lord_a)
hb = ctx.house_of(lord_b)
if ha is not None and ha == hb:
tmpl = cond.get("combo_template", "{la}与{lb}同在第{h}宫")
combo = tmpl.format(la=PLANET_CN.get(lord_a, lord_a), lb=PLANET_CN.get(lord_b, lord_b), h=ha)
combo = _combo(cond, "{la}与{lb}同在第{h}宫", la=_planet(lord_a), lb=_planet(lord_b), h=ha)
return [{"combination": combo, "strength": rule.get("strength", "中")}]
return []
@@ -1333,6 +1415,9 @@ class YogaEngine:
if result:
combo = cond.get("combo_template", "自定义条件满足")
if isinstance(combo, str):
# Custom templates were never formatted; keep that, and pair the English text.
combo = BiText(combo, cond.get("combo_template_en") if "combo_template" in cond else DEFAULT_COMBO_EN["自定义条件满足"])
if callable(combo):
combo = combo(ctx, bindings)
strength = cond.get("strength", rule.get("strength", "中"))