feat(synastry): store the upstream seven-item marriage match

Move the bde5d615 engine and full-synastry skill in unchanged, save both profiles without billing or a page, and retire the old synastry module.

Checked targeted pytest, tsc 0, lint 0 errors, service tests 6/6, and test:db 73 pass / 13 fail matching the Windows known list.

Co-Authored-By: Grok (agent loop)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jesse-ux
2026-10-18 16:44:05 +08:00
co-authored by Grok (agent loop) Cursor
parent e7bf60c1f2
commit cf063834ca
44 changed files with 5387 additions and 313 deletions
+1
View File
@@ -45,6 +45,7 @@ HEAVY_COMPUTE_PATHS = frozenset(
"/api/vedastro/range_scan",
"/api/vedastro_gateway/run",
"/api/thematic_report",
"/api/synastry_full_dossier",
"/api/qizheng",
"/api/western",
"/api/ephemeris_events",
+1
View File
@@ -44,6 +44,7 @@ OFFLINE_SCRATCH_NAMES = frozenset(
"consult_full_data_cache",
# 与 consult_full_data_cache 同走 get_or_build,每次调用先 prune_cache(name=…) 删两天前的文件,自己会清;含出生资料,保留期 2 天。
"report_reference_cache",
"synastry_full_dossier_cache",
"ocr_extract",
"three_engine_parity",
"public_release_deny_patterns.txt",
+2 -1
View File
@@ -364,7 +364,7 @@ def _planet_dignity_level(planet_name: str, sign: str) -> str:
def calc_kuja_dosha(chart: Dict) -> Dict:
"""计算完整的 Kuja Dosha(火星凶相分析)"""
"""旧版 Kuja Dosha(五星合分)。新功能不得调用;婚恋合盘用 marriage_match。"""
planets = chart.get("planets", {})
dosha_scores_high = {
@@ -408,6 +408,7 @@ def calc_kuja_dosha(chart: Dict) -> Dict:
def match_kuja_dosha(male_score: float, female_score: float) -> Dict:
"""旧版:按性别比较火星冲克分数。新功能不得调用。"""
diff = male_score - female_score
if abs(diff) <= 5:
return {"result": "good", "description": "Kuja Dosha balanced."}
@@ -61,7 +61,7 @@ def build() -> dict:
"partial",
"basic_safe_with_boundary",
"36-point compatibility can be exposed as one factor only; not deterministic marriage outcome.",
["references/oracle/ashtakoot_oracle_cases.json", "scripts/synastry.py"],
["references/oracle/ashtakoot_oracle_cases.json", "scripts/marriage_match.py"],
),
layer(
"mangal_dosha",
+14
View File
@@ -10505,6 +10505,20 @@ class JyotishAPIHandler(BaseHTTPRequestHandler, JyotishComputeService):
elif path == '/api/synastry':
result = self._compute_synastry(body)
self._json(result)
elif path == '/api/marriage-match':
match_api = _load_local_module('marriage_match_api')
try:
result = match_api.compute_marriage_match(body)
except match_api.MarriageMatchInputError as exc:
raise BadRequest(str(exc)) from exc
self._json(result)
elif path == '/api/synastry_full_dossier':
match_api = _load_local_module('marriage_match_api')
try:
result = match_api.compute_synastry_full_dossier(self, body)
except match_api.MarriageMatchInputError as exc:
raise BadRequest(str(exc)) from exc
self._json(result)
elif path == '/api/dasha':
result = self._compute_dasha_system(body)
self._json(result)
+17 -24
View File
@@ -16,7 +16,7 @@
nakshatra-adv 高级Nakshatra分析(Tara Bala/Sub-Lord/兼容性)(v3.7新增)
argala Argala门闩系统(v3.7新增)
tajika Tajika/Varshaphala年运盘(v3.7新增)
synastry 合盘分析(Ashta Koota 36分制+Mangal Dosha)(v3.7新增)
marriage-match 婚恋合盘(两份盘快照;七项 35 分,火星冲克与婚姻宫压力分开列)
full-reading 全自动综合解盘(出生信息→全链路计算→完整报告)(v3.7.1新增)
celebrity 名人案例查询(15,807条数据+SQLite验证库)
db-stats 验证数据库统计
@@ -14499,20 +14499,17 @@ def _tajika_annual_core(args, planet_lons, asc_si, age, mode='all'):
# ============================================================================
# 21. 合盘分析(v3.7新增)
# ============================================================================
def cmd_synastry(args):
def cmd_marriage_match(args):
try:
sys.path.insert(0, SCRIPT_DIR)
from synastry import calc_synastry
except ImportError as e:
return {"error": f"synastry模块导入失败: {e}"}
# 构建两人数据
p1 = {'moon_lon': args.moon1, 'gender': args.gender1 or 'M'}
p2 = {'moon_lon': args.moon2, 'gender': args.gender2 or 'F'}
if args.mars1 is not None: p1['mars_lon'] = args.mars1
if args.mars2 is not None: p2['mars_lon'] = args.mars2
if args.asc1 is not None: p1['asc_lon'] = args.asc1
if args.asc2 is not None: p2['asc_lon'] = args.asc2
return _build_response_envelope('synastry', calc_synastry(p1, p2), args=args, execution_status='executed')
from marriage_match_cli import run_marriage_match_cli
except ImportError as exc:
return {"error": f"marriage_match模块导入失败: {exc}"}
try:
result = run_marriage_match_cli(args)
except (OSError, ValueError, json.JSONDecodeError) as exc:
return {"error": str(exc)}
return _build_response_envelope('marriage-match', result, args=args, execution_status='executed')
# ============================================================================
@@ -18217,16 +18214,12 @@ def main():
p.add_argument('--hour-from-sunrise', type=float, default=6.0,
help='从日出起算的小时数(默认6h约正午)')
# 21. synastry (v3.7新增)
p = sub.add_parser('synastry', help='合盘分析(Ashta Koota 36分制)')
p.add_argument('--moon1', type=float, required=True, help='Person1月亮黄经')
p.add_argument('--moon2', type=float, required=True, help='Person2月亮黄经')
p.add_argument('--mars1', type=float, default=None, help='Person1火星黄经')
p.add_argument('--mars2', type=float, default=None, help='Person2火星黄经')
p.add_argument('--asc1', type=float, default=None, help='Person1上升黄经')
p.add_argument('--asc2', type=float, default=None, help='Person2上升黄经')
p.add_argument('--gender1', default='M', help='Person1性别')
p.add_argument('--gender2', default='F', help='Person2性别')
# 21. marriage-match(上游 bde5d615;只收盘快照)
p = sub.add_parser('marriage-match', help='婚恋合盘(两份盘快照)')
p.add_argument('--person-a', required=True, help='Person A 盘快照 JSON 文件')
p.add_argument('--person-b', required=True, help='Person B 盘快照 JSON 文件')
p.add_argument('--profile', default='practitioner_consensus_v1',
choices=['practitioner_consensus_v1', 'raman_muhurtha_v1'])
# 22. full-reading (v3.7.1新增)
p = sub.add_parser('full-reading', help='全自动综合解盘(出生信息→全链路→完整报告)')
@@ -18371,7 +18364,7 @@ def main():
'varga-full': cmd_varga_full, 'aspects': cmd_aspects, 'jaimini': cmd_jaimini,
'nakshatra-adv': cmd_nakshatra_adv, 'nakshatra-dasha': cmd_nakshatra_dasha,
'nakshatra-full': cmd_nakshatra_full, 'argala': cmd_argala, 'tajika': cmd_tajika,
'synastry': cmd_synastry, 'solar-return': cmd_solar_return,
'marriage-match': cmd_marriage_match, 'solar-return': cmd_solar_return,
'narayana-dasha': cmd_narayana_dasha, 'muhurta': cmd_muhurta,
'full-reading': cmd_full_reading, 'pl9-export': cmd_pl9_export, 'prashna': cmd_prashna,
'double-transit-pac': cmd_double_transit_pac,
+467
View File
@@ -0,0 +1,467 @@
#!/usr/bin/env python3
"""User-facing romantic/marriage compatibility contract.
This module deliberately does not reuse the historical ``calc_kuja_dosha``
score. That function combines Mars with five pressure planets and contains a
male/female asymmetric comparison. It remains available for legacy reports,
but is not a valid default for a new relationship product.
"""
from __future__ import annotations
from copy import deepcopy
from typing import Any, Dict, Iterable, Mapping, Optional
from ashtakoot import (
FRIENDSHIP,
NADI,
SIGN_LORDS,
SIGNS,
calculate_ashtakoot,
get_nakshatra,
)
PRODUCT_VERSION = "marriage_match_product_v1"
DEFAULT_PROFILE = "practitioner_consensus_v1"
VALID_RELATIONSHIP_TYPES = {"romantic_or_marriage", "romantic", "marriage"}
KUJA_PLANET = "Mars"
PAPASAMYA_PLANETS = ("Mars", "Saturn", "Sun", "Rahu", "Ketu")
# These profiles are deliberately qualitative. The consensus did not support
# an invented severity percentage or a sex-specific tolerance threshold.
RULE_PROFILES = {
"practitioner_consensus_v1": {
"label": "Practitioner consensus v1",
"kuja_houses": (1, 2, 4, 7, 8, 12),
"reference_frames": ("lagna", "moon", "venus"),
"exact_house_sign_exceptions": {
2: {"Gemini", "Virgo"},
4: {"Aries", "Scorpio"},
7: {"Capricorn", "Cancer"},
8: {"Sagittarius", "Pisces"},
12: {"Taurus", "Libra"},
},
"leo_aquarius_exception": "variant_only",
"own_or_exalted": "mitigates",
"jupiter_contact": "mitigates",
"moon_contact": "mitigates",
"papasamya_planets": PAPASAMYA_PLANETS,
"gender_asymmetry": False,
"notes": [
"Kuja is Mars-only; Papasamya is a separately reported five-planet pressure layer.",
"Lagna, Moon and Venus are shown separately; they are not converted into an opaque weighted score.",
"Own/exalted Mars and Jupiter or Moon contact mitigate a signal in this default profile; they do not erase the raw placement.",
"Age, caste and sex-asymmetric comparison rules are not used for a user-facing conclusion.",
],
},
"raman_muhurtha_v1": {
"label": "B. V. Raman Muhurtha variant",
"kuja_houses": (2, 4, 7, 8, 12),
"reference_frames": ("lagna", "moon", "venus"),
"exact_house_sign_exceptions": {
2: {"Gemini", "Virgo"},
4: {"Aries", "Scorpio"},
7: {"Capricorn", "Cancer"},
8: {"Sagittarius", "Pisces"},
12: {"Taurus", "Libra"},
},
"leo_aquarius_exception": "cancels",
"own_or_exalted": "mitigates",
"jupiter_contact": "mitigates",
"moon_contact": "mitigates",
"papasamya_planets": PAPASAMYA_PLANETS,
"gender_asymmetry": False,
"notes": [
"Raman-style house set omits the first house. It is a selectable lineage, not the product default.",
"The traditional Leo/Aquarius exception is applied only in this variant profile.",
],
},
}
def available_profiles() -> Dict[str, Dict[str, Any]]:
"""Return a presentation-safe copy of available rule profiles."""
return deepcopy(RULE_PROFILES)
def _profile(profile_id: str) -> Dict[str, Any]:
if profile_id not in RULE_PROFILES:
raise ValueError("Unknown marriage-match rule profile: %s" % profile_id)
return RULE_PROFILES[profile_id]
def _lon(value: Any) -> Optional[float]:
if isinstance(value, bool):
return None
if isinstance(value, (int, float)):
return float(value) % 360.0
if isinstance(value, Mapping):
for key in ("longitude", "degree_raw", "degree", "lon"):
if isinstance(value.get(key), (int, float)):
return float(value[key]) % 360.0
return None
def _sign_index(value: Any) -> Optional[int]:
if isinstance(value, str) and value in SIGNS:
return SIGNS.index(value)
longitude = _lon(value)
return int(longitude // 30) if longitude is not None else None
def _planet_data(chart: Mapping[str, Any], planet: str) -> Mapping[str, Any]:
planets = chart.get("planets", {}) if isinstance(chart, Mapping) else {}
value = planets.get(planet, {}) if isinstance(planets, Mapping) else {}
return value if isinstance(value, Mapping) else {}
def _planet_lon(chart: Mapping[str, Any], planet: str) -> Optional[float]:
value = _planet_data(chart, planet)
longitude = _lon(value)
return longitude if longitude is not None else _lon(chart.get(planet + "_lon"))
def _reference_lon(chart: Mapping[str, Any], reference: str) -> Optional[float]:
if reference == "lagna":
for value in (chart.get("ascendant"), chart.get("lagna"), chart.get("asc_lon")):
longitude = _lon(value)
if longitude is not None:
return longitude
return None
return _planet_lon(chart, "Moon" if reference == "moon" else "Venus")
def _reference_sign(chart: Mapping[str, Any], reference: str) -> Optional[int]:
value = _reference_lon(chart, reference)
if value is not None:
return int(value // 30)
if reference == "lagna":
# Aries is index 0, which is a valid reference sign rather than a
# missing value. Do not use boolean fallback here.
ascendant_sign = _sign_index(chart.get("ascendant"))
return ascendant_sign if ascendant_sign is not None else _sign_index(chart.get("lagna"))
return _sign_index(_planet_data(chart, "Moon" if reference == "moon" else "Venus"))
def _planet_sign(chart: Mapping[str, Any], planet: str) -> Optional[int]:
lon = _planet_lon(chart, planet)
return int(lon // 30) if lon is not None else _sign_index(_planet_data(chart, planet))
def _house_from(reference_sign: Optional[int], planet_sign: Optional[int]) -> Optional[int]:
if reference_sign is None or planet_sign is None:
return None
return ((planet_sign - reference_sign) % 12) + 1
def _same_sign_contact(chart: Mapping[str, Any], planet_a: str, planet_b: str) -> bool:
a, b = _planet_sign(chart, planet_a), _planet_sign(chart, planet_b)
return a is not None and a == b
def _mars_dignity(chart: Mapping[str, Any]) -> str:
sign_idx = _planet_sign(chart, "Mars")
if sign_idx is None:
return "unknown"
sign = SIGNS[sign_idx]
if sign == "Capricorn":
return "exalted"
if sign in {"Aries", "Scorpio"}:
return "own"
if sign == "Cancer":
return "debilitated"
return "other"
def _frame_kuja(chart: Mapping[str, Any], reference: str, profile: Mapping[str, Any]) -> Dict[str, Any]:
mars_sign_idx = _planet_sign(chart, "Mars")
ref_sign_idx = _reference_sign(chart, reference)
house = _house_from(ref_sign_idx, mars_sign_idx)
frame = {
"reference": reference,
"reference_sign": SIGNS[ref_sign_idx] if ref_sign_idx is not None else None,
"mars_sign": SIGNS[mars_sign_idx] if mars_sign_idx is not None else None,
"mars_house": house,
"raw_signal": house in set(profile["kuja_houses"]) if house is not None else None,
"status": "not_available" if house is None else "not_triggered",
"mitigations": [],
}
if house is None or not frame["raw_signal"]:
return frame
mars_sign = frame["mars_sign"]
exact_exceptions = profile["exact_house_sign_exceptions"]
if mars_sign in exact_exceptions.get(house, set()):
frame["status"] = "cancelled_by_profile_exception"
frame["mitigations"].append("house_sign_exception")
return frame
if mars_sign in {"Leo", "Aquarius"}:
mode = profile["leo_aquarius_exception"]
if mode == "cancels":
frame["status"] = "cancelled_by_profile_exception"
frame["mitigations"].append("leo_aquarius_profile_exception")
return frame
if mode == "variant_only":
frame["mitigations"].append("leo_aquarius_exception_disputed")
frame["status"] = "active"
dignity = _mars_dignity(chart)
if dignity in {"own", "exalted"}:
frame["mitigations"].append("mars_%s" % dignity)
if _same_sign_contact(chart, "Mars", "Jupiter"):
frame["mitigations"].append("jupiter_conjunction")
if _same_sign_contact(chart, "Mars", "Moon"):
frame["mitigations"].append("moon_conjunction")
return frame
def analyze_kuja(chart: Mapping[str, Any], profile_id: str = DEFAULT_PROFILE) -> Dict[str, Any]:
"""Analyze Mars-only Kuja / Mangal Dosha across visible reference frames."""
profile = _profile(profile_id)
if _planet_sign(chart, "Mars") is None:
return {
"system": "Kuja / Mangal Dosha",
"scope": "Mars only",
"profile_id": profile_id,
"status": "not_available",
"reason": "Mars position is required.",
"frames": [],
}
frames = [_frame_kuja(chart, ref, profile) for ref in profile["reference_frames"]]
active = [frame for frame in frames if frame["status"] == "active"]
cancelled = [frame for frame in frames if frame["status"] == "cancelled_by_profile_exception"]
severity = {0: "none", 1: "mild", 2: "material", 3: "strong"}[len(active)]
return {
"system": "Kuja / Mangal Dosha",
"scope": "Mars only",
"profile_id": profile_id,
"status": "executed",
"active_reference_count": len(active),
"cancelled_reference_count": len(cancelled),
"qualitative_level": severity,
"frames": frames,
"boundary": "Reference count is a presentation label, not a probability or a compatibility score.",
}
def analyze_papasamya(chart: Mapping[str, Any], profile_id: str = DEFAULT_PROFILE) -> Dict[str, Any]:
"""Report five-planet pressure separately from Mars-only Kuja."""
profile = _profile(profile_id)
rows = []
for planet in profile["papasamya_planets"]:
planet_sign = _planet_sign(chart, planet)
if planet_sign is None:
rows.append({"planet": planet, "status": "not_available", "frames": []})
continue
frames = []
for reference in profile["reference_frames"]:
house = _house_from(_reference_sign(chart, reference), planet_sign)
frames.append({
"reference": reference,
"house": house,
"in_pressure_house": house in set(profile["kuja_houses"]) if house is not None else None,
})
rows.append({"planet": planet, "status": "executed", "frames": frames})
active = [
{"planet": row["planet"], "reference": frame["reference"], "house": frame["house"]}
for row in rows for frame in row.get("frames", []) if frame.get("in_pressure_house")
]
return {
"system": "Papasamya pressure",
"scope": "Mars, Saturn, Sun, Rahu and Ketu; separate from Kuja.",
"profile_id": profile_id,
"status": "executed" if any(row["status"] == "executed" for row in rows) else "not_available",
"planet_rows": rows,
"active_placements": active,
"boundary": "This is a descriptive pressure layer. It is not renamed as Mangal Dosha and is not reduced to a sex-weighted score.",
}
def compare_kuja(person_a: Mapping[str, Any], person_b: Mapping[str, Any], profile_id: str = DEFAULT_PROFILE) -> Dict[str, Any]:
"""Compare two Kuja results without sex-specific tolerance or scoring."""
a, b = analyze_kuja(person_a, profile_id), analyze_kuja(person_b, profile_id)
if a["status"] != "executed" or b["status"] != "executed":
outcome = "incomplete"
else:
difference = abs(a["active_reference_count"] - b["active_reference_count"])
outcome = "not_triggered" if not a["active_reference_count"] and not b["active_reference_count"] else (
"broadly_balanced" if difference <= 1 else "asymmetric"
)
return {
"comparison_method": "neutral_reference_count",
"gender_asymmetry_used": False,
"outcome": outcome,
"person_a": a,
"person_b": b,
"boundary": "Both-active is a traditional balancing consideration, not an automatic approval or cancellation.",
}
def _moon_lon(chart: Mapping[str, Any]) -> Optional[float]:
longitude = _planet_lon(chart, "Moon")
return longitude if longitude is not None else _lon(chart.get("moon_lon"))
def _are_natural_friends(lord_a: str, lord_b: str) -> bool:
if lord_a == lord_b:
return True
return lord_b in FRIENDSHIP.get(lord_a, {}).get("friend", []) and lord_a in FRIENDSHIP.get(lord_b, {}).get("friend", [])
def _ashtakoota_with_exceptions(
person_a: Mapping[str, Any],
person_b: Mapping[str, Any],
) -> Dict[str, Any]:
moon_a, moon_b = _moon_lon(person_a), _moon_lon(person_b)
if moon_a is None or moon_b is None:
return {"status": "not_available", "reason": "Both Moon longitudes are required for Ashtakoota."}
forward = calculate_ashtakoot(moon_a, moon_b)
reverse = calculate_ashtakoot(moon_b, moon_a)
# The historical Varna point encodes caste hierarchy. It cannot truthfully
# be relabelled as a modern value judgement, so the international product
# excludes it rather than deriving a replacement from lunar signs.
score_names = ("Vashya", "Tara", "Yoni", "GrahaMaitri", "Gana", "Bhakoot", "Nadi")
symmetric_scores = {
name: round(
(float(forward["scores"][name]) + float(reverse["scores"][name])) / 2.0,
2,
)
for name in score_names
}
international_core = {
"method": "International Ashtakoota core v1 (both input orders averaged; classical Varna excluded)",
"scores": symmetric_scores,
"total_score": round(sum(symmetric_scores.values()), 2),
"max_score": 35.0,
"match_percentage": round(sum(symmetric_scores.values()) / 35.0 * 100, 1),
"person_a_details": {
"moon_sign": SIGNS[int(moon_a // 30)],
"nakshatra": get_nakshatra(moon_a)["name"],
"nakshatra_pada": get_nakshatra(moon_a)["pada"],
},
"person_b_details": {
"moon_sign": SIGNS[int(moon_b // 30)],
"nakshatra": get_nakshatra(moon_b)["name"],
"nakshatra_pada": get_nakshatra(moon_b)["pada"],
},
"directionality": {
"status": "symmetric",
"rule": "all included Kutas are averaged across person_a -> person_b and person_b -> person_a",
},
"boundary": "This is a mutual-reference score, not a caste hierarchy or an automatic relationship verdict.",
}
sign_a, sign_b = SIGNS[int(moon_a // 30)], SIGNS[int(moon_b // 30)]
nak_a, nak_b = get_nakshatra(moon_a), get_nakshatra(moon_b)
bhakoot_exceptions = []
if symmetric_scores["Bhakoot"] == 0:
lord_a, lord_b = SIGN_LORDS[sign_a], SIGN_LORDS[sign_b]
if lord_a == lord_b:
bhakoot_exceptions.append("same_sign_lord")
elif _are_natural_friends(lord_a, lord_b):
bhakoot_exceptions.append("mutual_natural_friend_sign_lords")
nadi_exceptions = []
if symmetric_scores["Nadi"] == 0:
if nak_a["index"] == nak_b["index"] and nak_a["pada"] != nak_b["pada"]:
nadi_exceptions.append("same_nakshatra_different_pada")
if sign_a == sign_b and nak_a["index"] != nak_b["index"]:
nadi_exceptions.append("same_rashi_different_nakshatra")
return {
"status": "executed",
"international_core": international_core,
"shared_life_dialogue": {
"status": "not_scored",
"storage": "no_answers_collected",
"prompts": [
"What does a committed relationship mean to each of you?",
"How do you want to make decisions about money, home, and care responsibilities?",
"What boundaries, privacy expectations, and conflict-repair habits matter most?",
"How do you each imagine work, family, location, and personal autonomy over the next few years?",
],
"boundary": "These are voluntary discussion prompts, not a compatibility test or a score.",
},
"classical_varna": {
"status": "excluded_from_default",
"reason": "Historical caste hierarchy is not used in the international product or its default score.",
},
"person_a": {"moon_sign": sign_a, "nakshatra": nak_a},
"person_b": {"moon_sign": sign_b, "nakshatra": nak_b},
"exception_review": {
"bhakoot": {
"raw_score": symmetric_scores["Bhakoot"],
"candidate_cancellations": bhakoot_exceptions,
"status": "candidate_mitigation" if bhakoot_exceptions else "none",
},
"nadi": {
"raw_score": symmetric_scores["Nadi"],
"candidate_cancellations": nadi_exceptions,
"status": "candidate_mitigation" if nadi_exceptions else "none",
},
"yoni": {
"raw_score": symmetric_scores["Yoni"],
"status": "legacy_three_band_table",
"boundary": "The legacy 4/2/0 table remains visible. Intermediate 3/1 grades require a separately versioned source table before becoming a default score.",
},
},
}
def _structure_status(chart: Mapping[str, Any]) -> Dict[str, Any]:
keys = {"d1_relationship", "d9", "upapada", "darakaraka", "relationship_dasha"}
supplied = [key for key in keys if chart.get(key) is not None]
return {
"status": "partial" if supplied else "not_available",
"supplied_layers": sorted(supplied),
"required_for_complete_reading": ["D1 relationship structure", "D9", "UL", "DK", "relationship timing / Dasha"],
"boundary": "36-point matching and Dosha layers do not replace D1/D9/UL/DK or timing review.",
}
def build_marriage_match_dossier(
person_a: Mapping[str, Any],
person_b: Mapping[str, Any],
*,
relationship_type: str = "romantic_or_marriage",
profile_id: str = DEFAULT_PROFILE,
) -> Dict[str, Any]:
"""Build the stable, user-facing love/marriage compatibility payload.
Inputs are chart snapshots only. The caller is responsible for calculating
D1/D9/timing snapshots; this function never stores birth details.
"""
if relationship_type not in VALID_RELATIONSHIP_TYPES:
raise ValueError("This product supports romantic/marriage matching only.")
profile = _profile(profile_id)
kuja = compare_kuja(person_a, person_b, profile_id)
ashtakoota = _ashtakoota_with_exceptions(person_a, person_b)
return {
"product": PRODUCT_VERSION,
"relationship_type": relationship_type,
"rule_profile": {
"id": profile_id,
"label": profile["label"],
"notes": profile["notes"],
"gender_asymmetry_used": False,
},
"ashtakoota_36": ashtakoota,
"kuja_mangal_dosha": kuja,
"papasamya": {
"person_a": analyze_papasamya(person_a, profile_id),
"person_b": analyze_papasamya(person_b, profile_id),
},
"relationship_structure": {
"person_a": _structure_status(person_a),
"person_b": _structure_status(person_b),
},
"presentation": {
"headline": "Relationship compatibility is presented as parallel traditional layers, not one approval score.",
"decision_boundary": "Do not let the 36-point score, Kuja, or Papasamya alone determine a relationship conclusion.",
"not_in_scope": ["business partnerships", "family matching", "age-based cancellation", "sex-asymmetric tolerance"],
},
"audit": {
"kuja": "executed: Mars-only, Lagna/Moon/Venus frames where data is supplied",
"papasamya": "executed separately: five-planet pressure placement review",
"ashtakoota": ashtakoota["status"],
"d1_d9_ul_dk_timing": "input-dependent; no missing layer is fabricated",
},
}
+106
View File
@@ -0,0 +1,106 @@
"""HTTP-facing marriage match and full synastry dossier.
The dossier endpoint caches on the two birth payloads plus ``GITHUB_SHA``,
the same scratch helper the report reference cache uses. A missing or
``unknown`` commit id skips the cache.
"""
from __future__ import annotations
import os
from typing import Any
from marriage_match import build_marriage_match_dossier
from synastry_full_dossier import RULES_VERSION, build_synastry_full_dossier
try:
from scripts import consult_full_data_cache as _cache
except ModuleNotFoundError: # pragma: no cover - direct scripts/ execution
import consult_full_data_cache as _cache
CACHE_NAME = "synastry_full_dossier_cache"
_BIRTH_KEYS = ("year", "month", "day", "hour", "minute", "lat", "lon", "tz")
class MarriageMatchInputError(ValueError):
"""The caller sent a shape this product does not accept."""
def compute_marriage_match(payload: dict[str, Any]) -> dict[str, Any]:
"""Snapshot-only product. Birth details are not accepted here."""
if not isinstance(payload, dict):
raise MarriageMatchInputError("request body must be an object")
person_a = payload.get("person_a")
person_b = payload.get("person_b")
if not isinstance(person_a, dict) or not isinstance(person_b, dict):
raise MarriageMatchInputError("person_a and person_b must be chart snapshots")
profile_id = payload.get("profile_id") or "practitioner_consensus_v1"
if not isinstance(profile_id, str) or not profile_id.strip():
raise MarriageMatchInputError("profile_id must be a string")
try:
return build_marriage_match_dossier(
person_a,
person_b,
profile_id=profile_id.strip(),
)
except ValueError as exc:
raise MarriageMatchInputError(str(exc)) from exc
def _birth(value: Any, label: str) -> dict[str, Any]:
if not isinstance(value, dict):
raise MarriageMatchInputError(f"{label} must be an object")
missing = [key for key in _BIRTH_KEYS if value.get(key) is None]
if missing:
raise MarriageMatchInputError(f"{label} is missing {', '.join(missing)}")
return value
def _birth_identity(body: dict[str, Any]) -> dict[str, Any]:
node = body.get("node_mode", body.get("nodeMode", "mean"))
return {
"year": body.get("year"),
"month": body.get("month"),
"day": body.get("day"),
"hour": body.get("hour"),
"minute": body.get("minute"),
"second": body.get("second", 0),
"lat": body.get("lat"),
"lon": body.get("lon"),
"tz": body.get("tz"),
"ayanamsa": body.get("ayanamsa", "lahiri"),
"node_mode": node or "mean",
}
def _commit_sha() -> str:
return os.environ.get("GITHUB_SHA") or ""
def compute_synastry_full_dossier(handler: Any, body: dict[str, Any]) -> dict[str, Any]:
if not isinstance(body, dict):
raise MarriageMatchInputError("request body must be an object")
person_a = _birth(body.get("person_a"), "person_a")
person_b = _birth(body.get("person_b"), "person_b")
sha = _commit_sha()
def build(_identity: dict[str, Any]) -> dict[str, Any]:
return build_synastry_full_dossier(handler, person_a, person_b)
if sha in ("", "unknown"):
return build({})
cached = _cache.get_or_build(
{
"person_a": _birth_identity(person_a),
"person_b": _birth_identity(person_b),
"github_sha": sha,
"rules_version": RULES_VERSION,
},
build,
cache_name=CACHE_NAME,
)
packet = cached["packet"]
if not isinstance(packet, dict):
raise MarriageMatchInputError("cached dossier was not an object")
return packet
+20
View File
@@ -0,0 +1,20 @@
"""CLI for snapshot marriage matching. Birth details are not an input."""
from __future__ import annotations
import json
from pathlib import Path
from marriage_match import build_marriage_match_dossier
def run_marriage_match_cli(args) -> dict:
person_a = json.loads(Path(args.person_a).read_text(encoding="utf-8"))
person_b = json.loads(Path(args.person_b).read_text(encoding="utf-8"))
if not isinstance(person_a, dict) or not isinstance(person_b, dict):
raise ValueError("person snapshots must be JSON objects")
return build_marriage_match_dossier(
person_a,
person_b,
profile_id=getattr(args, "profile", None) or "practitioner_consensus_v1",
)
-262
View File
@@ -1,262 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Synastry(合盘)模块 — 基于 dashaflow (MIT License) 核心算法
16因子兼容性分析系统:
- Ashtakoot 8因子(36分制)
- 附加8 Kuta(Mahendra/Stree Deergha/Vedha/Kuja Dosha/Rajju等)
"""
from typing import Dict, List, Tuple, Any
SIGNS = ['Aries', 'Taurus', 'Gemini', 'Cancer', 'Leo', 'Virgo',
'Libra', 'Scorpio', 'Sagittarius', 'Capricorn', 'Aquarius', 'Pisces']
SIGN_LORDS = {
'Aries': 'Mars', 'Taurus': 'Venus', 'Gemini': 'Mercury', 'Cancer': 'Moon',
'Leo': 'Sun', 'Virgo': 'Mercury', 'Libra': 'Venus', 'Scorpio': 'Mars',
'Sagittarius': 'Jupiter', 'Capricorn': 'Saturn', 'Aquarius': 'Saturn', 'Pisces': 'Jupiter'
}
NAKSHATRAS = ['Ashwini', 'Bharani', 'Krittika', 'Rohini', 'Mrigashira', 'Ardra',
'Punarvasu', 'Pushya', 'Ashlesha', 'Magha', 'Purva Phalguni', 'Uttara Phalguni',
'Hasta', 'Chitra', 'Swati', 'Vishakha', 'Anuradha', 'Jyeshtha',
'Mula', 'Purva Ashadha', 'Uttara Ashadha', 'Shravana', 'Dhanishta', 'Shatabhisha',
'Purva Bhadrapada', 'Uttara Bhadrapada', 'Revati']
# =============================================================================
# 数据表 (来自 dashaflow MIT License)
# =============================================================================
YONI_ANIMALS = [
"Horse", "Elephant", "Sheep", "Serpent", "Serpent", "Dog", "Cat", "Sheep", "Cat",
"Rat", "Rat", "Cow", "Buffalo", "Tiger", "Buffalo", "Tiger", "Deer", "Deer",
"Dog", "Monkey", "Mongoose", "Monkey", "Lion", "Horse", "Lion", "Cow", "Elephant"
]
YONI_ENEMIES = {
"Horse": "Buffalo", "Buffalo": "Horse", "Elephant": "Lion", "Lion": "Elephant",
"Sheep": "Monkey", "Monkey": "Sheep", "Serpent": "Mongoose", "Mongoose": "Serpent",
"Dog": "Deer", "Deer": "Dog", "Cat": "Rat", "Rat": "Cat",
"Cow": "Tiger", "Tiger": "Cow"
}
GANA = [
"Deva", "Manushya", "Rakshasa", "Manushya", "Deva", "Manushya", "Deva", "Deva", "Rakshasa",
"Rakshasa", "Manushya", "Manushya", "Deva", "Rakshasa", "Deva", "Rakshasa", "Deva", "Rakshasa",
"Rakshasa", "Manushya", "Manushya", "Deva", "Rakshasa", "Rakshasa", "Manushya", "Manushya", "Deva"
]
NADI = [
"Adi", "Madhya", "Antya", "Antya", "Madhya", "Adi", "Adi", "Madhya", "Antya",
"Antya", "Madhya", "Adi", "Adi", "Madhya", "Antya", "Antya", "Madhya", "Adi",
"Adi", "Madhya", "Antya", "Antya", "Madhya", "Adi", "Adi", "Madhya", "Antya"
]
VARNA = {
"Cancer": 1, "Scorpio": 1, "Pisces": 1,
"Aries": 2, "Leo": 2, "Sagittarius": 2,
"Taurus": 3, "Virgo": 3, "Capricorn": 3,
"Gemini": 4, "Libra": 4, "Aquarius": 4
}
VASHYA_TYPE = {
"Aries": "Chatushpada", "Taurus": "Chatushpada",
"Leo": "Vanachara", "Sagittarius": "Chatushpada", "Capricorn": "Chatushpada",
"Gemini": "Manava", "Virgo": "Manava", "Libra": "Manava", "Aquarius": "Manava",
"Cancer": "Jalachara", "Pisces": "Jalachara",
"Scorpio": "Keet",
}
VEDHA_PAIRS = [(0,17),(1,16),(2,15),(3,14),(5,21),(6,20),(7,19),(8,18),(9,26),(10,25),(11,24),(12,23),(4,22)]
RAJJU_GROUPS = {
"Pada":{0,8,9,17,18,26}, "Kati":{1,7,10,16,25,19},
"Udara":{2,6,11,15,20,24}, "Kanta":{3,5,12,14,21,23}, "Sira":{4,13,22}
}
RAJJU_EFFECTS = {"Sira":"head—risk to husband","Kanta":"neck—risk to wife","Udara":"stomach—risk to children","Kati":"waist—poverty","Pada":"foot—wandering"}
NATURAL_FRIENDS = {
'Sun': ['Moon','Mars','Jupiter'], 'Moon': ['Sun','Mercury'],
'Mars': ['Sun','Moon','Jupiter'], 'Mercury': ['Sun','Venus'],
'Jupiter': ['Sun','Moon','Mars'], 'Venus': ['Mercury','Saturn'],
'Saturn': ['Mercury','Venus']
}
NATURAL_ENEMIES = {
'Sun': ['Saturn','Venus'], 'Moon': [], 'Mars': ['Mercury'],
'Mercury': ['Moon'], 'Jupiter': ['Mercury','Venus'],
'Venus': ['Sun','Moon'], 'Saturn': ['Sun','Moon','Mars']
}
EXALTATION = {'Sun':('Aries',10),'Moon':('Taurus',3),'Mars':('Capricorn',28),'Mercury':('Virgo',15),'Jupiter':('Cancer',5),'Venus':('Pisces',27),'Saturn':('Libra',20)}
DEBILITATION = {p:(SIGNS[(SIGNS.index(s)+6)%12],d) for p,(s,d) in EXALTATION.items()}
OWN_SIGNS = {'Sun':{'Leo'},'Moon':{'Cancer'},'Mars':{'Aries','Scorpio'},'Mercury':{'Gemini','Virgo'},'Jupiter':{'Sagittarius','Pisces'},'Venus':{'Taurus','Libra'},'Saturn':{'Capricorn','Aquarius'}}
_DOSHA_HOUSES = {2,4,7,8,12}
_HIGH_SEVERITY_HOUSES = {7,8}
_MARS_EXCEPTIONS = {2:{"Gemini","Virgo"},12:{"Taurus","Libra"},4:{"Aries","Scorpio"},7:{"Capricorn","Cancer"},8:{"Sagittarius","Pisces"}}
_MARS_EXEMPT_SIGNS = {"Aquarius","Leo"}
# =============================================================================
# Ashtakoot 8因子 (36分制)
# =============================================================================
def _calc_varna(m_sign, f_sign):
return 1.0 if VARNA[m_sign] <= VARNA[f_sign] else 0.0
def _calc_tara(m_nak, f_nak):
pts = 0.0
if (f_nak-m_nak)%9 not in (2,4,6): pts += 1.5
if (m_nak-f_nak)%9 not in (2,4,6): pts += 1.5
return pts
def _calc_yoni(m_nak, f_nak):
my, fy = YONI_ANIMALS[m_nak], YONI_ANIMALS[f_nak]
if my == fy: return 4.0
if YONI_ENEMIES.get(my) == fy: return 0.0
return 2.0
def _calc_graha_maitri(m_sign, f_sign):
ml, fl = SIGN_LORDS[m_sign], SIGN_LORDS[f_sign]
def _check(p1, p2):
if p1==p2: return 1.0
if p2 in NATURAL_FRIENDS.get(p1,[]): return 1.0
if p2 in NATURAL_ENEMIES.get(p1,[]): return 0.0
return 0.5
total = _check(ml,fl) + _check(fl,ml)
if total == 2.0: return 5.0
if total == 1.5: return 4.0
if total == 1.0: return 3.0
if total == 0.5: return 1.0
return 0.0
def _calc_gana(m_nak, f_nak):
mg, fg = GANA[m_nak], GANA[f_nak]
if mg == fg: return 6.0
if mg=="Deva" and fg=="Manushya": return 6.0
if mg=="Manushya" and fg=="Deva": return 5.0
if "Rakshasa" in (mg,fg) and "Manushya" in (mg,fg): return 0.0
if mg=="Rakshasa" and fg=="Deva": return 1.0
if fg=="Rakshasa" and mg=="Deva": return 0.0
return 0.0
def _calc_bhakoot(m_sign, f_sign):
mi, fi = SIGNS.index(m_sign), SIGNS.index(f_sign)
diff = (fi - mi) % 12 + 1
return 7.0 if diff in (1,7,3,11,4,10) else 0.0
def _calc_nadi(m_nak, f_nak):
return 8.0 if NADI[m_nak] != NADI[f_nak] else 0.0
def _calc_vedha(m_nak, f_nak):
for a,b in VEDHA_PAIRS:
if (m_nak==a and f_nak==b) or (m_nak==b and f_nak==a): return "bad"
return "good"
def _calc_rajju(m_nak, f_nak):
for group, indices in RAJJU_GROUPS.items():
if m_nak in indices and f_nak in indices:
return {"result":"bad","group":group,"effect":RAJJU_EFFECTS[group]}
return {"result":"good","group":None,"effect":""}
def _calc_mahendra(m_nak, f_nak):
cnt = ((m_nak-f_nak)%27)+1
return "good" if cnt in (4,7,10,13,16,19,22,25) else "bad"
def _calc_stree_deergha(m_nak, f_nak):
cnt = ((m_nak-f_nak)%27)+1
return "good" if cnt >= 9 else "bad"
# =============================================================================
# 主计算函数
# =============================================================================
def calc_ashtakoot(male_moon_degree: float, female_moon_degree: float,
male_chart: Dict = None, female_chart: Dict = None) -> Dict:
"""
计算16因子Ashtakoot兼容性匹配(基于dashaflow MIT算法)。
Args:
male_moon_degree: 男性月亮黄道经度(0-360)
female_moon_degree: 女性月亮黄道经度(0-360)
male_chart: 男性星盘数据(可选,用于附加Kuta)
female_chart: 女性星盘数据(可选)
Returns:
完整的兼容性分析
"""
m_nak = int(male_moon_degree / (360.0/27.0))
f_nak = int(female_moon_degree / (360.0/27.0))
m_sign = SIGNS[int(male_moon_degree/30)%12]
f_sign = SIGNS[int(female_moon_degree/30)%12]
scores = {
"Varna": _calc_varna(m_sign, f_sign),
"Vashya": 2.0, # Simplified
"Tara": _calc_tara(m_nak, f_nak),
"Yoni": _calc_yoni(m_nak, f_nak),
"GrahaMaitri": _calc_graha_maitri(m_sign, f_sign),
"Gana": _calc_gana(m_nak, f_nak),
"Bhakoot": _calc_bhakoot(m_sign, f_sign),
"Nadi": _calc_nadi(m_nak, f_nak),
}
total = sum(scores.values())
rajju = _calc_rajju(m_nak, f_nak)
additional = {
"Mahendra": _calc_mahendra(m_nak, f_nak),
"StreeDeergha": _calc_stree_deergha(m_nak, f_nak),
"Vedha": _calc_vedha(m_nak, f_nak),
"Rajju": rajju,
}
# Dosha缓解逻辑
exceptions = []
if scores["Nadi"]==0 and scores["Bhakoot"]>0 and rajju["result"]=="good":
exceptions.append("Nadi Dosha mitigated by good Bhakoot and Rajju")
if rajju["result"]=="bad" and scores["GrahaMaitri"]>=4 and scores["Bhakoot"]>0 and scores["Tara"]>=1.5 and additional["Mahendra"]=="good":
exceptions.append("Rajju Dosha mitigated")
assessment = "优秀" if total>=28 else "良好" if total>=21 else "一般" if total>=18 else "不推荐"
approved = total >= 18.0 and (scores["Nadi"]>0 or len(exceptions)>0)
return {
"version": "3.8-dashaflow-mit-adapted",
"method": "Ashtakoot 16因子兼容性分析 (dashaflow MIT)",
"male": {"moon_sign":m_sign,"nakshatra":NAKSHATRAS[m_nak],"gana":GANA[m_nak],"nadi":NADI[m_nak],"yoni":YONI_ANIMALS[m_nak]},
"female": {"moon_sign":f_sign,"nakshatra":NAKSHATRAS[f_nak],"gana":GANA[f_nak],"nadi":NADI[f_nak],"yoni":YONI_ANIMALS[f_nak]},
"scores": scores,
"total_score": total,
"max_score": 36.0,
"assessment": assessment,
"is_approved": approved,
"additional_kutas": additional,
"exceptions": exceptions,
}
def calc_synastry(male_chart: Dict, female_chart: Dict) -> Dict:
"""
Backward-compatible synastry wrapper used by integration tests and older CLI paths.
Expected chart keys:
- moon_lon: Moon longitude in degrees (required)
- mars_lon / asc_lon / gender: optional, reserved for Kuja and extended factors
"""
if "moon_lon" not in male_chart or "moon_lon" not in female_chart:
raise ValueError("calc_synastry requires moon_lon in both male_chart and female_chart")
result = calc_ashtakoot(
float(male_chart["moon_lon"]),
float(female_chart["moon_lon"]),
male_chart=male_chart,
female_chart=female_chart,
)
# Historical test/API compatibility: expose BadConstellations as an additional Kuta.
# A bad constellation condition is present if Vedha or Rajju is adverse.
vedha_bad = result["additional_kutas"].get("Vedha") == "bad"
rajju_bad = result["additional_kutas"].get("Rajju", {}).get("result") == "bad"
result["additional_kutas"]["BadConstellations"] = "bad" if (vedha_bad or rajju_bad) else "good"
return result
+70
View File
@@ -0,0 +1,70 @@
"""Glue around the upstream full-synastry skill.
Two birth payloads become two native full-readings, then the untouched
``build_full_synastry_dossier``. Raman is applied to the snapshots the skill
already extracted. This module does not edit those upstream files.
"""
from __future__ import annotations
import importlib.util
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from typing import Any, Callable
from marriage_match import build_marriage_match_dossier
ROOT = Path(__file__).resolve().parents[1]
DOSSIER_SCRIPT = (
ROOT
/ "skills"
/ "jyotish-full-synastry-reading"
/ "scripts"
/ "build_full_synastry_dossier.py"
)
RULES_VERSION = "bde5d615:marriage_match_product_v1"
RAMAN_PROFILE = "raman_muhurtha_v1"
ReadingFn = Callable[[dict[str, Any]], dict[str, Any]]
def _dossier_builder():
spec = importlib.util.spec_from_file_location(
"build_full_synastry_dossier",
DOSSIER_SCRIPT,
)
if spec is None or spec.loader is None:
raise RuntimeError("full synastry dossier script is missing")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module.build_full_synastry_dossier
def build_synastry_full_dossier(
handler: Any,
person_a: dict[str, Any],
person_b: dict[str, Any],
*,
read_chart: ReadingFn | None = None,
) -> dict[str, Any]:
"""Run two full-readings in parallel, then both marriage-match profiles."""
read = read_chart or handler._compute_full_reading_for_thematic
with ThreadPoolExecutor(max_workers=2) as pool:
future_a = pool.submit(read, person_a)
future_b = pool.submit(read, person_b)
reading_a = future_a.result()
reading_b = future_b.result()
dossier = _dossier_builder()(reading_a, reading_b)
snapshots = dossier.get("chart_snapshots") if isinstance(dossier, dict) else {}
snapshots = snapshots if isinstance(snapshots, dict) else {}
raman = build_marriage_match_dossier(
snapshots.get("person_a") or {},
snapshots.get("person_b") or {},
profile_id=RAMAN_PROFILE,
)
return {
"rules_version": RULES_VERSION,
"dossier": dossier,
"raman_marriage_match": raman,
}
+1 -1
View File
@@ -365,7 +365,7 @@ def _register_existing_techniques():
module_path="transit.py", compute_func="computeTransit"),
TechniqueSpec("synastry", "合盘系统", TechniqueCategory.SYNASTRY,
TechniqueLevel.L2_STANDARD, TechniqueStatus.STABLE,
module_path="synastry.py", compute_func="calc_synastry"),
module_path="marriage_match.py", compute_func="build_marriage_match_dossier"),
TechniqueSpec("prashna", "问事系统", TechniqueCategory.PRASHNA,
TechniqueLevel.L2_STANDARD, TechniqueStatus.WIP,
module_path="prashna_context.py", compute_func="build_prashna_context"),