Files
Jyotisha/scripts/report_render/upstream_sync_chapters.py
ClaudeandClaude Opus 5.5 df7ad8ee89 feat(report): export gains upstream's KP house view, Cuspal Promise, KP house checks, KP monthly support, key months, annual points, varga dignity matrix, Bhava aspects and the rectification contract chapter
- KP monthly support read the birth-balance lord every month (no current_dasha in the issue #137 timeline); now the running lords, matching upstream
- ported prediction_verdict_matrix.py and references/rectification_evidence_contract_v1.json (the contract was always blocked as file missing)
- English audit statuses run / not_run written as Ran / Not run (run is also a packet key)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyn7Hj3LCW3BUs5bZ8
2026-11-07 17:05:27 +08:00

831 lines
49 KiB
Python
Raw Permalink 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.
"""Export chapters the astrologer's pl9-export has and ours lacked.
产品 2026-11-07 与占星师上游同步 (items 7 and 8):
* KP house view of the significators, Cuspal Promise, the career / finance /
relationship KP house checks, the Shodashvarga dignity matrix and the
planet-to-Bhava aspect table;
* three-year KP monthly support, the ephemeris key-month index and the
annual extra points index;
* the rectification contract chapter: birth-data provenance, timing precision,
event replay, the prediction verdict matrix, the ±1-minute birth-time
sensitivity matrix with its minute ranking, and the rectification evidence
domain registry. Without events the replay, the verdict matrix and the minute
ranking stay blocked, as upstream.
Values come from the packet. Two upstream producers are called here when the
packet lacks their output: ``prediction_verdict_matrix`` (ported unchanged) and
the ±1-minute recast through ``flexible_birth_time_profile`` — upstream builds
that matrix for every report, ours only for an unconfirmed time. PyJHora replay
tables are not ported (AGPL boundary).
"""
from __future__ import annotations
from datetime import datetime, timedelta
from typing import Any
try:
from scripts.report_render.appendix_language import planet_name as _planet_name
from scripts.report_render.appendix_language import sign_name
from scripts.report_render.audit_chapters import _clean, _status
from scripts.report_render.audit_chapters import _table as _audit_table
from scripts.report_render.text import _md_cell
except ModuleNotFoundError: # pragma: no cover - direct scripts/ execution
from report_render.appendix_language import planet_name as _planet_name
from report_render.appendix_language import sign_name
from report_render.audit_chapters import _clean, _status
from report_render.audit_chapters import _table as _audit_table
from report_render.text import _md_cell
KP_HEADING = {"zh": "KP 宫位显著星、Cuspal Promise 与分盘尊贵", "en": "KP House Significators, Cuspal Promise and Varga Dignity"}
MONTHLY_HEADING = {"zh": "三年流月、星历重点月份与年度附加点", "en": "Three-Year Monthly Support, Ephemeris Key Months and Annual Extra Points"}
RECT_HEADING = {"zh": "校时与断事合同", "en": "Rectification and Prediction Contract"}
PLANETS = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu")
HOUSE_THEMES = {
1: ("自我、健康、性格", "self, health, temperament"),
2: ("财富、家庭、言语", "wealth, family, speech"),
3: ("沟通、旅行、兄弟", "communication, short travel, siblings"),
4: ("母亲、房产、情感", "mother, property, feelings"),
5: ("子女、投资、创意", "children, speculation, creativity"),
6: ("身体负荷、对手、债务", "health load, rivals, debt"),
7: ("婚姻、合作、伴侣", "marriage, partnership, spouse"),
8: ("转型、意外、遗产", "transformation, sudden events, inheritance"),
9: ("命运、父亲、灵性", "fortune, father, dharma"),
10: ("事业、声望、成就", "career, reputation, achievement"),
11: ("收益、社交、愿望", "gains, networks, wishes"),
12: ("损失、外迁、解脱", "loss, moving abroad, release"),
}
KP_DOMAINS = (
("career", (2, 6, 10, 11), ("事业", "Career"),
("2宫看资源与收入,6宫看工作任务与压力,10宫看事业角色,11宫看结果兑现与回收。",
"2nd: resources and income; 6th: daily work and pressure; 10th: career role; 11th: results and returns.")),
("wealth", (2, 5, 9, 11), ("财务", "Finance"),
("2宫看资金与资源,5宫看判断与投机,9宫看机会与支持,11宫看回报兑现。",
"2nd: money and resources; 5th: judgement and speculation; 9th: opportunity and support; 11th: returns.")),
("relationship", (2, 7, 11), ("关系", "Relationship"),
("2宫看关系投入,7宫看伴侣/合作轴线,11宫看关系结果是否落地。",
"2nd: what goes into the relationship; 7th: the partner axis; 11th: whether it lands.")),
)
DIGNITY_VARGAS = (
("D1", None), ("D2", "D2_Hora"), ("D3", "D3_Drekkana"), ("D4", "D4_Turyamsa"), ("D7", "D7_Saptamsa"),
("D9", "D9_Navamsa"), ("D10", "D10_Dasamsa"), ("D12", "D12_Dwadashamsa"), ("D16", "D16_Shodasamsa"),
("D20", "D20_Vimsamsa"), ("D24", "D24_Siddhamsa"), ("D27", "D27_Bhamsa"), ("D30", "D30_Trimsamsa"),
("D40", "D40_Khavedamsa"), ("D45", "D45_Akshavedamsa"), ("D60", "D60_Shashtyamsa"),
)
DIGNITY_ZH = {
"Exalted": "入旺", "Debilitated": "落陷", "Own Sign": "自宫", "Moolatrikona": "三方宫",
"Neutral": "中性", "Friendly Sign": "入友", "Enemy Sign": "入敌", "Great Friend": "极友", "Great Enemy": "极敌",
}
SENSITIVITY_KEYS = (
"D1.ascendant", "D3.ascendant", "D4.ascendant", "D6.ascendant", "D7.ascendant", "D9.ascendant",
"D10.ascendant", "D11.ascendant", "D12.ascendant", "D16.ascendant", "D24.ascendant", "D30.ascendant",
"D40.ascendant", "D45.ascendant", "D60.ascendant", "arudha.UL", "arudha.A7", "arudha.A10", "KP.cusp_observation",
)
WORDS = {
# birth provenance
"not_recorded": ("未记录", "not recorded"),
"unverified_user_coordinates": ("用户提供坐标,未核验", "user coordinates, unverified"),
# timing precision claims
"exact_day_event": ("确定事件日期", "a certain event date"),
"exact_clock_event": ("确定事件钟点", "a certain event clock time"),
"dated_outcome_promise": ("带日期的结果承诺", "a dated outcome promise"),
"candidate_observation_window": ("候选观察窗口", "candidate observation window"),
"guaranteed_weekly_outcome": ("必然发生的周级事件", "a certain weekly outcome"),
"guaranteed_monthly_outcome": ("必然发生的月级事件", "a certain monthly outcome"),
"annual_solar_return_structure": ("独立太阳返照的年度结构", "the annual solar-return structure"),
"year_as_verified_event_calendar": ("把年度写成已验证的事件日历", "the year as a verified event calendar"),
# event replay fields
"event_id": ("事件编号", "event id"),
"year_or_date": ("年份或日期", "year or date"),
"domain": ("领域", "domain"),
"event_description": ("事件描述", "event description"),
"outcome_or_observation": ("结果或观察", "outcome or observation"),
"evidence_strength": ("证据强度", "evidence strength"),
"user_history_not_supplied": ("未提供过往事件", "no past events supplied"),
"required_before_execution": ("执行前必须满足", "required before running"),
"transit_multi_reference": ("四参考点行运", "four-reference transits"),
"annual_tajika": ("年度 Tajika", "annual Tajika"),
# verdict matrix
"career": ("事业", "career"), "relationship": ("关系", "relationship"), "wealth": ("财富", "wealth"),
"health": ("健康", "health"), "relocation": ("迁移", "relocation"), "education": ("教育", "education"),
"quarter_window": ("季度窗口", "quarter window"),
"season_window": ("季节窗口", "season window"),
"low_confidence_warning_only": ("仅低置信提醒", "low-confidence warning only"),
"case_replay_bounded_window": ("回放约束窗口", "replay-bounded window"),
"event_replay_missing": ("缺事件回放", "no event replay"),
"parameter_sensitivity_open": ("参数敏感未闭合", "parameter sensitivity open"),
"strength_filter_missing": ("缺力量过滤", "no strength filter"),
"multi_system_consensus_ready": ("多系统一致可用", "multi-system consensus ready"),
"stable": ("稳定", "stable"),
# sensitivity matrix
"stable_across_window": ("窗口内稳定", "stable across the window"),
"minute_sensitive": ("随分钟变化", "changes by minute"),
"review_not_materialized": ("尚无校时复核记录", "no rectification review on record"),
"record_rectification_review": ("记录校时复核", "record a rectification review"),
"forbidden": ("禁止", "forbidden"),
"research_cross_reference_only": ("仅作研究交叉参照", "research cross-reference only"),
}
DOMAIN_EN = {
"family_origin": "Parents and family of origin", "siblings_childhood": "Siblings and childhood",
"home_property": "Home, moves and property", "education_qualification": "Education and qualifications",
"career_trajectory": "Career path", "wealth_retention": "Wealth gained and kept",
"relationship_marriage": "Relationships, marriage and partnership", "children_family": "Children",
"health_adversity": "Health, accidents and adversity", "migration_foreign": "Migration and abroad",
"comfort_vehicles": "Comfort, vehicles and living standard", "career": "Career path",
"wealth": "Wealth gained and kept", "relationship": "Relationships, marriage and partnership",
"children": "Children", "relocation": "Migration and moving abroad", "education": "Education and qualifications",
"health": "Health, accidents and adversity", "family": "Parents and family of origin",
}
NODE_ZH = {"Rahu": "北交点", "Ketu": "南交点"}
def planet_name(value: Any, language: str) -> str:
"""The body's short node names (北交点 / 南交点); a slash inside a slash-joined cell reads as two planets."""
if language != "en" and str(value or "") in NODE_ZH:
return NODE_ZH[str(value)]
return _planet_name(value, language)
def _table(header: tuple[str, ...], rows: list[tuple[str, ...]]) -> list[str]:
"""Table cells start with a capital in English: a lone lower-case word reads as a raw packet key."""
fixed = [tuple(cell[:1].upper() + cell[1:] if cell[:1].isascii() and cell[:1].islower() else cell for cell in row)
for row in rows]
return _audit_table(header, fixed)
def _colon(language: str) -> str:
return ": " if language == "en" else ":"
def _lang(language: str) -> int:
return 1 if language == "en" else 0
def _dict(value: Any) -> dict:
return value if isinstance(value, dict) else {}
def _list(value: Any) -> list:
return value if isinstance(value, list) else []
def _word(value: Any, language: str) -> str:
key = str(value if value is not None else "").strip()
if key in WORDS:
return WORDS[key][_lang(language)]
if key.lower() in ("true", "false"):
return {"true": ("是", "yes"), "false": ("否", "no")}[key.lower()][_lang(language)]
return _clean(key, language) if key else "—"
def _words(values: Any, language: str, sep: str | None = None) -> str:
sep = sep or ("、" if language != "en" else ", ")
items = [_word(item, language) for item in _list(values) if item not in (None, "")]
return sep.join(items) or "—"
def _yes(value: Any, language: str) -> str:
if isinstance(value, bool):
return ("是", "yes")[_lang(language)] if value else ("否", "no")[_lang(language)]
return _word(value, language)
def _sheet(packet: dict, name: str) -> dict:
return _dict(_dict(packet.get("worksheets")).get(name))
def _significator(value: Any, language: str) -> str:
"""A/B/C/D cell: planets for the house view, house numbers for the planet view."""
items = value if isinstance(value, (list, tuple)) else ([value] if value not in (None, "", []) else [])
out = []
for item in items:
if isinstance(item, (int, float)) and not isinstance(item, bool):
out.append(str(int(item)))
elif item not in (None, ""):
out.append(planet_name(item, language))
return ("、" if language != "en" else ", ").join(out) or "—"
def _kp_houses(packet: dict) -> dict[int, dict]:
kp = _dict(_sheet(packet, "advanced_systems").get("kp"))
houses = _dict(kp.get("houses"))
out = {}
for house in range(1, 13):
row = _dict(houses.get(str(house)) or houses.get(house))
if _dict(row.get("significators")):
out[house] = row
return out
def _cusp_text(row: dict, language: str) -> str:
longitude = row.get("cusp_longitude")
if not isinstance(longitude, (int, float)) or isinstance(longitude, bool):
return "—"
degree = float(longitude) % 30.0
sign = row.get("sign") or ("Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", "Libra", "Scorpio",
"Sagittarius", "Capricorn", "Aquarius", "Pisces")[int(float(longitude) % 360 // 30)]
return f"{sign_name(sign, language)} {degree:.4f}°"
def _promise_judgement(significators: dict, language: str) -> str:
if significators.get("A") not in (None, "", [], ()):
return ("宫头承诺线索清晰,可作为该主题的专项核对入口", "Clear cusp promise; a usable entry for this theme")[_lang(language)]
if any(significators.get(key) not in (None, "", [], ()) for key in ("B", "C")):
return ("宫头已有局部牵连,但不足以单独支撑结论", "Partial links; not enough on its own")[_lang(language)]
return ("宫头牵连偏弱,只作补充观察", "Weak links; supplementary only")[_lang(language)]
def _kp_section(packet: dict, language: str) -> list[str]:
en = language == "en"
sensitive = _status("parameter_sensitive", language)
houses = _kp_houses(packet)
lines: list[str] = []
if houses:
lines.extend([
"### KP house view of the significators (Significations of the Houses)" if en
else "### KP 宫位显著星(宫位视角 / Significations of the Houses)",
"",
("The planet table above, read by house: which planets signify each house at the A (very strong) to D (weak) level. "
"It is a structural cross-check, not an event or timing verdict." if en else
"把前面的行星显著星表按宫位重排:每个宫由哪些行星在 A(很强)到 D(较弱)层级上牵连。只作结构核对,不单独生成事件或应期判断。"),
"",
*_table(("House", "Very strong (A)", "Strong (B)", "Normal (C)", "Weak (D)", "Status") if en
else ("宫位", "很强(A)", "强(B)", "一般(C)", "较弱(D)", "状态"),
[(str(house), *(_significator(row["significators"].get(key), language) for key in "ABCD"), sensitive)
for house, row in houses.items()]),
"",
"### KP Cuspal Promise (house-level judgement)" if en else "### KP Cuspal Promise(宫头承诺 / 宫位判断)",
"",
("Each cusp with its theme and the planets signifying it. A house whose A level is filled has a clear promise; "
"this is still not an event chain." if en else
"每个宫头连同主题与牵连它的行星。A 层有星的宫,宫头承诺较清晰;这仍不是事件链,也不单独给出应期。"),
"",
*_table(("House", "Cusp", "Theme", "A", "B", "C", "D", "Judgement", "Status") if en
else ("宫位", "宫头", "主题", "A", "B", "C", "D", "宫位判断", "状态"),
[(str(house), _cusp_text(row, language), HOUSE_THEMES[house][_lang(language)],
*(_significator(row["significators"].get(key), language) for key in "ABCD"),
_promise_judgement(row["significators"], language), sensitive)
for house, row in houses.items()]),
"",
])
for _domain, numbers, label, reader in KP_DOMAINS:
rows = [(str(house), *(_significator(houses[house]["significators"].get(key), language) for key in "ABCD"), sensitive)
for house in numbers if house in houses]
if not rows:
continue
suffix = " / ".join(str(number) for number in numbers)
lines.extend([
f"### KP {label[1].lower()} house check (houses {suffix})" if en else f"### KP {label[0]}宫位核对({suffix} 宫)",
"",
reader[_lang(language)],
"",
*_table(("House", "A", "B", "C", "D", "Status") if en else ("宫位", "A", "B", "C", "D", "状态"), rows),
"",
])
lines.extend(_dignity_section(packet, language))
lines.extend(_bhava_aspect_section(packet, language))
return lines
def _dignity_word(value: Any, language: str) -> str:
text = str(value or "").strip()
if not text:
return "—"
if "(" in text and text.endswith(")"):
chinese, english = text[:-1].split("(", 1)
return english.strip() if language == "en" else chinese.strip()
return text if language == "en" else DIGNITY_ZH.get(text, text)
def _dignity_section(packet: dict, language: str) -> list[str]:
en = language == "en"
varga_full = _dict(_sheet(packet, "divisional_and_special_charts").get("varga_full"))
d1 = _dict(_sheet(packet, "d1_rasi_bhava").get("planets"))
rows = []
for label, key in DIGNITY_VARGAS:
if key is None:
values = [_dignity_word(_dict(d1.get(planet)).get("status"), language) for planet in PLANETS]
if all(value == "—" for value in values):
continue
else:
dignity = _dict(_dict(varga_full.get(key)).get("_dignity"))
if not dignity:
continue
values = [_dignity_word(dignity.get(planet), language) for planet in PLANETS]
rows.append((label, *values, _status("parameter_sensitive", language)))
if not rows:
return []
return [
"### Shodashvarga dignity matrix" if en else "### Shodashvarga 分盘尊贵矩阵",
"",
("Each planet's dignity in D1 and the sixteen-varga set, as the engine returns it. D1 carries the friend/enemy "
"gradient; the vargas carry exalted / own / debilitated / neutral only." if en else
"各行星在 D1 与十六分盘中的尊贵状态,取自引擎原值。D1 带友敌梯度;分盘只分入旺、自宫、落陷与中性。"),
"",
*_table(("Varga", *(planet_name(planet, language) for planet in PLANETS), "Status") if en
else ("分盘", *(planet_name(planet, language) for planet in PLANETS), "状态"), rows),
"",
]
def _bhava_aspect_section(packet: dict, language: str) -> list[str]:
en = language == "en"
aspects = _dict(_sheet(packet, "advanced_systems").get("aspects"))
rows = []
for item in _list(aspects.get("house_aspects")):
item = _dict(item)
targets = _dict(item.get("aspects_to"))
for target in sorted(targets, key=lambda value: int(value) if str(value).isdigit() else 99):
detail = _dict(targets[target])
kind = str(detail.get("aspect_type") or "")
if en:
kind = kind.replace("宫相位", "th-house aspect")
rows.append((planet_name(item.get("planet"), language), str(detail.get("from_house") or item.get("house") or "—"),
str(target), kind or "—", _yes(bool(detail.get("is_special")), language),
_status("parameter_sensitive", language)))
if not rows:
return []
return [
"### Planet-to-Bhava aspects (Parashari)" if en else "### 行星对 Bhava 相位(Parashari 规则)",
"",
("Which houses each planet aspects under the Parashari house rule (7th for all; 4/8 Mars, 5/9 Jupiter, 3/10 Saturn). "
"Nodes are not included in this rule set." if en else
"按 Parashari 宫位相位规则列出每颗星照到的宫(各星 7 宫照;火星另照 4/8、木星 5/9、土星 3/10)。本规则不含交点。"),
"",
*_table(("Planet", "From house", "To house", "Aspect", "Special", "Status") if en
else ("行星", "来源宫", "目标宫", "相位类型", "特殊相位", "状态"), rows),
"",
]
def _level_lord(levels: dict, key: str, language: str) -> str:
lord = _dict(levels.get(key)).get("lord")
return planet_name(lord, language) if lord else "—"
def _sign_changes(previous: dict, current: dict, language: str) -> list[str]:
out = []
for planet in ("Jupiter", "Saturn", "Rahu", "Ketu"):
before = _dict(previous.get(planet)).get("sign")
after = _dict(current.get(planet)).get("sign")
if before and after and before != after:
if language == "en":
out.append(f"{planet_name(planet, language)} {sign_name(before, language)}→{sign_name(after, language)}")
else:
out.append(f"{planet_name(planet, language)}换座:{sign_name(before, language)}→{sign_name(after, language)}")
return out
def _monthly_section(packet: dict, language: str) -> list[str]:
en = language == "en"
sensitive = _status("parameter_sensitive", language)
monthly = _dict(_sheet(packet, "advanced_systems").get("kp_monthly_report"))
months = [_dict(row) for row in _list(monthly.get("months")) if _dict(row).get("month")]
ephemeris = _dict(_sheet(packet, "timing_and_predictive_systems").get("three_year_predictive_ephemeris_pack"))
ephemeris_by_month = {str(_dict(row).get("month")): _dict(row) for row in _list(ephemeris.get("months"))}
slow_by_month = {month: _dict(row.get("slow_planets")) for month, row in ephemeris_by_month.items()}
lines: list[str] = []
if months:
lines.extend([
"### Three-year KP monthly support" if en else "### KP 三年流月支持",
"",
(f"{months[0]['month']} to {months[-1]['month']}, {len(months)} months. Each month: the five Vimshottari levels at the "
"month anchor (1st, local noon), the birth ruling planet, the cusps the month triggers and slow-planet sign changes. "
"A support layer, not an event forecast." if en else
f"{months[0]['month']} 至 {months[-1]['month']},共 {len(months)} 个月。每月列出月锚点(当月 1 日本地正午)的五层 "
"Vimshottari、出生时刻主宰星、当月触发的宫头与慢行星换座。只是支持层,不是事件预测。"),
"",
])
by_year: dict[str, list[dict]] = {}
for row in months:
by_year.setdefault(str(row["month"])[:4], []).append(row)
for year, rows in sorted(by_year.items()):
header = (("Month", "MD / AD / PD", "Sookshma / Prana", "Ruling planet", "Cusp trigger", "Slow planets", "Status") if en
else ("月份", "大运 / 小运 / 细运", "微运 / 息运", "主宰星", "宫头触发", "慢行星提示", "事业", "财务", "关系", "状态"))
body = []
for row in rows:
levels = _dict(_dict(row.get("vimshottari_five_levels")).get("levels"))
support = _dict(row.get("theme_support"))
domains = [_dict(support.get(key)) for key in ("career", "wealth", "relationship")]
ruling = next((item.get("ruling_planet_snapshot") for item in domains if item.get("ruling_planet_snapshot")), None)
trigger = sorted({house for item in domains for house in _list(item.get("active_houses"))})
slow = slow_by_month.get(str(row["month"])) or {
planet: _dict(_dict(_dict(row.get("monthly_transits")).get("planets")).get(planet))
for planet in ("Jupiter", "Saturn", "Rahu", "Ketu")}
if en:
changes = _sign_changes(slow, slow_by_month.get(_next_month(str(row["month"])), {}), language)
else: # the engine's own wording, as upstream: changes during the month, retrograde notes left out
signals = _list(_dict(ephemeris_by_month.get(str(row["month"]))).get("slow_planet_signals")) \
or _list(row.get("slow_planet_signals"))
changes = [str(item) for item in signals if item and "逆行强调" not in str(item)]
cells = [
str(row["month"]),
" / ".join(_level_lord(levels, key, language) for key in ("mahadasha", "antardasha", "pratyantardasha")),
" / ".join(_level_lord(levels, key, language) for key in ("sookshma", "prana")),
planet_name(ruling, language) if ruling else "—",
("、" if not en else ", ").join(f"{house}{'' if en else '宫'}" for house in trigger) or "—",
(";" if not en else "; ").join(changes) or "—",
]
if not en:
cells.extend(_md_cell(item.get("brief_summary")) or "—" for item in domains)
cells.append(sensitive)
body.append(tuple(cells))
lines.extend([f"#### {year} KP monthly support" if en else f"#### {year} KP 月度支持", "", *_table(header, body), ""])
highlights = _list(ephemeris.get("yearly_highlights")) or _list(monthly.get("yearly_highlights"))
highlight_rows = [(str(_dict(year).get("year")), [_dict(item) for item in _list(_dict(year).get("months"))])
for year in highlights if _dict(year).get("year")]
if highlight_rows:
lines.extend([
"### Ephemeris key-month index" if en else "### 星历重点月份索引",
"",
("Months where a slow planet changes sign or turns retrograde. They are the months to read first, not months "
"where an event is certain." if en else
"慢行星换座或逆行强调的月份。这些是更值得优先阅读的月份,不代表具体事件一定发生。"),
"",
])
for year, items in highlight_rows:
body = []
for item in items:
if en:
month = str(item.get("month") or "")
reason = "; ".join(_sign_changes(slow_by_month.get(month, {}), slow_by_month.get(_next_month(month), {}), language)) \
or "slow-planet retrograde emphasis"
else:
reason = _md_cell(item.get("reason")) or "—"
body.append((str(item.get("month") or "—"), reason, _status(item.get("status") or "parameter_sensitive", language)))
lines.extend([f"#### {year} key months" if en else f"#### {year} 年重点月份索引", "",
*_table(("Month", "Why read it first", "Status") if en else ("月份", "为什么优先看", "状态"), body), ""])
lines.extend(_annual_extra_points(packet, language))
return lines
def _next_month(month: str) -> str:
try:
year, number = (int(part) for part in month.split("-", 1))
except ValueError:
return ""
return f"{year + 1:04d}-01" if number == 12 else f"{year:04d}-{number + 1:02d}"
def _annual_extra_points(packet: dict, language: str) -> list[str]:
en = language == "en"
timing = _sheet(packet, "timing_and_predictive_systems")
years = _dict(_dict(timing.get("annual_tajika_series")).get("years"))
packs = [(str(year), _dict(pack)) for year, pack in sorted(years.items())] or (
[(str(_dict(_dict(timing.get("annual_tajika_pack")).get("profile")).get("target_year") or "—"),
_dict(timing.get("annual_tajika_pack")))] if timing.get("annual_tajika_pack") else [])
rows = []
for year, pack in packs:
yogas = _dict(pack.get("tajika_yogas"))
interactions = [item for item in _list(_dict(yogas.get("data")).get("interactions")) if _dict(item).get("within_deeptamsa")]
if yogas:
rows.append((year, "Tajika Yogas",
f"{len(interactions)} pairs within Deeptamsa" if en else f"{len(interactions)} 对行星在 Deeptamsa 容许度内",
_status(yogas.get("status"), language)))
named = _dict(pack.get("tajika_named_yogas"))
if named:
supported = _list(_dict(named.get("data")).get("supported_named_yogas"))
rows.append((year, "Tajika named yogas" if en else "Tajika 命名瑜伽",
f"{len(supported)} named yogas supported" if en else f"支持 {len(supported)} 种命名瑜伽",
_status(named.get("status"), language)))
sahams = _dict(pack.get("sahams"))
if sahams:
count = sum(1 for key, value in _dict(sahams.get("data")).items() if key.endswith("_saham") and isinstance(value, dict))
rows.append((year, "Sahams", f"{count} points" if en else f"{count} 个点位", _status(sahams.get("status"), language)))
raman = _dict(pack.get("raman_sahams"))
if raman:
rows.append((year, "Raman Sahams", f"{len(_list(raman.get('rows')))} points" if en else f"{len(_list(raman.get('rows')))} 个点位",
_status(raman.get("status"), language)))
if not rows:
return []
return [
"### Annual extra points index" if en else "### 年度附加点索引",
"",
("Where each year's Tajika interactions and Saham points stand. The points themselves are in the annual chapters." if en
else "各年度 Tajika 交互与 Saham 点位的完成情况。点位本身在年度章节。"),
"",
*_table(("Year", "Item", "Count", "Status") if en else ("年份", "项目", "数量", "状态"), rows),
"",
]
def _birth_moment(packet: dict) -> datetime | None:
info = _dict(packet.get("birth_info"))
try:
if info.get("date"):
day = datetime.strptime(str(info["date"])[:10], "%Y-%m-%d")
else:
day = datetime(int(info["year"]), int(info["month"]), int(info["day"]))
if info.get("hour") is None and info.get("time"):
hour, minute = (int(part) for part in str(info["time"]).split(":")[:2])
else:
hour, minute = int(info.get("hour") or 0), int(info.get("minute") or 0)
return day.replace(hour=hour, minute=minute)
except (KeyError, TypeError, ValueError):
return None
def _tz_hours(packet: dict) -> float:
"""``birth_info.tz`` is ``UTC+8.0`` in the report packet; the profile carries ``+08:00``."""
info = _dict(packet.get("birth_info"))
raw = info.get("tz") if info.get("tz") is not None else info.get("timezone")
if isinstance(raw, (int, float)) and not isinstance(raw, bool):
return float(raw)
text = str(raw or "").upper().replace("UTC", "").replace("GMT", "").strip()
if not text:
text = str(_dict(_dict(packet.get("calculation_profile")).get("effective_settings")).get("timezone_offset") or "")
if ":" in text:
sign = -1.0 if text.startswith("-") else 1.0
hours, minutes = text.lstrip("+-").split(":", 1)
return sign * (int(hours) + int(minutes) / 60.0)
return float(text)
def three_minute_sensitivity(packet: dict) -> dict:
"""Upstream's ±1-minute recast matrix (yinduzhanxing ``_build_birth_time_sensitivity``)."""
info = _dict(packet.get("birth_info"))
center = _birth_moment(packet)
window = {}
if center is not None:
window = {"start": (center - timedelta(minutes=1)).strftime("%H:%M"), "center": center.strftime("%H:%M"),
"end": (center + timedelta(minutes=1)).strftime("%H:%M")}
base = {
"status": "blocked",
"window": window,
"approval_gate": {"status": "blocked", "can_enter_pending_approval": False,
"blocked_reasons": ["review_not_materialized"], "required_next_step": "record_rectification_review"},
"candidate_micro_compare": {"status": "blocked", "candidate_minutes": [], "minute_results": []},
}
if center is None:
return base
try:
try:
from scripts.flexible_birth_time_profile import build_flexible_birth_time_profile_from_window
except ModuleNotFoundError: # pragma: no cover
from flexible_birth_time_profile import build_flexible_birth_time_profile_from_window
profile_info = _dict(_dict(packet.get("calculation_profile")).get("effective_settings"))
profile = build_flexible_birth_time_profile_from_window(
birth_date=center.strftime("%Y-%m-%d"),
start_time=window["start"],
end_time=window["end"],
candidate_times=[window["start"], window["center"], window["end"]],
lat=float(info["lat"]),
lon=float(info["lon"]),
tz=_tz_hours(packet),
ayanamsa=str(profile_info.get("ayanamsa") or info.get("ayanamsa_name") or "lahiri"),
node_mode=str(profile_info.get("node_mode") or info.get("node_mode") or "mean"),
source_reference={"review_id": f"rectification-review://report-window/{center.strftime('%Y%m%d-%H%M')}",
"status": "review_required"},
)
except Exception: # noqa: BLE001 - the chapter shows the matrix as not run
return base
return {**base, "status": "candidate_window_only", "profile": profile}
def _rect_section(packet: dict, language: str) -> list[str]:
en = language == "en"
provenance = _dict(packet.get("birth_provenance"))
precision = _dict(packet.get("timing_precision_contract"))
replay = _dict(packet.get("event_replay"))
contract = _dict(packet.get("rectification_evidence_contract"))
sensitivity = three_minute_sensitivity(packet)
lines = [
"### Birth data source and uncertainty" if en else "### 出生资料来源与不确定范围",
"",
f"- {'Place label' if en else '地点标签'}{_colon(language)}{_word(provenance.get('birthplace_label', 'not_recorded'), language)}",
f"- {'Coordinate precision' if en else '坐标精度'}{_colon(language)}{_word(provenance.get('coordinate_precision', 'unverified_user_coordinates'), language)}",
f"- {'Coordinate source' if en else '坐标来源'}{_colon(language)}{_word(provenance.get('coordinate_source', 'not_recorded'), language)}",
f"- {'Birth time source' if en else '出生时间来源'}{_colon(language)}{_word(provenance.get('time_source', 'not_recorded'), language)}",
f"- {'Time uncertainty (minutes)' if en else '时间不确定范围(分钟)'}{_colon(language)}{_word(provenance.get('uncertainty_minutes', 'not_recorded'), language)}",
"",
"### Timing precision contract" if en else "### 时间精度合同",
"",
("Exact days are not given; weeks and months give candidate windows only; a year's themes come from that year's own "
"solar return. Every level needs real event replay before its wording can get stronger." if en else
"日级精确应期不可用;周级、月级只能给候选观察窗口;年度主题必须来自对应年份独立返照。各层级都要经过现实事件回放才能提高断言强度。"),
"",
]
tiers = _dict(precision.get("tiers"))
tier_names = {"day": ("日", "day"), "week": ("周", "week"), "month": ("月", "month"), "year": ("年", "year")}
lines.extend(_table(("Granularity", "Status", "Allowed", "Not allowed") if en else ("粒度", "状态", "允许结论", "禁止结论"),
[(tier_names[tier][_lang(language)], _status(_dict(tiers.get(tier)).get("status") or "blocked", language),
_words(_dict(tiers.get(tier)).get("allowed_claims"), language),
_words(_dict(tiers.get(tier)).get("prohibited_claims"), language))
for tier in ("day", "week", "month", "year")]))
events = [_dict(item) for item in _list(replay.get("events")) if isinstance(item, dict)]
lines.extend([
"",
"### Event replay check" if en else "### 事件回放校验",
"",
f"- {'Status' if en else '状态'}{_colon(language)}{_status(replay.get('status') or 'blocked', language)}",
f"- {'Reason' if en else '原因'}{_colon(language)}{_word(replay.get('reason') or 'user_history_not_supplied', language) if replay.get('reason') else _clean(replay.get('boundary'), language)}",
f"- {'Replay lanes' if en else '校验轨道'}{_colon(language)}{_words(replay.get('validation_lanes'), language)}",
f"- {'Fields each event needs' if en else '每条事件需提供'}{_colon(language)}{_words(replay.get('required_event_fields'), language)}",
])
if events:
lines.extend(["", *_table(("Year / date", "Domain", "Event", "Outcome") if en else ("年份/日期", "领域", "事件", "结果/观察"),
[(_md_cell(item.get("year_or_date")) or "—", _word(item.get("domain"), language),
_md_cell(item.get("event_description")) or "—", _md_cell(item.get("outcome_or_observation")) or "—")
for item in events])])
blind = _dict(replay.get("blind_holdout_policy"))
if blind:
lines.extend([
"",
"#### Blind replay and no-tuning boundary" if en else "#### 盲回放与防调参边界",
"",
f"- {'Status' if en else '状态'}{_colon(language)}{_word(blind.get('status'), language)}",
f"- {'Labels frozen first' if en else '标签先冻结'}{_colon(language)}{_yes(blind.get('freeze_user_labels_before_comparison'), language)}",
f"- {'No tuning the birth time or parameters from the same events' if en else '禁止用同一批事件反调出生时间或参数'}{_colon(language)}{_yes(blind.get('forbid_birth_time_or_parameter_tuning_from_same_events'), language)}",
("- Parent and family events may be compared against D12 only to tell candidates apart; D12 cannot pick a minute by itself."
if en else "- 父母/家庭事件只能拿来和 D12 比较候选差异;D12 不能单独选出分钟。"),
])
lines.extend(_verdict_section(packet, sensitivity, language))
lines.extend(_sensitivity_section(sensitivity, events, language))
lines.extend(_domain_contract_section(contract, language))
return lines
def _verdict_section(packet: dict, sensitivity: dict, language: str) -> list[str]:
en = language == "en"
matrix = _dict(packet.get("prediction_verdict_matrix"))
if not matrix:
try:
from scripts.prediction_verdict_matrix import build_prediction_verdict_matrix
except ModuleNotFoundError: # pragma: no cover
from prediction_verdict_matrix import build_prediction_verdict_matrix
view = dict(packet)
if _dict(packet.get("birth_time_sensitivity")).get("status") != "candidate_window_only":
view["birth_time_sensitivity"] = {"status": sensitivity.get("status")}
matrix = build_prediction_verdict_matrix(view)
rows = []
for row in _list(matrix.get("rows")):
row = _dict(row)
selector = _dict(row.get("selector"))
rows.append((_word(row.get("domain"), language), str(row.get("event_count", 0)),
_status(row.get("evidence_label"), language) if row.get("evidence_label") in ("blocked", "parameter_sensitive")
else _word(row.get("evidence_label"), language),
_word(row.get("claim_cap"), language), _words(row.get("blockers"), language, ";" if not en else "; "),
" / ".join(_word(item, language) if item in WORDS else str(item) for item in _list(selector.get("primary_dashas"))) or "—",
" / ".join(_word(item, language) if item in WORDS else str(item) for item in _list(selector.get("supporting_layers"))) or "—"))
return [
"",
"### Prediction verdict matrix" if en else "### 断事推运判决矩阵",
"",
f"- {'Status' if en else '状态'}{_colon(language)}{_status(matrix.get('status') or 'blocked', language)}",
f"- {'Strength filter' if en else '力量过滤'}{_colon(language)}{_status(_dict(matrix.get('strength_filter_gate')).get('status') or 'blocked', language)}",
f"- {'Parameter sensitivity' if en else '参数敏感'}{_colon(language)}{_word(_dict(matrix.get('parameter_sensitivity')).get('status'), language) if _dict(matrix.get('parameter_sensitivity')).get('status') == 'stable' else _status(_dict(matrix.get('parameter_sensitivity')).get('status') or 'blocked', language)}",
("- The matrix only adjudicates evidence already in this report; without event replay it cannot claim exact dates or a confirmed result."
if en else "- 本矩阵只裁决报告里已有的证据;没有事件回放时,不能据此给出确定日期或已证实的结果。"),
"",
*_table(("Domain", "Events", "Evidence", "Claim cap", "Blockers", "Primary dashas", "Supporting layers") if en
else ("领域", "事件数", "证据等级", "措辞上限", "阻断项", "主大运", "支撑层"), rows),
]
def _sensitivity_value(value: Any, language: str) -> str:
if isinstance(value, dict):
parts = []
for minute, item in value.items():
if isinstance(item, dict):
cusps = []
for house, cusp in item.items():
cusp = _dict(cusp)
if language == "en":
cusps.append(f"H{house.replace('house_', '')} {sign_name(cusp.get('sign'), language)} "
f"(star {planet_name(cusp.get('nakshatra_lord'), language)}, sub {planet_name(cusp.get('sub_lord'), language)})")
else:
cusps.append(f"{house.replace('house_', '')}宫 {sign_name(cusp.get('sign'), language)}"
f"(星宿主{planet_name(cusp.get('nakshatra_lord'), language)},分主{planet_name(cusp.get('sub_lord'), language)})")
parts.append(f"{minute}{_colon(language)}{','.join(cusps) if language != 'en' else ', '.join(cusps)}")
else:
parts.append(f"{minute} {sign_name(item, language)}")
return ";".join(parts) if language != "en" else "; ".join(parts)
return sign_name(value, language) if value not in (None, "") else "—"
def _layer_label(key: str, language: str) -> str:
"""Registry layer keys as words (``D1.house_4``, ``KP.cusp_observation`` are packet keys, not prose)."""
en = language == "en"
if key == "KP.cusp_observation":
return "KP cusps 1/4/7/10" if en else "KP 宫头(1/4/7/10)"
if key.endswith(".ascendant"):
return f"{key.split('.')[0]} {'ascendant' if en else '上升'}"
if key.startswith("arudha."):
return key.split(".", 1)[1]
if ".house_" in key:
chart, house = key.split(".house_", 1)
return f"{chart} house {house}" if en else f"{chart} 第{house}宫"
return _clean(key, language)
def _sensitivity_section(sensitivity: dict, events: list[dict], language: str) -> list[str]:
en = language == "en"
window = _dict(sensitivity.get("window"))
gate = _dict(sensitivity.get("approval_gate"))
profile = _dict(sensitivity.get("profile"))
stable = _dict(profile.get("stable_evidence"))
sensitive = _dict(profile.get("sensitive_evidence"))
rows = []
for key in SENSITIVITY_KEYS:
if key in stable:
value, kind = stable[key], "stable_across_window"
elif key in sensitive:
value, kind = sensitive[key], "minute_sensitive"
else:
value, kind = None, "blocked"
if key == "D12.ascendant":
boundary = ("Only with dated parent/family events; never picks a minute alone." if en
else "仅在有日期化父母/家庭事件时参与候选比较;不得单独选择分钟。")
elif key == "KP.cusp_observation":
boundary = ("Local observation of cusps 1/4/7/10 (sign / star lord / sub lord); exact-cusp oracle not closed." if en
else "1/4/7/10 宫头的本地观察(星座/星宿主/分主);精确宫头外部核对未闭合。")
else:
boundary = ("Raw structure for comparing candidates; cannot approve a birth minute alone." if en
else "可作为候选比较的原始结构,不可单独批准出生分钟。")
rows.append((_layer_label(key, language), _sensitivity_value(value, language) if value is not None else _status("blocked", language),
_word(kind, language) if kind != "blocked" else _status("blocked", language), boundary))
minute_status = "blocked"
lines = [
"",
"### Birth-time sensitivity matrix" if en else "### 生时敏感性矩阵",
"",
f"- {'Status' if en else '状态'}{_colon(language)}{_status(sensitivity.get('status'), language)}",
f"- {'Candidate minutes' if en else '候选窗口'}{_colon(language)}{window.get('start', '—')} / {window.get('center', '—')} / {window.get('end', '—')}",
f"- {'Review to approval gate' if en else '复核到批准门槛'}{_colon(language)}{_status(gate.get('status') or 'blocked', language)}",
f"- {'Can enter pending approval' if en else '可进入待批准'}{_colon(language)}{_yes(gate.get('can_enter_pending_approval', False), language)}",
f"- {'Held back because' if en else '阻断原因'}{_colon(language)}{_words(gate.get('blocked_reasons'), language)}",
f"- {'Next step' if en else '下一步'}{_colon(language)}{_word(gate.get('required_next_step'), language)}",
"",
*_table(("Layer", "Three-minute result", "Type", "Boundary") if en else ("校时层", "三分钟结果", "类型", "使用边界"), rows),
"",
("- This is a local comparison of the minute before and after, not a rectification result. It cannot replace the chart, "
"pick a winning minute or change the chart identity." if en else
"- 这只是出生分钟前后各一分钟的本地比较,不是校时结果;不能替换当前命盘、选出胜出分钟或改变命盘身份。"),
"",
"#### Rectification appendix: minute ranking" if en else "#### 校时附录:分钟排行表",
"",
f"- {'Status' if en else '状态'}{_colon(language)}{_status(minute_status, language)}",
(("- Events were supplied, but the report path does not score minutes; run the rectification tool for a ranking."
if en else "- 已提供事件,但报告路径不给分钟打分;分钟排行请用出生时间校正工具。") if events else
("- Minute comparison is packaged for the report but is not run until past events are supplied for replay."
if en else "- 分钟比较已打包进报告,但在提供过往事件做回放之前保持阻塞。")),
]
return lines
def _domain_contract_section(contract: dict, language: str) -> list[str]:
en = language == "en"
domains = [_dict(item) for item in _list(contract.get("domains")) if isinstance(item, dict)]
rules = _dict(contract.get("global_rules"))
lines = ["", "### Rectification evidence domain contract" if en else "### 校时证据领域合同", ""]
if not domains:
return lines + [f"- {'Status' if en else '状态'}{_colon(language)}{_status('blocked', language)}", ""]
lines.extend([
("Maps independently checkable life events to the evidence used to compare candidate minutes. It does not pick a "
"birth minute, establish medical facts or make event timing more precise than the precision contract allows."
if en else "把可独立核验的人生事件对应到候选分钟比较用的证据层。它不选出生分钟、不确立医疗事实,也不把应期精度提到时间精度合同之上。"),
"",
(f"- Global gate: at least {rules.get('minimum_independent_support_layers', '—')} independent supporting layers; labels frozen "
f"first = {_yes(rules.get('freeze_event_labels_before_candidate_comparison'), language)}; D60 decision power = "
f"{_word(rules.get('d60_decision_authority'), language)}; D40/D45 = {_word(rules.get('d40_d45_authority'), language)}."
if en else
f"- 全局门:至少 {rules.get('minimum_independent_support_layers', '—')} 个独立支持层;标签先冻结={_yes(rules.get('freeze_event_labels_before_candidate_comparison'), language)};"
f"D60 决策权={_word(rules.get('d60_decision_authority'), language)};D40/D45={_word(rules.get('d40_d45_authority'), language)}。"),
"",
])
rows = []
for item in domains:
label = DOMAIN_EN.get(str(item.get("domain_id")), _clean(item.get("domain_id"), language)) if en else (_md_cell(item.get("label")) or "—")
structural = ("、" if not en else ", ").join(_layer_label(str(layer), language) for layer in _list(item.get("structural_layers"))) or "—"
timing = ("、" if not en else ", ").join(_word(layer, language) if layer in WORDS else str(layer)
for layer in _list(item.get("timing_layers"))) or "—"
policy = item.get("special_policy")
policy_text = (_clean(policy, language) if policy else ("Global boundary" if en else "遵循全局边界"))
row = [label, structural, timing]
if not en:
row.append(";".join(_md_cell(example) for example in _list(item.get("objective_event_examples"))) or "—")
row.extend([str(item.get("minimum_cross_support", "—")), policy_text])
rows.append(tuple(row))
header = (("Event domain", "Structural layers", "Timing layers", "Minimum cross-support", "Special boundary") if en
else ("事件领域", "结构层", "时间/事件层", "可核验事实示例", "最低交叉", "专项边界"))
return lines + [*_table(header, rows), ""]
def render_upstream_sync_chapters(packet: dict, language: str) -> str:
language = "en" if language == "en" else "zh"
if not isinstance(packet, dict):
return ""
out: list[str] = []
kp = _kp_section(packet, language)
if kp:
out.extend([f"## {KP_HEADING[language]}", "", *kp])
monthly = _monthly_section(packet, language)
if monthly:
out.extend([f"## {MONTHLY_HEADING[language]}", "", *monthly])
out.extend([f"## {RECT_HEADING[language]}", "", *_rect_section(packet, language)])
return "\n".join(out).rstrip() + "\n"