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/__init__.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""perceptkit —— 主动感知内核:纯函数、零 I/O,与宿主环境无关。
|
|
2
|
+
|
|
3
|
+
这个包只做判断,不做执行:
|
|
4
|
+
|
|
5
|
+
· 有没有发生什么值得留意的事(glance,只出 bool,不泄露具体数值)
|
|
6
|
+
· 这次变化值不值得叫醒一次 agent(wake)
|
|
7
|
+
· 一条测量到底是"有观测到零值"还是"压根没测到"(observation 四态)
|
|
8
|
+
· 一条测量该记在本地日历的哪一天(attribution)
|
|
9
|
+
· "连续 N 天"怎么断续、只在跨入异常时触发一次(streaks)
|
|
10
|
+
· 按天汇总出什么趋势 —— 波动 / 漂移 / 周期性,三种判读方式(history / trend_models)
|
|
11
|
+
· agent 能看哪些字段、该不该给(fields)
|
|
12
|
+
· 该怎么把以上这些讲给模型(prompts)
|
|
13
|
+
· 每类信号的历史该留多久、一条测量的去重键怎么造(retention / identity)
|
|
14
|
+
|
|
15
|
+
**wake ≠ 该开口了。** 戳醒之后继续睡 / 只看一眼 / 开口说话是三个平行选项,
|
|
16
|
+
这个包不参与那个决定,也不产出任何"该说话了"式的措辞。
|
|
17
|
+
|
|
18
|
+
不在这里的(由调用方提供):
|
|
19
|
+
|
|
20
|
+
数据采集 · 存储 · 加解密 · 账号身份与鉴权 · 定时器 / 调度 ·
|
|
21
|
+
真正调模型 · 决定 agent 最终该说什么话
|
|
22
|
+
|
|
23
|
+
硬指标:**本包只依赖标准库**,不 import 任何宿主模块、不碰网络、不碰数据库、
|
|
24
|
+
不碰文件系统。一旦这条破了,"内核可独立发布 / 可被任意宿主嵌入"就都不成立
|
|
25
|
+
——见 ``tests/test_purity.py``(AST 扫描)与 ``tests/test_no_host_leakage.py``。
|
|
26
|
+
|
|
27
|
+
详见 ``README.md``。
|
|
28
|
+
"""
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
from . import contracts, manifest, ports, processing, rules
|
|
32
|
+
from .algorithms.attribution import attribute_episode, attribute_instant, split_across_midnight
|
|
33
|
+
from .catalog import CAPABILITIES, SIGNALS
|
|
34
|
+
from .contracts import IngestContext, Observation, PerceptionEvent, ReportEnvelope, WakeReceipt
|
|
35
|
+
from .kit import PerceptionKit
|
|
36
|
+
from .fields import AGENT_PERCEPTION_SIGNALS, project_signal
|
|
37
|
+
from .algorithms.glance import build_perception_glance
|
|
38
|
+
from .algorithms.history import is_historized
|
|
39
|
+
from .algorithms.identity import MissingIdentity, measurement_key
|
|
40
|
+
from .algorithms.observation import (
|
|
41
|
+
NO_OBSERVATION,
|
|
42
|
+
OBSERVED,
|
|
43
|
+
OBSERVED_ZERO,
|
|
44
|
+
UNAVAILABLE,
|
|
45
|
+
classify,
|
|
46
|
+
is_trend_eligible,
|
|
47
|
+
)
|
|
48
|
+
from .retention import retention_days, stores_history
|
|
49
|
+
from .algorithms.streaks import current_streak, should_trigger
|
|
50
|
+
from .algorithms.trend_models import model_for, wake_eligible
|
|
51
|
+
from .algorithms.wake import is_wake_worthy_signal, is_significant_change, should_wake
|
|
52
|
+
|
|
53
|
+
__all__ = [
|
|
54
|
+
# 接入口
|
|
55
|
+
"PerceptionKit",
|
|
56
|
+
"ReportEnvelope", "Observation", "PerceptionEvent", "WakeReceipt", "IngestContext",
|
|
57
|
+
"contracts", "manifest", "ports", "processing", "rules",
|
|
58
|
+
# 算法
|
|
59
|
+
"attribute_episode",
|
|
60
|
+
"attribute_instant",
|
|
61
|
+
"split_across_midnight",
|
|
62
|
+
"CAPABILITIES",
|
|
63
|
+
"SIGNALS",
|
|
64
|
+
"AGENT_PERCEPTION_SIGNALS",
|
|
65
|
+
"project_signal",
|
|
66
|
+
"build_perception_glance",
|
|
67
|
+
"is_historized",
|
|
68
|
+
"MissingIdentity",
|
|
69
|
+
"measurement_key",
|
|
70
|
+
"NO_OBSERVATION",
|
|
71
|
+
"OBSERVED",
|
|
72
|
+
"OBSERVED_ZERO",
|
|
73
|
+
"UNAVAILABLE",
|
|
74
|
+
"classify",
|
|
75
|
+
"is_trend_eligible",
|
|
76
|
+
"retention_days",
|
|
77
|
+
"stores_history",
|
|
78
|
+
"current_streak",
|
|
79
|
+
"should_trigger",
|
|
80
|
+
"model_for",
|
|
81
|
+
"wake_eligible",
|
|
82
|
+
"is_wake_worthy_signal",
|
|
83
|
+
"is_significant_change",
|
|
84
|
+
"should_wake",
|
|
85
|
+
]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""纯计算 —— 给定输入算出结果,不碰存储、不碰网络、不认识任何宿主。
|
|
2
|
+
|
|
3
|
+
产品规范 §18 要求这一层单独存在:「不能再把 contract、算法、存储、
|
|
4
|
+
宿主 runtime 接线混成一层」。先前这些模块散在包的顶层,和 ``kit.py``
|
|
5
|
+
(装配和接线)平级,正是那句话说的情况。
|
|
6
|
+
|
|
7
|
+
**分出来不只是好看。** 这一层是唯一可以放心大改的地方 —— 它没有副作用、
|
|
8
|
+
没有顺序依赖,一个函数改错了测试当场红,而不会在某个宿主的生产环境里
|
|
9
|
+
变成一条静默错掉的记录。
|
|
10
|
+
|
|
11
|
+
attribution 一条观测算哪一天(跨午夜、跨时区、夏令时)
|
|
12
|
+
glance 把感知事实压成一组布尔,给不需要细节的调用方
|
|
13
|
+
history 日聚合的各种形状与合并
|
|
14
|
+
identity 上游给不了稳定 id 时怎么造一个确定性的
|
|
15
|
+
observation 三态判断(测到 / 没测到 / 不能测)
|
|
16
|
+
streaks 连续 N 天
|
|
17
|
+
trend_models 三种趋势模型的数学
|
|
18
|
+
wake 值不值得戳醒(**不回答该不该开口**)
|
|
19
|
+
|
|
20
|
+
**刻意留在顶层、没搬进来的四个**:``catalog`` / ``fields`` / ``retention``
|
|
21
|
+
是声明表不是算法(而且正在被 manifest 取代),``prompts`` 归属还没定
|
|
22
|
+
(见给产品方的回复 §三)。把它们塞进 algorithms/ 只会让这个词失去意义。
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from . import ( # noqa: F401
|
|
27
|
+
attribution,
|
|
28
|
+
glance,
|
|
29
|
+
history,
|
|
30
|
+
identity,
|
|
31
|
+
observation,
|
|
32
|
+
streaks,
|
|
33
|
+
trend_models,
|
|
34
|
+
wake,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"attribution", "glance", "history", "identity",
|
|
39
|
+
"observation", "streaks", "trend_models", "wake",
|
|
40
|
+
]
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""一条测量该算哪一天。
|
|
2
|
+
|
|
3
|
+
★ 为什么需要这个模块(设计文档修订 D):标准里的 effective_time_frame 只描述
|
|
4
|
+
「事实发生于某个点或某段区间」,它不替产品决定「算哪一天」。睡眠 23:00–07:00
|
|
5
|
+
按直觉是「第二天的睡眠」,而在公司待的时长跨午夜时必须切成两天 —— 这是两条
|
|
6
|
+
不同的规则,不能一刀切。
|
|
7
|
+
|
|
8
|
+
★ 时区:一律用时间自带的 offset。缺 offset 直接报错,不猜 —— 静默按 UTC 或
|
|
9
|
+
按用户当前时区重解释,会让历史数据在用户出国时集体漂一天。
|
|
10
|
+
|
|
11
|
+
★ 零 I/O、不读时钟。
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import datetime as _dt
|
|
16
|
+
import zoneinfo as _zoneinfo
|
|
17
|
+
|
|
18
|
+
INSTANT = "instant" # 单点:按其自带 offset 的本地日期
|
|
19
|
+
EPISODE_END = "episode_end" # 区间:整体归结束(醒来)那天
|
|
20
|
+
SPLIT_AT_MIDNIGHT = "split_at_midnight" # 可加总时长:按本地午夜切分
|
|
21
|
+
SOURCE_LOCAL_DATE = "source_local_date" # 周期事件:用来源记录的本地日期,不重解释
|
|
22
|
+
|
|
23
|
+
ATTRIBUTION: dict[str, str] = {
|
|
24
|
+
"health_sleep": EPISODE_END,
|
|
25
|
+
"health_workout": EPISODE_END,
|
|
26
|
+
"health_body": INSTANT,
|
|
27
|
+
"health_vitals": INSTANT,
|
|
28
|
+
"health_metabolic": INSTANT,
|
|
29
|
+
"health_activity": INSTANT,
|
|
30
|
+
"health_mood": INSTANT,
|
|
31
|
+
"health_cycle": SOURCE_LOCAL_DATE,
|
|
32
|
+
"location_signal": SPLIT_AT_MIDNIGHT,
|
|
33
|
+
"playback": SPLIT_AT_MIDNIGHT,
|
|
34
|
+
"motion_state": SPLIT_AT_MIDNIGHT,
|
|
35
|
+
"focus": SPLIT_AT_MIDNIGHT,
|
|
36
|
+
"audio_route": SPLIT_AT_MIDNIGHT,
|
|
37
|
+
# calendar_next_event / reminders:条目自带日期(来自日历/提醒事项存储),
|
|
38
|
+
# 按用户「当前」时区重解释会在他出国时把一场会议错移到另一天 ——
|
|
39
|
+
# 正是这个模块存在的目的要防的那类漂移,所以用来源本地日期,不重算。
|
|
40
|
+
"calendar_next_event": SOURCE_LOCAL_DATE,
|
|
41
|
+
"reminders": SOURCE_LOCAL_DATE,
|
|
42
|
+
# weather 现在的 SHAPE 是 NUMERIC_DIST(history.py),是单点测量,
|
|
43
|
+
# 跟体重/心率同类 —— 归属规则是 INSTANT。
|
|
44
|
+
# ⚠️ Codex code_review 2026-08-23 抓到:早先按"weather 即将改成仅当前+预报、
|
|
45
|
+
# 不再产生 rollup"的未来态把这条声明成了"故意缺席",但 SHAPE/record_daily
|
|
46
|
+
# 从未真的改过去,导致四张声明表互相矛盾(history 说存、retention/attribution
|
|
47
|
+
# 说不存)。真要把 weather 改成不存历史时,这一行、retention.RETENTION_DAYS
|
|
48
|
+
# 里的 weather 条目、history.SHAPE 里的 weather 条目,三处必须在同一批
|
|
49
|
+
# 一起删,不许只删一处。
|
|
50
|
+
"weather": INSTANT,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _aware(raw: str) -> _dt.datetime:
|
|
55
|
+
"""解析成带 offset 的时刻。没有 offset 就报错 —— 不猜。"""
|
|
56
|
+
try:
|
|
57
|
+
parsed = _dt.datetime.fromisoformat(str(raw))
|
|
58
|
+
except (TypeError, ValueError) as exc:
|
|
59
|
+
raise ValueError(f"无法解析时间:{raw!r}") from exc
|
|
60
|
+
if parsed.tzinfo is None or parsed.utcoffset() is None:
|
|
61
|
+
raise ValueError(f"时间缺少时区 offset,拒绝按 UTC 或本机时区猜测:{raw!r}")
|
|
62
|
+
return parsed
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _utc_naive(dtobj: _dt.datetime) -> _dt.datetime:
|
|
66
|
+
"""把一个 aware datetime 精确转成"UTC 等价的裸 datetime",用于比较/相减。
|
|
67
|
+
|
|
68
|
+
见 ``split_across_midnight`` 里的坑注释:两个 aware datetime 若共享同一个
|
|
69
|
+
``tzinfo`` 对象,CPython 的 `<`/`-` 会走一条忽略 offset 变化的捷径,对
|
|
70
|
+
``ZoneInfo`` 这种 offset 会随日期变的时区算错。显式转成裸 UTC datetime
|
|
71
|
+
绕开这条捷径;用 timedelta 精确运算,不经过 float epoch,避免精度损失。
|
|
72
|
+
"""
|
|
73
|
+
return dtobj.replace(tzinfo=None) - dtobj.utcoffset()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def attribute_instant(when: str) -> str:
|
|
77
|
+
"""单点测量:按它自己那个 offset 下的本地日期。"""
|
|
78
|
+
return _aware(when).date().isoformat()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def attribute_episode(start: str, end: str) -> str:
|
|
82
|
+
"""区间事件(睡眠、一次运动):整体归结束那天。"""
|
|
83
|
+
s, e = _aware(start), _aware(end)
|
|
84
|
+
if e < s:
|
|
85
|
+
raise ValueError(f"区间结束早于开始:{start!r} -> {end!r}")
|
|
86
|
+
return e.date().isoformat()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def split_across_midnight(start: str, end: str, *, tz: str | None = None) -> list[tuple[str, float]]:
|
|
90
|
+
"""可加总的时长:按本地午夜切开,返回 ``[(本地日期, 分钟数), ...]``。
|
|
91
|
+
|
|
92
|
+
``tz``(可选):传入 IANA 时区名(如 ``"America/New_York"``)时,用
|
|
93
|
+
``zoneinfo.ZoneInfo`` 按该时区的真实换日规则计算本地午夜 —— 跨夏令时
|
|
94
|
+
切换的那一天会正确算出 23 小时(春季提前)或 25 小时(秋季回退),
|
|
95
|
+
不会被硬当成 1440 分钟。
|
|
96
|
+
|
|
97
|
+
不传 ``tz`` 时(默认):本地午夜用 ``start`` 自带的 offset 推算 —— 这是
|
|
98
|
+
一个**固定** offset,不是一整套带换日规则的时区。★ 老实说明局限:跨夏
|
|
99
|
+
令时切换的那一天,本函数并不知道当地钟表在那天真的跳了一小时,切分点
|
|
100
|
+
仍按「每天 1440 分钟」机械推进,那一天算出来的分钟数会是错的(例如秋季
|
|
101
|
+
回退的 25 小时天,仍会被切成 24×60)。要正确处理夏令时切换,必须显式
|
|
102
|
+
传入 ``tz``。
|
|
103
|
+
|
|
104
|
+
各段分钟数不做单独四舍五入 —— 调用方需要展示或落库时再自己 round,
|
|
105
|
+
否则多段各自舍入会破坏「各段之和 = 总时长」这个不变式(例如跨午夜的
|
|
106
|
+
0.08 秒会被两段各自舍成 0.001,加总 0.002,而真实值是 0.001333...)。
|
|
107
|
+
"""
|
|
108
|
+
s, e = _aware(start), _aware(end)
|
|
109
|
+
if e < s:
|
|
110
|
+
raise ValueError(f"区间结束早于开始:{start!r} -> {end!r}")
|
|
111
|
+
if e == s:
|
|
112
|
+
return []
|
|
113
|
+
|
|
114
|
+
zone = _zoneinfo.ZoneInfo(tz) if tz else None
|
|
115
|
+
if zone is not None:
|
|
116
|
+
s = s.astimezone(zone)
|
|
117
|
+
e = e.astimezone(zone)
|
|
118
|
+
|
|
119
|
+
# ★ 坑(真实调试过,别删):下面比较/相减都拿 `_utc_naive()` 转换过的值,
|
|
120
|
+
# 不直接对两个 aware datetime 做 `<` / `-`。原因是 CPython 对"两个 aware
|
|
121
|
+
# datetime 共享同一个 tzinfo 对象"有一条捷径:直接比较墙上时间、完全不
|
|
122
|
+
# 重新问 tzinfo 要 offset —— 对固定 offset 的 tzinfo 这条捷径没问题
|
|
123
|
+
# (offset 反正不变),但 ZoneInfo 的 offset 会随日期变(换季),同一个
|
|
124
|
+
# ZoneInfo 对象在夏令时切换前后 offset 不同,这条捷径会把跨切换的那一段
|
|
125
|
+
# 整整算错一小时。`_utc_naive()` 每次都显式调用 `.utcoffset()` 拿当下
|
|
126
|
+
# 那个墙上时间对应的真实 offset,不吃这条捷径;且转换本身是 timedelta
|
|
127
|
+
# 精确运算(不经过 float epoch),不会像 `.timestamp()` 那样在秒级精度
|
|
128
|
+
# 之外引入浮点误差。
|
|
129
|
+
e_utc = _utc_naive(e)
|
|
130
|
+
out: list[tuple[str, float]] = []
|
|
131
|
+
cursor = s
|
|
132
|
+
cursor_utc = _utc_naive(cursor)
|
|
133
|
+
while cursor_utc < e_utc:
|
|
134
|
+
next_midnight = _dt.datetime.combine(
|
|
135
|
+
cursor.date() + _dt.timedelta(days=1),
|
|
136
|
+
_dt.time(0, 0),
|
|
137
|
+
tzinfo=cursor.tzinfo,
|
|
138
|
+
)
|
|
139
|
+
next_midnight_utc = _utc_naive(next_midnight)
|
|
140
|
+
if next_midnight_utc < e_utc:
|
|
141
|
+
chunk_end, chunk_end_utc = next_midnight, next_midnight_utc
|
|
142
|
+
else:
|
|
143
|
+
chunk_end, chunk_end_utc = e, e_utc
|
|
144
|
+
minutes = (chunk_end_utc - cursor_utc).total_seconds() / 60.0
|
|
145
|
+
out.append((cursor.date().isoformat(), minutes))
|
|
146
|
+
cursor, cursor_utc = chunk_end, chunk_end_utc
|
|
147
|
+
return out
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Pure, number-free projections for Runtime V2 proactive perception."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections.abc import Mapping, Sequence
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
import sys
|
|
9
|
+
from typing import Any, Callable
|
|
10
|
+
|
|
11
|
+
_HEALTH_SIGNALS = ("steps", "sleep", "workout", "vitals", "activity", "body", "metabolic", "cycle")
|
|
12
|
+
_HEALTH_HISTORY = frozenset({
|
|
13
|
+
"health_vitals", "health_sleep", "health_workout", "health_activity",
|
|
14
|
+
"health_body", "health_metabolic", "health_cycle",
|
|
15
|
+
})
|
|
16
|
+
_EXPIRED_MARKERS = frozenset({"expired", "stale", "is_expired", "is_stale"})
|
|
17
|
+
_EVENT_FIELDS = {
|
|
18
|
+
"unlock_after_absence": {"trigger": "unlock_after_absence", "returned_after_absence": True},
|
|
19
|
+
"arrived_at_anchor": {"trigger": "arrived_at_anchor", "anchor_changed": True},
|
|
20
|
+
"photo_added": {"trigger": "photo_added", "new_photo": True},
|
|
21
|
+
"scene_change": {"trigger": "scene_change"},
|
|
22
|
+
"broadcast_opened": {"trigger": "broadcast_opened", "screen_share_started": True},
|
|
23
|
+
"broadcast_closed": {"trigger": "broadcast_closed", "screen_share_ended": True},
|
|
24
|
+
}
|
|
25
|
+
V1_PRESENCE_HINT_FIELDS = (
|
|
26
|
+
"place_label",
|
|
27
|
+
"motion_state",
|
|
28
|
+
"now_playing",
|
|
29
|
+
"locale",
|
|
30
|
+
"broadcast_state",
|
|
31
|
+
"broadcast_active",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _finite_number(value: Any) -> bool:
|
|
36
|
+
if type(value) is int:
|
|
37
|
+
# Preserve the projector's historical "finite float" input range
|
|
38
|
+
# without coercing an arbitrary-precision int through float(), which
|
|
39
|
+
# raises OverflowError for malformed giant values.
|
|
40
|
+
return -sys.float_info.max <= value <= sys.float_info.max
|
|
41
|
+
if type(value) is float:
|
|
42
|
+
return math.isfinite(value)
|
|
43
|
+
return False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _text(value: Any) -> bool:
|
|
47
|
+
return isinstance(value, str) and bool(value.strip())
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _items(value: Any, predicate: Callable[[Any], bool]) -> bool:
|
|
51
|
+
return (
|
|
52
|
+
isinstance(value, Sequence)
|
|
53
|
+
and not isinstance(value, (str, bytes, bytearray))
|
|
54
|
+
and any(predicate(item) for item in value)
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _event(value: Any) -> bool:
|
|
59
|
+
return isinstance(value, Mapping) and any((
|
|
60
|
+
_text(value.get("title")),
|
|
61
|
+
_text(value.get("start_time")),
|
|
62
|
+
_finite_number(value.get("starts_in_min")),
|
|
63
|
+
_finite_number(value.get("minutes_until_start")),
|
|
64
|
+
))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _reminder(value: Any) -> bool:
|
|
68
|
+
return isinstance(value, Mapping) and any((
|
|
69
|
+
_text(value.get("title")),
|
|
70
|
+
_text(value.get("due_time")),
|
|
71
|
+
_text(value.get("due_date")),
|
|
72
|
+
type(value.get("overdue")) is bool,
|
|
73
|
+
))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _alert(value: Any) -> bool:
|
|
77
|
+
return isinstance(value, Mapping) and any(
|
|
78
|
+
_text(value.get(field)) for field in ("title", "summary", "headline", "description", "severity")
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _doc(signals: Mapping[str, Any], name: str) -> Mapping[str, Any]:
|
|
83
|
+
value = signals.get(name)
|
|
84
|
+
if (
|
|
85
|
+
not isinstance(value, Mapping)
|
|
86
|
+
or value.get("disabled") is True
|
|
87
|
+
or value.get("fresh") is False
|
|
88
|
+
or any(value.get(marker) is True for marker in _EXPIRED_MARKERS)
|
|
89
|
+
):
|
|
90
|
+
return {}
|
|
91
|
+
return value
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _available(
|
|
95
|
+
doc: Mapping[str, Any],
|
|
96
|
+
*,
|
|
97
|
+
number_fields: Sequence[str] = (),
|
|
98
|
+
text_fields: Sequence[str] = (),
|
|
99
|
+
bool_fields: Sequence[str] = (),
|
|
100
|
+
event_fields: Sequence[str] = (),
|
|
101
|
+
) -> bool:
|
|
102
|
+
return any(_finite_number(doc.get(field)) for field in number_fields) or any(
|
|
103
|
+
_text(doc.get(field)) for field in text_fields
|
|
104
|
+
) or any(type(doc.get(field)) is bool for field in bool_fields) or any(
|
|
105
|
+
_event(doc.get(field)) for field in event_fields
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _positive_count(value: Any) -> bool:
|
|
110
|
+
return _finite_number(value) and value > 0
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def build_perception_glance(
|
|
114
|
+
signals: Mapping[str, Mapping[str, Any]],
|
|
115
|
+
*,
|
|
116
|
+
notable_changes: Sequence[Mapping[str, Any]] = (),
|
|
117
|
+
) -> dict[str, dict[str, bool]]:
|
|
118
|
+
safe_signals = signals if isinstance(signals, Mapping) else {}
|
|
119
|
+
changed = {
|
|
120
|
+
str(item.get("signal") or "")
|
|
121
|
+
for item in notable_changes
|
|
122
|
+
if isinstance(item, Mapping)
|
|
123
|
+
}
|
|
124
|
+
out: dict[str, dict[str, bool]] = {}
|
|
125
|
+
location = _doc(safe_signals, "location")
|
|
126
|
+
if _available(location, text_fields=("place_label", "wifi_label", "country", "locality", "wifi_anchor_id")):
|
|
127
|
+
out["location"] = {"available": True, "notable_change": "location_signal" in changed}
|
|
128
|
+
now = _doc(safe_signals, "now")
|
|
129
|
+
playing = now.get("now_playing")
|
|
130
|
+
if isinstance(playing, Mapping) and _available(playing, text_fields=("title", "artist", "album", "playback_state")):
|
|
131
|
+
out["media"] = {"available": True, "active": True, "notable_change": "playback" in changed}
|
|
132
|
+
app = _doc(safe_signals, "app")
|
|
133
|
+
if _available(app, text_fields=("app_name", "app_category", "app_state")):
|
|
134
|
+
out["app"] = {"available": True, "recent_activity": True}
|
|
135
|
+
health_docs = [_doc(safe_signals, name) for name in _HEALTH_SIGNALS]
|
|
136
|
+
if any((
|
|
137
|
+
_available(health_docs[0], number_fields=("step_count",)),
|
|
138
|
+
_available(health_docs[1], number_fields=("asleep_minutes", "core_minutes", "deep_minutes", "rem_minutes")),
|
|
139
|
+
_available(health_docs[2], number_fields=("duration_min", "count_today"), text_fields=("workout_type",)),
|
|
140
|
+
_available(health_docs[3], number_fields=("resting_heart_rate", "step_count", "current_heart_rate", "hrv_sdnn_ms", "respiratory_rate", "oxygen_saturation_pct", "vo2_max")),
|
|
141
|
+
_available(health_docs[4], number_fields=("active_energy_kcal", "exercise_minutes", "stand_minutes", "mindful_minutes")),
|
|
142
|
+
_available(health_docs[5], number_fields=("weight_kg", "bmi", "body_fat_pct", "height_cm")),
|
|
143
|
+
_available(health_docs[6], number_fields=("blood_glucose_mmol_l", "blood_pressure_systolic", "blood_pressure_diastolic")),
|
|
144
|
+
_available(health_docs[7], text_fields=("flow_level",), bool_fields=("is_active_period",)),
|
|
145
|
+
)):
|
|
146
|
+
out["health"] = {"available": True, "notable_change": bool(changed & _HEALTH_HISTORY)}
|
|
147
|
+
weather = _doc(safe_signals, "weather")
|
|
148
|
+
if _available(
|
|
149
|
+
weather,
|
|
150
|
+
number_fields=("temperature", "apparent_temperature", "humidity", "precipitation_chance", "uv_index"),
|
|
151
|
+
text_fields=("condition",),
|
|
152
|
+
bool_fields=("is_daylight",),
|
|
153
|
+
) or _items(weather.get("alerts"), _alert):
|
|
154
|
+
out["weather"] = {"available": True, "notable_change": "weather" in changed}
|
|
155
|
+
mood = _doc(safe_signals, "mood")
|
|
156
|
+
if _available(mood, number_fields=("valence", "label_count"), text_fields=("valence_classification", "kind"), bool_fields=("recorded_today",)):
|
|
157
|
+
out["mood"] = {"available": True, "recorded": mood.get("recorded_today") is True}
|
|
158
|
+
reminders = _doc(safe_signals, "reminders")
|
|
159
|
+
if _available(
|
|
160
|
+
reminders,
|
|
161
|
+
number_fields=("overdue_count", "due_today_count"),
|
|
162
|
+
text_fields=("next_reminder",),
|
|
163
|
+
bool_fields=("reminders_truncated",),
|
|
164
|
+
) or _items(reminders.get("reminders"), _reminder):
|
|
165
|
+
out["reminders"] = {
|
|
166
|
+
"available": True,
|
|
167
|
+
"has_due": _positive_count(reminders.get("due_today_count")),
|
|
168
|
+
"has_overdue": _positive_count(reminders.get("overdue_count")),
|
|
169
|
+
}
|
|
170
|
+
calendar = _doc(safe_signals, "calendar")
|
|
171
|
+
if _available(calendar, event_fields=("calendar_next_event",)) or _items(calendar.get("calendar_events"), _event):
|
|
172
|
+
out["calendar"] = {
|
|
173
|
+
"available": True,
|
|
174
|
+
"has_upcoming": _event(calendar.get("calendar_next_event")) or _items(calendar.get("calendar_events"), _event),
|
|
175
|
+
}
|
|
176
|
+
return out
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def project_perception_wake_events(items: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]:
|
|
180
|
+
"""Project the producer-bounded wake facts, excluding internal cursor data.
|
|
181
|
+
|
|
182
|
+
``serve_worker._read_perception_wake_context`` is the trust boundary that
|
|
183
|
+
limits every string/list/scalar. The old projection discarded those facts
|
|
184
|
+
and retained only a trigger-derived constant, leaving the provider unable to
|
|
185
|
+
tell what actually changed. Copy only the closed field set consumed by the
|
|
186
|
+
prompt; never pass through arbitrary keys from storage.
|
|
187
|
+
"""
|
|
188
|
+
out: list[dict[str, Any]] = []
|
|
189
|
+
for item in items:
|
|
190
|
+
if not isinstance(item, Mapping):
|
|
191
|
+
continue
|
|
192
|
+
trigger = str(item.get("trigger") or "")
|
|
193
|
+
if trigger not in _EVENT_FIELDS:
|
|
194
|
+
continue
|
|
195
|
+
projected: dict[str, Any] = dict(_EVENT_FIELDS[trigger])
|
|
196
|
+
for field, cap in (
|
|
197
|
+
("wake_id", 160),
|
|
198
|
+
("source", 120),
|
|
199
|
+
("change_digest", 2000),
|
|
200
|
+
):
|
|
201
|
+
value = str(item.get(field) or "")[:cap]
|
|
202
|
+
if value:
|
|
203
|
+
projected[field] = value
|
|
204
|
+
origin_refs = [
|
|
205
|
+
str(ref)[:200] for ref in list(item.get("origin_refs") or [])[:10]
|
|
206
|
+
]
|
|
207
|
+
if origin_refs:
|
|
208
|
+
projected["origin_refs"] = origin_refs
|
|
209
|
+
raw_hints = item.get("presence_hints")
|
|
210
|
+
hints: dict[str, bool | int | float | str] = {}
|
|
211
|
+
if isinstance(raw_hints, Mapping):
|
|
212
|
+
for key in V1_PRESENCE_HINT_FIELDS:
|
|
213
|
+
value = raw_hints.get(key)
|
|
214
|
+
safe_key = str(key)[:80]
|
|
215
|
+
if isinstance(value, bool):
|
|
216
|
+
hints[safe_key] = value
|
|
217
|
+
elif isinstance(value, int):
|
|
218
|
+
hints[safe_key] = value
|
|
219
|
+
elif isinstance(value, float) and math.isfinite(value):
|
|
220
|
+
hints[safe_key] = value
|
|
221
|
+
elif isinstance(value, str):
|
|
222
|
+
hints[safe_key] = value[:200]
|
|
223
|
+
if hints:
|
|
224
|
+
projected["presence_hints"] = hints
|
|
225
|
+
if trigger == "photo_added":
|
|
226
|
+
for field, cap in (("photo_id", 160), ("scene", 200), ("time_of_day", 80)):
|
|
227
|
+
value = str(item.get(field) or "")[:cap]
|
|
228
|
+
if value:
|
|
229
|
+
projected[field] = value
|
|
230
|
+
out.append(projected)
|
|
231
|
+
return out
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def perception_glance_fingerprint(glance: Mapping[str, Any]) -> str:
|
|
235
|
+
canonical = json.dumps(glance, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
|
|
236
|
+
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|