feat: add rectification event decision contract v2
This commit is contained in:
@@ -8,19 +8,41 @@ from typing import Any, Literal, NotRequired, TypedDict, cast
|
||||
|
||||
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"}),
|
||||
"other": frozenset({"other"}),
|
||||
}
|
||||
BACKGROUND_EVENT_KINDS = frozenset({"family_event", "other"})
|
||||
SCOREABLE_EVENT_KINDS: dict[str, frozenset[str]] = {
|
||||
"education": frozenset({"education_milestone"}),
|
||||
"relocation": frozenset({"relocation"}),
|
||||
"relationship": frozenset({"relationship_start", "relationship_change"}),
|
||||
"career": frozenset({"career_change"}),
|
||||
"finance": frozenset({"finance_change"}),
|
||||
"health_pressure": frozenset({"self_health_event"}),
|
||||
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"})
|
||||
_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"}) | _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")
|
||||
@@ -38,6 +60,8 @@ class LifeEvent(TypedDict):
|
||||
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):
|
||||
@@ -57,6 +81,10 @@ class RectificationRequest(TypedDict):
|
||||
JsonObject = dict[str, Any]
|
||||
|
||||
|
||||
def is_scoreable_event(event: LifeEvent) -> bool:
|
||||
return event["event_kind"] not in BACKGROUND_EVENT_KINDS
|
||||
|
||||
|
||||
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)):
|
||||
@@ -123,9 +151,9 @@ def normalize_rectification_request(body: Any, *, today: date | None = None) ->
|
||||
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 SCOREABLE_EVENT_KINDS:
|
||||
raise ValueError(f"events[{index}].domain is not scoreable")
|
||||
if event_kind not in SCOREABLE_EVENT_KINDS[cast(str, domain)]:
|
||||
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:
|
||||
@@ -139,6 +167,13 @@ def normalize_rectification_request(body: Any, *, today: date | None = None) ->
|
||||
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")
|
||||
subject = raw_event.get("subject")
|
||||
if subject is None:
|
||||
subject = "family" if domain == "family" else "other" if domain == "other" else "self"
|
||||
if subject not in {"self", "family", "other"}:
|
||||
raise ValueError(f"events[{index}].subject is invalid")
|
||||
if event_kind not in BACKGROUND_EVENT_KINDS 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),
|
||||
@@ -147,11 +182,21 @@ def normalize_rectification_request(body: Any, *, today: date | None = None) ->
|
||||
"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] = {
|
||||
|
||||
Reference in New Issue
Block a user