th commited on
Commit
fefbb42
·
1 Parent(s): be3b8cb

fix(reading): add multi-chart evidence guardrails

Browse files
ai_interpreter.py CHANGED
@@ -1053,31 +1053,71 @@ class AIInterpreter:
1053
  has_guansha = '正官' in ten_gods or '七杀' in ten_gods
1054
 
1055
  if has_guansha:
1056
- # 推断官煞类型:正官格、七杀格、官杀混杂等
 
 
 
 
1057
  guansha_type = None
1058
  if '正官' in ten_gods and '七杀' not in ten_gods:
1059
  guansha_type = '正官格'
1060
  elif '七杀' in ten_gods and '正官' not in ten_gods:
1061
  guansha_type = '七杀格'
1062
  elif '正官' in ten_gods and '七杀' in ten_gods:
1063
- guansha_type = '官杀混杂'
1064
 
1065
  if guansha_type:
1066
  guansha_info = get_guansha_analysis(guansha_type)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1067
  if guansha_info:
1068
  interpretation += "**【关煞分析】**\n"
1069
- interpretation += f"**官煞性质:**{guansha_info.get('characteristics', '')}\n"
1070
- interpretation += f"**制化方法:**{guansha_info.get('control_method', '')}\n"
1071
- interpretation += f"**实战应用:**{guansha_info.get('practical_use', '')}\n\n"
1072
-
1073
- # 🔥 3. 疾病分析 (get_disease_by_wuxing)
1074
- day_master_element = self.day_master_element
 
 
 
 
 
 
 
 
 
 
 
1075
  disease_info = get_disease_by_wuxing(day_master_element)
1076
  if disease_info:
1077
- interpretation += "**【健康隐患(滴天髓疾病篇)】**\n"
1078
- interpretation += f"**易患疾病:**{disease_info.get('diseases', '')}\n"
1079
- interpretation += f"**病因分析:**{disease_info.get('causes', '')}\n"
1080
- interpretation += f"**调养建议:**{disease_info.get('remedies', '')}\n\n"
 
 
1081
 
1082
  # 如果没有任何分析结果,返回空
1083
  #
@@ -1703,27 +1743,79 @@ class AIInterpreter:
1703
 
1704
  # ========== 神煞解读 ==========
1705
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1706
  def _interpret_auspicious_spirits(self) -> str:
1707
  """吉神解读"""
1708
  if not self.shensha:
1709
  return ''
1710
 
1711
- auspicious = self.shensha.get('auspicious_gods', [])
1712
 
1713
  if not auspicious:
1714
- return '你的八字中吉神较少,需要多积德行善、广结善缘。'
1715
 
1716
  interpretation = f"**吉神数量:**{len(auspicious)}个\n\n"
1717
 
1718
- for god in auspicious[:5]: # 最多显示5个
1719
  name = god.get('name', '')
1720
  name_en = god.get('name_en', '')
1721
- effect = god.get('effect', '')
1722
- description = god.get('description', '')
1723
 
1724
  interpretation += f"**{name}({name_en})**\n"
1725
- interpretation += f"作用:{self._phase_safe_text(effect)}\n"
1726
- interpretation += f"{self._phase_safe_text(description[:100])}...\n\n"
 
 
 
1727
 
1728
  return interpretation
1729
 
@@ -1732,27 +1824,26 @@ class AIInterpreter:
1732
  if not self.shensha:
1733
  return ''
1734
 
1735
- inauspicious = self.shensha.get('inauspicious_gods', [])
1736
 
1737
  if not inauspicious:
1738
  return '当前可见的凶煞信号较少;仍应以现实风险和健康建议为准。'
1739
 
1740
  interpretation = f"**凶煞数量:**{len(inauspicious)}个\n\n"
1741
 
1742
- for god in inauspicious[:5]: # 最多显示5个
1743
  name = god.get('name', '')
1744
  name_en = god.get('name_en', '')
1745
- effect = god.get('effect', '')
1746
- description = god.get('description', '')
1747
- remedy = god.get('remedy', '')
1748
 
1749
  interpretation += f"**{name}({name_en})**\n"
1750
- interpretation += f"影响:{self._phase_safe_text(effect)}\n"
1751
- interpretation += f"{self._phase_safe_text(description[:100])}...\n"
1752
- if remedy:
1753
- interpretation += f"化解方法:{self._phase_safe_text(remedy)}\n"
1754
  interpretation += "\n"
1755
 
 
 
1756
  return interpretation
1757
 
1758
  # ========== 人生规划 ==========
 
1053
  has_guansha = '正官' in ten_gods or '七杀' in ten_gods
1054
 
1055
  if has_guansha:
1056
+ # 推断官煞类型:正官格、七杀格、官杀混杂等。
1057
+ # ``ditianshi_complete`` 的知识库键是“官杀混杂格”,
1058
+ # 且字段名是 condition/treatment/outcome;旧代码传入
1059
+ # “官杀混杂”并读取另一套字段,导致 6/9 张有官杀的盘
1060
+ # 静默没有正文。
1061
  guansha_type = None
