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/fields.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Single source of truth for agent-facing perception projection.
|
|
2
|
+
|
|
3
|
+
A host typically has more than one code path that lets an agent pull the
|
|
4
|
+
current perception state (e.g. a CLI/tools path and a hosted runtime path).
|
|
5
|
+
Both should project perception_state through THIS map, so the agent sees
|
|
6
|
+
exactly the same signals/fields no matter which path served the request --
|
|
7
|
+
no second, stale catalog.
|
|
8
|
+
|
|
9
|
+
Add a new agent-pullable signal/field here ONCE and every path picks it up.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import Mapping
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
FAST_AGENT_PERCEPTION_SIGNALS = ("now", "location", "weather", "motion", "calendar")
|
|
17
|
+
SLOW_AGENT_PERCEPTION_SIGNALS = (
|
|
18
|
+
"steps", "sleep", "workout", "vitals",
|
|
19
|
+
"activity", "body", "metabolic", "cycle", "mood", "reminders",
|
|
20
|
+
)
|
|
21
|
+
# `app` = the last app event we observed, within its TTL. `app_state` says which
|
|
22
|
+
# kind it was: "foreground" (the user opened it) or "closed" (the user left it).
|
|
23
|
+
# Both come from iOS Shortcut automations the user configures per app, so a user
|
|
24
|
+
# who only wired the open automation never produces "closed" — treat a missing
|
|
25
|
+
# app_state as unknown, not as "still in the app".
|
|
26
|
+
# App-event HISTORY is deliberately not a signal: it returns a list, takes
|
|
27
|
+
# limit/hours, and doesn't fit project_signal's state-field projection. It's a
|
|
28
|
+
# separate query tool a host exposes on its own (e.g. a "recent apps" lookup).
|
|
29
|
+
PULL_ONLY_AGENT_PERCEPTION_SIGNALS = ("focus", "audio_route", "app")
|
|
30
|
+
AGENT_PERCEPTION_SIGNALS = (
|
|
31
|
+
FAST_AGENT_PERCEPTION_SIGNALS
|
|
32
|
+
+ SLOW_AGENT_PERCEPTION_SIGNALS
|
|
33
|
+
+ PULL_ONLY_AGENT_PERCEPTION_SIGNALS
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
AGENT_SIGNAL_FIELDS: dict[str, tuple[str, ...]] = {
|
|
37
|
+
"now": (
|
|
38
|
+
"local_time", "timezone", "locale", "battery_level", "charging", "low_power_mode",
|
|
39
|
+
"place_label", "motion_state", "now_playing", "broadcast_state", "broadcast_active",
|
|
40
|
+
),
|
|
41
|
+
"location": ("place_label", "wifi_label", "country", "locality", "wifi_anchor_id"),
|
|
42
|
+
"weather": (
|
|
43
|
+
"condition", "temperature", "apparent_temperature", "humidity",
|
|
44
|
+
"precipitation_chance", "uv_index", "is_daylight", "alerts",
|
|
45
|
+
),
|
|
46
|
+
"motion": ("motion_state",),
|
|
47
|
+
"calendar": ("calendar_next_event", "calendar_events", "calendar_events_truncated"),
|
|
48
|
+
"focus": ("focus_authorization_status", "in_focus"),
|
|
49
|
+
"audio_route": ("output_type", "is_bluetooth", "device_name"),
|
|
50
|
+
"app": ("app_name", "app_category", "app_state"),
|
|
51
|
+
"steps": ("step_count",),
|
|
52
|
+
"sleep": ("asleep_minutes", "core_minutes", "deep_minutes", "rem_minutes"),
|
|
53
|
+
"workout": ("workout_type", "duration_min", "count_today"),
|
|
54
|
+
"vitals": (
|
|
55
|
+
"resting_heart_rate", "step_count", "current_heart_rate", "hrv_sdnn_ms",
|
|
56
|
+
"respiratory_rate", "oxygen_saturation_pct", "vo2_max",
|
|
57
|
+
),
|
|
58
|
+
"activity": ("active_energy_kcal", "exercise_minutes", "stand_minutes", "mindful_minutes"),
|
|
59
|
+
"body": ("weight_kg", "bmi", "body_fat_pct", "height_cm"),
|
|
60
|
+
"metabolic": ("blood_glucose_mmol_l", "blood_pressure_systolic", "blood_pressure_diastolic"),
|
|
61
|
+
"cycle": ("flow_level", "is_active_period"),
|
|
62
|
+
"mood": ("valence", "valence_classification", "kind", "label_count", "recorded_today"),
|
|
63
|
+
"reminders": ("next_reminder", "reminders", "overdue_count", "due_today_count", "reminders_truncated"),
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def project_signal(
|
|
68
|
+
signal: str,
|
|
69
|
+
snapshot: Mapping[str, Any],
|
|
70
|
+
pull_snapshot: Mapping[str, Any],
|
|
71
|
+
) -> dict[str, Any]:
|
|
72
|
+
"""Project one agent signal's fields from the right source.
|
|
73
|
+
|
|
74
|
+
`now` and shortcut-reported `app` are cheap snapshot fields; everything else
|
|
75
|
+
comes from the pull snapshot.
|
|
76
|
+
"""
|
|
77
|
+
source = snapshot if signal in {"now", "app"} else pull_snapshot
|
|
78
|
+
out = {field: source.get(field) for field in AGENT_SIGNAL_FIELDS.get(signal, ())}
|
|
79
|
+
if signal == "now":
|
|
80
|
+
out["time"] = source.get("local_time")
|
|
81
|
+
if "user_state" in source:
|
|
82
|
+
out["user_state"] = source.get("user_state")
|
|
83
|
+
return out
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# --- Permission judgment ----------------------------------------------------
|
|
87
|
+
#
|
|
88
|
+
# Shared agent-perception authorization judgment, so EVERY read path enforces
|
|
89
|
+
# the same rule. It's easy for this to drift when there is more than one entry
|
|
90
|
+
# point (a dedicated single-signal route, a bulk/list route, a different
|
|
91
|
+
# runtime's own adapter) and only one of them remembers to check the switch —
|
|
92
|
+
# a capability the user turned off stays readable through whichever path
|
|
93
|
+
# forgot. Keeping the judgment here, next to projection and glance (the only
|
|
94
|
+
# two callers), makes that class of bug structurally harder to reintroduce.
|
|
95
|
+
|
|
96
|
+
# Agent signal name -> the permission keys that may gate it. A signal absent
|
|
97
|
+
# here is gated by its own name.
|
|
98
|
+
SIGNAL_PERMISSION_KEYS: dict[str, tuple[str, ...]] = {
|
|
99
|
+
"now": ("now", "time", "device", "battery", "broadcast"),
|
|
100
|
+
"location": ("location", "location_signal"),
|
|
101
|
+
"weather": ("weather",),
|
|
102
|
+
"motion": ("motion", "motion_state"),
|
|
103
|
+
"calendar": ("calendar", "calendar_next_event"),
|
|
104
|
+
"focus": ("focus",),
|
|
105
|
+
"audio_route": ("audio_route",),
|
|
106
|
+
"steps": ("steps", "health", "health_vitals"),
|
|
107
|
+
"sleep": ("sleep", "health", "health_sleep"),
|
|
108
|
+
"workout": ("workout", "health", "health_workout"),
|
|
109
|
+
"vitals": ("vitals", "health", "health_vitals"),
|
|
110
|
+
"activity": ("activity", "health", "health_activity"),
|
|
111
|
+
"body": ("body", "health", "health_body"),
|
|
112
|
+
"metabolic": ("metabolic", "health", "health_metabolic"),
|
|
113
|
+
"cycle": ("cycle", "health", "health_cycle"),
|
|
114
|
+
"mood": ("mood", "health", "health_mood"),
|
|
115
|
+
"reminders": ("reminders",),
|
|
116
|
+
# App-open HISTORY rides the same capability as the current-app field: one
|
|
117
|
+
# switch, both resolutions. Turning `app` off must stop the trajectory read
|
|
118
|
+
# too, not just the current value.
|
|
119
|
+
"recent_apps": ("app",),
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
OFF_VALUES = {"0", "false", "off", "disabled", "switch_off", "switch-off", "no"}
|
|
123
|
+
DENIED_VALUES = {
|
|
124
|
+
"denied",
|
|
125
|
+
"not_permitted",
|
|
126
|
+
"not-permitted",
|
|
127
|
+
"not_allowed",
|
|
128
|
+
"not-allowed",
|
|
129
|
+
"not_authorized",
|
|
130
|
+
"not-authorized",
|
|
131
|
+
"unauthorized",
|
|
132
|
+
"restricted",
|
|
133
|
+
"permission_denied",
|
|
134
|
+
}
|
|
135
|
+
ALLOW_VALUES = {"1", "true", "on", "enabled", "allowed", "authorized", "granted", "yes"}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def boolish_doc_reason(value: Any) -> str:
|
|
139
|
+
if isinstance(value, bool):
|
|
140
|
+
return "" if value else "switch_off"
|
|
141
|
+
if isinstance(value, (int, float)):
|
|
142
|
+
return "" if bool(value) else "switch_off"
|
|
143
|
+
normalized = str(value or "").strip().lower()
|
|
144
|
+
if not normalized or normalized in ALLOW_VALUES:
|
|
145
|
+
return ""
|
|
146
|
+
if normalized in OFF_VALUES:
|
|
147
|
+
return "switch_off"
|
|
148
|
+
if normalized in DENIED_VALUES:
|
|
149
|
+
return "not_permitted"
|
|
150
|
+
return ""
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def permission_state_reason(value: Any) -> str:
|
|
154
|
+
if isinstance(value, Mapping):
|
|
155
|
+
explicit_reason = str(value.get("reason") or "").strip().lower()
|
|
156
|
+
for key in ("enabled", "allowed", "authorized", "granted", "permitted"):
|
|
157
|
+
if key in value:
|
|
158
|
+
reason = boolish_doc_reason(value.get(key))
|
|
159
|
+
if reason:
|
|
160
|
+
return "not_permitted" if explicit_reason in DENIED_VALUES else reason
|
|
161
|
+
return ""
|
|
162
|
+
for key in ("state", "status", "permission", "value"):
|
|
163
|
+
if key in value:
|
|
164
|
+
reason = boolish_doc_reason(value.get(key))
|
|
165
|
+
if reason:
|
|
166
|
+
return reason
|
|
167
|
+
return boolish_doc_reason(explicit_reason)
|
|
168
|
+
return boolish_doc_reason(value)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def permission_states_reason(settings: Mapping[str, Any], signal: str) -> str:
|
|
172
|
+
""""" when the signal is readable, else a reason ("switch_off" /
|
|
173
|
+
"not_permitted"). Absent permission_states means "never configured" ->
|
|
174
|
+
readable, matching the implicit-authorization design."""
|
|
175
|
+
states = settings.get("permission_states") if isinstance(settings, Mapping) else {}
|
|
176
|
+
if not isinstance(states, Mapping):
|
|
177
|
+
return ""
|
|
178
|
+
for key in SIGNAL_PERMISSION_KEYS.get(signal, (signal,)):
|
|
179
|
+
if key not in states:
|
|
180
|
+
continue
|
|
181
|
+
reason = permission_state_reason(states.get(key))
|
|
182
|
+
if reason:
|
|
183
|
+
return reason
|
|
184
|
+
return ""
|
perceptkit/kit.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""接入口 —— 一个新 runtime 要打交道的全部东西。
|
|
2
|
+
|
|
3
|
+
kit = PerceptionKit(storage=my_storage, wake=my_runtime, definitions=my_rules)
|
|
4
|
+
result = kit.ingest(report, context=IngestContext(subject_id=..., received_at=...))
|
|
5
|
+
|
|
6
|
+
**宿主不需要读这个包的源码就能接上。** 顺序、幂等、一致性都在管线里,
|
|
7
|
+
宿主只填 ``StoragePort`` 和 ``WakePort`` 的方法体,再配几条规则。
|
|
8
|
+
|
|
9
|
+
上报和投递是**分开的两件事**:``ingest`` 同步做到"事件已落地并提交"就返回,
|
|
10
|
+
``dispatch`` 由宿主自己的 worker 驱动。这样上报接口的延迟只取决于数据库,
|
|
11
|
+
不取决于 agent runtime —— runtime 一慢,上报接口跟着超时、客户端重传、
|
|
12
|
+
雪上加霜。
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from datetime import date, datetime
|
|
18
|
+
from typing import Any, Callable, Mapping, Sequence
|
|
19
|
+
|
|
20
|
+
from .contracts.context import IngestContext
|
|
21
|
+
from .contracts.report import ReportEnvelope
|
|
22
|
+
from .manifest.minimal import MINIMAL_SIGNALS
|
|
23
|
+
from .manifest.types import SignalDefinition
|
|
24
|
+
from .ports.storage import StoragePort
|
|
25
|
+
from .ports.wake import WakePort
|
|
26
|
+
from .processing.dispatch import DispatchOutcome, drain
|
|
27
|
+
from .processing.pipeline import AGGREGATION_VERSION, IngestOutcome, ingest_report
|
|
28
|
+
from .processing.recompute import RecomputeOutcome, recompute_range
|
|
29
|
+
from .processing.scheduled import ScheduledOutcome, evaluate_absence, evaluate_daily
|
|
30
|
+
from .queries import api as _queries
|
|
31
|
+
from .rules.types import EventDefinition
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class PerceptionKit:
|
|
36
|
+
"""把端口、manifest 和规则装配起来。"""
|
|
37
|
+
|
|
38
|
+
storage: StoragePort
|
|
39
|
+
wake: WakePort | None = None
|
|
40
|
+
#: 信号声明。默认是最小集(五个信号,覆盖两种存储形态);
|
|
41
|
+
#: 宿主应当传自己的完整 manifest。
|
|
42
|
+
signals: Mapping[str, SignalDefinition] = field(
|
|
43
|
+
default_factory=lambda: dict(MINIMAL_SIGNALS)
|
|
44
|
+
)
|
|
45
|
+
definitions: Sequence[EventDefinition] = ()
|
|
46
|
+
#: 宿主注册的自定义 evaluator。普通用户配置仍然只能用声明式模板。
|
|
47
|
+
extra_evaluators: Mapping[str, Callable[..., Any]] | None = None
|
|
48
|
+
#: 观测没带时区时用什么兜底。见 OPEN-QUESTIONS B2 —— 这一条还没和
|
|
49
|
+
#: 产品方对齐,所以由宿主传,不在包里写死。
|
|
50
|
+
timezone_fallback: str | None = None
|
|
51
|
+
max_observations: int = 200
|
|
52
|
+
|
|
53
|
+
# -- 写入侧 ----------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
def ingest(
|
|
56
|
+
self,
|
|
57
|
+
report: ReportEnvelope | Mapping[str, Any],
|
|
58
|
+
*,
|
|
59
|
+
context: IngestContext,
|
|
60
|
+
dispatch: bool = False,
|
|
61
|
+
worker_id: str = "inline",
|
|
62
|
+
) -> IngestOutcome:
|
|
63
|
+
"""收一批上报,走完落地为止的全部步骤。
|
|
64
|
+
|
|
65
|
+
``dispatch=False``(默认)时**不投递** —— 事件留在发件箱,由宿主的
|
|
66
|
+
worker 去投。这不是偷懒:同步投递会把 agent runtime 的延迟直接叠加到
|
|
67
|
+
上报接口上。想同步投的宿主传 ``dispatch=True``,但要清楚代价。
|
|
68
|
+
"""
|
|
69
|
+
envelope = (report if isinstance(report, ReportEnvelope)
|
|
70
|
+
else ReportEnvelope.parse(report))
|
|
71
|
+
outcome = ingest_report(
|
|
72
|
+
envelope,
|
|
73
|
+
context=context,
|
|
74
|
+
storage=self.storage,
|
|
75
|
+
signals=self.signals,
|
|
76
|
+
definitions=self.definitions,
|
|
77
|
+
extra_evaluators=self.extra_evaluators,
|
|
78
|
+
timezone_fallback=self.timezone_fallback,
|
|
79
|
+
max_observations=self.max_observations,
|
|
80
|
+
)
|
|
81
|
+
if dispatch and outcome.events:
|
|
82
|
+
if self.wake is None:
|
|
83
|
+
raise ValueError("dispatch=True 需要一个 WakePort")
|
|
84
|
+
self.dispatch_pending(worker_id=worker_id, now=context.received_at)
|
|
85
|
+
return outcome
|
|
86
|
+
|
|
87
|
+
# -- 投递侧 ----------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
def dispatch_pending(
|
|
90
|
+
self, *, worker_id: str, now: datetime,
|
|
91
|
+
limit: int = 100, lease_seconds: float = 60.0,
|
|
92
|
+
) -> DispatchOutcome:
|
|
93
|
+
"""把发件箱里能投的都投一遍。宿主的 worker 循环调它。
|
|
94
|
+
|
|
95
|
+
``now`` 由调用方传 —— 这个包不读时钟,否则重放和测试都做不了。
|
|
96
|
+
"""
|
|
97
|
+
if self.wake is None:
|
|
98
|
+
raise ValueError("没有 WakePort,无法投递")
|
|
99
|
+
return drain(
|
|
100
|
+
storage=self.storage, wake=self.wake, worker_id=worker_id,
|
|
101
|
+
now=now, limit=limit, lease_seconds=lease_seconds,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# -- 时钟驱动的两种规则 ----------------------------------------------
|
|
105
|
+
#
|
|
106
|
+
# 九种规则里有两种主管线跑不到:streak 要按天判(跟着观测跑是一天几千次,
|
|
107
|
+
# 而它一天只可能变化一次)、absence 是【没有数据才该触发】(跟着观测跑
|
|
108
|
+
# 永远等不到自己被调用)。
|
|
109
|
+
#
|
|
110
|
+
# 宿主不用为此多起一个东西 —— 投递那条线本来就需要定时循环,搭上去就行:
|
|
111
|
+
#
|
|
112
|
+
# while True:
|
|
113
|
+
# kit.dispatch_pending(worker_id="w1", now=now())
|
|
114
|
+
# kit.evaluate_absence(subject_id=..., now=now())
|
|
115
|
+
# sleep(60)
|
|
116
|
+
|
|
117
|
+
def evaluate_daily(
|
|
118
|
+
self, *, subject_id: str, local_date: date, now: datetime,
|
|
119
|
+
) -> ScheduledOutcome:
|
|
120
|
+
"""某天的聚合算完后调一次,跑 ``streak`` 这类按天判的规则。"""
|
|
121
|
+
return evaluate_daily(
|
|
122
|
+
storage=self.storage, subject_id=subject_id, local_date=local_date,
|
|
123
|
+
now=now, signals=self.signals, definitions=self.definitions,
|
|
124
|
+
extra_evaluators=self.extra_evaluators,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def recompute_aggregates(
|
|
128
|
+
self, *, subject_id: str, signal: str, start: date, end: date,
|
|
129
|
+
now: datetime, version: int | None = None,
|
|
130
|
+
allow_incomplete: bool = False,
|
|
131
|
+
):
|
|
132
|
+
"""聚合算法升级之后,按新版本把历史重算一遍。
|
|
133
|
+
|
|
134
|
+
**默认拒绝重算明细可能已经被保留期清掉的日子** —— 拿残缺明细折出来的
|
|
135
|
+
永久统计会错一个数量级,而且旧值已经被覆盖、救不回来。真要算就显式
|
|
136
|
+
传 ``allow_incomplete=True``,结果里会标出来。
|
|
137
|
+
"""
|
|
138
|
+
return recompute_range(
|
|
139
|
+
storage=self.storage, signals=self.signals, subject_id=subject_id,
|
|
140
|
+
signal=signal, start_date=start, end_date=end,
|
|
141
|
+
version=AGGREGATION_VERSION if version is None else version,
|
|
142
|
+
now=now, allow_incomplete=allow_incomplete,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
def evaluate_absence(
|
|
146
|
+
self, *, subject_id: str, now: datetime,
|
|
147
|
+
) -> ScheduledOutcome:
|
|
148
|
+
"""定时调,跑 ``absence``(该来的没来)。"""
|
|
149
|
+
return evaluate_absence(
|
|
150
|
+
storage=self.storage, subject_id=subject_id, now=now,
|
|
151
|
+
signals=self.signals, definitions=self.definitions,
|
|
152
|
+
extra_evaluators=self.extra_evaluators,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# -- 读取侧 ----------------------------------------------------------
|
|
156
|
+
#
|
|
157
|
+
# 这条路和写入侧共用存储,方向相反:agent 主动来查。
|
|
158
|
+
# 八个函数的实现在 queries/api.py —— 这里只是绑上 manifest 的薄封装。
|
|
159
|
+
|
|
160
|
+
def get_current(self, *, subject_id: str, signals: Sequence[str],
|
|
161
|
+
now: datetime) -> dict[str, _queries.CurrentView]:
|
|
162
|
+
"""取当前值,**带 TTL 判定**:过期的不冒充现在。"""
|
|
163
|
+
return _queries.get_current(
|
|
164
|
+
self.storage, subject_id=subject_id, signals=signals,
|
|
165
|
+
manifest=self.signals, now=now,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
def get_last_known(self, *, subject_id: str, signal: str):
|
|
169
|
+
return _queries.get_last_known(
|
|
170
|
+
self.storage, subject_id=subject_id, signal=signal, manifest=self.signals,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
def list_timeline(self, *, subject_id: str, signal: str, **kw):
|
|
174
|
+
return _queries.list_timeline(
|
|
175
|
+
self.storage, subject_id=subject_id, signal=signal,
|
|
176
|
+
manifest=self.signals, **kw,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
def get_daily(self, *, subject_id: str, signal: str, start: date, end: date):
|
|
180
|
+
"""日聚合。空缺的日子**不补零** —— `no_data` 不是 0。"""
|
|
181
|
+
return _queries.get_daily_aggregates(
|
|
182
|
+
self.storage, subject_id=subject_id, signal=signal,
|
|
183
|
+
start_date=start, end_date=end,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
def get_trend(self, *, subject_id: str, signal: str, field: str,
|
|
187
|
+
start: date, end: date) -> dict[str, Any]:
|
|
188
|
+
"""趋势。按 manifest 声明的模型选算法,并报出缺了几天。"""
|
|
189
|
+
return _queries.get_trend(
|
|
190
|
+
self.storage, subject_id=subject_id, signal=signal, field=field,
|
|
191
|
+
manifest=self.signals, start_date=start, end_date=end,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
def list_calendar_events(self, *, subject_id: str, **kw):
|
|
195
|
+
"""返回 ``(日程, 下一页游标)``。重复日程按窗口展开,见 processing/recurrence。"""
|
|
196
|
+
return _queries.list_calendar_events(self.storage, subject_id=subject_id, **kw)
|
|
197
|
+
|
|
198
|
+
def list_reminders(self, *, subject_id: str, **kw):
|
|
199
|
+
return _queries.list_reminders(self.storage, subject_id=subject_id, **kw)
|
|
200
|
+
|
|
201
|
+
def list_events(self, *, subject_id: str, **kw):
|
|
202
|
+
"""事件列表,可按投递状态筛、分页。
|
|
203
|
+
|
|
204
|
+
「为什么没提醒我」的答案常常是 suppressed 或 rejected,不是 pending。
|
|
205
|
+
"""
|
|
206
|
+
return _queries.list_events(self.storage, subject_id=subject_id, **kw)
|
|
207
|
+
|
|
208
|
+
def list_definitions(self, *, subject_id: str | None = None, **kw):
|
|
209
|
+
"""当前装配了哪些规则。用户能自己配规则,就会问「我那条还在吗」。"""
|
|
210
|
+
return _queries.list_definitions(self.definitions, subject_id=subject_id, **kw)
|
|
211
|
+
|
|
212
|
+
def export_subject(self, *, subject_id: str, **kw) -> dict[str, Any]:
|
|
213
|
+
"""把一个人的全部数据导出来(「把我的数据给我」那条法定请求)。
|
|
214
|
+
|
|
215
|
+
**只含 kit 管的部分。** 宿主自己存的东西要自己追加进去 ——
|
|
216
|
+
返回值里的 `kit_managed_only` 就是提醒这件事的。
|
|
217
|
+
"""
|
|
218
|
+
return _queries.export_subject(
|
|
219
|
+
self.storage, subject_id=subject_id, manifest=self.signals, **kw,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
__all__ = ["PerceptionKit"]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Manifest —— 每个信号、每个字段的单一声明处。
|
|
2
|
+
|
|
3
|
+
在这之前,一个字段的属性散在七个互不关联的地方(catalog / fields / history /
|
|
4
|
+
retention / attribution / trend_models / prompts)。加一个字段要同时改七处,
|
|
5
|
+
**漏一处不会有任何东西变红**。manifest 把它们并成一处,并让四条自动检查
|
|
6
|
+
有了施力点。
|
|
7
|
+
|
|
8
|
+
types 声明的形状(SignalDefinition / FieldDefinition + 允许的取值)
|
|
9
|
+
minimal 最小可执行 manifest:五个信号,覆盖四种存储形态
|
|
10
|
+
checks 四条自动检查,对应产品规范 §8
|
|
11
|
+
|
|
12
|
+
manifest 只声明属性,不实现算法 —— 但它声明的每个名字都必须能解析到实现,
|
|
13
|
+
``check_named_implementations_exist`` 盯着这条。
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from .checks import (
|
|
18
|
+
check_history_has_retention,
|
|
19
|
+
check_named_implementations_exist,
|
|
20
|
+
check_types_and_units,
|
|
21
|
+
check_wake_eligible_fields_have_comparators,
|
|
22
|
+
check_projections_do_not_drift,
|
|
23
|
+
validate_manifest,
|
|
24
|
+
)
|
|
25
|
+
from .minimal import (
|
|
26
|
+
AUDIO_ROUTE,
|
|
27
|
+
BATTERY,
|
|
28
|
+
BROADCAST,
|
|
29
|
+
DECLINED_SIGNALS,
|
|
30
|
+
FOCUS_STATE,
|
|
31
|
+
LOCATION_CITY,
|
|
32
|
+
MINIMAL_SIGNALS,
|
|
33
|
+
MOTION_STATE,
|
|
34
|
+
PHOTO_LIBRARY_ADDED,
|
|
35
|
+
PRESENCE_RECOVERY,
|
|
36
|
+
SCREEN_CHANGE,
|
|
37
|
+
STEPS,
|
|
38
|
+
TIME_CONTEXT,
|
|
39
|
+
WEATHER,
|
|
40
|
+
)
|
|
41
|
+
from .types import PERMANENT, FieldDefinition, SignalDefinition
|
|
42
|
+
|
|
43
|
+
from .mapping import MODE_OBJECTS, reference_mapping, render_reference_mapping
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"SignalDefinition", "FieldDefinition", "PERMANENT",
|
|
47
|
+
"MODE_OBJECTS", "reference_mapping", "render_reference_mapping",
|
|
48
|
+
"MINIMAL_SIGNALS", "DECLINED_SIGNALS",
|
|
49
|
+
"BATTERY", "PRESENCE_RECOVERY", "STEPS", "LOCATION_CITY", "FOCUS_STATE",
|
|
50
|
+
"TIME_CONTEXT", "BROADCAST", "SCREEN_CHANGE", "AUDIO_ROUTE", "WEATHER",
|
|
51
|
+
"MOTION_STATE", "PHOTO_LIBRARY_ADDED",
|
|
52
|
+
"validate_manifest",
|
|
53
|
+
"check_types_and_units", "check_history_has_retention",
|
|
54
|
+
"check_named_implementations_exist",
|
|
55
|
+
"check_wake_eligible_fields_have_comparators",
|
|
56
|
+
"check_projections_do_not_drift",
|
|
57
|
+
]
|