Files
Jyotisha/scripts/rectification/contracts.py
T
Jesse_Chen 814c924e4a
Independent Staging Quality Gate / validate (push) Successful in 9m20s
Independent Staging Quality Gate / publish (push) Successful in 6m51s
fix(rectification): exhaustion exit, explain layer, range reading, unknown-time scan (BUG-565–568)
Keep askable cards after exhaustion, explain each probe, read the adopted credible range in reports and chat, and compare declared periods before the minute grid when the clock is unknown.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 09:10:37 +08:00

277 lines
13 KiB
Python

from __future__ import annotations
import math
import re
import uuid
from datetime import date
from typing import Any, Literal, NotRequired, TypedDict, cast
from scripts.ayanamsa_utils import UnsupportedAyanamsaError, normalize_ayanamsa_name
DatePrecision = Literal["day", "month", "quarter", "year", "range"]
EVENT_CONTRACT_VERSION = "rectification-event-contract-v2"
EVENT_KINDS: dict[str, frozenset[str]] = {
"education": frozenset({
"education_start", "education_completion", "education_interruption", "education_change",
"education_milestone", # v1 compatibility
}),
"career": frozenset({
"career_entry", "career_change", "promotion", "career_pressure", "career_exit", "business_start",
}),
"relationship": frozenset({
"relationship_start", "relationship_commitment", "relationship_separation", "relationship_end",
"relationship_change", # v1 compatibility
}),
"relocation": frozenset({"relocation", "foreign_move", "return", "home_change"}),
"finance": frozenset({"finance_gain", "finance_loss", "income_change", "asset_change", "finance_change"}),
"health": frozenset({"self_health_event", "pressure_period"}),
"health_pressure": frozenset({"self_health_event", "pressure_period"}), # v1 domain compatibility
"family": frozenset({"family_event"}),
"appearance": frozenset({"appearance_note"}),
"marks": frozenset({"birthmark_or_scar"}),
"occupation": frozenset({"occupation_note"}),
"horary": frozenset({"horary_query"}),
"other": frozenset({"other"}),
}
BACKGROUND_EVENT_KINDS = frozenset({"other", "horary_query"})
AUXILIARY_EVENT_KINDS = frozenset({"appearance_note", "birthmark_or_scar", "occupation_note"})
SCOREABLE_EVENT_KINDS: dict[str, frozenset[str]] = {
domain: frozenset(kind for kind in kinds if kind not in BACKGROUND_EVENT_KINDS)
for domain, kinds in EVENT_KINDS.items()
if any(kind not in BACKGROUND_EVENT_KINDS for kind in kinds)
}
DATE_PRECISIONS = frozenset({"day", "month", "quarter", "year", "range"})
_BIRTH_TIME_SOURCES = frozenset({"hospital_record", "family_exact", "approximate", "period_only", "unknown", "legacy_import"})
_LOCAL_TIME_STATUSES = frozenset({"resolved", "not_provided", "ambiguous", "nonexistent"})
_REQUEST_PROVENANCE_FIELDS = frozenset({"birth_time_source", "timezone_id", "timezone_source", "local_time_status"})
_EVENT_PROVENANCE_FIELDS = frozenset({
"date_source", "date_reliability", "date_corroboration", "date_conflict_status",
"source_turn_id", "subject",
})
_REQUEST_FIELDS = frozenset({
"birth_date", "start_time", "end_time", "lat", "lon", "tz", "events",
"ayanamsa", "node_mode", "asked_probe_keys", "minute_step",
}) | _REQUEST_PROVENANCE_FIELDS
_EVENT_FIELDS = frozenset({"id", "domain", "event_kind", "date_start", "date_end", "precision", "summary"}) | _EVENT_PROVENANCE_FIELDS
_CLOCK = re.compile(r"(?:[01]\d|2[0-3]):[0-5]\d\Z")
class LifeEvent(TypedDict):
id: str
domain: str
event_kind: str
date_start: str
date_end: str
precision: DatePrecision
summary: NotRequired[str]
date_source: NotRequired[str | None]
date_reliability: NotRequired[str | None]
date_corroboration: NotRequired[str | None]
date_conflict_status: NotRequired[str | None]
source_turn_id: NotRequired[str | None]
subject: NotRequired[Literal["self", "family", "other"]]
class RectificationRequest(TypedDict):
birth_date: str
start_time: str
end_time: str
lat: float
lon: float
tz: float
events: list[LifeEvent]
ayanamsa: NotRequired[str]
node_mode: NotRequired[str]
birth_time_source: NotRequired[str | None]
timezone_id: NotRequired[str | None]
timezone_source: NotRequired[str | None]
local_time_status: NotRequired[str | None]
asked_probe_keys: NotRequired[list[str]]
minute_step: NotRequired[int]
JsonObject = dict[str, Any]
def is_scoreable_event(event: LifeEvent) -> bool:
return event["event_kind"] not in BACKGROUND_EVENT_KINDS
def is_primary_scoreable_event(event: LifeEvent) -> bool:
"""Dated events that may move the candidate ranking as a primary formula."""
return is_scoreable_event(event) and event["event_kind"] not in AUXILIARY_EVENT_KINDS
def subject_for_event_domain(domain: str, subject: str | None = None) -> Literal["self", "family", "other"]:
if domain == "family":
return "family"
if domain == "other":
return "other"
if subject in {"self", "family", "other"}:
return cast(Literal["self", "family", "other"], subject)
return "self"
def _bounded_number(body: dict[str, Any], name: str, minimum: float, maximum: float) -> float:
value = body.get(name)
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(float(value)):
raise ValueError(f"{name} must be a finite number")
result = float(value)
if not minimum <= result <= maximum:
raise ValueError(f"{name} must be between {minimum:g} and {maximum:g}")
return result
def _calendar_date(value: Any, label: str) -> date:
if not isinstance(value, str):
raise ValueError(f"{label} must be a valid YYYY-MM-DD value")
try:
return date.fromisoformat(value)
except ValueError as exc:
raise ValueError(f"{label} must be a valid YYYY-MM-DD value") from exc
def _copy_nullable_text(
source: dict[str, Any], target: dict[str, Any], name: str, label: str, maximum: int,
allowed: frozenset[str] | None = None,
) -> None:
if name not in source:
return
value = source[name]
if value is None:
target[name] = None
return
if not isinstance(value, str) or not value.strip() or len(value.strip()) > maximum:
raise ValueError(f"{label} must be null or a non-empty string up to {maximum} characters")
cleaned = value.strip()
if allowed is not None and cleaned not in allowed:
raise ValueError(f"{label} is invalid")
target[name] = cleaned
def normalize_rectification_request(body: Any, *, today: date | None = None) -> RectificationRequest:
if not isinstance(body, dict):
raise ValueError("request body must be an object")
unsupported = sorted(set(body) - _REQUEST_FIELDS)
if unsupported:
raise ValueError(f"unsupported rectification field: {unsupported[0]}")
birth_day = _calendar_date(body.get("birth_date"), "birth_date")
start_time, end_time = body.get("start_time"), body.get("end_time")
if not isinstance(start_time, str) or not _CLOCK.fullmatch(start_time):
raise ValueError("start_time must be HH:MM")
if not isinstance(end_time, str) or not _CLOCK.fullmatch(end_time):
raise ValueError("end_time must be HH:MM")
events = body.get("events")
if not isinstance(events, list) or not 0 <= len(events) <= 100:
raise ValueError("events must contain between 0 and 100 items")
upper_date = today or date.today()
cleaned_events: list[LifeEvent] = []
for index, raw_event in enumerate(events):
if not isinstance(raw_event, dict):
raise ValueError(f"events[{index}] must be an object")
unsupported_event_fields = sorted(set(raw_event) - _EVENT_FIELDS)
if unsupported_event_fields:
raise ValueError(f"events[{index}] contains unsupported field: {unsupported_event_fields[0]}")
try:
event_id = str(uuid.UUID(str(raw_event.get("id") or "")))
except (ValueError, AttributeError) as exc:
raise ValueError(f"events[{index}].id must be a UUID") from exc
domain, event_kind = raw_event.get("domain"), raw_event.get("event_kind")
if domain not in EVENT_KINDS:
raise ValueError(f"events[{index}].domain is invalid")
if event_kind not in EVENT_KINDS[cast(str, domain)]:
raise ValueError(f"events[{index}].event_kind does not match domain")
precision = raw_event.get("precision")
if precision not in DATE_PRECISIONS:
raise ValueError(f"events[{index}].precision is invalid")
start_day = _calendar_date(raw_event.get("date_start"), f"events[{index}].date_start")
end_day = _calendar_date(raw_event.get("date_end"), f"events[{index}].date_end")
if start_day > end_day:
raise ValueError(f"events[{index}].date_start must not exceed date_end")
if start_day < birth_day or end_day > upper_date:
raise ValueError(f"events[{index}] dates must be between birth_date and today")
summary = raw_event.get("summary", "")
if not isinstance(summary, str) or len(summary) > 1_000:
raise ValueError(f"events[{index}].summary must be a string up to 1000 characters")
raw_subject = raw_event.get("subject")
if raw_subject is not None and raw_subject not in {"self", "family", "other"}:
raise ValueError(f"events[{index}].subject is invalid")
subject = subject_for_event_domain(cast(str, domain), None if raw_subject is None else str(raw_subject))
if event_kind not in BACKGROUND_EVENT_KINDS and domain != "family" and subject != "self":
raise ValueError(f"events[{index}].subject must be self for scoreable events")
cleaned_event: dict[str, Any] = {
"id": event_id,
"domain": cast(str, domain),
"event_kind": cast(str, event_kind),
"date_start": start_day.isoformat(),
"date_end": end_day.isoformat(),
"precision": cast(DatePrecision, precision),
"summary": summary.strip(),
"subject": cast(Literal["self", "family", "other"], subject),
}
_copy_nullable_text(raw_event, cleaned_event, "date_source", f"events[{index}].date_source", 120)
_copy_nullable_text(raw_event, cleaned_event, "date_reliability", f"events[{index}].date_reliability", 120)
_copy_nullable_text(raw_event, cleaned_event, "date_corroboration", f"events[{index}].date_corroboration", 1_000)
_copy_nullable_text(raw_event, cleaned_event, "date_conflict_status", f"events[{index}].date_conflict_status", 120)
if "source_turn_id" in raw_event:
source_turn_id = raw_event.get("source_turn_id")
if source_turn_id is None:
cleaned_event["source_turn_id"] = None
else:
try:
cleaned_event["source_turn_id"] = str(uuid.UUID(str(source_turn_id)))
except (ValueError, AttributeError) as exc:
raise ValueError(f"events[{index}].source_turn_id must be null or a UUID") from exc
cleaned_events.append(cast(LifeEvent, cleaned_event))
cleaned_request: dict[str, Any] = {
"birth_date": birth_day.isoformat(),
"start_time": start_time,
"end_time": end_time,
"lat": _bounded_number(body, "lat", -90, 90),
"lon": _bounded_number(body, "lon", -180, 180),
"tz": _bounded_number(body, "tz", -14, 14),
"events": cleaned_events,
}
if "ayanamsa" in body:
try:
cleaned_request["ayanamsa"] = normalize_ayanamsa_name(body.get("ayanamsa"))
except UnsupportedAyanamsaError as exc:
raise ValueError(str(exc)) from exc
if "node_mode" in body:
node_mode = body.get("node_mode")
if not isinstance(node_mode, str) or node_mode.strip().lower() not in {"mean", "true"}:
raise ValueError("node_mode must be mean or true")
cleaned_request["node_mode"] = node_mode.strip().lower()
_copy_nullable_text(body, cleaned_request, "birth_time_source", "birth_time_source", 120, _BIRTH_TIME_SOURCES)
_copy_nullable_text(body, cleaned_request, "timezone_id", "timezone_id", 120)
_copy_nullable_text(body, cleaned_request, "timezone_source", "timezone_source", 80)
_copy_nullable_text(body, cleaned_request, "local_time_status", "local_time_status", 120, _LOCAL_TIME_STATUSES)
if "asked_probe_keys" in body:
asked = body.get("asked_probe_keys")
if not isinstance(asked, list) or len(asked) > 200:
raise ValueError("asked_probe_keys must contain between 0 and 200 strings")
cleaned_keys: list[str] = []
seen: set[str] = set()
for index, item in enumerate(asked):
if not isinstance(item, str) or not item.strip() or len(item.strip()) > 120:
raise ValueError(
f"asked_probe_keys[{index}] must be a non-empty string up to 120 characters"
)
key = item.strip()
if key in seen:
continue
seen.add(key)
cleaned_keys.append(key)
cleaned_request["asked_probe_keys"] = cleaned_keys
if "minute_step" in body:
minute_step = body.get("minute_step")
if isinstance(minute_step, bool) or not isinstance(minute_step, int) or not 1 <= minute_step <= 15:
raise ValueError("minute_step must be an integer from 1 to 15")
if minute_step != 1:
cleaned_request["minute_step"] = minute_step
return cast(RectificationRequest, cleaned_request)