feat(consult): copy the full-data packet into a per-domain judgment table

Dialogue cards now take numbers from the same packet as the full-data report. Each of the twelve domains gets one table the card builder assembles. Lookups allow three calls a turn. further_reading is a per-domain technique index. BUG-1293, BUG-1294, BUG-1295.
This commit is contained in:
jesse-ux
2026-10-09 10:00:45 +08:00
parent 8bb7ff9383
commit 3874a328a5
24 changed files with 2082 additions and 74 deletions
+824
View File
@@ -0,0 +1,824 @@
"""Copy consult-card facts from the full-data packet.
Values are packet leaves. A missing leaf is a gap. A technique with no
``references/`` heading is blocked. D16 and finer, plus Ghati, stay off the
card unless the birth minute is confirmed. This module does not assemble the
judgment table; the card builder does that from these leaves.
"""
from __future__ import annotations
import json
from typing import Any
from pl9_full_data_export import read_full_data_domain
PRECISION_SENTENCE = "出生时间精度不够,不参与"
RAHU_NOTE = "罗计不单独定功能吉凶"
CONSULTATION_DOMAINS = (
"career", "marriage", "wealth", "health", "education", "migration",
"family", "parents", "children", "annual", "timing", "general",
)
PACKET_DOMAIN = {
"career": "career",
"marriage": "relationship",
"wealth": "wealth",
"health": "health",
"education": "education",
"migration": "relocation",
"family": "family",
"parents": "parents",
"children": "children",
"annual": "annual",
"timing": "timing",
"general": "general",
}
PLANETS = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu")
VARGA_KEYS = {
"D2": ("D2_Hora",),
"D3": ("D3_Drekkana",),
"D4": ("D4_Turyamsa", "D4_Chaturthamsa"),
"D5": ("D5_Panchamsa",),
"D6": ("D6_Shashthamsa",),
"D7": ("D7_Saptamsa",),
"D8": ("D8_Ashtamsa",),
"D9": ("D9_Navamsa",),
"D10": ("D10_Dasamsa",),
"D11": ("D11_Rudramsa",),
"D12": ("D12_Dwadashamsa",),
"D16": ("D16_Shodasamsa",),
"D20": ("D20_Vimsamsa",),
"D24": ("D24_Siddhamsa", "D24_Chaturvimsamsa"),
"D27": ("D27_Bhamsa",),
"D30": ("D30_Trimsamsa",),
"D40": ("D40_Khavedamsa",),
"D45": ("D45_Akshavedamsa",),
"D60": ("D60_Shashtyamsa",),
}
EXTENDED_VARGA = {"D5": "Panchamsa", "D6": "Shashthamsa", "D8": "Ashtamsa", "D11": "Rudramsa"}
PRECISION_VARGA = {"D16", "D20", "D24", "D27", "D30", "D40", "D45", "D60"}
ROUTER = "references/strict-workflow-router.md"
MAPPING = "references/house-domain-planet-mapping.md"
CAREER = "references/event_judgment_career.md"
MARRIAGE = "references/event_judgment_marriage.md"
WEALTH = "references/event_judgment_wealth.md"
JAIMINI = "references/jaimini-complete-system.md"
DEEP = "references/deep-analysis-complete-workflow.md"
VARSHAPHALA = "references/varshaphala-annual-chart-guide.md"
PDF = "references/pdf-chart-reading-guide.md"
RECTIFY = "references/birth-time-rectification-decision-tree.md"
SHODASA = "references/shodasavarga-complete-guide.md"
BADHAKA = "references/badhaka-obstacle-planet-guide.md"
NARAYANA = "references/bphs-ch48-narayana-dasha.md"
VIM = "references/vimshottari_dasha_guide.md"
def dig(node: Any, path: str) -> Any:
current = node
for part in path.split("."):
if isinstance(current, dict) and part in current:
current = current[part]
elif isinstance(current, list) and part.isdigit():
index = int(part)
if index < 0 or index >= len(current):
return None
current = current[index]
else:
return None
return current
def _gap(path: str | None = None) -> dict[str, Any]:
item: dict[str, Any] = {"status": "gap", "check": "path"}
if path:
item["path"] = path
return item
def _leaf(view: dict, path: str) -> dict[str, Any]:
value = dig(view, path)
if value is None or value == "" or value == {} or value == []:
return _gap(path)
if isinstance(value, (str, int, float, bool)):
return {"status": "value", "path": path, "value": value, "check": "path"}
encoded = json.dumps(value, ensure_ascii=False, default=str)
if "tajika_named_yogas" in encoded or len(encoded) > 800:
return {"status": "value", "path": path, "check": "nonempty"}
return {"status": "value", "path": path, "value": value, "check": "path"}
def _first(view: dict, paths: list[str]) -> dict[str, Any]:
seen = paths[0] if paths else None
for path in paths:
item = _leaf(view, path)
if item["status"] == "value":
return item
seen = path
return _gap(seen)
def minute_confirmed_from_body(body: dict | None) -> bool:
"""Same gate as persistedConsultationMode's verified_chart, on the raw body.
``birth_time_accuracy`` counts only when the request set it. The API's
internal default of confirmed is not a confirmation.
"""
payload = body or {}
if payload.get("hour") is None or payload.get("minute") is None:
return False
status = payload.get("birth_time_status")
if status in ("accepted", "confirmed"):
return True
if "birth_time_accuracy" in payload and payload.get("birth_time_accuracy") == "confirmed":
return True
# `rectified` is the profile map for an accepted or confirmed minute.
# `minute` also covers a reported clock that is not accepted, and that
# clock still withholds D16 and Ghati.
if payload.get("declared_accuracy") == "rectified":
return True
return False
def _planet_base(view: dict, name: str) -> str | None:
for path in (
f"core_chart.planets.{name}",
f"worksheets.d1_rasi_bhava.planets.{name}",
):
node = dig(view, path)
if isinstance(node, dict) and node.get("sign"):
return path
return None
def _functional_role(view: dict, name: str) -> dict[str, Any]:
base = "worksheets.strengths_and_scores.functional_benefic_malefic"
if name in ("Rahu", "Ketu"):
return {"status": "value", "path": base, "value": None, "check": "node_role", "note": RAHU_NOTE}
table = dig(view, base)
if not isinstance(table, dict):
return _gap(base)
for key in ("functional_benefics", "functional_malefics", "functional_neutrals", "yogakarakas"):
names = table.get(key)
if isinstance(names, list) and name in names:
return {"status": "value", "path": f"{base}.{key}", "value": key, "check": "membership", "member": name}
return {"status": "value", "path": base, "value": None, "check": "node_role", "note": RAHU_NOTE}
def _bav(view: dict, name: str, sign_idx: Any) -> dict[str, Any]:
path = f"worksheets.strengths_and_scores.ashtakavarga.bav.{name}.bindus"
bindus = dig(view, path)
if not isinstance(bindus, list) or not isinstance(sign_idx, int):
return _gap(path)
if sign_idx < 0 or sign_idx >= len(bindus):
return _gap(path)
return {"status": "value", "path": path, "index": sign_idx, "value": bindus[sign_idx], "check": "index"}
def _varga_chart(view: dict, code: str) -> tuple[Any, str | None]:
for key in VARGA_KEYS.get(code, ()):
path = f"worksheets.divisional_and_special_charts.varga_full.{key}"
node = dig(view, path)
if isinstance(node, dict) and node:
return node, path
extended = EXTENDED_VARGA.get(code)
if extended:
path = f"worksheets.divisional_and_special_charts.varga_extended.{extended}"
node = dig(view, path)
if isinstance(node, dict) and node:
return node, path
return None, None
def _varga_planet_path(chart_path: str, chart: dict, name: str) -> str | None:
direct = chart.get(name)
if isinstance(direct, dict):
return f"{chart_path}.{name}"
planets = chart.get("planets")
if isinstance(planets, dict) and isinstance(planets.get(name), dict):
return f"{chart_path}.planets.{name}"
return None
def _domain_varga_house(view: dict, name: str, code: str, minute_ok: bool) -> dict[str, Any]:
if code in PRECISION_VARGA and not minute_ok:
return {"status": "withheld", "value": PRECISION_SENTENCE, "check": "withheld"}
chart, chart_path = _varga_chart(view, code)
if chart_path:
planet_path = _varga_planet_path(chart_path, chart, name)
if planet_path:
house = _leaf(view, f"{planet_path}.house")
if house["status"] == "value":
return house
linked = _leaf(view, f"worksheets.advanced_systems.inter_chart_linkage.{name}.{code}.house")
if linked["status"] == "value":
return linked
return _gap(f"varga.{code}.{name}.house")
def _d9(view: dict, name: str) -> tuple[dict[str, Any], dict[str, Any]]:
chart, chart_path = _varga_chart(view, "D9")
if not chart_path:
return _gap("varga.D9"), _gap("varga.D9.dignity")
planet_path = _varga_planet_path(chart_path, chart, name)
sign = _leaf(view, f"{planet_path}.sign") if planet_path else _gap(f"{chart_path}.{name}.sign")
dignity = _leaf(view, f"{chart_path}._dignity.{name}")
if dignity["status"] != "value" and planet_path:
dignity = _leaf(view, f"{planet_path}.dignity")
return sign, dignity
def _co_tenants(view: dict, name: str, house: Any) -> dict[str, Any]:
if not isinstance(house, int):
return {"status": "gap", "check": "selection"}
names = []
for other in PLANETS:
if other == name:
continue
base = _planet_base(view, other)
if base and dig(view, f"{base}.house") == house:
names.append(other)
return {"status": "value", "check": "selection", "value": names}
def _planet_record(view: dict, name: str, varga: str | None, minute_ok: bool) -> dict[str, Any]:
base = _planet_base(view, name)
if not base:
return {"present": False}
sign_idx = dig(view, f"{base}.sign_idx")
house = dig(view, f"{base}.house")
d9_sign, d9_dignity = _d9(view, name)
record = {
"present": True,
"d1_sign": _leaf(view, f"{base}.sign"),
"d1_house": _leaf(view, f"{base}.house"),
"d1_dignity": _leaf(view, f"{base}.status"),
"rasi_lord": _leaf(view, f"{base}.rasi_lord"),
"combust": _leaf(view, f"{base}.combust"),
"aspected_by": _leaf(view, f"{base}.aspected_by"),
"functional_role": _functional_role(view, name),
"shadbala_rupas": _leaf(view, f"worksheets.strengths_and_scores.shadbala.planets.{name}.total_rupas"),
"shadbala_min": _leaf(view, f"worksheets.strengths_and_scores.shadbala.planets.{name}.min_required"),
"bav": _bav(view, name, sign_idx),
"d9_sign": d9_sign,
"d9_dignity": d9_dignity,
"co_tenants": _co_tenants(view, name, house),
}
if varga:
record["domain_varga_house"] = _domain_varga_house(view, name, varga, minute_ok)
return record
def _house_record(view: dict, number: int) -> dict[str, Any]:
base = f"core_chart.houses.house_{number}"
if not isinstance(dig(view, base), dict):
base = f"worksheets.d1_rasi_bhava.houses.house_{number}"
occupants = []
for name in PLANETS:
planet_base = _planet_base(view, name)
if planet_base and dig(view, f"{planet_base}.house") == number:
occupants.append(name)
return {
"sign": _leaf(view, f"{base}.cusp_sign"),
"lord": _leaf(view, f"{base}.lord"),
"sav_score": _leaf(view, f"worksheets.strengths_and_scores.ashtakavarga.house_scores.house_{number}.sav_score"),
"sav_sodhita": _leaf(view, f"worksheets.strengths_and_scores.ashtakavarga.house_scores.house_{number}.sav_sodhita"),
"bhava_bala": _first(view, [
f"worksheets.strengths_and_scores.bhava_bala.{number}",
f"worksheets.strengths_and_scores.shadbala.bhava_bala.{number}",
]),
"occupants": {"status": "value", "check": "selection", "value": occupants},
}
def _current_period_lord(view: dict, direct: str, timeline: str) -> dict[str, Any]:
found = _leaf(view, direct)
if found["status"] == "value":
return found
rows = dig(view, timeline)
if isinstance(rows, list):
for index, row in enumerate(rows):
if isinstance(row, dict) and row.get("is_current") is True and isinstance(row.get("lord"), str):
return {"status": "value", "path": f"{timeline}.{index}.lord", "value": row["lord"], "check": "path"}
return _gap(direct)
def _timing(view: dict) -> dict[str, Any]:
dasha = "worksheets.timing_and_predictive_systems.dasha.current_dasha"
ad = _current_period_lord(view, f"{dasha}.antardasha.lord", f"{dasha}.antardasha_timeline")
pd = _gap(f"{dasha}.pratyantar.lord")
rows = dig(view, f"{dasha}.antardasha_timeline")
if isinstance(rows, list):
for index, row in enumerate(rows):
if not (isinstance(row, dict) and row.get("is_current") is True):
continue
nested = row.get("pratyantar_dasha_timeline")
if isinstance(nested, list):
for sub, item in enumerate(nested):
if isinstance(item, dict) and item.get("is_current") is True and isinstance(item.get("lord"), str):
pd = {
"status": "value",
"path": f"{dasha}.antardasha_timeline.{index}.pratyantar_dasha_timeline.{sub}.lord",
"value": item["lord"],
"check": "path",
}
break
narayana = "worksheets.timing_and_predictive_systems.narayana_dasha.current_dasha"
return {
"md": _leaf(view, f"{dasha}.lord"),
"ad": ad,
"pd": pd,
"narayana_sign": _leaf(view, f"{narayana}.md.sign"),
"narayana_lord": _leaf(view, f"{narayana}.md.lord"),
"narayana_ad_sign": _leaf(view, f"{narayana}.ad.sign"),
"narayana_ad_lord": _leaf(view, f"{narayana}.ad.lord"),
}
def _karaka(view: dict, table: str, role: str) -> dict[str, Any]:
key = "karaka_table" if table == "7" else "karaka_table_8"
folder = "chara_karaka_7" if table == "7" else "chara_karaka_8"
return _leaf(view, f"worksheets.advanced_systems.jaimini.{folder}.{key}.{role}.planet")
def _point(view: dict, paths: list[str], *, precision: bool, minute_ok: bool) -> dict[str, Any]:
if precision and not minute_ok:
return {"status": "withheld", "value": PRECISION_SENTENCE, "check": "withheld", "path": paths[0] if paths else None}
return _first(view, paths)
def _blocked(item_id: str, citation: str) -> dict[str, Any]:
return {"id": item_id, "status": "blocked", "citation": citation, "check": "blocked"}
def _sourced(item_id: str, citation: str, leaf: dict[str, Any]) -> dict[str, Any]:
return {"id": item_id, "citation": citation, **leaf}
# Static §3A catalog. blocked items have no packet path. sourced items name a file.
def domain_source_catalog() -> dict[str, list[dict[str, str]]]:
shared = [
("sudarshana", "sourced", f"{ROUTER} Sudarshana Chakra"),
("d9", "sourced", f"{ROUTER} §2 D9 Navamsa"),
("bhava_chalit", "sourced", f"{ROUTER} Full Bhava Chalit"),
("shadbala", "sourced", f"{ROUTER} §2 Shadbala"),
("bhava_bala", "sourced", f"{ROUTER} §2 Shadbala"),
("avastha", "sourced", "references/deep-varga-avastha-execution-guide.md"),
("vimsopaka", "sourced", f"{DEEP} Vimsopaka"),
("ashtakavarga", "sourced", f"{ROUTER} §2 Ashtakavarga"),
("functional", "sourced", f"{ROUTER} §2 Functional benefic/malefic"),
("parashari_aspects", "blocked", f"{ROUTER} 未找到把 Parashari 星照单独列为参与层的段落"),
("jaimini_aspects", "sourced", f"{JAIMINI} Jaimini Drishti"),
("argala", "sourced", "references/argala-complete-guide.md"),
("dispositor_chain", "sourced", f"{ROUTER} §2 dispositor chain"),
("vimshottari", "sourced", VIM),
("narayana", "sourced", NARAYANA),
("yogini", "sourced", "references/alternative-dasha-systems.md"),
("transits", "sourced", f"{ROUTER} §2 Transit"),
("double_transit", "sourced", f"{CAREER} §2 Transit / Double Transit"),
("yogas_hits", "sourced", "references/event_judgment_skeleton.md"),
("kp", "sourced", "决策 8:KP 不参与"),
]
extra = {
"career": [
("D10", "sourced", f"{ROUTER} §2 D10 for career"),
("A10", "sourced", f"{CAREER} §2 A10 / Karma Pada"),
("AL", "sourced", f"{PDF} Arudha Lagna"),
("AmK", "sourced", f"{JAIMINI} Amatyakaraka"),
("Karakamsha", "sourced", f"{CAREER} §2 Karakamsha"),
("Ghati", "sourced", f"{DEEP} Ghati Lagna 权力指标"),
("Vighati", "blocked", "references/ 未找到 Vighati 条目"),
("natural:Sun", "sourced", f"{CAREER} §2 Sun"),
("natural:Saturn", "sourced", f"{CAREER} §2 Saturn"),
("natural:Mercury", "sourced", f"{CAREER} §2 Mercury"),
("natural:Jupiter", "sourced", f"{CAREER} §2 Jupiter"),
("house:10", "sourced", f"{CAREER} §2 10H"),
("D1_D9_D10", "sourced", f"{ROUTER} §2 D10 for career"),
],
"wealth": [
("D2", "sourced", f"{WEALTH} §3 D2"),
("D11", "sourced", f"{WEALTH} §3 D11"),
("Hora", "sourced", f"{DEEP} Hora Lagna 财富指标"),
("Indu", "blocked", "references/ 未找到 Indu Lagna 财富条目"),
("Sree", "sourced", "references/promise-assessment-templates.md Hora Lagna + Sree Lagna"),
("AL2", "blocked", f"{WEALTH} §3 未列出从 AL 起第 2 宫"),
("AL11", "blocked", f"{WEALTH} §3 未列出从 AL 起第 11 宫"),
("natural:Jupiter", "sourced", f"{WEALTH} §3 Jupiter"),
("natural:Venus", "sourced", f"{WEALTH} §3 Venus"),
("natural:Mercury", "sourced", f"{WEALTH} §3 Mercury"),
("house:2", "sourced", f"{WEALTH} §3 2H"),
("house:11", "sourced", f"{WEALTH} §3 11H"),
("house:5", "sourced", f"{WEALTH} §3 5H"),
("house:9", "sourced", f"{WEALTH} §3 9H"),
("house:10", "sourced", f"{WEALTH} §3 10H"),
],
"marriage": [
("D9_7", "sourced", f"{MARRIAGE} §2 D9 7H / 7L"),
("UL", "sourced", f"{MARRIAGE} §2 UL"),
("UL2", "blocked", f"{MARRIAGE} §2 未列出 UL 起第 2 宫"),
("A7", "blocked", f"{MARRIAGE} §2 未列出 A7"),
("DK", "sourced", f"{JAIMINI} Darakaraka"),
("natural:Venus", "sourced", f"{MARRIAGE} §2 Venus"),
("natural:Jupiter", "sourced", f"{MARRIAGE} §2 Jupiter"),
("Vivah", "sourced", f"{MARRIAGE} §2 Vivah Saham"),
("Mars", "sourced", f"{MARRIAGE} §2 Mars"),
("house:7", "sourced", f"{MARRIAGE} §2 7H"),
],
"health": [
("D6", "sourced", f"{ROUTER} §7 D6"),
("D8", "sourced", f"{ROUTER} §7 D8"),
("D30", "sourced", f"{ROUTER} §7 D30"),
("drekkana22", "sourced", f"{SHODASA} 第22Drekkana与寿限"),
("navamsa64", "blocked", "references/ 未找到第 64 个 Navamsa 条目"),
("Badhaka", "sourced", BADHAKA),
("Gulika", "blocked", "references/prashna-complete-guide.md 只在问事章写 Gulika,未列入本命健康参与层"),
("Mandi", "blocked", "references/prashna-complete-guide.md 只在问事章写 Mandi,未列入本命健康参与层"),
("Pranapada", "blocked", "references/prashna-complete-guide.md 的 Pranapada 是问事替代计算,未列入本命健康参与层"),
("natural:Sun", "sourced", f"{ROUTER} §7 Sun"),
("natural:Moon", "sourced", f"{ROUTER} §7 Moon"),
("natural:Saturn", "sourced", f"{ROUTER} §7 Saturn"),
("natural:Mars", "sourced", f"{ROUTER} §7 Mars"),
("house:1", "sourced", f"{ROUTER} §7 Lagna"),
("house:6", "sourced", f"{ROUTER} §7 6H"),
("house:8", "sourced", f"{ROUTER} §7 8H"),
("house:12", "sourced", f"{ROUTER} §7 12H"),
],
"children": [
("D7", "sourced", f"{ROUTER} §2 D7 for children"),
("PK", "sourced", f"{JAIMINI} Putrakaraka"),
("natural:Jupiter", "sourced", f"{MAPPING} 子女 木星"),
("house:5", "sourced", f"{MAPPING} 子女 5宫"),
],
"parents": [
("D12", "sourced", f"{ROUTER} §2 D12 for family/ancestral themes"),
("MK", "sourced", f"{JAIMINI} Matrikaraka"),
("PiK", "sourced", f"{JAIMINI} 可选PiK"),
("natural:Sun", "sourced", f"{MAPPING} 父亲 太阳"),
("natural:Moon", "sourced", f"{MAPPING} 母亲 月亮"),
("house:4", "sourced", f"{MAPPING} 母亲 4宫"),
("house:9", "sourced", f"{MAPPING} 父亲 9宫"),
],
"education": [
("D24", "sourced", f"{ROUTER} §2 D24 for education"),
("D5", "blocked", "references/ 未找到把 D5 标成学业分盘的段落"),
("natural:Mercury", "sourced", f"{MAPPING} 教育 水星"),
("natural:Jupiter", "sourced", f"{MAPPING} 教育 木星"),
("house:4", "sourced", f"{MAPPING} 教育(基础教育)4宫"),
("house:5", "sourced", f"{MAPPING} 智力 5宫"),
("house:9", "sourced", f"{MAPPING} 高等教育 9宫"),
],
"migration": [
("D4", "sourced", f"{RECTIFY} 房产/搬迁 D4"),
("D12", "blocked", f"{ROUTER} §2 把 D12 标给家族,未标给迁居"),
("natural:Rahu", "sourced", f"{MAPPING} 罗睺 外国 / 海外发展"),
("house:4", "sourced", f"{RECTIFY} 房产/搬迁"),
("house:9", "sourced", f"{MAPPING} 长途旅行 9宫"),
("house:12", "sourced", f"{MAPPING} 外国事务 12宫"),
],
"family": [
("D4", "blocked", f"{RECTIFY} 把 D4 标给房产/搬迁,未标给家庭领域"),
("D7", "blocked", f"{ROUTER} §2 把 D7 标给子女,未标给家庭领域"),
("D12", "sourced", f"{ROUTER} §2 D12 for family/ancestral themes"),
("natural", "blocked", f"{MAPPING} 未给出家庭领域的一组自然代表星"),
],
"annual": [
("tajika", "sourced", VARSHAPHALA),
("Muntha", "sourced", f"{VARSHAPHALA} Muntha"),
("year_lord", "sourced", f"{VARSHAPHALA} 年主星"),
("Mudda", "sourced", f"{VARSHAPHALA} Mudda Dasha"),
("Sahams", "sourced", VARSHAPHALA),
("natural", "blocked", "references/ 未给出流年单独的一组自然代表星"),
("house", "blocked", "references/ 未把流年钉在单一本命宫"),
],
"timing": [
("narayana_sub", "sourced", NARAYANA),
("year_chart", "sourced", VARSHAPHALA),
("natural", "blocked", "references/ 未给出时间领域单独的一组自然代表星"),
("house", "blocked", "references/ 未把时间领域钉在单一本命宫"),
],
"general": [
("D1", "sourced", f"{ROUTER} §2 D1 Rashi"),
("D9", "sourced", f"{ROUTER} §2 D9 Navamsa"),
("D10", "blocked", f"{ROUTER} §2 把 D10 标给事业,未标给综合领域"),
("D2", "blocked", f"{ROUTER} §2 把 D2 标给财富,未标给综合领域"),
("AL", "sourced", f"{PDF} Arudha Lagna"),
("Karakamsha", "sourced", f"{JAIMINI} Karakamsha"),
("natural", "blocked", "references/ 未给出综合领域的一组自然代表星"),
("house:1", "sourced", f"{MAPPING} 第1宫"),
],
}
catalog = {}
for domain, rows in extra.items():
catalog[domain] = [
{"id": item_id, "status": status, "citation": citation}
for item_id, status, citation in ([*shared, *rows])
]
return catalog
def _shared_participation(view: dict, minute_ok: bool) -> list[dict[str, Any]]:
items = [
_sourced("sudarshana", f"{ROUTER} Sudarshana Chakra", _leaf(view, "worksheets.divisional_and_special_charts.sudarshana")),
_sourced("d9", f"{ROUTER} §2 D9 Navamsa", _leaf(view, "worksheets.divisional_and_special_charts.varga_full.D9_Navamsa")),
_sourced("bhava_chalit", f"{ROUTER} Full Bhava Chalit", _first(view, [
"worksheets.d1_rasi_bhava.bhava_chalit",
"worksheets.divisional_and_special_charts.bhava_chalit",
])),
_sourced("shadbala", f"{ROUTER} §2 Shadbala", _leaf(view, "worksheets.strengths_and_scores.shadbala")),
_sourced("bhava_bala", f"{ROUTER} §2 Shadbala", _leaf(view, "worksheets.strengths_and_scores.bhava_bala")),
_sourced("avastha", "references/deep-varga-avastha-execution-guide.md", _leaf(view, "worksheets.advanced_systems.avasthas")),
_sourced("vimsopaka", f"{DEEP} Vimsopaka", _leaf(view, "worksheets.strengths_and_scores.vimsopaka")),
_sourced("ashtakavarga", f"{ROUTER} §2 Ashtakavarga", _leaf(view, "worksheets.strengths_and_scores.ashtakavarga")),
_sourced("functional", f"{ROUTER} §2 Functional benefic/malefic", _leaf(view, "worksheets.strengths_and_scores.functional_benefic_malefic")),
_blocked("parashari_aspects", f"{ROUTER} 未找到把 Parashari 星照单独列为参与层的段落"),
_sourced("jaimini_aspects", f"{JAIMINI} Jaimini Drishti", _first(view, [
"worksheets.advanced_systems.jaimini.rashi_aspects",
"worksheets.advanced_systems.jaimini.drishti",
])),
_sourced("argala", "references/argala-complete-guide.md", _leaf(view, "worksheets.advanced_systems.argala")),
_sourced("dispositor_chain", f"{ROUTER} §2 dispositor chain", _leaf(view, "worksheets.advanced_systems.dispositor_chains")),
_sourced("vimshottari", VIM, _leaf(view, "worksheets.timing_and_predictive_systems.dasha.current_dasha.lord")),
_sourced("narayana", NARAYANA, _leaf(view, "worksheets.timing_and_predictive_systems.narayana_dasha.current_dasha.md.sign")),
_sourced("yogini", "references/alternative-dasha-systems.md", _leaf(view, "worksheets.timing_and_predictive_systems.yogini_dasha.current.planet")),
_sourced("transits", f"{ROUTER} §2 Transit", _leaf(view, "worksheets.timing_and_predictive_systems.transit_multi_reference")),
_sourced("double_transit", f"{CAREER} §2 Transit / Double Transit", _leaf(view, "worksheets.timing_and_predictive_systems.double_transit_pac")),
_sourced("yogas_hits", "references/event_judgment_skeleton.md", _first(view, [
"worksheets.advanced_systems.yoga.hits",
"worksheets.advanced_systems.yogas.hits",
])),
{"id": "kp", "status": "value", "value": "不参与", "citation": "决策 8:KP 不参与", "check": "label"},
]
del minute_ok
return items
def _varga_item(view: dict, item_id: str, citation: str, code: str, minute_ok: bool) -> dict[str, Any]:
if code in PRECISION_VARGA and not minute_ok:
return {"id": item_id, "status": "withheld", "value": PRECISION_SENTENCE, "citation": citation, "check": "withheld"}
_chart, path = _varga_chart(view, code)
if not path:
return {"id": item_id, "status": "gap", "path": f"varga.{code}", "citation": citation, "check": "path"}
return _sourced(item_id, citation, _leaf(view, path))
def _domain_participation(view: dict, domain: str, minute_ok: bool) -> list[dict[str, Any]]:
lagna = "worksheets.divisional_and_special_charts.special_lagnas"
annual = "worksheets.timing_and_predictive_systems"
if domain == "career":
return [
_varga_item(view, "D10", f"{ROUTER} §2 D10 for career", "D10", minute_ok),
_sourced("A10", f"{CAREER} §2 A10 / Karma Pada", _leaf(view, f"{lagna}.A10_Karma_Pada.sign")),
_sourced("AL", f"{PDF} Arudha Lagna", _leaf(view, f"{lagna}.Arudha_Lagna.sign")),
_sourced("AmK", f"{JAIMINI} Amatyakaraka", _karaka(view, "7", "Amatyakaraka")),
_sourced("Karakamsha", f"{CAREER} §2 Karakamsha", _leaf(view, "worksheets.advanced_systems.jaimini.karakamsha")),
_sourced("Ghati", f"{DEEP} Ghati Lagna 权力指标", _point(view, [f"{lagna}.Ghati_Lagna.sign"], precision=True, minute_ok=minute_ok)),
_blocked("Vighati", "references/ 未找到 Vighati 条目"),
_sourced("D1_D9_D10", f"{ROUTER} §2 D10 for career", _leaf(view, "worksheets.advanced_systems.inter_chart_linkage")),
]
if domain == "wealth":
return [
_varga_item(view, "D2", f"{WEALTH} §3 D2", "D2", minute_ok),
_varga_item(view, "D11", f"{WEALTH} §3 D11", "D11", minute_ok),
_sourced("Hora", f"{DEEP} Hora Lagna 财富指标", _leaf(view, f"{lagna}.Hora_Lagna.sign")),
_blocked("Indu", "references/ 未找到 Indu Lagna 财富条目"),
_sourced("Sree", "references/promise-assessment-templates.md Hora Lagna + Sree Lagna", _first(view, [
f"{lagna}.Sree_Lagna.sign",
f"{lagna}.Sri_Lagna.sign",
])),
_blocked("AL2", f"{WEALTH} §3 未列出从 AL 起第 2 宫"),
_blocked("AL11", f"{WEALTH} §3 未列出从 AL 起第 11 宫"),
]
if domain == "marriage":
return [
_varga_item(view, "D9_7", f"{MARRIAGE} §2 D9 7H / 7L", "D9", minute_ok),
_sourced("UL", f"{MARRIAGE} §2 UL", _leaf(view, f"{lagna}.Upapada_Lagna.sign")),
_blocked("UL2", f"{MARRIAGE} §2 未列出 UL 起第 2 宫"),
_blocked("A7", f"{MARRIAGE} §2 未列出 A7"),
_sourced("DK", f"{JAIMINI} Darakaraka", _karaka(view, "7", "Darakaraka")),
_sourced("Vivah", f"{MARRIAGE} §2 Vivah Saham", _first(view, [
f"{annual}.annual_tajika_pack.sahams.data.vivah_saham",
"worksheets.advanced_systems.sahams.vivah_saham",
])),
_sourced("Mars", f"{MARRIAGE} §2 Mars", _leaf(view, "core_chart.planets.Mars.sign")),
]
if domain == "health":
return [
_varga_item(view, "D6", f"{ROUTER} §7 D6", "D6", minute_ok),
_varga_item(view, "D8", f"{ROUTER} §7 D8", "D8", minute_ok),
_varga_item(view, "D30", f"{ROUTER} §7 D30", "D30", minute_ok),
_sourced("drekkana22", f"{SHODASA} 第22Drekkana与寿限", _first(view, [
"worksheets.divisional_and_special_charts.sensitive_points.drekkana_22",
"worksheets.divisional_and_special_charts.varga_full.D3_Drekkana",
])),
_blocked("navamsa64", "references/ 未找到第 64 个 Navamsa 条目"),
_sourced("Badhaka", BADHAKA, _first(view, [
"worksheets.advanced_systems.badhaka",
"worksheets.d1_rasi_bhava.badhaka",
])),
_blocked("Gulika", "references/prashna-complete-guide.md 只在问事章写 Gulika,未列入本命健康参与层"),
_blocked("Mandi", "references/prashna-complete-guide.md 只在问事章写 Mandi,未列入本命健康参与层"),
_blocked("Pranapada", "references/prashna-complete-guide.md 的 Pranapada 是问事替代计算,未列入本命健康参与层"),
]
if domain == "children":
return [
_varga_item(view, "D7", f"{ROUTER} §2 D7 for children", "D7", minute_ok),
_sourced("PK", f"{JAIMINI} Putrakaraka", _karaka(view, "7", "Putrakaraka")),
]
if domain == "parents":
return [
_varga_item(view, "D12", f"{ROUTER} §2 D12 for family/ancestral themes", "D12", minute_ok),
_sourced("MK", f"{JAIMINI} Matrikaraka", _karaka(view, "7", "Matrikaraka")),
_sourced("PiK", f"{JAIMINI} 可选PiK", _karaka(view, "8", "Pitrukaraka")),
]
if domain == "education":
return [
_varga_item(view, "D24", f"{ROUTER} §2 D24 for education", "D24", minute_ok),
_blocked("D5", "references/ 未找到把 D5 标成学业分盘的段落"),
]
if domain == "migration":
return [
_varga_item(view, "D4", f"{RECTIFY} 房产/搬迁 D4", "D4", minute_ok),
_blocked("D12", f"{ROUTER} §2 把 D12 标给家族,未标给迁居"),
]
if domain == "family":
return [
_blocked("D4", f"{RECTIFY} 把 D4 标给房产/搬迁,未标给家庭领域"),
_blocked("D7", f"{ROUTER} §2 把 D7 标给子女,未标给家庭领域"),
_varga_item(view, "D12", f"{ROUTER} §2 D12 for family/ancestral themes", "D12", minute_ok),
]
if domain == "annual":
return [
_sourced("tajika", VARSHAPHALA, _leaf(view, f"{annual}.tajika_yogas")),
_sourced("Muntha", f"{VARSHAPHALA} Muntha", _first(view, [
f"{annual}.annual_tajika_pack.muntha",
f"{annual}.tajika.muntha",
])),
_sourced("year_lord", f"{VARSHAPHALA} 年主星", _first(view, [
f"{annual}.annual_tajika_pack.year_lord",
f"{annual}.tajika.year_lord",
])),
_sourced("Mudda", f"{VARSHAPHALA} Mudda Dasha", _first(view, [
f"{annual}.annual_tajika_pack.mudda_dasha",
f"{annual}.tajika.mudda_dasha",
])),
_sourced("Sahams", VARSHAPHALA, _first(view, [
f"{annual}.annual_tajika_pack.sahams",
"worksheets.advanced_systems.sahams",
])),
]
if domain == "timing":
return [
_sourced("narayana_sub", NARAYANA, _leaf(view, f"{annual}.narayana_dasha.current_dasha.ad.sign")),
_sourced("year_chart", VARSHAPHALA, _first(view, [
f"{annual}.annual_tajika_pack",
f"{annual}.tajika",
])),
]
if domain == "general":
return [
_varga_item(view, "D1", f"{ROUTER} §2 D1 Rashi", "D1", minute_ok),
_varga_item(view, "D9", f"{ROUTER} §2 D9 Navamsa", "D9", minute_ok),
_blocked("D10", f"{ROUTER} §2 把 D10 标给事业,未标给综合领域"),
_blocked("D2", f"{ROUTER} §2 把 D2 标给财富,未标给综合领域"),
_sourced("AL", f"{PDF} Arudha Lagna", _leaf(view, f"{lagna}.Arudha_Lagna.sign")),
_sourced("Karakamsha", f"{JAIMINI} Karakamsha", _leaf(view, "worksheets.advanced_systems.jaimini.karakamsha")),
]
return []
def _factors(domain: str, timing: dict[str, Any], karakas: dict[str, dict[str, Any]]) -> list[dict[str, Any]]:
catalog = domain_source_catalog()[domain]
factors: list[dict[str, Any]] = []
for row in catalog:
item_id = row["id"]
if item_id.startswith("house:"):
factors.append({"id": item_id, "kind": "house", "house": int(item_id.split(":")[1]), "citation": row["citation"]})
elif item_id.startswith("natural:"):
factors.append({"id": item_id, "kind": "planet", "planet": item_id.split(":")[1], "citation": row["citation"], "role": "natural"})
elif item_id == "natural":
factors.append({"id": item_id, "kind": "blocked", "citation": row["citation"]})
elif item_id == "house":
factors.append({"id": item_id, "kind": "blocked", "citation": row["citation"]})
for item_id, role in (("AmK", "Amatyakaraka"), ("DK", "Darakaraka"), ("PK", "Putrakaraka"), ("MK", "Matrikaraka")):
leaf = karakas.get(role)
if leaf and leaf.get("status") == "value" and isinstance(leaf.get("value"), str):
if any(row["id"] == item_id for row in catalog):
factors.append({"id": f"jaimini:{item_id}", "kind": "planet", "planet": leaf["value"], "citation": f"{JAIMINI} {role}", "role": item_id})
elif any(row["id"] == item_id for row in catalog):
factors.append({"id": f"jaimini:{item_id}", "kind": "gap", "citation": f"{JAIMINI} {role}", "path": leaf.get("path") if leaf else None})
pik = karakas.get("Pitrukaraka")
if any(row["id"] == "PiK" for row in catalog):
if pik and pik.get("status") == "value" and isinstance(pik.get("value"), str):
factors.append({"id": "jaimini:PiK", "kind": "planet", "planet": pik["value"], "citation": f"{JAIMINI} 可选PiK", "role": "PiK"})
else:
factors.append({"id": "jaimini:PiK", "kind": "gap", "citation": f"{JAIMINI} 可选PiK", "path": (pik or {}).get("path")})
if not any(row["id"].startswith("jaimini") or row["id"] in {"AmK", "DK", "PK", "MK", "PiK"} for row in catalog):
factors.append({"id": "jaimini", "kind": "blocked", "citation": f"{JAIMINI} 未给出该领域的 Jaimini 代表星"})
for key, label in (("md", "Vimshottari MD"), ("ad", "Vimshottari AD"), ("pd", "Vimshottari PD")):
leaf = timing.get(key) or {}
if leaf.get("status") == "value" and isinstance(leaf.get("value"), str):
factors.append({"id": f"vimshottari:{key}", "kind": "planet", "planet": leaf["value"], "citation": VIM, "role": label})
else:
factors.append({"id": f"vimshottari:{key}", "kind": "gap", "citation": VIM, "path": leaf.get("path")})
sign = timing.get("narayana_sign") or {}
lord = timing.get("narayana_lord") or {}
factors.append({"id": "narayana:md", "kind": "sign", "citation": NARAYANA, "sign": sign, "lord": lord})
if lord.get("status") == "value" and isinstance(lord.get("value"), str):
factors.append({"id": "narayana:lord", "kind": "planet", "planet": lord["value"], "citation": NARAYANA, "role": "Narayana MD lord"})
else:
factors.append({"id": "narayana:lord", "kind": "gap", "citation": NARAYANA, "path": lord.get("path")})
return factors
def build_domain_facts(packet: dict, domain: str, *, minute_confirmed: bool) -> dict[str, Any]:
if domain not in PACKET_DOMAIN:
raise KeyError(domain)
view = read_full_data_domain(packet, PACKET_DOMAIN[domain])
varga = {
"career": "D10", "wealth": "D2", "marriage": "D9", "health": "D6",
"children": "D7", "parents": "D12", "education": "D24", "migration": "D4",
"family": "D4", "annual": "D1", "timing": "D9", "general": "D10",
}[domain]
planets = {name: _planet_record(view, name, varga, minute_confirmed) for name in PLANETS}
houses = {}
for row in domain_source_catalog()[domain]:
if row["id"].startswith("house:"):
number = int(row["id"].split(":")[1])
houses[str(number)] = _house_record(view, number)
timing = _timing(view)
karakas = {
"Amatyakaraka": _karaka(view, "7", "Amatyakaraka"),
"Darakaraka": _karaka(view, "7", "Darakaraka"),
"Putrakaraka": _karaka(view, "7", "Putrakaraka"),
"Matrikaraka": _karaka(view, "7", "Matrikaraka"),
"Pitrukaraka": _karaka(view, "8", "Pitrukaraka"),
}
participation = _shared_participation(view, minute_confirmed) + _domain_participation(view, domain, minute_confirmed)
facts = {
"schema": "consult-card-facts-v1",
"domain": domain,
"packet_domain": PACKET_DOMAIN[domain],
"header_note": RAHU_NOTE,
"precision_sentence": PRECISION_SENTENCE,
"minute_confirmed": minute_confirmed,
"domain_varga": varga,
"planets": planets,
"houses": houses,
"timing": timing,
"factors": _factors(domain, timing, karakas),
"participation": participation,
}
encoded = json.dumps(facts, ensure_ascii=False, default=str)
if "tajika_named_yogas" in encoded:
raise RuntimeError("consult card facts must not read tajika_named_yogas")
return facts
def build_consult_card_facts(
packet: dict,
domains: list[str],
*,
minute_confirmed: bool | None,
body: dict | None = None,
) -> dict[str, Any]:
confirmed = minute_confirmed_from_body(body) if minute_confirmed is None else minute_confirmed
rows = []
for domain in domains:
if domain not in PACKET_DOMAIN:
continue
rows.append(build_domain_facts(packet, domain, minute_confirmed=confirmed))
return {
"schema": "consult-card-facts-v1",
"minute_confirmed": confirmed,
"domains": rows,
"gaps": [] if rows else ["consult_domain_missing"],
}
def assert_fact_leaves(packet: dict, facts: dict[str, Any]) -> None:
"""Every copied leaf equals the packet. Derived selections are not leaves."""
view = read_full_data_domain(packet, facts["packet_domain"])
def check(item: Any) -> None:
if isinstance(item, list):
for entry in item:
check(entry)
return
if not isinstance(item, dict):
return
kind = item.get("check")
if kind == "path" and item.get("status") == "value":
assert dig(view, item["path"]) == item["value"], item["path"]
elif kind == "index" and item.get("status") == "value":
assert dig(view, item["path"])[item["index"]] == item["value"], item["path"]
elif kind == "nonempty" and item.get("status") == "value":
assert dig(view, item["path"]) not in (None, {}, [], ""), item["path"]
elif kind == "membership" and item.get("status") == "value":
assert item["member"] in dig(view, item["path"]), item["path"]
elif kind == "withheld":
assert item.get("value") == PRECISION_SENTENCE
for key, value in item.items():
if key in ("value", "path", "citation"):
continue
check(value)
check(facts)
+182
View File
@@ -0,0 +1,182 @@
"""Cache the full-data report packet for the consult card.
The key is chart identity + ayanamsa + node mode + engine version.
The file name is a hash. Birth data stays inside the scratch file.
"""
from __future__ import annotations
import hashlib
import json
import time
from pathlib import Path
from types import SimpleNamespace
from typing import Any, Callable
ROOT = Path(__file__).resolve().parents[1]
CACHE_SCHEMA = "consult-full-data-cache-v1"
SLOW_MISS_SECONDS = 20.0
PacketBuilder = Callable[[dict[str, Any]], dict[str, Any]]
def cache_dir() -> Path:
path = ROOT / "scratch" / "local" / "consult_full_data_cache"
path.mkdir(parents=True, exist_ok=True)
return path
def engine_version() -> str:
try:
from jyotish_vedic import __version__ as version
except Exception:
return "unknown"
return str(version)
def _number(value: Any) -> int | float:
number = float(value)
if number == int(number):
return int(number)
return number
def identity_from_body(body: dict[str, Any]) -> dict[str, Any]:
"""Birth fields are required. Missing keys raise KeyError so the caller can gap."""
node = body.get("node_mode", body.get("nodeMode", "mean"))
ayanamsa = body.get("ayanamsa", "lahiri")
second = body.get("second", 0)
return {
"schema": CACHE_SCHEMA,
"year": int(body["year"]),
"month": int(body["month"]),
"day": int(body["day"]),
"hour": _number(body["hour"]),
"minute": _number(body["minute"]),
"second": _number(second if second is not None else 0),
"lat": _number(body["lat"]),
"lon": _number(body["lon"]),
"tz": _number(body["tz"]),
"ayanamsa": str(ayanamsa),
"node_mode": str(node or "mean"),
"engine_version": engine_version(),
}
def cache_key(identity: dict[str, Any]) -> str:
canonical = json.dumps(identity, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
def _cache_path(identity: dict[str, Any]) -> Path:
return cache_dir() / f"{cache_key(identity)}.json"
def get_or_build(
identity: dict[str, Any],
builder: PacketBuilder | None = None,
*,
clock: Callable[[], float] | None = None,
) -> dict[str, Any]:
"""Return the packet. A miss calls builder once and stores the packet."""
now = clock or time.perf_counter
started = now()
path = _cache_path(identity)
if path.is_file():
packet = json.loads(path.read_text(encoding="utf-8"))
elapsed = now() - started
return {"packet": packet, "hit": True, "elapsed_s": elapsed, "slow": False}
if builder is None:
builder = build_full_data_packet
packet = builder(identity)
temporary = path.with_suffix(".json.tmp")
temporary.write_text(json.dumps(packet, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
temporary.replace(path)
elapsed = now() - started
return {
"packet": packet,
"hit": False,
"elapsed_s": elapsed,
"slow": elapsed > SLOW_MISS_SECONDS,
}
def build_full_data_packet(identity: dict[str, Any]) -> dict[str, Any]:
"""Packet-only build. No markdown render and no sanitized rewrite."""
from datetime import date
from calculation_profile_contract import attach_calculation_profile
from jyotish_engine import build_pl9_style_export_packet, cmd_full_reading
today = date.today()
args = SimpleNamespace(
year=int(identity["year"]),
month=int(identity["month"]),
day=int(identity["day"]),
hour=float(identity["hour"]),
minute=float(identity["minute"]),
second=float(identity["second"]),
lat=float(identity["lat"]),
lon=float(identity["lon"]),
tz=float(identity["tz"]),
ayanamsa=identity["ayanamsa"],
node_mode=identity["node_mode"],
today=today.isoformat(),
target_year=today.year,
age=today.year - int(identity["year"]),
transit_date=today.isoformat(),
birth_time_accuracy="confirmed",
)
reading = cmd_full_reading(args)
if not isinstance(reading, dict) or reading.get("error"):
raise RuntimeError("full_reading_unavailable")
packet = build_pl9_style_export_packet(reading)
return attach_calculation_profile(packet, args)
def _requested_domains(body: dict[str, Any]) -> list[str]:
# Card domains (parents, children) are not the engine theme (family).
raw = body.get("consult_card_domains")
if raw is None:
raw = body.get("themes", body.get("theme"))
if raw is None:
return []
if isinstance(raw, str):
return [raw]
if isinstance(raw, list):
return [str(item) for item in raw if isinstance(item, str)]
return []
def attach_consult_full_data(result: dict[str, Any], body: dict[str, Any] | None) -> dict[str, Any]:
"""Thin hook for the consultation response. Failures do not break the chat."""
payload = dict(body or {})
if payload.get("include_consult_card_facts") is not True:
return result
try:
from consult_card_domain_facts import build_consult_card_facts
except ImportError:
from scripts.consult_card_domain_facts import build_consult_card_facts
try:
identity = identity_from_body(payload)
cached = get_or_build(identity)
facts = build_consult_card_facts(
cached["packet"],
_requested_domains(payload),
minute_confirmed=None,
body=payload,
)
facts["timing"] = {
"hit": bool(cached["hit"]),
"elapsed_s": cached["elapsed_s"],
"slow": bool(cached["slow"]),
}
except Exception as exc:
facts = {
"schema": "consult-card-facts-v1",
"gaps": ["consult_full_data_unavailable"],
"error_type": type(exc).__name__,
}
attached = dict(result)
attached["consult_card_facts"] = facts
return attached
+6 -1
View File
@@ -4331,11 +4331,16 @@ class JyotishAPIHandler(BaseHTTPRequestHandler, VedastroEvidenceMixin, SynastryM
})
def _compute_consultation_workflow(self, body):
return execute_consultation_workflow(
result = execute_consultation_workflow(
self,
body=dict(body or {}),
surface=body.get('surface', 'api_web'),
)
try:
from scripts.consult_full_data_cache import attach_consult_full_data
except ImportError:
from consult_full_data_cache import attach_consult_full_data
return attach_consult_full_data(result, body or {})
def _build_audited_remedies_from_guided_topics(self, guided_topics):
if not isinstance(guided_topics, list):