fix(rectification): honor declared birth-time uncertainty and split windows over two hours (BUG-571–573)

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>
This commit is contained in:
Jesse_Chen
2026-09-07 12:17:04 +08:00
parent b2a8d3a005
commit 8e31680b45
49 changed files with 3047 additions and 142 deletions
+82 -1
View File
@@ -52,10 +52,88 @@ _EVENT_PROVENANCE_FIELDS = frozenset({
})
_REQUEST_FIELDS = frozenset({
"birth_date", "start_time", "end_time", "lat", "lon", "tz", "events",
"ayanamsa", "node_mode", "asked_probe_keys", "minute_step",
"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):
@@ -90,6 +168,7 @@ class RectificationRequest(TypedDict):
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]
@@ -273,4 +352,6 @@ def normalize_rectification_request(body: Any, *, today: date | None = None) ->
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)