fix(consult): stop the consult-gate VedAstro wait and record step timing
Consult turns reuse a same-day official snapshot inside the rectification gate, or record deferred_in_consultation instead of starting another 4-second snapshot. Classification, step 0, step 1, and the time to the first answer character go into the existing usage metadata and a new admin usage column. BUG-1231, BUG-1232
This commit is contained in:
@@ -2291,13 +2291,21 @@ def execute_consultation_workflow(
|
||||
elif step == 'run_rectification_gate':
|
||||
chart_planets = chart.get('planets') if isinstance(chart, dict) else {}
|
||||
chart_ascendant = chart.get('ascendant') if isinstance(chart, dict) else {}
|
||||
rectification = handler._compute_rectification_gate({
|
||||
gate_body = {
|
||||
**birth_payload,
|
||||
'planets': chart_planets if isinstance(chart_planets, dict) else {},
|
||||
'ascendant': chart_ascendant if isinstance(chart_ascendant, dict) else {},
|
||||
'declared_accuracy': body.get('declared_accuracy', body.get('accuracy', 'minute')),
|
||||
'time_source': body.get('time_source', 'family_clear'),
|
||||
})
|
||||
}
|
||||
# Consult already set this flag for the foreground gateway. The gate
|
||||
# must see it too, or it starts a second official snapshot that cannot
|
||||
# finish inside the request. Rectification HTTP calls do not set it.
|
||||
if defer_optional_external_evidence:
|
||||
from scripts.vedastro_consultation_snapshot import snapshot_identity_from_request
|
||||
gate_body['defer_optional_external_evidence'] = True
|
||||
gate_body['vedastro_snapshot_identity'] = snapshot_identity_from_request(body)
|
||||
rectification = handler._compute_rectification_gate(gate_body)
|
||||
executed_steps.append('run_rectification_gate')
|
||||
elif step == 'compute_chart':
|
||||
if not computed_chart:
|
||||
@@ -8471,7 +8479,11 @@ class JyotishAPIHandler(BaseHTTPRequestHandler, VedastroEvidenceMixin, SynastryM
|
||||
next_action = '保留原始出生记录来源;重要预测仍建议用 Dasha/Transit/案例验证交叉确认。'
|
||||
|
||||
try:
|
||||
vedastro_gateway = self._compute_vedastro_gateway_run(body)
|
||||
if body.get('defer_optional_external_evidence'):
|
||||
from scripts.vedastro_consultation_snapshot import consult_rectification_vedastro_gateway
|
||||
vedastro_gateway = consult_rectification_vedastro_gateway(body)
|
||||
else:
|
||||
vedastro_gateway = self._compute_vedastro_gateway_run(body)
|
||||
except Exception as exc: # Keep rectification available when the external observation is down.
|
||||
vedastro_gateway = {
|
||||
'scope': 'vedastro_gateway_run',
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
"""Consult-only VedAstro snapshot for the rectification gate.
|
||||
|
||||
The foreground gateway (BUG-301) still runs. This module is the other call,
|
||||
inside the consultation rectification gate, which used to start the same
|
||||
4-second official snapshot runner and then drop the result.
|
||||
|
||||
When the request already asked to defer optional external evidence:
|
||||
|
||||
- reuse a BUG-727 snapshot for the same birth, ayanamsa, node, and UTC date;
|
||||
- otherwise return a negative-cache hit from earlier today;
|
||||
- otherwise do not start the runner. Record
|
||||
``official_closure_reason=deferred_in_consultation`` until the end of
|
||||
that UTC day.
|
||||
|
||||
The rectification HTTP route does not set the flag, so it still calls the
|
||||
real gateway. Negative entries live in their own directory and are never
|
||||
served through the 7-day stale window.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from scripts.vedastro_snapshot_cache import (
|
||||
annotate_stale_gateway,
|
||||
lookup_snapshot,
|
||||
official_snapshot_reference_date,
|
||||
snapshot_cache_key,
|
||||
)
|
||||
except ModuleNotFoundError: # pragma: no cover - script execution path
|
||||
from vedastro_snapshot_cache import (
|
||||
annotate_stale_gateway,
|
||||
lookup_snapshot,
|
||||
official_snapshot_reference_date,
|
||||
snapshot_cache_key,
|
||||
)
|
||||
|
||||
NEGATIVE_CACHE_SCHEMA = "vedastro_snapshot_negative_cache.v1"
|
||||
DEFERRED_IN_CONSULTATION = "deferred_in_consultation"
|
||||
_SHA256_NAME = re.compile(r"^[0-9a-f]{64}\.json$")
|
||||
_IDENTITY_FIELDS = (
|
||||
"year",
|
||||
"month",
|
||||
"day",
|
||||
"hour",
|
||||
"minute",
|
||||
"second",
|
||||
"lat",
|
||||
"lon",
|
||||
"tz",
|
||||
"ayanamsa",
|
||||
"ayanamsa_name",
|
||||
"ayanamsa_policy",
|
||||
"node_mode",
|
||||
"nodeMode",
|
||||
"reference_date",
|
||||
"today",
|
||||
"transit_date",
|
||||
"current_date",
|
||||
"entrypoint",
|
||||
"consult_entrypoint",
|
||||
)
|
||||
_IDENTITY_KEYS = {
|
||||
"name",
|
||||
"email",
|
||||
"user_id",
|
||||
"userid",
|
||||
"user_email",
|
||||
"session_id",
|
||||
"sessionid",
|
||||
"full_name",
|
||||
"display_name",
|
||||
}
|
||||
|
||||
|
||||
def snapshot_identity_from_request(body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""Fields the BUG-727 key reads, copied without normalizing numbers.
|
||||
|
||||
The consultation birth payload turns hour ``5`` into ``5.0``. Those are
|
||||
different cache keys, so the gate must look up the request's own values.
|
||||
"""
|
||||
payload = body if isinstance(body, dict) else {}
|
||||
return {key: payload[key] for key in _IDENTITY_FIELDS if key in payload}
|
||||
|
||||
|
||||
def negative_cache_dir() -> Path:
|
||||
raw = str(os.environ.get("JYOTISH_VEDASTRO_SNAPSHOT_NEGATIVE_CACHE_DIR") or "").strip()
|
||||
path = Path(raw) if raw else Path(__file__).resolve().parents[1] / "scratch" / "local" / "vedastro_snapshot_negative_cache"
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def _negative_path(cache_key: str) -> Path:
|
||||
if not re.fullmatch(r"[0-9a-f]{64}", cache_key):
|
||||
raise ValueError("vedastro negative cache key must be sha256 hex")
|
||||
return negative_cache_dir() / f"{cache_key}.json"
|
||||
|
||||
|
||||
def _strip_identity(value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
key: _strip_identity(item)
|
||||
for key, item in value.items()
|
||||
if str(key).strip().lower() not in _IDENTITY_KEYS
|
||||
}
|
||||
if isinstance(value, list):
|
||||
return [_strip_identity(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def _cache_body(body: dict[str, Any]) -> dict[str, Any]:
|
||||
identity = body.get("vedastro_snapshot_identity")
|
||||
if isinstance(identity, dict):
|
||||
return identity
|
||||
return body
|
||||
|
||||
|
||||
def _deferred_packet() -> dict[str, Any]:
|
||||
return {
|
||||
"scope": "vedastro_gateway_run",
|
||||
"status": "official_blocked",
|
||||
"official_closure_state": "official_blocked",
|
||||
"official_closure_reason": DEFERRED_IN_CONSULTATION,
|
||||
}
|
||||
|
||||
|
||||
def lookup_negative_snapshot(
|
||||
body: dict[str, Any] | None,
|
||||
*,
|
||||
today: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Same-day negative hit only. Tomorrow is a different key, so it misses."""
|
||||
payload = body if isinstance(body, dict) else {}
|
||||
served = (today or official_snapshot_reference_date(payload))[:10]
|
||||
path = _negative_path(snapshot_cache_key(payload, reference_date=served))
|
||||
if not _SHA256_NAME.match(path.name) or not path.is_file():
|
||||
return None
|
||||
try:
|
||||
record = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
if not isinstance(record, dict) or record.get("schema") != NEGATIVE_CACHE_SCHEMA:
|
||||
return None
|
||||
if str(record.get("reference_date") or "")[:10] != served:
|
||||
return None
|
||||
gateway = record.get("gateway")
|
||||
return dict(gateway) if isinstance(gateway, dict) else None
|
||||
|
||||
|
||||
def store_negative_snapshot(
|
||||
body: dict[str, Any] | None,
|
||||
gateway: dict[str, Any],
|
||||
*,
|
||||
today: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
if not isinstance(gateway, dict):
|
||||
return None
|
||||
state = gateway.get("official_closure_state") or gateway.get("status")
|
||||
if state == "official_verified":
|
||||
return None
|
||||
payload = body if isinstance(body, dict) else {}
|
||||
served = (today or official_snapshot_reference_date(payload))[:10]
|
||||
try:
|
||||
datetime.strptime(served, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
return None
|
||||
cache_key = snapshot_cache_key(payload, reference_date=served)
|
||||
record = {
|
||||
"schema": NEGATIVE_CACHE_SCHEMA,
|
||||
"cache_key": cache_key,
|
||||
"reference_date": served,
|
||||
"stored_at": datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"gateway": _strip_identity(gateway),
|
||||
}
|
||||
path = _negative_path(cache_key)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
encoded = json.dumps(record, ensure_ascii=False, sort_keys=True)
|
||||
fd, tmp_name = tempfile.mkstemp(prefix=f"{cache_key}.", suffix=".tmp", dir=str(path.parent))
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
handle.write(encoded)
|
||||
os.replace(tmp_name, path)
|
||||
except Exception:
|
||||
with contextlib.suppress(OSError):
|
||||
os.unlink(tmp_name)
|
||||
raise
|
||||
return record
|
||||
|
||||
|
||||
def _verified_gateway(hit: dict[str, Any]) -> dict[str, Any] | None:
|
||||
record = hit.get("record") if isinstance(hit, dict) else None
|
||||
if not isinstance(record, dict):
|
||||
return None
|
||||
gateway = record.get("gateway")
|
||||
if not isinstance(gateway, dict):
|
||||
return None
|
||||
state = gateway.get("official_closure_state") or gateway.get("status")
|
||||
if state != "official_verified":
|
||||
return None
|
||||
if hit.get("freshness") == "stale":
|
||||
return annotate_stale_gateway(
|
||||
gateway,
|
||||
reference_date=str(record.get("reference_date") or ""),
|
||||
served_on_utc_date=str(hit.get("served_on_utc_date") or ""),
|
||||
)
|
||||
return dict(gateway)
|
||||
|
||||
|
||||
def consult_rectification_vedastro_gateway(body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""Official snapshot for the consultation rectification gate. Never starts the runner."""
|
||||
payload = body if isinstance(body, dict) else {}
|
||||
cache_body = _cache_body(payload)
|
||||
today = official_snapshot_reference_date(cache_body)
|
||||
reused = _verified_gateway(lookup_snapshot(cache_body, today=today) or {})
|
||||
if reused is not None:
|
||||
return reused
|
||||
cached = lookup_negative_snapshot(cache_body, today=today)
|
||||
if cached is not None:
|
||||
return cached
|
||||
packet = _deferred_packet()
|
||||
with contextlib.suppress(OSError):
|
||||
store_negative_snapshot(cache_body, packet, today=today)
|
||||
return packet
|
||||
Reference in New Issue
Block a user