feat(consult): rule engine always runs and merges with chart yogas (BUG-1174); closed-list traditional_yogas on general/career/wealth cards (BUG-1175)

Goldens regenerated by the capture scripts (PYTHONHASHSEED=0, cache TTL 0; two runs byte-identical).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
This commit is contained in:
Jesse_Chen
2026-10-02 08:32:11 +08:00
co-authored by Claude Opus 5.5
parent 8e978cabc4
commit c74bfcc06b
7 changed files with 236 additions and 23 deletions
+116
View File
@@ -696,6 +696,121 @@ def build_yoga_domains(modules: dict) -> dict:
}
def _yoga_key(entry: dict) -> str:
name = "".join(ch for ch in str(entry.get("name") or "").lower() if ch.isalnum())
return name.replace("yoga", "") or name
def merge_detected_yogas(*groups: list) -> list[dict]:
"""Merge yoga lists in order, dropping repeats by normalised name (BUG-1174)."""
merged: list[dict] = []
seen: set[str] = set()
for group in groups:
for entry in group or []:
if not isinstance(entry, dict) or not entry.get("name"):
continue
key = _yoga_key(entry)
if key in seen:
continue
seen.add(key)
merged.append(entry)
return merged
def collect_consultation_yogas(handler, chart: dict, birth_payload: dict, planets: dict,
ascendant: Any, compact_entry) -> list[dict]:
"""Every yoga the consultation sieve sees: the chart's own list plus the rule engine.
BUG-1174 (TASK-consult-career-yoga-functional-20261002 T2): the rule engine
used to run only when `chart.yogas` was empty, so a chart that already
carried one or two yogas never had the Raman packet members checked. It now
always runs; the two lists are merged and de-duplicated by name. No
detection rule or sieve boundary changes.
"""
raw = chart.get("yogas") if isinstance(chart.get("yogas"), list) else []
own = [entry for entry in (compact_entry(item) for item in raw) if entry]
try:
api = handler._compute_yogas_api({**birth_payload, "planets": planets, "ascendant": ascendant})
except Exception:
if not own:
raise
return merge_detected_yogas(own)
result = api.get("result") if isinstance(api, dict) else {}
result = result if isinstance(result, dict) else {}
engine = [
entry for entry in (
compact_entry(item)
for item in (result.get("extended_yogas") or []) + (result.get("rule_engine_yogas") or [])
)
if entry
]
return merge_detected_yogas(own, engine)
# Closed list for `traditional_yogas` (TASK-consult-career-yoga-functional-20261002
# D3, BUG-1175): canonical name -> name-token prefixes the detectors emit
# (yoga_expansion / yoga_engine / chart.yogas / Raman packet hits). Nothing
# outside this list is shown, and no detector is added here.
TRADITIONAL_YOGA_LABEL = "传统格局,本站未验证,只作参考"
TRADITIONAL_YOGAS = (
("Ruchaka Yoga", ("ruchaka",)),
("Bhadra Yoga", ("bhadra",)),
("Hamsa Yoga", ("hamsa",)),
("Malavya Yoga", ("malavya",)),
("Sasa Yoga", ("sasa", "shasha")),
("Gajakesari Yoga", ("gajakesari",)),
("Neecha Bhanga Raja Yoga", ("neechabhanga", "neechbhanga")),
# Budha-Aditya: the engine also emits the same Sun-Mercury conjunction as
# "Surya-Budha Yoga"; both map to one row.
("Budha-Aditya Yoga", ("budhaaditya", "budhaditya", "suryabudha")),
)
def _traditional_name(name: Any) -> str | None:
token = "".join(ch for ch in str(name or "").lower() if ch.isalnum())
for canonical, prefixes in TRADITIONAL_YOGAS:
if any(token.startswith(prefix) for prefix in prefixes):
return canonical
return None
def build_traditional_yogas(modules: dict) -> dict:
"""Closed-list classical yogas the detectors found formed (BUG-1175).
Reads what the consultation sieve was handed (`raman_support.yogas`: packet
hits plus `ungoverned_detected`). Only formed yogas are listed; the label is
fixed. Reading rule (card): strength only, no stand-alone conclusion, never
counted as affliction or support against affliction. Raman packets,
`modules.yogas`, confidence caps and claim boundaries are unchanged.
"""
sieve = _record(_record(modules.get("raman_support")).get("yogas"))
if sieve.get("status") != "executed":
return _blocked("raman_support_yogas_missing")
found: dict[str, list[str]] = {}
for entry in list(sieve.get("hits") or []) + list(sieve.get("ungoverned_detected") or []):
row = _record(entry)
if row.get("hit") is False:
continue
canonical = _traditional_name(row.get("name"))
if not canonical:
continue
planets = found.setdefault(canonical, [])
for planet in row.get("planets") or []:
if isinstance(planet, str) and planet not in planets:
planets.append(planet)
names = [
f"{canonical}({'、'.join(found[canonical])})" if found[canonical] else canonical
for canonical, _prefixes in TRADITIONAL_YOGAS
if canonical in found
]
return {
"status": "executed",
"source": "raman_support.yogas (hits + ungoverned_detected), closed list TRADITIONAL_YOGAS",
"label": TRADITIONAL_YOGA_LABEL,
"names": names,
}
# ── Annual Tajika / Varshaphala ─────────────────────────────────────────────
def _annual_payload(birth_payload: dict, target_year: int) -> dict:
@@ -889,6 +1004,7 @@ def attach_native_consultation_layers(chart: dict, *, birth_payload: dict, body:
layers["inter_chart_linkage"] = _guard(build_inter_chart_linkage, engine, chart, modules)
layers["graha_drishti"] = _guard(build_graha_drishti, engine, chart)
layers["yoga_domains"] = _guard(build_yoga_domains, modules)
layers["traditional_yogas"] = _guard(build_traditional_yogas, modules)
if "annual" in routes:
layers["annual_tajika"] = _guard(build_annual_tajika, birth_payload, chart, reference=reference)
else:
+2 -18
View File
@@ -1705,24 +1705,8 @@ def _attach_local_consultation_layers(handler, chart: dict, birth_payload: dict,
if not _consultation_layer_present(modules.get('yogas')):
try:
raw_yogas = chart.get('yogas') if isinstance(chart.get('yogas'), list) else []
compact = [entry for entry in (_compact_yoga_entry(item) for item in raw_yogas) if entry]
if not compact:
api = handler._compute_yogas_api({
**birth_payload,
'planets': planets,
'ascendant': ascendant,
})
result = api.get('result') if isinstance(api, dict) else {}
extended = result.get('extended_yogas') if isinstance(result, dict) else []
engine = result.get('rule_engine_yogas') if isinstance(result, dict) else []
compact = [
entry for entry in (
_compact_yoga_entry(item)
for item in (extended or []) + (engine or [])
)
if entry
]
from scripts.consultation_native_layers import collect_consultation_yogas # BUG-1174
compact = collect_consultation_yogas(handler, chart, birth_payload, planets, ascendant, _compact_yoga_entry)
routes = _consultation_declared_routes(body)
raman = build_raman_support_observations(chart, compact, routes)
sieved = raman['yogas']['yogas']