From dbba127e715759f0e225fc57faf12508a01d2380 Mon Sep 17 00:00:00 2001 From: jesse-ux Date: Thu, 15 Oct 2026 20:59:11 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8B=86=E5=88=86=E6=8A=A5=E5=91=8A=E6=B8=B2?= =?UTF-8?q?=E6=9F=93=E5=87=BD=E6=95=B0=E7=9A=84=E7=AC=AC=E4=B8=80=E9=98=B6?= =?UTF-8?q?=E6=AE=B5=EF=BC=8C=E6=8A=8A=2034=20=E4=B8=AA=E6=97=A0=E5=A4=96?= =?UTF-8?q?=E5=B1=82=E4=BE=9D=E8=B5=96=E7=9A=84=E8=BE=85=E5=8A=A9=E5=87=BD?= =?UTF-8?q?=E6=95=B0=E6=90=AC=E5=88=B0=20report=5Frender=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同一份真实盘包在屏蔽与不屏蔽 PyJHora 下新旧渲染 sha 都一致,各 24 项且退出码 0。指定 pytest 233 通过、3 跳过。 Co-Authored-By: Grok (agent loop) --- .../PROGRESS-pl9-render-split-20261013.md | 48 ++ scripts/jyotish_engine.py | 501 ++++-------------- scripts/report_render/__init__.py | 1 + scripts/report_render/envelope.py | 49 ++ scripts/report_render/sections_misc.py | 127 +++++ scripts/report_render/text.py | 255 +++++++++ scripts/research/report_render_split_diff.py | 367 +++++++++++++ tests/test_report_render_package.py | 114 ++++ 8 files changed, 1050 insertions(+), 412 deletions(-) create mode 100644 docs/tasks/PROGRESS-pl9-render-split-20261013.md create mode 100644 scripts/report_render/__init__.py create mode 100644 scripts/report_render/envelope.py create mode 100644 scripts/report_render/sections_misc.py create mode 100644 scripts/report_render/text.py create mode 100644 scripts/research/report_render_split_diff.py create mode 100644 tests/test_report_render_package.py diff --git a/docs/tasks/PROGRESS-pl9-render-split-20261013.md b/docs/tasks/PROGRESS-pl9-render-split-20261013.md new file mode 100644 index 00000000..8a087017 --- /dev/null +++ b/docs/tasks/PROGRESS-pl9-render-split-20261013.md @@ -0,0 +1,48 @@ +# PROGRESS · 报告渲染函数拆分第一阶段 + +- 日期:2026-10-15 +- 分支:`codex/pl9-render-split-20261013` +- 基线:`origin/staging` `32b238cf8619cfab4b1107059ec997c974eb0d13` +- 范围:只做任务书第一阶段。不改 BUG_HISTORY(开工最大号 BUG-1357,本单不占号)、CHANGELOG、前端、API 服务器。 + +## 做了什么 + +把 `render_pl9_markdown` 里不依赖外层数据的 34 个嵌套函数原样搬到 `scripts/report_render/`。函数体只把 `planet_label_cn` 改成模块常量 `PLANET_LABEL_CN`。`SIGNS` / `SIGNS_CN` 按原字面量抄到 `text.py`,避免新包顶层导入引擎。 + +| 模块 | 函数 | +| --- | --- | +| envelope.py | `_unwrap` `_envelope_list` `_envelope_dict` `_bool_text` `_source_dict` `_has_executed_payload` | +| text.py | `_humanize_reader_token` `_humanize_reader_group` `_humanize_tajika_candidate` `_humanize_tajika_motion` `_display_rashi_from_value` `_humanize_reader_summary_line` `_humanize_annual_highlight_line` `_humanize_annual_narrative_highlight` `_humanize_domain_title` `_workbook_readable_label` `_workbook_readable_sentence` `_workbook_reading_paragraph` `_md_cell` `_render_compact_payload` `_sign_text` `_value_text` `_compose_narrative_paragraph` `_append_unique_text` `_planet_list_text` | +| sections_misc.py | `_render_boundary_packet_summary_line` `_status_from_report_pack_section` `_section_evidence_boundary` `_domi_pl9_current_gap_appendix` `_chart_planets_from_positions` `_kp_house_focus_text` `_high_value_theme_authority_sentence` `_kp_conflict_adjudication_sentence` `_prashna_exact_cusp_boundary_sentence` | + +## 排除 + +- `_degree_text`:函数内定义两次,本阶段不搬。 +- `_sign_degree_text`、`_longitude_text`:任务书点名与度数簇一起排除。 +- `_profile_aware_benchmark_appendix`:要读引擎里的 `_blocked_planned_section` 和 `ROOT_DIR`。新包不能顶层导入引擎,否则成环,也过不了干净进程导入测试。 +- 依赖外层数据、或调用上述未搬函数的嵌套函数:留给下一阶段。 + +## 前后 + +| 项 | 搬之前 | 搬之后 | +| --- | --- | --- | +| `render_pl9_markdown` 行数 | 6917 | 6506 | +| 嵌套函数 | 148 | 114 | + +行数少了 411,嵌套函数少了 34。没有改既有断言。 + +## 检查 + +| 检查 | 命令 | 结果数字 | +| --- | --- | --- | +| 未搬移,不屏蔽 | `report_render_split_diff.py` | 退出码 0,24 项 | +| 未搬移,屏蔽 | 同上 `--mask-pyjhora` | 退出码 0,24 项 | +| 搬移后,不屏蔽 | 同上 | 退出码 0,24 项 | +| 搬移后,屏蔽 | 同上 `--mask-pyjhora` | 退出码 0,24 项 | +| 指定 pytest + 隐私扫描 | `.venv\Scripts\python.exe -m pytest` 任务书列表与 `tests/test_repo_privacy_markers.py` | 退出码 0;236 收集,233 通过,3 跳过,0 失败 | + +同一轮里,基线工作树和新代码渲染同一份 pickle,24 个 sha 全部相同。完整数据版中英 8 个 sha 在四轮里不变。屏蔽那轮曾有一次基线对基线不一致:夜间盘接口响应里年主重放一会儿是超时、一会儿是导入失败。这是屏蔽后的外部重放抖动,不是这次搬函数引入的。原样重跑后退出码 0。 + +## 偏离 + +任务书预期行数下降约 600 到 900。实际 411,因为上面三个排除项里有一份 142 行的附录。没有做第二阶段及以后。 diff --git a/scripts/jyotish_engine.py b/scripts/jyotish_engine.py index 982f1077..c378ab40 100644 --- a/scripts/jyotish_engine.py +++ b/scripts/jyotish_engine.py @@ -109,6 +109,94 @@ if ROOT_DIR not in sys.path: sys.path.insert(0, ROOT_DIR) if SCRIPT_DIR not in sys.path: sys.path.insert(0, SCRIPT_DIR) +try: + from scripts.report_render.envelope import ( + _bool_text, + _envelope_dict, + _envelope_list, + _has_executed_payload, + _source_dict, + _unwrap, + ) +except ModuleNotFoundError: # pragma: no cover - direct scripts/ execution + from report_render.envelope import ( + _bool_text, + _envelope_dict, + _envelope_list, + _has_executed_payload, + _source_dict, + _unwrap, + ) +try: + from scripts.report_render.sections_misc import ( + _chart_planets_from_positions, + _domi_pl9_current_gap_appendix, + _high_value_theme_authority_sentence, + _kp_conflict_adjudication_sentence, + _kp_house_focus_text, + _prashna_exact_cusp_boundary_sentence, + _render_boundary_packet_summary_line, + _section_evidence_boundary, + _status_from_report_pack_section, + ) +except ModuleNotFoundError: # pragma: no cover - direct scripts/ execution + from report_render.sections_misc import ( + _chart_planets_from_positions, + _domi_pl9_current_gap_appendix, + _high_value_theme_authority_sentence, + _kp_conflict_adjudication_sentence, + _kp_house_focus_text, + _prashna_exact_cusp_boundary_sentence, + _render_boundary_packet_summary_line, + _section_evidence_boundary, + _status_from_report_pack_section, + ) +try: + from scripts.report_render.text import ( + PLANET_LABEL_CN, + _append_unique_text, + _compose_narrative_paragraph, + _display_rashi_from_value, + _humanize_annual_highlight_line, + _humanize_annual_narrative_highlight, + _humanize_domain_title, + _humanize_reader_group, + _humanize_reader_summary_line, + _humanize_reader_token, + _humanize_tajika_candidate, + _humanize_tajika_motion, + _md_cell, + _planet_list_text, + _render_compact_payload, + _sign_text, + _value_text, + _workbook_readable_label, + _workbook_readable_sentence, + _workbook_reading_paragraph, + ) +except ModuleNotFoundError: # pragma: no cover - direct scripts/ execution + from report_render.text import ( + PLANET_LABEL_CN, + _append_unique_text, + _compose_narrative_paragraph, + _display_rashi_from_value, + _humanize_annual_highlight_line, + _humanize_annual_narrative_highlight, + _humanize_domain_title, + _humanize_reader_group, + _humanize_reader_summary_line, + _humanize_reader_token, + _humanize_tajika_candidate, + _humanize_tajika_motion, + _md_cell, + _planet_list_text, + _render_compact_payload, + _sign_text, + _value_text, + _workbook_readable_label, + _workbook_readable_sentence, + _workbook_reading_paragraph, + ) try: from calculation_profile_contract import ( @@ -3501,173 +3589,7 @@ def render_pl9_markdown(packet: dict) -> str: else {} ) - def _unwrap(raw_value): - # 兼容 packet 字段的 response_envelope 升级: - # 真实数据可能直接是 list/dict,也可能被包成 {"result": ..., "response_envelope": {...}}。 - if ( - isinstance(raw_value, dict) - and 'result' in raw_value - and 'response_envelope' in raw_value - ): - return raw_value.get('result') - return raw_value - - def _envelope_list(raw_value): - raw_value = _unwrap(raw_value) - if isinstance(raw_value, list): - return raw_value - if isinstance(raw_value, tuple): - return list(raw_value) - # 期望 list 时,即使只有 result 键也解包,避免 nested planets 被空渲染成 '-'。 - if isinstance(raw_value, dict) and 'result' in raw_value: - nested = raw_value.get('result') - if isinstance(nested, list): - return nested - if isinstance(nested, tuple): - return list(nested) - return [] - - def _envelope_dict(raw_value): - raw_value = _unwrap(raw_value) - return raw_value if isinstance(raw_value, dict) else {} - - def _bool_text(value): - return '是' if value else '否' - - planet_label_cn = { - 'Sun': '太阳', - 'Moon': '月亮', - 'Mars': '火星', - 'Mercury': '水星', - 'Jupiter': '木星', - 'Venus': '金星', - 'Saturn': '土星', - 'Rahu': '北交点', - 'Ketu': '南交点', - } - - def _humanize_reader_token(token: str) -> str: - value = str(token or '').strip() - if not value: - return value - if value in SIGNS_CN: - return SIGNS_CN[value] - if value in planet_label_cn: - return planet_label_cn[value] - return value - - def _humanize_reader_group(raw_group: str) -> str: - seen = set() - parts = [] - for item in str(raw_group or '').split(','): - humanized = _humanize_reader_token(item) - if humanized and humanized not in seen: - seen.add(humanized) - parts.append(humanized) - return ' / '.join(parts) - - def _humanize_tajika_candidate(name: str) -> str: - return { - 'Ithasala_candidate': '趋近相位候选', - 'Easarapha_candidate': '分离相位候选', - }.get(str(name or ''), '年度相位候选') - - def _humanize_tajika_motion(motion: str) -> str: - return {'applying': '趋近', 'separating': '分离'}.get(str(motion or ''), str(motion or '-')) - - def _display_rashi_from_value(raw_value): - if isinstance(raw_value, int) and 0 <= raw_value < len(SIGNS): - return _humanize_reader_token(SIGNS[raw_value]) - return _humanize_reader_token(raw_value) - - def _render_boundary_packet_summary_line(packet: dict, summary_pairs: list[tuple[str, str]]) -> str: - summary = packet.get('summary') if isinstance(packet.get('summary'), dict) else {} - bits = [_md_cell(packet.get('packet_mode'))] - for key, suffix in summary_pairs: - value = summary.get(key) - if value not in (None, '', []): - bits.append(f"{_md_cell(value)}{suffix}") - return f"参考包摘要:{';'.join(bits)}。" - - def _humanize_reader_summary_line(summary_line: str) -> str: - summary_line = _unwrap(summary_line) - if isinstance(summary_line, dict): - summary_line = ( - summary_line.get('brief') - or summary_line.get('text') - or summary_line.get('summary') - or '' - ) - text = str(summary_line or '').strip() - if not text: - return text - if text == "年度层已经有可读壳层,但仍需要带着冲突标签阅读。": - return "这一年的年度主线已经形成。" - if text == "This pack converts Dasha trigger facts into report-ready interpretation seeds without final event claims.": - return "这层已经把 Dasha 触发事实压成可读提示,但仍不直接升级为具体事件断语。" - if text == "Source: dasha_master_pack.interpretation_triggers.": - return "当前解释仍然直接来自 dasha_master_pack.interpretation_triggers 的证据层。" - if text == "All claims remain evidence-labeled and parameter-sensitive where the source pack is unclosed.": - return "只要 source pack 还没闭环,相关结论就继续保持 evidence-labeled 与 parameter_sensitive。" - if text == "Tajika Yogas: annual candidate structures visible": - return "Tajika Yogas 当前已经能读到年度候选交互结构。" - if text == "Sahams: annual sensitive points visible": - return "Sahams 当前已经能读到年度敏感点。" - match = re.match(r'^([^:]+):\s*conflict between\s+(.+?)\s+vs\s+(.+)$', text) - if match: - topic = match.group(1).strip() - left = _humanize_reader_group(match.group(2)) - right = _humanize_reader_group(match.group(3)) - topic_label = { - 'Muntha': 'Muntha', - 'Year Lord': 'Year Lord', - 'Annual Asc': '返照上升', - }.get(topic, topic) - if left == right: - return f"{topic_label} 当前结果指向{left}。" - return f"{topic_label} 当前存在候选分歧:一组结果指向{left},另一组结果指向{right}。" - simple_match = re.match(r'^([^:]+):\s*(.+)$', text) - if simple_match: - topic = simple_match.group(1).strip() - value = _humanize_reader_group(simple_match.group(2)) - if topic == 'Year Lord' and value: - return f"Year Lord 当前优先候选指向{value}。" - if topic == 'Muntha' and value: - return f"Muntha 当前优先候选指向{value}。" - if topic == 'Tajika Yogas' and 'annual candidate structures visible' in str(simple_match.group(2)).lower(): - return "Tajika Yogas 当前已经能读到年度候选交互结构。" - if topic == 'Sahams' and 'annual sensitive points visible' in str(simple_match.group(2)).lower(): - return "Sahams 当前已经能读到年度敏感点。" - return text - - def _humanize_annual_highlight_line(highlight: str) -> str: - text = str(highlight or '').strip() - if not text: - return text - if text == "Solar Return / Tajika annual shell is available.": - return "本年度返照与 Tajika 时间结构已经生成。" - if text == "Muntha and Year Lord are visible, but Year Lord remains producer-disagree sensitive.": - return "Muntha 和 Year Lord 现在都已经可见,但 Year Lord 仍要带着分歧标签阅读。" - if text == "The report should read annual structure first, then the evidence appendix.": - return "这一层适合从年度结构展开,再回头核对证据附录。" - return text - - def _humanize_annual_narrative_highlight(highlight: str) -> str: - return _humanize_annual_highlight_line(str(highlight)).rstrip('。') - - def _humanize_domain_title(title: str) -> str: - text = str(title or '').strip() - if not text: - return text - mapping = { - "Career and public direction": "事业与公众方向", - "Wealth and growth opportunities": "财富与增长机会", - "Relationship and alliance themes": "关系与合作主题", - "Health and vitality caution": "健康与精力提醒", - "Risk, delay, and friction markers": "阻力、延迟与摩擦提示", - "Timing windows and cross-check needs": "时间窗口与交叉核对重点", - } - return mapping.get(text, text) + planet_label_cn = PLANET_LABEL_CN def _degree_text(value): if value in (None, '', [], {}): @@ -3677,12 +3599,6 @@ def render_pl9_markdown(packet: dict) -> str: except (TypeError, ValueError): return str(value) - def _source_dict(*candidates): - for candidate in candidates: - if isinstance(candidate, dict) and candidate: - return candidate - return {} - def _render_basic_tables() -> list[str]: birth_sheet = worksheets.get('birth_and_parameters') if isinstance(worksheets.get('birth_and_parameters'), dict) else {} d1_sheet = worksheets.get('d1_rasi_bhava') if isinstance(worksheets.get('d1_rasi_bhava'), dict) else {} @@ -3907,113 +3823,11 @@ def render_pl9_markdown(packet: dict) -> str: readable = [_segment_sentence(segment_id).rstrip('。') for segment_id in segment_ids] return f"{group_name}包括:{';'.join(readable)}。" - def _workbook_readable_label(worksheet_id: str) -> str: - if worksheet_id == 'birth_and_parameters': - return '出生与计算口径' - if worksheet_id == 'd1_rasi_bhava': - return '本命盘主结构' - if worksheet_id == 'strengths_and_scores': - return '力量与技术表' - if worksheet_id == 'divisional_and_special_charts': - return '分盘与特殊点位' - if worksheet_id == 'timing_and_predictive_systems': - return '时间系统与流年层' - if worksheet_id == 'advanced_systems': - return '进阶体系' - if worksheet_id == 'ai_and_audit': - return '审计与证据附录' - return '补充工作簿' - - def _workbook_readable_sentence(worksheet_id: str) -> str: - if worksheet_id == 'birth_and_parameters': - return '出生参数、岁差、交点模式与计算口径已经固定,是整份报告的可信底座。' - if worksheet_id == 'd1_rasi_bhava': - return '本命盘主结构已经就位,适合先看性格主轴、房屋结构和基础趋势。' - if worksheet_id == 'strengths_and_scores': - return '力量、功能性吉凶与技术表可以直接用来判断支撑与压力来源。' - if worksheet_id == 'divisional_and_special_charts': - return '分盘与特殊点位可作为扩展阅读,适合补充看细节。' - if worksheet_id == 'timing_and_predictive_systems': - return '大运、行运与年度层已经可以形成时间主线。' - if worksheet_id == 'advanced_systems': - return 'Jaimini、KP、Yoga 等进阶体系可作为补充主轴。' - if worksheet_id == 'ai_and_audit': - return 'AI 读写、审计与证据层用于核对来源与边界,不直接替代正文。' - return f'{worksheet_id} 已纳入当前报告。' - def _reader_group_paragraph(group_name: str, segment_ids: list[str]) -> str: if not segment_ids: return f'{group_name}当前暂无稳定内容。' return _segment_group_sentence(group_name, segment_ids) - def _workbook_reading_paragraph(worksheet_ids: list[str]) -> str: - parts = [] - for worksheet_id in worksheet_ids: - sentence = _workbook_readable_sentence(worksheet_id).rstrip('。') - parts.append(f"先看{_workbook_readable_label(worksheet_id)},这里会告诉你{sentence}") - if not parts: - return '当前暂无可用工作簿阅读顺序。' - return ';'.join(parts) + '。' - - def _md_cell(value) -> str: - text = str(value if value not in (None, '') else '-').replace('\n', ' ').strip() - return text.replace('|', '/') - - def _render_compact_payload(value, depth: int = 0) -> str: - if value in (None, '', [], {}): - return '-' - if depth >= 2: - return _md_cell(type(value).__name__) - if isinstance(value, dict): - parts = [] - for key in sorted(value, key=str): - if key in { - 'narrative_items', - 'domain_paragraphs', - 'response_envelope', - 'input_hash', - 'profile_hash', - 'raw_calculation', - 'shared_full_report_authority', - }: - continue - parts.append(f"{key}={_render_compact_payload(value.get(key), depth + 1)}") - return '; '.join(parts) if parts else '-' - if isinstance(value, (list, tuple)): - items = [_render_compact_payload(item, depth + 1) for item in list(value)[:8]] - suffix = ' …' if len(value) > 8 else '' - return ', '.join(items) + suffix if items else '-' - return _md_cell(value) - - def _status_from_report_pack_section(section: dict) -> str: - if not isinstance(section, dict): - return 'blocked' - audit = section.get('audit') if isinstance(section.get('audit'), dict) else {} - if section.get('status'): - return str(section.get('status')) - if audit.get('status'): - return str(audit.get('status')) - if section: - return 'partial_verified' - return 'blocked' - - def _section_evidence_boundary(section: dict) -> str: - if not isinstance(section, dict): - return 'section_missing' - exports = section.get('exports') if isinstance(section.get('exports'), dict) else {} - ai_bundle = exports.get('ai_evidence_bundle') if isinstance(exports.get('ai_evidence_bundle'), dict) else {} - audit = section.get('audit') if isinstance(section.get('audit'), dict) else {} - boundaries = [] - if ai_bundle.get('contains_private_pl9_text') is False: - boundaries.append('no_private_pl9_text') - if ai_bundle.get('claim_policy'): - boundaries.append(str(ai_bundle.get('claim_policy'))) - if audit.get('must_not_claim'): - boundaries.append('must_not_claim=' + ','.join(map(str, audit.get('must_not_claim')[:3]))) - if section.get('reason'): - boundaries.append(str(section.get('reason'))) - return '; '.join(boundaries) if boundaries else 'evidence_labeled' - def _full_report_pack_assembly_section() -> list[str]: if not isinstance(full_report_pack, dict) or not full_report_pack: return [] @@ -4071,9 +3885,6 @@ def render_pl9_markdown(packet: dict) -> str: out.append('') return out - def _domi_pl9_current_gap_appendix() -> list[str]: - return [] - def _profile_aware_benchmark_appendix() -> list[str]: dashboard_path = Path(ROOT_DIR) / 'references/oracle/profile_aware_benchmark_boundary_dashboard_2026_08_27.json' if not dashboard_path.exists(): @@ -4401,21 +4212,6 @@ def render_pl9_markdown(packet: dict) -> str: out.append('') return out - def _chart_planets_from_positions(positions: dict) -> dict: - chart_planets = {} - if not isinstance(positions, dict): - return chart_planets - for planet_name in ('Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn', 'Rahu', 'Ketu'): - planet = positions.get(planet_name) - if not isinstance(planet, dict): - continue - sign = planet.get('sign') or planet.get('rashi') or planet.get('sign_name') - if not sign: - continue - degree = planet.get('degree_in_sign', planet.get('degree', planet.get('longitude_in_sign', 0))) - chart_planets[planet_name] = {'sign': sign, 'degree': degree or 0} - return chart_planets - def _render_south_chart( positions: dict, ascendant_row: dict, @@ -5385,30 +5181,6 @@ def render_pl9_markdown(packet: dict) -> str: return "UL 在待确认位置、Pranapada 在待确认位置,但关系主题仍建议把制度承诺层与气质投射层分开看。" return None - def _kp_house_focus_text(value) -> str: - house_label_map = { - 2: '收入与资源', - 5: '机会与投入', - 6: '职责与压力', - 7: '合作与伴侣', - 9: '运气与扩张', - 10: '事业与位置', - 11: '回报与支持', - } - parts = [] - if isinstance(value, (list, tuple)): - raw_parts = [str(item).strip() for item in value if str(item).strip()] - else: - raw_parts = [part.strip() for part in str(value or '').replace('/', ',').split(',') if part.strip()] - for part in raw_parts: - try: - house_no = int(part) - except ValueError: - parts.append(part) - continue - parts.append(f"{house_no}宫[{house_label_map.get(house_no, '-')}]") - return '、'.join(parts) if parts else '-' - def _graha_padas_domain_bridge(domain: str) -> str | None: graha_padas = jaimini.get('graha_padas') if isinstance(jaimini.get('graha_padas'), dict) else {} graha_pada_rows = graha_padas.get('graha_padas') if isinstance(graha_padas.get('graha_padas'), dict) else {} @@ -6230,41 +6002,6 @@ def render_pl9_markdown(packet: dict) -> str: segments.append('这些层当前仍只作为 ' + ' / '.join(statuses) + ' 的辅助阅读,不直接升级为主题主结论') return '主题支持补充:' + ';'.join(segments) + '。' - def _high_value_theme_authority_sentence(domain: str) -> str | None: - domain_label_map = { - 'career': '事业', - 'wealth': '财务', - 'relationship': '关系', - } - action_map = { - 'career': '优先排序事业推进顺序、外部舞台和月份节奏', - 'wealth': '优先排序资源承接、现金流节奏和收益兑现顺序', - 'relationship': '优先排序合作推进、联结温度和关系节奏', - } - return ( - "主结论加厚:Narayana 第二时间轴、力量兑现结构、年度 highlights / field briefs、Patyayini 年内换挡、Graha Padas 这几层现在直接参与" - + f"{domain_label_map.get(domain, domain)}章主结论," - + f"用来{action_map.get(domain, '补强主题排序')};" - + "它们仍保留 parameter_sensitive / unclosed_divisional_chart 边界,但不再只停留在附录或登记表。" - ) - - def _kp_conflict_adjudication_sentence(domain: str) -> str: - domain_label_map = { - 'career': '事业', - 'wealth': '财务', - 'relationship': '关系', - } - focus_map = { - 'career': '外部舞台、职位变化和项目推进', - 'wealth': '资源承接、现金流和回收节奏', - 'relationship': '合作推进、联结温度和承诺节奏', - } - return ( - f"如果 Vimshottari、Narayana 与年度专题同向,而 KP 月度支持也落在同一主题上,就把这个月份优先当作{domain_label_map.get(domain, domain)}线的可执行窗口。" - f"如果 KP 单独活跃、但本命结构或双大运没有接力,KP 只负责提示 {focus_map.get(domain, '主题变化')} 的月份排序,不改写主题事实。" - f"如果 KP 与年度专题或双大运出现冲突,先以本命结构 + Vimshottari + Narayana 定主轴,KP 退回到月度提醒与核对层,继续保持 parameter_sensitive。" - ) - def _patyayini_reader_bridge() -> list[str]: if not replay_patyayini_rows: return [] @@ -6305,12 +6042,6 @@ def render_pl9_markdown(packet: dict) -> str: ) return out - def _prashna_exact_cusp_boundary_sentence() -> str | None: - return ( - "如果下一步要把月份继续缩到更细日期,仍需要补 question_time / place / timezone / question category;" - "Prashna ruling planets 与 exact cusp oracle 在这份本命个人报告里继续保持 blocked,只作为后续缩窄入口,不冒充 timing truth。" - ) - def _reader_unexported_high_value_layers_section() -> list[str]: divisional_sheet = worksheets.get('divisional_and_special_charts') if isinstance(worksheets.get('divisional_and_special_charts'), dict) else {} points = divisional_sheet.get('sensitive_points') if isinstance(divisional_sheet.get('sensitive_points'), dict) else {} @@ -6394,12 +6125,6 @@ def render_pl9_markdown(packet: dict) -> str: except (TypeError, ValueError): return _md_cell(value) - def _sign_text(row: dict, sign_key: str = 'sign', cn_key: str = 'sign_cn') -> str: - if not isinstance(row, dict): - return '-' - sign = row.get(cn_key) or _humanize_reader_token(row.get(sign_key)) or row.get(sign_key) - return _md_cell(sign) - def _pl9_core_structure_tables() -> list[str]: birth_sheet = worksheets.get('birth_and_parameters') if isinstance(worksheets.get('birth_and_parameters'), dict) else {} d1_sheet = worksheets.get('d1_rasi_bhava') if isinstance(worksheets.get('d1_rasi_bhava'), dict) else {} @@ -6508,12 +6233,6 @@ def render_pl9_markdown(packet: dict) -> str: out.append('') return out - def _value_text(*values): - for value in values: - if value not in (None, '', [], {}): - return str(value) - return None - ai_audit_sheet = worksheets.get('ai_and_audit') if isinstance(worksheets.get('ai_and_audit'), dict) else {} ai_summary = ai_audit_sheet.get('summary') if isinstance(ai_audit_sheet.get('summary'), dict) else {} guided_topics = ai_summary.get('guided_topics') if isinstance(ai_summary.get('guided_topics'), list) else [] @@ -6681,20 +6400,6 @@ def render_pl9_markdown(packet: dict) -> str: return ';'.join(bits) + '。' return None - def _compose_narrative_paragraph(*parts: str | None) -> str | None: - cleaned = [] - for part in parts: - text = str(part or '').strip() - if not text: - continue - text = text.rstrip() - if text.endswith('。'): - text = text[:-1] - cleaned.append(text) - if not cleaned: - return None - return ';'.join(cleaned) + '。' - dasha_domain_rows = {} domain_paragraphs = dasha_interpretation_pack.get('domain_paragraphs') if isinstance(dasha_interpretation_pack.get('domain_paragraphs'), list) else [] for row in domain_paragraphs: @@ -6724,15 +6429,6 @@ def render_pl9_markdown(packet: dict) -> str: ] special_lagnas_layer = divisional_sheet.get('special_lagnas') if isinstance(divisional_sheet.get('special_lagnas'), dict) else {} - def _has_executed_payload(value) -> bool: - if value in (None, '', [], {}): - return False - if isinstance(value, dict): - status = str(value.get('status') or value.get('execution_status') or '').strip().lower() - if status in {'blocked', 'missing', 'not_found', 'parameter_sensitive'}: - return False - return True - def _has_varga_code(varga_code: str) -> bool: prefix = f'{varga_code}_' for layer in varga_layers: @@ -6955,20 +6651,6 @@ def render_pl9_markdown(packet: dict) -> str: return None return ';'.join(summary_bits) + '。' - def _planet_list_text(items: list[str], limit: int = 3) -> str | None: - names = [] - seen = set() - for item in items: - humanized = _humanize_reader_token(item) - if humanized and humanized not in seen: - seen.add(humanized) - names.append(humanized) - if len(names) >= limit: - break - if not names: - return None - return '、'.join(names) - def _functional_layer_sentence() -> str | None: if functional_layer.get('status') != 'used': return None @@ -7368,11 +7050,6 @@ def render_pl9_markdown(packet: dict) -> str: annual_evidence_appendix = _envelope_dict(annual_report_sections.get('evidence_appendix')) annual_field_briefs = _envelope_dict(annual_evidence_appendix.get('field_briefs')) - def _append_unique_text(items: list[str], value: str) -> None: - text = str(value or '').strip().rstrip('。') - if text and text not in items: - items.append(text) - def _reader_executive_summary() -> dict: ai_audit = packet.get('ai_and_audit') if isinstance(packet.get('ai_and_audit'), dict) else {} worksheet_ai = worksheets.get('ai_and_audit') if isinstance(worksheets.get('ai_and_audit'), dict) else {} diff --git a/scripts/report_render/__init__.py b/scripts/report_render/__init__.py new file mode 100644 index 00000000..9d3271b6 --- /dev/null +++ b/scripts/report_render/__init__.py @@ -0,0 +1 @@ +"""Helpers moved unchanged out of render_pl9_markdown (phase 1).""" diff --git a/scripts/report_render/envelope.py b/scripts/report_render/envelope.py new file mode 100644 index 00000000..9405617c --- /dev/null +++ b/scripts/report_render/envelope.py @@ -0,0 +1,49 @@ +"""Helpers moved unchanged out of render_pl9_markdown (phase 1).""" + +def _unwrap(raw_value): + # 兼容 packet 字段的 response_envelope 升级: + # 真实数据可能直接是 list/dict,也可能被包成 {"result": ..., "response_envelope": {...}}。 + if ( + isinstance(raw_value, dict) + and 'result' in raw_value + and 'response_envelope' in raw_value + ): + return raw_value.get('result') + return raw_value + +def _envelope_list(raw_value): + raw_value = _unwrap(raw_value) + if isinstance(raw_value, list): + return raw_value + if isinstance(raw_value, tuple): + return list(raw_value) + # 期望 list 时,即使只有 result 键也解包,避免 nested planets 被空渲染成 '-'。 + if isinstance(raw_value, dict) and 'result' in raw_value: + nested = raw_value.get('result') + if isinstance(nested, list): + return nested + if isinstance(nested, tuple): + return list(nested) + return [] + +def _envelope_dict(raw_value): + raw_value = _unwrap(raw_value) + return raw_value if isinstance(raw_value, dict) else {} + +def _bool_text(value): + return '是' if value else '否' + +def _source_dict(*candidates): + for candidate in candidates: + if isinstance(candidate, dict) and candidate: + return candidate + return {} + +def _has_executed_payload(value) -> bool: + if value in (None, '', [], {}): + return False + if isinstance(value, dict): + status = str(value.get('status') or value.get('execution_status') or '').strip().lower() + if status in {'blocked', 'missing', 'not_found', 'parameter_sensitive'}: + return False + return True diff --git a/scripts/report_render/sections_misc.py b/scripts/report_render/sections_misc.py new file mode 100644 index 00000000..ab66e01a --- /dev/null +++ b/scripts/report_render/sections_misc.py @@ -0,0 +1,127 @@ +"""Helpers moved unchanged out of render_pl9_markdown (phase 1).""" + +try: + from scripts.report_render.text import _md_cell +except ModuleNotFoundError: # pragma: no cover - direct scripts/ execution + from report_render.text import _md_cell + +def _render_boundary_packet_summary_line(packet: dict, summary_pairs: list[tuple[str, str]]) -> str: + summary = packet.get('summary') if isinstance(packet.get('summary'), dict) else {} + bits = [_md_cell(packet.get('packet_mode'))] + for key, suffix in summary_pairs: + value = summary.get(key) + if value not in (None, '', []): + bits.append(f"{_md_cell(value)}{suffix}") + return f"参考包摘要:{';'.join(bits)}。" + +def _status_from_report_pack_section(section: dict) -> str: + if not isinstance(section, dict): + return 'blocked' + audit = section.get('audit') if isinstance(section.get('audit'), dict) else {} + if section.get('status'): + return str(section.get('status')) + if audit.get('status'): + return str(audit.get('status')) + if section: + return 'partial_verified' + return 'blocked' + +def _section_evidence_boundary(section: dict) -> str: + if not isinstance(section, dict): + return 'section_missing' + exports = section.get('exports') if isinstance(section.get('exports'), dict) else {} + ai_bundle = exports.get('ai_evidence_bundle') if isinstance(exports.get('ai_evidence_bundle'), dict) else {} + audit = section.get('audit') if isinstance(section.get('audit'), dict) else {} + boundaries = [] + if ai_bundle.get('contains_private_pl9_text') is False: + boundaries.append('no_private_pl9_text') + if ai_bundle.get('claim_policy'): + boundaries.append(str(ai_bundle.get('claim_policy'))) + if audit.get('must_not_claim'): + boundaries.append('must_not_claim=' + ','.join(map(str, audit.get('must_not_claim')[:3]))) + if section.get('reason'): + boundaries.append(str(section.get('reason'))) + return '; '.join(boundaries) if boundaries else 'evidence_labeled' + +def _domi_pl9_current_gap_appendix() -> list[str]: + return [] + +def _chart_planets_from_positions(positions: dict) -> dict: + chart_planets = {} + if not isinstance(positions, dict): + return chart_planets + for planet_name in ('Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn', 'Rahu', 'Ketu'): + planet = positions.get(planet_name) + if not isinstance(planet, dict): + continue + sign = planet.get('sign') or planet.get('rashi') or planet.get('sign_name') + if not sign: + continue + degree = planet.get('degree_in_sign', planet.get('degree', planet.get('longitude_in_sign', 0))) + chart_planets[planet_name] = {'sign': sign, 'degree': degree or 0} + return chart_planets + +def _kp_house_focus_text(value) -> str: + house_label_map = { + 2: '收入与资源', + 5: '机会与投入', + 6: '职责与压力', + 7: '合作与伴侣', + 9: '运气与扩张', + 10: '事业与位置', + 11: '回报与支持', + } + parts = [] + if isinstance(value, (list, tuple)): + raw_parts = [str(item).strip() for item in value if str(item).strip()] + else: + raw_parts = [part.strip() for part in str(value or '').replace('/', ',').split(',') if part.strip()] + for part in raw_parts: + try: + house_no = int(part) + except ValueError: + parts.append(part) + continue + parts.append(f"{house_no}宫[{house_label_map.get(house_no, '-')}]") + return '、'.join(parts) if parts else '-' + +def _high_value_theme_authority_sentence(domain: str) -> str | None: + domain_label_map = { + 'career': '事业', + 'wealth': '财务', + 'relationship': '关系', + } + action_map = { + 'career': '优先排序事业推进顺序、外部舞台和月份节奏', + 'wealth': '优先排序资源承接、现金流节奏和收益兑现顺序', + 'relationship': '优先排序合作推进、联结温度和关系节奏', + } + return ( + "主结论加厚:Narayana 第二时间轴、力量兑现结构、年度 highlights / field briefs、Patyayini 年内换挡、Graha Padas 这几层现在直接参与" + + f"{domain_label_map.get(domain, domain)}章主结论," + + f"用来{action_map.get(domain, '补强主题排序')};" + + "它们仍保留 parameter_sensitive / unclosed_divisional_chart 边界,但不再只停留在附录或登记表。" + ) + +def _kp_conflict_adjudication_sentence(domain: str) -> str: + domain_label_map = { + 'career': '事业', + 'wealth': '财务', + 'relationship': '关系', + } + focus_map = { + 'career': '外部舞台、职位变化和项目推进', + 'wealth': '资源承接、现金流和回收节奏', + 'relationship': '合作推进、联结温度和承诺节奏', + } + return ( + f"如果 Vimshottari、Narayana 与年度专题同向,而 KP 月度支持也落在同一主题上,就把这个月份优先当作{domain_label_map.get(domain, domain)}线的可执行窗口。" + f"如果 KP 单独活跃、但本命结构或双大运没有接力,KP 只负责提示 {focus_map.get(domain, '主题变化')} 的月份排序,不改写主题事实。" + f"如果 KP 与年度专题或双大运出现冲突,先以本命结构 + Vimshottari + Narayana 定主轴,KP 退回到月度提醒与核对层,继续保持 parameter_sensitive。" + ) + +def _prashna_exact_cusp_boundary_sentence() -> str | None: + return ( + "如果下一步要把月份继续缩到更细日期,仍需要补 question_time / place / timezone / question category;" + "Prashna ruling planets 与 exact cusp oracle 在这份本命个人报告里继续保持 blocked,只作为后续缩窄入口,不冒充 timing truth。" + ) diff --git a/scripts/report_render/text.py b/scripts/report_render/text.py new file mode 100644 index 00000000..413b54c0 --- /dev/null +++ b/scripts/report_render/text.py @@ -0,0 +1,255 @@ +"""Helpers moved unchanged out of render_pl9_markdown (phase 1).""" + +import re + +try: + from scripts.report_render.envelope import _unwrap +except ModuleNotFoundError: # pragma: no cover - direct scripts/ execution + from report_render.envelope import _unwrap + +SIGNS = ['Aries', 'Taurus', 'Gemini', 'Cancer', 'Leo', 'Virgo', 'Libra', 'Scorpio', 'Sagittarius', 'Capricorn', 'Aquarius', 'Pisces'] +SIGNS_CN = {'Aries': '白羊座', 'Taurus': '金牛座', 'Gemini': '双子座', 'Cancer': '巨蟹座', 'Leo': '狮子座', 'Virgo': '处女座', 'Libra': '天秤座', 'Scorpio': '天蝎座', 'Sagittarius': '射手座', 'Capricorn': '摩羯座', 'Aquarius': '水瓶座', 'Pisces': '双鱼座'} + +PLANET_LABEL_CN = { + 'Sun': '太阳', + 'Moon': '月亮', + 'Mars': '火星', + 'Mercury': '水星', + 'Jupiter': '木星', + 'Venus': '金星', + 'Saturn': '土星', + 'Rahu': '北交点', + 'Ketu': '南交点', +} + +def _humanize_reader_token(token: str) -> str: + value = str(token or '').strip() + if not value: + return value + if value in SIGNS_CN: + return SIGNS_CN[value] + if value in PLANET_LABEL_CN: + return PLANET_LABEL_CN[value] + return value + +def _humanize_reader_group(raw_group: str) -> str: + seen = set() + parts = [] + for item in str(raw_group or '').split(','): + humanized = _humanize_reader_token(item) + if humanized and humanized not in seen: + seen.add(humanized) + parts.append(humanized) + return ' / '.join(parts) + +def _humanize_tajika_candidate(name: str) -> str: + return { + 'Ithasala_candidate': '趋近相位候选', + 'Easarapha_candidate': '分离相位候选', + }.get(str(name or ''), '年度相位候选') + +def _humanize_tajika_motion(motion: str) -> str: + return {'applying': '趋近', 'separating': '分离'}.get(str(motion or ''), str(motion or '-')) + +def _display_rashi_from_value(raw_value): + if isinstance(raw_value, int) and 0 <= raw_value < len(SIGNS): + return _humanize_reader_token(SIGNS[raw_value]) + return _humanize_reader_token(raw_value) + +def _humanize_reader_summary_line(summary_line: str) -> str: + summary_line = _unwrap(summary_line) + if isinstance(summary_line, dict): + summary_line = ( + summary_line.get('brief') + or summary_line.get('text') + or summary_line.get('summary') + or '' + ) + text = str(summary_line or '').strip() + if not text: + return text + if text == "年度层已经有可读壳层,但仍需要带着冲突标签阅读。": + return "这一年的年度主线已经形成。" + if text == "This pack converts Dasha trigger facts into report-ready interpretation seeds without final event claims.": + return "这层已经把 Dasha 触发事实压成可读提示,但仍不直接升级为具体事件断语。" + if text == "Source: dasha_master_pack.interpretation_triggers.": + return "当前解释仍然直接来自 dasha_master_pack.interpretation_triggers 的证据层。" + if text == "All claims remain evidence-labeled and parameter-sensitive where the source pack is unclosed.": + return "只要 source pack 还没闭环,相关结论就继续保持 evidence-labeled 与 parameter_sensitive。" + if text == "Tajika Yogas: annual candidate structures visible": + return "Tajika Yogas 当前已经能读到年度候选交互结构。" + if text == "Sahams: annual sensitive points visible": + return "Sahams 当前已经能读到年度敏感点。" + match = re.match(r'^([^:]+):\s*conflict between\s+(.+?)\s+vs\s+(.+)$', text) + if match: + topic = match.group(1).strip() + left = _humanize_reader_group(match.group(2)) + right = _humanize_reader_group(match.group(3)) + topic_label = { + 'Muntha': 'Muntha', + 'Year Lord': 'Year Lord', + 'Annual Asc': '返照上升', + }.get(topic, topic) + if left == right: + return f"{topic_label} 当前结果指向{left}。" + return f"{topic_label} 当前存在候选分歧:一组结果指向{left},另一组结果指向{right}。" + simple_match = re.match(r'^([^:]+):\s*(.+)$', text) + if simple_match: + topic = simple_match.group(1).strip() + value = _humanize_reader_group(simple_match.group(2)) + if topic == 'Year Lord' and value: + return f"Year Lord 当前优先候选指向{value}。" + if topic == 'Muntha' and value: + return f"Muntha 当前优先候选指向{value}。" + if topic == 'Tajika Yogas' and 'annual candidate structures visible' in str(simple_match.group(2)).lower(): + return "Tajika Yogas 当前已经能读到年度候选交互结构。" + if topic == 'Sahams' and 'annual sensitive points visible' in str(simple_match.group(2)).lower(): + return "Sahams 当前已经能读到年度敏感点。" + return text + +def _humanize_annual_highlight_line(highlight: str) -> str: + text = str(highlight or '').strip() + if not text: + return text + if text == "Solar Return / Tajika annual shell is available.": + return "本年度返照与 Tajika 时间结构已经生成。" + if text == "Muntha and Year Lord are visible, but Year Lord remains producer-disagree sensitive.": + return "Muntha 和 Year Lord 现在都已经可见,但 Year Lord 仍要带着分歧标签阅读。" + if text == "The report should read annual structure first, then the evidence appendix.": + return "这一层适合从年度结构展开,再回头核对证据附录。" + return text + +def _humanize_annual_narrative_highlight(highlight: str) -> str: + return _humanize_annual_highlight_line(str(highlight)).rstrip('。') + +def _humanize_domain_title(title: str) -> str: + text = str(title or '').strip() + if not text: + return text + mapping = { + "Career and public direction": "事业与公众方向", + "Wealth and growth opportunities": "财富与增长机会", + "Relationship and alliance themes": "关系与合作主题", + "Health and vitality caution": "健康与精力提醒", + "Risk, delay, and friction markers": "阻力、延迟与摩擦提示", + "Timing windows and cross-check needs": "时间窗口与交叉核对重点", + } + return mapping.get(text, text) + +def _workbook_readable_label(worksheet_id: str) -> str: + if worksheet_id == 'birth_and_parameters': + return '出生与计算口径' + if worksheet_id == 'd1_rasi_bhava': + return '本命盘主结构' + if worksheet_id == 'strengths_and_scores': + return '力量与技术表' + if worksheet_id == 'divisional_and_special_charts': + return '分盘与特殊点位' + if worksheet_id == 'timing_and_predictive_systems': + return '时间系统与流年层' + if worksheet_id == 'advanced_systems': + return '进阶体系' + if worksheet_id == 'ai_and_audit': + return '审计与证据附录' + return '补充工作簿' + +def _workbook_readable_sentence(worksheet_id: str) -> str: + if worksheet_id == 'birth_and_parameters': + return '出生参数、岁差、交点模式与计算口径已经固定,是整份报告的可信底座。' + if worksheet_id == 'd1_rasi_bhava': + return '本命盘主结构已经就位,适合先看性格主轴、房屋结构和基础趋势。' + if worksheet_id == 'strengths_and_scores': + return '力量、功能性吉凶与技术表可以直接用来判断支撑与压力来源。' + if worksheet_id == 'divisional_and_special_charts': + return '分盘与特殊点位可作为扩展阅读,适合补充看细节。' + if worksheet_id == 'timing_and_predictive_systems': + return '大运、行运与年度层已经可以形成时间主线。' + if worksheet_id == 'advanced_systems': + return 'Jaimini、KP、Yoga 等进阶体系可作为补充主轴。' + if worksheet_id == 'ai_and_audit': + return 'AI 读写、审计与证据层用于核对来源与边界,不直接替代正文。' + return f'{worksheet_id} 已纳入当前报告。' + +def _workbook_reading_paragraph(worksheet_ids: list[str]) -> str: + parts = [] + for worksheet_id in worksheet_ids: + sentence = _workbook_readable_sentence(worksheet_id).rstrip('。') + parts.append(f"先看{_workbook_readable_label(worksheet_id)},这里会告诉你{sentence}") + if not parts: + return '当前暂无可用工作簿阅读顺序。' + return ';'.join(parts) + '。' + +def _md_cell(value) -> str: + text = str(value if value not in (None, '') else '-').replace('\n', ' ').strip() + return text.replace('|', '/') + +def _render_compact_payload(value, depth: int = 0) -> str: + if value in (None, '', [], {}): + return '-' + if depth >= 2: + return _md_cell(type(value).__name__) + if isinstance(value, dict): + parts = [] + for key in sorted(value, key=str): + if key in { + 'narrative_items', + 'domain_paragraphs', + 'response_envelope', + 'input_hash', + 'profile_hash', + 'raw_calculation', + 'shared_full_report_authority', + }: + continue + parts.append(f"{key}={_render_compact_payload(value.get(key), depth + 1)}") + return '; '.join(parts) if parts else '-' + if isinstance(value, (list, tuple)): + items = [_render_compact_payload(item, depth + 1) for item in list(value)[:8]] + suffix = ' …' if len(value) > 8 else '' + return ', '.join(items) + suffix if items else '-' + return _md_cell(value) + +def _sign_text(row: dict, sign_key: str = 'sign', cn_key: str = 'sign_cn') -> str: + if not isinstance(row, dict): + return '-' + sign = row.get(cn_key) or _humanize_reader_token(row.get(sign_key)) or row.get(sign_key) + return _md_cell(sign) + +def _value_text(*values): + for value in values: + if value not in (None, '', [], {}): + return str(value) + return None + +def _compose_narrative_paragraph(*parts: str | None) -> str | None: + cleaned = [] + for part in parts: + text = str(part or '').strip() + if not text: + continue + text = text.rstrip() + if text.endswith('。'): + text = text[:-1] + cleaned.append(text) + if not cleaned: + return None + return ';'.join(cleaned) + '。' + +def _planet_list_text(items: list[str], limit: int = 3) -> str | None: + names = [] + seen = set() + for item in items: + humanized = _humanize_reader_token(item) + if humanized and humanized not in seen: + seen.add(humanized) + names.append(humanized) + if len(names) >= limit: + break + if not names: + return None + return '、'.join(names) + +def _append_unique_text(items: list[str], value: str) -> None: + text = str(value or '').strip().rstrip('。') + if text and text not in items: + items.append(text) diff --git a/scripts/research/report_render_split_diff.py b/scripts/research/report_render_split_diff.py new file mode 100644 index 00000000..d4d6f2df --- /dev/null +++ b/scripts/research/report_render_split_diff.py @@ -0,0 +1,367 @@ +#!/usr/bin/env python3 +"""Byte-compare phase-1 report rendering against a baseline worktree. + +The current tree builds real-engine packets and pickles them outside the repo. +A baseline checkout renders those same packets. Baseline-vs-baseline runs first. +""" + +from __future__ import annotations + +import argparse +import copy +import hashlib +import json +import os +import pickle +import subprocess +import sys +import tempfile +from pathlib import Path +from types import SimpleNamespace + + +ROOT = Path(__file__).resolve().parents[2] +PRODUCTION_BODY = { + "format": "markdown", + "edition": "full_data", + "include_fact_tables": True, + "languages": ["zh", "en"], +} +FOURTH = { + "name": "虚构丁", + "year": 1990, + "month": 1, + "day": 1, + "hour": 12, + "minute": 0, + "second": 0, + "lat": 28.6139, + "lon": 77.2090, + "tz": 5.5, + "node_mode": "mean", + "ayanamsa": "lahiri", + "today": "2026-10-13", + "transit_date": "2026-10-13", + "target_year": 2026, + "age": 36, + "birth_time_accuracy": "confirmed", +} + + +def _ensure_hashseed() -> None: + if os.environ.get("PYTHONHASHSEED") == "0": + return + env = dict(os.environ) + env["PYTHONHASHSEED"] = "0" + raise SystemExit(subprocess.call([sys.executable, *sys.argv], env=env)) + + +def _activate(root: Path) -> None: + root = root.resolve() + script_dir = str(Path(__file__).resolve().parent) + while script_dir in sys.path: + sys.path.remove(script_dir) + folders = [str(root / "jyotish_vedic"), str(root), str(root / "scripts")] + for folder in folders: + if not Path(folder).exists(): + continue + while folder in sys.path: + sys.path.remove(folder) + sys.path.insert(0, folder) + + +def _mask_pyjhora() -> None: + import importlib + + root = Path(tempfile.mkdtemp(prefix="nojhora-")) + package = root / "jhora" + package.mkdir() + (package / "__init__.py").write_text('raise ImportError("masked")\n', encoding="utf-8") + sys.path.insert(0, str(root)) + prior = os.environ.get("PYTHONPATH", "") + os.environ["PYTHONPATH"] = str(root) + (os.pathsep + prior if prior else "") + for name in list(sys.modules): + if name == "jhora" or name.startswith("jhora."): + del sys.modules[name] + importlib.invalidate_caches() + + +def _sha(text: str) -> str: + return hashlib.sha256(text.encode("utf-8")).hexdigest() + + +def _freeze_clocks() -> None: + import datetime as dt_mod + + real = dt_mod.datetime + fixed_utc = real(2026, 10, 13, 0, 0, 0) + fixed_local = real(2026, 10, 13, 8, 0, 0) + + class Frozen(real): + @classmethod + def now(cls, tz=None): + if tz is not None: + return fixed_utc.replace(tzinfo=tz) + return fixed_local + + @classmethod + def utcnow(cls): + return fixed_utc + + for module in list(sys.modules.values()): + if module is None: + continue + current = getattr(module, "datetime", None) + if isinstance(current, type) and current.__name__ == "datetime": + module.datetime = Frozen + + +def _cases() -> list[tuple[str, dict]]: + from tests.test_report_english_edition import CASES + + ordered = [(name, dict(CASES[name])) for name in ("day", "night", "south")] + ordered.append(("fourth", dict(FOURTH))) + return ordered + + +def _subset_pack_id(packet: dict) -> str | None: + manifest = packet.get("report_pack_manifest") if isinstance(packet, dict) else None + packs = manifest.get("packs") if isinstance(manifest, dict) else None + if not isinstance(packs, list): + return None + for item in packs: + if isinstance(item, dict) and item.get("id") and item.get("id") != "full": + return str(item["id"]) + return None + + +def _build_records() -> list[dict]: + from scripts.jyotish_engine import ( + _apply_pl9_pack_selection, + build_professional_report_reference_packet, + cmd_full_reading, + ) + from scripts.professional_report_reference import _export_args + + records = [] + for name, case in _cases(): + print(f"build {name}", file=sys.stderr, flush=True) + reading = cmd_full_reading(SimpleNamespace(**case)) + packet = build_professional_report_reference_packet(reading, _export_args(case), []) + records.append({"id": f"{name}/reference", "kind": "pl9", "payload": packet}) + stripped = copy.deepcopy(packet) + if isinstance(stripped, dict): + stripped.pop("personal_report_producer", None) + records.append({"id": f"{name}/no-producer", "kind": "pl9", "payload": stripped}) + pack_id = _subset_pack_id(packet if isinstance(packet, dict) else {}) + if pack_id: + selected = _apply_pl9_pack_selection(copy.deepcopy(packet), [pack_id]) + records.append({"id": f"{name}/pack/{pack_id}", "kind": "pl9", "payload": selected}) + else: + print(f"skip pack {name}: no non-full pack", file=sys.stderr) + records.append({"id": f"{name}/full-data-zh", "kind": "full", "payload": packet}) + english = copy.deepcopy(packet) + if isinstance(english, dict): + english["report_language"] = "en" + records.append({"id": f"{name}/full-data-en", "kind": "full", "payload": english}) + records.append({ + "id": f"{name}/api-production", + "kind": "api", + "payload": {"birth": case, "reading": reading}, + }) + return records + + +def _render_text(kind: str, payload) -> str: + if kind == "pl9": + from scripts.jyotish_engine import render_pl9_markdown + + return render_pl9_markdown(copy.deepcopy(payload)) + if kind == "full": + from scripts.pl9_full_data_export import render_full_data_markdown + + return render_full_data_markdown(copy.deepcopy(payload)) + if kind == "api": + from scripts.professional_report_reference import build_professional_report_reference + + birth = payload["birth"] + reading = copy.deepcopy(payload["reading"]) + + class Handler: + def _high_rigor_birth_payload(self, _body): + return dict(birth) + + def _compute_full_reading_for_thematic(self, _birth): + return reading + + response = build_professional_report_reference(Handler(), dict(PRODUCTION_BODY)) + return json.dumps(response, ensure_ascii=False, default=str) + raise RuntimeError(f"unknown kind {kind}") + + +def _render_records(records: list[dict]) -> list[dict]: + import scripts.jyotish_engine # noqa: F401 + import scripts.pl9_full_data_export # noqa: F401 + import scripts.professional_report_reference # noqa: F401 + + _freeze_clocks() + rendered = [] + for record in records: + text = _render_text(record["kind"], record["payload"]) + if not isinstance(text, str): + raise RuntimeError(f"{record['id']} did not render to text") + rendered.append({"id": record["id"], "sha": _sha(text), "text": text}) + return rendered + + +def _diff_context(left: str, right: str) -> str: + left_lines = left.splitlines() or [left] + right_lines = right.splitlines() or [right] + limit = min(len(left_lines), len(right_lines)) + index = next((i for i in range(limit) if left_lines[i] != right_lines[i]), limit) + start = max(0, index - 2) + rows = [] + for line_no in range(start, start + 5): + sample = left_lines[line_no] if line_no < len(left_lines) else "" + other = right_lines[line_no] if line_no < len(right_lines) else "" + if sample == other and line_no != index: + rows.append(f" {line_no + 1}: {sample}") + continue + cut = _window(sample, other) + rows.append(f"- {line_no + 1}: {cut[0]}") + rows.append(f"+ {line_no + 1}: {cut[1]}") + return "\n".join(rows) + + +def _window(left: str, right: str) -> tuple[str, str]: + if len(left) <= 240 and len(right) <= 240: + return left, right + limit = min(len(left), len(right)) + index = next((i for i in range(limit) if left[i] != right[i]), limit) + start = max(0, index - 80) + stop = index + 80 + return left[start:stop], right[start:stop] + + +def _invoke_baseline(baseline: Path, pickle_path: Path, result_path: Path, mask: bool) -> subprocess.CompletedProcess[str]: + command = [ + sys.executable, + str(Path(__file__).resolve()), + "--render-only", + "--pickle", + str(pickle_path), + "--engine-root", + str(baseline), + "--result", + str(result_path), + ] + if mask: + command.append("--mask-pyjhora") + env = dict(os.environ) + env["PYTHONHASHSEED"] = "0" + env["JYOTISH_REPORT_REFERENCE_CACHE"] = "0" + env["JYOTISH_REPORT_RENDER_FAST"] = "0" + return subprocess.run( + command, + cwd=str(baseline), + env=env, + text=True, + capture_output=True, + check=False, + ) + + +def _load_result(path: Path) -> list[dict]: + return pickle.loads(path.read_bytes()) + + +def _print_shas(rows: list[dict]) -> None: + for row in rows: + print(f"{row['id']}\t{row['sha']}") + + +def _compare(label: str, left: list[dict], right: list[dict]) -> int: + if [row["id"] for row in left] != [row["id"] for row in right]: + print(f"{label}: record ids differ", file=sys.stderr) + return 1 + failed = 0 + for lhs, rhs in zip(left, right): + if lhs["sha"] == rhs["sha"]: + continue + failed += 1 + print(f"{label} mismatch {lhs['id']} {lhs['sha']} vs {rhs['sha']}", file=sys.stderr) + print(_diff_context(lhs["text"], rhs["text"]), file=sys.stderr) + return 1 if failed else 0 + + +def _render_only(args: argparse.Namespace) -> int: + if args.mask_pyjhora: + _mask_pyjhora() + _activate(Path(args.engine_root)) + os.environ["JYOTISH_REPORT_REFERENCE_CACHE"] = "0" + os.environ["JYOTISH_REPORT_RENDER_FAST"] = "0" + records = pickle.loads(Path(args.pickle).read_bytes()) + rendered = _render_records(records) + Path(args.result).write_bytes(pickle.dumps(rendered, protocol=pickle.HIGHEST_PROTOCOL)) + return 0 + + +def main() -> int: + _ensure_hashseed() + parser = argparse.ArgumentParser() + parser.add_argument("--baseline", type=Path) + parser.add_argument("--mask-pyjhora", action="store_true") + parser.add_argument("--render-only", action="store_true") + parser.add_argument("--pickle", type=Path) + parser.add_argument("--engine-root", type=Path) + parser.add_argument("--result", type=Path) + args = parser.parse_args() + if args.render_only: + return _render_only(args) + if args.baseline is None: + print("missing --baseline", file=sys.stderr) + return 2 + if args.mask_pyjhora: + _mask_pyjhora() + _activate(ROOT) + os.environ["JYOTISH_REPORT_REFERENCE_CACHE"] = "0" + os.environ["JYOTISH_REPORT_RENDER_FAST"] = "0" + records = _build_records() + temp_dir = Path(tempfile.mkdtemp(prefix="pl9-render-split-")) + try: + pickle_path = temp_dir / "packets.pkl" + pickle_path.write_bytes(pickle.dumps(records, protocol=pickle.HIGHEST_PROTOCOL)) + first_path = temp_dir / "baseline-a.pkl" + second_path = temp_dir / "baseline-b.pkl" + first = _invoke_baseline(args.baseline, pickle_path, first_path, args.mask_pyjhora) + second = _invoke_baseline(args.baseline, pickle_path, second_path, args.mask_pyjhora) + if first.returncode != 0 or second.returncode != 0: + print(first.stderr[-4000:], file=sys.stderr) + print(second.stderr[-4000:], file=sys.stderr) + print(first.stdout[-1000:], file=sys.stderr) + return first.returncode or second.returncode or 1 + baseline_a = _load_result(first_path) + baseline_b = _load_result(second_path) + if _compare("baseline-vs-baseline", baseline_a, baseline_b) != 0: + print("基线对基线两次 sha 不同,存在非确定性,停止", file=sys.stderr) + return 2 + current = _render_records(pickle.loads(pickle_path.read_bytes())) + if _compare("current-vs-baseline", current, baseline_a) != 0: + return 1 + _print_shas(current) + print(f"ok items={len(current)} masked={int(args.mask_pyjhora)}") + return 0 + finally: + for child in temp_dir.glob("*"): + try: + child.unlink() + except OSError: + pass + try: + temp_dir.rmdir() + except OSError: + pass + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_report_render_package.py b/tests/test_report_render_package.py new file mode 100644 index 00000000..a9a230b3 --- /dev/null +++ b/tests/test_report_render_package.py @@ -0,0 +1,114 @@ +"""Phase-1 report_render package: import boundary and moved-helper parity.""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path +from types import SimpleNamespace + + +ROOT = Path(__file__).resolve().parents[1] +ORIGINAL_PLANET_LABEL_CN = { + "Sun": "太阳", + "Moon": "月亮", + "Mars": "火星", + "Mercury": "水星", + "Jupiter": "木星", + "Venus": "金星", + "Saturn": "土星", + "Rahu": "北交点", + "Ketu": "南交点", +} + + +def test_report_render_imports_without_engine() -> None: + code = "\n".join([ + "import sys", + "import scripts.report_render.envelope", + "import scripts.report_render.text", + "import scripts.report_render.sections_misc", + "banned = [name for name in sys.modules if name == 'jyotish_engine' or name.startswith('jyotish_engine.') or name == 'scripts.jyotish_engine' or name.startswith('scripts.jyotish_engine.')]", + "assert banned == []", + ]) + env = dict(os.environ) + env["PYTHONPATH"] = str(ROOT) + env["PYTHONHASHSEED"] = "0" + completed = subprocess.run( + [sys.executable, "-c", code], + cwd=ROOT, + env=env, + text=True, + capture_output=True, + check=False, + ) + assert completed.returncode == 0, completed.stderr + + +def test_planet_label_and_signs_match_engine_literals() -> None: + from scripts.jyotish_engine import SIGNS, SIGNS_CN + from scripts.report_render import text as moved + + assert list(moved.PLANET_LABEL_CN) == list(ORIGINAL_PLANET_LABEL_CN) + for key, value in ORIGINAL_PLANET_LABEL_CN.items(): + assert moved.PLANET_LABEL_CN[key] == value + assert moved.SIGNS == SIGNS + assert moved.SIGNS_CN == SIGNS_CN + + +def _first(packet, kind: str): + stack = [packet] + while stack: + current = stack.pop() + if kind == "bool" and isinstance(current, bool): + return current + if kind == "str" and isinstance(current, str) and current.strip(): + return current + if kind == "list" and isinstance(current, list) and current: + return current + if isinstance(current, dict): + stack.extend(current.values()) + elif isinstance(current, (list, tuple)): + stack.extend(list(current)) + raise AssertionError(kind) + + +def test_five_moved_helpers_match_engine_on_real_packet() -> None: + from scripts.jyotish_engine import ( + _bool_text, + _envelope_list, + _humanize_reader_token, + _md_cell, + _unwrap, + build_professional_report_reference_packet, + cmd_full_reading, + render_pl9_markdown, + ) + from scripts.professional_report_reference import _export_args + from scripts.report_render.envelope import _bool_text as moved_bool + from scripts.report_render.envelope import _envelope_list as moved_list + from scripts.report_render.envelope import _unwrap as moved_unwrap + from scripts.report_render.text import _humanize_reader_token as moved_token + from scripts.report_render.text import _md_cell as moved_cell + from tests.test_report_reader_main import FICTIONAL + + reading = cmd_full_reading(SimpleNamespace(**FICTIONAL)) + packet = build_professional_report_reference_packet(reading, _export_args(dict(FICTIONAL)), []) + rendered = render_pl9_markdown(packet) + assert isinstance(rendered, str) and len(rendered) > 100 + flag = _first(packet, "bool") + label = _first(packet, "str") + rows = _first(packet, "list") + birth = packet.get("birth_info") + assert _bool_text(flag) == moved_bool(flag) + assert _md_cell(label) == moved_cell(label) + assert _unwrap(birth) == moved_unwrap(birth) + assert _humanize_reader_token("Sun") == moved_token("Sun") + assert _humanize_reader_token("Aries") == moved_token("Aries") + assert _envelope_list(rows) == moved_list(rows) + assert _bool_text is moved_bool + assert _md_cell is moved_cell + assert _unwrap is moved_unwrap + assert _humanize_reader_token is moved_token + assert _envelope_list is moved_list