Files
Jyotisha/scripts/consultation_engine_field_bridge.py
T
Jesse_Chen 734d20590c
Independent Staging Quality Gate / validate (push) Failing after 31m48s
Independent Staging Quality Gate / publish (push) Has been skipped
feat(upstream): wire merged engine fields into chat and reports
Consultation and personal reports otherwise ignore the merged friendship table, timing seed, module audit, and Pratyantar windows.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-03 20:58:47 +08:00

288 lines
12 KiB
Python

#!/usr/bin/env python3
"""Attach merged-engine fields to the commercial consultation path.
Task 6 of TASK-upstream-sync-20260903: the three-way merge left
planetary_friendship, timing_narrative, module_execution_audit, and
pratyantar_dasha_timeline on the engine. The web consultation workflow does
not call cmd_full_reading, so those keys never reached /api/consultation_workflow
until this module copies a compact, allowlisted subset onto the chart and
response. Import-missing research helpers stay blocked. No nadi/classical
loci, no birth-data files.
"""
from __future__ import annotations
import sys
from datetime import datetime
from pathlib import Path
from typing import Any
_HEADLINE_MAX = 300
_LINE_MAX = 400
_LIST_MAX = 8
ASHTOTTARI_PARTIAL_NOTE = "参数敏感、未验证"
def _clip_text(value: Any, limit: int) -> str:
text = " ".join(str(value or "").split())
if len(text) <= limit:
return text
return text[: max(0, limit - 1)].rstrip() + ""
def _clip_lines(values: Any) -> list[str]:
if not isinstance(values, list):
return []
out: list[str] = []
for item in values:
text = _clip_text(item, _LINE_MAX)
if text:
out.append(text)
if len(out) >= _LIST_MAX:
break
return out
def _load_engine():
scripts_dir = str(Path(__file__).resolve().parent)
if scripts_dir not in sys.path:
sys.path.insert(0, scripts_dir)
try:
from scripts import jyotish_engine as engine
except ModuleNotFoundError: # pragma: no cover - script execution
import jyotish_engine as engine
return engine
def _modules_for_timing(modules: dict | None) -> dict[str, Any]:
view = dict(modules) if isinstance(modules, dict) else {}
dasha = dict(view.get("dasha") or {}) if isinstance(view.get("dasha"), dict) else {}
current = dasha.get("current_dasha") if isinstance(dasha.get("current_dasha"), dict) else {}
if not current.get("mahadasha"):
sub = view.get("dasha_sub_periods") if isinstance(view.get("dasha_sub_periods"), dict) else {}
running = sub.get("current") if isinstance(sub.get("current"), dict) else {}
maha = running.get("mahadasha") if isinstance(running.get("mahadasha"), dict) else {}
lord = maha.get("lord")
if lord:
dasha["current_dasha"] = {"mahadasha": lord}
view["dasha"] = dasha
return view
def compact_timing_narrative(modules: dict | None) -> dict[str, Any]:
"""Length-capped copy of the engine timing seed for the chat prompt pack."""
try:
engine = _load_engine()
payload = engine._build_timing_narrative_payload(_modules_for_timing(modules))
except Exception as exc:
return {"status": "blocked", "reason": f"timing_narrative_unavailable:{exc.__class__.__name__}"}
if not isinstance(payload, dict):
return {"status": "blocked", "reason": "timing_narrative_unavailable"}
return {
"status": payload.get("status") or "parameter_sensitive",
"headline": _clip_text(payload.get("headline"), _HEADLINE_MAX),
"strengths": _clip_lines(payload.get("strengths")),
"risks": _clip_lines(payload.get("risks")),
"boundaries": _clip_lines(payload.get("boundaries")),
}
def compact_planetary_friendship(snapshot: dict | None) -> dict[str, Any]:
if not isinstance(snapshot, dict):
return {"status": "blocked", "reason": "planetary_friendship_unavailable", "rows": []}
rows = []
for raw in snapshot.get("rows") or []:
if not isinstance(raw, dict) or not raw.get("planet"):
continue
compound = raw.get("compound") if isinstance(raw.get("compound"), dict) else {}
def names(key: str) -> list[str]:
values = compound.get(key)
if not isinstance(values, list):
return []
return [str(item) for item in values if item][:8]
rows.append({
"planet": str(raw["planet"]),
"great_friends": names("great_friends"),
"friends": names("friends"),
"neutral": names("neutral"),
"enemies": names("enemies"),
"great_enemies": names("great_enemies"),
})
if len(rows) >= 9:
break
return {
"status": snapshot.get("status") or "parameter_sensitive",
"rows": rows,
"boundary": str(snapshot.get("boundary") or "local raw relationship snapshot only"),
}
def compact_pratyantar_timeline(sub_periods: dict | None, reference_dt: datetime) -> dict[str, Any]:
"""Current and next Pratyantar boundaries for the running antardasha."""
if not isinstance(sub_periods, dict):
return {"status": "blocked", "reason": "dasha_sub_periods_missing"}
current = sub_periods.get("current") if isinstance(sub_periods.get("current"), dict) else {}
maha = current.get("mahadasha") if isinstance(current.get("mahadasha"), dict) else {}
antar = current.get("antardasha") if isinstance(current.get("antardasha"), dict) else {}
md_lord = str(maha.get("lord") or "")
ad_lord = str(antar.get("lord") or "")
start = _parse_date(antar.get("start"))
end = _parse_date(antar.get("end"))
if not md_lord or not ad_lord or start is None or end is None or end <= start:
return {"status": "blocked", "reason": "antardasha_boundaries_unavailable"}
engine = _load_engine()
try:
timeline = engine._build_pratyantar_timeline(md_lord, ad_lord, start, end, reference_dt=reference_dt)
except Exception as exc:
return {"status": "blocked", "reason": f"pratyantar_unavailable:{exc.__class__.__name__}"}
if not isinstance(timeline, list) or not timeline:
return {"status": "blocked", "reason": "pratyantar_timeline_empty"}
current_row = next((row for row in timeline if isinstance(row, dict) and row.get("is_current")), None)
if current_row is None:
current_row = timeline[0]
next_row = None
if isinstance(current_row, dict):
try:
index = timeline.index(current_row)
next_row = timeline[index + 1] if index + 1 < len(timeline) else None
except ValueError:
next_row = None
def boundary(row: dict | None) -> dict[str, str] | None:
if not isinstance(row, dict) or not row.get("lord"):
return None
return {
"lord": str(row["lord"]),
"start": str(row.get("start") or ""),
"end": str(row.get("end") or ""),
}
current_boundary = boundary(current_row)
next_boundary = boundary(next_row if isinstance(next_row, dict) else None)
return {
"status": "ready",
"current": current_boundary,
"next": next_boundary,
"boundary": "candidate window inside the running antardasha; not a verified event date",
}
def module_execution_audit_for_chart(chart: dict | None) -> list[dict[str, Any]]:
engine = _load_engine()
modules = (chart or {}).get("modules") if isinstance((chart or {}).get("modules"), dict) else {}
packet = {
"raw_full_reading": {"modules": modules},
"calculation_profile_id": "consultation_workflow_local",
"raw_module_usage_map": {},
}
try:
rows = engine._build_module_execution_audit(packet)
except Exception:
return []
if not isinstance(rows, list):
return []
compact = []
for row in rows:
if not isinstance(row, dict) or not row.get("module_id"):
continue
if str(row["module_id"]) == "module_execution_audit":
continue
compact.append({
"module_id": str(row["module_id"]),
"status": str(row.get("status") or "available"),
"conclusion_use": str(row.get("conclusion_use") or "raw_appendix_only"),
"limitation": _clip_text(row.get("limitation"), _LINE_MAX),
})
if len(compact) >= 40:
break
return compact
def extra_technique_audit_rows(chart: dict | None) -> list[dict[str, Any]]:
"""Audit rows derived from the merged fields. Partial never reads as certainty."""
modules = (chart or {}).get("modules") if isinstance((chart or {}).get("modules"), dict) else {}
friendship = modules.get("planetary_friendship") if isinstance(modules.get("planetary_friendship"), dict) else {}
friendship_rows = friendship.get("rows") if isinstance(friendship.get("rows"), list) else []
friendship_status = "executed" if friendship_rows else "blocked"
audit = module_execution_audit_for_chart(chart)
return [
{
"technique": "Planetary Friendship",
"status": friendship_status,
"system": "jyotish",
"boundary": "友敌等级表;不得当作确定性结论",
},
{
"technique": "Ashtottari Dasha",
"status": "partial",
"system": "jyotish",
"boundary": ASHTOTTARI_PARTIAL_NOTE,
},
{
"technique": "Module Execution Audit",
"status": "executed" if audit else "blocked",
"system": "jyotish",
"boundary": "附录可见;不得升格为确定性结论",
},
]
def attach_merged_engine_fields(chart: dict, *, reference_dt: datetime) -> dict:
"""Mutate chart.modules with friendship + pratyantar. Safe on empty charts."""
if not isinstance(chart, dict):
return chart
modules = chart.get("modules") if isinstance(chart.get("modules"), dict) else {}
chart["modules"] = modules
planets = chart.get("planets") if isinstance(chart.get("planets"), dict) else {}
if planets and not isinstance(modules.get("planetary_friendship"), dict):
try:
engine = _load_engine()
snapshot = engine._build_planetary_friendship_snapshot(planets)
modules["planetary_friendship"] = compact_planetary_friendship(snapshot)
except Exception as exc:
modules["planetary_friendship"] = {
"status": "blocked",
"reason": f"planetary_friendship_unavailable:{exc.__class__.__name__}",
"rows": [],
}
sub_periods = modules.get("dasha_sub_periods") if isinstance(modules.get("dasha_sub_periods"), dict) else None
pratyantar = compact_pratyantar_timeline(sub_periods, reference_dt)
if isinstance(sub_periods, dict):
sub_periods["pratyantar_dasha_timeline"] = pratyantar
modules["pratyantar_dasha_timeline"] = pratyantar
modules["ashtottari_dasha"] = {
"status": "parameter_sensitive",
"overlay_status": "partial",
"claim_boundary": ASHTOTTARI_PARTIAL_NOTE,
"execution_status": "not_asserted_as_deterministic",
}
modules["module_execution_audit"] = module_execution_audit_for_chart(chart)
return chart
def consultation_workflow_extra_fields(chart: dict | None) -> dict[str, Any]:
modules = (chart or {}).get("modules") if isinstance((chart or {}).get("modules"), dict) else {}
snapshot = (((chart or {}).get("ai_prompt_pack") or {}).get("evidence_snapshot") or {}) if isinstance(chart, dict) else {}
timing = snapshot.get("timing_narrative") if isinstance(snapshot.get("timing_narrative"), dict) else compact_timing_narrative(modules)
return {
"timing_narrative": timing,
"module_execution_audit": modules.get("module_execution_audit") or module_execution_audit_for_chart(chart),
"pratyantar_dasha_timeline": modules.get("pratyantar_dasha_timeline") or {"status": "blocked"},
}
def _parse_date(value: Any) -> datetime | None:
if isinstance(value, datetime):
return value.replace(tzinfo=None)
if isinstance(value, str) and value.strip():
text = value.strip().replace("Z", "+00:00")
try:
if "T" in text:
return datetime.fromisoformat(text).replace(tzinfo=None)
return datetime.strptime(text[:10], "%Y-%m-%d")
except ValueError:
return None
return None