feat(synastry): write the full relationship report on demand

The pair page and reader replace the leftover chat draft, and the same pair is not charged again.

Co-Authored-By: Grok (agent loop)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jesse-ux
2026-10-19 10:03:55 +08:00
co-authored by Grok (agent loop) Cursor
parent 2130682d43
commit 9ea5b3b0c0
52 changed files with 2987 additions and 127 deletions
+7
View File
@@ -10520,6 +10520,13 @@ class JyotishAPIHandler(BaseHTTPRequestHandler, JyotishComputeService):
except match_api.MarriageMatchInputError as exc:
raise BadRequest(str(exc)) from exc
self._json(result)
elif path == '/api/synastry_full_reader_gate':
match_api = _load_local_module('marriage_match_service.api')
try:
result = match_api.check_synastry_reader(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)
+16
View File
@@ -78,6 +78,22 @@ def _commit_sha() -> str:
return os.environ.get("GITHUB_SHA") or ""
def check_synastry_reader(body: dict[str, Any]) -> dict[str, Any]:
"""Relationship-language gate. The body is the delivery plus the section inputs."""
if not isinstance(body, dict):
raise MarriageMatchInputError("request body must be an object")
from marriage_match_service.synastry_full_reader_gate import validate_synastry_reader
delivery = body.get("delivery")
section_inputs = body.get("sectionInputs")
language = body.get("language") or "zh"
if not isinstance(delivery, dict) or not isinstance(language, str):
raise MarriageMatchInputError("delivery and language are required")
if section_inputs is not None and not isinstance(section_inputs, dict):
raise MarriageMatchInputError("sectionInputs must be an object")
return validate_synastry_reader(delivery, section_inputs or {}, language)
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")
@@ -9,6 +9,7 @@ from __future__ import annotations
import importlib.util
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, Callable
@@ -41,6 +42,60 @@ def _dossier_builder():
return module.build_full_synastry_dossier
def _parse_day(value: Any) -> datetime | None:
if not isinstance(value, str) or not value.strip():
return None
text = value.strip()[:10]
try:
return datetime.strptime(text, "%Y-%m-%d")
except ValueError:
return None
def _clip_period(period: dict[str, Any], level: str, window_start: datetime, window_end: datetime) -> dict[str, Any] | None:
start = _parse_day(period.get("start") or period.get("start_datetime"))
end = _parse_day(period.get("end") or period.get("end_datetime"))
if start is None or end is None or end <= window_start or start >= window_end:
return None
lord = period.get("lord")
if not isinstance(lord, str) or not lord:
return None
lord_cn = period.get("lord_cn") if isinstance(period.get("lord_cn"), str) else lord
return {
"level": level,
"lord": lord,
"lordCn": lord_cn,
"start": max(start, window_start).strftime("%Y-%m-%d"),
"end": min(end, window_end).strftime("%Y-%m-%d"),
}
def upcoming_vimshottari(reading: dict[str, Any], today: str, years: int = 5) -> list[dict[str, Any]]:
"""Mahadasha and antardasha slices that overlap the next few years."""
packet = reading.get("raw_full_reading") if isinstance(reading.get("raw_full_reading"), dict) else reading
modules = packet.get("modules") if isinstance(packet, dict) else {}
dasha = modules.get("dasha") if isinstance(modules, dict) else {}
timeline = dasha.get("timeline") if isinstance(dasha, dict) else []
start = _parse_day(today) or datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
end = start + timedelta(days=365 * years)
rows: list[dict[str, Any]] = []
if not isinstance(timeline, list):
return rows
for mahadasha in timeline:
if not isinstance(mahadasha, dict):
continue
clipped = _clip_period(mahadasha, "md", start, end)
if clipped:
rows.append(clipped)
for antardasha in mahadasha.get("antardasha_timeline") or []:
if not isinstance(antardasha, dict):
continue
child = _clip_period(antardasha, "ad", start, end)
if child:
rows.append(child)
return rows
def build_synastry_full_dossier(
handler: Any,
person_a: dict[str, Any],
@@ -56,6 +111,7 @@ def build_synastry_full_dossier(
reading_a = future_a.result()
reading_b = future_b.result()
dossier = _dossier_builder()(reading_a, reading_b)
today = str(person_a.get("today") or person_b.get("today") or "")
snapshots = dossier.get("chart_snapshots") if isinstance(dossier, dict) else {}
snapshots = snapshots if isinstance(snapshots, dict) else {}
raman = build_marriage_match_dossier(
@@ -67,4 +123,8 @@ def build_synastry_full_dossier(
"rules_version": RULES_VERSION,
"dossier": dossier,
"raman_marriage_match": raman,
"timing_periods": {
"person_a": upcoming_vimshottari(reading_a if isinstance(reading_a, dict) else {}, today),
"person_b": upcoming_vimshottari(reading_b if isinstance(reading_b, dict) else {}, today),
},
}
@@ -0,0 +1,290 @@
"""Relationship-language gate for the full synastry report.
The upstream bilingual validator stays untouched. This gate only borrows its
private-evidence and supplementary-method lists. It judges the reader text
against the relationship-language guide, and it checks that each "盘面" line
matches the evidence it cites.
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[2]
SKILL_SCRIPTS = ROOT / "skills" / "jyotish-full-synastry-reading" / "scripts"
if str(SKILL_SCRIPTS) not in sys.path:
sys.path.insert(0, str(SKILL_SCRIPTS))
from validate_bilingual_synastry_delivery import ( # noqa: E402
PRIVATE_EVIDENCE_TERMS,
SUPPLEMENTARY_METHOD_TERMS,
)
SECTION_ORDER = (
"climate",
"emotion",
"communication",
"safety",
"conflict",
"daily",
"direction",
"timeline",
"practices",
)
PROSE_SECTIONS = SECTION_ORDER[:7]
EVIDENCE_LABELS = (
"multi_system_consensus",
"single_system_inference",
"parameter_sensitive",
"unclosed_divisional_chart",
"user_history_verification_required",
"blocked",
)
ZH_PLANETS = ("太阳", "月亮", "水星", "金星", "火星", "木星", "土星", "罗睺", "计都")
ZH_SIGNS = (
"白羊", "金牛", "双子", "巨蟹", "狮子", "处女",
"天秤", "天蝎", "射手", "摩羯", "水瓶", "双鱼",
)
ZH_TERMS = (
*ZH_PLANETS,
*ZH_SIGNS,
"宫", "星宿", "上升", "分盘", "九分盘", "婚姻分盘",
"大运", "小运", "行运", "Dasha", "Koota", "Kuja", "火星冲克",
"Nadi", "Yoni", "Varna", "Ashtakoota", "Papasamya", "UL", "DK", "满分",
)
ZH_JUDGEMENTS = (
"克", "煞", "凶", "吉", "注定", "必然", "命中", "一定会",
"合不合", "不合", "相合", "不适合", "适合结婚", "不宜",
"灵魂伴侣", "宇宙安排", "百分百", "会不会在一起",
)
EN_PLANETS = ("sun", "moon", "mercury", "venus", "mars", "jupiter", "saturn", "rahu", "ketu")
EN_SIGNS = (
"aries", "taurus", "gemini", "cancer", "leo", "virgo",
"libra", "scorpio", "sagittarius", "capricorn", "aquarius", "pisces",
)
EN_TERMS = (
*EN_PLANETS,
*EN_SIGNS,
"house", "nakshatra", "ascendant", "navamsa", "dasha", "transit", "koota",
)
EN_JUDGEMENTS = (
"soulmate", "destined", "inevitable", "guaranteed", "the universe",
"not suitable", "will marry", "will divorce",
)
ZH_OPENING = ("这段关系的核心不是", "不是有没有")
EN_OPENING = ("the core of this relationship is not", "it is not whether")
ZH_EVENT = re.compile(r"(19|20)\d{2}\s*年.{0,12}(结婚|领证|分手|离婚)")
EN_EVENT = re.compile(r"\b(19|20)\d{2}\b.{0,24}\b(marry|married|marriage|divorce|break up)\b", re.I)
ZH_SCORE = re.compile(r"\d+\s*分")
EN_SCORE = re.compile(r"\b\d+\s*(points|point|score)\b", re.I)
ZH_HOUSE = re.compile(r"(?:第)?[一二三四五六七八九十\d]{1,3}宫")
EN_HOUSE = re.compile(r"\b(?:house\s*)?\d{1,2}(?:st|nd|rd|th)?\s+house\b", re.I)
D_CHART = re.compile(r"\bD\d{1,2}\b", re.I)
def _fail(section: str, code: str, detail: str) -> dict[str, str]:
return {"section": section, "code": code, "detail": detail}
def _contains_any(text: str, terms: tuple[str, ...]) -> str | None:
for term in terms:
if term and term in text:
return term
return None
def _contains_word(text: str, words: tuple[str, ...]) -> str | None:
lowered = text.lower()
for word in words:
if re.search(rf"\b{re.escape(word.lower())}\b", lowered):
return word
return None
def _first_sentence(text: str) -> str:
match = re.split(r"[。!?.!?]", text.strip(), maxsplit=1)
return match[0] if match else text.strip()
def _basis_tokens(basis: str, language: str) -> list[str]:
tokens: list[str] = []
if language == "zh":
tokens.extend(term for term in ZH_PLANETS if term in basis)
tokens.extend(match.group(0) for match in ZH_HOUSE.finditer(basis))
if "大运" in basis:
tokens.append("大运")
if "小运" in basis:
tokens.append("小运")
else:
tokens.extend(word for word in EN_PLANETS if re.search(rf"\b{word}\b", basis, re.I))
tokens.extend(match.group(0) for match in EN_HOUSE.finditer(basis))
if re.search(r"\bdasha\b", basis, re.I):
tokens.append("dasha")
tokens.extend(match.group(0) for match in D_CHART.finditer(basis))
return tokens
def _input_text(section_inputs: dict[str, Any], section_id: str) -> str:
value = section_inputs.get(section_id, "")
if isinstance(value, str):
return value
return json.dumps(value, ensure_ascii=False, sort_keys=True)
def _scan_prose(text: str, language: str, section: str, *, opening: bool) -> list[dict[str, str]]:
failures: list[dict[str, str]] = []
private = _contains_any(text, PRIVATE_EVIDENCE_TERMS)
supplementary = _contains_any(text, SUPPLEMENTARY_METHOD_TERMS)
if private:
failures.append(_fail(section, "private_term", private))
if supplementary:
failures.append(_fail(section, "supplementary_method", supplementary))
if language == "zh":
term = _contains_any(text, ZH_TERMS)
judgement = _contains_any(text, ZH_JUDGEMENTS)
if term:
failures.append(_fail(section, "banned_term", term))
if judgement:
failures.append(_fail(section, "banned_judgement", judgement))
if ZH_SCORE.search(text):
failures.append(_fail(section, "score", "数字分"))
if ZH_EVENT.search(text):
failures.append(_fail(section, "dated_event", "年份事件"))
if D_CHART.search(text):
failures.append(_fail(section, "banned_term", "D盘"))
if opening and _contains_any(_first_sentence(text), ZH_OPENING):
failures.append(_fail(section, "analytic_opening", _first_sentence(text)))
else:
term = _contains_word(text, EN_TERMS)
judgement = _contains_word(text, EN_JUDGEMENTS)
if term:
failures.append(_fail(section, "banned_term", term or ""))
if judgement:
failures.append(_fail(section, "banned_judgement", judgement or ""))
if EN_SCORE.search(text):
failures.append(_fail(section, "score", "score"))
if EN_EVENT.search(text):
failures.append(_fail(section, "dated_event", "dated event"))
if D_CHART.search(text):
failures.append(_fail(section, "banned_term", "D chart"))
if opening and _contains_word(_first_sentence(text), EN_OPENING):
failures.append(_fail(section, "analytic_opening", _first_sentence(text)))
return failures
def validate_synastry_reader(
delivery: dict[str, Any],
section_inputs: dict[str, Any] | None = None,
language: str = "zh",
) -> dict[str, Any]:
"""Return ``{ok, failures}``. ``section_inputs`` is what the model was shown."""
if language not in {"zh", "en"}:
return {"ok": False, "failures": [_fail("delivery", "language", language)]}
inputs = section_inputs or {}
failures: list[dict[str, str]] = []
sections = delivery.get("sections")
if not isinstance(sections, list):
return {"ok": False, "failures": [_fail("delivery", "sections", "missing")]}
by_id = {
str(row.get("id")): row
for row in sections
if isinstance(row, dict) and row.get("id")
}
if [row for row in SECTION_ORDER if row not in by_id] or [str(row.get("id")) for row in sections] != list(SECTION_ORDER):
failures.append(_fail("delivery", "section_order", "九节顺序不对"))
prose_characters = 0
prose_words = 0
for section_id in PROSE_SECTIONS:
row = by_id.get(section_id)
if not isinstance(row, dict):
failures.append(_fail(section_id, "missing_section", section_id))
continue
text = str(row.get("text") or "")
prose_characters += len(re.sub(r"\s+", "", text))
prose_words += len(re.findall(r"\b[\w']+\b", text))
minimum = 120 if language == "zh" else 80
measured = len(re.sub(r"\s+", "", text)) if language == "zh" else len(re.findall(r"\b[\w']+\b", text))
if measured < minimum:
failures.append(_fail(section_id, "too_short", str(measured)))
failures.extend(_scan_prose(text, language, section_id, opening=section_id == "climate"))
reasons = row.get("reasons")
if not isinstance(reasons, list) or not 2 <= len(reasons) <= 3:
failures.append(_fail(section_id, "reason_count", "需要两到三条分析"))
continue
source = _input_text(inputs, section_id)
for index, reason in enumerate(reasons):
if not isinstance(reason, dict):
failures.append(_fail(section_id, "reason_shape", str(index)))
continue
plain = str(reason.get("plain") or "")
basis = str(reason.get("basis") or "")
evidence = str(reason.get("evidence") or "")
label = str(reason.get("label") or "")
failures.extend(_scan_prose(plain, language, section_id, opening=False))
if not evidence or evidence not in source:
failures.append(_fail(section_id, "evidence_missing", evidence or str(index)))
if label not in EVIDENCE_LABELS:
failures.append(_fail(section_id, "evidence_label", label or str(index)))
for token in _basis_tokens(basis, language):
if token.lower() not in evidence.lower() and token not in evidence:
failures.append(_fail(section_id, "basis_mismatch", token))
timeline = by_id.get("timeline") if isinstance(by_id.get("timeline"), dict) else {}
segments = timeline.get("segments") if isinstance(timeline, dict) else None
if not isinstance(segments, list) or not 3 <= len(segments) <= 5:
failures.append(_fail("timeline", "segment_count", "需要三到五段"))
else:
for segment in segments:
if not isinstance(segment, dict):
failures.append(_fail("timeline", "segment_shape", "bad"))
continue
description = str(segment.get("text") or "")
failures.extend(_scan_prose(description, language, "timeline", opening=False))
if not str(segment.get("basis") or "").strip():
failures.append(_fail("timeline", "basis_missing", str(segment.get("id") or "")))
practices = by_id.get("practices") if isinstance(by_id.get("practices"), dict) else {}
items = practices.get("items") if isinstance(practices, dict) else None
if not isinstance(items, list) or not items:
failures.append(_fail("practices", "missing_items", "需要共同练习"))
else:
practice_source = _input_text(inputs, "practices")
for item in items:
if not isinstance(item, dict):
failures.append(_fail("practices", "item_shape", "bad"))
continue
text = str(item.get("text") or "")
evidence = str(item.get("evidence") or "")
failures.extend(_scan_prose(text, language, "practices", opening=False))
if not evidence or evidence not in practice_source:
failures.append(_fail("practices", "evidence_missing", evidence or "item"))
total_minimum = 1800 if language == "zh" else 1100
total = prose_characters if language == "zh" else prose_words
if total < total_minimum:
failures.append(_fail("delivery", "too_short", str(total)))
return {"ok": not failures, "failures": failures}
def main() -> None:
payload = json.load(sys.stdin)
if not isinstance(payload, dict):
raise SystemExit("payload must be an object")
result = validate_synastry_reader(
payload.get("delivery") if isinstance(payload.get("delivery"), dict) else {},
payload.get("sectionInputs") if isinstance(payload.get("sectionInputs"), dict) else {},
str(payload.get("language") or "zh"),
)
json.dump(result, sys.stdout, ensure_ascii=False)
if not result["ok"]:
raise SystemExit(1)
if __name__ == "__main__":
main()