8e31680b45
Intake stores how sure the user is; rectification now searches that range, offers a one-click widen when event fit is low at the edge, and trisects windows longer than two hours before the minute grid. Co-authored-by: Cursor <cursoragent@cursor.com>
358 lines
16 KiB
Python
358 lines
16 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", "blocks",
|
|
}) | _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")
|
|
_MINUTES_PER_DAY = 24 * 60
|
|
|
|
|
|
def _clock_minutes(value: str) -> int:
|
|
hour, minute = value.split(":", 1)
|
|
return int(hour) * 60 + int(minute)
|
|
|
|
|
|
def _clock_in_window(clock: str, start_time: str, end_time: str) -> bool:
|
|
current = _clock_minutes(clock)
|
|
start = _clock_minutes(start_time)
|
|
end = _clock_minutes(end_time)
|
|
if start <= end:
|
|
return start <= current <= end
|
|
return current >= start or current <= end
|
|
|
|
|
|
def _inclusive_minutes(start_time: str, end_time: str) -> list[int]:
|
|
start = _clock_minutes(start_time)
|
|
end = _clock_minutes(end_time)
|
|
span = end - start if end >= start else _MINUTES_PER_DAY - start + end
|
|
return [(start + offset) % _MINUTES_PER_DAY for offset in range(span + 1)]
|
|
|
|
|
|
def _block_contained(block_start: str, block_end: str, window_start: str, window_end: str) -> bool:
|
|
window = set(_inclusive_minutes(window_start, window_end))
|
|
return all(minute in window for minute in _inclusive_minutes(block_start, block_end))
|
|
|
|
|
|
def _ranges_overlap(left_start: str, left_end: str, right_start: str, right_end: str) -> bool:
|
|
left = set(_inclusive_minutes(left_start, left_end))
|
|
right = set(_inclusive_minutes(right_start, right_end))
|
|
shared = left & right
|
|
if not shared:
|
|
return False
|
|
endpoints = {
|
|
_clock_minutes(left_start),
|
|
_clock_minutes(left_end),
|
|
_clock_minutes(right_start),
|
|
_clock_minutes(right_end),
|
|
}
|
|
interior = shared - endpoints
|
|
if interior:
|
|
return True
|
|
# Adjacent blocks may share a single endpoint minute; more than that is overlap.
|
|
return len(shared) > 1
|
|
|
|
|
|
def _normalize_blocks(body: dict[str, Any], start_time: str, end_time: str) -> list[dict[str, str]]:
|
|
raw_blocks = body.get("blocks")
|
|
if not isinstance(raw_blocks, list) or not 1 <= len(raw_blocks) <= 5:
|
|
raise ValueError("blocks must contain between 1 and 5 items")
|
|
cleaned: list[dict[str, str]] = []
|
|
for index, raw in enumerate(raw_blocks):
|
|
if not isinstance(raw, dict):
|
|
raise ValueError(f"blocks[{index}] must be an object")
|
|
label = raw.get("label") or raw.get("period")
|
|
block_start, block_end = raw.get("start_time"), raw.get("end_time")
|
|
if not isinstance(label, str) or not label.strip() or len(label.strip()) > 40:
|
|
raise ValueError(f"blocks[{index}].label must be a non-empty string up to 40 characters")
|
|
if not isinstance(block_start, str) or not _CLOCK.fullmatch(block_start):
|
|
raise ValueError(f"blocks[{index}].start_time must be HH:MM")
|
|
if not isinstance(block_end, str) or not _CLOCK.fullmatch(block_end):
|
|
raise ValueError(f"blocks[{index}].end_time must be HH:MM")
|
|
if block_start == block_end:
|
|
raise ValueError(f"blocks[{index}] start_time and end_time must differ")
|
|
if not _block_contained(block_start, block_end, start_time, end_time):
|
|
raise ValueError(f"blocks[{index}] must fall inside the request window")
|
|
for previous in cleaned:
|
|
if _ranges_overlap(previous["start_time"], previous["end_time"], block_start, block_end):
|
|
raise ValueError("blocks must not overlap")
|
|
cleaned.append({
|
|
"label": label.strip(),
|
|
"period": label.strip(),
|
|
"start_time": block_start,
|
|
"end_time": block_end,
|
|
})
|
|
return cleaned
|
|
|
|
|
|
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]
|
|
blocks: NotRequired[list[dict[str, Any]]]
|
|
|
|
|
|
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
|
|
if "blocks" in body:
|
|
cleaned_request["blocks"] = _normalize_blocks(body, start_time, end_time)
|
|
return cast(RectificationRequest, cleaned_request)
|