fix(consult): compute the antardasha boundaries answers need, and stop billing an apology for an unwritten answer
Independent Staging Quality Gate / validate (push) Failing after 8m32s
Independent Staging Quality Gate / publish (push) Has been skipped

The model packet read chart.modules.dasha_boundaries, a key the engine never
wrote, so no answer ever had sub-period boundaries while the receipt still
reported precise timing as allowed. The server now cuts the running mahadasha
into antardashas out of the periods the packet already shows, exposes them as
their own evidence section, and precise timing requires that section.

A run whose calculation succeeded and whose model then wrote nothing was
answered with a fixed apology and billed as completed. It now asks once more
against the cached calculation, and fails with empty_answer—no charge—if that
attempt is silent too.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-18 18:32:49 +08:00
co-authored by Cursor
parent 46f38422f6
commit 927bdd7a21
11 changed files with 388 additions and 61 deletions
+94 -2
View File
@@ -590,6 +590,79 @@ def _consultation_current_age(birth_payload: dict, body: dict) -> float:
return max(0.0, (reference.replace(tzinfo=None) - born).total_seconds() / (365.2425 * 86400))
def _consultation_period_datetime(value) -> datetime | None:
if isinstance(value, datetime):
return value.replace(tzinfo=None)
if isinstance(value, str) and value.strip():
try:
return datetime.fromisoformat(value.strip().replace('Z', '+00:00')).replace(tzinfo=None)
except ValueError:
return None
return None
def _consultation_dasha_sub_periods(chart: dict, body: dict) -> dict | None:
"""Cut the running mahadasha into its antardashas.
A mahadasha is six to twenty years wide, so mahadasha boundaries alone cannot place an answer in
a month or a year; the packet has to carry the sub-period boundaries whose presence it claims
precise timing from. They are cut out of the periods the packet already shows rather than from a
second timeline built from the moon's longitude: both would come from the same engine, but only
this way are the sub-period boundaries guaranteed to sit inside the mahadasha boundaries the
model reads, instead of handing it two nearly-identical sets of dasha dates to choose between.
"""
dasha = chart.get('dasha') if isinstance(chart.get('dasha'), dict) else {}
periods = dasha.get('periods') if isinstance(dasha.get('periods'), list) else []
parsed = []
for period in periods:
if not isinstance(period, dict) or not period.get('lord'):
continue
start = _consultation_period_datetime(period.get('start'))
end = _consultation_period_datetime(period.get('end'))
if start is None or end is None or end <= start:
continue
parsed.append({'lord': str(period['lord']), 'start': start, 'end': end})
reference = _consultation_reference_date(body).replace(tzinfo=None)
current = next((period for period in parsed if period['start'] <= reference < period['end']), None)
if current is None:
return None
analyzer = _load_local_module('dasha_analyzer')
sub_periods = analyzer.build_antardasha(current)
if not sub_periods:
return None
current_sub = analyzer.find_current_sub(sub_periods, reference)
following = next(
(sub_periods[index + 1] for index, period in enumerate(sub_periods)
if period is current_sub and index + 1 < len(sub_periods)),
None,
)
def boundary(period: dict) -> dict:
return {
'lord': period['lord'],
'start': period['start'].strftime('%Y-%m-%d'),
'end': period['end'].strftime('%Y-%m-%d'),
}
return {
'status': 'ready',
'source': 'chart.dasha.periods + dasha_analyzer.build_antardasha',
'method': 'vimshottari_antardasha_proportional',
'current': {'mahadasha': boundary(current), 'antardasha': boundary(current_sub)},
**({'next': boundary(following)} if following else {}),
'boundaries': [boundary(period) for period in sub_periods],
'boundary_count': len(sub_periods),
'summary': (
f"当前 {current['lord']} 大运下的小运为 {current_sub['lord']}"
f"边界 {boundary(current_sub)['start']}{boundary(current_sub)['end']}"
'更细的 Pratyantardasha 与行运触发不在本层计算范围内。'
),
}
def _attach_local_consultation_layers(handler, chart: dict, birth_payload: dict, body: dict) -> dict:
"""Attach locally-computable consultation layers before reports/evidence are assembled.
@@ -666,6 +739,20 @@ def _attach_local_consultation_layers(handler, chart: dict, birth_payload: dict,
except Exception as exc:
diagnostics.append({'layer': 'ashtakavarga', 'status': 'unavailable', 'reason': exc.__class__.__name__})
if not isinstance(modules.get('dasha_sub_periods'), dict):
try:
sub_periods = _consultation_dasha_sub_periods(chart, body)
if isinstance(sub_periods, dict):
modules['dasha_sub_periods'] = sub_periods
else:
diagnostics.append({
'layer': 'dasha_sub_periods',
'status': 'unavailable',
'reason': 'no_mahadasha_period_covers_reference_date',
})
except Exception as exc:
diagnostics.append({'layer': 'dasha_sub_periods', 'status': 'unavailable', 'reason': exc.__class__.__name__})
if planets and ascendant and not isinstance(modules.get('kp_cusps'), dict):
try:
normalized, _, asc_sign_idx = handler._normalized_planets_from_body({
@@ -683,7 +770,7 @@ def _attach_local_consultation_layers(handler, chart: dict, birth_payload: dict,
'source': 'repository_local_engines',
'available': [
name
for name in ('varga_full', 'arudha_padas', 'narayana_dasha', 'ashtakavarga', 'kp_cusps')
for name in ('varga_full', 'arudha_padas', 'narayana_dasha', 'dasha_sub_periods', 'ashtakavarga', 'kp_cusps')
if isinstance(modules.get(name), dict) and modules.get(name)
],
'diagnostics': diagnostics,
@@ -831,9 +918,14 @@ def _build_consumer_context(
# Read the sections directly. Gating on `missing_route_layers` made this vacuously true for every
# route that does not require narayana_dasha, so precise timing was granted without that layer
# ever being checked.
#
# `dasha_boundaries` is the mahadasha list, and a six-to-twenty-year period cannot place an
# answer in a month, so it alone never justified precise timing: the antardasha boundaries have
# to be there too. The gate has to name them itself, because a section that is missing is a
# section no route requirement can speak for (BUG-279).
timing_layers_ready = all(
isinstance(sections.get(name), dict) and sections[name].get('status') == 'used'
for name in ('dasha_boundaries', 'narayana_dasha')
for name in ('dasha_boundaries', 'dasha_sub_periods', 'narayana_dasha')
)
precision_allows_timing = not any(name in disabled_vargas for name in ('D9', 'D10'))
can_answer_precise_timing = d1_ready and timing_layers_ready and precision_allows_timing and not missing_route_layers
@@ -694,6 +694,10 @@ class UnifiedConsultationOrchestrator:
"planet_degrees": self._section(base_chart.get("planets"), "chart.planets"),
"house_degrees": self._section(base_chart.get("houses") or chart_data.get("houses"), "chart.houses"),
"dasha_boundaries": self._section(modules.get("dasha") or chart_data.get("dasha"), "modules.dasha"),
# The mahadasha list above and the antardasha cut below are different claims: one says
# which decade, the other which months. They are separate sections so that an answer
# policy can require the second without the first standing in for it.
"dasha_sub_periods": self._section(modules.get("dasha_sub_periods"), "modules.dasha_sub_periods"),
"narayana_dasha": self._section(modules.get("narayana_dasha"), "modules.narayana_dasha"),
"shadbala": self._section(modules.get("shadbala") or chart_data.get("shadbala"), "modules.shadbala"),
"ashtakavarga": self._section(modules.get("ashtakavarga") or chart_data.get("ashtakavarga"), "modules.ashtakavarga"),