1062
  if '正官' in ten_gods and '七杀' not in ten_gods:
1063
  guansha_type = '正官格'
1064
  elif '七杀' in ten_gods and '正官' not in ten_gods:
1065
  guansha_type = '七杀格'
1066
  elif '正官' in ten_gods and '七杀' in ten_gods:
1067
+ guansha_type = '官杀混杂格'
1068
 
1069
  if guansha_type:
1070
  guansha_info = get_guansha_analysis(guansha_type)
1071
+ # The complete classical table has specialist entries
1072
+ # for mixed/treated patterns, but not a bare 正官格 or
1073
+ # 七杀格 key. Keep those two outputs as an explicit,
1074
+ # non-deterministic structural prompt rather than
1075
+ # silently dropping the chapter.
1076
+ if not guansha_info:
1077
+ guansha_info = {
1078
+ 'condition': (
1079
+ '命局出现正官信号;仍需结合月令、根气、印财食伤与日主强弱,'
1080
+ '不能只凭一颗官星定格。'
1081
+ if guansha_type == '正官格' else
1082
+ '命局出现七杀信号;仍需结合月令、根气、制化与日主强弱,'
1083
+ '不能只凭一颗七杀定吉凶。'
1084
+ ),
1085
+ 'treatment': (
1086
+ '先看官杀是否清纯有根,再看印比护身、食伤制化与岁运变化;'
1087
+ '把它当作观察规则/压力/责任的入口。'
1088
+ ),
1089
+ 'outcome': (
1090
+ '这是传统理论的结构提示,不是职位、财富或事件保证;'
1091
+ '用现实中的组织关系、责任压力与反馈核对。'
1092
+ ),
1093
+ }
1094
  if guansha_info:
1095
  interpretation += "**【关煞分析】**\n"
1096
+ interpretation += f"**官煞性质:**{guansha_info.get('characteristics') or guansha_info.get('condition', '')}\n"
1097
+ interpretation += f"**制化方法:**{guansha_info.get('control_method') or guansha_info.get('treatment', '')}\n"
1098
+ interpretation += f"**实战应用:**{guansha_info.get('practical_use') or guansha_info.get('outcome', '')}\n\n"
1099
+
1100
+ # 🔥 3. 五行身心象意提示 (get_disease_by_wuxing)
1101
+ # BaZiCalculator stores the day-master element in English
1102
+ # (``wood``/``fire``/...), while the classical knowledge table is
1103
+ # keyed by Chinese names (``木``/``火``/...). Passing the English
1104
+ # value silently returned an empty section for every chart.
1105
+ element_names = {
1106
+ 'wood': '木',
1107
+ 'fire': '火',
1108
+ 'earth': '土',
1109
+ 'metal': '金',
1110
+ 'water': '水',
1111
+ }
1112
+ day_master_element = element_names.get(self.day_master_element, self.day_master_element)
1113
  disease_info = get_disease_by_wuxing(day_master_element)
1114
  if disease_info:
1115
+ interpretation += "**【传统身心象意提示】**\n"
1116
+ interpretation += f"**传统对应部位:**{disease_info.get('organs', '')}\n"
1117
+ interpretation += f"**可能敏感主题:**{disease_info.get('diseases', '')}\n"
1118
+ interpretation += f"**命理象意解释:**{disease_info.get('causes', '')}\n"
1119
+ interpretation += f"**日常保养提醒:**{disease_info.get('remedies', '')}\n"
1120
+ interpretation += "以上是传统象意提示,不是医学诊断;如有症状请以合格医疗人员的判断为准。\n\n"
1121
 
1122
  # 如果没有任何分析结果,返回空
1123
  #
 
1743
 
1744
  # ========== 神煞解读 ==========
1745
 
