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
|
@@ -0,0 +1,1282 @@
|
|
|
1
|
+
"""最小可执行 manifest —— 五个信号,把整条管线走通一遍。
|
|
2
|
+
|
|
3
|
+
**为什么先做五个而不是二十个。** 管线的正确性(幂等、乱序、TTL、聚合重算、
|
|
4
|
+
规则求值、投递可靠性)和信号数量无关;而字段细节还有一堆没和产品方对齐的问题
|
|
5
|
+
(见 ``OPEN-QUESTIONS.md``)。先用五个把管线跑通,剩下十五个只是往表里填格子。
|
|
6
|
+
|
|
7
|
+
选这五个是因为它们**恰好覆盖四种存储形态和三种身份策略**:
|
|
8
|
+
|
|
9
|
+
battery current_only · singleton
|
|
10
|
+
presence_recovery current_only · source_event_id · occurrence 事件
|
|
11
|
+
steps timeline + aggregate · source_event_id · threshold 事件
|
|
12
|
+
location_city timeline + aggregate · deterministic · 变化才追加
|
|
13
|
+
focus_state timeline + aggregate · deterministic · 时长聚合
|
|
14
|
+
|
|
15
|
+
**和产品规范的三处有意出入**,都在各自的 ``note`` 里写明了原因,
|
|
16
|
+
并且都进了 ``OPEN-QUESTIONS.md`` 等确认 —— 不是默默改掉。
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from .types import PERMANENT, FieldDefinition, SignalDefinition
|
|
21
|
+
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
# battery —— 最简单的一种:只留当前值
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
BATTERY = SignalDefinition(
|
|
27
|
+
key="battery",
|
|
28
|
+
label="电量",
|
|
29
|
+
schema_version=1,
|
|
30
|
+
capability="device",
|
|
31
|
+
storage_mode="current_only",
|
|
32
|
+
current_ttl_sec=600.0,
|
|
33
|
+
identity_strategy="singleton",
|
|
34
|
+
attribution_strategy="instant",
|
|
35
|
+
history_retention_days=0,
|
|
36
|
+
fields=(
|
|
37
|
+
FieldDefinition(
|
|
38
|
+
key="level_ratio",
|
|
39
|
+
value_type="number",
|
|
40
|
+
unit="ratio",
|
|
41
|
+
privacy_class="public",
|
|
42
|
+
nullable=False,
|
|
43
|
+
valid_range=(0.0, 1.0),
|
|
44
|
+
comparison_strategy="threshold_crossing",
|
|
45
|
+
wake_eligible=False,
|
|
46
|
+
query_visibility="always",
|
|
47
|
+
),
|
|
48
|
+
FieldDefinition(
|
|
49
|
+
key="is_charging",
|
|
50
|
+
value_type="boolean",
|
|
51
|
+
privacy_class="public",
|
|
52
|
+
nullable=False,
|
|
53
|
+
comparison_strategy="state_change",
|
|
54
|
+
query_visibility="always",
|
|
55
|
+
),
|
|
56
|
+
FieldDefinition(
|
|
57
|
+
key="is_low_power_mode_enabled",
|
|
58
|
+
value_type="boolean",
|
|
59
|
+
privacy_class="public",
|
|
60
|
+
comparison_strategy="state_change",
|
|
61
|
+
query_visibility="always",
|
|
62
|
+
),
|
|
63
|
+
),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
# presence_recovery —— occurrence 型事件
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
PRESENCE_RECOVERY = SignalDefinition(
|
|
72
|
+
key="presence_recovery",
|
|
73
|
+
label="久别之后重新在场",
|
|
74
|
+
schema_version=1,
|
|
75
|
+
capability="device",
|
|
76
|
+
storage_mode="current_only",
|
|
77
|
+
# 不按普通 TTL 失效:查询时返回"多久之前",由调用方判断还算不算新鲜。
|
|
78
|
+
current_ttl_sec=0.0,
|
|
79
|
+
identity_strategy="source_event_id",
|
|
80
|
+
attribution_strategy="instant",
|
|
81
|
+
history_retention_days=0,
|
|
82
|
+
source_profile="device_occurrence",
|
|
83
|
+
note=(
|
|
84
|
+
"产品规范叫 device_unlock,这里改名 presence_recovery —— iOS 拿不到硬件"
|
|
85
|
+
"解锁事件(precise_unlock 恒为 null),能给的只是「app 自己进后台到回前台"
|
|
86
|
+
"的间隔」。沿用 unlock 这个名字会让模型解释成「用户刚解锁手机」,和产品方"
|
|
87
|
+
"自己撤回 device_boot 是同一类错误。见 OPEN-QUESTIONS B10。"
|
|
88
|
+
),
|
|
89
|
+
fields=(
|
|
90
|
+
FieldDefinition(
|
|
91
|
+
key="recovered_at",
|
|
92
|
+
value_type="timestamp",
|
|
93
|
+
privacy_class="personal",
|
|
94
|
+
nullable=False,
|
|
95
|
+
# occurrence:这条观测到达本身就是事件,没有前后值可比,
|
|
96
|
+
# 靠 source_event_id 去重。
|
|
97
|
+
comparison_strategy="occurrence",
|
|
98
|
+
wake_eligible=True,
|
|
99
|
+
query_visibility="always",
|
|
100
|
+
),
|
|
101
|
+
FieldDefinition(
|
|
102
|
+
key="absence_seconds",
|
|
103
|
+
value_type="number",
|
|
104
|
+
unit="seconds",
|
|
105
|
+
privacy_class="personal",
|
|
106
|
+
valid_range=(0.0, None),
|
|
107
|
+
comparison_strategy="threshold_crossing",
|
|
108
|
+
query_visibility="always",
|
|
109
|
+
),
|
|
110
|
+
FieldDefinition(
|
|
111
|
+
# 这个值是估的,不是测的。字段本身带上这个事实,比写在文档里可靠 ——
|
|
112
|
+
# 读到值的人不一定读过文档。
|
|
113
|
+
key="absence_quality",
|
|
114
|
+
value_type="enum",
|
|
115
|
+
privacy_class="public",
|
|
116
|
+
enum=("measured", "estimated"),
|
|
117
|
+
nullable=False,
|
|
118
|
+
query_visibility="always",
|
|
119
|
+
),
|
|
120
|
+
),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
# steps —— 累计量 + 阈值事件
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
STEPS = SignalDefinition(
|
|
129
|
+
key="steps",
|
|
130
|
+
label="步数",
|
|
131
|
+
schema_version=1,
|
|
132
|
+
capability="health_vitals",
|
|
133
|
+
storage_mode="current_timeline_aggregate",
|
|
134
|
+
current_ttl_sec=3600.0,
|
|
135
|
+
identity_strategy="source_event_id",
|
|
136
|
+
attribution_strategy="source_local_date",
|
|
137
|
+
history_retention_days=PERMANENT,
|
|
138
|
+
source_profile="health_sample",
|
|
139
|
+
fields=(
|
|
140
|
+
FieldDefinition(
|
|
141
|
+
key="step_count",
|
|
142
|
+
value_type="integer",
|
|
143
|
+
unit="count",
|
|
144
|
+
privacy_class="sensitive",
|
|
145
|
+
nullable=False,
|
|
146
|
+
valid_range=(0, None),
|
|
147
|
+
aggregation_strategy="daily_total",
|
|
148
|
+
# 必须是"跨过"而不是"大于等于" —— 后者会让 3001、3010、3100
|
|
149
|
+
# 每次上报都重复触发。
|
|
150
|
+
comparison_strategy="threshold_crossing",
|
|
151
|
+
wake_eligible=True,
|
|
152
|
+
query_visibility="on_demand",
|
|
153
|
+
# 每天的步数围绕一个"平时水平"上下浮动,偏离才是信号 ——
|
|
154
|
+
# 不是单调漂移(那是体重),也不是看间隔(那是经期)。
|
|
155
|
+
trend_model="fluctuating",
|
|
156
|
+
),
|
|
157
|
+
),
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# ---------------------------------------------------------------------------
|
|
162
|
+
# location_city —— 城市级位置
|
|
163
|
+
# ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
LOCATION_CITY = SignalDefinition(
|
|
166
|
+
key="location_city",
|
|
167
|
+
label="所在城市",
|
|
168
|
+
schema_version=1,
|
|
169
|
+
capability="location",
|
|
170
|
+
storage_mode="current_timeline_aggregate",
|
|
171
|
+
current_ttl_sec=900.0,
|
|
172
|
+
# iOS 不给稳定的位置事件 id,用 (signal, occurred_at, 值摘要) 造确定性键。
|
|
173
|
+
identity_strategy="deterministic_digest",
|
|
174
|
+
attribution_strategy="instant",
|
|
175
|
+
history_retention_days=PERMANENT,
|
|
176
|
+
source_profile="location",
|
|
177
|
+
note=(
|
|
178
|
+
"只有城市级。精细位置(home / work / 某个房间)是另一个信号"
|
|
179
|
+
"(proximity_anchor),不能混进同一个字段 —— 两个时期都叫 home "
|
|
180
|
+
"就看不出搬过家。"
|
|
181
|
+
),
|
|
182
|
+
fields=(
|
|
183
|
+
FieldDefinition(
|
|
184
|
+
key="locality",
|
|
185
|
+
value_type="string",
|
|
186
|
+
privacy_class="personal",
|
|
187
|
+
comparison_strategy="exact",
|
|
188
|
+
aggregation_strategy="duration_by_state",
|
|
189
|
+
wake_eligible=True,
|
|
190
|
+
query_visibility="always",
|
|
191
|
+
),
|
|
192
|
+
FieldDefinition(
|
|
193
|
+
key="country_code",
|
|
194
|
+
value_type="string",
|
|
195
|
+
privacy_class="personal",
|
|
196
|
+
comparison_strategy="exact",
|
|
197
|
+
wake_eligible=True,
|
|
198
|
+
query_visibility="always",
|
|
199
|
+
),
|
|
200
|
+
FieldDefinition(
|
|
201
|
+
key="region",
|
|
202
|
+
value_type="string",
|
|
203
|
+
privacy_class="personal",
|
|
204
|
+
comparison_strategy="exact",
|
|
205
|
+
# 省 / 州。**不参与唤醒** —— 换省一定伴随换城市,
|
|
206
|
+
# 两个都能唤醒就是同一件事报两次。
|
|
207
|
+
wake_eligible=False,
|
|
208
|
+
query_visibility="always",
|
|
209
|
+
note="城市和国家之间那一层。同名城市(好几个 Springfield)靠它区分。",
|
|
210
|
+
),
|
|
211
|
+
FieldDefinition(
|
|
212
|
+
key="accuracy_m",
|
|
213
|
+
value_type="number",
|
|
214
|
+
unit="m",
|
|
215
|
+
privacy_class="personal",
|
|
216
|
+
valid_range=(0, None),
|
|
217
|
+
comparison_strategy="none",
|
|
218
|
+
query_visibility="on_demand",
|
|
219
|
+
note=(
|
|
220
|
+
"这次定位有多准。**它不是位置本身,是位置的可信度** —— "
|
|
221
|
+
"误差半径 5 公里时说「你在上海」和误差 20 米时说,是两句"
|
|
222
|
+
"可信度完全不同的话,而不带这个字段就分不出来。"
|
|
223
|
+
),
|
|
224
|
+
),
|
|
225
|
+
FieldDefinition(
|
|
226
|
+
key="placemark_source",
|
|
227
|
+
value_type="string",
|
|
228
|
+
privacy_class="personal",
|
|
229
|
+
comparison_strategy="none",
|
|
230
|
+
query_visibility="on_demand",
|
|
231
|
+
note=(
|
|
232
|
+
"城市名是怎么来的:系统反地理编码 / 缓存 / 用户手填。"
|
|
233
|
+
"来源不同可信度不同,排查「为什么说我在另一个城市」时是第一条线索。"
|
|
234
|
+
),
|
|
235
|
+
),
|
|
236
|
+
FieldDefinition(
|
|
237
|
+
# 声明出来,是为了让"它永远不该被持久化、永远不该给 agent"
|
|
238
|
+
# 成为一条可被测试检查的规则,而不是一句口头约定。
|
|
239
|
+
key="coordinate",
|
|
240
|
+
value_type="object",
|
|
241
|
+
privacy_class="restricted",
|
|
242
|
+
query_visibility="never",
|
|
243
|
+
# 不挂 normalizer:把坐标粗化成城市要查地理编码,那是 I/O,
|
|
244
|
+
# kit 不做。实际链路里 iOS 在端上就解析完并丢弃了坐标,
|
|
245
|
+
# 这个字段是留给其他 producer 的(协议要通用),
|
|
246
|
+
# 声明它是为了让"永不持久化、永不给 agent"成为可测试的规则。
|
|
247
|
+
),
|
|
248
|
+
),
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# ---------------------------------------------------------------------------
|
|
253
|
+
# focus_state —— 状态时长聚合
|
|
254
|
+
# ---------------------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
FOCUS_STATE = SignalDefinition(
|
|
257
|
+
key="focus_state",
|
|
258
|
+
label="专注模式",
|
|
259
|
+
schema_version=1,
|
|
260
|
+
capability="focus",
|
|
261
|
+
storage_mode="current_timeline_aggregate",
|
|
262
|
+
# 产品规范给的是 300s。实测 iOS 后台保活上报间隔正好也是 300s、
|
|
263
|
+
# 进程被挂起后更长 —— TTL 等于上报间隔,意味着用户只要不在前台,
|
|
264
|
+
# 这个值几乎永远是 stale。取 3 倍。见 OPEN-QUESTIONS B12。
|
|
265
|
+
current_ttl_sec=900.0,
|
|
266
|
+
identity_strategy="deterministic_digest",
|
|
267
|
+
# 同上:时间点快照,时长由聚合层从相邻观测算。
|
|
268
|
+
attribution_strategy="instant",
|
|
269
|
+
# 明细 1 年、聚合永久(hx 2026-08-28)。产品规范给的是两者都永久,
|
|
270
|
+
# 但明细是聚合的几十倍体量,而"上周三下午你专注了多久"时间越久越没人问。
|
|
271
|
+
# 和 motion_state 同一条决定 —— 这两个信号必须一致。
|
|
272
|
+
history_retention_days=365,
|
|
273
|
+
aggregate_retention_days=PERMANENT,
|
|
274
|
+
note=(
|
|
275
|
+
"替掉产品规范阶段二里的 proximity_anchor:那个信号的 bluetooth 类型 iOS "
|
|
276
|
+
"给不了(只有音频路由这个子集),enter/leave 边缘也没有。focus_state "
|
|
277
|
+
"覆盖同一种存储形态,且采集能力确凿。见 OPEN-QUESTIONS B14/B17。"
|
|
278
|
+
),
|
|
279
|
+
fields=(
|
|
280
|
+
FieldDefinition(
|
|
281
|
+
key="is_active",
|
|
282
|
+
value_type="boolean",
|
|
283
|
+
privacy_class="personal",
|
|
284
|
+
nullable=False,
|
|
285
|
+
aggregation_strategy="duration_by_state",
|
|
286
|
+
comparison_strategy="state_change",
|
|
287
|
+
wake_eligible=False,
|
|
288
|
+
query_visibility="always",
|
|
289
|
+
),
|
|
290
|
+
),
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
# ---------------------------------------------------------------------------
|
|
295
|
+
# 以下是 §5.1「时间、设备与短期环境」逐条对完之后加进来的
|
|
296
|
+
# ---------------------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
TIME_CONTEXT = SignalDefinition(
|
|
299
|
+
key="time_context",
|
|
300
|
+
label="时间语境",
|
|
301
|
+
schema_version=1,
|
|
302
|
+
capability="time",
|
|
303
|
+
storage_mode="current_timeline_aggregate",
|
|
304
|
+
# 产品规范给的是 300s。时区一年可能才变两次 —— 5 分钟就过期,等于用户
|
|
305
|
+
# 不在前台时我们几乎永远不知道他在哪个时区,而「这条数据算哪一天」恰恰靠它。
|
|
306
|
+
# 改成不失效,由查询层返回「这个信息多久之前的」。(hx 2026-08-28)
|
|
307
|
+
current_ttl_sec=0.0,
|
|
308
|
+
identity_strategy="deterministic_digest",
|
|
309
|
+
attribution_strategy="instant",
|
|
310
|
+
history_retention_days=PERMANENT,
|
|
311
|
+
note=(
|
|
312
|
+
"只记录时区【变化】,本地时间不存历史 —— 有时区 + UTC 时刻就能算出来。"
|
|
313
|
+
"TTL 改成不失效,原因见 current_ttl_sec 上面。"
|
|
314
|
+
),
|
|
315
|
+
fields=(
|
|
316
|
+
FieldDefinition(
|
|
317
|
+
key="time_zone_id",
|
|
318
|
+
value_type="string",
|
|
319
|
+
privacy_class="personal",
|
|
320
|
+
nullable=False,
|
|
321
|
+
# 必须是 IANA 名字,不能只有偏移:纽约冬天 -05:00、夏天 -04:00
|
|
322
|
+
# 是同一个时区,光看偏移分不出来,夏令时切换那天就会算错。
|
|
323
|
+
comparison_strategy="exact",
|
|
324
|
+
aggregation_strategy="event_list",
|
|
325
|
+
wake_eligible=True,
|
|
326
|
+
query_visibility="always",
|
|
327
|
+
),
|
|
328
|
+
FieldDefinition(
|
|
329
|
+
key="utc_offset_seconds",
|
|
330
|
+
value_type="integer",
|
|
331
|
+
unit="seconds",
|
|
332
|
+
privacy_class="personal",
|
|
333
|
+
comparison_strategy="none",
|
|
334
|
+
query_visibility="always",
|
|
335
|
+
),
|
|
336
|
+
FieldDefinition(
|
|
337
|
+
key="locale",
|
|
338
|
+
value_type="string",
|
|
339
|
+
privacy_class="personal",
|
|
340
|
+
comparison_strategy="none",
|
|
341
|
+
query_visibility="always",
|
|
342
|
+
),
|
|
343
|
+
),
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
BROADCAST = SignalDefinition(
|
|
348
|
+
key="broadcast",
|
|
349
|
+
label="屏幕采集会话",
|
|
350
|
+
schema_version=1,
|
|
351
|
+
capability="broadcast",
|
|
352
|
+
storage_mode="current_short_timeline",
|
|
353
|
+
current_ttl_sec=300.0,
|
|
354
|
+
identity_strategy="deterministic_digest",
|
|
355
|
+
# 同上:时间点快照,时长由聚合层从相邻观测算。
|
|
356
|
+
attribution_strategy="instant",
|
|
357
|
+
history_retention_days=7,
|
|
358
|
+
note=(
|
|
359
|
+
"和 screen_change 是两件事:这个表示【采集会话开着没有】(一天开关几次),"
|
|
360
|
+
"screen_change 表示【画面变了没有】(采集期间每几秒可能一次)。"
|
|
361
|
+
),
|
|
362
|
+
fields=(
|
|
363
|
+
FieldDefinition(
|
|
364
|
+
key="is_active",
|
|
365
|
+
value_type="boolean",
|
|
366
|
+
privacy_class="personal",
|
|
367
|
+
nullable=False,
|
|
368
|
+
comparison_strategy="state_change",
|
|
369
|
+
aggregation_strategy="duration_by_state",
|
|
370
|
+
wake_eligible=True,
|
|
371
|
+
query_visibility="always",
|
|
372
|
+
),
|
|
373
|
+
),
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
SCREEN_CHANGE = SignalDefinition(
|
|
378
|
+
key="screen_change",
|
|
379
|
+
label="画面是否发生变化",
|
|
380
|
+
schema_version=1,
|
|
381
|
+
capability="broadcast",
|
|
382
|
+
storage_mode="current_only",
|
|
383
|
+
current_ttl_sec=60.0,
|
|
384
|
+
identity_strategy="singleton",
|
|
385
|
+
attribution_strategy="instant",
|
|
386
|
+
history_retention_days=0,
|
|
387
|
+
note=(
|
|
388
|
+
"🔴 只存「变了 / 没变」这个布尔。**画面指纹不进这个信号,也不落库** —— "
|
|
389
|
+
"指纹序列存久了,理论上能反推用户屏幕上出现过什么。"
|
|
390
|
+
"比较放在设备端做(设备本地记着上一次的指纹,只上报布尔),"
|
|
391
|
+
"iOS 其他信号的 changed 标志已经是这套机制,screen 这条跟上即可。"
|
|
392
|
+
),
|
|
393
|
+
fields=(
|
|
394
|
+
FieldDefinition(
|
|
395
|
+
key="changed",
|
|
396
|
+
value_type="boolean",
|
|
397
|
+
privacy_class="personal",
|
|
398
|
+
nullable=False,
|
|
399
|
+
comparison_strategy="occurrence",
|
|
400
|
+
wake_eligible=True,
|
|
401
|
+
query_visibility="always",
|
|
402
|
+
),
|
|
403
|
+
),
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
AUDIO_ROUTE = SignalDefinition(
|
|
408
|
+
key="audio_route",
|
|
409
|
+
label="声音从哪个设备出",
|
|
410
|
+
schema_version=1,
|
|
411
|
+
capability="audio_route",
|
|
412
|
+
storage_mode="current_short_timeline",
|
|
413
|
+
current_ttl_sec=600.0,
|
|
414
|
+
identity_strategy="deterministic_digest",
|
|
415
|
+
# 这几个信号发的是**时间点快照**,没有 start_at / end_at ——
|
|
416
|
+
# 时长是聚合层从相邻两条观测的时间差里算出来的,不是观测自带的。
|
|
417
|
+
# 先前这里声明的是 split_at_midnight,于是管线每条都去找一个不存在的
|
|
418
|
+
# 区间、警告、再退回按 occurred_at 归属,**结果和 instant 一模一样**。
|
|
419
|
+
# 声明成实际发生的样子,行为逐字节不变,只是不再骗读它的人。
|
|
420
|
+
# 跨午夜的时长该怎么摊,是**聚合层**的问题(见 FEATURE_LOG 的待办),
|
|
421
|
+
# 不该伪装成一个归属策略。
|
|
422
|
+
attribution_strategy="instant",
|
|
423
|
+
history_retention_days=7,
|
|
424
|
+
note=(
|
|
425
|
+
"当场景线索用:连车机大概率在开车、戴 AirPods 可能在通勤或想专注。"
|
|
426
|
+
"它也是蓝牙锚点唯一能拿到的残片 —— iOS 不给第三方 app 看系统级蓝牙连接,"
|
|
427
|
+
"只有音频输出设备这一个子集。"
|
|
428
|
+
),
|
|
429
|
+
fields=(
|
|
430
|
+
FieldDefinition(
|
|
431
|
+
key="output_type",
|
|
432
|
+
value_type="enum",
|
|
433
|
+
privacy_class="personal",
|
|
434
|
+
nullable=False,
|
|
435
|
+
enum=("builtin", "headphones", "car_audio",
|
|
436
|
+
"bluetooth_a2dp", "bluetooth_hfp", "bluetooth_le", "other"),
|
|
437
|
+
comparison_strategy="state_change",
|
|
438
|
+
aggregation_strategy="duration_by_state",
|
|
439
|
+
wake_eligible=True,
|
|
440
|
+
query_visibility="always",
|
|
441
|
+
),
|
|
442
|
+
FieldDefinition(
|
|
443
|
+
key="is_bluetooth",
|
|
444
|
+
value_type="boolean",
|
|
445
|
+
privacy_class="personal",
|
|
446
|
+
comparison_strategy="state_change",
|
|
447
|
+
query_visibility="always",
|
|
448
|
+
),
|
|
449
|
+
FieldDefinition(
|
|
450
|
+
key="device_label",
|
|
451
|
+
value_type="string",
|
|
452
|
+
privacy_class="personal",
|
|
453
|
+
comparison_strategy="none",
|
|
454
|
+
query_visibility="on_demand",
|
|
455
|
+
),
|
|
456
|
+
),
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
WEATHER = SignalDefinition(
|
|
461
|
+
key="weather",
|
|
462
|
+
label="天气",
|
|
463
|
+
schema_version=1,
|
|
464
|
+
capability="weather",
|
|
465
|
+
storage_mode="current_short_timeline",
|
|
466
|
+
current_ttl_sec=1800.0,
|
|
467
|
+
identity_strategy="deterministic_digest",
|
|
468
|
+
attribution_strategy="instant",
|
|
469
|
+
history_retention_days=7,
|
|
470
|
+
note=(
|
|
471
|
+
"天气是【预报】不是测量 —— valid_at 说明这份数据什么时候有效,"
|
|
472
|
+
"和「我们什么时候收到的」是两件事。"
|
|
473
|
+
),
|
|
474
|
+
fields=(
|
|
475
|
+
FieldDefinition(
|
|
476
|
+
key="condition",
|
|
477
|
+
value_type="string",
|
|
478
|
+
privacy_class="public",
|
|
479
|
+
comparison_strategy="exact",
|
|
480
|
+
query_visibility="always",
|
|
481
|
+
),
|
|
482
|
+
FieldDefinition(
|
|
483
|
+
key="temperature_c",
|
|
484
|
+
value_type="number",
|
|
485
|
+
unit="celsius",
|
|
486
|
+
privacy_class="public",
|
|
487
|
+
valid_range=(-90.0, 60.0),
|
|
488
|
+
comparison_strategy="numeric_delta",
|
|
489
|
+
aggregation_strategy="numeric_dist",
|
|
490
|
+
trend_model="fluctuating",
|
|
491
|
+
query_visibility="always",
|
|
492
|
+
),
|
|
493
|
+
FieldDefinition(
|
|
494
|
+
key="apparent_temperature_c",
|
|
495
|
+
value_type="number", unit="celsius", privacy_class="public",
|
|
496
|
+
valid_range=(-90.0, 70.0), query_visibility="always",
|
|
497
|
+
),
|
|
498
|
+
FieldDefinition(
|
|
499
|
+
key="humidity_ratio",
|
|
500
|
+
value_type="number", unit="ratio", privacy_class="public",
|
|
501
|
+
valid_range=(0.0, 1.0), query_visibility="always",
|
|
502
|
+
),
|
|
503
|
+
FieldDefinition(
|
|
504
|
+
key="precipitation_probability",
|
|
505
|
+
value_type="number", unit="ratio", privacy_class="public",
|
|
506
|
+
valid_range=(0.0, 1.0), query_visibility="always",
|
|
507
|
+
),
|
|
508
|
+
FieldDefinition(
|
|
509
|
+
key="uv_index",
|
|
510
|
+
value_type="number", unit="index", privacy_class="public",
|
|
511
|
+
valid_range=(0.0, 20.0), query_visibility="always",
|
|
512
|
+
),
|
|
513
|
+
FieldDefinition(
|
|
514
|
+
key="is_daylight",
|
|
515
|
+
value_type="boolean", privacy_class="public",
|
|
516
|
+
comparison_strategy="state_change", query_visibility="always",
|
|
517
|
+
),
|
|
518
|
+
FieldDefinition(
|
|
519
|
+
key="alerts",
|
|
520
|
+
value_type="array", privacy_class="public",
|
|
521
|
+
comparison_strategy="exact", wake_eligible=True,
|
|
522
|
+
query_visibility="always",
|
|
523
|
+
),
|
|
524
|
+
FieldDefinition(
|
|
525
|
+
# 这份预报什么时候有效 —— 和"我们什么时候收到的"不是一回事。
|
|
526
|
+
key="valid_at",
|
|
527
|
+
value_type="timestamp", privacy_class="public",
|
|
528
|
+
query_visibility="always",
|
|
529
|
+
),
|
|
530
|
+
FieldDefinition(
|
|
531
|
+
key="location_scope",
|
|
532
|
+
value_type="string", privacy_class="personal",
|
|
533
|
+
query_visibility="always",
|
|
534
|
+
),
|
|
535
|
+
),
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
# ---------------------------------------------------------------------------
|
|
540
|
+
# §5.3「行为、应用与媒体」逐条对完之后加进来的
|
|
541
|
+
# ---------------------------------------------------------------------------
|
|
542
|
+
|
|
543
|
+
MOTION_STATE = SignalDefinition(
|
|
544
|
+
key="motion_state",
|
|
545
|
+
label="活动状态",
|
|
546
|
+
schema_version=1,
|
|
547
|
+
capability="motion",
|
|
548
|
+
storage_mode="current_timeline_aggregate",
|
|
549
|
+
current_ttl_sec=900.0,
|
|
550
|
+
identity_strategy="deterministic_digest",
|
|
551
|
+
# 同上:时间点快照,时长由聚合层从相邻观测算。
|
|
552
|
+
attribution_strategy="instant",
|
|
553
|
+
# 产品规范给的是"永久"。改成明细 1 年 + 聚合永久 —— 明细是聚合的 60 倍体量,
|
|
554
|
+
# 但能答的问题正好反过来:明细答「上周三下午」时间越久越没人问,
|
|
555
|
+
# 聚合答「今年比去年」时间越久越值钱。(hx 2026-08-28)
|
|
556
|
+
history_retention_days=365,
|
|
557
|
+
# 同 focus_state:明细 1 年、聚合永久。
|
|
558
|
+
aggregate_retention_days=PERMANENT,
|
|
559
|
+
note=(
|
|
560
|
+
"保留期偏离规范:明细 1 年(规范给「永久」),聚合仍然永久。"
|
|
561
|
+
"TTL 也偏离(规范 300s → 900s):后台保活上报间隔正好是 300s,"
|
|
562
|
+
"TTL 等于上报间隔意味着用户不在前台时这个值几乎永远是 stale。"
|
|
563
|
+
"另外:同一状态重复上报只刷新当前值、不写明细 —— 否则用户开着专注模式"
|
|
564
|
+
"工作四小时会在历史里留下 48 条一模一样的记录。"
|
|
565
|
+
),
|
|
566
|
+
fields=(
|
|
567
|
+
FieldDefinition(
|
|
568
|
+
key="state",
|
|
569
|
+
value_type="enum",
|
|
570
|
+
privacy_class="personal",
|
|
571
|
+
nullable=False,
|
|
572
|
+
enum=("stationary", "walking", "running", "cycling",
|
|
573
|
+
"automotive", "unknown"),
|
|
574
|
+
comparison_strategy="state_change",
|
|
575
|
+
aggregation_strategy="duration_by_state",
|
|
576
|
+
wake_eligible=False,
|
|
577
|
+
query_visibility="always",
|
|
578
|
+
),
|
|
579
|
+
FieldDefinition(
|
|
580
|
+
# Core Motion 本来就给置信度。低置信度的「可能在跑步」不该当事实用 ——
|
|
581
|
+
# 带上它,调用方才能自己决定信不信。
|
|
582
|
+
key="confidence",
|
|
583
|
+
value_type="number",
|
|
584
|
+
unit="ratio",
|
|
585
|
+
privacy_class="public",
|
|
586
|
+
valid_range=(0.0, 1.0),
|
|
587
|
+
query_visibility="always",
|
|
588
|
+
),
|
|
589
|
+
),
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
PHOTO_LIBRARY_ADDED = SignalDefinition(
|
|
594
|
+
key="photo_library_added",
|
|
595
|
+
label="相册新增照片",
|
|
596
|
+
schema_version=1,
|
|
597
|
+
capability="photos",
|
|
598
|
+
storage_mode="current_timeline_aggregate",
|
|
599
|
+
current_ttl_sec=0.0, # 「最近一次新增」,不按普通 TTL 失效
|
|
600
|
+
identity_strategy="source_event_id",
|
|
601
|
+
attribution_strategy="instant",
|
|
602
|
+
history_retention_days=7,
|
|
603
|
+
source_profile="device_occurrence",
|
|
604
|
+
note=(
|
|
605
|
+
"一张照片一条 count=1,不是「今天 5 张」报一次 —— 拆成一条条才能让照片"
|
|
606
|
+
"走通用管线:跨午夜天然各归各的日、某条字段有问题只拒那一条。"
|
|
607
|
+
"传输上仍然可以一个信封装多条,不多发请求。\n"
|
|
608
|
+
"🔴 删照片【不回减】过去某日的数量:它记的是「那天发生过什么」,"
|
|
609
|
+
"不是「现在还剩几张」。\n"
|
|
610
|
+
"🔴 前提未满足:iOS 现在上报的是随机 id,同一张照片两次上报算出两个不同"
|
|
611
|
+
"身份 —— 去重表再完美也挡不住。要 iOS 改成用 PHAsset.localIdentifier 的"
|
|
612
|
+
"端上哈希(和 wifi_anchor_id 同一套做法)。已列入 iOS 待办。\n"
|
|
613
|
+
"去重指纹的保留期:规范建议永久;我们查下来当前实现【找不到超过 7 天的"
|
|
614
|
+
"重放路径】,所以按「覆盖明细保留期 + 富余」取 30 天更实在。"
|
|
615
|
+
"规范自己也是条件句:「若 producer 可以在超过 7 天后重放,才必须永久保留」。"
|
|
616
|
+
),
|
|
617
|
+
fields=(
|
|
618
|
+
FieldDefinition(
|
|
619
|
+
key="count",
|
|
620
|
+
value_type="integer",
|
|
621
|
+
unit="count",
|
|
622
|
+
privacy_class="personal",
|
|
623
|
+
nullable=False,
|
|
624
|
+
valid_range=(1, 1), # 永远是 1 —— 一张照片一条
|
|
625
|
+
aggregation_strategy="daily_total",
|
|
626
|
+
comparison_strategy="occurrence",
|
|
627
|
+
trend_model="fluctuating",
|
|
628
|
+
wake_eligible=True,
|
|
629
|
+
query_visibility="on_demand",
|
|
630
|
+
),
|
|
631
|
+
FieldDefinition(
|
|
632
|
+
key="added_at",
|
|
633
|
+
value_type="timestamp",
|
|
634
|
+
privacy_class="personal",
|
|
635
|
+
nullable=False,
|
|
636
|
+
query_visibility="on_demand",
|
|
637
|
+
),
|
|
638
|
+
),
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
# ---------------------------------------------------------------------------
|
|
643
|
+
# §5.5 Health 与长期趋势
|
|
644
|
+
#
|
|
645
|
+
# 保留期一律「样本与聚合都永久」(照产品规范)—— 和 Focus/Motion/Music 不一样:
|
|
646
|
+
# 健康数据的【明细本身】就有长期价值("我三年前的静息心率是多少"真的有人问),
|
|
647
|
+
# 而且量小得多,一天几十条不是几百条。(hx 2026-08-28)
|
|
648
|
+
#
|
|
649
|
+
# 三道防线一起用(hx 2026-08-28):换算 → 值域 → 跳变。
|
|
650
|
+
# 单独任何一道都挡不住"设备把单位标错":70 kg 标成 lb,换算完 31.8 kg,
|
|
651
|
+
# 值域完全合法,只有"一次掉了 55%"这条能看出不对。
|
|
652
|
+
# ---------------------------------------------------------------------------
|
|
653
|
+
|
|
654
|
+
HEALTH_SLEEP = SignalDefinition(
|
|
655
|
+
key="health_sleep",
|
|
656
|
+
label="睡眠",
|
|
657
|
+
schema_version=1,
|
|
658
|
+
capability="health_sleep",
|
|
659
|
+
storage_mode="current_timeline_aggregate",
|
|
660
|
+
current_ttl_sec=86400.0,
|
|
661
|
+
identity_strategy="source_event_id",
|
|
662
|
+
# 整段归【结束】那天:8月27日 23:40 睡、28日 07:20 醒 → 全算 28 日。
|
|
663
|
+
# 和人说话的方式一致 —— 28 号早上你说"我昨晚睡了七个半小时"。
|
|
664
|
+
attribution_strategy="episode_end",
|
|
665
|
+
history_retention_days=PERMANENT,
|
|
666
|
+
source_profile="health_sample",
|
|
667
|
+
fields=(
|
|
668
|
+
FieldDefinition(
|
|
669
|
+
key="stage", value_type="enum", privacy_class="sensitive", nullable=False,
|
|
670
|
+
enum=("awake", "core", "deep", "rem", "asleep", "unknown"),
|
|
671
|
+
aggregation_strategy="duration_by_state",
|
|
672
|
+
comparison_strategy="state_change", query_visibility="on_demand",
|
|
673
|
+
),
|
|
674
|
+
FieldDefinition(
|
|
675
|
+
key="duration_minutes", value_type="number", unit="minutes",
|
|
676
|
+
privacy_class="sensitive", valid_range=(0.0, 1440.0),
|
|
677
|
+
aggregation_strategy="daily_total", trend_model="fluctuating",
|
|
678
|
+
comparison_strategy="threshold_crossing",
|
|
679
|
+
max_relative_jump=None, # 睡眠时长天天不同,跳变检查没有意义
|
|
680
|
+
wake_eligible=True, query_visibility="on_demand",
|
|
681
|
+
),
|
|
682
|
+
FieldDefinition(key="start_at", value_type="timestamp",
|
|
683
|
+
privacy_class="sensitive", query_visibility="on_demand"),
|
|
684
|
+
FieldDefinition(key="end_at", value_type="timestamp",
|
|
685
|
+
privacy_class="sensitive", query_visibility="on_demand"),
|
|
686
|
+
),
|
|
687
|
+
)
|
|
688
|
+
|
|
689
|
+
HEALTH_WORKOUT = SignalDefinition(
|
|
690
|
+
key="health_workout", label="运动", schema_version=1,
|
|
691
|
+
capability="health_workout", storage_mode="current_timeline_aggregate",
|
|
692
|
+
current_ttl_sec=86400.0, identity_strategy="source_event_id",
|
|
693
|
+
attribution_strategy="episode_end", history_retention_days=PERMANENT,
|
|
694
|
+
source_profile="health_sample",
|
|
695
|
+
fields=(
|
|
696
|
+
FieldDefinition(
|
|
697
|
+
key="workout_type", value_type="string", privacy_class="sensitive",
|
|
698
|
+
nullable=False, aggregation_strategy="event_list",
|
|
699
|
+
comparison_strategy="occurrence", wake_eligible=True,
|
|
700
|
+
query_visibility="on_demand",
|
|
701
|
+
),
|
|
702
|
+
FieldDefinition(
|
|
703
|
+
key="duration_minutes", value_type="number", unit="minutes",
|
|
704
|
+
privacy_class="sensitive", valid_range=(0.0, 1440.0),
|
|
705
|
+
aggregation_strategy="daily_total", trend_model="fluctuating",
|
|
706
|
+
query_visibility="on_demand",
|
|
707
|
+
),
|
|
708
|
+
FieldDefinition(
|
|
709
|
+
key="active_energy_kcal", value_type="number", unit="kcal",
|
|
710
|
+
accepted_units=("kj",), privacy_class="sensitive",
|
|
711
|
+
valid_range=(0.0, 20000.0), query_visibility="on_demand",
|
|
712
|
+
),
|
|
713
|
+
FieldDefinition(
|
|
714
|
+
key="distance_m", value_type="number", unit="m",
|
|
715
|
+
accepted_units=("km", "mi"), privacy_class="sensitive",
|
|
716
|
+
valid_range=(0.0, 500000.0), query_visibility="on_demand",
|
|
717
|
+
),
|
|
718
|
+
FieldDefinition(key="start_at", value_type="timestamp",
|
|
719
|
+
privacy_class="sensitive", query_visibility="on_demand"),
|
|
720
|
+
FieldDefinition(key="end_at", value_type="timestamp",
|
|
721
|
+
privacy_class="sensitive", query_visibility="on_demand"),
|
|
722
|
+
),
|
|
723
|
+
)
|
|
724
|
+
|
|
725
|
+
HEALTH_VITALS = SignalDefinition(
|
|
726
|
+
key="health_vitals", label="生命体征", schema_version=1,
|
|
727
|
+
capability="health_vitals", storage_mode="current_timeline_aggregate",
|
|
728
|
+
current_ttl_sec=3600.0, identity_strategy="source_event_id",
|
|
729
|
+
attribution_strategy="instant", history_retention_days=PERMANENT,
|
|
730
|
+
source_profile="health_sample",
|
|
731
|
+
note=(
|
|
732
|
+
"⚠️ 建模方式和规范不同。规范用 metric + value + unit(一条观测一个指标),"
|
|
733
|
+
"那需要「同一信号下多条并列当前值」的支持 —— 这个能力我们还没有"
|
|
734
|
+
"(已记为已知缺口)。这里先按【每个指标一个字段】建模,和宿主现状一致,"
|
|
735
|
+
"今天就能跑。等多维当前值做出来再切回规范的形态。"
|
|
736
|
+
),
|
|
737
|
+
fields=(
|
|
738
|
+
FieldDefinition(
|
|
739
|
+
key="resting_heart_rate", value_type="number", unit="bpm",
|
|
740
|
+
privacy_class="sensitive", valid_range=(20.0, 200.0),
|
|
741
|
+
aggregation_strategy="numeric_dist", trend_model="fluctuating",
|
|
742
|
+
comparison_strategy="numeric_delta", max_relative_jump=0.5,
|
|
743
|
+
wake_eligible=True, query_visibility="on_demand",
|
|
744
|
+
),
|
|
745
|
+
FieldDefinition(
|
|
746
|
+
key="current_heart_rate", value_type="number", unit="bpm",
|
|
747
|
+
privacy_class="sensitive", valid_range=(20.0, 250.0),
|
|
748
|
+
query_visibility="on_demand",
|
|
749
|
+
),
|
|
750
|
+
FieldDefinition(
|
|
751
|
+
key="hrv_sdnn_ms", value_type="number", unit="ms",
|
|
752
|
+
privacy_class="sensitive", valid_range=(0.0, 500.0),
|
|
753
|
+
aggregation_strategy="numeric_dist", trend_model="fluctuating",
|
|
754
|
+
query_visibility="on_demand",
|
|
755
|
+
),
|
|
756
|
+
FieldDefinition(
|
|
757
|
+
key="respiratory_rate", value_type="number", unit="breaths_per_minute",
|
|
758
|
+
privacy_class="sensitive", valid_range=(4.0, 60.0),
|
|
759
|
+
query_visibility="on_demand",
|
|
760
|
+
),
|
|
761
|
+
FieldDefinition(
|
|
762
|
+
key="oxygen_saturation_pct", value_type="number", unit="percent",
|
|
763
|
+
privacy_class="sensitive", valid_range=(50.0, 100.0),
|
|
764
|
+
query_visibility="on_demand",
|
|
765
|
+
),
|
|
766
|
+
FieldDefinition(
|
|
767
|
+
key="vo2_max", value_type="number", unit="ml_kg_min",
|
|
768
|
+
privacy_class="sensitive", valid_range=(5.0, 100.0),
|
|
769
|
+
aggregation_strategy="main_of_day", trend_model="drifting",
|
|
770
|
+
query_visibility="on_demand",
|
|
771
|
+
),
|
|
772
|
+
),
|
|
773
|
+
)
|
|
774
|
+
|
|
775
|
+
HEALTH_ACTIVITY = SignalDefinition(
|
|
776
|
+
key="health_activity", label="今日活动量", schema_version=1,
|
|
777
|
+
capability="health_activity", storage_mode="current_timeline_aggregate",
|
|
778
|
+
current_ttl_sec=3600.0, identity_strategy="source_event_id",
|
|
779
|
+
# 上游直接给本地日期。和 steps 一样是日内单调累加的量。
|
|
780
|
+
attribution_strategy="source_local_date", history_retention_days=PERMANENT,
|
|
781
|
+
source_profile="health_sample",
|
|
782
|
+
note=(
|
|
783
|
+
"和 steps 同一种形态:日内单调累加,当天代表值取【最大值】不是求和。"
|
|
784
|
+
"取最大值天然不怕跨天回退 —— 00:01 的新一天读数会归到新的一天,"
|
|
785
|
+
"不会和昨天的数字相减产生负增量。"
|
|
786
|
+
),
|
|
787
|
+
fields=(
|
|
788
|
+
FieldDefinition(
|
|
789
|
+
key="active_energy_kcal", value_type="number", unit="kcal",
|
|
790
|
+
accepted_units=("kj",), privacy_class="sensitive",
|
|
791
|
+
valid_range=(0.0, 20000.0), aggregation_strategy="daily_total",
|
|
792
|
+
trend_model="fluctuating", comparison_strategy="threshold_crossing",
|
|
793
|
+
wake_eligible=True, query_visibility="on_demand",
|
|
794
|
+
),
|
|
795
|
+
FieldDefinition(
|
|
796
|
+
key="exercise_minutes", value_type="number", unit="minutes",
|
|
797
|
+
privacy_class="sensitive", valid_range=(0.0, 1440.0),
|
|
798
|
+
aggregation_strategy="daily_total", trend_model="fluctuating",
|
|
799
|
+
comparison_strategy="threshold_crossing", wake_eligible=True,
|
|
800
|
+
query_visibility="on_demand",
|
|
801
|
+
),
|
|
802
|
+
FieldDefinition(
|
|
803
|
+
key="stand_minutes", value_type="number", unit="minutes",
|
|
804
|
+
privacy_class="sensitive", valid_range=(0.0, 1440.0),
|
|
805
|
+
aggregation_strategy="daily_total", trend_model="fluctuating",
|
|
806
|
+
query_visibility="on_demand",
|
|
807
|
+
),
|
|
808
|
+
FieldDefinition(
|
|
809
|
+
key="mindful_minutes", value_type="number", unit="minutes",
|
|
810
|
+
privacy_class="sensitive", valid_range=(0.0, 1440.0),
|
|
811
|
+
aggregation_strategy="daily_total", trend_model="fluctuating",
|
|
812
|
+
query_visibility="on_demand",
|
|
813
|
+
),
|
|
814
|
+
),
|
|
815
|
+
)
|
|
816
|
+
|
|
817
|
+
HEALTH_BODY = SignalDefinition(
|
|
818
|
+
key="health_body", label="身体测量", schema_version=1,
|
|
819
|
+
capability="health_body", storage_mode="current_timeline_aggregate",
|
|
820
|
+
current_ttl_sec=86400.0, identity_strategy="source_event_id",
|
|
821
|
+
attribution_strategy="instant", history_retention_days=PERMANENT,
|
|
822
|
+
source_profile="health_sample",
|
|
823
|
+
note=(
|
|
824
|
+
"🔴 这一组是「用户改数据」最常发生的地方(体重录错、手动补录)——"
|
|
825
|
+
"修订机制主要为它们服务。也是单位最容易标错的一组(kg / lb),"
|
|
826
|
+
"所以 max_relative_jump 卡得比别的紧:70 kg 被标成 lb,"
|
|
827
|
+
"换算完 31.8 kg 值域完全合法,只有「一次掉 55%」能看出不对。"
|
|
828
|
+
),
|
|
829
|
+
fields=(
|
|
830
|
+
FieldDefinition(
|
|
831
|
+
key="weight_kg", value_type="number", unit="kg",
|
|
832
|
+
accepted_units=("lb", "g"), privacy_class="sensitive",
|
|
833
|
+
valid_range=(2.0, 500.0), aggregation_strategy="main_of_day",
|
|
834
|
+
trend_model="drifting", comparison_strategy="numeric_delta",
|
|
835
|
+
max_relative_jump=0.2, wake_eligible=True,
|
|
836
|
+
query_visibility="on_demand",
|
|
837
|
+
),
|
|
838
|
+
FieldDefinition(
|
|
839
|
+
key="bmi", value_type="number", unit="kg_m2",
|
|
840
|
+
privacy_class="sensitive", valid_range=(5.0, 100.0),
|
|
841
|
+
aggregation_strategy="main_of_day", trend_model="drifting",
|
|
842
|
+
max_relative_jump=0.2, query_visibility="on_demand",
|
|
843
|
+
),
|
|
844
|
+
FieldDefinition(
|
|
845
|
+
key="body_fat_ratio", value_type="number", unit="ratio",
|
|
846
|
+
privacy_class="sensitive", valid_range=(0.01, 0.75),
|
|
847
|
+
aggregation_strategy="main_of_day", trend_model="drifting",
|
|
848
|
+
max_relative_jump=0.3, query_visibility="on_demand",
|
|
849
|
+
),
|
|
850
|
+
FieldDefinition(
|
|
851
|
+
key="height_cm", value_type="number", unit="cm",
|
|
852
|
+
accepted_units=("in", "m", "ft"), privacy_class="sensitive",
|
|
853
|
+
valid_range=(30.0, 280.0), max_relative_jump=0.1,
|
|
854
|
+
query_visibility="on_demand",
|
|
855
|
+
),
|
|
856
|
+
),
|
|
857
|
+
)
|
|
858
|
+
|
|
859
|
+
HEALTH_METABOLIC = SignalDefinition(
|
|
860
|
+
key="health_metabolic", label="代谢点值", schema_version=1,
|
|
861
|
+
capability="health_metabolic", storage_mode="current_timeline_aggregate",
|
|
862
|
+
current_ttl_sec=86400.0, identity_strategy="source_event_id",
|
|
863
|
+
attribution_strategy="instant", history_retention_days=PERMANENT,
|
|
864
|
+
source_profile="health_sample",
|
|
865
|
+
note=(
|
|
866
|
+
"血糖本身波动就大(餐前餐后能差一倍),所以不设跳变阈值 —— "
|
|
867
|
+
"设了会天天误报。血压相对稳定,设一个宽的。"
|
|
868
|
+
),
|
|
869
|
+
fields=(
|
|
870
|
+
FieldDefinition(
|
|
871
|
+
key="blood_glucose_mmol_l", value_type="number", unit="mmol_l",
|
|
872
|
+
accepted_units=("mg_dl",), privacy_class="sensitive",
|
|
873
|
+
valid_range=(0.5, 40.0), aggregation_strategy="numeric_dist",
|
|
874
|
+
trend_model="fluctuating", comparison_strategy="threshold_crossing",
|
|
875
|
+
wake_eligible=True, query_visibility="on_demand",
|
|
876
|
+
),
|
|
877
|
+
FieldDefinition(
|
|
878
|
+
key="blood_pressure_systolic_mmhg", value_type="number", unit="mmhg",
|
|
879
|
+
privacy_class="sensitive", valid_range=(50.0, 260.0),
|
|
880
|
+
aggregation_strategy="numeric_dist", trend_model="fluctuating",
|
|
881
|
+
max_relative_jump=0.6, comparison_strategy="threshold_crossing",
|
|
882
|
+
wake_eligible=True, query_visibility="on_demand",
|
|
883
|
+
),
|
|
884
|
+
FieldDefinition(
|
|
885
|
+
key="blood_pressure_diastolic_mmhg", value_type="number", unit="mmhg",
|
|
886
|
+
privacy_class="sensitive", valid_range=(30.0, 180.0),
|
|
887
|
+
aggregation_strategy="numeric_dist", trend_model="fluctuating",
|
|
888
|
+
max_relative_jump=0.6, query_visibility="on_demand",
|
|
889
|
+
),
|
|
890
|
+
),
|
|
891
|
+
)
|
|
892
|
+
|
|
893
|
+
HEALTH_CYCLE = SignalDefinition(
|
|
894
|
+
key="health_cycle", label="经期", schema_version=1,
|
|
895
|
+
capability="health_cycle", storage_mode="current_timeline_aggregate",
|
|
896
|
+
current_ttl_sec=86400.0, identity_strategy="source_event_id",
|
|
897
|
+
attribution_strategy="instant", history_retention_days=PERMANENT,
|
|
898
|
+
source_profile="health_sample",
|
|
899
|
+
note="周期型:看【间隔】不看数值高低 —— 「比平均晚了 4 天」才是信号。",
|
|
900
|
+
fields=(
|
|
901
|
+
FieldDefinition(
|
|
902
|
+
key="is_active_period", value_type="boolean", privacy_class="sensitive",
|
|
903
|
+
aggregation_strategy="main_of_day", comparison_strategy="state_change",
|
|
904
|
+
wake_eligible=True, query_visibility="on_demand",
|
|
905
|
+
),
|
|
906
|
+
FieldDefinition(
|
|
907
|
+
key="flow_level", value_type="enum", privacy_class="sensitive",
|
|
908
|
+
enum=("none", "light", "medium", "heavy", "unspecified"),
|
|
909
|
+
comparison_strategy="state_change", query_visibility="on_demand",
|
|
910
|
+
),
|
|
911
|
+
FieldDefinition(key="start_at", value_type="timestamp",
|
|
912
|
+
privacy_class="sensitive", query_visibility="on_demand"),
|
|
913
|
+
FieldDefinition(key="end_at", value_type="timestamp",
|
|
914
|
+
privacy_class="sensitive", query_visibility="on_demand"),
|
|
915
|
+
),
|
|
916
|
+
)
|
|
917
|
+
|
|
918
|
+
HEALTH_MOOD = SignalDefinition(
|
|
919
|
+
key="health_mood", label="心情", schema_version=1,
|
|
920
|
+
capability="health_mood", storage_mode="current_timeline_aggregate",
|
|
921
|
+
current_ttl_sec=86400.0, identity_strategy="source_event_id",
|
|
922
|
+
attribution_strategy="instant", history_retention_days=PERMANENT,
|
|
923
|
+
source_profile="health_sample",
|
|
924
|
+
note="用户自己记的,一天可能好几条 —— 所以是 event_list 不是取当天某一个值。",
|
|
925
|
+
fields=(
|
|
926
|
+
FieldDefinition(
|
|
927
|
+
key="valence", value_type="number", unit="scale_minus1_to_1",
|
|
928
|
+
privacy_class="sensitive", valid_range=(-1.0, 1.0),
|
|
929
|
+
aggregation_strategy="numeric_dist", trend_model="fluctuating",
|
|
930
|
+
comparison_strategy="numeric_delta", wake_eligible=True,
|
|
931
|
+
query_visibility="on_demand",
|
|
932
|
+
),
|
|
933
|
+
FieldDefinition(
|
|
934
|
+
key="valence_classification", value_type="string",
|
|
935
|
+
privacy_class="sensitive", query_visibility="on_demand",
|
|
936
|
+
),
|
|
937
|
+
FieldDefinition(
|
|
938
|
+
key="kind", value_type="string", privacy_class="sensitive",
|
|
939
|
+
query_visibility="on_demand",
|
|
940
|
+
),
|
|
941
|
+
FieldDefinition(
|
|
942
|
+
key="labels", value_type="array", privacy_class="sensitive",
|
|
943
|
+
query_visibility="on_demand",
|
|
944
|
+
),
|
|
945
|
+
FieldDefinition(key="recorded_at", value_type="timestamp",
|
|
946
|
+
privacy_class="sensitive", query_visibility="on_demand"),
|
|
947
|
+
),
|
|
948
|
+
)
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
#: manifest 的全部内容。逐条过完 Seven 的文档后往里加。
|
|
952
|
+
# ---------------------------------------------------------------------------
|
|
953
|
+
# proximity_anchor —— 「此刻在哪个东西旁边」
|
|
954
|
+
# ---------------------------------------------------------------------------
|
|
955
|
+
|
|
956
|
+
PROXIMITY_ANCHOR = SignalDefinition(
|
|
957
|
+
key="proximity_anchor",
|
|
958
|
+
label="连接性锚点",
|
|
959
|
+
schema_version=1,
|
|
960
|
+
capability="location",
|
|
961
|
+
# 产品规范 §3.3 和 §7.13 两处都把 Wi-Fi / 蓝牙连接归为「当前 + 短期
|
|
962
|
+
# 时间线」,没有长期聚合。先前这里写成 current_timeline_aggregate
|
|
963
|
+
# 并配了永久聚合,理由是"同 focus/motion" —— **那是我们自己的外推,
|
|
964
|
+
# 产品方没这么说,也没人拍过板**。改回他的分类,把"要不要给锚点留
|
|
965
|
+
# 永久 dwell 聚合"作为建议提出去,而不是先斩后奏。
|
|
966
|
+
storage_mode="current_short_timeline",
|
|
967
|
+
# 和 location_city 一样取 900s:它同样跟着整份快照走,
|
|
968
|
+
# 前台 30s / 后台保活 5min / 被挂起后不可控。
|
|
969
|
+
current_ttl_sec=900.0,
|
|
970
|
+
# anchor_id 本身就是稳定身份,但一次「连着」不是一个事件 ——
|
|
971
|
+
# 用 (signal, occurred_at, 值摘要) 造确定性键,重传能对上。
|
|
972
|
+
identity_strategy="deterministic_digest",
|
|
973
|
+
# 同上:时间点快照,时长由聚合层从相邻观测算。
|
|
974
|
+
attribution_strategy="instant",
|
|
975
|
+
# 产品规范 §1-15:Wi-Fi / 蓝牙连接历史保留 7 天。
|
|
976
|
+
history_retention_days=7,
|
|
977
|
+
source_profile="location",
|
|
978
|
+
note=(
|
|
979
|
+
"和 location_city 是【两个信号,不是一个字段的粗细两档】。城市回答"
|
|
980
|
+
"「在哪座城」,锚点回答「在哪个地方」—— 混进同一个字段,搬家之后"
|
|
981
|
+
"新旧两个「home」就看不出区别了(产品规范 §5.2-6 点名的场景)。\n"
|
|
982
|
+
"两处和规范不一致,都是 iOS 平台限制:\n"
|
|
983
|
+
"① anchor_type 的 bluetooth 这一档基本拿不到 —— iOS 不给第三方看"
|
|
984
|
+
"系统级蓝牙连接,只有音频输出设备这一个子集,那部分走 audio_route。\n"
|
|
985
|
+
"③ 我们建议给它加一层永久的 dwell 聚合(每天在各锚点待了多久):体量很小,"
|
|
986
|
+
"而「今年在家的时间比去年多吗」正是时间越久越值钱的那类问题。"
|
|
987
|
+
"**但规范把这个信号归为「当前 + 短期时间线」,没有长期聚合,所以现在照规范做,"
|
|
988
|
+
"这条只是建议。**\n"
|
|
989
|
+
"② connect/disconnect 边缘取决于「app 被后台唤起时还读不读得到 Wi-Fi」,"
|
|
990
|
+
"这一条正在真机实测。读不到的话 dwell 只能从相邻快照推,"
|
|
991
|
+
"精度 = 上报间隔,且用户全程在后台的那段会整块漏掉。"
|
|
992
|
+
),
|
|
993
|
+
fields=(
|
|
994
|
+
FieldDefinition(
|
|
995
|
+
key="anchor_id",
|
|
996
|
+
value_type="string",
|
|
997
|
+
privacy_class="personal",
|
|
998
|
+
nullable=False,
|
|
999
|
+
comparison_strategy="exact",
|
|
1000
|
+
wake_eligible=True,
|
|
1001
|
+
query_visibility="always",
|
|
1002
|
+
note=(
|
|
1003
|
+
"端上算的 HMAC 假名(iOS 拿 BSSID 算,截断 64bit),"
|
|
1004
|
+
"稳定但不可逆 —— 后端能认出「又是这个网络」,还原不出 BSSID。"
|
|
1005
|
+
"重装 app 会换一批密钥,锚点重新学,这是可接受的代价。"
|
|
1006
|
+
),
|
|
1007
|
+
),
|
|
1008
|
+
FieldDefinition(
|
|
1009
|
+
key="anchor_type",
|
|
1010
|
+
value_type="enum",
|
|
1011
|
+
privacy_class="personal",
|
|
1012
|
+
nullable=False,
|
|
1013
|
+
enum=("wifi", "bluetooth"),
|
|
1014
|
+
comparison_strategy="exact",
|
|
1015
|
+
query_visibility="always",
|
|
1016
|
+
note="bluetooth 这一档 iOS 基本给不了,见信号级 note。",
|
|
1017
|
+
),
|
|
1018
|
+
FieldDefinition(
|
|
1019
|
+
key="label",
|
|
1020
|
+
value_type="string",
|
|
1021
|
+
privacy_class="personal",
|
|
1022
|
+
comparison_strategy="none",
|
|
1023
|
+
query_visibility="always",
|
|
1024
|
+
note=(
|
|
1025
|
+
"用户给这个锚点起的名字。**可以改名,改名不影响 anchor_id** ——"
|
|
1026
|
+
"身份和标签分开正是规范 §5.2-4 要的。"
|
|
1027
|
+
),
|
|
1028
|
+
),
|
|
1029
|
+
FieldDefinition(
|
|
1030
|
+
key="is_connected",
|
|
1031
|
+
value_type="boolean",
|
|
1032
|
+
privacy_class="personal",
|
|
1033
|
+
nullable=False,
|
|
1034
|
+
comparison_strategy="state_change",
|
|
1035
|
+
aggregation_strategy="duration_by_state",
|
|
1036
|
+
wake_eligible=True,
|
|
1037
|
+
query_visibility="always",
|
|
1038
|
+
),
|
|
1039
|
+
FieldDefinition(
|
|
1040
|
+
# 和 location_city.coordinate 同一个道理:声明出来,是为了让
|
|
1041
|
+
# 「永不持久化、永不给 agent」成为一条可被测试检查的规则。
|
|
1042
|
+
key="raw_identifier",
|
|
1043
|
+
value_type="string",
|
|
1044
|
+
privacy_class="restricted",
|
|
1045
|
+
query_visibility="never",
|
|
1046
|
+
note="原始 BSSID / 设备地址。端上算完假名就丢弃,永远不该出设备。",
|
|
1047
|
+
),
|
|
1048
|
+
),
|
|
1049
|
+
)
|
|
1050
|
+
|
|
1051
|
+
|
|
1052
|
+
# ---------------------------------------------------------------------------
|
|
1053
|
+
# music_playback —— 在听什么
|
|
1054
|
+
# ---------------------------------------------------------------------------
|
|
1055
|
+
|
|
1056
|
+
MUSIC_PLAYBACK = SignalDefinition(
|
|
1057
|
+
key="music_playback",
|
|
1058
|
+
label="正在播放",
|
|
1059
|
+
schema_version=1,
|
|
1060
|
+
capability="now_playing",
|
|
1061
|
+
storage_mode="current_timeline_aggregate",
|
|
1062
|
+
# 产品规范给 600s,照做。音乐这个信号的采集节奏和 focus/motion 不一样 ——
|
|
1063
|
+
# 系统播放器是事件驱动的,切歌 2 秒后就有新值,不靠保活轮询兜底。
|
|
1064
|
+
current_ttl_sec=600.0,
|
|
1065
|
+
identity_strategy="deterministic_digest",
|
|
1066
|
+
# 同上:时间点快照,时长由聚合层从相邻观测算。
|
|
1067
|
+
attribution_strategy="instant",
|
|
1068
|
+
# 明细 1 年、聚合永久(hx 2026-08-28,同 focus/motion 那条决定)。
|
|
1069
|
+
history_retention_days=365,
|
|
1070
|
+
aggregate_retention_days=PERMANENT,
|
|
1071
|
+
note=(
|
|
1072
|
+
"两处和产品规范不同,都是 iOS 平台限制:\n"
|
|
1073
|
+
"① **没有 track_id**。Apple 是给歌曲持久化 ID 的,但 iOS 侧当初为隐私"
|
|
1074
|
+
"主动砍掉了 —— 那个 ID 能反查用户整个曲库。我们用 (title, artist) 的"
|
|
1075
|
+
"哈希当稳定身份,代价是同名同歌手的两首(现场版 / 录音室版)会被"
|
|
1076
|
+
"当成同一首。\n"
|
|
1077
|
+
"② **播放边缘只覆盖一半播放器**。iOS 订阅的是 systemMusicPlayer,"
|
|
1078
|
+
"也就是 Apple Music / 系统播放器:切歌 2 秒后就上报,起止时刻是准的。"
|
|
1079
|
+
"Spotify、网易云用自己的播放器,不发这些通知,只能靠快照采到的那几个点。"
|
|
1080
|
+
"所以派生 session 的 quality **不能一刀切成 estimated** —— 规范 §5.3 "
|
|
1081
|
+
"写的是「只有轮询样本就标 estimated」,实际是同一个信号两种精度并存,"
|
|
1082
|
+
"一律标 estimated 会把本来准确的那一半信息丢掉。"
|
|
1083
|
+
),
|
|
1084
|
+
fields=(
|
|
1085
|
+
FieldDefinition(
|
|
1086
|
+
key="track_key",
|
|
1087
|
+
value_type="string",
|
|
1088
|
+
privacy_class="sensitive",
|
|
1089
|
+
nullable=False,
|
|
1090
|
+
comparison_strategy="exact",
|
|
1091
|
+
wake_eligible=True,
|
|
1092
|
+
query_visibility="on_demand",
|
|
1093
|
+
note=(
|
|
1094
|
+
"(title, artist) 的哈希,替代规范里的 track_id。"
|
|
1095
|
+
"**不是 Apple 的持久化 ID** —— 那个能反查曲库,iOS 侧不给。"
|
|
1096
|
+
),
|
|
1097
|
+
),
|
|
1098
|
+
FieldDefinition(
|
|
1099
|
+
key="title",
|
|
1100
|
+
value_type="string",
|
|
1101
|
+
privacy_class="sensitive",
|
|
1102
|
+
comparison_strategy="none",
|
|
1103
|
+
query_visibility="on_demand",
|
|
1104
|
+
),
|
|
1105
|
+
FieldDefinition(
|
|
1106
|
+
key="artist",
|
|
1107
|
+
value_type="string",
|
|
1108
|
+
privacy_class="sensitive",
|
|
1109
|
+
comparison_strategy="none",
|
|
1110
|
+
query_visibility="on_demand",
|
|
1111
|
+
),
|
|
1112
|
+
FieldDefinition(
|
|
1113
|
+
key="album",
|
|
1114
|
+
value_type="string",
|
|
1115
|
+
privacy_class="sensitive",
|
|
1116
|
+
comparison_strategy="none",
|
|
1117
|
+
query_visibility="on_demand",
|
|
1118
|
+
),
|
|
1119
|
+
FieldDefinition(
|
|
1120
|
+
key="playback_state",
|
|
1121
|
+
value_type="enum",
|
|
1122
|
+
privacy_class="sensitive",
|
|
1123
|
+
nullable=False,
|
|
1124
|
+
enum=("playing", "paused", "stopped"),
|
|
1125
|
+
comparison_strategy="state_change",
|
|
1126
|
+
aggregation_strategy="duration_by_state",
|
|
1127
|
+
wake_eligible=True,
|
|
1128
|
+
query_visibility="on_demand",
|
|
1129
|
+
),
|
|
1130
|
+
FieldDefinition(
|
|
1131
|
+
key="position_seconds",
|
|
1132
|
+
value_type="number",
|
|
1133
|
+
unit="s",
|
|
1134
|
+
privacy_class="sensitive",
|
|
1135
|
+
valid_range=(0, None),
|
|
1136
|
+
comparison_strategy="none",
|
|
1137
|
+
query_visibility="on_demand",
|
|
1138
|
+
note="播放进度。不参与比较 —— 它每秒都在变,当成变化会把每次采样都算成一次事件。",
|
|
1139
|
+
),
|
|
1140
|
+
FieldDefinition(
|
|
1141
|
+
key="edge_quality",
|
|
1142
|
+
value_type="enum",
|
|
1143
|
+
privacy_class="sensitive",
|
|
1144
|
+
nullable=False,
|
|
1145
|
+
enum=("measured", "estimated"),
|
|
1146
|
+
comparison_strategy="none",
|
|
1147
|
+
query_visibility="always",
|
|
1148
|
+
note=(
|
|
1149
|
+
"这条记录的起止时刻是**真事件**还是**从相邻快照推的**。\n"
|
|
1150
|
+
"systemMusicPlayer(Apple Music)来的标 measured,"
|
|
1151
|
+
"第三方播放器只能靠快照采到,标 estimated。\n"
|
|
1152
|
+
"**把这个写进数据本身**,是因为读到值的人不一定读过文档 —— "
|
|
1153
|
+
"同一个信号两种精度并存,不说出来就会被当成一样准。"
|
|
1154
|
+
),
|
|
1155
|
+
),
|
|
1156
|
+
),
|
|
1157
|
+
)
|
|
1158
|
+
|
|
1159
|
+
|
|
1160
|
+
# ---------------------------------------------------------------------------
|
|
1161
|
+
# app_usage —— 打开过哪些 app
|
|
1162
|
+
# ---------------------------------------------------------------------------
|
|
1163
|
+
|
|
1164
|
+
APP_USAGE = SignalDefinition(
|
|
1165
|
+
key="app_usage",
|
|
1166
|
+
label="应用使用",
|
|
1167
|
+
schema_version=1,
|
|
1168
|
+
capability="app",
|
|
1169
|
+
storage_mode="current_timeline_aggregate",
|
|
1170
|
+
current_ttl_sec=900.0,
|
|
1171
|
+
identity_strategy="source_event_id",
|
|
1172
|
+
attribution_strategy="instant",
|
|
1173
|
+
history_retention_days=PERMANENT,
|
|
1174
|
+
aggregate_retention_days=PERMANENT,
|
|
1175
|
+
source_profile="device_occurrence",
|
|
1176
|
+
note=(
|
|
1177
|
+
"⚠️ **这个信号的覆盖面天然残缺,不是实现没做好。**\n"
|
|
1178
|
+
"iOS 拿不到前台 app(`frontmost_app` 恒为 null),数据全靠用户在"
|
|
1179
|
+
"「快捷指令」里逐个 app 手动配自动化。没配的 app 在我们眼里完全不存在 ——"
|
|
1180
|
+
"用户刷了三小时小红书,只要没登记它,看上去就像一整天没用手机。\n\n"
|
|
1181
|
+
"**所以刻意不建「每天用了多久」这类时长统计。** 产品规范 §1-7 要求"
|
|
1182
|
+
"永久保存可重建的 session 和长期时长统计,但那份统计会是一份"
|
|
1183
|
+
"大概率残缺的轨迹,而 agent 基于它判断「他今天用手机多不多」"
|
|
1184
|
+
"从根上不成立 —— 错得不明显,比没有更糟。\n\n"
|
|
1185
|
+
"**做的是只靠 open 就能答、且答得准的那部分**:最近打开了什么、"
|
|
1186
|
+
"今天打开了几次。这两个问题不需要 close,所以配了 open 没配 close 的"
|
|
1187
|
+
"用户也拿得到正确答案。\n\n"
|
|
1188
|
+
"已把三个选项交给产品方(照做但标不可信 / 只做 open 侧 / 等原生能力),"
|
|
1189
|
+
"**当前实现是「只做 open 侧」**。要改回完整时长统计是加东西,不是改东西 ——"
|
|
1190
|
+
"反过来(先建了永久时长表再撤)那张表已经在收数据了。"
|
|
1191
|
+
),
|
|
1192
|
+
fields=(
|
|
1193
|
+
FieldDefinition(
|
|
1194
|
+
key="app_id",
|
|
1195
|
+
value_type="string",
|
|
1196
|
+
privacy_class="sensitive",
|
|
1197
|
+
nullable=False,
|
|
1198
|
+
comparison_strategy="exact",
|
|
1199
|
+
wake_eligible=True,
|
|
1200
|
+
query_visibility="on_demand",
|
|
1201
|
+
),
|
|
1202
|
+
FieldDefinition(
|
|
1203
|
+
key="app_name",
|
|
1204
|
+
value_type="string",
|
|
1205
|
+
privacy_class="sensitive",
|
|
1206
|
+
comparison_strategy="none",
|
|
1207
|
+
query_visibility="on_demand",
|
|
1208
|
+
),
|
|
1209
|
+
FieldDefinition(
|
|
1210
|
+
key="category",
|
|
1211
|
+
value_type="string",
|
|
1212
|
+
privacy_class="sensitive",
|
|
1213
|
+
comparison_strategy="none",
|
|
1214
|
+
query_visibility="on_demand",
|
|
1215
|
+
),
|
|
1216
|
+
FieldDefinition(
|
|
1217
|
+
key="action",
|
|
1218
|
+
value_type="enum",
|
|
1219
|
+
privacy_class="sensitive",
|
|
1220
|
+
nullable=False,
|
|
1221
|
+
enum=("open", "close"),
|
|
1222
|
+
comparison_strategy="exact",
|
|
1223
|
+
query_visibility="on_demand",
|
|
1224
|
+
note=(
|
|
1225
|
+
"`close` **收下但不据此算时长**。用户可能只配了 open 那条自动化,"
|
|
1226
|
+
"于是绝大多数 session 根本没有结束事件 —— 拿有 close 的那部分"
|
|
1227
|
+
"算平均时长,会得到一个只反映「谁配得全」的数字。"
|
|
1228
|
+
),
|
|
1229
|
+
),
|
|
1230
|
+
FieldDefinition(
|
|
1231
|
+
key="open_count",
|
|
1232
|
+
value_type="integer",
|
|
1233
|
+
unit="count",
|
|
1234
|
+
privacy_class="sensitive",
|
|
1235
|
+
valid_range=(0, None),
|
|
1236
|
+
# 每条 open 贡献 1,当天累加。**只靠 open,所以可信** ——
|
|
1237
|
+
# 这正是砍掉时长统计之后仍然答得准的那部分。
|
|
1238
|
+
aggregation_strategy="daily_total",
|
|
1239
|
+
comparison_strategy="none",
|
|
1240
|
+
query_visibility="on_demand",
|
|
1241
|
+
trend_model="fluctuating",
|
|
1242
|
+
note="「今天打开了几次」。不需要 close 就能答,所以配置不全也不影响。",
|
|
1243
|
+
),
|
|
1244
|
+
),
|
|
1245
|
+
)
|
|
1246
|
+
|
|
1247
|
+
|
|
1248
|
+
MINIMAL_SIGNALS: dict[str, SignalDefinition] = {
|
|
1249
|
+
s.key: s for s in (
|
|
1250
|
+
# 阶段二的五个代表信号
|
|
1251
|
+
BATTERY, PRESENCE_RECOVERY, STEPS, LOCATION_CITY, FOCUS_STATE,
|
|
1252
|
+
# §5.1 时间、设备与短期环境
|
|
1253
|
+
TIME_CONTEXT, BROADCAST, SCREEN_CHANGE, AUDIO_ROUTE, WEATHER,
|
|
1254
|
+
# §5.2 位置与连接性锚点
|
|
1255
|
+
PROXIMITY_ANCHOR,
|
|
1256
|
+
# §5.3 行为、应用与媒体
|
|
1257
|
+
MOTION_STATE, PHOTO_LIBRARY_ADDED, MUSIC_PLAYBACK, APP_USAGE,
|
|
1258
|
+
# §5.5 健康与长期趋势
|
|
1259
|
+
HEALTH_SLEEP, HEALTH_WORKOUT, HEALTH_VITALS, HEALTH_ACTIVITY,
|
|
1260
|
+
HEALTH_BODY, HEALTH_METABOLIC, HEALTH_CYCLE, HEALTH_MOOD,
|
|
1261
|
+
)
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
#: 明确【不做】的信号,写下来免得以后有人当成漏项。
|
|
1265
|
+
DECLINED_SIGNALS: dict[str, str] = {
|
|
1266
|
+
"network_connection": (
|
|
1267
|
+
"不做。产品规范标的是「建议/待确认」不是要求。理由:我们能收到的上报,"
|
|
1268
|
+
"必然是「有网」那一刻发出的 —— 「没网」那段永远传不到服务端,"
|
|
1269
|
+
"这个信号自证不了自己。(hx 2026-08-28)"
|
|
1270
|
+
),
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
|
|
1274
|
+
__all__ = [
|
|
1275
|
+
"BATTERY", "PRESENCE_RECOVERY", "STEPS", "LOCATION_CITY", "FOCUS_STATE",
|
|
1276
|
+
"TIME_CONTEXT", "BROADCAST", "SCREEN_CHANGE", "AUDIO_ROUTE", "WEATHER",
|
|
1277
|
+
"PROXIMITY_ANCHOR",
|
|
1278
|
+
"MOTION_STATE", "PHOTO_LIBRARY_ADDED", "MUSIC_PLAYBACK", "APP_USAGE",
|
|
1279
|
+
"HEALTH_SLEEP", "HEALTH_WORKOUT", "HEALTH_VITALS", "HEALTH_ACTIVITY",
|
|
1280
|
+
"HEALTH_BODY", "HEALTH_METABOLIC", "HEALTH_CYCLE", "HEALTH_MOOD",
|
|
1281
|
+
"MINIMAL_SIGNALS", "DECLINED_SIGNALS",
|
|
1282
|
+
]
|