拆分报告渲染函数的第一阶段,把 34 个无外层依赖的辅助函数搬到 report_render。

同一份真实盘包在屏蔽与不屏蔽 PyJHora 下新旧渲染 sha 都一致,各 24 项且退出码 0。指定 pytest 233 通过、3 跳过。

Co-Authored-By: Grok (agent loop)
This commit is contained in:
jesse-ux
2026-10-15 20:59:11 +08:00
co-authored by Grok (agent loop)
parent 32b238cf86
commit dbba127e71
8 changed files with 1050 additions and 412 deletions
+1
View File
@@ -0,0 +1 @@
"""Helpers moved unchanged out of render_pl9_markdown (phase 1)."""
+49
View File
@@ -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
+127
View File
@@ -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。"
)
+255
View File
@@ -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)