1746
+ @staticmethod
1747
+ def _spirit_theme(name: str, auspicious: bool) -> str:
1748
+ """Return a bounded, reader-facing theme instead of raw old断语."""
1749
+ themes = {
1750
+ '天乙贵人': '支持网络、协作与困难中的转圜',
1751
+ '红鸾': '关系连接与表达好感的机会',
1752
+ '天喜': '关系连接与共同庆祝的机会',
1753
+ '禄神': '收入、资源管理与稳定回报',
1754
+ '福星': '顺势感、支持感与资源出现',
1755
+ '金舆': '物质资源、责任角色与生活安排',
1756
+ '太极贵人': '学习、钻研与独特方法',
1757
+ '学堂': '学习、表达与知识传递',
1758
+ '国印': '责任、组织角色与影响力',
1759
+ '羊刃': '行动强度、边界与冲动管理',
1760
+ '空亡': '不确定性、落空感与计划复核',
1761
+ '灾煞': '安全意识、意外预案与风险边界',
1762
+ '白虎': '高强度、工具或出行场景的安全边界',
1763
+ '孤辰': '独处需求、关系边界与沟通方式',
1764
+ '寡宿': '独处需求、亲密关系节奏与支持',
1765
+ '元辰': '误解、反复与信息核对',
1766
+ '阴阳差错': '关系沟通、期待差异与共同规则',
1767
+ '十恶大败': '财务边界、承诺核对与风险复盘',
1768
+ '魁罡': '强势表达、责任承担与柔性协作',
1769
+ '六厄': '身心节奏、日常照护与专业咨询边界',
1770
+ '勾煞': '合同、纠纷与沟通留痕',
1771
+ '绞煞': '牵连、边界与问题拆解',
1772
+ }
1773
+ return themes.get(name, '传统神煞名目仅作辅助提示,以现实中可观察的行为和反馈为准')
1774
+
1775
+ @staticmethod
1776
+ def _unique_spirits(items: Any) -> List[Dict[str, Any]]:
1777
+ """按神煞名称去重,避免同一神煞在四柱重复放大。"""
1778
+ def strength(item: Dict[str, Any]) -> float:
1779
+ try:
1780
+ return float(item.get('strength', 0) or 0)
1781
+ except (TypeError, ValueError):
1782
+ return 0.0
1783
+
1784
+ unique: Dict[str, Dict[str, Any]] = {}
1785
+ for item in items or []:
1786
+ if not isinstance(item, dict):
1787
+ continue
1788
+ name = str(item.get('name') or '').strip()
1789
+ if not name:
1790
+ continue
1791
+ previous = unique.get(name)
1792
+ if previous is None or strength(item) > strength(previous):
1793
+ unique[name] = item
1794
+ return sorted(unique.values(), key=lambda item: (-strength(item), item.get('name', '')))
1795
+
1796
  def _interpret_auspicious_spirits(self) -> str:
1797
  """吉神解读"""
1798
  if not self.shensha:
1799
  return ''
1800
 
1801
+ auspicious = self._unique_spirits(self.shensha.get('auspicious_gods', []))
1802
 
1803
  if not auspicious:
1804
+ return '当前未见明显吉神标记;这不等于缺少机会,仍以现实中的支持、行动与反馈为准。'
1805
 
1806
  interpretation = f"**吉神数量:**{len(auspicious)}个\n\n"
1807
 
1808
+ for god in auspicious[:3]:
1809
  name = god.get('name', '')
1810
  name_en = god.get('name_en', '')
1811
+ position = god.get('position_cn') or god.get('position') or '命局'
 
1812
 
1813
  interpretation += f"**{name}({name_en})**\n"
1814
+ interpretation += f"出现位置:{position}\n"
1815
+ interpretation += f"传统主题:{self._spirit_theme(name, True)}\n"
1816
+ interpretation += '现实行动:记录哪些支持或资源确实出现,再决定是否保留这套做法。\n\n'
1817
+
1818
+ interpretation += '神煞只作辅助象意,不单独决定吉凶;请用现实反馈复核。\n'
1819
 
1820
  return interpretation
1821
 
 
1824
  if not self.shensha:
1825
  return ''
1826
 
1827
+ inauspicious = self._unique_spirits(self.shensha.get('inauspicious_gods', []))
1828
 
1829
  if not inauspicious:
1830
  return '当前可见的凶煞信号较少;仍应以现实风险和健康建议为准。'
1831
 
1832
  interpretation = f"**凶煞数量:**{len(inauspicious)}个\n\n"
1833
 
1834
+ for god in inauspicious[:3]:
1835
  name = god.get('name', '')
1836
  name_en = god.get('name_en', '')
1837
+ position = god.get('position_cn') or god.get('position') or '命局'
 
 
1838
 
1839
  interpretation += f"**{name}({name_en})**\n"
1840
+ interpretation += f"出现位置:{position}\n"
1841
+ interpretation += f"传统主题:{self._spirit_theme(name, False)}\n"
1842
+ interpretation += '现实行动:把它当作一个风险核验点,保留边界与记录,必要时寻求合格专业支持。\n'
 
1843
  interpretation += "\n"
1844
 
1845
+ interpretation += '神煞只作辅助象意,不单独决定吉凶;不替代医疗、法律或财务判断。\n'
1846
+
1847
  return interpretation
1848
 
1849
  # ========== 人生规划 ==========
case_matcher.py CHANGED
@@ -129,26 +129,26 @@ FEMALE_PRINCIPLES = {
129
  "夫星得用": {
130
  "category": "marriage_good",
131
  "conditions": ["官星清透", "财生官旺", "印护夫星"],
132
- "interpretation": "婚姻运势良好,夫妻和谐,丈夫事业有成。",
133
- "advice": ["珍惜幸福", "支持丈夫但保持自我", "共同决策平等相处"]
134
  },
135
  "伤官见官": {
136
  "category": "marriage_bad",
137
  "conditions": ["伤官旺盛", "官星受克", "多合多冲"],
138
- "interpretation": "婚姻可能有波折,容易与伴侣产生矛盾。需要修炼情商。",
139
- "advice": ["学习沟通技巧", "控制情绪", "专注事业成就", "晚婚更佳"]
140
  },
141
  "食神吐秀": {
142
  "category": "children_good",
143
  "conditions": ["食神有力", "子女宫吉"],
144
- "interpretation": "子女运势好,教育成功,子女有出息。",
145
- "advice": ["重视教育投入", "培养孩子独立性", "不过度溺爱"]
146
  },
147
  "子女宫受克": {
148
  "category": "children_bad",
149
  "conditions": ["时柱受冲克", "食伤被制"],
150
- "interpretation": "与子女缘分可能较浅,或生育有困难。",
151
- "advice": ["现代可接受丁克", "考虑医学辅助或收养", "把精力投入其他领域"]
152
  }
153
  }
