th commited on
Commit
5424d2c
·
1 Parent(s): 0649308

feat: 四层schema追加(meta/strength/timing/evidence) — /api/calculate-vedic-chart

Browse files

只追加新字段,旧字段一个不改(已验证old_keys全部保留)。

- evidence.py:build_meta/build_strength/build_timing/build_evidence
- evidence部分是sisi-astrology前端generate-vedic-report.ts里formatEvidence()的
Python移植版(已于2026-07-12在vedic-astrology skill的local_vedic_report.py里
验证过输出正确),现在下沉到API侧生成——这是Phase 3的核心动机:消费方(CF Function)
以后可以优先读API返回的evidence,不用自己每次重新拼一份
- strength统一shadbala/sav_by_house/dignity/combustion(不重复计算,只是换个
位置引用,跟bazi/ziwei的schema形状保持一致)
- timing展开mahadasha全序列+当前mahadasha内的antardasha逐条(月级颗粒度)
- 已用小鱼盘验证:土星Shadbala 132.6%、当前Moon-Venus antardasha、
Atmakaraka/DOUBLE-TRANSIT等关键证据行全部正确;
test_golden_charts.py新增1条,6项全过(需在_vedic-spike-venv里跑,
已装fastapi/kerykeion/python-dotenv补全依赖)

🦊 Generated with Sisi

Files changed (3) hide show
  1. app.py +9 -0
  2. evidence.py +227 -0
  3. vedic/test_golden_charts.py +31 -0
app.py CHANGED
@@ -16,6 +16,7 @@ import io
16
  import re
17
  import os
18
  import secrets
 
19
 
20
  # ===== Knowledge Base Extractor (Embedded) =====
21
  # Extracts professional aspect interpretations from markdown knowledge base
