proactive-gate 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,31 @@
1
+ """proactive-gate: decide whether a proactive assistant may speak now.
2
+
3
+ The Python sibling of the TypeScript package, held to the same behaviour
4
+ contract in ``spec/`` and the same fixtures.
5
+ """
6
+ from . import checks, presets
7
+ from .checks import Check, default_checks
8
+ from .gate import AsyncGate, Evaluation, Gate, Hooks, decide
9
+ from .policy import KNOWN_CHECKS, CompiledPolicy, compile_policy, load_policy
10
+ from .presets import Preset
11
+ from .stores import AsyncMemoryStore, AsyncStore, MemoryStore, RedisStore, SqliteStore, Store
12
+ from .types import (
13
+ PRIORITY_RANK,
14
+ Candidate,
15
+ Context,
16
+ Decision,
17
+ EvaluateInput,
18
+ Outcome,
19
+ Priority,
20
+ TraceEntry,
21
+ UserState,
22
+ )
23
+
24
+ __version__ = "0.2.0"
25
+
26
+ __all__ = [
27
+ "PRIORITY_RANK", "KNOWN_CHECKS", "AsyncGate", "AsyncMemoryStore", "AsyncStore", "Candidate", "Check",
28
+ "CompiledPolicy", "Context", "Decision", "EvaluateInput", "Evaluation", "Gate", "Hooks", "MemoryStore",
29
+ "Outcome", "Preset", "Priority", "RedisStore", "SqliteStore", "Store", "TraceEntry", "UserState",
30
+ "__version__", "checks", "compile_policy", "decide", "default_checks", "load_policy", "presets",
31
+ ]
@@ -0,0 +1,522 @@
1
+ """The checks, as sans-IO objects. A check names the store keys it needs
2
+ (``keys``) and decides purely from their values (``run``); budget-like checks
3
+ also describe their commit-time increment (``consume_plan``). Reasons match the
4
+ TypeScript package word for word so the shared fixtures hold for both."""
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import math
9
+ from collections.abc import Callable, Mapping, Sequence
10
+ from datetime import datetime, timedelta
11
+ from typing import Protocol, runtime_checkable
12
+
13
+ from .clock import DAY_SECONDS, in_window, iso_week_key, local_clock, local_day, parse_hhmm
14
+ from .types import (
15
+ PASS,
16
+ ConsumePlan,
17
+ Context,
18
+ NearLimit,
19
+ Outcome,
20
+ Priority,
21
+ at_least,
22
+ defer,
23
+ epoch_ms,
24
+ iso_z,
25
+ reject,
26
+ skip,
27
+ )
28
+
29
+ Values = Mapping[str, str | None]
30
+
31
+
32
+ @runtime_checkable
33
+ class Check(Protocol):
34
+ id: str
35
+ non_rejecting: bool
36
+ shadow: bool
37
+
38
+ def keys(self, ctx: Context) -> Sequence[str]: ...
39
+
40
+ def run(self, ctx: Context, values: Values) -> Outcome: ...
41
+
42
+
43
+ class Consumer(Protocol):
44
+ """A check that takes one unit at commit time."""
45
+
46
+ def consume_plan(self, ctx: Context) -> ConsumePlan | None: ...
47
+
48
+
49
+ class BaseCheck:
50
+ id: str = "check"
51
+ non_rejecting: bool = False
52
+ shadow: bool = False
53
+
54
+ def keys(self, ctx: Context) -> Sequence[str]:
55
+ return ()
56
+
57
+ def run(self, ctx: Context, values: Values) -> Outcome:
58
+ return PASS
59
+
60
+
61
+ def _num(n: float) -> str:
62
+ """JavaScript number formatting for the reasons: 1 not 1.0, 0.556 not 0.5560."""
63
+ if float(n).is_integer():
64
+ return str(int(n))
65
+ return repr(float(n))
66
+
67
+
68
+ def _round3(n: float) -> float:
69
+ return math.floor(n * 1000 + 0.5) / 1000
70
+
71
+
72
+ def _js_round(n: float) -> int:
73
+ return math.floor(n + 0.5)
74
+
75
+
76
+ class KillSwitch(BaseCheck):
77
+ id = "killSwitch"
78
+
79
+ def __init__(self, is_on: Callable[[], bool] | bool = False) -> None:
80
+ self._is_on = is_on
81
+
82
+ def run(self, ctx: Context, values: Values) -> Outcome:
83
+ on = self._is_on() if callable(self._is_on) else self._is_on
84
+ return reject("engine kill switch is on") if on else PASS
85
+
86
+
87
+ class Consent(BaseCheck):
88
+ id = "consent"
89
+
90
+ def run(self, ctx: Context, values: Values) -> Outcome:
91
+ return PASS if ctx.user.consent else reject("user has not consented to proactive behaviour")
92
+
93
+
94
+ class Enabled(BaseCheck):
95
+ id = "enabled"
96
+
97
+ def run(self, ctx: Context, values: Values) -> Outcome:
98
+ if ctx.user.proactive_enabled is False:
99
+ return reject("proactive behaviour is disabled on this profile")
100
+ return PASS
101
+
102
+
103
+ class Mode(BaseCheck):
104
+ id = "mode"
105
+
106
+ def __init__(self, allow: Sequence[str] = ("normal",)) -> None:
107
+ self.allow = tuple(allow)
108
+
109
+ def run(self, ctx: Context, values: Values) -> Outcome:
110
+ mode = ctx.user.mode
111
+ if mode is not None and mode not in self.allow:
112
+ return reject(f'operating mode "{mode}" does not allow proactive messages')
113
+ return PASS
114
+
115
+
116
+ class Snooze(BaseCheck):
117
+ id = "snooze"
118
+
119
+ def __init__(self, defer: bool = False) -> None:
120
+ self.defer = defer
121
+
122
+ def run(self, ctx: Context, values: Values) -> Outcome:
123
+ until = ctx.user.snoozed_until
124
+ if until is None or until <= ctx.now:
125
+ return PASS
126
+ reason = f"snoozed until {iso_z(until)}"
127
+ return defer(reason, until) if self.defer else reject(reason)
128
+
129
+
130
+ class Mute(BaseCheck):
131
+ id = "mute"
132
+
133
+ def run(self, ctx: Context, values: Values) -> Outcome:
134
+ if ctx.candidate.type in ctx.user.muted_types:
135
+ return reject(f'type "{ctx.candidate.type}" is muted by the user')
136
+ return PASS
137
+
138
+
139
+ class Intensity(BaseCheck):
140
+ id = "intensity"
141
+
142
+ def __init__(self, floors: Mapping[str, str] | None = None) -> None:
143
+ self.floors: Mapping[str, str] = floors or {"low": "high", "normal": "normal", "high": "low"}
144
+
145
+ def run(self, ctx: Context, values: Values) -> Outcome:
146
+ level = ctx.user.intensity or "normal"
147
+ floor = self.floors[level]
148
+ if at_least(ctx.priority, floor):
149
+ return PASS
150
+ return reject(f'priority {ctx.priority} is below the "{level}" intensity floor ({floor})')
151
+
152
+
153
+ class QuietHours(BaseCheck):
154
+ id = "quietHours"
155
+
156
+ def __init__(self, priority_floor: str = "critical") -> None:
157
+ self.floor = priority_floor
158
+
159
+ def run(self, ctx: Context, values: Values) -> Outcome:
160
+ qh = ctx.user.quiet_hours
161
+ if qh is None:
162
+ return PASS
163
+ if not ctx.user.timezone:
164
+ return skip("quiet hours set but no timezone on the user; cannot evaluate")
165
+ minutes, _ = local_clock(ctx.now, ctx.user.timezone)
166
+ if not in_window(minutes, parse_hhmm(qh.start), parse_hhmm(qh.end)):
167
+ return PASS
168
+ if at_least(ctx.priority, self.floor):
169
+ return PASS
170
+ return reject(f"quiet hours {qh.start} to {qh.end} {ctx.user.timezone}; priority {ctx.priority} is below the floor ({self.floor})")
171
+
172
+
173
+ class TrustRamp(BaseCheck):
174
+ id = "trustRamp"
175
+
176
+ def __init__(self, days: float = 7, min_priority: str = "high") -> None:
177
+ self.days = days
178
+ self.floor = min_priority
179
+
180
+ def run(self, ctx: Context, values: Values) -> Outcome:
181
+ created = ctx.user.created_at
182
+ if created is None:
183
+ return skip("no createdAt on the user; ramp cannot be evaluated")
184
+ age = (ctx.now - created).total_seconds() / DAY_SECONDS
185
+ if age >= self.days:
186
+ return PASS
187
+ if at_least(ctx.priority, self.floor):
188
+ return PASS
189
+ return reject(f"trust ramp: day {math.floor(age) + 1} of {_num(self.days)}, priority {ctx.priority} is below {self.floor}")
190
+
191
+
192
+ def dismissal_key(user_id: str, type_: str) -> str:
193
+ return f"cooldown:{user_id}:{type_}"
194
+
195
+
196
+ class DismissalCooldown(BaseCheck):
197
+ id = "dismissalCooldown"
198
+
199
+ def __init__(self, dismissals: int = 3, within_days: float = 30, silence_days: float = 7) -> None:
200
+ self.n = dismissals
201
+ self.within_days = within_days
202
+ self.silence_days = silence_days
203
+
204
+ def keys(self, ctx: Context) -> Sequence[str]:
205
+ return (dismissal_key(ctx.user.id, ctx.candidate.type),)
206
+
207
+ def run(self, ctx: Context, values: Values) -> Outcome:
208
+ raw = values.get(dismissal_key(ctx.user.id, ctx.candidate.type))
209
+ stamps: list[int] = json.loads(raw) if raw else []
210
+ window_start = epoch_ms(ctx.now) - self.within_days * DAY_SECONDS * 1000
211
+ recent = sorted(t for t in stamps if t >= window_start)
212
+ if len(recent) < self.n:
213
+ return PASS
214
+ silent_until = recent[-1] + self.silence_days * DAY_SECONDS * 1000
215
+ if epoch_ms(ctx.now) >= silent_until:
216
+ return PASS
217
+ until = datetime.fromtimestamp(silent_until / 1000, tz=ctx.now.tzinfo)
218
+ return reject(f'{len(recent)} dismissals of "{ctx.candidate.type}" in {_num(self.within_days)} days; silent until {iso_z(until)}')
219
+
220
+
221
+ class AdaptiveTiming(BaseCheck):
222
+ """Never rejects. Plug in ``next_good_moment`` and ``surfaces_for`` from your own model."""
223
+
224
+ id = "adaptiveTiming"
225
+ non_rejecting = True
226
+
227
+ def __init__(
228
+ self,
229
+ next_good_moment: Callable[[Context], datetime | None] | None = None,
230
+ surfaces_for: Callable[[Context], Sequence[str] | None] | None = None,
231
+ ) -> None:
232
+ self.next_good_moment = next_good_moment
233
+ self.surfaces_for = surfaces_for
234
+
235
+ def run(self, ctx: Context, values: Values) -> Outcome:
236
+ at = self.next_good_moment(ctx) if self.next_good_moment else None
237
+ surfaces = self.surfaces_for(ctx) if self.surfaces_for else None
238
+ if at is None and surfaces is None:
239
+ return PASS
240
+ parts: list[str] = []
241
+ if at is not None:
242
+ parts.append(f"deliver at {iso_z(at)}")
243
+ if surfaces is not None:
244
+ parts.append(f"surfaces {','.join(surfaces)}")
245
+ return Outcome("adjust", "; ".join(parts), deliver_at=at, surfaces=tuple(surfaces) if surfaces is not None else None)
246
+
247
+
248
+ class Budget(BaseCheck):
249
+ """Reads a counter at evaluate, increments it at commit. Subclasses name the key."""
250
+
251
+ label = "budget"
252
+ default_limit = 5
253
+ ttl_seconds = 2 * DAY_SECONDS
254
+
255
+ def __init__(self, limit: int | None = None, bypass_priority: str | None = None, near_limit: float = 0.8) -> None:
256
+ self.limit = limit if limit is not None else self.default_limit
257
+ self.bypass_priority = bypass_priority
258
+ self.near_at = max(1, math.ceil(self.limit * near_limit))
259
+
260
+ def key_for(self, ctx: Context) -> str:
261
+ raise NotImplementedError
262
+
263
+ def bypass(self, ctx: Context) -> bool:
264
+ return self.bypass_priority is not None and at_least(ctx.priority, self.bypass_priority)
265
+
266
+ def keys(self, ctx: Context) -> Sequence[str]:
267
+ return () if self.bypass(ctx) else (self.key_for(ctx),)
268
+
269
+ def run(self, ctx: Context, values: Values) -> Outcome:
270
+ if self.bypass(ctx):
271
+ return PASS
272
+ used = int(values.get(self.key_for(ctx)) or 0)
273
+ if used >= self.limit:
274
+ return reject(f"{self.label} of {self.limit} used ({used})")
275
+ if used >= self.near_at:
276
+ return Outcome("pass", f"{used} of {self.limit} used", near_limit=NearLimit(used, self.limit))
277
+ return PASS
278
+
279
+ def consume_plan(self, ctx: Context) -> ConsumePlan | None:
280
+ if self.bypass(ctx):
281
+ return None
282
+ return ConsumePlan(self.key_for(ctx), self.ttl_seconds, self.limit)
283
+
284
+
285
+ def budget_key(user_id: str, now: datetime, tz: str | None = None) -> str:
286
+ return f"budget:{user_id}:{local_day(now, tz)}"
287
+
288
+
289
+ def weekly_budget_key(user_id: str, now: datetime, tz: str | None = None) -> str:
290
+ return f"weeklyBudget:{user_id}:{iso_week_key(local_day(now, tz))}"
291
+
292
+
293
+ def monthly_budget_key(user_id: str, now: datetime, tz: str | None = None) -> str:
294
+ return f"monthlyBudget:{user_id}:{local_day(now, tz)[:7]}"
295
+
296
+
297
+ class DailyBudget(Budget):
298
+ id = "dailyBudget"
299
+ label = "daily budget"
300
+ default_limit = 5
301
+ ttl_seconds = 2 * DAY_SECONDS
302
+
303
+ def key_for(self, ctx: Context) -> str:
304
+ return budget_key(ctx.user.id, ctx.now, ctx.user.timezone)
305
+
306
+
307
+ class WeeklyBudget(Budget):
308
+ id = "weeklyBudget"
309
+ label = "weekly budget"
310
+ default_limit = 20
311
+ ttl_seconds = 8 * DAY_SECONDS
312
+
313
+ def key_for(self, ctx: Context) -> str:
314
+ return weekly_budget_key(ctx.user.id, ctx.now, ctx.user.timezone)
315
+
316
+
317
+ class MonthlyBudget(Budget):
318
+ id = "monthlyBudget"
319
+ label = "monthly budget"
320
+ default_limit = 60
321
+ ttl_seconds = 32 * DAY_SECONDS
322
+
323
+ def key_for(self, ctx: Context) -> str:
324
+ return monthly_budget_key(ctx.user.id, ctx.now, ctx.user.timezone)
325
+
326
+
327
+ class UtilityFloor(BaseCheck):
328
+ """Act only when the caller's pAccept clears tau = cFA / (cFA + pNeed * cFN)."""
329
+
330
+ id = "utilityFloor"
331
+
332
+ def __init__(self, cost_false_alarm: float = 1, cost_missed_help: float = 1) -> None:
333
+ self.c_fa = cost_false_alarm
334
+ self.c_fn = cost_missed_help
335
+
336
+ def run(self, ctx: Context, values: Values) -> Outcome:
337
+ p_accept = ctx.candidate.p_accept
338
+ if p_accept is None:
339
+ return skip("no pAccept on the candidate; utility floor cannot be evaluated")
340
+ p_need = ctx.candidate.p_need if ctx.candidate.p_need is not None else 1
341
+ tau = self.c_fa / (self.c_fa + p_need * self.c_fn)
342
+ if p_accept >= tau:
343
+ return PASS
344
+ return reject(f"pAccept {_num(_round3(p_accept))} < tau {_num(_round3(tau))}")
345
+
346
+
347
+ class BoundedDeferral(BaseCheck):
348
+ """Horvitz bounded deferral: wait t* = min(bound, lambda * c / (2 * staleness)) while the user is busy."""
349
+
350
+ id = "boundedDeferral"
351
+ non_rejecting = True
352
+
353
+ def __init__(
354
+ self,
355
+ lambda_: float = 1 / 43,
356
+ interrupt_cost: float = 1,
357
+ staleness: float = 0.0001,
358
+ bound_seconds: float = 240,
359
+ is_busy: Callable[[Context], bool] | None = None,
360
+ ) -> None:
361
+ self.t_star = min(bound_seconds, (lambda_ * interrupt_cost) / (2 * staleness))
362
+ self.is_busy = is_busy
363
+
364
+ def run(self, ctx: Context, values: Values) -> Outcome:
365
+ busy = self.is_busy(ctx) if self.is_busy else ctx.candidate.busy is True
366
+ if not busy:
367
+ return PASS
368
+ at = ctx.now + timedelta(milliseconds=_js_round(self.t_star * 1000))
369
+ return Outcome("adjust", f"user busy; deliver at {iso_z(at)} (t* {_js_round(self.t_star)} s)", deliver_at=at)
370
+
371
+
372
+ def _zone_of(ctx: Context, tz: str) -> str | None:
373
+ return ctx.user.timezone if tz == "user" else tz
374
+
375
+
376
+ class AllowedWindow(BaseCheck):
377
+ def __init__(self, start: str, end: str, timezone: str = "user", priority_floor: str | None = None, id: str = "allowedWindow") -> None:
378
+ self.id = id
379
+ self.start_text, self.end_text = start, end
380
+ self.start, self.end = parse_hhmm(start), parse_hhmm(end)
381
+ self.timezone = timezone
382
+ self.priority_floor = priority_floor
383
+
384
+ def run(self, ctx: Context, values: Values) -> Outcome:
385
+ zone = _zone_of(ctx, self.timezone)
386
+ if not zone:
387
+ return skip("no timezone on the user; window cannot be evaluated")
388
+ if self.priority_floor and at_least(ctx.priority, self.priority_floor):
389
+ return PASS
390
+ minutes, _ = local_clock(ctx.now, zone)
391
+ if in_window(minutes, self.start, self.end):
392
+ return PASS
393
+ return reject(f"outside the allowed window {self.start_text} to {self.end_text} {zone}")
394
+
395
+
396
+ class RequiresConsent(BaseCheck):
397
+ def __init__(self, name: str, when: Mapping[str, str] | None = None, id: str | None = None) -> None:
398
+ self.name = name
399
+ self.id = id or f"consent:{name}"
400
+ self.when = when
401
+
402
+ def run(self, ctx: Context, values: Values) -> Outcome:
403
+ suffix = ""
404
+ if self.when:
405
+ zone = _zone_of(ctx, self.when["timezone"])
406
+ if not zone:
407
+ return skip("no timezone on the user; consent window cannot be evaluated")
408
+ minutes, _ = local_clock(ctx.now, zone)
409
+ if not in_window(minutes, parse_hhmm(self.when["start"]), parse_hhmm(self.when["end"])):
410
+ return PASS
411
+ suffix = f" (required {self.when['start']} to {self.when['end']})"
412
+ if ctx.user.consents.get(self.name):
413
+ return PASS
414
+ return reject(f'consent "{self.name}" is missing{suffix}')
415
+
416
+
417
+ class RateLimit(Budget):
418
+ """Fixed-window rate limit keyed by user or by candidate.channel; consumed at commit."""
419
+
420
+ def __init__(self, limit: int, per_seconds: int, key_by: str = "user", id: str | None = None) -> None:
421
+ super().__init__(limit=limit, near_limit=1)
422
+ self.per_seconds = per_seconds
423
+ self.key_by = key_by
424
+ self.id = id or f"rate:{limit}/{per_seconds}s"
425
+ self.label = f"rate limit {limit} per {per_seconds} s"
426
+ self.ttl_seconds = per_seconds * 2
427
+
428
+ def key_for(self, ctx: Context) -> str:
429
+ scope = (ctx.candidate.channel or ctx.user.id) if self.key_by == "channel" else ctx.user.id
430
+ window = math.floor(epoch_ms(ctx.now) / 1000 / self.per_seconds)
431
+ return f"rate:{self.key_by}:{scope}:{self.per_seconds}:{window}"
432
+
433
+
434
+ class RecentInteraction(BaseCheck):
435
+ id = "recentInteraction"
436
+
437
+ def __init__(self, within_hours: float = 48) -> None:
438
+ self.within_hours = within_hours
439
+
440
+ def run(self, ctx: Context, values: Values) -> Outcome:
441
+ last = ctx.user.last_inbound_at
442
+ if last is None:
443
+ return reject("no inbound message from the user on record")
444
+ age = (ctx.now - last).total_seconds() / 3600
445
+ if age <= self.within_hours:
446
+ return PASS
447
+ return reject(f"last inbound message {math.floor(age)} h ago, window is {_num(self.within_hours)} h")
448
+
449
+
450
+ class WindowBudget(Budget):
451
+ id = "windowBudget"
452
+ label = "window budget"
453
+
454
+ def __init__(self, limit: int, within_hours: float) -> None:
455
+ super().__init__(limit=limit, near_limit=1)
456
+ self.ttl_seconds = int(within_hours * 3600)
457
+
458
+ def key_for(self, ctx: Context) -> str:
459
+ last = ctx.user.last_inbound_at
460
+ opened = math.floor(epoch_ms(last) / 1000) if last else "none"
461
+ return f"windowBudget:{ctx.user.id}:{opened}"
462
+
463
+
464
+ class OnlyWhen(BaseCheck):
465
+ """Runs the wrapped check only when ``predicate`` holds; otherwise passes with ``reason``."""
466
+
467
+ def __init__(self, inner: Check, predicate: Callable[[Context], bool], reason: str) -> None:
468
+ self.inner = inner
469
+ self.id = inner.id
470
+ self.non_rejecting = inner.non_rejecting
471
+ self.predicate = predicate
472
+ self.reason = reason
473
+
474
+ def keys(self, ctx: Context) -> Sequence[str]:
475
+ return self.inner.keys(ctx) if self.predicate(ctx) else ()
476
+
477
+ def run(self, ctx: Context, values: Values) -> Outcome:
478
+ return self.inner.run(ctx, values) if self.predicate(ctx) else Outcome("pass", self.reason)
479
+
480
+ def consume_plan(self, ctx: Context) -> ConsumePlan | None:
481
+ if not self.predicate(ctx):
482
+ return None
483
+ inner = self.inner
484
+ if isinstance(inner, Budget):
485
+ return inner.consume_plan(ctx)
486
+ return None
487
+
488
+
489
+ def default_checks(
490
+ kill_switch: Callable[[], bool] | bool = False,
491
+ modes: Sequence[str] = ("normal",),
492
+ daily_limit: int = 5,
493
+ weekly_limit: int | None = None,
494
+ quiet_hours_floor: str = "critical",
495
+ ) -> list[Check]:
496
+ """The LILA order, as a starting point."""
497
+ checks: list[Check] = [
498
+ KillSwitch(kill_switch),
499
+ Consent(),
500
+ Enabled(),
501
+ Mode(modes),
502
+ Snooze(),
503
+ Mute(),
504
+ Intensity(),
505
+ QuietHours(quiet_hours_floor),
506
+ TrustRamp(),
507
+ DismissalCooldown(),
508
+ AdaptiveTiming(),
509
+ ]
510
+ if weekly_limit is not None:
511
+ checks.append(WeeklyBudget(limit=weekly_limit))
512
+ checks.append(DailyBudget(limit=daily_limit))
513
+ return checks
514
+
515
+
516
+ __all__ = [
517
+ "AdaptiveTiming", "AllowedWindow", "BaseCheck", "BoundedDeferral", "Budget", "Check", "Consent", "Consumer",
518
+ "DailyBudget", "DismissalCooldown", "Enabled", "Intensity", "KillSwitch", "Mode", "MonthlyBudget", "Mute",
519
+ "OnlyWhen", "QuietHours", "RateLimit", "RecentInteraction", "RequiresConsent", "Snooze", "TrustRamp",
520
+ "UtilityFloor", "WeeklyBudget", "WindowBudget", "budget_key", "default_checks", "dismissal_key",
521
+ "monthly_budget_key", "weekly_budget_key",
522
+ ]
@@ -0,0 +1,40 @@
1
+ """Local-time arithmetic with the standard library only."""
2
+ from __future__ import annotations
3
+
4
+ from datetime import datetime
5
+ from zoneinfo import ZoneInfo
6
+
7
+ DAY_SECONDS = 24 * 60 * 60
8
+
9
+
10
+ def local_clock(now: datetime, tz: str) -> tuple[int, str]:
11
+ """Minutes since local midnight and the local calendar day, ``YYYY-MM-DD``."""
12
+ local = now.astimezone(ZoneInfo(tz))
13
+ return local.hour * 60 + local.minute, local.strftime("%Y-%m-%d")
14
+
15
+
16
+ def parse_hhmm(text: str) -> int:
17
+ parts = text.split(":")
18
+ if len(parts) != 2 or not all(p.isdigit() for p in parts):
19
+ raise ValueError(f'bad time "{text}", expected HH:MM')
20
+ return int(parts[0]) * 60 + int(parts[1])
21
+
22
+
23
+ def in_window(minutes: int, start: int, end: int) -> bool:
24
+ """True when ``minutes`` falls inside [start, end); the window may cross midnight."""
25
+ if start == end:
26
+ return False
27
+ if start < end:
28
+ return start <= minutes < end
29
+ return minutes >= start or minutes < end
30
+
31
+
32
+ def local_day(now: datetime, tz: str | None) -> str:
33
+ if tz:
34
+ return local_clock(now, tz)[1]
35
+ return now.astimezone(ZoneInfo("UTC")).strftime("%Y-%m-%d")
36
+
37
+
38
+ def iso_week_key(day: str) -> str:
39
+ year, week, _ = datetime.strptime(day, "%Y-%m-%d").isocalendar()
40
+ return f"{year}-W{week:02d}"