154
 
 
129
  "夫星得用": {
130
  "category": "marriage_good",
131
  "conditions": ["官星清透", "财生官旺", "印护夫星"],
132
+ "interpretation": "传统象意更强调伴侣关系中的协作与共同目标;它不证明配偶的职业、财富或婚姻结果。",
133
+ "advice": ["把期待说清楚", "支持彼此但保持自我", "共同决策、平等相处"]
134
  },
135
  "伤官见官": {
136
  "category": "marriage_bad",
137
  "conditions": ["伤官旺盛", "官星受克", "多合多冲"],
138
+ "interpretation": "传统象意提示沟通、边界与权责更容易出现张力;它不等于必然分手或婚姻失败。",
139
+ "advice": ["把分歧具体化", "先冷静再讨论", "为关系保留共同规则"]
140
  },
141
  "食神吐秀": {
142
  "category": "children_good",
143
  "conditions": ["食神有力", "子女宫吉"],
144
+ "interpretation": "传统象意更适合读作照护、教养与创造性亲子互动的资源;它不能证明子女数量、健康或成就。",
145
+ "advice": ["重视稳定陪伴", "培养彼此独立性", "不过度替对方做决定"]
146
  },
147
  "子女宫受克": {
148
  "category": "children_bad",
149
  "conditions": ["时柱受冲克", "食伤被制"],
150
+ "interpretation": "传统象意提示亲子角色、照护边界或家庭节奏需要更多沟通与支持;不能据此推断生育能力或是否生育。",
151
+ "advice": ["提前讨论照护边界", "需要时寻求专业支持", "把精力投入可验证的家庭安排"]
152
  }
153
  }
154
 
experiments/baseline_density.py CHANGED
@@ -17,10 +17,11 @@
17
  1. 必须用 BaZiCalculator.calculate_bazi_complete(include_dayun=True)。
18
  用 calculate_bazi() 拿不到大运→拿不到流年→风险年恒为 0,
19
  会得到一份「全 0」的漂亮结果。全 0 是 harness 坏了,不是发现。
20
- 2. 只能测 analyze_*() 公开方法。直接调 _analyze_*_years() 会绕过
21
- _focus_years 里的 [:3] 封顶,量到的是封顶前的数(约 40%),
22
- 跟用户实际读到的(25%)差一倍。
23
- 3. 分母是**当前大运窗口长度**,不是一辈子。用户读的是这 12 年。
 
24
 
25
  用法
26
  ----
@@ -37,6 +38,7 @@ from dayun_analyzer import DayunAnalyzer
37
  from marriage_analyzer import MarriageAnalyzer
38
  from official_analyzer import OfficialAnalyzer
39
  from legal_risk_analyzer import LegalRiskAnalyzer
 
40
 
41
  CHARTS = [('小鱼','1981-10-04','20:38','female'),('林磊','1983-12-13','13:15','female'),
42
  ('Jason','1978-10-30','10:00','male'),('C4','2008-07-31','21:00','female'),
@@ -44,10 +46,37 @@ CHARTS = [('小鱼','1981-10-04','20:38','female'),('林磊','1983-12-13','13:15
44
  ('C7','2013-05-13','03:40','male'),('C8','1990-01-08','06:30','male'),
45
  ('C9','1980-06-07','17:30','female')]
46
  NOW = 2026
 
47
 
48
  def ages(lst):
49
  return sorted({e['age'] for e in (lst or []) if isinstance(e, dict) and 'age' in e})
50
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  def collect(name, date, time, gender):
52
  chart = BaZiCalculator.calculate_bazi_complete(birth_date=date, birth_time=time,
53
  gender=gender, is_lunar=False, include_dayun=True, num_dayun=12)
@@ -66,11 +95,23 @@ def collect(name, date, time, gender):
66
  '官非风险': ages(off.get('official_risk_years')),
67
  '升迁': ages(off.get('promotion_years')),
68
  '灾劫': ages(leg.get('disaster_years'))}
 
 
 
 
 
 
 
 
 
 
 
69
  return {'name': name, 'by': int(date[:4]), 'age': age, 'win': (lo, hi),
70
- 'len': (hi - lo + 1) if lo else None, 'd': d}
 
 
71
 
72
  R = [collect(*c) for c in CHARTS]
73
- DOM = ['婚姻风险','官非风险','升迁','灾劫']
74
  print('='*86)
75
  print('Q1 · 上线后的实际密度(analyze_*() 公开方法输出)')
76
  print('='*86)
@@ -145,3 +186,21 @@ for x in DOM:
145
  f'落在窗口前半段的比例 {front:.0%} (n={len(pos)})')
