proactive-gate 0.2.2__tar.gz → 0.2.4__tar.gz

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.
@@ -9,3 +9,6 @@ __pycache__/
9
9
  *.egg-info/
10
10
  .pytest_cache/
11
11
  .mypy_cache/
12
+
13
+ # uv writes this when the Python package is run locally; the library pins nothing.
14
+ python/uv.lock
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: proactive-gate
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: Decide whether a proactive assistant may speak now: ordered checks, budgets, quiet hours, JSON policies, shared conformance fixtures with the TypeScript package.
5
5
  Project-URL: Homepage, https://bubblegunn.github.io/proactive-gate/
6
6
  Project-URL: Repository, https://github.com/Bubblegunn/proactive-gate
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "proactive-gate"
7
- version = "0.2.2"
7
+ version = "0.2.4"
8
8
  description = "Decide whether a proactive assistant may speak now: ordered checks, budgets, quiet hours, JSON policies, shared conformance fixtures with the TypeScript package."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -526,6 +526,58 @@ class OnlyWhen(BaseCheck):
526
526
  return None
527
527
 
528
528
 
529
+ def dedupe_key_for(user_id: str, key: str) -> str:
530
+ return f"dedupe:{user_id}:{key}"
531
+
532
+
533
+ def _human_window(seconds: int) -> str:
534
+ if seconds % DAY_SECONDS == 0:
535
+ return f"{seconds // DAY_SECONDS}d"
536
+ if seconds % 3600 == 0:
537
+ return f"{seconds // 3600}h"
538
+ if seconds % 60 == 0:
539
+ return f"{seconds // 60}m"
540
+ return f"{seconds}s"
541
+
542
+
543
+ class Dedupe(BaseCheck):
544
+ """One delivery per event per window, claimed atomically at commit time.
545
+
546
+ Transports deliver at least once, so the same event arrives twice, and two
547
+ workers can hold it at the same moment. Both then evaluate against a store
548
+ where nothing is recorded, so reading cannot separate them: the claim has to
549
+ be atomic and it has to happen at commit. See spec clauses 5.5 to 5.8.
550
+
551
+ The window is the caller's; the 24-hour default is the common retry horizon,
552
+ the same figure Stripe prunes an idempotency key after and Nylas gives as the
553
+ safe default for webhook deduplication.
554
+ """
555
+
556
+ id = "dedupe"
557
+
558
+ def __init__(self, window_seconds: int = DAY_SECONDS) -> None:
559
+ self.window_seconds = window_seconds
560
+ self.label = _human_window(window_seconds)
561
+
562
+ def _key(self, ctx: Context) -> str | None:
563
+ key = getattr(ctx.candidate, "dedupe_key", None)
564
+ return dedupe_key_for(ctx.user.id, key) if key else None
565
+
566
+ def keys(self, ctx: Context) -> Sequence[str]:
567
+ key = self._key(ctx)
568
+ return (key,) if key else ()
569
+
570
+ def run(self, ctx: Context, values: Values) -> Outcome:
571
+ key = self._key(ctx)
572
+ if key is None:
573
+ return skip("no dedupeKey on the candidate; deduplication cannot be evaluated")
574
+ return PASS if values.get(key) is None else reject(f"already delivered within the last {self.label}")
575
+
576
+ def consume_plan(self, ctx: Context) -> ConsumePlan | None:
577
+ key = self._key(ctx)
578
+ return None if key is None else ConsumePlan(key, self.window_seconds, 1)
579
+
580
+
529
581
  def default_checks(
530
582
  kill_switch: Callable[[], bool] | bool = False,
531
583
  modes: Sequence[str] = ("normal",),
@@ -555,8 +607,8 @@ def default_checks(
555
607
 
556
608
  __all__ = [
557
609
  "AdaptiveTiming", "AllowedWindow", "BaseCheck", "BoundedDeferral", "Budget", "Check", "Consent", "Consumer",
558
- "DailyBudget", "DismissalCooldown", "Enabled", "Intensity", "KillSwitch", "Mode", "MonthlyBudget", "Mute",
610
+ "DailyBudget", "Dedupe", "DismissalCooldown", "Enabled", "Intensity", "KillSwitch", "Mode", "MonthlyBudget", "Mute",
559
611
  "OnlyWhen", "QuietHours", "RateLimit", "RecentInteraction", "RequiresConsent", "Snooze", "TrustRamp",
560
612
  "UtilityFloor", "WeeklyBudget", "WindowBudget", "budget_key", "default_checks", "dismissal_key",
561
- "monthly_budget_key", "weekly_budget_key",
613
+ "dedupe_key_for", "monthly_budget_key", "weekly_budget_key",
562
614
  ]
@@ -202,7 +202,9 @@ def _elapsed(started: float) -> float:
202
202
 
203
203
 
204
204
  def _consumers(checks: Iterable[Check]) -> list[Check]:
205
- return [c for c in checks if isinstance(c, (Budget, OnlyWhen)) and hasattr(c, "consume_plan")]
205
+ """Any check that offers a commit-time plan. Duck-typed, as the TypeScript side is:
206
+ a new consumer must not have to be added to a class list to be honoured."""
207
+ return [c for c in checks if hasattr(c, "consume_plan")]
206
208
 
207
209
 
208
210
  def _plan(check: Check, ctx: Context) -> ConsumePlan | None:
@@ -32,6 +32,7 @@ KNOWN_CHECKS: dict[str, Factory] = {
32
32
  "trustRamp": lambda o: c.TrustRamp(o.get("days", 7), o.get("minPriority", "high")),
33
33
  "dismissalCooldown": lambda o: c.DismissalCooldown(o.get("dismissals", 3), o.get("withinDays", 30), o.get("silenceDays", 7)),
34
34
  "adaptiveTiming": lambda o: c.AdaptiveTiming(),
35
+ "dedupe": lambda o: c.Dedupe(int(o.get("windowSeconds", 86400))),
35
36
  "dailyBudget": _budget(c.DailyBudget),
36
37
  "weeklyBudget": _budget(c.WeeklyBudget),
37
38
  "monthlyBudget": _budget(c.MonthlyBudget),
@@ -135,6 +135,7 @@ class Candidate:
135
135
  busy: bool | None = None
136
136
  p_accept: float | None = None
137
137
  p_need: float | None = None
138
+ dedupe_key: str | None = None
138
139
  payload: Any = None
139
140
 
140
141
  @staticmethod
@@ -148,6 +149,7 @@ class Candidate:
148
149
  busy=data.get("busy"),
149
150
  p_accept=data.get("pAccept"),
150
151
  p_need=data.get("pNeed"),
152
+ dedupe_key=data.get("dedupeKey"),
151
153
  payload=data.get("payload"),
152
154
  )
153
155
 
File without changes