Files
Jyotisha/scripts/pl9_full_data_export.py
T
jesse-ux 9d3eb23ee4 feat: 完整数据版加入六种星座大运
Sthira、Drig、Shoola、Navamsha、Niryaana Shoola 与 K.N. Rao 的 Chara 从本盘算出。Lagna Kendradi 不上表。未推送。
2026-10-09 00:43:00 +08:00

2299 lines
120 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Full-data personal report, upstream pl9_ai_density at 23be1807.
Renderer and sanitizer only. Numbers come from this repository's engine.
Upstream calculation helpers are not copied. Sections whose packet fields are
absent are omitted. The private same-case PDF transcription in upstream
`_attach_pl9_source_visible_tables` is not copied. `customer_timing_supplement`
fits apparent positions and a -5 second node adjustment; red line 4 forbids
that profile, so it is not called.
"""
from __future__ import annotations
import copy
import re
from collections.abc import Mapping
from typing import Any
try:
from scripts.pl9_reader_export import (
_pl9_english_report_title,
_pl9_public_report_title,
_pl9_report_language,
_sanitize_pl9_public_markdown,
_strip_pl9_user_engineering_markers,
render_pl9_parity_markdown,
)
except ModuleNotFoundError: # pragma: no cover - direct scripts/ execution
from pl9_reader_export import (
_pl9_english_report_title,
_pl9_public_report_title,
_pl9_report_language,
_sanitize_pl9_public_markdown,
_strip_pl9_user_engineering_markers,
render_pl9_parity_markdown,
)
FULL_DATA_EDITION = "full_data"
REPORT_VERSION_FULL = "pl9_personal_long_report.v4"
FULL_DATA_DOMAINS = {
"career": "事业",
"wealth": "财富",
"relationship": "婚恋",
"health": "健康",
"children": "子女",
"parents": "父母",
"education": "学业",
"relocation": "迁居",
"family": "家庭",
"annual": "流年",
"timing": "时间",
"general": "综合",
}
def _pl9_public_term(value: Any) -> str:
"""Report-facing label for a planet or sign name."""
terms = {
"Sun": "太阳", "Moon": "月亮", "Mars": "火星", "Mercury": "水星",
"Jupiter": "木星", "Venus": "金星", "Saturn": "土星",
"Rahu": "北交点/罗睺", "Ketu": "南交点/计都",
"Aries": "白羊座", "Taurus": "金牛座", "Gemini": "双子座",
"Cancer": "巨蟹座", "Leo": "狮子座", "Virgo": "处女座",
"Libra": "天秤座", "Scorpio": "天蝎座", "Sagittarius": "射手座",
"Capricorn": "摩羯座", "Aquarius": "水瓶座", "Pisces": "双鱼座",
}
text = str(value or "").strip()
return terms.get(text, text or "-")
def _load_glossary():
try:
from scripts.pl9_en_glossary import CN_TERMS_EN, COMBINATION_EN, EFFECTS_EN
except ModuleNotFoundError:
from pl9_en_glossary import CN_TERMS_EN, COMBINATION_EN, EFFECTS_EN
return CN_TERMS_EN, COMBINATION_EN, EFFECTS_EN
def _load_sudarshana():
try:
from scripts.sudarshana_chakra import calc_sudarshana_chakra
except ModuleNotFoundError:
from sudarshana_chakra import calc_sudarshana_chakra
return calc_sudarshana_chakra
def _load_bhava_rows():
try:
from scripts.special_lagnas import bhava_house_rows
except ModuleNotFoundError:
from special_lagnas import bhava_house_rows
return bhava_house_rows
def _sanitize_pl9_ai_density_markdown(markdown: str, title: str = "印度占星星盘报告") -> str:
"""Keep the parity volume's facts while removing internal-only labels."""
try:
from pl9_language_terms import (
normalize_zh_markdown_terms,
replace_natural_language_term,
strip_han_plural_s,
)
except ModuleNotFoundError: # pragma: no cover - package import compatibility
from scripts.pl9_language_terms import (
normalize_zh_markdown_terms,
replace_natural_language_term,
strip_han_plural_s,
)
text = _sanitize_pl9_public_markdown(markdown)
replacements = {
"# PL9 对标主册": f"# {title}",
"出生资料与图盘(PL9 p1-p17)": "出生资料与图盘",
"行星、星宿与宫位长页解释(PL9 p18-p29 对标)": "行星、星宿与宫位解释",
"强度、关系与 Ashtakavarga 技术页(PL9 p30-p60 对标)": "强度、关系与 Ashtakavarga",
"标准 Dasha 表格页(PL9 p61-p120 对标)": "Dasha 表格",
"Saturn 与 KP 支持页(PL9 p121-p125 对标)": "Saturn 与 KP",
"年度图盘与 Tajika(PL9 p126-p155 对标)": "年度 Varshaphala 与 Tajika",
"Bhavesh、Dosha 与 Yoga 解释页(PL9 p156-p189 对标)": "Bhavesh、Dosha 与 Yoga",
"大运长页解释(PL9 p190-p204 对标)": "大运解释",
"p3-p5 Birth Particulars / Hindu Calendar": "出生资料与印度历信息",
"p3 Birth Particulars 原版补充字段": "出生资料补充字段",
"p6 Birth Chart 行星原始位置": "Birth Chart 行星原始位置",
"p6 Birth Chart 星主与状态字段": "Birth Chart 星主与状态字段",
"p126-p127 Varshaphala 基础资料与年度行星表": "Varshaphala 基础资料与年度行星表",
"p139-p141 Varshaphala 年度结果解释": "Varshaphala 年度结果解释",
"p144-p145 Annual Dasha 结果解释": "Annual Dasha 结果解释",
"p132-p135 Mudda / Patyayini 年度 Dasha 表": "Mudda / Patyayini 年度 Dasha 表",
"missing_in_local": "未提供",
"external_mudda_start_boundary": "阶段起点",
"dasha_beginning_dates": "阶段起止日期",
"dasha_ending_dates": "阶段结束日期",
"moon_at_birth_time": "出生时月亮",
"moon_in_varshaphala": "年盘月亮",
"without_dasha_balance": "不计余额",
"balance 口径": "起始依据",
"边界口径": "日期说明",
"Progression (local ": "Progression (",
"p152 - birth_place_eight_year_overview": "",
"p153 - local_eight_year_overview": "",
"p154 - tithi_pravesh_birth_place": "",
"p155 - tithi_pravesh_current_location": "",
"外部边界:Chesta remains cross-engine method-conflicted; totals inherit that boundary。": "Chesta Bala 总分按本报告采用的计算口径解读。",
"外部边界:Chesta remains cross-engine method-conflicted; totals inherit that boundary.": "Chesta Bala 总分按本报告采用的计算口径解读。",
"资料路径": "资料来源",
"生成模块": "计算来源",
"结构": "项目",
"校验": "说明",
"functional_role_not_returned": "功能属性未单独返回",
"natural_benefic": "自然吉星",
"natural_malefic": "自然凶星",
"current_dasha": "当前 Dasha",
"birth_balance": "出生余额",
"Cycle / phase": "周期 / 阶段",
"Transit of 土星": "土星行运星座",
"Beginning date": "开始日期",
"Ending date": "结束日期",
"Duration Yr-Mn-Dy": "持续时间(年-月-日)",
"First Cycle of Sadhesati": "第一轮 Sade Sati",
"Second Cycle of Sadhesati": "第二轮 Sade Sati",
"Third Cycle of Sadhesati": "第三轮 Sade Sati",
"First Dhayya": "第一段 Dhayya",
"Second Dhayya": "第二段 Dhayya",
"Third Dhayya": "第三段 Dhayya",
"(Twelfth from birth rashi)": "(本命月亮星座前一宫)",
"(On birth rashi)": "(本命月亮星座本宫)",
"(Second from birth rashi)": "(本命月亮星座后一宫)",
";功能性角色为待补充": "",
"功能性角色为待补充。": "",
"Consideration: Mars is checked from Lagna, Moon, and Venus against the classical marriage-sensitive houses.": "校验说明:火星婚姻煞会同时从上升、月亮与金星出发,核对传统婚恋敏感宫位。",
"Consideration: this Dosha row is shown only when the local detector returns a chart-specific condition.": "校验说明:只有本命盘检测器返回具体条件时,才显示这一条 Dosha。",
"Consideration: Mars is checked from Lagna, Moon, and Venus against the classical marriage-sensitive houses.": "校验说明:火星婚姻煞会同时从上升、月亮与金星出发,核对传统婚恋敏感宫位。",
"In this chart Mars is listed in house": "本盘火星位于第",
"the detector severity is none.": "检测强度为无。",
"the detector severity is low.": "检测强度为低。",
"the detector severity is medium.": "检测强度为中。",
"the detector severity is high.": "检测强度为高。",
"Classical reference family: Muhurtha or Electional Astrology; 印度占星读书摘要.": "古典参考体系:Muhurtha / Electional Astrology 与印度占星读书摘要。",
"Classical reference family is attached in the source map.": "古典参考体系已随来源映射保留。",
"against the classical marriage-sensitive houses.": "并核对传统婚恋敏感宫位。",
"and Venus": "与金星",
"from Lagna, Moon,": "从上升与月亮",
"火星 is checked": "火星会被校验",
"In this chart 火星 is listed in 第-;": "本盘未列火星命中婚恋敏感宫位;",
"Birth Particulars / Hindu Calendar": "出生资料 / 印度历",
"Birth Chart 行星原始位置": "本命盘行星原始位置",
"Birth Chart Lords and Status Fields": "本命盘星主与状态字段",
"Chandra Chart / Bhava Spashta - Sripati System": "月亮盘 / Sripati 宫位精确表",
"Bhava Number": "宫位编号",
"Bhava Arambha / 宫位 beginning": "宫位起点",
"Bhava Madhya / Middle of 宫位": "宫位中点",
"Bhava Antya / 宫位 ending": "宫位终点",
"Planetary Raw Positions": "行星原始位置",
"| Field | Value |": "| 字段 | 数值 |",
"| Panchanga | Value |": "| Panchanga | 数值 |",
"| Planet | R/C | Sign | Degree | Longitude | House | Nakshatra | Pada | Speed |": "| 行星 | 顺逆 | 星座 | 落座度数 | 黄经 | 宫位 | 星宿 | Pada | 速度 |",
"| Planet | RL | NL | SL | SS | Status | SB |": "| 行星 | 星座主 | 星宿主 | Sub Lord | Sub-sub | 状态 | Shadbala |",
"| Point | Sign | Degree | Longitude | Lord |": "| 点位 | 星座 | 落座度数 | 黄经 | 守护星 |",
"| Body | Sign | Degree | Longitude |": "| 对象 | 星座 | 落座度数 | 黄经 |",
"| Planet | Sign | Degree | Longitude |": "| 行星 | 星座 | 落座度数 | 黄经 |",
}
for raw, public in replacements.items():
text = text.replace(raw, public)
for raw, public in (
("functional_benefic", "功能吉星"),
("functional_malefic", "功能凶星"),
("functional_neutral", "功能中性"),
):
text = replace_natural_language_term(text, raw, public)
text = re.sub(
r"\n## Bhavesh House-Lord Interpretations\n\n.*?(?=\n## |\Z)",
"\n",
text,
flags=re.DOTALL,
)
text = re.sub(
r"(.+?)落在(.+?),首先把(.+?)放到(.+?)这一现实场域中阅读。",
r"\1落在\2,表示\3会主要通过\4来表现。",
text,
)
text = re.sub(r"第([1-4])足", r"第\1 Pada(星宿四分区)", text)
text = text.replace("Pada | 速度", "Pada(星宿四分区) | 速度")
text = text.replace("Natural friends", "天然友星")
text = text.replace("Natural enemies", "天然敌星")
text = text.replace("Temporary relation", "临时关系")
text = text.replace("Natal / KP Ruling Planets", "本命 / KP 主宰星")
text = text.replace("Ruling Planets", "主宰星")
text = text.replace("Sub-Planets", "副行星")
text = text.replace("Planetary", "行星")
text = text.replace("Planets", "行星")
text = text.replace("Planet", "行星")
text = replace_natural_language_term(text, "Sign", "星座")
text = text.replace("Degree in sign", "落座度数")
text = text.replace("Degree", "度数")
text = text.replace("Longitude", "黄经")
text = text.replace("Whole-sign house", "整宫宫位")
text = text.replace("House", "宫位")
text = text.replace("Object", "对象")
text = text.replace("Reference point", "参考点")
text = text.replace("Reference chart", "参考盘")
text = text.replace("Role", "角色")
text = text.replace("Yoga", "瑜伽")
text = text.replace("Category", "类别")
text = text.replace("Combination", "组合条件")
text = text.replace("Effects / notes", "作用说明")
text = text.replace("Planet 1", "行星1")
text = text.replace("Planet 2", "行星2")
text = text.replace("Exact degree", "精确角度")
text = text.replace("Actual difference", "实际角距")
text = text.replace("Orb", "容许度")
text = text.replace("Applying", "入相")
text = text.replace("From house", "起始宫位")
text = text.replace("Target house", "目标宫位")
text = text.replace("Aspect type", "相位类型")
text = text.replace("Special", "特殊相位")
text = text.replace("friends:", "友星:")
text = text.replace("enemies:", "敌星:")
text = text.replace("True", "是")
text = text.replace("False", "否")
text = text.replace("conjunction", "合相")
text = text.replace("opposition", "对冲")
text = re.sub(r"(?<![A-Za-z])special(?![A-Za-z])", "特殊", text)
text = text.replace("solar_yoga", "太阳瑜伽")
text = text.replace("lunar_yoga", "月亮瑜伽")
text = text.replace("kalatra", "婚姻类")
text = text.replace("durabhaga", "困难类")
text = text.replace("moderate", "中等")
text = text.replace("common", "普通")
text = text.replace("_aspect_", "宫相位_")
text = re.sub(
r"Supported houses gain expression through (.+?); strained houses require steadier handling, e特殊ly where the same houses repeat in 大运, divisional charts, Ashtakavarga, or the annual chart\.",
r"受支持的宫位会通过\1获得表达;若同一宫位在大运、分盘、Ashtakavarga 或年度盘中反复承压,则需要更稳妥地处理。",
text,
)
text = normalize_zh_markdown_terms(text)
dasha_heading_patterns = (
(
r"Effects of Antar 大运 of (.+?) in the Maha 大运 of (.+)",
r"\2主大运中\1子运的作用",
),
(
r"Antar 大运 of (.+?) in the Maha 大运 of (.+?):",
r"\2主大运中的\1子运:",
),
(
r"Pratyantar 大运 of (.+?) in the Antar 大运 of (.+)",
r"\2子运中的\1次子运",
),
(
r"Interpretation of the Maha 大运 of (.+)",
r"\1主大运解释",
),
)
for raw_pattern, public_pattern in dasha_heading_patterns:
text = re.sub(raw_pattern, public_pattern, text)
dasha_sentence_patterns = (
(
r"General effects during the Maha 大运 of (.+?) are read from the planet's natural significations, house placement, owned houses, dignity, 星宿, and functional role\.",
r"\1主大运的总体作用,需要从该行星的自然象征、落宫、守护宫、尊贵状态、星宿与功能角色一起阅读。",
),
(
r"General effects during the 主大运 of (.+?) are read from the planet's 自然象征, 落宫, owned houses, 尊贵状态, 星宿, and 功能角色\.",
r"\1主大运的总体作用,需要从该行星的自然象征、落宫、守护宫、尊贵状态、星宿与功能角色一起阅读。",
),
(
r"General effects during the Maha 大运 of (.+?) are read from the planet's 自然象征, 落宫, owned houses, 尊贵状态, 星宿, and 功能角色\.",
r"\1主大运的总体作用,需要从该行星的自然象征、落宫、守护宫、尊贵状态、星宿与功能角色一起阅读。",
),
(
r"General effects which are felt during the Maha 大运 of (.+?) are as follows:",
r"\1主大运期间通常会启动以下主题:",
),
(
r"General effects which are felt during the 主大运 of (.+?) are as follows:",
r"\1主大运期间通常会启动以下主题:",
),
(
r"Interpretations based on the condition of the planet in the birth chart and divisional charts are as follows:",
r"结合本命盘与分盘条件后,可按以下方式细读:",
),
(
r"In this chart (.+?) is read from house (.+?), ownership of (.+?), dignity (.+?), and 星宿 (.+?)\.",
r"在本盘中,\1需要从第\2宫、守护\3宫、尊贵状态\4与星宿\5一起阅读。",
),
(
r"In this chart (.+?) is read from house (.+?), 守护宫 of (.+?), 尊贵状态 (.+?), and 星宿 (.+?)\.",
r"在本盘中,\1需要从第\2宫、守护\3宫、尊贵状态\4与星宿\5一起阅读。",
),
(
r"Supported houses gain expression through (.+?); strained houses require steadier handling, especially where the same houses repeat in 大运, divisional charts, Ashtakavarga, or the annual chart\.",
r"受支持的宫位会通过\1获得表达;若同一宫位在大运、分盘、Ashtakavarga 或年度盘中反复承压,则需要更稳妥地处理。",
),
(
r"Owned houses (.+?) supply the concrete fields through which (.+?) gives results\.",
r"\2会通过其守护的第\1宫给出更具体的结果领域。",
),
(
r"This section follows the general/specific 大运 reading structure indexed from (.+?)\.",
r"本节按传统大运的总体作用与具体命盘条件两层结构展开,参考:\1。",
),
(
r"The Antar 大运 of (.+?) activates (.+?)'s natural themes inside the broader Maha 大运 of (.+?)\.",
r"\3主大运中的\1子运,会把\2的自然主题带入当前阶段。",
),
(
r"(.+?) brings house (.+?), ownership (.+?), dignity (.+?), and 星宿 (.+?) into focus\.",
r"\1会突出第\2宫、守护\3宫、尊贵状态\4与星宿\5。",
),
(
r"(.+?) brings house (.+?), 守护宫 (.+?), 尊贵状态 (.+?), and 星宿 (.+?) into focus\.",
r"\1会突出第\2宫、守护\3宫、尊贵状态\4与星宿\5。",
),
(
r"In the Pratyantar 大运 of (.+?) in the Antar 大运 of (.+?), (.+?)'s significations give the short-period result inside the (.+?) background\.",
r"\2子运中的\1次子运,会在\4背景下呈现\3的短周期结果。",
),
(
r"The immediate focus is house (.+?), ownership (.+?), dignity (.+?), and 星宿 (.+?)\.",
r"短期重点是第\1宫、守护\2宫、尊贵状态\3与星宿\4。",
),
(
r"The immediate focus is house (.+?), 守护宫 (.+?), 尊贵状态 (.+?), and 星宿 (.+?)\.",
r"短期重点是第\1宫、守护\2宫、尊贵状态\3与星宿\4。",
),
(
r"Supported houses gain expression through (.+?); strained houses require steadier handling, especially where the same houses repeat in 主大运, divisional charts, Ashtakavarga, or the annual chart\.",
r"受支持的宫位会通过\1获得表达;若同一宫位在主大运、分盘、Ashtakavarga 或年度盘中反复承压,则需要更稳妥地处理。",
),
)
for raw_pattern, public_pattern in dasha_sentence_patterns:
text = re.sub(raw_pattern, public_pattern, text)
text = text.replace("Maha 大运", "主大运")
text = text.replace("Antar 大运", "子运")
text = text.replace("Pratyantar 大运", "次子运")
text = text.replace("Current 大运", "当前大运")
text = text.replace("迁移/出行", "迁移与出行")
text = text.replace("strongweak", "强弱")
text = text.replace("Topic", "主题")
text = text.replace("Practice", "修持")
text = normalize_zh_markdown_terms(text)
text = re.sub(r"^#\s+.*$", f"# {title}", text, count=1, flags=re.MULTILINE)
drop_fragments = (
"年度图盘渲染索引",
"年度图盘内容预览",
"年度北印度盘预览会嵌入 PDF",
"annual candidate structures visible",
"annual sensitive points visible",
"从年度执行层往下看",
"Topic | Detected / phase | Key facts | Summary",
"Detected / phase",
"Reference:",
"source_path",
"计算项",
"计算来源",
"资料来源",
"Report Layers",
"Worksheet Index",
"Report Pack Manifest",
"Reference:",
"资料来源",
"计算来源",
"| method |",
"swisseph_newton",
"jd_ut:",
"time_offset_seconds",
"口径_time_offset",
)
cleaned_lines = [
line.rstrip()
for line in text.splitlines()
if not any(fragment in line for fragment in drop_fragments)
]
text = "\n".join(cleaned_lines)
text = re.sub(r"\s*\(PL9 p\d+(?:-p\d+)?[^)]*\)", "", text)
text = re.sub(r"\s*PL9 p\d+(?:-p\d+)?", "", text)
text = re.sub(r"(?m)^(#{2,5}\s*)p\d+(?:-p\d+)?\s*[·.-]?\s*", r"\1", text)
text = re.sub(r"(?m)^(#{2,5}\s*)(.+?)\s*(PL9 p\d+(?:-p\d+)?[^)]*)", r"\1\2", text)
text = re.sub(r"Progression \(local (\d+)\)", r"Progression (\1)", text)
text = _strip_pl9_user_engineering_markers(text)
text = normalize_zh_markdown_terms(text)
text = text.replace("项目化资料附录", "结构化资料附录")
zh_heading_repairs = {
"Birth Data and Calculation 口径": "出生资料与计算口径",
"Core Chart Raw Positions": "本命盘原始位置",
"Divisional Charts Numeric Tables": "分盘数值位置表",
"Sensitive Points": "敏感点",
"Upagraha and Sub-行星 Points": "Upagraha 与副行星点",
"Upagraha and 副行星 Points": "Upagraha 与副行星点",
"行星 Friendship Matrix": "行星友敌关系矩阵",
"行星 Friendship 矩阵源数据": "行星友敌关系矩阵源数据",
"六维力量 Components": "六维力量细项",
"Functional 自然吉星 / 自然凶星 Layer": "功能性吉凶星层",
"Bhava 年龄状态 宫位 强度": "十二宫力量",
"行星ary Friendship Matrix": "行星友敌关系矩阵",
"行星ary Friendship 矩阵源数据": "行星友敌关系矩阵源数据",
"Sarvashtakavarga": "Sarvashtakavarga 总分",
"Bhinnashtakavarga": "Bhinnashtakavarga 单星八分表",
"KP 行星 星座ificators": "KP 行星征象星",
"KP 宫位 Cusps and 星座ificators": "KP 宫头与征象星",
# Since the term pass stopped rewriting inside words (BUG-1274),
# "Significators" arrives intact; these keys match that spelling.
"KP 宫位 Cusps and Significators": "KP 宫头与征象星",
"KP 行星 Significators": "KP 行星征象星",
"KP Significators / 行星 宫主": "KP 征象星 / 行星主星",
"KP Chart / Cusps": "KP 星盘 / 宫头",
"Annual Tajika瑜伽 Rows": "年度 Tajika 瑜伽行",
"KP 星座ificators / 行星 Lords": "KP 征象星 / 行星主星",
"Upagraha / Sub-行星s 点位": "Upagraha / 副行星点位",
"大运 Tables": "Dasha 表格",
"Annual Varshaphala Core Fields": "年度 Varshaphala 核心字段",
"Annual Mudda 大运 Rows": "年度 Mudda Dasha 行",
"Annual Patyayini / Patyamsha Rows": "年度 Patyayini / Patyamsha 行",
"Annual Saham Rows": "年度 Saham 行",
"Annual 行星 Extended Fields": "年度行星扩展字段",
"Annual Anchor Points": "年度锚点",
"Monthly Chart Pack": "月度图盘包",
"Lifetime Annual Chart Pages": "生命期年度图盘页",
"Birth-Place Eight-Year Annual Series": "出生地八年年度序列",
"Local Eight-Year Annual Series": "现居地八年年度序列",
"Applicable Yogas": "可用瑜伽",
"Dosha and Transit-Condition Summary": "Dosha 与行运状态摘要",
"Dosha and Transit Summary": "Dosha 与行运摘要",
"| Topic | Detected / phase | Summary |": "| 项目 | 是否成立 / 阶段 | 摘要 |",
"Dharidhra:": "贫困组合:",
"not listed": "未列",
"Weak": "弱",
"Moderate": "中等",
"Strong": "强",
"weak": "弱",
"medium": "中等",
"strong": "强",
"house ": "第",
}
text = replace_natural_language_term(text, "house condition", "宫位条件")
for raw, public in zh_heading_repairs.items():
text = text.replace(raw, public)
# Heading lines only: a Latin yoga name keeps a space before 瑜伽 ("Dhana 瑜伽").
text = re.sub(r"(?m)^(#{1,6} .*?[A-Za-z])瑜伽", r"\1 瑜伽", text)
text = replace_natural_language_term(text, " condition", "宫条件")
zh_public_key_repairs = {
"mangal_dosha": "火星婚姻煞",
"kaal_sarp_dosha": "时蛇煞",
"pitra_dosha": "祖业/父系业力煞",
"sade_sati": "Sade Sati(土星七年半)",
"Mangala / Kuja Dosha": "火星婚姻煞(Mangala / Kuja Dosha)",
"Kaal Sarp Dosha": "时蛇煞(Kaal Sarp Dosha)",
"Pitra Dosha": "祖业/父系业力煞(Pitra Dosha)",
}
for raw, public in zh_public_key_repairs.items():
text = text.replace(raw, public)
# A source cell that already carries the gloss would be wrapped a second time.
text = re.sub(
r"(火星婚姻煞|时蛇煞|祖业/父系业力煞)(\1(([^)]+)))",
r"\1(\2)",
text,
)
text = text.replace("Sarvashtakavarga 总分 总分", "Sarvashtakavarga 总分")
text = text.replace("Bhinnashtakavarga 单星八分表 单星八分表", "Bhinnashtakavarga 单星八分表")
text = re.sub(r"Mula(?:(根[,、/]本源))+", "Mula(根、本源)", text)
text = re.sub(r"根宿(Mula(根[,、/]本源))", "Mula(根、本源)", text)
text = re.sub(r"^#\s+.*$", f"# {title}", text, count=1, flags=re.MULTILINE)
text = _clean_pl9_user_table_placeholders(text, language="zh")
text = _repair_user_facing_orphan_table_headers(text, language="zh")
text = re.sub(r"\n{3,}", "\n\n", text).strip()
visible_lines = [line.strip() for line in text.splitlines() if line.strip()]
first_body_line = ""
for line in visible_lines[:8]:
if line.startswith("#"):
continue
first_body_line = line
break
has_opening_summary = bool(first_body_line) and any(
marker in first_body_line for marker in ("摘要:", "结论:", "重点:")
)
if text.startswith(f"# {title}") and not has_opening_summary:
summary = "\n".join(
[
f"# {title}",
"",
"摘要:本册先保留完整排盘资料,再按强度、Dasha、KP、年度与解释页进入证据附录。",
"",
"## 重点",
"1. 先核对出生资料、图盘、度数和计算口径。",
"2. 再看 Shadbala、Ashtakavarga、Dasha、KP 与年度 Tajika 表格。",
"3. 最后用证据附录核验来源、边界与仍需复核的项目。",
]
)
text = re.sub(r"^#\s+.*$", summary, text, count=1, flags=re.MULTILINE)
return strip_han_plural_s(text) + "\n"
def _clean_pl9_user_table_placeholders(markdown: str, *, language: str) -> str:
"""Remove empty placeholder cells and compressed table fragments from user exports."""
zh_planet_names = "太阳|月亮|火星|水星|木星|金星|土星|北交点/罗睺|南交点/计都"
en_planet_names = "Sun|Moon|Mars|Mercury|Jupiter|Venus|Saturn|Rahu|Ketu"
def normalize_condition_cell(cell: str) -> str:
value = cell.strip()
if value in {"", "-", "未列", "not listed"}:
return "未列" if language == "zh" else "not listed"
if language == "zh":
value = re.sub(rf"\b(\d+)({zh_planet_names})(\d+)\b", r"第\1宫;\2在第\3宫", value)
value = re.sub(rf"\b({zh_planet_names})(\d+)\b", r"\1在第\2宫", value)
value = re.sub(r"(?<!\d)(\d+)KendraTrikona\b", r"第\1宫;角宫/三分宫条件", value)
value = re.sub(r"(?<!\d)(\d+)Kendra\b", r"第\1宫;角宫条件", value)
value = re.sub(r"(?<!\d)(\d+)弱/?", r"第\1宫较弱", value)
value = re.sub(r"(?<!\d)(\d+)/(?=\s|$|[|,,])", r"第\1宫条件", value)
value = re.sub(r"第(\d+)宫条件第(\d+)宫条件", r"第\1宫、第\2宫条件", value)
value = re.sub(r"第(\d+)宫条件第(\d+)宫条件", r"第\1宫、第\2宫条件", value)
if re.fullmatch(r"\d+", value):
if len(value) == 2:
return f"第{value[0]}/{value[1]}宫条件"
return f"第{value}宫条件"
value = re.sub(r"\b74,\s*D1/D9", "第7/4宫条件;D1/D9", value)
return value
value = re.sub(rf"\b(\d+)({en_planet_names})(\d+)\b", r"house \1; \2 in house \3", value)
value = re.sub(rf"\b({en_planet_names})(\d+)\b", r"\1 in house \2", value)
value = re.sub(r"(?<!\d)(\d+)KendraTrikona\b", r"house \1; kendra/trikona condition", value)
value = re.sub(r"(?<!\d)(\d+)Kendra\b", r"house \1; kendra condition", value)
value = re.sub(r"(?<!\d)(\d+)weak/?", r"house \1 weak", value)
value = re.sub(r"(?<!\d)(\d+)/(?=\s|$|[|,])", r"house \1 condition", value)
if re.fullmatch(r"\d+", value):
if len(value) == 2:
return f"houses {value[0]}/{value[1]} condition"
return f"house {value} condition"
value = re.sub(r"\b74,\s*D1/D9", "houses 7/4; D1/D9", value)
return value
def compress_markdown_tables(raw_text: str) -> str:
lines = raw_text.splitlines()
out: list[str] = []
i = 0
empty_values = {"", "-", "未列", "not listed"}
while i < len(lines):
if (
i + 1 < len(lines)
and lines[i].lstrip().startswith("|")
and lines[i + 1].lstrip().startswith("|")
and set(lines[i + 1].replace("|", "").replace("-", "").replace(":", "").strip()) == set()
):
block: list[str] = []
while i < len(lines) and lines[i].lstrip().startswith("|"):
block.append(lines[i])
i += 1
rows = [[cell.strip() for cell in line.strip().strip("|").split("|")] for line in block]
if len(rows) >= 2:
headers = rows[0]
body = rows[2:]
for row in rows[2:]:
for index, header in enumerate(headers):
if index >= len(row):
continue
if header in {"组合条件", "Combination"}:
row[index] = normalize_condition_cell(row[index])
elif header in {"作用说明", "Effects / notes", "Notes", "Summary"} and row[index].strip() in empty_values:
row[index] = "未列" if language == "zh" else "not listed"
remove: set[int] = set()
for index, header in enumerate(headers):
column = [row[index] if index < len(row) else "" for row in body]
empty_count = sum(1 for cell in column if cell.strip() in empty_values)
if header in {"作用说明", "Effects / notes", "Notes", "Summary"} and body and empty_count / len(body) >= 0.65:
remove.add(index)
if header in {"组合条件", "Combination"} and body and empty_count / len(body) >= 0.9:
remove.add(index)
if remove and len(remove) < len(headers):
rows = [[cell for index, cell in enumerate(row) if index not in remove] for row in rows]
rows[1] = ["---"] * len(rows[0])
out.extend("| " + " | ".join(row) + " |" for row in rows)
else:
out.extend(block)
continue
out.append(lines[i])
i += 1
return "\n".join(out)
text = markdown
empty_label = "未列" if language == "zh" else "not listed"
text = re.sub(r"(?:^|;\s*)-(?:\s*;\s*-){1,}\s*$", empty_label, text, flags=re.MULTILINE)
text = re.sub(r"\|\s*(?:-\s*\|\s*){3,}(?:missing_in_local|未单独列出)\s*\|", "", text)
text = re.sub(r"(?m)^\|\s*-\s*\|.*\|\s*(?:missing_in_local|未单独列出|not listed)\s*\|\s*$", "", text)
text = text.replace("missing_in_local", empty_label)
text = re.sub(r"\blisted planets(\d+)\b", r"\1 listed planet(s)", text)
text = re.sub(r"\b(木星|太阳|月亮|火星|水星|金星|土星|北交点/罗睺|南交点/计都)(\d+)\b", r"\1在第\2宫", text)
text = re.sub(r"\b(Jupiter|Sun|Moon|Mars|Mercury|Venus|Saturn|Rahu|Ketu)(\d+)\b", r"\1 in house \2", text)
text = re.sub(r"\b(MercuryMoon|SunMercury|MoonVenus)(\d+)\b", r"\1 in house \2", text)
text = re.sub(r"\b(\d+)(Mars|Venus|Jupiter|Sun|Moon|Mercury|Saturn|Rahu|Ketu)(\d+)\b", r"house \1; \2 in house \3", text)
text = re.sub(r"\b(\d+)弱/", r"第\1宫较弱", text)
text = re.sub(r"\b(\d+)weak/", r"house \1 weak", text)
text = re.sub(r"\b(\d+)/\b", r"第\1宫条件", text)
text = re.sub(r"//,\s*", "", text)
text = text.replace("D1/D9debilitated", "D1/D9 落陷" if language == "zh" else "D1/D9 debilitated")
text = text.replace("Dharidhra:", "贫困组合:" if language == "zh" else "Daridra combination:")
text = text.replace("Dharidhra Yoga", "Daridra Yoga")
text = text.replace("-; -; 弱", "弱")
text = text.replace("-; -; weak", "weak")
text = re.sub(r"-;\s*(强|弱|中等|strong|weak|medium|moderate);\s*-", r"\1", text)
text = re.sub(r"-;\s*/", empty_label, text)
text = re.sub(r"\b(MercuryMoon|SunVenus|VenusSun|MoonVenus|VenusMoon)(?:\s+in\s+)?第?(\d+)\b", r"\1 in house \2", text)
text = re.sub(r"\b(SunVenus|VenusSun|MoonVenus|VenusMoon|MercuryMoon)(\d+)\b", r"\1 in house \2", text)
text = re.sub(r"\b(MercuryVenus|VenusMercury)(\d+)\b", r"\1 in house \2", text)
text = re.sub(r"\b(\d+)KendraTrikona\b", r"house \1; kendra/trikona condition", text)
text = re.sub(r"\b(\d+)Kendra\b", r"house \1; kendra condition", text)
text = re.sub(r"\b(\d+)/(?=\s*\|)", r"house \1 condition", text)
text = text.replace("weak/", "weak")
text = text.replace(", Kendra", "kendra condition")
text = re.sub(r"\b(Mars|Mercury|Venus|Jupiter|Saturn|Sun|Moon|Rahu|Ketu)(\d+)(Aries|Taurus|Gemini|Cancer|Leo|Virgo|Libra|Scorpio|Sagittarius|Capricorn|Aquarius|Pisces)\b", r"\1 in house \2, \3", text)
text = re.sub(r"\b(Saturn|Mars|Mercury|Venus|Jupiter|Sun|Moon|Rahu|Ketu)(medium|weak|strong|moderate)\b", r"\1 \2", text)
if language == "zh":
text = re.sub(
r"Consideration:\s*(.+?)会被校验 from 上升,\s*月亮,\s*and 金星 并核对传统婚恋敏感宫位。\s*In this chart\s+(.+?)\s+is listed in 第(.+?)\s+in\s+(.+?);\s*检测强度为(.+?)。\s*古典参考体系:Muhurtha / Electional Astrology 与印度占星读书摘要。",
r"校验说明:\1会同时从上升、月亮与金星出发,核对传统婚恋敏感宫位;本盘中,\2位于第\3宫、\4,检测强度为\5。古典参考体系:Muhurtha / Electional Astrology 与印度占星读书摘要。",
text,
)
text = re.sub(
r"校验说明:火星婚姻煞会同时从上升、月亮与金星出发,核对传统婚恋敏感宫位。\s*本盘火星位于第\s*(.+?)\s+in\s+(.+?);\s*检测强度为(.+?)。\s*古典参考体系:Muhurtha / Electional Astrology 与印度占星读书摘要。",
r"校验说明:火星婚姻煞会同时从上升、月亮与金星出发,核对传统婚恋敏感宫位;本盘中,火星位于第\1宫、\2,检测强度为\3。古典参考体系:Muhurtha / Electional Astrology 与印度占星读书摘要。",
text,
)
text = re.sub(
r"Consideration:\s*火星会被校验 from 上升,\s*月亮,\s*and 金星 并核对传统婚恋敏感宫位。\s*In this chart 火星 is listed in 第(.+?)\s+in\s+(.+?);\s*the detector severity is\s+(.+?)\.\s*古典参考体系:Muhurtha / Electional Astrology 与印度占星读书摘要。",
r"校验说明:火星婚姻煞会同时从上升、月亮与金星出发,核对传统婚恋敏感宫位;本盘中,火星位于第\1宫、\2,检测强度为\3。古典参考体系:Muhurtha / Electional Astrology 与印度占星读书摘要。",
text,
)
text = re.sub(r"\n项目字段:.*?(?=\n)", "", text)
text = re.sub(
r"Consideration:\s*(.+?)\s+is checked from\s+(.+?)\s+against the classical marriage-sensitive houses\.\s*In this chart\s+(.+?)\s+is listed in house\s+(.+?);?\s+the detector severity is\s+(.+?)\.\s*Classical reference family:\s*(.+?)\.",
r"校验说明:\1会从\2出发,核对传统婚恋敏感宫位;本盘中,\3列于第\4宫,检测强度为\5。古典参考体系:\6。",
text,
)
text = re.sub(
r"Consideration:\s*(.+?)\s+is checked from\s+(.+?)\s+against the classical marriage-sensitive houses\.\s*In this chart\s+(.+?)\s+is listed in\s+(.+?);?\s+the detector severity is\s+(.+?)\.\s*Classical reference family:\s*(.+?)\.",
r"校验说明:\1会从\2出发,核对传统婚恋敏感宫位;本盘中,\3列于\4,检测强度为\5。古典参考体系:\6。",
text,
)
text = re.sub(
r"Consideration:\s*火星会被校验 from 上升,\s*月亮,\s*and 金星 并核对传统婚恋敏感宫位。\s*In this chart 火星 is listed in 第-;\s*检测强度为无。\s*古典参考体系:Muhurtha / Electional Astrology 与印度占星读书摘要。",
"校验说明:火星婚姻煞会同时从上升、月亮与金星出发,核对传统婚恋敏感宫位;本盘未列火星命中婚恋敏感宫位,检测强度为无。古典参考体系:Muhurtha / Electional Astrology 与印度占星读书摘要。",
text,
)
text = re.sub(
r"Consideration:\s*火星 is checked from 上升,\s*月亮,\s*and 金星 against the classical marriage-sensitive houses\.\s*In this chart 火星 is listed in 第-;\s*the detector severity is none\.\s*Classical reference family:\s*Muhurtha or Electional Astrology;\s*印度占星读书摘要\.",
"校验说明:火星婚姻煞会同时从上升、月亮与金星出发,核对传统婚恋敏感宫位;本盘未列火星命中婚恋敏感宫位,检测强度为无。古典参考体系:Muhurtha / Electional Astrology 与印度占星读书摘要。",
text,
)
text = re.sub(
r"Consideration:\s*this Dosha row is shown only when the local detector returns a chart-specific condition\.\s*Classical reference family is attached in the source map\.",
"校验说明:只有本命盘检测器返回具体条件时,才显示这一条 Dosha;古典参考体系已随来源映射保留。",
text,
)
planet_pair_terms = {
"MercuryMoon": "水星与月亮",
"SunVenus": "太阳与金星",
"VenusSun": "金星与太阳",
"MoonVenus": "月亮与金星",
"VenusMoon": "金星与月亮",
"MercuryVenus": "水星与金星",
"VenusMercury": "金星与水星",
}
for raw, public in planet_pair_terms.items():
text = text.replace(raw, public)
english_planet_to_zh = {
"Sun": "太阳",
"Moon": "月亮",
"Mars": "火星",
"Mercury": "水星",
"Jupiter": "木星",
"Venus": "金星",
"Saturn": "土星",
"Rahu": "北交点/罗睺",
"Ketu": "南交点/计都",
}
def triple_planets_to_zh(match: re.Match[str]) -> str:
names = [english_planet_to_zh.get(match.group(i), match.group(i)) for i in (1, 2, 3)]
return f"{'、'.join(names)}在第{match.group(4)}宫"
text = re.sub(
r"\b(Sun|Moon|Mars|Mercury|Jupiter|Venus|Saturn|Rahu|Ketu)(Sun|Moon|Mars|Mercury|Jupiter|Venus|Saturn|Rahu|Ketu)(Sun|Moon|Mars|Mercury|Jupiter|Venus|Saturn|Rahu|Ketu)(\d+)\b",
triple_planets_to_zh,
text,
)
text = re.sub(r"in house (\d+)", r"在第\1宫", text)
else:
text = re.sub(r"第(\d+)宫条件第(\d+)宫条件(\d+)", r"houses \1/\2/\3 condition", text)
text = re.sub(r"第(\d+)宫条件(\d+)", r"houses \1/\2 condition", text)
text = re.sub(r"第(\d+)宫条件", r"house \1 condition", text)
text = re.sub(r"\b(Moon|Sun|Mars|Mercury|Jupiter|Venus|Saturn|Rahu|Ketu)(Rahu|Ketu|Moon|Sun|Mars|Mercury|Jupiter|Venus|Saturn)(Rahu|Ketu|Moon|Sun|Mars|Mercury|Jupiter|Venus|Saturn)(\d+)\b", r"\1, \2, \3 in house \4", text)
text = re.sub(r"\b(\d+)(strong|medium|weak|moderate)/", r"house \1 \2", text)
text = re.sub(r"\b(\d+)Navamsa(\d+)/house (\d+) condition(\d+)", r"Navamsa \1/\2; houses \3/\4 condition", text)
text = re.sub(r"\b(\d+)Navamsa(\d+)/houses? ([0-9/]+) condition", r"Navamsa \1/\2; houses \3 condition", text)
text = re.sub(r"\b(\d+)Navamsa(\d+)/(\d+)", r"Navamsa \1/\2/\3 condition", text)
text = text.replace("MercuryMoon", "Mercury-Moon")
text = text.replace("SunMercury", "Sun-Mercury")
text = text.replace("MoonVenus", "Moon-Venus")
text = text.replace("SunVenus", "Sun-Venus")
text = text.replace("VenusSun", "Venus-Sun")
text = text.replace("VenusMoon", "Venus-Moon")
text = text.replace("MercuryVenus", "Mercury-Venus")
text = text.replace("VenusMercury", "Venus-Mercury")
text = re.sub(r"\|\s*-\s*\|", f"| {empty_label} |", text)
text = compress_markdown_tables(text)
text = re.sub(r"\n{3,}", "\n\n", text)
return text
def _repair_user_facing_orphan_table_headers(markdown: str, *, language: str) -> str:
"""Restore user-facing table headers removed by language cleanup.
The source report may contain Chinese section/table headers before English
sanitization. Dropping those lines is correct, but the markdown separator
row must not be left as the first row of a table.
"""
def is_table_line(line: str) -> bool:
return line.lstrip().startswith("|") and line.rstrip().endswith("|")
def is_separator(line: str) -> bool:
stripped = line.strip()
if not is_table_line(stripped):
return False
body = stripped.replace("|", "").replace("-", "").replace(":", "").strip()
return body == ""
def cells(line: str) -> list[str]:
return [cell.strip() for cell in line.strip().strip("|").split("|")]
def has_cjk(value: str) -> bool:
return bool(re.search(r"[\u4e00-\u9fff]", value))
def header_for(next_cells: list[str]) -> list[str]:
count = len(next_cells)
sample = " | ".join(next_cells)
if language == "zh":
if count == 4 and re.search(r"火星婚姻煞|时蛇煞|Sade Sati|Dosha", sample):
return ["项目", "是否成立", "条件", "说明"]
return [f"列{index}" for index in range(1, count + 1)]
if count == 2:
return ["Field", "Value"]
if count == 3 and re.search(r"Saham|Arudha|Lagna", sample, re.I):
return ["Point", "Sign", "Degree"]
if count == 4 and re.search(r"\b(Sun|Moon|Mars|Mercury|Jupiter|Venus|Saturn|Rahu|Ketu)\b", sample):
if re.search(r"\d+\.\d+°", sample):
return ["Planet", "Sign", "Longitude", "House"]
return ["Lord", "Sign/Rashi", "Start", "End"]
if count == 4 and re.search(r"\d{4}-\d{2}-\d{2}", sample):
return ["Year", "Age", "Solar return time", "Annual ascendant"]
if count == 4:
return ["Name", "Sign", "Degree", "House"]
if count == 5 and re.search(r"\d{4}-\d{2}-\d{2}", sample):
if re.search(r"\d{4}-\d{2}-\d{2}.*-", sample):
return ["Order", "Lord", "Duration", "Period", "Notes"]
return ["Order", "Lord", "Start", "End", "Years"]
if count == 5 and re.search(r"^\d+$", next_cells[0]):
return ["House", "Sign", "SAV", "Strength", "Notes"]
if count == 5:
return ["House", "Sign", "Lord", "Score", "Key factors"]
if count == 6 and re.search(r"\d{4}-\d{2}-\d{2}", sample):
return ["Order", "Major lord", "Sub lord", "Sub-sub lord", "Start", "End"]
if count == 6 and re.search(r"Paravatamsa|Simhasanamsa|Vimsopaka", sample):
return ["Planet", "Score", "Grade", "Combustion", "Retrograde", "Debilitated"]
if count == 6:
return ["Order", "Year", "Solar return time", "Annual ascendant", "Ascendant degree", "Planetary longitudes"]
if count == 8:
return ["Planet 1", "Planet 2", "Aspect", "Exact degree", "Actual difference", "Orb", "Applying", "Strength"]
return [f"Column {index}" for index in range(1, count + 1)]
lines = markdown.splitlines()
out: list[str] = []
i = 0
while i < len(lines):
line = lines[i]
if not is_separator(line):
out.append(line)
i += 1
continue
previous_is_table = bool(out and is_table_line(out[-1]) and not is_separator(out[-1]))
if previous_is_table:
out.append(line)
i += 1
continue
j = i + 1
while j < len(lines) and not lines[j].strip():
j += 1
if j >= len(lines) or not is_table_line(lines[j]) or is_separator(lines[j]):
i += 1
continue
next_cells = cells(lines[j])
header = header_for(next_cells)
if language == "en":
header = [cell if not has_cjk(cell) else f"Column {idx}" for idx, cell in enumerate(header, 1)]
out.append("| " + " | ".join(header) + " |")
out.append("| " + " | ".join(["---"] * len(header)) + " |")
i += 1
repaired = "\n".join(out)
lines = repaired.splitlines()
compacted: list[str] = []
i = 0
while i < len(lines):
if (
i + 1 < len(lines)
and is_table_line(lines[i])
and is_separator(lines[i + 1])
):
j = i + 2
while j < len(lines) and not lines[j].strip():
j += 1
next_starts_new_empty_table = (
j + 1 < len(lines)
and is_table_line(lines[j])
and not is_separator(lines[j])
and is_separator(lines[j + 1])
)
if j >= len(lines) or not is_table_line(lines[j]) or is_separator(lines[j]) or next_starts_new_empty_table:
i = i + 2
continue
compacted.append(lines[i])
i += 1
lines = compacted
compacted = []
i = 0
while i < len(lines):
compacted.append(lines[i])
if is_separator(lines[i]):
j = i + 1
while j < len(lines) and not lines[j].strip():
j += 1
if j > i + 1 and j < len(lines) and is_table_line(lines[j]) and not is_separator(lines[j]):
i = j
continue
i += 1
return "\n".join(compacted)
def _sanitize_pl9_ai_density_markdown_en(markdown: str, title: str = "Vedic Astrology Chart Report") -> str:
"""English user volume: remove engineering labels without translating facts."""
try:
from pl9_language_terms import normalize_en_markdown_terms
except ModuleNotFoundError: # pragma: no cover - package import compatibility
from scripts.pl9_language_terms import normalize_en_markdown_terms
text = markdown
replacements = {
"# PL9 对标主册": f"# {title}",
"PL9 对标主册": title,
"PL9 风格专业排盘导出": title,
"parameter_sensitive / unverified": "reference",
"parameter_sensitive": "reference",
"partial_verified": "listed",
"pyjhora_behavior_only / not_multiengine_parity": "reference",
"restricted_or_unclosed": "reference",
"not_applicable": "not listed",
"blocked_and_conflict_fields_cannot_generate_final_predictions": "listed only",
"blocked": "not listed",
"profile_sensitive": "reference",
"source_path": "source",
"producer": "calculation item",
"schema": "item",
"audit": "review",
"status": "status",
"dasha_beginning_dates": "beginning dates",
"dasha_ending_dates": "ending dates",
"birth_balance": "birth balance",
"moon_in_varshaphala": "Moon in Varshaphala",
"without_dasha_balance": "without Dasha balance",
"external_mudda_start_boundary": "start boundary",
"p132-p135 Mudda / Patyayini 年度 Dasha 表": "Mudda / Patyayini Annual Dasha Table",
"p136-p138 Tajika Yoga Presence 对照表": "Tajika Yoga Presence Table",
"行星、星宿与宫位长页解释(PL9 p18-p29 对标)": "Planets, Nakshatras and Houses",
"大运长页解释(PL9 p190-p204 对标)": "Dasha Interpretations",
"强度、关系与 Ashtakavarga 技术页(PL9 p30-p60 对标)": "Strength, Relationships and Ashtakavarga",
"标准 Dasha 表格页(PL9 p61-p120 对标)": "Standard Dasha Tables",
"Saturn 与 KP 支持页(PL9 p121-p125 对标)": "Saturn and KP Tables",
"年度图盘与 Tajika(PL9 p126-p155 对标)": "Annual Varshaphala and Tajika",
"Bhavesh、Dosha 与 Yoga 解释页(PL9 p156-p189 对标)": "Bhavesh, Dosha and Yoga Interpretations",
"Result: when present, Kuja/Mangala Dosha is read as heat, impatience, conflict, or pressure around partnership handling; when absent or cancelled, the report does not promote it as a dominant relationship obstacle. The result must be read with the seventh house, Venus, Upapada, Navamsha, current Dasha, and partner-chart comparison where available.": "When present, Kuja/Mangala Dosha is read as heat, impatience, conflict, or pressure around partnership handling. When absent or cancelled, it is not treated as a dominant relationship obstacle. This factor is read together with the seventh house, Venus, Upapada, Navamsha, current Dasha, and partner-chart comparison where available.",
"Consideration: this Dosha row is shown only when the local detector returns a chart-specific condition. Classical reference family is attached in the source map.": "This Dosha row is shown only when the chart-specific condition is present. The classical reference family is retained in the source layer.",
"Result: this row contributes a supporting condition and is not promoted over the core chart, Dasha, and divisional evidence.": "This row contributes a supporting condition and is not promoted over the core chart, Dasha, and divisional evidence.",
}
for raw, public in replacements.items():
text = text.replace(raw, public)
reverse_terms = {
"出生资料与图盘": "Birth Data and Charts",
"姓名": "Name",
"出生": "Birth",
"计算口径": "Calculation profile",
"出生日期": "Birth date",
"出生时间": "Birth time",
"纬度": "Latitude",
"经度": "Longitude",
"时区": "Time zone",
"岁差名称": "Ayanamsa name",
"岁差值": "Ayanamsa value",
"岁差": "Ayanamsa",
"交点模式": "Node mode",
"吉性_count": "auspicious_count",
"中(Mixed)": "Mixed",
"行星原始位置": "Planetary Raw Positions",
"星主与状态字段": "Lords and Status Fields",
"星宿": "Nakshatra",
"状态": "Status",
"对象": "Object",
"落座度数": "Degree in sign",
"落座": "Sign",
"黄经": "Longitude",
"整宫宫位": "Whole-sign house",
"资料": "Data",
"点位": "Points",
"敏感点与特殊落点": "Sensitive and Special Points",
"本命与分盘北印度图盘": "Natal and Divisional North Indian Charts",
"Ashtakavarga 完整宫位分数": "Ashtakavarga Full House Scores",
"分盘对出生时间敏感,原始计算供核对,不单独增加结论的确定性。": "Divisional charts are sensitive to birth time. The raw calculations are here for checking and do not by themselves make a conclusion more certain.",
"行星主星与状态": "Planet Lords and Dignity",
"| 行星 | 星座主 | 星宿主 | 分主 | 分分主 | 尊贵状态 | 力量比 |": "| Planet | Sign lord | Star lord | Sub lord | Sub-sub lord | Dignity | Strength ratio |",
"Avastha 行星状态": "Planetary Avasthas",
"Sudarshan Chakra 资料": "Sudarshan Chakra Data",
"Sudarshana Chakra(三参考点原始结构)": "Sudarshana Chakra Tri-Reference Structure",
"数值位置表": "Numeric Position Table",
"上升": "Lagna",
"合计": "Total",
"月亮 Chart": "Moon Chart",
"九分盘": "Navamsha",
"可用瑜伽总表": "Applicable Yoga Summary",
"可用吉性瑜伽": "Applicable Benefic Yogas",
"可用混合型瑜伽": "Applicable Mixed Yogas",
"可用凶性瑜伽": "Applicable Malefic Yogas",
"检出的瑜伽数量": "Yoga detected count",
"参考层": "Context layers",
"| 瑜伽 | 类别 | 强度 | 组合条件 | 作用说明 |": "| Yoga | Category | Strength | Combination | Effects / notes |",
"|------|------|------|----------|----------|": "|------|----------|----------|-------------|-----------------|",
"合相": "conjunction",
"特殊组合": "special",
"婚恋": "kalatra",
"不利组合": "durbhaga",
"太阳瑜伽": "solar yoga",
"月亮瑜伽": "lunar yoga",
"中等": "moderate",
"常见": "common",
"偶见": "occasional",
"强": "strong",
"弱": "weak",
"中强": "medium-strong",
"中": "medium",
"主题": "Topic",
"建议": "Practice",
"宝石/次数": "Gem / count",
"说明": "Notes",
"太阳": "Sun",
"月亮": "Moon",
"火星": "Mars",
"水星": "Mercury",
"木星": "Jupiter",
"金星": "Venus",
"土星": "Saturn",
"北交点/罗睺": "Rahu",
"南交点/计都": "Ketu",
"北交点": "Rahu",
"南交点": "Ketu",
"极友": "great friend",
"入友": "friendly",
"友好星座": "friendly sign",
"中性": "neutral",
"落陷": "debilitated",
"入庙": "own sign",
"充足": "adequate",
"自然吉星": "natural benefic",
"自然凶星": "natural malefic",
"功能吉星": "functional benefic",
"功能凶星": "functional malefic",
"功能中性": "functional neutral",
"第": "house ",
"宫": "",
"足": "pada",
"大运": "Dasha",
"财富": "wealth",
"事业": "career",
"健康": "health",
"吉性": "benefic",
"王权/事业": "authority/career",
"天象格局": "configuration",
"子女/创造": "children/creativity",
"体质参考": "constitution reference",
"寿命/健康": "constitution reference",
"愿望/收益": "desire/gains",
"解脱/内在": "moksha/inner life",
"贵人/助力": "patronage/support",
"迁移/出行": "travel/movement",
"婚姻稳定": "marital stability",
"自定义条件满足": "custom condition satisfied",
"同在": "together in",
"在": "in",
"与": "and",
"白羊座": "Aries",
"金牛座": "Taurus",
"双子座": "Gemini",
"巨蟹座": "Cancer",
"狮子座": "Leo",
"处女座": "Virgo",
"天秤座": "Libra",
"天蝎座": "Scorpio",
"射手座": "Sagittarius",
"摩羯座": "Capricorn",
"水瓶座": "Aquarius",
"双鱼座": "Pisces",
"前阿沙陀(Purva Ashadha)": "Purva Ashadha",
"Mula(根、本源)": "Mula",
"后破伽(Uttara Phalguni)": "Uttara Phalguni",
"后阿沙陀(Uttara Ashadha)": "Uttara Ashadha",
"后跋陀罗(Uttara Bhadrapada)": "Uttara Bhadrapada",
"阿湿毗尼(Ashwini)": "Ashwini",
"巴拉尼(Bharani)": "Bharani",
"基利提卡(Krittika)": "Krittika",
"罗希尼(Rohini)": "Rohini",
"鹿首(Mrigashira)": "Mrigashira",
"阿尔德拉(Ardra)": "Ardra",
"普那婆苏(Punarvasu)": "Punarvasu",
"普沙(Pushya)": "Pushya",
"阿什列沙(Ashlesha)": "Ashlesha",
"摩伽(Magha)": "Magha",
"前破伽(Purva Phalguni)": "Purva Phalguni",
"哈斯塔(Hasta)": "Hasta",
"吉多罗(Chitra)": "Chitra",
"斯瓦蒂(Swati)": "Swati",
"毗舍佉(Vishakha)": "Vishakha",
"阿奴罗陀(Anuradha)": "Anuradha",
"杰耶什塔(Jyeshtha)": "Jyeshtha",
"室罗伐那(Shravana)": "Shravana",
"陀尼湿陀(Dhanishta)": "Dhanishta",
"百药宿(Shatabhisha)": "Shatabhisha",
"前跋陀罗(Purva Bhadrapada)": "Purva Bhadrapada",
"雷瓦蒂(Revati)": "Revati",
}
for raw, public in sorted(reverse_terms.items(), key=lambda item: len(item[0]), reverse=True):
text = text.replace(raw, public)
phrase_terms = {
"Sudarshana Chakra 三参考点盘 (BPHS标准)": "Sudarshana Chakra tri-reference chart (BPHS standard)",
"行星、Nakshatra与宫位解释": "Planets, Nakshatras and Houses",
"身份、目标与可见责任": "identity, purpose and visible responsibility",
"情绪、安全感与适应方式": "emotional security and adaptive response",
"行动、竞争与执行方式": "action, competition and execution style",
"学习、表达与交易能力": "learning, expression and exchange",
"信念、判断与扩张方式": "belief, judgment and expansion",
"关系、审美与协调能力": "relationship, taste and harmonizing capacity",
"结构、压力与长期责任": "structure, pressure and long-term responsibility",
"业力放大、突破与非传统路径": "amplification, disruption and unconventional pathways",
"收束、分离与内在化路径": "release, separation and inwardization",
"职业、职责与社会角色": "career, duty and public role",
"合作、关系与对外互动": "partnership, contracts and public interaction",
"自我选择、身体节律和人生方向": "self-direction, body rhythm and life orientation",
"家庭基础、居住与内在稳定": "home base, residence and inner stability",
"行动、学习与沟通": "initiative, learning and communication",
"资源、表达与家庭价值": "resources, speech and family values",
"创造、学习与判断": "creativity, learning and judgment",
"服务、压力与日常责任": "service, pressure and daily obligations",
"共享资源、风险与深层变化": "shared resources, risk and deep change",
"信念、远行与高阶学习": "belief, long-distance travel and higher learning",
"收益、社群与长期愿景": "gains, networks and long-range aims",
"休整、支出与幕后领域": "retreat, expenditure and behind-the-scenes matters",
"这颗星的表达会通过定位星所在宫位继续展开": "the planet continues through the house occupied by its dispositor",
"Nakshatra主会进一步修饰这颗星的表达方式,并与当前Dasha节奏一起阅读": "the nakshatra lord further qualifies the planet and is read with the current Dasha rhythm",
"自然属性与功能属性不同,阅读时需要同时参考两层": "the natural and functional roles differ, so both layers are considered",
"这些力量信息用于判断该落点的表现strongweak,并与宫主、Nakshatra和Dasha节奏合看": "these strength factors are read with the house lord, nakshatra and Dasha rhythm",
"当前不在Dasha主导层,其Topic更偏向本命基础背景": "is not currently a leading Dasha factor; its topic is mainly natal background",
"会把该宫Topic带到": "carries this house topic into",
"表现为必须落到行动和成果": "showing that the matter must be grounded in action and results",
"表现为需要通过他人关系显化": "showing that the matter expresses through others and relationship interfaces",
"表现为本人主动承接": "showing direct personal involvement",
"判断strongweak时同时参考宫主尊贵、同宫行星、相位、六维力量、分盘重复度和当前Dasha": "strength is judged with dignity, conjunctions, aspects, Shadbala, divisional repetition and current Dasha",
"这一宫主位置需要与行星力量、分盘和相位合看": "this house-lord placement is read with planetary strength, divisions and aspects",
"当前不在活动Dasha主导层,该宫主Topic以本命基础背景为主": "is not currently active as a Dasha ruler; this house topic remains natal background",
"无其他同宫行星": "no other planets in the same house",
"本案检测到": "detected in this chart",
"本案未检测到": "not detected in this chart",
"原始摘要": "source summary",
"结构字段": "structured fields",
"未列额外结构字段": "no additional structured fields listed",
"传统辅助Practice": "Traditional Supportive Practices",
"瑜伽": "Yoga",
"格局": "Yoga",
"王者": "authority",
"落陷取消": "debilitation cancellation",
"大地": "earth/support",
"后": "after",
"前": "before",
"月吉夹": "Moon hemmed by benefics",
"身体": "body",
"疾病": "illness",
"独子": "single child",
"常行": "frequent movement",
"真诚": "sincerity",
"田园": "field/land",
"婚姻": "marriage",
"贤妻": "spouse virtue",
"性欲": "sensuality",
"消瘦体型": "lean body",
"精神错乱": "mental disturbance",
"言语障碍": "speech impediment",
"车": "cart",
"贫困": "poverty",
"被亲属遗弃": "family abandonment",
"母亲不利": "mother affliction",
"权力地位;事业成功;社会影响力": "authority, career success and social influence",
"财富丰厚;生活富足;物质成功": "wealth, comfort and material success",
"财富充裕;生活舒适;性情愉快": "wealth, comfort and pleasant disposition",
"自力更生;财富充裕;受人尊敬": "self-reliance, wealth and respect",
"名声清白;受人敬仰;事业有成": "clean reputation, respect and career success",
"婚姻延迟;需要匹配化解": "marital delay; matching and remedy may be needed",
"婚姻延迟;配偶关系紧张": "marital delay and partnership tension",
"财务困难;收入不稳定;需节俭": "financial difficulty, unstable income and need for thrift",
"表达受阻,可能表现为说话不顺、迟疑或难以清楚表达": "speech may be obstructed, hesitant or difficult to express clearly",
"思绪容易扰动,需留意情绪、压力与判断稳定性": "the mind may be disturbed; emotional pressure and judgment stability need care",
"体型偏瘦,体质较敏感": "leaner body type and sensitive constitution",
"水瓶": "Aquarius",
"天蝎": "Scorpio",
"白羊": "Aries",
"摩羯": "Capricorn",
"金牛": "Taurus",
"双子": "Gemini",
"巨蟹": "Cancer",
"狮子": "Leo",
"处女": "Virgo",
"天秤": "Libra",
"射手": "Sagittarius",
"双鱼": "Pisces",
"火": "Mars",
"水": "Mercury",
"木": "Jupiter",
"金": "Venus",
"土": "Saturn",
"日": "Sun",
"月": "Moon",
}
for raw, public in sorted(phrase_terms.items(), key=lambda item: len(item[0]), reverse=True):
text = text.replace(raw, public)
text = normalize_en_markdown_terms(text)
def _retain_remaining_cjk_lines(raw_text: str) -> str:
# Preserve source values when the English glossary has no translation.
# Dropping a whole row makes the English delivery look clean while
# silently losing evidence. A visible marker keeps the row auditable
# until a real translation is added.
kept: list[str] = []
for line in raw_text.splitlines():
if re.search(r"[\u4e00-\u9fff]", line):
line = re.sub(
r"[\u4e00-\u9fff]+",
lambda match: f"[source text retained: {match.group(0)}]",
line,
)
kept.append(line.rstrip())
return "\n".join(kept)
text = _retain_remaining_cjk_lines(text)
drop_fragments = (
"jd_ut:",
"time_offset_seconds",
"swisseph_newton",
"source_path",
"producer",
"schema",
"parameter_sensitive",
"blocked",
"PyJHora",
)
cleaned_lines = [
line.rstrip()
for line in text.splitlines()
if not any(fragment in line for fragment in drop_fragments)
]
text = "\n".join(cleaned_lines)
text = re.sub(r"(?m)^(#{2,6}\s*)p\d+(?:-p\d+)?\s*[·.-]?\s*", r"\1", text)
text = re.sub(r"\s*\(PL9 p\d+(?:-p\d+)?[^)]*\)", "", text)
text = re.sub(r"\s*(PL9 p\d+(?:-p\d+)?[^)]*)", "", text)
text = re.sub(r"^#\s+.*$", f"# {title}", text, count=1, flags=re.MULTILINE)
text = text.replace(":", ": ").replace(";", "; ").replace(",", ", ").replace("。", ".")
text = re.sub(r"\n{3,}", "\n\n", text).strip() + "\n"
note_heading = "## Local Equivalent Interpretive Notes"
first_note = text.find(note_heading)
second_note = text.find(note_heading, first_note + len(note_heading)) if first_note >= 0 else -1
if second_note >= 0:
text = text[:second_note].rstrip() + "\n"
if not text.startswith("# "):
text = f"# {title}\n\n{text}"
text = _clean_pl9_user_table_placeholders(text, language="en")
known_header_repairs = {
"|-------|------|------|-------|----------|": "| House | Sign | Lord | Score | Key factors |\n|-------|------|------|-------|----------|",
"|----------|----------|--------|--------------|-------------------|-----|----------|----------|": "| Planet 1 | Planet 2 | Aspect | Exact degree | Actual difference | Orb | Applying | Strength |\n|----------|----------|--------|--------------|-------------------|-----|----------|----------|",
}
for separator, repaired in known_header_repairs.items():
text = re.sub(
rf"(?m)^{re.escape(separator)}$",
repaired,
text,
)
text = _repair_user_facing_orphan_table_headers(text, language="en")
# The parity renderer contributes sections after the raw appendix has
# already been normalized. Run the same semantic cleanup at the final
# user-volume boundary so legacy payload fragments cannot leak through.
text = re.sub(
r"\b(Mars|Jupiter|Venus|Saturn|Mercury|Sun|Moon|Rahu|Ketu)\1(?=\d|\b)",
r"\1 ",
text,
flags=re.IGNORECASE,
)
text = re.sub(
r"\b(Mars|Jupiter|Venus|Saturn|Mercury|Sun|Moon|Rahu|Ketu)(\d{1,2})\b",
r"\1 \2",
text,
flags=re.IGNORECASE,
)
text = re.sub(r"(?i)(condition)(houses?|signs?|planets?)", r"\1 \2", text)
text = re.sub(
r"\b(houses?|signs?|planets?)\s+\1\b",
r"\1",
text,
flags=re.IGNORECASE,
)
# 防御性幂等:折叠残留的 "condition condition"(确保 sanitize 可安全重复执行)。
text = re.sub(r"\b(condition)(?:\s+condition)+\b", r"\1", text, flags=re.IGNORECASE)
text = re.sub(r"\n{3,}", "\n\n", text).strip() + "\n"
return text
def _pl9_markdown_hygiene_receipt(markdown: str, *, language: str) -> dict[str, Any]:
"""Check the Markdown before rendering user-facing PDF."""
try:
from pl9_language_terms import language_quality_receipt
except ModuleNotFoundError: # pragma: no cover - package import compatibility
from scripts.pl9_language_terms import language_quality_receipt
def is_table_line(line: str) -> bool:
stripped = line.strip()
return stripped.startswith("|") and stripped.endswith("|")
def is_separator(line: str) -> bool:
stripped = line.strip()
if not is_table_line(stripped):
return False
return stripped.replace("|", "").replace("-", "").replace(":", "").strip() == ""
def orphan_separator_lines(text: str) -> list[int]:
lines = text.splitlines()
offenders: list[int] = []
for index, line in enumerate(lines):
if not is_separator(line):
continue
previous = lines[index - 1] if index else ""
next_line = lines[index + 1] if index + 1 < len(lines) else ""
if (
not is_table_line(previous)
or is_separator(previous)
or not is_table_line(next_line)
or is_separator(next_line)
):
offenders.append(index + 1)
return offenders
if language == 'zh':
forbidden = [
"parameter_sensitive", "source_path", "producer", "blocked",
"Applicable ", "| Yoga | Category | Strength | Combination | Effects / notes |",
"conjunction", "kalatra", "solar_yoga", "lunar_yoga", "durbhaga",
"Excessive sensuality", "Sharp intellect", "Dharidhra", "{planets}",
"长页解释", "True 普沙", "Results of ", "field_状态",
"Maha 大运", "Antar 大运", "Pratyantar 大运", "Effects of ",
"General effects during", "General effects which are felt",
"Interpretations based on the condition",
"Natural friends", "Natural enemies", "Temporary relation",
"Planet 1", "Planet 2", "Exact degree", "Actual difference",
"From house", "Target house", "Aspect type", "Applying",
"现实场域中阅读", "这一现实场域",
]
else:
forbidden = [
"parameter_sensitive", "source_path", "producer", "blocked",
"未单独列出", "长页解释", "True 普沙", "{planets}",
"dasha_beginning_dates", "dasha_ending_dates", "jd_ut:", "time_offset_seconds",
":", ";", ",", "。", "、", "(", ")", "——",
"solar_yoga", "lunar_yoga",
]
violations = [item for item in forbidden if item in markdown]
if language == 'en':
chinese_chars = len(re.findall(r"[\u4e00-\u9fff]", markdown))
if chinese_chars:
violations.append(f"chinese_characters:{chinese_chars}")
language_receipt = language_quality_receipt(markdown, language=language)
violations.extend(language_receipt.get("violations") or [])
orphan_lines = orphan_separator_lines(markdown)
if orphan_lines:
violations.append(f"orphan_table_separators:{','.join(str(line) for line in orphan_lines[:12])}")
return {
"schema": "pl9.markdown_hygiene_receipt.v1",
"language": language,
"status": "pass" if not violations else "fail",
"violations": violations,
"language_quality": language_receipt,
}
def _pl9_ai_density_visibility_receipt(markdown: str, *, language: str,
maturity_contract: dict[str, Any] | None = None) -> dict[str, Any]:
"""Fail fast when mature PL9 AI-density page families disappear."""
if language == 'en':
required = {
"raw_positions": ("Birth Chart Planetary Raw Positions",),
"divisional_tables": ("Natal and Divisional North Indian Charts", "D1 Rashi Numeric Position Table"),
"technical_pages": ("Shadbala", "Ashtakavarga"),
"dasha_tables": ("Vimshottari Dasha", "Pratyantar Dasha"),
"saturn_kp": ("Sade Sati", "Dhayya", "KP"),
"annual_tables": ("Tajika", "without Dasha balance", "Sahams"),
"interpretive_notes": ("Maha Dasha", "Pratyantar Dasha"),
}
else:
required = {
"numeric_charts": ("D1 Rashi 数值位置表", "月亮盘", "特殊上升"),
"technical_pages": ("六维力量", "Ashtakavarga", "相位矩阵"),
"dasha_tables": ("Vimshottari AD", "Vimshottari PD", "Kala Chakra 大运"),
"saturn_kp": ("Sade Sati", "Dhayya", "KP"),
"annual_pages": ("年度图盘与 Tajika", "Mudda", "Patyayini", "Sahams"),
"long_form_interpretation": ("行星、星宿与宫位解释", "Bhavesh 宫主逐宫解释", "## 大运解释"),
}
missing = {
family: [marker for marker in markers if marker not in markdown]
for family, markers in required.items()
}
missing = {family: markers for family, markers in missing.items() if markers}
return {
"schema": "pl9.ai_density_visibility_receipt.v1",
"language": language,
"status": "pass" if not missing else "fail",
"missing": missing,
}
def _render_pl9_ai_density_raw_data_markdown_en(packet: dict) -> str:
"""Render the English AI/astrologer edition directly from structured payload."""
CN_TERMS_EN, COMBINATION_EN, EFFECTS_EN = _load_glossary()
calc_sudarshana_chakra = _load_sudarshana()
english_field_explanations = {
"effect_on_confidence": (
"Reduce confidence when functional and natural indications conflict."
),
"confidence_impact": (
"Confidence is reduced when functional and natural indications conflict."
),
}
def clean(value: Any) -> str:
if value is None:
return "-"
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, float):
return f"{value:.6f}".rstrip("0").rstrip(".")
if isinstance(value, (int,)):
return str(value)
if isinstance(value, dict):
parts = []
for key, item in list(value.items())[:8]:
key_text = clean(key)
if key_text in english_field_explanations:
value_text = english_field_explanations[key_text]
else:
value_text = clean(item)
parts.append(f"{key_text}={value_text}")
text = "; ".join(parts)
if len(value) > 8:
text += f"; ... +{len(value) - 8} fields"
return text[:480] + ("..." if len(text) > 480 else "")
if isinstance(value, (list, tuple, set)):
parts = [clean(item) for item in list(value)[:12]]
text = ", ".join(parts)
if len(value) > 12:
text += f", ... +{len(value) - 12} items"
return text[:480] + ("..." if len(text) > 480 else "")
text = str(value)
# 整串中文 effect 短词 -> glossary 直译(最高优先级,避免后续替换破坏语义)
_stripped = text.strip()
if _stripped in EFFECTS_EN:
return EFFECTS_EN[_stripped]
# 组合条件整句 -> 英文(逐词替换之前全串精确匹配,避免整句被拆坏)
if _stripped in COMBINATION_EN:
return COMBINATION_EN[_stripped]
# 中文行星/星座/结构词 -> 英文(combination 混排串逐词替换,避免删 CJK 后黏连)。
# 按 key 长度降序替换:长词(宫主/上升主)先于短词(宫/主),避免被拆开覆盖。
for _cn, _en in sorted(CN_TERMS_EN.items(), key=lambda kv: -len(kv[0])):
text = text.replace(_cn, _en)
replacements = {
":": ": ",
";": "; ",
",": ", ",
"。": ".",
"、": ", ",
"(": "(",
")": ")",
"——": ": ",
"{planets}": "listed planets",
"solar_yoga": "solar yoga",
"lunar_yoga": "lunar yoga",
"北交点": "Rahu",
"南交点": "Ketu",
"极友": "great friend",
"入友": "friendly",
"友好星座": "friendly sign",
"中性": "neutral",
"落陷": " debilitated",
"入庙": "own sign",
"中强": "medium-strong",
"强": " strong",
"弱": " weak",
"中": "medium",
}
for raw, public in replacements.items():
text = text.replace(raw, public)
text = re.sub(r"(?:^|;\s*)-(?:\s*;\s*-){1,}\s*$", "-", text)
text = re.sub(r"\blisted planets(\d+)\b", r"\1 listed planet(s)", text)
text = text.replace("missing_in_local", "-")
text = text.replace("Dharidhra:", "Daridra combination:")
text = text.replace("D1/D9debilitated", "D1/D9 debilitated")
text = re.sub(r"^(\d+)weak/$", r"house \1 weak", text)
text = re.sub(r"^(\d+)/$", r"house \1 condition", text)
text = re.sub(r"^//,\s*", "", text)
text = re.sub(r"([A-Za-z]{3,})\1", r"\1", text)
text = re.sub(
r"\b(Mars|Jupiter|Venus|Saturn|Mercury|Sun|Moon|Rahu|Ketu)(\d{1,2})\b",
r"\1 \2",
text,
flags=re.IGNORECASE,
)
text = re.sub(r"\b(houses?|signs?|planets?)\s+\1\b", r"\1", text, flags=re.IGNORECASE)
# 先折叠连续重复的 condition(如 "condition condition"),再重排
text = re.sub(r"\b(condition)(?:\s+condition)+\b", r"\1", text, flags=re.IGNORECASE)
text = re.sub(
r"\b(houses?|signs?|planets?)\s+([0-9/, -]+?)\s+condition\b",
r"condition \1 \2",
text,
flags=re.IGNORECASE,
)
text = re.sub(r"[\u4e00-\u9fff]+", "", text)
# 黏连修复:行星 + dignity 词(如 Venusexalted -> Venus exalted)
text = re.sub(
r"\b(Mars|Jupiter|Venus|Saturn|Mercury|Sun|Moon|Rahu|Ketu)(exalted|debilitated|strong|weak|moderate|medium|own|moolatrikona|combust)\b",
r"\1 \2",
text,
flags=re.IGNORECASE,
)
# 黏连修复:小写词尾 + 大写词首(dignity/行星 + 星座,如 strongAquarius / SaturnAquarius)
text = re.sub(r"([a-z])([A-Z])", r"\1 \2", text)
# condition 重复去重(如 "condition condition")
text = re.sub(r"\b(condition)\s+\1\b", r"\1", text, flags=re.IGNORECASE)
# 移除 "=" 残留(Sade Sati 等 rashi 标签前缀,如 "=Capricorn" -> "Capricorn")
text = re.sub(r"=\s*(?=[A-Z])", "", text)
text = re.sub(r"\b([A-Za-z ]+)\(\1\)", r"\1", text)
text = re.sub(r"\s+", " ", text)
text = re.sub(r"\(\s*\)", "", text)
text = text.strip() or "-"
if not re.search(r"[A-Za-z0-9]", text):
# 删 CJK / 无英文等价后降级为 "-"(空占位),不输出 "Not available" 占位符。
return "-"
return text[:480] + ("..." if len(text) > 480 else "")
def clean_effect(value: Any) -> str:
"""Translate one effect phrase; return "" when it has no English equivalent."""
if not isinstance(value, str):
return clean(value)
stripped = value.strip()
translated = EFFECTS_EN.get(stripped)
if translated:
try:
from pl9_language_terms import soften_lifespan_wording
except ModuleNotFoundError: # pragma: no cover
from scripts.pl9_language_terms import soften_lifespan_wording
return soften_lifespan_wording(translated, "en")
# 已是英文短语(无 CJK)时原样透传,避免被误当占位删掉。
if re.search(r"[A-Za-z]", stripped) and not re.search(r"[\u4e00-\u9fff]", stripped):
try:
from pl9_language_terms import soften_lifespan_wording
except ModuleNotFoundError: # pragma: no cover
from scripts.pl9_language_terms import soften_lifespan_wording
return soften_lifespan_wording(stripped, "en")
return ""
def table(headers: list[str], rows: list[list[Any]]) -> str:
if not rows:
return ""
cleaned_rows = [[clean(cell) for cell in row] for row in rows]
empty_values = {"", "-", "None", "none", "null", "missing_in_local"}
filtered_rows: list[list[str]] = []
for row in cleaned_rows:
non_empty = [cell for cell in row if cell not in empty_values]
if not non_empty:
continue
if row and row[0] in empty_values and len(non_empty) <= 1:
continue
if len(row) > 1 and row[0] not in empty_values and not any(cell not in empty_values for cell in row[1:]):
continue
filtered_rows.append(row)
cleaned_rows = filtered_rows
if not cleaned_rows:
return ""
keep_indices: list[int] = []
for index, header in enumerate(headers):
column = [row[index] if index < len(row) else "-" for row in cleaned_rows]
non_empty = [cell for cell in column if cell not in empty_values]
if not non_empty:
continue
if header in {"Longitude", "House"} and len(non_empty) <= 1 and len(cleaned_rows) > 3:
continue
keep_indices.append(index)
headers = [headers[index] for index in keep_indices]
cleaned_rows = [[row[index] if index < len(row) else "-" for index in keep_indices] for row in cleaned_rows]
out = ["| " + " | ".join(headers) + " |", "|" + "|".join(["---"] * len(headers)) + "|"]
for row in cleaned_rows:
display_row = []
for header, cell in zip(headers, row):
if cell == "-" and header in {"Effects / notes", "Summary", "Notes", "Detected / phase"}:
display_row.append("not listed")
else:
display_row.append(cell)
out.append("| " + " | ".join(display_row) + " |")
return "\n".join(out)
def dict_get(path: tuple[str, ...], default: Any = None) -> Any:
obj: Any = packet
for key in path:
if not isinstance(obj, dict):
return default
obj = obj.get(key)
return obj if obj is not None else default
def dict_get_any(*paths: tuple[str, ...], default: Any = None) -> Any:
for path in paths:
value = dict_get(path)
if value:
return value
return default
lines: list[str] = [f"# {_pl9_english_report_title(packet)}", ""]
birth = dict_get(("birth_info",), {})
if isinstance(birth, dict):
lines.extend([
"## Birth Data and Calculation Profile",
"",
table(["Field", "Value"], [
["Name", birth.get("name") or birth.get("user_name")],
["Birth date", birth.get("date")],
["Birth time", birth.get("time")],
["Latitude", birth.get("lat")],
["Longitude", birth.get("lon")],
["Time zone", f"UTC{birth.get('tz'):+}" if isinstance(birth.get("tz"), (int, float)) else birth.get("tz")],
["Ayanamsa", birth.get("ayanamsa_display") or birth.get("ayanamsa_name") or birth.get("ayanamsa")],
["Node mode", birth.get("node_mode")],
]),
"",
])
asc = dict_get(("core_chart", "ascendant"), {})
planets = dict_get(("core_chart", "planets"), {})
rows = []
if isinstance(asc, dict):
rows.append(["Lagna", asc.get("sign"), asc.get("degree_in_sign"), asc.get("degree_raw") or asc.get("lon"), 1, "-", "-", "-", "-"])
if isinstance(planets, dict):
for planet in ["Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu"]:
item = planets.get(planet, {})
if isinstance(item, dict):
rows.append([
planet,
item.get("sign"),
item.get("degree_in_sign"),
item.get("degree") or item.get("longitude"),
item.get("house"),
item.get("nakshatra"),
item.get("pada"),
item.get("status"),
item.get("speed"),
])
lines.extend(["## Core Chart Raw Positions", "", table(["Object", "Sign", "Degree in sign", "Longitude", "House", "Nakshatra", "Pada", "Status", "Speed"], rows), ""])
if isinstance(asc, dict) and isinstance(planets, dict):
try:
asc_lon = asc.get("longitude")
if asc_lon is None:
asc_lon = asc.get("lon") or asc.get("degree") or asc.get("degree_raw")
planet_lons = {
planet: item.get("longitude") if item.get("longitude") is not None else item.get("degree")
for planet, item in planets.items()
if isinstance(item, dict) and (item.get("longitude") is not None or item.get("degree") is not None)
}
if asc_lon is not None and planet_lons:
sudarshana = calc_sudarshana_chakra(planet_lons, float(asc_lon))
reference_rows = []
for key, value in (sudarshana.get("reference_points") or {}).items():
if isinstance(value, dict):
reference_rows.append([key, value.get("sign"), value.get("role")])
if reference_rows:
lines.extend(["## Sudarshana Chakra", "", table(["Reference point", "Sign", "Role"], reference_rows), ""])
chart_rows = []
for chart_name, chart in (sudarshana.get("three_charts") or {}).items():
if not isinstance(chart, dict):
continue
for planet, item in chart.items():
if isinstance(item, dict):
chart_rows.append([chart_name, planet, item.get("sign"), item.get("house")])
if chart_rows:
lines.extend(["### Sudarshana Chakra Three-Reference Placements", "", table(["Reference chart", "Planet", "Sign", "House"], chart_rows), ""])
except Exception:
pass
d1_sheet = dict_get(("worksheets", "d1_rasi_bhava"), {})
d1_houses = d1_sheet.get("houses") if isinstance(d1_sheet, dict) else {}
d1_planets = d1_sheet.get("planets") if isinstance(d1_sheet, dict) else {}
if not isinstance(d1_houses, dict):
d1_houses = dict_get(("core_chart", "houses"), {})
if not isinstance(d1_planets, dict):
d1_planets = dict_get(("core_chart", "planets"), {})
if isinstance(d1_houses, dict) and isinstance(d1_planets, dict) and d1_houses:
current_dasha = dict_get(("timing_and_predictive_systems", "dasha", "current_dasha"), {})
active_lords: set[str] = set()
if isinstance(current_dasha, dict) and current_dasha.get("lord"):
active_lords.add(str(current_dasha.get("lord")))
for ad_row in current_dasha.get("antardasha_timeline") or []:
if isinstance(ad_row, dict) and ad_row.get("is_current") and ad_row.get("lord"):
active_lords.add(str(ad_row.get("lord")))
house_topics = {
1: "self, body and life direction",
2: "resources, speech and family values",
3: "initiative, learning and communication",
4: "home, residence and inner stability",
5: "creativity, intelligence and judgment",
6: "service, pressure and daily duties",
7: "partnership, marriage and public interaction",
8: "shared resources, risk, research and transformation",
9: "belief, teachers, higher learning and long journeys",
10: "career, responsibility and public role",
11: "gains, networks and long-range aims",
12: "retreat, expenditure, sleep, foreign places and release",
}
destination_topics = {
1: "identity and bodily expression",
2: "resources, speech and family continuity",
3: "effort, skill and communication",
4: "home, land and emotional foundation",
5: "learning, creativity and merit",
6: "workload, service, rivalry and repair",
7: "partnership, clients and public dealings",
8: "shared resources, research, vulnerability and deep change",
9: "belief, study, teachers and distant movement",
10: "career, authority and visible duty",
11: "income, networks and fulfilment of goals",
12: "expense, retreat, sleep, foreign links and release",
}
summary_rows = []
narrative_rows: list[str] = []
for house_index in range(1, 13):
house = d1_houses.get(f"house_{house_index}") or d1_houses.get(str(house_index)) or {}
if not isinstance(house, dict):
continue
lord = house.get("lord")
lord_row = d1_planets.get(str(lord)) if lord is not None else {}
if not isinstance(lord_row, dict):
lord_row = {}
sign = house.get("cusp_sign") or house.get("sign")
lord_sign = lord_row.get("sign")
lord_house = lord_row.get("house")
dignity = lord_row.get("status") or lord_row.get("dignity")
activation = "active Dasha layer" if str(lord) in active_lords else "natal background"
placement = f"{clean(lord_sign)} house {clean(lord_house)}"
summary_rows.append([house_index, sign, lord, placement, dignity, activation])
topic = house_topics.get(house_index, f"house {house_index} topics")
destination = destination_topics.get(int(lord_house), f"house {clean(lord_house)} topics") if str(lord_house).isdigit() else "its placement field"
narrative_rows.extend([
f"### House {house_index} lord: {clean(lord)}",
"",
f"House {house_index} covers {topic}. Its sign is {clean(sign)}, and its lord is {clean(lord)}.",
"",
f"The lord is placed in {placement}, with dignity/status {clean(dignity)}. This carries the house-{house_index} agenda into {destination}.",
"",
f"This placement is read with dignity, conjunctions, aspects, Shadbala, divisional repetition and Dasha timing. Current timing layer: {activation}.",
"",
])
if summary_rows:
lines.extend(["## Bhavesh House-Lord Interpretations", "", table(["House", "Sign", "Lord", "Lord placement", "Lord dignity", "Dasha activation"], summary_rows), ""])
lines.extend(narrative_rows)
moon_chart = dict_get(("divisional_and_special_charts", "moon_chart"), {})
if isinstance(moon_chart, dict):
rows = []
moon_planets = (moon_chart.get("raw") or {}).get("planets") if isinstance(moon_chart.get("raw"), dict) else moon_chart
for planet, item in (moon_planets or {}).items():
if isinstance(item, dict):
rows.append([planet, item.get("sign"), item.get("degree") or item.get("longitude"), item.get("house_from_moon") or item.get("house")])
lines.extend(["## Moon Chart", "", table(["Object", "Sign", "Longitude", "House"], rows), ""])
varga = dict_get(("divisional_and_special_charts", "varga_full"), {})
if isinstance(varga, dict):
lines.extend(["## Divisional Charts Numeric Tables", ""])
for chart_name, chart_payload in varga.items():
positions = chart_payload.get("positions") if isinstance(chart_payload, dict) else None
if not isinstance(positions, dict):
positions = chart_payload if isinstance(chart_payload, dict) else None
rows = []
if isinstance(positions, dict):
for obj_name, item in positions.items():
if str(obj_name).startswith("_") or obj_name in {"planets"}:
continue
if isinstance(item, dict):
rows.append([obj_name, item.get("sign"), item.get("degree_in_sign"), item.get("longitude"), item.get("house")])
if rows:
lines.extend([f"### {clean(chart_name)}", "", table(["Object", "Sign", "Degree in sign", "Longitude", "House"], rows), ""])
special_lagnas = dict_get(("divisional_and_special_charts", "special_lagnas"), {})
if isinstance(special_lagnas, dict):
rows = []
for name, item in special_lagnas.items():
if isinstance(item, dict) and item.get("sign"):
rows.append([name, item.get("sign"), item.get("sign_degree") or item.get("degree"), item.get("degree"), item.get("house")])
lines.extend(["## Special Lagnas", "", table(["Point", "Sign", "Degree in sign", "Longitude", "House"], rows), ""])
sensitive = dict_get(("divisional_and_special_charts", "sensitive_points"), {})
if isinstance(sensitive, dict):
rows = []
for name, item in sensitive.items():
if isinstance(item, dict) and item:
rows.append([name, item.get("sign"), item.get("degree_in_sign"), item.get("longitude"), item.get("lord")])
lines.extend(["## Sensitive Points", "", table(["Point", "Sign", "Degree in sign", "Longitude", "Lord"], rows), ""])
upagrahas = dict_get(("divisional_and_special_charts", "upagrahas", "raw"), {})
if isinstance(upagrahas, dict):
rows = []
sign_names = ["Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", "Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces"]
for name, item in upagrahas.items():
if isinstance(item, dict):
sign = item.get("sign")
if sign is None and isinstance(item.get("sign_idx"), int):
sign = sign_names[item["sign_idx"] % 12]
rows.append([name, sign, item.get("degree_in_sign"), item.get("longitude")])
lines.extend(["## Upagraha and Sub-Planet Points", "", table(["Point", "Sign", "Degree in sign", "Longitude"], rows), ""])
strength_sheet = dict_get_any(
("strengths_and_scores",),
("worksheets", "strengths_and_scores"),
default={},
)
shadbala = {}
if isinstance(strength_sheet, dict):
shadbala = (strength_sheet.get("shadbala") or {}).get("planets") if isinstance(strength_sheet.get("shadbala"), dict) else {}
if isinstance(shadbala, dict):
rows = []
for planet, item in shadbala.items():
if isinstance(item, dict):
sthana = item.get("sthana_bala", {})
kala = item.get("kala_bala", {})
rows.append([planet, sthana.get("total"), item.get("dig_bala"), kala.get("total"), item.get("chesta_bala"), item.get("naisargika_bala"), item.get("drik_bala"), item.get("total_virupas")])
lines.extend(["## Shadbala Components", "", table(["Planet", "Sthana", "Dig", "Kala", "Chesta", "Naisargika", "Drik", "Total"], rows), ""])
if isinstance(strength_sheet, dict):
functional = strength_sheet.get("functional_benefic_malefic")
if isinstance(functional, dict):
rows = []
for key, value in functional.items():
if key in english_field_explanations:
rows.append([key, english_field_explanations[key]])
continue
if isinstance(value, (list, tuple)):
rows.append([key, ", ".join(map(clean, value))])
elif isinstance(value, dict):
rows.append([key, "; ".join(f"{clean(k)}={clean(v)}" for k, v in value.items())])
else:
rows.append([key, value])
lines.extend(["## Functional Benefic / Malefic Layer", "", table(["Field", "Value"], rows), ""])
bhava_bala = strength_sheet.get("bhava_bala")
if isinstance(bhava_bala, dict):
bhava_house_rows = _load_bhava_rows()
rows = []
for item in bhava_house_rows(bhava_bala):
rows.append([item['house'], item.get("total") if item.get("total") is not None else item.get("score"), item.get("strength") or item.get("grade"), item.get("notes")])
lines.extend(["## Bhava Bala House Strength", "", table(["House", "Score", "Strength", "Notes"], rows), ""])
friendship = strength_sheet.get("planetary_friendship")
if isinstance(friendship, dict):
rows = []
for planet, item in friendship.items():
if isinstance(item, dict):
rows.append([planet, item.get("natural_friends"), item.get("natural_enemies"), item.get("temporary_relation") or item.get("compound_relationship")])
lines.extend(["## Planetary Friendship Matrix", "", table(["Planet", "Natural friends", "Natural enemies", "Temporary relation"], rows), ""])
av = strength_sheet.get("ashtakavarga") if isinstance(strength_sheet, dict) else {}
if isinstance(av, dict):
sav = av.get("sav", {})
if isinstance(sav, dict):
scores = sav.get("scores", {})
rows = [[sign, score] for sign, score in scores.items()] if isinstance(scores, dict) else []
lines.extend(["## Sarvashtakavarga", "", table(["Sign", "SAV"], rows), ""])
bav = av.get("bav", {})
if isinstance(bav, dict):
rows = []
for planet, scores in bav.items():
if isinstance(scores, dict):
bindus = scores.get("bindus")
if isinstance(bindus, list) and len(bindus) >= 12:
row = [planet] + bindus[:12] + [scores.get("total")]
else:
row = [planet] + [scores.get(sign) for sign in ["Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", "Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces"]] + [scores.get("total")]
rows.append(row)
lines.extend(["## Bhinnashtakavarga", "", table(["Planet", "Ar", "Ta", "Ge", "Cn", "Le", "Vi", "Li", "Sc", "Sg", "Cp", "Aq", "Pi", "Total"], rows), ""])
kp = dict_get(("advanced_systems", "kp"), {})
if isinstance(kp, dict):
planet_rows = []
for planet, item in (kp.get("planets") or {}).items():
if isinstance(item, dict):
lord = item.get("kp_lords", {})
sig = item.get("significators", {})
planet_rows.append([planet, lord.get("sign"), lord.get("nakshatra"), lord.get("rasi_lord"), lord.get("nakshatra_lord"), lord.get("sub_lord"), lord.get("sub_sub_lord"), sig.get("A"), sig.get("B"), sig.get("C"), sig.get("D")])
house_rows = []
for house, item in (kp.get("houses") or {}).items():
if isinstance(item, dict):
lord = item.get("kp_lords", {})
sig = item.get("significators", {})
house_rows.append([house, item.get("sign"), item.get("cusp_longitude"), lord.get("nakshatra"), lord.get("rasi_lord"), lord.get("nakshatra_lord"), lord.get("sub_lord"), lord.get("sub_sub_lord"), sig.get("A"), sig.get("B"), sig.get("C"), sig.get("D")])
lines.extend(["## KP Planet Significators", "", table(["Planet", "Sign", "Nakshatra", "Rasi lord", "Star lord", "Sub lord", "Sub-sub lord", "A", "B", "C", "D"], planet_rows), ""])
lines.extend(["## KP House Cusps and Significators", "", table(["House", "Sign", "Cusp longitude", "Nakshatra", "Rasi lord", "Star lord", "Sub lord", "Sub-sub lord", "A", "B", "C", "D"], house_rows), ""])
dasha_families = dict_get(("timing_and_predictive_systems", "dasha_master_pack", "families"), {})
if isinstance(dasha_families, dict):
lines.extend(["## Dasha Tables", ""])
for family, payload in dasha_families.items():
if not isinstance(payload, dict):
continue
periods = payload.get("periods") or payload.get("mahadasha") or []
rows = []
for item in periods[:80] if isinstance(periods, list) else []:
if isinstance(item, dict):
rows.append([item.get("lord") or item.get("planet"), item.get("rashi") or item.get("sign"), item.get("start") or item.get("start_date"), item.get("end") or item.get("end_date"), item.get("years") or (item.get("duration") or {}).get("years")])
if rows:
lines.extend([f"### {clean(family).replace('_', ' ').title()}", "", table(["Lord", "Sign/Rashi", "Start", "End", "Years"], rows), ""])
annual_pack = dict_get_any(
("worksheets", "timing_and_predictive_systems", "annual_tajika_pack"),
("timing_and_predictive_systems", "annual_tajika_pack"),
default={},
)
tajika = dict_get(("timing_and_predictive_systems", "tajika"), {})
if isinstance(tajika, dict) or isinstance(annual_pack, dict):
canonical_muntha = annual_pack.get("muntha") if isinstance(annual_pack, dict) else {}
canonical_year_lord = annual_pack.get("year_lord") if isinstance(annual_pack, dict) else {}
muntha = canonical_muntha if isinstance(canonical_muntha, dict) and canonical_muntha else tajika.get("muntha", {})
year_lord = canonical_year_lord if isinstance(canonical_year_lord, dict) and canonical_year_lord else tajika.get("year_lord", {})
if isinstance(muntha, dict) and muntha.get("status") == "conflict":
values = muntha.get("values") if isinstance(muntha.get("values"), list) else []
muntha_signs = [
item.get("value", {}).get("muntha_sign")
for item in values
if isinstance(item, dict) and isinstance(item.get("value"), dict)
]
muntha_value = "Conflict: " + " vs ".join(str(value) for value in muntha_signs if value)
else:
muntha_data = muntha.get("data") if isinstance(muntha.get("data"), dict) else muntha
muntha_value = muntha_data.get("muntha_sign") or muntha_data.get("sign")
if isinstance(year_lord, dict) and year_lord.get("status") == "conflict":
values = year_lord.get("values") if isinstance(year_lord.get("values"), list) else []
year_lords = [
item.get("value", {}).get("year_lord") or item.get("value", {}).get("lord")
for item in values
if isinstance(item, dict) and isinstance(item.get("value"), dict)
]
year_lord_value = "Conflict: " + " vs ".join(str(value) for value in year_lords if value)
else:
year_lord_data = year_lord.get("data") if isinstance(year_lord, dict) else {}
year_lord_value = (
year_lord_data.get("year_lord") or year_lord_data.get("lord")
if isinstance(year_lord_data, dict)
else year_lord
)
lines.extend(["## Annual Varshaphala Core Fields", "", table(["Field", "Value"], [
["Muntha sign", muntha_value],
["Muntha lord", (
(muntha.get("data") or {}).get("muntha_lord")
if isinstance(muntha, dict) and isinstance(muntha.get("data"), dict)
else (muntha.get("muntha_lord") if isinstance(muntha, dict) else None)
)],
["Year lord", year_lord_value],
]), ""])
mudda = (
annual_pack.get("mudda_dasha", {})
if isinstance(annual_pack, dict)
else {}
)
if not isinstance(mudda, dict) or not mudda:
mudda = tajika.get("mudda_dasha", {}) if isinstance(tajika, dict) else {}
if isinstance(mudda, dict):
rows = [[item.get("order"), item.get("lord"), item.get("months")] for item in mudda.get("dasha_sequence", []) if isinstance(item, dict)]
lines.extend(["## Mudda Dasha", "", table(["Order", "Lord", "Months"], rows), ""])
if isinstance(annual_pack, dict):
normalized = annual_pack.get("normalized_tables") if isinstance(annual_pack.get("normalized_tables"), dict) else {}
annual_table_specs = (
("mudda_rows", "Annual Mudda Dasha Rows", ("balance_profile", "lord", "start", "end", "boundary_status")),
("patyayini_rows", "Annual Patyayini / Patyamsha Rows", ("main", "sub", "boundary", "boundary_status")),
("saham_rows", "Annual Saham Rows", ("name", "saham", "sign", "degree_in_sign")),
("tajika_yoga_rows", "Annual Tajika Yoga Rows", ("yoga", "name", "present", "forming_planets")),
("annual_planet_rows", "Annual Planet Extended Fields", ("planet", "sign", "degree", "longitude", "nakshatra", "pada")),
("annual_anchor_rows", "Annual Anchor Points", ("name", "label", "value", "sign", "degree", "house")),
)
for key, title, preferred_keys in annual_table_specs:
rows_src = normalized.get(key)
if not isinstance(rows_src, list):
continue
rows_src = [row for row in rows_src if isinstance(row, dict)]
if not rows_src:
continue
keys = [field for field in preferred_keys if any(row.get(field) not in (None, "") for row in rows_src)]
if not keys:
keys = [
field for field in rows_src[0]
if field not in {"source_path", "execution_status", "confidence_status", "boundary_status", "status", "selection_status"}
and not isinstance(rows_src[0].get(field), (dict, list, tuple))
][:6]
if not keys:
continue
lines.extend([f"## {title}", "", table([field.replace("_", " ").title() for field in keys], [[row.get(field) for field in keys] for row in rows_src]), ""])
monthly_pack = annual_pack.get("monthly_chart_pack") if isinstance(annual_pack.get("monthly_chart_pack"), dict) else {}
monthly_rows = monthly_pack.get("rows") if isinstance(monthly_pack.get("rows"), list) else []
if monthly_rows:
rows = []
for row in monthly_rows:
if not isinstance(row, dict):
continue
asc = row.get("ascendant") if isinstance(row.get("ascendant"), dict) else {}
rows.append([row.get("month_index"), row.get("local_datetime"), asc.get("sign"), asc.get("degree_in_sign") or asc.get("degree")])
lines.extend(["## Monthly Chart Pack", "", table(["Month", "Local time", "Annual ascendant", "Ascendant degree"], rows), ""])
lifetime_pack = annual_pack.get("lifetime_annual_chart_pages") if isinstance(annual_pack.get("lifetime_annual_chart_pages"), dict) else {}
lifetime_pages = lifetime_pack.get("pages") if isinstance(lifetime_pack.get("pages"), list) else []
lifetime_rows = []
for page in lifetime_pages:
if not isinstance(page, dict):
continue
for row in page.get("rows") or []:
if not isinstance(row, dict):
continue
asc = row.get("ascendant") if isinstance(row.get("ascendant"), dict) else {}
lifetime_rows.append([row.get("completed_age"), row.get("target_year"), row.get("local_datetime"), asc.get("sign"), asc.get("degree_in_sign") or asc.get("degree")])
if lifetime_rows:
lines.extend(["## Lifetime Annual Chart Pages", "", table(["Age", "Year", "Solar return time", "Annual ascendant", "Ascendant degree"], lifetime_rows), ""])
series_pack = annual_pack.get("annual_chart_series_pack") if isinstance(annual_pack.get("annual_chart_series_pack"), dict) else {}
for series_key, title in (
("birth_place_eight_year_overview", "Birth-Place Eight-Year Annual Series"),
("local_eight_year_overview", "Local Eight-Year Annual Series"),
):
series = series_pack.get(series_key) if isinstance(series_pack.get(series_key), dict) else {}
series_rows = series.get("rows") if isinstance(series.get("rows"), list) else []
rows = [
[row.get("target_year"), row.get("age"), row.get("solar_return_local") or row.get("local_datetime"), row.get("annual_ascendant")]
for row in series_rows
if isinstance(row, dict)
]
if rows:
lines.extend([f"## {title}", "", table(["Year", "Age", "Solar return time", "Annual ascendant"], rows), ""])
sahams = dict_get(("advanced_systems", "sahams"), {})
if isinstance(sahams, dict):
rows = []
for name, item in sahams.items():
if name in {"daynight_evidence", "formula_traces"}:
continue
if isinstance(item, dict):
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), ""])
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]
effects_text = "; ".join(effects) if effects else "-"
rows.append([item.get("name"), item.get("category"), item.get("strength"), item.get("combination"), effects_text])
lines.extend(["## Applicable Yogas", "", table(["Yoga", "Category", "Strength", "Combination", "Effects / notes"], rows), ""])
dosha = dict_get(("advanced_systems", "yogas_doshas"), {})
if isinstance(dosha, dict):
rows = []
dosha_labels = {
"mangal_dosha": "Mangala / Kuja Dosha",
"kaal_sarp_dosha": "Kaal Sarp Dosha",
"pitra_dosha": "Pitra Dosha",
"sade_sati": "Sade Sati",
}
for key in ["mangal_dosha", "kaal_sarp_dosha", "pitra_dosha", "sade_sati"]:
item = dosha.get(key)
if isinstance(item, dict):
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"),
])
lines.extend(["## Dosha and Transit Summary", "", table(["Topic", "Detected / phase", "Summary"], rows), ""])
text = "\n".join(line for line in lines if line is not None)
# Some legacy yoga payloads already contain duplicated labels. Normalize
# them after all sections have been assembled, including generated rows.
text = re.sub(
r"\b(Mars|Jupiter|Venus|Saturn|Mercury|Sun|Moon|Rahu|Ketu)\1(?=\d|\b)",
r"\1 ",
text,
flags=re.IGNORECASE,
)
text = re.sub(r"(?i)(condition)(houses?|signs?|planets?)", r"\1 \2", text)
text = re.sub(
r"\b(houses?|signs?|planets?)\s+\1\b",
r"\1",
text,
flags=re.IGNORECASE,
)
text = re.sub(r"\n{3,}", "\n\n", text).strip() + "\n"
return text
def _render_pl9_ai_density_raw_data_markdown_zh(packet: dict) -> str:
calc_sudarshana_chakra = _load_sudarshana()
"""Render Chinese structured appendix from the same payload used by English."""
def clean(value: Any) -> str:
if value is None:
return "-"
if isinstance(value, bool):
return "是" if value else "否"
if isinstance(value, float):
return f"{value:.6f}".rstrip("0").rstrip(".")
if isinstance(value, int):
return str(value)
text = str(value).strip()
return _pl9_public_term(text) if text else "-"
def table(headers: list[str], rows: list[list[Any]]) -> str:
if not rows:
return ""
out = ["| " + " | ".join(headers) + " |", "|" + "|".join(["---"] * len(headers)) + "|"]
for row in rows:
out.append("| " + " | ".join(clean(cell) for cell in row) + " |")
return "\n".join(out)
def dict_get(path: tuple[str, ...], default: Any = None) -> Any:
obj: Any = packet
for key in path:
if not isinstance(obj, dict):
return default
obj = obj.get(key)
return obj if obj is not None else default
lines: list[str] = [f"# {_pl9_public_report_title(packet)}", ""]
asc = dict_get(("core_chart", "ascendant"), {})
planets = dict_get(("core_chart", "planets"), {})
if isinstance(asc, dict) and isinstance(planets, dict):
try:
asc_lon = asc.get("longitude")
if asc_lon is None:
asc_lon = asc.get("lon") or asc.get("degree") or asc.get("degree_raw")
planet_lons = {
planet: item.get("longitude") if item.get("longitude") is not None else item.get("degree")
for planet, item in planets.items()
if isinstance(item, dict) and (item.get("longitude") is not None or item.get("degree") is not None)
}
if asc_lon is not None and planet_lons:
sudarshana = calc_sudarshana_chakra(planet_lons, float(asc_lon))
reference_rows = []
reference_labels = {
"ascendant_lagna": "出生上升参考点",
"moon_lagna": "月亮参考点",
"sun_lagna": "太阳参考点",
}
for key, value in (sudarshana.get("reference_points") or {}).items():
if isinstance(value, dict):
reference_rows.append([reference_labels.get(key, key), value.get("sign"), value.get("role")])
if reference_rows:
lines.extend(["## Sudarshana Chakra(三参考点盘)", "", table(["参考点", "星座", "角色"], reference_rows), ""])
chart_rows = []
chart_labels = {
"ascendant_lagna": "从出生上升起算",
"moon_lagna": "从月亮起算",
"sun_lagna": "从太阳起算",
}
for chart_name, chart in (sudarshana.get("three_charts") or {}).items():
if not isinstance(chart, dict):
continue
for planet, item in chart.items():
if isinstance(item, dict):
chart_rows.append([chart_labels.get(chart_name, chart_name), planet, item.get("sign"), item.get("house")])
if chart_rows:
lines.extend([
"### Sudarshana Chakra 三参考点落宫表",
"",
table(["参考盘", "行星", "星座", "宫位"], chart_rows),
"",
])
except Exception:
pass
text = "\n".join(line for line in lines if line is not None)
text = re.sub(r"\n{3,}", "\n\n", text).strip() + "\n"
return text
_SITE_BHAVA_SCORE = re.compile(r"\(Benefic\):\s*\+2|\(Malefic\):\s*-1\.5|吉星\s*\+2|凶星\s*-1\.5")
_NEECHA_RAJA = re.compile(r"Neecha Bhanga Raja Yoga|落陷取消王瑜伽|坏座位消王")
def _apply_local_report_conventions(markdown: str, *, language: str = "zh") -> str:
"""Keep this site's report wording inside the upstream appendix."""
if language == "en":
text = _NEECHA_RAJA.sub(
"Neecha Bhanga (debilitation cancellation, not a Raja Yoga)",
markdown,
)
text = re.sub(r"\bguaranteed\b", "certain", text, flags=re.IGNORECASE)
text = re.sub(r"\bguarantee\b", "certain outcome", text, flags=re.IGNORECASE)
text = re.sub(r"\bdivorce\b", "relationship strain", text, flags=re.IGNORECASE)
text = re.sub(r"\bmiscarriage\b", "a sensitive health topic", text, flags=re.IGNORECASE)
text = re.sub(r"\binfertility\b", "a sensitive fertility topic", text, flags=re.IGNORECASE)
else:
text = _NEECHA_RAJA.sub("落陷取消(Neecha Bhanga)", markdown)
text = text.replace("疾病诊断", "身体负荷话题")
text = text.replace("一定会", "有可能")
text = re.sub(r"(?<!不是)必然", "可能", text)
text = text.replace("再婚", "关系变化")
text = text.replace("几段关系", "关系主题")
text = text.replace("早夭", "早年体质")
text = text.replace("疾病", "身体负荷")
text = text.replace("落陷取消王", "落陷取消")
text = _SITE_BHAVA_SCORE.sub("", text)
return text
def _append_native_rashi_dashas(markdown: str, packet: dict) -> str:
"""Full-data edition only. The parity reader and the consult card do not call this."""
try:
from rashi_dasha_report import render_rashi_dasha_chapter
except ModuleNotFoundError: # pragma: no cover - package import compatibility
from scripts.rashi_dasha_report import render_rashi_dasha_chapter
chapter = render_rashi_dasha_chapter(packet if isinstance(packet, dict) else {})
if not chapter.strip():
return markdown
return markdown.rstrip() + "\n\n" + chapter
def _insert_opening_focus(markdown: str, focus: str) -> str:
"""Put the current-period focus directly under the title."""
first = next((line.strip() for line in focus.splitlines() if line.strip()), "")
if not first or first in markdown:
return markdown
match = re.search(r"^#\s+.+$", markdown, flags=re.MULTILINE)
if not match:
return focus.rstrip() + "\n\n" + markdown
return markdown[: match.end()] + "\n\n" + focus.rstrip() + "\n" + markdown[match.end() :]
def _interpretation_tools():
try:
from pl9_interpretation_packs import attach_full_data_interpretation, opening_focus_block
except ModuleNotFoundError: # pragma: no cover - package import compatibility
from scripts.pl9_interpretation_packs import attach_full_data_interpretation, opening_focus_block
return attach_full_data_interpretation, opening_focus_block
def render_full_data_markdown(packet: dict) -> str:
"""Upstream `pl9_ai_density` branch at 23be1807, on this site's parity body."""
attach_full_data_interpretation, opening_focus_block = _interpretation_tools()
if isinstance(packet, dict):
attach_full_data_interpretation(packet)
focus = opening_focus_block(packet)
else:
focus = ""
language = _pl9_report_language(packet if isinstance(packet, dict) else {})
if language == "en":
structured_appendix = re.sub(
r"^#\s+.*?(?:\n{2,}|\n)",
"",
_render_pl9_ai_density_raw_data_markdown_en(packet),
count=1,
flags=re.DOTALL,
).strip()
english_markdown = _append_native_rashi_dashas(
_insert_opening_focus(render_pl9_parity_markdown(packet), focus),
packet,
)
if structured_appendix:
english_markdown = (
english_markdown.rstrip()
+ "\n\n## Structured Data Appendix\n\n"
+ "<details>\n<summary>Machine-readable source fields (raw evidence)</summary>\n\n"
+ structured_appendix
+ "\n\n</details>\n"
)
rendered = _sanitize_pl9_ai_density_markdown_en(
english_markdown,
_pl9_english_report_title(packet),
)
return _apply_local_report_conventions(rendered, language="en")
zh_structured_markdown = _sanitize_pl9_ai_density_markdown(
_render_pl9_ai_density_raw_data_markdown_en(packet),
_pl9_public_report_title(packet),
)
zh_structured_appendix = re.sub(
r"^#\s+.*?(?:\n{2,}|\n)",
"",
zh_structured_markdown,
count=1,
flags=re.DOTALL,
).strip()
zh_markdown = _append_native_rashi_dashas(
_insert_opening_focus(render_pl9_parity_markdown(packet), focus),
packet,
)
if zh_structured_appendix and "## 结构化资料附录" not in zh_markdown:
zh_markdown = (
zh_markdown.rstrip()
+ "\n\n## 结构化资料附录\n\n"
+ "<details>\n<summary>机器可读源字段(原始证据)</summary>\n\n"
+ zh_structured_appendix
+ "\n\n</details>\n"
)
rendered = _sanitize_pl9_ai_density_markdown(zh_markdown, _pl9_public_report_title(packet))
return _apply_local_report_conventions(rendered, language="zh")
def read_full_data_domain(packet: Mapping, domain: str) -> dict:
"""Deep-copied packet view for one consult domain.
Field selection belongs to the consult card. This entry only freezes the
domain name and detaches the copy from the live packet.
"""
if domain not in FULL_DATA_DOMAINS:
raise KeyError(domain)
if not isinstance(packet, Mapping):
raise TypeError("packet must be a mapping")
return copy.deepcopy({
"domain": domain,
"label": FULL_DATA_DOMAINS[domain],
"report_version": packet.get("report_version"),
"birth_info": packet.get("birth_info"),
"core_chart": packet.get("core_chart"),
"calculation_profile": packet.get("calculation_profile"),
"worksheets": packet.get("worksheets"),
})