146
  print()
147
  print('若随机分布,平均位置应≈0.50、前半段比例应≈50%。')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  1. 必须用 BaZiCalculator.calculate_bazi_complete(include_dayun=True)。
18
  用 calculate_bazi() 拿不到大运→拿不到流年→风险年恒为 0,
19
  会得到一份「全 0」的漂亮结果。全 0 是 harness 坏了,不是发现。
20
+ 2. 用户实际看到的是 analyze_*() 的公开输出;它会先裁到当前窗口,再按显著度
21
+ 封顶。基线必须同时报告「窗口内候选」与「最终呈现」,否则封顶会被误读成
22
+ 命理分辨力。
23
+ 3. 分母是**当前大运窗口长度**,不是一辈子。用户读的是这 12 年;窗口内
24
+ 被隐藏的候选不能悄悄算进命中率。
25
 
26
  用法
27
  ----
 
38
  from marriage_analyzer import MarriageAnalyzer
39
  from official_analyzer import OfficialAnalyzer
40
  from legal_risk_analyzer import LegalRiskAnalyzer
41
+ from fortune_window import filter_to_window
42
 
43
  CHARTS = [('小鱼','1981-10-04','20:38','female'),('林磊','1983-12-13','13:15','female'),
44
  ('Jason','1978-10-30','10:00','male'),('C4','2008-07-31','21:00','female'),
 
46
  ('C7','2013-05-13','03:40','male'),('C8','1990-01-08','06:30','male'),
47
  ('C9','1980-06-07','17:30','female')]
48
  NOW = 2026
49
+ DOM = ['婚姻风险','官非风险','升迁','灾劫']
50
 
51
  def ages(lst):
52
  return sorted({e['age'] for e in (lst or []) if isinstance(e, dict) and 'age' in e})
53
 
54
+
55
+ class _NoCapList(list):
56
+ """诊断用列表:让婚姻模块跳过展示层 [:3],保留候选供对照。
57
+
58
+ MarriageAnalyzer 的「全局最多 20 条 + 当前窗口最多 3 条」逻辑目前写在
59
+ 一个方法里,没有单独的 raw API。这个小替身只在本实验中使用,不能进入
60
+ 生产路径;结果应理解为“模块内部全局筛选之后、窗口展示封顶之前”的候选。
61
+ """
62
+
63
+ def __len__(self):
64
+ actual = super().__len__()
65
+ return min(actual, 3)
66
+
67
+
68
+ def marriage_candidates_before_window(chart, dayun):
69
+ """读取婚姻模块的窗口前候选,不改变生产方法。"""
70
+ import fortune_window
71
+
72
+ original_filter = fortune_window.filter_to_window
73
+ try:
74
+ fortune_window.filter_to_window = lambda entries, window, age_key='age': _NoCapList(entries or [])
75
+ diagnostic = MarriageAnalyzer(chart, dayun).analyze_marriage()
76
+ finally:
77
+ fortune_window.filter_to_window = original_filter
78
+ return (diagnostic.get('marriage_fortune_years') or {}).get('risk_years') or []
79
+
80
  def collect(name, date, time, gender):
81
  chart = BaZiCalculator.calculate_bazi_complete(birth_date=date, birth_time=time,
82
  gender=gender, is_lunar=False, include_dayun=True, num_dayun=12)
 
95
  '官非风险': ages(off.get('official_risk_years')),
96
  '升迁': ages(off.get('promotion_years')),
97
  '灾劫': ages(leg.get('disaster_years'))}
98
+ # 诊断层:公开输出以外,量出窗口内原本有多少候选。这里不把候选当作
99
+ # “预测命中”,只是为了识别 [:3] 封顶造成的密度变化。
100
+ off_analyzer = OfficialAnalyzer(chart, genju, dayun)
101
+ leg_analyzer = LegalRiskAnalyzer(chart, genju, dayun)
102
+ raw_entries = {
103
+ '婚姻风险': marriage_candidates_before_window(chart, dayun),
104
+ '官非风险': off_analyzer._analyze_official_risk_years(),
105
+ '升迁': off_analyzer._analyze_promotion_years(),
106
+ '灾劫': leg_analyzer._predict_disaster_years(),
107
+ }
108
+ raw_window = {x: ages(filter_to_window(raw_entries[x], (lo, hi))) for x in DOM}
109
  return {'name': name, 'by': int(date[:4]), 'age': age, 'win': (lo, hi),
110
+ 'len': (hi - lo + 1) if lo else None, 'd': d,
111
+ 'raw_window': raw_window,
112
+ 'raw_total': {x: sum(1 for _ in raw_entries[x]) for x in DOM}}
113
 
114
  R = [collect(*c) for c in CHARTS]
 
115
  print('='*86)
116
  print('Q1 · 上线后的实际密度(analyze_*() 公开方法输出)')
117
  print('='*86)
 
