perceptkit 0.2.2__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.
- perceptkit/__init__.py +85 -0
- perceptkit/algorithms/__init__.py +40 -0
- perceptkit/algorithms/attribution.py +147 -0
- perceptkit/algorithms/glance.py +236 -0
- perceptkit/algorithms/history.py +663 -0
- perceptkit/algorithms/identity.py +43 -0
- perceptkit/algorithms/observation.py +44 -0
- perceptkit/algorithms/streaks.py +111 -0
- perceptkit/algorithms/trend_models.py +184 -0
- perceptkit/algorithms/wake.py +149 -0
- perceptkit/catalog.py +252 -0
- perceptkit/conformance/__init__.py +28 -0
- perceptkit/conformance/memory.py +364 -0
- perceptkit/conformance/report.py +170 -0
- perceptkit/conformance/suite.py +419 -0
- perceptkit/conformance/wake.py +151 -0
- perceptkit/contracts/__init__.py +97 -0
- perceptkit/contracts/_time.py +89 -0
- perceptkit/contracts/availability.py +77 -0
- perceptkit/contracts/context.py +50 -0
- perceptkit/contracts/delivery.py +167 -0
- perceptkit/contracts/errors.py +22 -0
- perceptkit/contracts/event.py +137 -0
- perceptkit/contracts/observation.py +172 -0
- perceptkit/contracts/receipt.py +129 -0
- perceptkit/contracts/records.py +367 -0
- perceptkit/contracts/report.py +127 -0
- perceptkit/contracts/versioning.py +63 -0
- perceptkit/fields.py +184 -0
- perceptkit/kit.py +223 -0
- perceptkit/manifest/__init__.py +57 -0
- perceptkit/manifest/checks.py +323 -0
- perceptkit/manifest/mapping.py +96 -0
- perceptkit/manifest/minimal.py +1282 -0
- perceptkit/manifest/types.py +211 -0
- perceptkit/manifest/units.py +84 -0
- perceptkit/ports/__init__.py +19 -0
- perceptkit/ports/storage.py +288 -0
- perceptkit/ports/wake.py +43 -0
- perceptkit/processing/__init__.py +49 -0
- perceptkit/processing/aggregate.py +80 -0
- perceptkit/processing/dispatch.py +356 -0
- perceptkit/processing/normalize.py +458 -0
- perceptkit/processing/pipeline.py +406 -0
- perceptkit/processing/recompute.py +170 -0
- perceptkit/processing/recurrence.py +166 -0
- perceptkit/processing/scheduled.py +233 -0
- perceptkit/prompts.py +75 -0
- perceptkit/queries/__init__.py +32 -0
- perceptkit/queries/api.py +457 -0
- perceptkit/retention.py +84 -0
- perceptkit/rules/__init__.py +19 -0
- perceptkit/rules/engine.py +112 -0
- perceptkit/rules/evaluators.py +228 -0
- perceptkit/rules/types.py +236 -0
- perceptkit-0.2.2.dist-info/METADATA +439 -0
- perceptkit-0.2.2.dist-info/RECORD +59 -0
- perceptkit-0.2.2.dist-info/WHEEL +4 -0
- perceptkit-0.2.2.dist-info/licenses/LICENSE +202 -0
perceptkit/catalog.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""Capability catalog — the single source of truth for Extended Perception.
|
|
2
|
+
|
|
3
|
+
Every perceptual capability is declared here ONCE. This drives all the generic
|
|
4
|
+
machinery:
|
|
5
|
+
- the report endpoint (which input fields are accepted, which permission gates
|
|
6
|
+
them, how raw values resolve to labels, which state fields they produce)
|
|
7
|
+
- the snapshot endpoint (which fields are cheap context, their freshness TTL)
|
|
8
|
+
- wake triggering (which capabilities are wake sources + their debounce)
|
|
9
|
+
- the transparency UI (label + tier + default-on per capability)
|
|
10
|
+
|
|
11
|
+
Adding a Tier 2 capability = adding rows here + (if it has query tools) a thin
|
|
12
|
+
MCP pass-through. No changes to service/routes logic.
|
|
13
|
+
|
|
14
|
+
Privacy: capabilities whose `resolver` is set accept RAW values (lat/lon, ssid,
|
|
15
|
+
bundle id) and resolve them to coarse labels via the user's perception_config.
|
|
16
|
+
The raw value is used transiently and never written to perception_state, so the
|
|
17
|
+
agent only ever sees labels.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Capability:
|
|
26
|
+
"""One perceptual ability (always available; authorization is implicit in
|
|
27
|
+
whether the client reports a value — see the host's design docs for the
|
|
28
|
+
reasoning behind implicit, report-based authorization)."""
|
|
29
|
+
key: str # canonical capability key
|
|
30
|
+
label: str # human copy for the transparency UI
|
|
31
|
+
tier: int # 1 = ships with V2, 2 = follow-up
|
|
32
|
+
wake_source: bool = False
|
|
33
|
+
debounce_sec: float = 0.0
|
|
34
|
+
context_field: bool = False # appears in cheap wake snapshot
|
|
35
|
+
query_tool: bool = False # agent pulls on demand
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class Signal:
|
|
40
|
+
"""One field the client may report, mapped to its capability + processing."""
|
|
41
|
+
input: str # key inside report `signals`
|
|
42
|
+
capability: str # permission key gating it
|
|
43
|
+
outputs: tuple[str, ...] # state field name(s) produced
|
|
44
|
+
resolver: str | None = None # name in resolve.RESOLVERS, or None (store as-is)
|
|
45
|
+
ttl_sec: float = 600.0 # snapshot freshness; older -> null
|
|
46
|
+
significant: bool = True # value change can trigger a wake (if wake_source)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
# Capabilities
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
CAPABILITIES: dict[str, Capability] = {c.key: c for c in [
|
|
54
|
+
# --- always-on (no iOS permission) ---
|
|
55
|
+
Capability("time", "本地时间", 1, context_field=True),
|
|
56
|
+
Capability("device", "电量", 1, context_field=True),
|
|
57
|
+
# Starting/stopping a user-initiated share is a discrete presence edge. A
|
|
58
|
+
# shared cooldown prevents rapid iOS on/off flapping from producing a burst
|
|
59
|
+
# of proactive turns.
|
|
60
|
+
Capability("broadcast", "屏幕采集状态", 1,
|
|
61
|
+
wake_source=True, debounce_sec=60.0, context_field=True),
|
|
62
|
+
|
|
63
|
+
# --- permissioned ---
|
|
64
|
+
Capability("location", "你大概在哪里(只看地点标签,不看具体地址)", 1,
|
|
65
|
+
wake_source=True, debounce_sec=60.0, context_field=True, query_tool=True),
|
|
66
|
+
# Motion remains pullable context, but is intentionally not a wake source.
|
|
67
|
+
# It changes too frequently to justify interrupting the user — see
|
|
68
|
+
# wake.NOT_WAKE_WORTHY_SIGNALS, which suppresses it for the same reason.
|
|
69
|
+
Capability("motion", "你在动还是静止", 1, context_field=True),
|
|
70
|
+
Capability("calendar", "日历下一场日程", 1, context_field=True, query_tool=True),
|
|
71
|
+
Capability("now_playing", "你在听的音乐", 1, context_field=True, query_tool=True),
|
|
72
|
+
Capability("focus", "专注模式", 1, context_field=True),
|
|
73
|
+
Capability("audio_route", "音频输出路由", 2, query_tool=True),
|
|
74
|
+
# One switch covers BOTH resolutions (current field + the recent_apps
|
|
75
|
+
# history tool), so the copy has to say so — otherwise the user thinks they
|
|
76
|
+
# are allowing "which app now" and is actually allowing a usage trajectory.
|
|
77
|
+
Capability("app", "你最近打开了哪些 app(通过 iOS 快捷指令上报,含最近使用记录)", 1,
|
|
78
|
+
context_field=True, query_tool=True),
|
|
79
|
+
Capability("weather", "粗天气", 2, query_tool=True),
|
|
80
|
+
Capability("photos", "你拍的照片", 2, wake_source=True, query_tool=True),
|
|
81
|
+
Capability("reminders", "提醒事项", 2, query_tool=True),
|
|
82
|
+
Capability("health_sleep", "睡眠", 2, query_tool=True),
|
|
83
|
+
Capability("health_workout", "运动", 2, query_tool=True),
|
|
84
|
+
Capability("health_vitals", "身体趋势", 2, query_tool=True),
|
|
85
|
+
Capability("health_activity", "活动量(能量/锻炼/站立/正念)", 2, query_tool=True),
|
|
86
|
+
Capability("health_body", "身体测量(体重/BMI/体脂/身高)", 2, query_tool=True),
|
|
87
|
+
Capability("health_metabolic", "代谢点值(血糖/血压)", 2, query_tool=True),
|
|
88
|
+
Capability("health_cycle", "经期", 2, query_tool=True),
|
|
89
|
+
Capability("health_mood", "心情 / State of Mind", 2, query_tool=True),
|
|
90
|
+
]}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
# Signals (report inputs) — keyed by the iOS context_snapshot `key`.
|
|
95
|
+
# `data` (a JSON string) parses into the shapes in perception-report-fields.md;
|
|
96
|
+
# the resolver picks out the label/state fields and DISCARDS raw/precise fields
|
|
97
|
+
# (coordinates, BSSID, placemark address) so the agent only sees coarse state.
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
SIGNALS: dict[str, Signal] = {s.input: s for s in [
|
|
101
|
+
# always-on
|
|
102
|
+
Signal("time", "time", ("local_time", "timezone", "locale"),
|
|
103
|
+
resolver="time", ttl_sec=300.0, significant=False),
|
|
104
|
+
Signal("battery", "device", ("battery_level", "charging", "low_power_mode"),
|
|
105
|
+
resolver="battery", ttl_sec=600.0, significant=False),
|
|
106
|
+
Signal("broadcast", "broadcast", ("broadcast_state", "broadcast_active"),
|
|
107
|
+
resolver="broadcast", ttl_sec=300.0, significant=True),
|
|
108
|
+
Signal("focus", "focus", ("focus_authorization_status", "in_focus"),
|
|
109
|
+
resolver="focus_presence", ttl_sec=300.0, significant=False),
|
|
110
|
+
|
|
111
|
+
# permissioned
|
|
112
|
+
Signal("location_signal", "location", ("place_label", "wifi_label", "country", "locality", "wifi_anchor_id"),
|
|
113
|
+
resolver="location_signal", ttl_sec=900.0),
|
|
114
|
+
Signal("motion_state", "motion", ("motion_state",), ttl_sec=300.0),
|
|
115
|
+
Signal("calendar_next_event", "calendar", ("calendar_next_event", "calendar_events", "calendar_events_truncated"),
|
|
116
|
+
ttl_sec=3600.0, significant=False),
|
|
117
|
+
Signal("playback", "now_playing", ("now_playing",),
|
|
118
|
+
ttl_sec=600.0, significant=False),
|
|
119
|
+
Signal("audio_route", "audio_route", ("output_type", "is_bluetooth", "device_name"),
|
|
120
|
+
resolver="audio_route", ttl_sec=600.0, significant=False),
|
|
121
|
+
Signal("weather", "weather",
|
|
122
|
+
("condition", "temperature", "apparent_temperature", "humidity",
|
|
123
|
+
"precipitation_chance", "uv_index", "is_daylight", "alerts"),
|
|
124
|
+
resolver="weather", ttl_sec=1800.0, significant=False),
|
|
125
|
+
Signal("reminders", "reminders",
|
|
126
|
+
("next_reminder", "reminders", "overdue_count", "due_today_count", "reminders_truncated"),
|
|
127
|
+
ttl_sec=3600.0, significant=False),
|
|
128
|
+
Signal("health_sleep", "health_sleep",
|
|
129
|
+
("asleep_minutes", "core_minutes", "deep_minutes", "rem_minutes"),
|
|
130
|
+
resolver="health_sleep", ttl_sec=86400.0, significant=False),
|
|
131
|
+
Signal("health_workout", "health_workout", ("workout_type", "duration_min", "count_today"),
|
|
132
|
+
resolver="health_workout", ttl_sec=86400.0, significant=False),
|
|
133
|
+
Signal("health_vitals", "health_vitals",
|
|
134
|
+
("resting_heart_rate", "step_count", "current_heart_rate", "hrv_sdnn_ms",
|
|
135
|
+
"respiratory_rate", "oxygen_saturation_pct", "vo2_max"),
|
|
136
|
+
resolver="health_vitals", ttl_sec=3600.0, significant=False),
|
|
137
|
+
Signal("health_activity", "health_activity",
|
|
138
|
+
("active_energy_kcal", "exercise_minutes", "stand_minutes", "mindful_minutes"),
|
|
139
|
+
ttl_sec=3600.0, significant=False),
|
|
140
|
+
Signal("health_body", "health_body",
|
|
141
|
+
("weight_kg", "bmi", "body_fat_pct", "height_cm"),
|
|
142
|
+
ttl_sec=86400.0, significant=False),
|
|
143
|
+
Signal("health_metabolic", "health_metabolic",
|
|
144
|
+
("blood_glucose_mmol_l", "blood_pressure_systolic", "blood_pressure_diastolic"),
|
|
145
|
+
ttl_sec=86400.0, significant=False),
|
|
146
|
+
Signal("health_cycle", "health_cycle",
|
|
147
|
+
("flow_level", "is_active_period"),
|
|
148
|
+
ttl_sec=86400.0, significant=False),
|
|
149
|
+
Signal("health_mood", "health_mood",
|
|
150
|
+
("valence", "valence_classification", "kind", "label_count", "recorded_today"),
|
|
151
|
+
ttl_sec=86400.0, significant=False),
|
|
152
|
+
# `app` is reported via the GET /app_open + /app_close shortcut endpoints
|
|
153
|
+
# (not /report); this entry exists so app_name/app_category/app_state appear
|
|
154
|
+
# in the snapshot with a TTL. 900s, not the 300s this used to be: an app
|
|
155
|
+
# session routinely outlives a 5-minute window, so a shorter TTL made the
|
|
156
|
+
# agent see app_name=None for any turn that wasn't right after a launch.
|
|
157
|
+
# `app_state` is what distinguishes "still in it" (foreground) from "just
|
|
158
|
+
# left it" (closed); `recent_apps` covers anything older.
|
|
159
|
+
#
|
|
160
|
+
# NOTE: this `app_state` (values "foreground" / "closed" / null) is about
|
|
161
|
+
# some OTHER app on the user's phone — the one the iOS Shortcut just
|
|
162
|
+
# opened or closed. It is UNRELATED to any `app_state` field a host's own
|
|
163
|
+
# scene-phase tracking might use for ITS OWN foreground/background/inactive
|
|
164
|
+
# phase. Same name, different namespace (perception_state field vs. a
|
|
165
|
+
# host-internal event payload), no functional overlap — don't assume they
|
|
166
|
+
# can be compared or merged.
|
|
167
|
+
Signal("app", "app", ("app_name", "app_category", "app_state"),
|
|
168
|
+
ttl_sec=900.0, significant=False),
|
|
169
|
+
]}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# Back-compat aliases: canonical capability names also map to the iOS key.
|
|
173
|
+
KEY_ALIASES = {
|
|
174
|
+
"location": "location_signal",
|
|
175
|
+
"motion": "motion_state",
|
|
176
|
+
"now_playing": "playback",
|
|
177
|
+
"calendar": "calendar_next_event",
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
# iOS keys that carry only null placeholders (frontmost_app/silent_mode/focus/
|
|
181
|
+
# precise_unlock — not obtainable on iOS). Accepted and silently ignored.
|
|
182
|
+
IGNORED_KEYS = {"unsupported"}
|
|
183
|
+
|
|
184
|
+
# Composite report keys whose `data` expands into several signals. (none now —
|
|
185
|
+
# battery is its own iOS key.)
|
|
186
|
+
COMPOSITE_KEYS: dict[str, list[str]] = {}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# perception_items kinds (collection-style data; see migration 0002) and the
|
|
190
|
+
# capability that gates the GENERIC /items endpoint for each kind.
|
|
191
|
+
# NOTE: "photo" is intentionally ABSENT from KIND_CAPABILITY — photos must go
|
|
192
|
+
# through the dedicated /photo/evaluate flow, which stores the encrypted
|
|
193
|
+
# envelope in the frame channel. Allowing kind=photo via /items would let a
|
|
194
|
+
# caller inject a confirmed photo doc without the envelope path.
|
|
195
|
+
# (calendar is reported via /report, not /items.)
|
|
196
|
+
KIND_CAPABILITY = {
|
|
197
|
+
"workout": "health_workout",
|
|
198
|
+
"sleep": "health_sleep",
|
|
199
|
+
"vitals": "health_vitals",
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
# Burst de-dup backstop. Clustering is primarily done ON DEVICE (iOS collapses a
|
|
203
|
+
# 30s burst and uploads only the representative frame); this window is the
|
|
204
|
+
# server-side safety net so a client that uploads several still wakes once.
|
|
205
|
+
PHOTO_CLUSTER_SEC = 30.0
|
|
206
|
+
|
|
207
|
+
# scene_hint — the canonical enum shared with the iOS Vision classifier. The
|
|
208
|
+
# client MUST emit one of these strings; anything else is treated as "other".
|
|
209
|
+
# V2 removes the old platform hard block: sensitive hints are metadata for the
|
|
210
|
+
# companion's expression policy, not a perception gate.
|
|
211
|
+
SCENE_HINTS = (
|
|
212
|
+
# non-sensitive — may reach the agent
|
|
213
|
+
"landscape", "food", "people", "pet", "activity", "object", "art",
|
|
214
|
+
"text_note", "other",
|
|
215
|
+
# contextual
|
|
216
|
+
"private", "receipt",
|
|
217
|
+
# objectively sensitive
|
|
218
|
+
"document", "id_card", "medical", "screenshot",
|
|
219
|
+
)
|
|
220
|
+
SENSITIVE_PHOTO_SCENES = {"private", "receipt", "document", "id_card", "medical", "screenshot"}
|
|
221
|
+
|
|
222
|
+
# Extended on-device metadata the iOS Vision pass may include. The runtime does
|
|
223
|
+
# not use these fields as a gate; they are passed through as context for the
|
|
224
|
+
# agent's own judgment and voice.
|
|
225
|
+
PHOTO_METADATA_FIELDS = (
|
|
226
|
+
"has_faces", "face_count", "scene_hint", "scene_confidence",
|
|
227
|
+
"time_of_day", "is_burst", "is_indoor", "has_text_block", "is_screenshot",
|
|
228
|
+
# Stable per-photo identity + capture time. Both are new; older clients
|
|
229
|
+
# simply omit them and every consumer must treat them as optional.
|
|
230
|
+
#
|
|
231
|
+
# `source_event_id` is an on-device HMAC of PHAsset.localIdentifier under a
|
|
232
|
+
# device-local random key -- stable across re-reports, never reversible to
|
|
233
|
+
# the user's library. It is deliberately NOT the envelope id: that one is
|
|
234
|
+
# bound into the ciphertext's AAD and must stay random.
|
|
235
|
+
#
|
|
236
|
+
# `occurred_at` is when the photo was taken, which is not when it reaches
|
|
237
|
+
# us -- an import backfills months of photos in one session.
|
|
238
|
+
"source_event_id", "occurred_at",
|
|
239
|
+
)
|
|
240
|
+
# "Back after long lock" wake threshold.
|
|
241
|
+
UNLOCK_BACK_THRESHOLD_SEC = 1800.0 # 30 min
|
|
242
|
+
|
|
243
|
+
# Legacy recent app-open rows surfaced in the snapshot (folds the standalone
|
|
244
|
+
# /app_usage read; capped to keep the wake-attached snapshot small). The merged
|
|
245
|
+
# TTL-bounded open/close trajectory is exposed separately as recent_app_events.
|
|
246
|
+
RECENT_APPS_LIMIT = 10
|
|
247
|
+
|
|
248
|
+
# Explicit `perception.recent_apps` pulls merge the existing open/close streams,
|
|
249
|
+
# but an agent asking "what have I been using today?" wants more than the wake
|
|
250
|
+
# budget.
|
|
251
|
+
RECENT_APPS_TOOL_LIMIT = 20
|
|
252
|
+
RECENT_APPS_TOOL_MAX = 100
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""一致性测试工具。
|
|
2
|
+
|
|
3
|
+
memory 内存版 StoragePort —— **只是测试工具,不是生产实现**
|
|
4
|
+
suite storage adapter:十条一致性保证
|
|
5
|
+
wake wake adapter:回执形状与幂等
|
|
6
|
+
report report adapter:producer 产出的信封本身对不对
|
|
7
|
+
|
|
8
|
+
产品规范 §20 把这三种 adapter conformance 并列为最低交付物。
|
|
9
|
+
|
|
10
|
+
🔴 内存实现天然原子、天然没有并发,所以它**验不出**真实数据库的事务边界和
|
|
11
|
+
隔离级别。"RuleState 和 Outbox 必须同事务"这类保证在这里永远是绿的。
|
|
12
|
+
宿主必须另外用真实数据库 + 两条独立连接 + 在关键写操作之间打断点来证明。
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from .memory import InMemoryStorage
|
|
17
|
+
from .suite import GUARANTEES, NOT_PROVABLE_IN_MEMORY, run_storage_conformance
|
|
18
|
+
from .report import (
|
|
19
|
+
REPORT_GUARANTEES, REPORT_NOT_PROVABLE, run_report_conformance,
|
|
20
|
+
)
|
|
21
|
+
from .wake import WAKE_GUARANTEES, WAKE_NOT_PROVABLE, run_wake_conformance
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"InMemoryStorage",
|
|
25
|
+
"run_storage_conformance", "GUARANTEES", "NOT_PROVABLE_IN_MEMORY",
|
|
26
|
+
"run_wake_conformance", "WAKE_GUARANTEES", "WAKE_NOT_PROVABLE",
|
|
27
|
+
"run_report_conformance", "REPORT_GUARANTEES", "REPORT_NOT_PROVABLE",
|
|
28
|
+
]
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
"""内存版存储 —— **只是测试工具,不是生产实现。**
|
|
2
|
+
|
|
3
|
+
它存在的意义是让宿主在写自己的 adapter 之前,先有一个能跑通的参照,
|
|
4
|
+
以及让 kit 自己的管线测试不依赖任何数据库。
|
|
5
|
+
|
|
6
|
+
🔴 **它验不出真正的事务边界和隔离级别。** 内存实现天然是原子的、天然没有
|
|
7
|
+
并发 —— "RuleState 和 Outbox 必须同事务"这类保证,在这里永远是绿的,
|
|
8
|
+
不代表真实数据库上也绿。宿主必须另外用真实数据库、两条独立连接、
|
|
9
|
+
在关键写操作之间打断点,才能证明那条保证成立。
|
|
10
|
+
|
|
11
|
+
在这里绿 = 端口语义、调用顺序、确定性没问题。
|
|
12
|
+
仅此而已。
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from contextlib import contextmanager
|
|
17
|
+
from datetime import date, datetime, timezone
|
|
18
|
+
|
|
19
|
+
#: 排序时给「没有时间」的条目垫底用的哨兵,不参与任何业务判断。
|
|
20
|
+
_EPOCH = datetime(1, 1, 1, tzinfo=timezone.utc)
|
|
21
|
+
from typing import Any, Iterator, Sequence
|
|
22
|
+
|
|
23
|
+
from ..contracts import delivery as _delivery
|
|
24
|
+
from ..contracts.records import (
|
|
25
|
+
CalendarEventMirror,
|
|
26
|
+
CurrentProjection,
|
|
27
|
+
DailyAggregate,
|
|
28
|
+
DurableDedupeIdentity,
|
|
29
|
+
EventOutboxEntry,
|
|
30
|
+
ReminderItemMirror,
|
|
31
|
+
SourceSyncState,
|
|
32
|
+
StoredObservation,
|
|
33
|
+
)
|
|
34
|
+
from ..contracts.receipt import (
|
|
35
|
+
INGEST_ACCEPTED,
|
|
36
|
+
INGEST_CONFLICT,
|
|
37
|
+
INGEST_DUPLICATE,
|
|
38
|
+
IngestReceipt,
|
|
39
|
+
WakeReceipt,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class InMemoryStorage:
|
|
44
|
+
"""把 :class:`~perceptkit.ports.storage.StoragePort` 实现在几个字典上。"""
|
|
45
|
+
|
|
46
|
+
def __init__(self) -> None:
|
|
47
|
+
self.reports: dict[tuple[str, str, str], IngestReceipt] = {}
|
|
48
|
+
self.observations: dict[str, StoredObservation] = {}
|
|
49
|
+
self.identities: set[tuple[str, str, str, str]] = set()
|
|
50
|
+
self.current: dict[tuple[str, str, str], CurrentProjection] = {}
|
|
51
|
+
self.aggregates: dict[tuple[str, str, date, str, int], DailyAggregate] = {}
|
|
52
|
+
self.calendar: dict[tuple, CalendarEventMirror] = {}
|
|
53
|
+
self.reminders: dict[tuple, ReminderItemMirror] = {}
|
|
54
|
+
self.sync_state: dict[tuple[str, str, str], SourceSyncState] = {}
|
|
55
|
+
self.rule_state: dict[tuple[str, str, str], dict[str, Any]] = {}
|
|
56
|
+
self.outbox: dict[str, EventOutboxEntry] = {}
|
|
57
|
+
self.receipts: list[WakeReceipt] = []
|
|
58
|
+
#: 测试用:数一数事务嵌套层数,验证调用方确实把该原子的操作包起来了。
|
|
59
|
+
self.transaction_depth = 0
|
|
60
|
+
self.transactions_opened = 0
|
|
61
|
+
|
|
62
|
+
# -- 事务 ------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
@contextmanager
|
|
65
|
+
def transaction(self) -> Iterator[None]:
|
|
66
|
+
"""内存里没有真正的回滚 —— 只记录边界,供测试断言"该包起来的确实包了"。
|
|
67
|
+
|
|
68
|
+
**不要把这里的绿当成"原子性验过了"。** 见模块开头。
|
|
69
|
+
"""
|
|
70
|
+
self.transaction_depth += 1
|
|
71
|
+
self.transactions_opened += 1
|
|
72
|
+
try:
|
|
73
|
+
yield
|
|
74
|
+
finally:
|
|
75
|
+
self.transaction_depth -= 1
|
|
76
|
+
|
|
77
|
+
# -- 批级幂等 --------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
def claim_report(self, *, subject_id, producer, report_id, payload_digest,
|
|
80
|
+
received_at) -> IngestReceipt:
|
|
81
|
+
key = (subject_id, producer, report_id)
|
|
82
|
+
prior = self.reports.get(key)
|
|
83
|
+
if prior is not None:
|
|
84
|
+
status = (INGEST_DUPLICATE if prior.payload_digest == payload_digest
|
|
85
|
+
else INGEST_CONFLICT)
|
|
86
|
+
return IngestReceipt(
|
|
87
|
+
subject_id=subject_id, producer=producer, report_id=report_id,
|
|
88
|
+
payload_digest=prior.payload_digest, received_at=prior.received_at,
|
|
89
|
+
status=status,
|
|
90
|
+
error_code=None if status == INGEST_DUPLICATE else "digest_mismatch",
|
|
91
|
+
observations_applied=0,
|
|
92
|
+
)
|
|
93
|
+
fresh = IngestReceipt(
|
|
94
|
+
subject_id=subject_id, producer=producer, report_id=report_id,
|
|
95
|
+
payload_digest=payload_digest, received_at=received_at,
|
|
96
|
+
status=INGEST_ACCEPTED,
|
|
97
|
+
)
|
|
98
|
+
self.reports[key] = fresh
|
|
99
|
+
return fresh
|
|
100
|
+
|
|
101
|
+
# -- 观测 ------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
def append_observation(self, observation: StoredObservation) -> bool:
|
|
104
|
+
if observation.observation_id in self.observations:
|
|
105
|
+
return False
|
|
106
|
+
self.observations[observation.observation_id] = observation
|
|
107
|
+
return True
|
|
108
|
+
|
|
109
|
+
def list_observations(self, *, subject_id, signal, start=None, end=None,
|
|
110
|
+
cursor=None, limit=100):
|
|
111
|
+
rows = sorted(
|
|
112
|
+
(o for o in self.observations.values()
|
|
113
|
+
if o.subject_id == subject_id and o.signal == signal
|
|
114
|
+
and (start is None or o.occurred_at >= start)
|
|
115
|
+
and (end is None or o.occurred_at <= end)),
|
|
116
|
+
key=lambda o: (o.occurred_at, o.observation_id),
|
|
117
|
+
)
|
|
118
|
+
offset = int(cursor) if cursor else 0
|
|
119
|
+
page = rows[offset:offset + limit]
|
|
120
|
+
nxt = str(offset + limit) if offset + limit < len(rows) else None
|
|
121
|
+
return page, nxt
|
|
122
|
+
|
|
123
|
+
def delete_observations(self, *, subject_id, signal=None, before=None) -> int:
|
|
124
|
+
doomed = [
|
|
125
|
+
k for k, o in self.observations.items()
|
|
126
|
+
if o.subject_id == subject_id
|
|
127
|
+
and (signal is None or o.signal == signal)
|
|
128
|
+
and (before is None or o.occurred_at < before)
|
|
129
|
+
]
|
|
130
|
+
for k in doomed:
|
|
131
|
+
del self.observations[k]
|
|
132
|
+
return len(doomed)
|
|
133
|
+
|
|
134
|
+
# -- 去重身份 --------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
def remember_identity(self, identity: DurableDedupeIdentity) -> bool:
|
|
137
|
+
key = (identity.subject_id, identity.signal, identity.source,
|
|
138
|
+
identity.source_event_identity_digest)
|
|
139
|
+
if key in self.identities:
|
|
140
|
+
return False
|
|
141
|
+
self.identities.add(key)
|
|
142
|
+
return True
|
|
143
|
+
|
|
144
|
+
def has_seen_identity(self, *, subject_id, signal, source, digest) -> bool:
|
|
145
|
+
return (subject_id, signal, source, digest) in self.identities
|
|
146
|
+
|
|
147
|
+
# -- 当前值 ----------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
def get_current(self, *, subject_id, signals):
|
|
150
|
+
out: dict[str, list[CurrentProjection]] = {s: [] for s in signals}
|
|
151
|
+
for (subj, sig, _dim), proj in self.current.items():
|
|
152
|
+
if subj == subject_id and sig in out:
|
|
153
|
+
out[sig].append(proj)
|
|
154
|
+
return out
|
|
155
|
+
|
|
156
|
+
def compare_and_put_current(self, projection, *, expected_version) -> bool:
|
|
157
|
+
key = (projection.subject_id, projection.signal, projection.dimension_key)
|
|
158
|
+
existing = self.current.get(key)
|
|
159
|
+
actual = existing.version if existing else -1
|
|
160
|
+
if actual != expected_version:
|
|
161
|
+
return False
|
|
162
|
+
self.current[key] = projection
|
|
163
|
+
return True
|
|
164
|
+
|
|
165
|
+
# -- 聚合 ------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
def get_aggregate(self, *, subject_id, signal, start_date, end_date,
|
|
168
|
+
aggregation_kind=None):
|
|
169
|
+
return [
|
|
170
|
+
a for (subj, sig, day, kind, _v), a in self.aggregates.items()
|
|
171
|
+
if subj == subject_id and sig == signal
|
|
172
|
+
and start_date <= day <= end_date
|
|
173
|
+
and (aggregation_kind is None or kind == aggregation_kind)
|
|
174
|
+
]
|
|
175
|
+
|
|
176
|
+
def put_aggregate(self, aggregate: DailyAggregate) -> None:
|
|
177
|
+
self.aggregates[(
|
|
178
|
+
aggregate.subject_id, aggregate.signal, aggregate.local_date,
|
|
179
|
+
aggregate.aggregation_kind, aggregate.aggregation_version,
|
|
180
|
+
)] = aggregate
|
|
181
|
+
|
|
182
|
+
# -- 来源镜像 --------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
def get_sync_state(self, *, subject_id, source, collection_kind):
|
|
185
|
+
return self.sync_state.get((subject_id, source, collection_kind))
|
|
186
|
+
|
|
187
|
+
def put_sync_state(self, state: SourceSyncState) -> None:
|
|
188
|
+
self.sync_state[(state.subject_id, state.source, state.collection_kind)] = state
|
|
189
|
+
|
|
190
|
+
def upsert_calendar_events(self, *, subject_id, events) -> None:
|
|
191
|
+
for e in events:
|
|
192
|
+
self.calendar[(subject_id, e.source_account_id, e.source_calendar_id,
|
|
193
|
+
e.source_event_id)] = e
|
|
194
|
+
|
|
195
|
+
def upsert_reminders(self, *, subject_id, items) -> None:
|
|
196
|
+
for r in items:
|
|
197
|
+
self.reminders[(subject_id, r.source_account_id, r.source_list_id,
|
|
198
|
+
r.source_reminder_id)] = r
|
|
199
|
+
|
|
200
|
+
def list_calendar_events(self, *, subject_id, start=None, end=None, limit=50):
|
|
201
|
+
rows = [v for k, v in self.calendar.items() if k[0] == subject_id]
|
|
202
|
+
keep = []
|
|
203
|
+
for item in rows:
|
|
204
|
+
at = item.event_fields.get("start_at")
|
|
205
|
+
# 时间不明的条目**保留** —— 和删除那边同一条纪律:证明不了它在
|
|
206
|
+
# 范围外,就不能替用户把它藏起来。
|
|
207
|
+
if at is not None and start is not None and at < start:
|
|
208
|
+
continue
|
|
209
|
+
if at is not None and end is not None and at > end:
|
|
210
|
+
continue
|
|
211
|
+
keep.append(item)
|
|
212
|
+
keep.sort(key=lambda i: (i.event_fields.get("start_at") is None,
|
|
213
|
+
i.event_fields.get("start_at") or _EPOCH,
|
|
214
|
+
i.source_event_id))
|
|
215
|
+
return keep[:limit]
|
|
216
|
+
|
|
217
|
+
def list_reminders(self, *, subject_id, include_completed=False, limit=50):
|
|
218
|
+
keep = [
|
|
219
|
+
v for k, v in self.reminders.items()
|
|
220
|
+
if k[0] == subject_id
|
|
221
|
+
and (include_completed or not v.reminder_fields.get("is_completed"))
|
|
222
|
+
]
|
|
223
|
+
keep.sort(key=lambda i: (i.reminder_fields.get("due_at") is None,
|
|
224
|
+
i.reminder_fields.get("due_at") or _EPOCH,
|
|
225
|
+
i.source_reminder_id))
|
|
226
|
+
return keep[:limit]
|
|
227
|
+
|
|
228
|
+
def apply_source_snapshot(self, *, subject_id, source, collection_kind, sync_id,
|
|
229
|
+
coverage_start, coverage_end, snapshot_kind) -> int:
|
|
230
|
+
# 增量同步没有资格删任何东西 —— 它只知道"变了什么",不知道"还剩什么"。
|
|
231
|
+
if snapshot_kind != "full":
|
|
232
|
+
return 0
|
|
233
|
+
store = self.calendar if collection_kind == "calendar" else self.reminders
|
|
234
|
+
doomed = []
|
|
235
|
+
for key, item in store.items():
|
|
236
|
+
if key[0] != subject_id or item.last_seen_sync_id == sync_id:
|
|
237
|
+
continue
|
|
238
|
+
# 🔴 只删【能证明落在覆盖范围内】的。拿局部窗口去删窗口外的数据,
|
|
239
|
+
# 会让用户发现自己去年的日程凭空消失,而且不可逆。
|
|
240
|
+
# **时间不明的条目一律不删** —— 证明不了它在范围内,就没有资格删它。
|
|
241
|
+
start = (item.event_fields.get("start_at")
|
|
242
|
+
if isinstance(item, CalendarEventMirror)
|
|
243
|
+
else item.reminder_fields.get("due_at"))
|
|
244
|
+
if start is None or not (coverage_start <= start <= coverage_end):
|
|
245
|
+
continue
|
|
246
|
+
doomed.append(key)
|
|
247
|
+
for key in doomed:
|
|
248
|
+
del store[key]
|
|
249
|
+
return len(doomed)
|
|
250
|
+
|
|
251
|
+
# -- 规则状态 --------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
def get_rule_state(self, *, subject_id, definition_id, scope_key):
|
|
254
|
+
return self.rule_state.get((subject_id, definition_id, scope_key))
|
|
255
|
+
|
|
256
|
+
def put_rule_state(self, *, subject_id, definition_id, scope_key, state) -> None:
|
|
257
|
+
self.rule_state[(subject_id, definition_id, scope_key)] = dict(state)
|
|
258
|
+
|
|
259
|
+
# -- 事件与投递 ------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
def enqueue_event(self, entry: EventOutboxEntry) -> bool:
|
|
262
|
+
if entry.event_id in self.outbox:
|
|
263
|
+
return False
|
|
264
|
+
self.outbox[entry.event_id] = entry
|
|
265
|
+
return True
|
|
266
|
+
|
|
267
|
+
def claim_pending_event(self, *, worker_id, now, lease_seconds):
|
|
268
|
+
from dataclasses import replace
|
|
269
|
+
from datetime import timedelta
|
|
270
|
+
for event_id, entry in sorted(self.outbox.items()):
|
|
271
|
+
claimable = (
|
|
272
|
+
entry.delivery_state == _delivery.PENDING
|
|
273
|
+
or (entry.delivery_state == _delivery.CLAIMED
|
|
274
|
+
and entry.lease_expires_at is not None
|
|
275
|
+
and entry.lease_expires_at <= now)
|
|
276
|
+
)
|
|
277
|
+
if not claimable:
|
|
278
|
+
continue
|
|
279
|
+
if entry.next_attempt_at is not None and entry.next_attempt_at > now:
|
|
280
|
+
continue
|
|
281
|
+
claimed = replace(
|
|
282
|
+
entry,
|
|
283
|
+
delivery_state=_delivery.CLAIMED,
|
|
284
|
+
attempt_count=entry.attempt_count + 1,
|
|
285
|
+
lease_owner=worker_id,
|
|
286
|
+
lease_expires_at=now + timedelta(seconds=lease_seconds),
|
|
287
|
+
# 额度只是占位,不是消耗 —— delivered 时才兑现。
|
|
288
|
+
budget_reservation_id=f"resv_{event_id}_{entry.attempt_count + 1}",
|
|
289
|
+
# 每次认领换一个新令牌 —— 旧 worker 回来时手里是旧的。
|
|
290
|
+
claim_token=f"{worker_id}:{entry.attempt_count + 1}",
|
|
291
|
+
)
|
|
292
|
+
self.outbox[event_id] = claimed
|
|
293
|
+
return claimed
|
|
294
|
+
return None
|
|
295
|
+
|
|
296
|
+
def record_wake_receipt(self, *, receipt, next_state, claim_token=None,
|
|
297
|
+
next_attempt_at=None) -> bool:
|
|
298
|
+
from dataclasses import replace
|
|
299
|
+
entry = self.outbox.get(receipt.event_id)
|
|
300
|
+
if entry is None:
|
|
301
|
+
raise KeyError(f"unknown event_id {receipt.event_id!r}")
|
|
302
|
+
if claim_token is not None and entry.claim_token != claim_token:
|
|
303
|
+
# 令牌过期:这个事件已经被别人接管了。只记审计,不改状态。
|
|
304
|
+
self.receipts.append(receipt)
|
|
305
|
+
return False
|
|
306
|
+
_delivery.assert_transition(entry.delivery_state, next_state)
|
|
307
|
+
self.receipts.append(receipt)
|
|
308
|
+
self.outbox[receipt.event_id] = replace(
|
|
309
|
+
entry,
|
|
310
|
+
delivery_state=next_state,
|
|
311
|
+
next_attempt_at=next_attempt_at,
|
|
312
|
+
lease_owner=None,
|
|
313
|
+
lease_expires_at=None,
|
|
314
|
+
# 兑现或释放:只有 delivered 会把占位变成真正的消耗。
|
|
315
|
+
claim_token=None,
|
|
316
|
+
budget_reservation_id=(
|
|
317
|
+
entry.budget_reservation_id
|
|
318
|
+
if _delivery.consumes_budget(next_state) else None
|
|
319
|
+
),
|
|
320
|
+
)
|
|
321
|
+
return True
|
|
322
|
+
|
|
323
|
+
def list_pending_events(self, *, subject_id=None, limit=100):
|
|
324
|
+
return [
|
|
325
|
+
e for e in self.outbox.values()
|
|
326
|
+
if not e.is_terminal and (subject_id is None or e.subject_id == subject_id)
|
|
327
|
+
][:limit]
|
|
328
|
+
|
|
329
|
+
# -- 用户数据 --------------------------------------------------------
|
|
330
|
+
|
|
331
|
+
def purge_subject(self, *, subject_id) -> dict[str, int]:
|
|
332
|
+
doomed_event_ids = {e.event_id for e in self.outbox.values()
|
|
333
|
+
if e.subject_id == subject_id}
|
|
334
|
+
|
|
335
|
+
def drop(store: dict, pick) -> int:
|
|
336
|
+
doomed = [k for k, v in store.items() if pick(k, v) == subject_id]
|
|
337
|
+
for k in doomed:
|
|
338
|
+
del store[k]
|
|
339
|
+
return len(doomed)
|
|
340
|
+
|
|
341
|
+
counts = {
|
|
342
|
+
"reports": drop(self.reports, lambda k, v: k[0]),
|
|
343
|
+
"observations": drop(self.observations, lambda k, v: v.subject_id),
|
|
344
|
+
"current": drop(self.current, lambda k, v: k[0]),
|
|
345
|
+
"aggregates": drop(self.aggregates, lambda k, v: k[0]),
|
|
346
|
+
"calendar": drop(self.calendar, lambda k, v: k[0]),
|
|
347
|
+
"reminders": drop(self.reminders, lambda k, v: k[0]),
|
|
348
|
+
"sync_state": drop(self.sync_state, lambda k, v: k[0]),
|
|
349
|
+
"rule_state": drop(self.rule_state, lambda k, v: k[0]),
|
|
350
|
+
"outbox": drop(self.outbox, lambda k, v: v.subject_id),
|
|
351
|
+
}
|
|
352
|
+
before = len(self.identities)
|
|
353
|
+
self.identities = {i for i in self.identities if i[0] != subject_id}
|
|
354
|
+
counts["identities"] = before - len(self.identities)
|
|
355
|
+
# 回执按它对应事件的 subject 清理。以前这里只是原样复制了一遍列表 ——
|
|
356
|
+
# "删除我的数据"这件事没有部分成功。
|
|
357
|
+
doomed_events = {k for k in doomed_event_ids}
|
|
358
|
+
kept = [r for r in self.receipts if r.event_id not in doomed_events]
|
|
359
|
+
counts["receipts"] = len(self.receipts) - len(kept)
|
|
360
|
+
self.receipts = kept
|
|
361
|
+
return counts
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
__all__ = ["InMemoryStorage"]
|