@@ -1172,6 +1173,14 @@ def calculate_vedic_chart(req: BirthRequest, api_key: str = Depends(verify_api_k
1172
  finally:
1173
  _sys.stdout = _real_stdout
1174
 
 
 
 
 
 
 
 
 
1175
  logger.info(f"✅ Vedic chart success for {req.name}")
1176
  return {"success": True, "data": _vedic_jsonable(chart)}
1177
 
 
16
  import re
17
  import os
18
  import secrets
19
+ from evidence import build_meta, build_strength, build_timing, build_evidence
20
 
21
  # ===== Knowledge Base Extractor (Embedded) =====
22
  # Extracts professional aspect interpretations from markdown knowledge base
 
1173
  finally:
1174
  _sys.stdout = _real_stdout
1175
 
1176
+ # 2026-07-12 Phase 3: 追加 meta/strength/timing/evidence 四层字段,旧字段一个不改。
1177
+ # evidence是CF Function里generate-vedic-report.ts的formatEvidence()的Python移植版,
1178
+ # 下沉到API侧生成,消费方(CF Function)优先读这里返回的evidence,不用自己再拼一份。
1179
+ chart['meta'] = build_meta()
1180
+ chart['strength'] = build_strength(chart)
1181
+ chart['timing'] = build_timing(chart)
1182
+ chart['evidence'] = build_evidence(chart)
1183
+
1184
  logger.info(f"✅ Vedic chart success for {req.name}")
1185
  return {"success": True, "data": _vedic_jsonable(chart)}
1186
 
evidence.py ADDED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 印度占星四层Schema组装:meta / strength / timing / evidence。
3
+
4
+ 只追加新字段,不改动任何旧字段——见 API_UNIFICATION_PLAN.md §3。
5
+ 调用方:app.py 的 /api/calculate-vedic-chart endpoint。
6
+
7
+ evidence部分是 sisi-astrology 前端 generate-vedic-report.ts 里 formatEvidence()
8
+ 的Python移植版(已于2026-07-12用小鱼真实生辰在 vedic-astrology skill 的
9
+ local_vedic_report.py 里验证过输出正确)——现在把它下沉到API侧生成,而不是
10
+ 让每个消费方(CF Function)各自重新拼一份,这是Phase 3的核心动机。
11
+ """
12
+
13
+ from typing import Any, Dict, List, Optional
14
+
15
+ GRAHA_ORDER = ["Lagna", "Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu"]
16
+
17
+
18
+ def build_meta() -> Dict[str, str]:
19
+ return {"engine": "pyjhora", "schema_version": "1.0"}
20
+
21
+
22
+ def build_strength(chart: Dict[str, Any]) -> Dict[str, Any]:
23
+ """力量层:把已经算好的shadbala/SAV/dignity/combustion归拢成统一的strength字段,
24
+ 跟bazi/ziwei的schema保持形状一致——不重复计算,只是换个位置引用。"""
25
+ return {
26
+ "shadbala": chart.get("shadbala"),
27
+ "sav_by_house": chart.get("sav_by_house"),
28
+ "dignity": chart.get("dignity"),
29
+ "combustion": chart.get("combustion"),
30
+ }
31
+
32
+
33
+ def build_timing(chart: Dict[str, Any]) -> List[Dict[str, Any]]:
34
+ """时间线:mahadasha全序列 + 当前mahadasha内的antardasha逐条展开(月级颗粒度)。"""
35
+ timing: List[Dict[str, Any]] = []
36
+ for d in chart.get("dashas") or []:
37
+ timing.append({
38
+ "layer": "mahadasha",
39
+ "label": d.get("planet", ""),
40
+ "label_en": d.get("planet", ""),
41
+ "start": d.get("start"),
42
+ "end": d.get("end"),
43
+ "is_current": bool(d.get("is_current", False)),
44
+ "flags": [],
45
+ })
46
+ if d.get("is_current"):
47
+ for a in d.get("antardashas") or []:
48
+ timing.append({
49
+ "layer": "antardasha",
50
+ "label": f"{d.get('planet','')}-{a.get('planet','')}",
51
+ "label_en": f"{d.get('planet','')}-{a.get('planet','')}",
52
+ "start": a.get("start"),
53
+ "end": a.get("end"),
54
+ "is_current": bool(a.get("is_current", False)),
55
+ "flags": [],
56
+ })
57
+ return timing
58
+
59
+
60
+ def build_evidence(v: Dict[str, Any]) -> str:
61
+ """结论性事实 + 括号内数据来源,AI可以直接引用出证据链。"""
62
+ lines: List[str] = []
63
+ hl = v.get("house_lords") or {}
64
+
65
+ def lord(h):
66
+ return hl.get(str(h)) or hl.get(h)
67
+
68
+ def dignity_of(p):
69
+ d = (v.get("dignity") or {}).get(p)
70
+ return d.get("basic") if d else "—"
71
+
72
+ l1 = lord(1)
73
+ if l1:
74
+ lines.append(
75
+ f"- Lagna (1st) lord: {l1['sign']} ruled by {l1['lord']}, sitting in house "
76
+ f"{l1['lord_house']} (dignity: {dignity_of(l1['lord'])})."
77
+ )
78
+ all_lords = []
79
+ for h in range(1, 13):
80
+ L = lord(h)
81
+ if L and L.get("lord_house"):
82
+ all_lords.append(f"H{h}:{L['lord']}→H{L['lord_house']}")
83
+ if all_lords:
84
+ lines.append(
85
+ f"- All house lords (H=house, →=sits in): {' '.join(all_lords)}. "
86
+ f"For yoga audit: kendra lords 1/4/7/10 + trikona lords 1/5/9 linked = Raja Yoga; "
87
+ f"2/5/9/11 lords linked = Dhana Yoga; 6/8/12 lords sitting in each other's houses = Vipareeta Raja."
88
+ )
89
+
90
+ ak = (v.get("karakas") or {}).get("7k", [None])[0]
91
+ if ak:
92
+ lines.append(f"- Atmakaraka (soul significator, highest-degree planet): {ak[1]} — the central karmic lesson of this life.")
93
+
94
+ d9 = v.get("d9")
95
+ if isinstance(d9, dict):
96
+ vargottama = v.get("vargottama") or {}
97
+ parts = [f"{k}→{d9[k][0]}{'★' if vargottama.get(k) else ''}" for k in GRAHA_ORDER if k in d9]
98
+ if parts:
99
+ lines.append(f"- D9 Navamsa (marriage, inner strength, latter half of life; ★=Vargottama=doubly strong): {', '.join(parts)}.")
100
+ d10 = v.get("d10")
101
+ if isinstance(d10, dict):
102
+ parts = [f"{k}→{d10[k][0]}" for k in GRAHA_ORDER if k in d10]
103
+ if parts:
104
+ lines.append(f"- D10 Dasamsa (career, status, profession): {', '.join(parts)}.")
105
+
106
+ sav_h = v.get("sav_by_house")
107
+ if isinstance(sav_h, dict):
108
+ entries = sorted([{"h": int(h), "val": (sav_h[h] or {}).get("value", 0)} for h in sav_h.keys()], key=lambda e: e["h"])
109
+ if entries:
110
+ sav_str = " ".join(f"H{e['h']}:{e['val']}" for e in entries)
111
+ by_val = sorted(entries, key=lambda e: e["val"], reverse=True)
112
+ strong = ", ".join(f"H{e['h']}({e['val']})" for e in by_val[:2])
113
+ weak = ", ".join(f"H{e['h']}({e['val']})" for e in by_val[-2:])
114
+ lines.append(f"- Ashtakavarga SAV per house (life-area strength, ~28 = average): {sav_str}. Strongest areas: {strong}; weakest: {weak}.")
115
+
116
+ comb = v.get("combustion") or {}
117
+ comb_str = ", ".join(f"{p} ({comb[p].get('distance', 0):.1f}° from Sun)" for p in comb) if comb else "none"
118
+ lines.append(f"- Combust planets (weakened by the Sun's glare): {comb_str}.")
119
+
120
+ dign = v.get("dignity") or {}
121
+ hard = [p for p in dign if dign[p].get("basic") in ("debilitated", "enemy", "great_enemy") or dign[p].get("compound") in ("debilitated", "enemy", "great_enemy")]
122
+ if hard:
123
+ lines.append(f"- Planets under strain (enemy/debilitated): {', '.join(hard)}.")
124
+
125
+ dashas = v.get("dashas") or []
126
+ if dashas:
127
+ maha = "; ".join(f"{d['planet']} {d['start']}–{d['end']}{' (CURRENT)' if d.get('is_current') else ''}" for d in dashas)
128
+ lines.append(f"- Vimshottari Mahadasha timeline: {maha}.")
129
+ cur = next((d for d in dashas if d.get("is_current")), None)
130
+ if cur and cur.get("antardashas"):
131
+ ad = "; ".join(f"{a['planet']} {a['start']}–{a['end']}{' (CURRENT)' if a.get('is_current') else ''}" for a in cur["antardashas"])
132
+ lines.append(f"- Antardashas inside the current {cur['planet']} Mahadasha (use these for 3–5 year windows): {ad}.")
133
+
134
+ t = v.get("transits") or {}
135
+ tparts = []
136
+ if t.get("Saturn"):
137
+ tparts.append(f"Saturn transiting {t['Saturn']['sign']} (your house {t['Saturn']['house']})")
138
+ if t.get("Jupiter"):
139
+ tparts.append(f"Jupiter transiting {t['Jupiter']['sign']} (your house {t['Jupiter']['house']})")
140
+ if t.get("sade_sati"):
141
+ tparts.append(f"Sade Sati status: {t['sade_sati']}")
142
+ if tparts:
143
+ lines.append(f"- Gochar (current transits): {'; '.join(tparts)}.")
144
+
145
+ sb = v.get("shadbala") or {}
146
+ if sb and not sb.get("error"):
147
+ parts = []
148
+ for p in sb:
149
+ s = sb[p]
150
+ if s.get("total_rupas") is None:
151
+ continue
152
+ tag = " WEAK" if s.get("classification") == "weak" else (" STRONG" if s.get("classification") == "strong" else "")
153
+ parts.append(f"{p} {s['total_rupas']}R/{s['strength_pct']}%{tag}")
154
+ if parts:
155
+ lines.append(
156
+ f"- Shadbala six-fold strength (R=rupas, %=vs BPHS required minimum; "
157
+ f"<100% = under-powered to DELIVER results even if well-placed): {', '.join(parts)}."
158
+ )
159
+
160
+ aspects = v.get("aspects") or []
161
+ tight = [f"{a['p1']}+{a['p2']} ({a['orb']}°)" for a in aspects if a.get("type") == "合相" and isinstance(a.get("orb"), (int, float)) and a["orb"] <= 3]
162
+ if tight:
163
+ lines.append(
164
+ f"- Tight conjunctions ≤3° (BUNDLED: the two planets' agendas are tied together — "
165
+ f"benefits and costs cannot be separated; always state both sides): {', '.join(tight)}."
166
+ )
167
+
168
+ lag_deg = (v.get("lagna") or {}).get("degree")
169
+ if isinstance(lag_deg, (int, float)) and (lag_deg < 1 or lag_deg > 29):
170
+ lines.append(
171
+ f"- Lagna at {lag_deg:.2f}° — SANDHI (junction degree): identity carries the adjacent "
172
+ f"sign's transitional flavor, and birth-time sensitivity is high (gently suggest time rectification)."
173
+ )
174
+
175
+ moon = (v.get("planets") or {}).get("Moon")
176
+ if moon and isinstance(moon.get("sign_idx"), int):
177
+ moon_idx = moon["sign_idx"]
178
+ flanked = any(
179
+ (pd["sign_idx"] - moon_idx + 12) % 12 in (1, 11)
180
+ for p, pd in (v.get("planets") or {}).items() if p not in ("Moon", "Sun", "Rahu", "Ketu")
181
+ )
182
+ if not flanked:
183
+ lines.append(
184
+ "- Kemadruma indication: no planet flanks the Moon (2nd & 12th from Moon empty) — "
185
+ "thin emotional buffer, processes feelings alone. MUST check cancellation (planet in "
186
+ "kendra from Lagna/Moon, or Jupiter aspecting Moon) before asserting it."
187
+ )
188
+
189
+ mp = v.get("moon_phase")
190
+ if mp:
191
+ lines.append(f"- Moon phase: {'waxing (Shukla — benefic strength rising)' if mp.get('waxing') else 'waning (Krishna — benefic strength reduced)'}.")
192
+
193
+ gd = v.get("graha_drishti") or {}
194
+ if gd:
195
+ parts = []
196
+ for p in gd:
197
+ d = gd[p]
198
+ houses = "/H".join(str(h) for h in (d.get("houses") or []))
199
+ planets_on = f" [on {','.join(d['planets'])}]" if d.get("planets") else ""
200
+ parts.append(f"{p}→H{houses}{planets_on}")
201
+ if parts:
202
+ lines.append(f"- Graha drishti (classical vedic full aspects — use for affliction/support checks, NOT western orbs): {'; '.join(parts)}.")
203
+
204
+ tt = v.get("transit_timeline")
205
+ if isinstance(tt, dict):
206
+ def segs(arr):
207
+ return "; ".join(f"{s['sign']}(H{s['house']}) {s['start']}→{s['end']}" for s in arr)
208
+ if tt.get("saturn_segments"):
209
+ lines.append(f"- Saturn transit timeline next 5y (retrograde dips included — a sign re-entry is a revisit, not an error): {segs(tt['saturn_segments'])}.")
210
+ if tt.get("jupiter_segments"):
211
+ lines.append(f"- Jupiter transit timeline next 5y: {segs(tt['jupiter_segments'])}.")
212
+ if tt.get("sade_sati_windows"):
213
+ ssw = "; ".join(f"{w['phase']} {w['start']}→{w['end']}" for w in tt["sade_sati_windows"])
214
+ lines.append(f"- Sade Sati windows within 5y: {ssw}.")
215
+ dtw = tt.get("double_transit_windows") or []
216
+ hits = [w for w in dtw if w.get("houses")]
217
+ if hits:
218
+ hit_parts = []
219
+ for w in hits:
220
+ hstr = "H" + "/H".join(str(h) for h in w["houses"])
221
+ hit_parts.append(f"{hstr} {w['start']}→{w['end']}")
222
+ lines.append(
223
+ f"- DOUBLE-TRANSIT windows (Saturn AND Jupiter both activating the same house — "
224
+ f"the classic event trigger WHEN the running dasha also supports that house): {'; '.join(hit_parts)}."
225
+ )
226
+
227
+ return "\n".join(lines)
vedic/test_golden_charts.py CHANGED
@@ -73,3 +73,34 @@ def test_current_dasha(xiaoyu_chart):
73
  assert current["planet"] == "Moon"
74
  assert current["start"] == "2017-05"
75
  assert current["end"] == "2027-05"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  assert current["planet"] == "Moon"
74
  assert current["start"] == "2017-05"
75
  assert current["end"] == "2027-05"
76
+
77
+
78
+ def test_four_layer_schema_via_app_endpoint():
79
+ """
80
+ 2026-07-12 Phase 3新增:/api/calculate-vedic-chart 追加 meta/strength/timing/evidence,
81
+ evidence是CF Function里formatEvidence()的Python移植版,下沉到API侧生成。
82
+ 这里直接调FastAPI handler(需要fastapi+kerykeion,跟pyswisseph装在同一个venv里)。
83
+ """
84
+ import sys, os
85
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # sisi-astrology-api/ 根目录
86
+ from app import calculate_vedic_chart, BirthRequest
87
+
88
+ req = BirthRequest(
89
+ name="Xiaoyu", date="1981-10-04", time="20:40", location="Fuzhou",
90
+ latitude=26.0745, longitude=119.2965, timezone="Asia/Shanghai",
91
+ )
92
+ resp = calculate_vedic_chart(req, api_key="bypass")
93
+ d = resp["data"]
94
+
95
+ assert d["meta"]["schema_version"] == "1.0"
96
+ assert d["strength"]["shadbala"]["Saturn"]["strength_pct"] == pytest.approx(132.5, abs=1.0)
97
+
98
+ current_maha = next(t for t in d["timing"] if t["layer"] == "mahadasha" and t["is_current"])
99
+ assert current_maha["label"] == "Moon"
100
+ current_antar = next(t for t in d["timing"] if t["layer"] == "antardasha" and t["is_current"])
101
+ assert current_antar["label"] == "Moon-Venus"
102
+
103
+ evidence = d["evidence"]
104
+ assert "Atmakaraka" in evidence and "Moon" in evidence
105
+ assert "Shadbala" in evidence
106
+ assert "DOUBLE-TRANSIT" in evidence