186
  f'落在窗口前半段的比例 {front:.0%} (n={len(pos)})')
187
  print()
188
  print('若随机分布,平均位置应≈0.50、前半段比例应≈50%。')
189
+
190
+ print()
191
+ print('='*86)
192
+ print('Q5 · 当前窗口候选 vs 实际呈现(上限效应)')
193
+ print('='*86)
194
+ print('候选 = 模块已完成自身规则筛选、但尚未套当前窗口展示上限的年份;')
195
+ print('呈现 = 用户实际拿到的年份。隐藏不是“被验证为错误”,只是展示层封顶。')
196
+ print()
197
+ print(f"{'领域':<10}{'窗��候选':>12}{'实际呈现':>12}{'隐藏':>10}{'呈现率':>10}")
198
+ for x in DOM:
199
+ raw = sum(len(r['raw_window'][x]) for r in R)
200
+ shown = sum(len(r['d'][x]) for r in R)
201
+ hidden = raw - shown
202
+ ratio = shown / raw if raw else 0
203
+ print(f"{x:<10}{raw:>12}{shown:>12}{hidden:>10}{ratio:>9.0%}")
204
+ print()
205
+ print('解释:若“窗口候选”远高于“实际呈现”,密度下降来自产品封顶而非规则本身;')
206
+ print('因此在获得真实事件追踪前,逐年模块只能作为阶段提示,不能宣称准确率。')
genju_analyzer.py CHANGED
@@ -683,14 +683,14 @@ class GenjuAnalyzer:
683
  base['reading'] = ''
684
  else:
685
  base['note'] = (f'两个流派在这张盘上不一致:格局法取「{geju_yongshen}」,'
686
- f'扶抑法主张「{fuyi}」。这是真实存在的分歧,不是计算错误,'
687
- f'我们不替你裁决。')
688
  base['reading'] = (
689
  f'这类分歧通常出现在「{label}但格局要求你输出」的命上。'
690
- f'可以这样读:格局法说的「{geju_yongshen}」是命局要你交出的成果形态,'
691
- f'扶抑法说的「{fuyi}」是你的承载力缺口。'
692
- f'两者相冲,意味着你要做的那件事,正在消耗你本来就不够的那部分——'
693
- f'不是不能做,是做之前得先把托底的结构搭起来。'
694
  )
695
  return base
696
 
 
683
  base['reading'] = ''
684
  else:
685
  base['note'] = (f'两个流派在这张盘上不一致:格局法取「{geju_yongshen}」,'
686
+ f'扶抑法主张「{fuyi}」。这是同一命盘的两种读法,不是计算错误;'
687
+ f'本报告把口径并列呈现,不把分歧冒充成事实裁决。')
688
  base['reading'] = (
689
  f'这类分歧通常出现在「{label}但格局要求你输出」的命上。'
690
+ f'可以这样读:格局法说的「{geju_yongshen}」描述命局要交出的成果形态,'
691
+ f'扶抑法说的「{fuyi}」提示承载力与托底资源。两者相冲时,'
692
+ f'更稳妥的工作假设是先把托底结构搭起来,再逐步验证输出是否可持续——'
693
+ f'不是不能做,而是先后顺序需要用现实反馈校准。'
694
  )
695
  return base
696
 
