#!/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_health_narrative(modules: dict | None, chart: dict | None = None) -> dict[str, Any]: """Length-capped non-medical health seed for the commercial prompt pack.""" try: engine = _load_engine() payload = engine._build_health_narrative_payload(modules, chart) except Exception as exc: return {"status": "blocked", "reason": f"health_narrative_unavailable:{exc.__class__.__name__}"} if not isinstance(payload, dict): return {"status": "blocked", "reason": "health_narrative_unavailable"} return { "status": "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