test_case_insights_guardrails.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """六亲/子女洞察的表达边界测试。
2
+
3
+ 当前数据不足以验证子女数量、健康、生育能力或“子女有出息”等结果,
4
+ 所以经典案例层只能交付关系主题与可行动的照护建议。
5
+ """
6
+
7
+ import os
8
+ import sys
9
+
10
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
11
+
12
+ from ai_interpreter import AIInterpreter
13
+ from bazi_calculator import BaZiCalculator
14
+ from dayun_analyzer import DayunAnalyzer
15
+ from genju_analyzer import GenjuAnalyzer
16
+ from shensha_analyzer import ShenshaAnalyzer
17
+
18
+
19
+ CASES = [
20
+ ('小鱼', '1981-10-04', '20:40', 'female'),
21
+ ('林磊', '1983-12-13', '13:15', 'female'),
22
+ ('C4', '2008-07-31', '21:00', 'female'),
23
+ ('C9', '1980-06-07', '17:30', 'female'),
24
+ ]
25
+
26
+
27
+ def test_child_and_marriage_case_copy_does_not_claim_outcomes():
28
+ banned = ('教育成功', '子女有出息', '生育有困难', '子女运势好')
29
+ for name, date, time, gender in CASES:
30
+ chart = BaZiCalculator.calculate_bazi_complete(
31
+ birth_date=date, birth_time=time, gender=gender,
32
+ is_lunar=False, include_dayun=True, num_dayun=12,
33
+ )
34
+ genju = GenjuAnalyzer(chart).analyze_genju()
35
+ dayun = DayunAnalyzer(chart, genju, current_age=2026 - int(date[:4])).analyze_all_dayun()
36
+ shensha = ShenshaAnalyzer(chart).analyze_all_shenshas()
37
+ text = AIInterpreter(chart, shensha, genju, dayun).generate_interpretation(
38
+ 'master', 'zh-CN'
39
+ )['sections']['case_insights']
40
+ assert not any(phrase in text for phrase in banned), f'{name}: 出现未验证六亲断言'
41
+ if '子女' in text or '配偶' in text or '伴侣' in text:
42
+ assert '不能证明' in text or '不等于' in text, f'{name}: 缺少六亲边界说明'
43
+
44
+
45
+ if __name__ == '__main__':
46
+ test_child_and_marriage_case_copy_does_not_claim_outcomes()
47
+ print('case insight guardrails: PASS')
test_master_multi_chart.py ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """大师版跨命盘契约测试。
2
+
3
+ 这组测试不判断哪一派命理“正确”,只锁产品承诺:大师层应把已实现的
4
+ 经典模块真正交付出来,而且不能因为某个知识表的键名不一致而静默消失。
5
+ """
6
+
7
+ import os
8
+ import sys
9
+
10
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
11
+
12
+ from ai_interpreter import AIInterpreter
13
+ from bazi_calculator import BaZiCalculator
14
+ from dayun_analyzer import DayunAnalyzer
15
+ from genju_analyzer import GenjuAnalyzer
16
+ from shensha_analyzer import ShenshaAnalyzer
17
+
18
+
19
+ CASES = [
20
+ ('小鱼', '1981-10-04', '20:40', 'female'),
21
+ ('林磊', '1983-12-13', '13:15', 'female'),
22
+ ('Jason', '1978-10-30', '10:00', 'male'),
23
+ ('C4', '2008-07-31', '21:00', 'female'),
24
+ ('C5', '2012-04-09', '07:30', 'male'),
25
+ ('C6', '2014-04-15', '16:30', 'male'),
26
+ ('C7', '2013-05-13', '03:40', 'male'),
27
+ ('C8', '1990-01-08', '06:30', 'male'),
28
+ ('C9', '1980-06-07', '17:30', 'female'),
29
+ ]
30
+
31
+
32
+ def _master(name, date, time, gender):
33
+ chart = BaZiCalculator.calculate_bazi_complete(
34
+ birth_date=date,
35
+ birth_time=time,
36
+ gender=gender,
37
+ is_lunar=False,
38
+ include_dayun=True,
39
+ num_dayun=12,
40
+ )
41
+ genju = GenjuAnalyzer(chart).analyze_genju()
42
+ dayun = DayunAnalyzer(chart, genju, current_age=2026 - int(date[:4])).analyze_all_dayun()
43
+ shensha = ShenshaAnalyzer(chart).analyze_all_shenshas()
44
+ return AIInterpreter(chart, shensha, genju, dayun).generate_interpretation('master', 'zh-CN')
45
+
46
+
47
+ def test_master_sections_are_non_empty_across_reference_charts():
48
+ """每张参考盘都应真正拿到经典深层洞察,而不是只返回标题或直接省略。"""
49
+ required = {
50
+ 'personality', 'career', 'wealth', 'marriage', 'health', 'overall',
51
+ 'tiaohou_analysis', 'shishen_analysis', 'case_insights',
52
+ 'dayun_overview', 'life_stages', 'turning_points', 'ten_year_fortunes',
53
+ 'life_curve_detailed', 'key_ages_analysis', 'life_planning',
54
+ 'ditianshi_insights',
55
+ }
56
+
57
+ for name, date, time, gender in CASES:
58
+ result = _master(name, date, time, gender)
59
+ sections = result['sections']
60
+ missing = required - set(sections)
61
+ assert not missing, f'{name}: 大师版缺少章节 {sorted(missing)}'
62
+ empty = [key for key in required if len(str(sections.get(key, '')).strip()) < 30]
63
+ assert not empty, f'{name}: 大师版章节为空壳 {empty}'
64
+ assert result['word_count'] > 2500, f'{name}: 大师版内容异常过短'
65
+
66
+
67
+ def test_master_health_symbolism_is_explicitly_non_diagnostic():
68
+ """经典疾病篇只作为传统象意提示,不应伪装成医学诊断。"""
69
+ for name, date, time, gender in CASES:
70
+ result = _master(name, date, time, gender)
71
+ text = result['sections']['ditianshi_insights']
72
+ assert '传统身心象意提示' in text, name
73
+ assert '不是医学诊断' in text, name
74
+
75
+
76
+ def test_guansha_branch_has_non_empty_evidence_when_present():
77
+ """官杀归一后,6/9 参考盘应真正拿到内容而不是空标题。
78
+
79
+ The classical table uses ``condition/treatment/outcome`` while the
80
+ renderer exposes reader-facing labels. This catches both the old
81
+ ``官杀混杂`` vs ``官杀混杂格`` key mismatch and empty-field regressions.
82
+ """
83
+ seen = 0
84
+ for name, date, time, gender in CASES:
85
+ result = _master(name, date, time, gender)
86
+ text = result['sections']['ditianshi_insights']
87
+ chart = BaZiCalculator.calculate_bazi_complete(
88
+ birth_date=date, birth_time=time, gender=gender,
89
+ is_lunar=False, include_dayun=True, num_dayun=12,
90
+ )
91
+ stats = GenjuAnalyzer(chart).analyze_genju()['ten_gods_stats']
92
+ expected = '正官' in stats or '七杀' in stats
93
+ present = '**【关煞分析】**' in text
94
+ assert present == expected, f'{name}: 官杀存在性与章节输出不一致'
95
+ if present:
96
+ seen += 1
97
+ for label in ('官煞性质', '制化方法', '实战应用'):
98
+ line = next((ln for ln in text.splitlines() if ln.startswith(f'**{label}:**')), '')
99
+ assert len(line) > len(label) + 6, f'{name}: {label} 为空壳'
100
+ assert seen == 6, f'参考盘官杀章节数量漂移:{seen}/9'
101
+
102
+
103
+ def test_spirit_sections_are_deduped_and_boundaried():
104
+ """神煞只交付位置+主题+核验动作,不把旧断语当成结果。"""
105
+ banned = (
106
+ '子女发达', '一生富贵', '一生多灾多难', '血光、凶灾、手术',
107
+ '逢凶化吉', '能掌实权', '学业有成',
108
+ )
109
+ for name, date, time, gender in CASES:
110
+ sections = _master(name, date, time, gender)['sections']
111
+ for key in ('auspicious_spirits', 'inauspicious_spirits'):
112
+ text = sections.get(key, '')
113
+ assert text, f'{name}: {key} 不应为空壳'
114
+ assert not any(phrase in text for phrase in banned), f'{name}: {key} 泄漏绝对断语'
115
+ assert '神煞只作辅助象意' in text, f'{name}: {key} 缺少边界说明'
116
+ names = [line for line in text.splitlines() if line.startswith('**') and '(' in line]
117
+ assert len(names) <= 3, f'{name}: {key} 未限制展示数量'
118
+
119
+
120
+ if __name__ == '__main__':
121
+ test_master_sections_are_non_empty_across_reference_charts()
122
+ test_master_health_symbolism_is_explicitly_non_diagnostic()
123
+ print('master multi-chart contract: PASS')
test_ten_gods_naming.py CHANGED
@@ -40,6 +40,16 @@ CASES = [
40
 
41
  FULL_NAMES = {'比肩', '劫财', '食神', '伤官', '正财', '偏财', '正官', '七杀', '正印', '偏印'}
42
  ABBREVS = {'比', '劫', '食', '伤', '财', '才', '官', '杀', '印', '枭'}
 
 
 
 
 
 
 
 
 
 
43
 
44
 
45
  def _stats(date, time, gender):
@@ -118,9 +128,13 @@ def test_normalisation_is_a_faithful_one_to_one_rename():
118
  raw[god] += 1
119
 
120
  mapping = GenjuAnalyzer._TEN_GOD_FULL_NAME
 
 
 
 
121
  expected = Counter()
122
  for abbrev, n in raw.items():
123
- expected[mapping.get(abbrev, abbrev)] += n
124
 
125
  actual = Counter(GenjuAnalyzer(chart).analyze_genju()['ten_gods_stats'])
126
  assert actual == expected, f'{name}: 归一后分布对不上\n 原始 {dict(raw)}\n 期望 {dict(expected)}\n 实际 {dict(actual)}'
 
40
 
41
  FULL_NAMES = {'比肩', '劫财', '食神', '伤官', '正财', '偏财', '正官', '七杀', '正印', '偏印'}
42
  ABBREVS = {'比', '劫', '食', '伤', '财', '才', '官', '杀', '印', '枭'}
43
+ # Independent oracle for the public naming contract. Do not derive this from
44
+ # GenjuAnalyzer: a bijective but semantically swapped production map must fail
45
+ # the test rather than redefining the expected answer.
46
+ EXPECTED_FULL_NAMES = {
47
+ '比': '比肩', '劫': '劫财',
48
+ '食': '食神', '伤': '伤官',
49
+ '财': '正财', '才': '偏财',
50
+ '官': '正官', '杀': '七杀',
51
+ '印': '正印', '枭': '偏印',
52
+ }
53
 
54
 
55
  def _stats(date, time, gender):
 
128
  raw[god] += 1
129
 
130
  mapping = GenjuAnalyzer._TEN_GOD_FULL_NAME
131
+ assert mapping == EXPECTED_FULL_NAMES, (
132
+ f'{name}: 简写到全称的语义映射发生漂移\n'
133
+ f' 期望 {EXPECTED_FULL_NAMES}\n 实际 {mapping}'
134
+ )
135
  expected = Counter()
136
  for abbrev, n in raw.items():
137
+ expected[EXPECTED_FULL_NAMES.get(abbrev, abbrev)] += n
138
 
139
  actual = Counter(GenjuAnalyzer(chart).analyze_genju()['ten_gods_stats'])
140
  assert actual == expected, f'{name}: 归一后分布对不上\n 原始 {dict(raw)}\n 期望 {dict(expected)}\n 实际 {dict(actual)}'