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,228 @@
|
|
|
1
|
+
"""九种内置规则。
|
|
2
|
+
|
|
3
|
+
每个 evaluator 只回答一件事:**这次观测,让这条规则命中了吗。**
|
|
4
|
+
"今天已经触发过了要不要再触发""明天要不要重新武装"是生命周期的事,
|
|
5
|
+
在 ``engine`` 里统一处理 —— 不然九个 evaluator 各写一遍,迟早不一致。
|
|
6
|
+
|
|
7
|
+
九种:
|
|
8
|
+
|
|
9
|
+
changed 值变了
|
|
10
|
+
equals 值等于某个东西
|
|
11
|
+
enters / leaves 进入 / 离开某个状态
|
|
12
|
+
threshold_crossing 跨过一条线(**不是"大于某个数"**,见下)
|
|
13
|
+
delta 相邻两次的变化量超过某个幅度
|
|
14
|
+
occurrence 这条观测到达本身就是事件
|
|
15
|
+
streak 连续 N 个周期满足条件
|
|
16
|
+
absence 该来的没来
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from typing import Any, Callable, Protocol, runtime_checkable
|
|
21
|
+
|
|
22
|
+
from .types import MAX_SEEN_KEYS, EventDefinition, RuleResult, RuleState
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@runtime_checkable
|
|
26
|
+
class RuleEvaluator(Protocol):
|
|
27
|
+
"""自定义 evaluator 的接口。
|
|
28
|
+
|
|
29
|
+
宿主可以注册代码型 evaluator 处理内置模板覆盖不了的逻辑。
|
|
30
|
+
**但普通用户配置只能用声明式模板** —— 用户配的规则跑在服务端,
|
|
31
|
+
允许任意代码就是一整类新的安全面。
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
kind: str
|
|
35
|
+
|
|
36
|
+
def evaluate(
|
|
37
|
+
self, definition: EventDefinition, state: RuleState,
|
|
38
|
+
current: Any, context: dict[str, Any],
|
|
39
|
+
) -> RuleResult:
|
|
40
|
+
...
|
|
41
|
+
|
|
42
|
+
# ⚠️ 和产品规范 §12.4 的签名草案有两处不同,都是有意的:
|
|
43
|
+
#
|
|
44
|
+
# previous 在 ``state`` 里(``state.previous_value``),不单独传 ——
|
|
45
|
+
# 规则状态本来就是"上一次看到什么"的载体,拆成两个参数
|
|
46
|
+
# 会让实现者不知道该信哪一个。
|
|
47
|
+
#
|
|
48
|
+
# history **不传**。九种内置规则里只有 streak 需要历史,而它跑在
|
|
49
|
+
# 时钟驱动那条路上(一天判一次),历史由那边读好再通过
|
|
50
|
+
# ``context`` 传进来。给每次求值都挂上历史,意味着前台
|
|
51
|
+
# 每 30 秒一条观测就要读一次历史表。
|
|
52
|
+
#
|
|
53
|
+
# 代价说清楚:**自定义 evaluator 拿不到历史**。需要历史的
|
|
54
|
+
# 自定义规则,宿主目前只能自己在时钟循环里算好,
|
|
55
|
+
# 通过 ``extra_context`` 递进来。
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _numeric(value: Any) -> float | None:
|
|
59
|
+
if isinstance(value, bool) or value is None:
|
|
60
|
+
return None
|
|
61
|
+
if isinstance(value, (int, float)):
|
|
62
|
+
return float(value)
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _hit(state: RuleState, previous: Any, current: Any, reason: str) -> RuleResult:
|
|
67
|
+
return RuleResult(True, state, previous=previous, current=current, reason=reason)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _miss(state: RuleState, previous: Any, current: Any, reason: str) -> RuleResult:
|
|
71
|
+
return RuleResult(False, state, previous=previous, current=current, reason=reason)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
def eval_changed(d: EventDefinition, s: RuleState, current: Any, ctx) -> RuleResult:
|
|
77
|
+
prev = s.previous_value
|
|
78
|
+
if prev is None and current is None:
|
|
79
|
+
return _miss(s, prev, current, "两次都没有值")
|
|
80
|
+
if prev == current:
|
|
81
|
+
return _miss(s, prev, current, "没变")
|
|
82
|
+
# 第一次见到某个值不算"变了" —— 否则用户刚装上 app,所有信号会一起触发。
|
|
83
|
+
if prev is None:
|
|
84
|
+
return _miss(s, prev, current, "第一次观测,不算变化")
|
|
85
|
+
return _hit(s, prev, current, f"{prev!r} -> {current!r}")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def eval_equals(d: EventDefinition, s: RuleState, current: Any, ctx) -> RuleResult:
|
|
89
|
+
if current == d.value:
|
|
90
|
+
return _hit(s, s.previous_value, current, f"等于 {d.value!r}")
|
|
91
|
+
return _miss(s, s.previous_value, current, f"不等于 {d.value!r}")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def eval_enters(d: EventDefinition, s: RuleState, current: Any, ctx) -> RuleResult:
|
|
95
|
+
"""从"不是它"变成"是它"。**只在跨入那一刻触发**,之后一直是它也不再触发。"""
|
|
96
|
+
prev = s.previous_value
|
|
97
|
+
if current == d.value and prev != d.value:
|
|
98
|
+
return _hit(s, prev, current, f"进入 {d.value!r}")
|
|
99
|
+
return _miss(s, prev, current, "没有跨入")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def eval_leaves(d: EventDefinition, s: RuleState, current: Any, ctx) -> RuleResult:
|
|
103
|
+
prev = s.previous_value
|
|
104
|
+
if prev == d.value and current != d.value:
|
|
105
|
+
return _hit(s, prev, current, f"离开 {d.value!r}")
|
|
106
|
+
return _miss(s, prev, current, "没有跨出")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
_OPS: dict[str, Callable[[float, float], bool]] = {
|
|
110
|
+
"gte": lambda a, b: a >= b,
|
|
111
|
+
"gt": lambda a, b: a > b,
|
|
112
|
+
"lte": lambda a, b: a <= b,
|
|
113
|
+
"lt": lambda a, b: a < b,
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def eval_threshold_crossing(d: EventDefinition, s: RuleState, current: Any, ctx) -> RuleResult:
|
|
118
|
+
"""**跨过**一条线,不是"在线的那一边"。
|
|
119
|
+
|
|
120
|
+
这是最容易写错的一条:``current >= 3000`` 会让 3001、3010、3100 的每次
|
|
121
|
+
上报都重复触发 —— 用户走一天路能被提醒几十次。正确的是
|
|
122
|
+
``previous < 3000 and current >= 3000``。
|
|
123
|
+
|
|
124
|
+
第一次观测就已经在线的另一边时**不触发**:用户可能是中午才装上 app,
|
|
125
|
+
那时步数已经过万,不该立刻收到"你走够 3000 步了"。
|
|
126
|
+
"""
|
|
127
|
+
op = _OPS.get(d.operator or "gte")
|
|
128
|
+
threshold = _numeric(d.value)
|
|
129
|
+
now = _numeric(current)
|
|
130
|
+
prev = _numeric(s.previous_value)
|
|
131
|
+
if op is None or threshold is None or now is None:
|
|
132
|
+
return _miss(s, s.previous_value, current, "阈值或当前值不是数字")
|
|
133
|
+
if prev is None:
|
|
134
|
+
return _miss(s, s.previous_value, current, "第一次观测,没有可比的前值")
|
|
135
|
+
if op(prev, threshold):
|
|
136
|
+
return _miss(s, s.previous_value, current, "之前就已经在线的另一边")
|
|
137
|
+
if op(now, threshold):
|
|
138
|
+
return _hit(s, s.previous_value, current,
|
|
139
|
+
f"{prev:g} -> {now:g} 跨过 {threshold:g}")
|
|
140
|
+
return _miss(s, s.previous_value, current, "还没跨过")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def eval_delta(d: EventDefinition, s: RuleState, current: Any, ctx) -> RuleResult:
|
|
144
|
+
"""相邻两次的变化幅度超过某个值。用于"突然掉了很多"这类。"""
|
|
145
|
+
now, prev, limit = _numeric(current), _numeric(s.previous_value), _numeric(d.value)
|
|
146
|
+
if now is None or prev is None or limit is None:
|
|
147
|
+
return _miss(s, s.previous_value, current, "缺少可比的数字")
|
|
148
|
+
change = now - prev
|
|
149
|
+
magnitude = abs(change)
|
|
150
|
+
if magnitude >= abs(limit):
|
|
151
|
+
return _hit(s, s.previous_value, current, f"变化 {change:+g},超过 {limit:g}")
|
|
152
|
+
return _miss(s, s.previous_value, current, f"变化 {change:+g},未达 {limit:g}")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def eval_occurrence(d: EventDefinition, s: RuleState, current: Any, ctx) -> RuleResult:
|
|
156
|
+
"""观测到达本身就是事件。没有前后值可比,靠去重键挡住重复。
|
|
157
|
+
|
|
158
|
+
去重键取自 ``ctx``(通常是 ``source_event_id``)。**没有去重键时不触发** ——
|
|
159
|
+
宁可漏一次,也不要因为客户端重传而让用户被同一件事提醒两次。
|
|
160
|
+
"""
|
|
161
|
+
key = ctx.get(d.dedupe_field)
|
|
162
|
+
if not key:
|
|
163
|
+
return _miss(s, None, current, f"没有 {d.dedupe_field},无法去重,跳过")
|
|
164
|
+
if key in s.seen_keys:
|
|
165
|
+
return _miss(s, None, current, "这次事件之前处理过")
|
|
166
|
+
# 有上限:高频信号会让这条状态记录无限膨胀,而它每次求值都要被读出来。
|
|
167
|
+
seen = (s.seen_keys + (key,))[-MAX_SEEN_KEYS:]
|
|
168
|
+
return _hit(RuleState(
|
|
169
|
+
previous_value=s.previous_value, fired_in_scope=s.fired_in_scope,
|
|
170
|
+
last_fired_at=s.last_fired_at, seen_keys=seen,
|
|
171
|
+
), None, current, f"发生了({d.dedupe_field}={key})")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def eval_streak(d: EventDefinition, s: RuleState, current: Any, ctx) -> RuleResult:
|
|
175
|
+
"""连续 N 个周期满足条件。
|
|
176
|
+
|
|
177
|
+
两个参数分开:``operator`` / ``value`` 是**每天的条件**("睡眠 < 360 分钟"),
|
|
178
|
+
``params["periods"]`` 是**连续几天**。挤在一个字段里表达不了。
|
|
179
|
+
|
|
180
|
+
连续长度由调用方通过 ``ctx["streak_length"]`` 给 —— 它要读历史,
|
|
181
|
+
而 evaluator 只看单次观测。
|
|
182
|
+
|
|
183
|
+
**由日聚合完成时驱动,不是每条观测都跑**:前台每 30 秒一条观测,
|
|
184
|
+
但"连续三天"这件事一天只可能变化一次。
|
|
185
|
+
|
|
186
|
+
**边缘触发**:只在恰好跨到 N 的那一次触发。第 N+1 天仍然连续,
|
|
187
|
+
但不再提醒 —— 否则"连续三天睡不好"会变成天天念叨。
|
|
188
|
+
"""
|
|
189
|
+
need = int(_numeric(d.params.get("periods")) or _numeric(d.value) or 0)
|
|
190
|
+
length = int(_numeric(ctx.get("streak_length")) or 0)
|
|
191
|
+
prev_length = int(_numeric(s.previous_value) or 0)
|
|
192
|
+
if need <= 0:
|
|
193
|
+
return _miss(s, prev_length, length, "没有指定连续长度")
|
|
194
|
+
if length >= need > prev_length:
|
|
195
|
+
return _hit(s, prev_length, length, f"连续到达 {length} 个周期")
|
|
196
|
+
return _miss(s, prev_length, length, f"连续 {length},需要 {need}")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def eval_absence(d: EventDefinition, s: RuleState, current: Any, ctx) -> RuleResult:
|
|
200
|
+
"""该来的没来。
|
|
201
|
+
|
|
202
|
+
多久算"没来"由 ``ctx["silent_seconds"]`` 给 —— 同样要读历史。
|
|
203
|
+
这条规则天然由**定时检查**驱动,而不是由上报驱动:没有上报的时候,
|
|
204
|
+
才是它该触发的时候。
|
|
205
|
+
"""
|
|
206
|
+
limit = _numeric(d.value)
|
|
207
|
+
silent = _numeric(ctx.get("silent_seconds"))
|
|
208
|
+
if limit is None or silent is None:
|
|
209
|
+
return _miss(s, None, current, "缺少静默时长")
|
|
210
|
+
if silent >= limit and not s.fired_in_scope:
|
|
211
|
+
return _hit(s, None, current, f"已经 {silent:g}s 没有数据,超过 {limit:g}s")
|
|
212
|
+
return _miss(s, None, current, f"静默 {silent:g}s,未达 {limit:g}s")
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
BUILTIN: dict[str, Callable[..., RuleResult]] = {
|
|
216
|
+
"changed": eval_changed,
|
|
217
|
+
"equals": eval_equals,
|
|
218
|
+
"enters": eval_enters,
|
|
219
|
+
"leaves": eval_leaves,
|
|
220
|
+
"threshold_crossing": eval_threshold_crossing,
|
|
221
|
+
"delta": eval_delta,
|
|
222
|
+
"occurrence": eval_occurrence,
|
|
223
|
+
"streak": eval_streak,
|
|
224
|
+
"absence": eval_absence,
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
__all__ = ["RuleEvaluator", "BUILTIN"] + [f"eval_{k}" for k in BUILTIN]
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""规则的形状 —— 定义、状态、求值结果。
|
|
2
|
+
|
|
3
|
+
**定义和发生是两件事,必须分开。**
|
|
4
|
+
|
|
5
|
+
EventDefinition 用户或宿主说"什么条件算一件事"。会被改、被删、被停用。
|
|
6
|
+
PerceptionEvent 某条规则命中后产生的**不可变事实**。规则后来变了,
|
|
7
|
+
已经发生的事实仍然解释得通 —— 所以事件里带条件快照,
|
|
8
|
+
不带指向定义的活引用。
|
|
9
|
+
|
|
10
|
+
**不做通用表达式 DSL。** 九种模板已经覆盖真实需求,而 DSL 意味着要解析、
|
|
11
|
+
要防注入、要考虑求值超时 —— 用户配的规则跑在服务端,那是一整类新的安全面。
|
|
12
|
+
需要更复杂逻辑的宿主可以注册代码型 evaluator,但那要求宿主自己信任那段代码。
|
|
13
|
+
|
|
14
|
+
**规则用 dict/JSON 表达,不是 YAML。** 这个包零依赖(``dependencies = []``,
|
|
15
|
+
有 AST 测试盯着),引 PyYAML 当场破坏"任何宿主都能直接嵌入"这句话。
|
|
16
|
+
宿主想用 YAML 写规则完全可以 —— 自己 ``yaml.safe_load`` 成 dict 再传进来。
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from typing import Any, Mapping
|
|
22
|
+
|
|
23
|
+
from ..contracts.errors import ContractError
|
|
24
|
+
|
|
25
|
+
#: 规则在什么范围内计一次。``local_day`` 每天重置,``forever`` 永不重置。
|
|
26
|
+
SCOPES: frozenset[str] = frozenset({"local_day", "forever"})
|
|
27
|
+
|
|
28
|
+
#: 一个范围内触发几次。
|
|
29
|
+
FIRE_MODES: frozenset[str] = frozenset({"once", "every"})
|
|
30
|
+
|
|
31
|
+
#: 什么时候重新武装。
|
|
32
|
+
REARM_MODES: frozenset[str] = frozenset({"next_scope", "cooldown", "never"})
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class Lifecycle:
|
|
37
|
+
"""一条规则的触发节奏。
|
|
38
|
+
|
|
39
|
+
默认是"每天一次、次日重新武装" —— 这是绝大多数感知规则想要的:
|
|
40
|
+
今天走够 3000 步提醒一次,明天重新算。
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
scope: str = "local_day"
|
|
44
|
+
fire: str = "once"
|
|
45
|
+
rearm: str = "next_scope"
|
|
46
|
+
cooldown_seconds: float = 0.0
|
|
47
|
+
|
|
48
|
+
def __post_init__(self) -> None:
|
|
49
|
+
problems = []
|
|
50
|
+
if self.scope not in SCOPES:
|
|
51
|
+
problems.append(f"scope={self.scope!r} 不在 {sorted(SCOPES)}")
|
|
52
|
+
if self.fire not in FIRE_MODES:
|
|
53
|
+
problems.append(f"fire={self.fire!r} 不在 {sorted(FIRE_MODES)}")
|
|
54
|
+
if self.rearm not in REARM_MODES:
|
|
55
|
+
problems.append(f"rearm={self.rearm!r} 不在 {sorted(REARM_MODES)}")
|
|
56
|
+
if self.cooldown_seconds < 0:
|
|
57
|
+
problems.append("cooldown_seconds 不能为负")
|
|
58
|
+
# 这两个组合以前"能配出来但不生效"——比配不出来更糟,因为用户以为配上了。
|
|
59
|
+
if self.rearm == "never" and self.scope != "forever":
|
|
60
|
+
problems.append(
|
|
61
|
+
"rearm=never 只能配 scope=forever:"
|
|
62
|
+
"按天算范围的话,换一天就是一条新状态,本来就重新武装了"
|
|
63
|
+
)
|
|
64
|
+
if self.rearm == "cooldown" and self.cooldown_seconds <= 0:
|
|
65
|
+
problems.append("rearm=cooldown 必须给一个正的 cooldown_seconds")
|
|
66
|
+
if problems:
|
|
67
|
+
raise ContractError(problems)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def default_lifecycle_for(condition_type: str) -> Lifecycle:
|
|
71
|
+
"""没有显式写 lifecycle 时用什么默认值。
|
|
72
|
+
|
|
73
|
+
``occurrence`` 型默认 ``fire="every"`` —— 它的"不重复"已经由去重键保证了,
|
|
74
|
+
再叠一层"每个范围只触发一次"是双重限制:"久别之后重新在场"一天可能发生
|
|
75
|
+
三次,不该只报第一次。产品规范的两个示例正好印证:步数那条显式写了
|
|
76
|
+
``fire: once``,解锁那条整个 lifecycle 块都没写。
|
|
77
|
+
|
|
78
|
+
其余规则默认"每天一次、次日重新武装" —— 绝大多数感知规则想要的就是这个。
|
|
79
|
+
"""
|
|
80
|
+
if condition_type == "occurrence":
|
|
81
|
+
return Lifecycle(fire="every")
|
|
82
|
+
return Lifecycle()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class EventDefinition:
|
|
87
|
+
"""用户或宿主定义的一条规则。"""
|
|
88
|
+
|
|
89
|
+
definition_id: str
|
|
90
|
+
version: int
|
|
91
|
+
signal: str
|
|
92
|
+
condition_type: str
|
|
93
|
+
event_type: str
|
|
94
|
+
enabled: bool = True
|
|
95
|
+
#: 盯哪个字段。``occurrence`` 型规则盯的是整条信号的到达,为 ``None``。
|
|
96
|
+
field_name: str | None = None
|
|
97
|
+
operator: str | None = None
|
|
98
|
+
value: Any = None
|
|
99
|
+
#: 没写时按 condition_type 取默认值,见 default_lifecycle_for。
|
|
100
|
+
lifecycle: Lifecycle | None = None
|
|
101
|
+
wake_enabled: bool = True
|
|
102
|
+
#: 只属于某个用户的规则;``None`` = 宿主级,对所有用户生效。
|
|
103
|
+
subject_id: str | None = None
|
|
104
|
+
#: ``occurrence`` 型用什么去重。默认用 ``source_event_id``。
|
|
105
|
+
dedupe_field: str = "source_event_id"
|
|
106
|
+
#: 某些规则型需要的额外参数。放一个口袋,而不是给每种规则各开一个字段。
|
|
107
|
+
#: ``streak`` 用 ``{"periods": 3}``(连续几个周期)—— 它的 ``operator`` /
|
|
108
|
+
#: ``value`` 已经被"每天的条件"占用了(比如"睡眠 < 360 分钟")。
|
|
109
|
+
params: Mapping[str, Any] = field(default_factory=dict)
|
|
110
|
+
|
|
111
|
+
def __post_init__(self) -> None:
|
|
112
|
+
if self.lifecycle is None:
|
|
113
|
+
object.__setattr__(
|
|
114
|
+
self, "lifecycle", default_lifecycle_for(self.condition_type)
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
@classmethod
|
|
118
|
+
def parse(cls, payload: Mapping[str, Any]) -> "EventDefinition":
|
|
119
|
+
"""从 dict 解析。宿主想用 YAML 就自己 ``safe_load`` 成 dict 再进来。"""
|
|
120
|
+
if not isinstance(payload, Mapping):
|
|
121
|
+
raise ContractError(["definition 必须是一个对象"])
|
|
122
|
+
problems: list[str] = []
|
|
123
|
+
|
|
124
|
+
def need(key: str, types: tuple[type, ...], label: str) -> Any:
|
|
125
|
+
raw = payload.get(key)
|
|
126
|
+
if not isinstance(raw, types) or (isinstance(raw, str) and not raw.strip()):
|
|
127
|
+
problems.append(f"{key}: 必填,{label}")
|
|
128
|
+
return None
|
|
129
|
+
return raw
|
|
130
|
+
|
|
131
|
+
definition_id = need("id", (str,), "非空字符串")
|
|
132
|
+
version = need("version", (int,), "整数")
|
|
133
|
+
source = payload.get("source") or {}
|
|
134
|
+
condition = payload.get("condition") or {}
|
|
135
|
+
event = payload.get("event") or {}
|
|
136
|
+
wake = payload.get("wake") or {}
|
|
137
|
+
|
|
138
|
+
signal = source.get("signal") if isinstance(source, Mapping) else None
|
|
139
|
+
if not isinstance(signal, str) or not signal.strip():
|
|
140
|
+
problems.append("source.signal: 必填")
|
|
141
|
+
|
|
142
|
+
condition_type = condition.get("type") if isinstance(condition, Mapping) else None
|
|
143
|
+
if not isinstance(condition_type, str) or not condition_type.strip():
|
|
144
|
+
problems.append("condition.type: 必填")
|
|
145
|
+
|
|
146
|
+
event_type = event.get("type") if isinstance(event, Mapping) else None
|
|
147
|
+
if not isinstance(event_type, str) or not event_type.strip():
|
|
148
|
+
problems.append("event.type: 必填")
|
|
149
|
+
|
|
150
|
+
if problems:
|
|
151
|
+
raise ContractError(problems)
|
|
152
|
+
|
|
153
|
+
raw_lifecycle = payload.get("lifecycle")
|
|
154
|
+
if raw_lifecycle is None:
|
|
155
|
+
lifecycle = default_lifecycle_for(condition_type) # type: ignore[arg-type]
|
|
156
|
+
else:
|
|
157
|
+
fallback = default_lifecycle_for(condition_type) # type: ignore[arg-type]
|
|
158
|
+
lifecycle = Lifecycle(
|
|
159
|
+
scope=raw_lifecycle.get("scope", fallback.scope),
|
|
160
|
+
fire=raw_lifecycle.get("fire", fallback.fire),
|
|
161
|
+
rearm=raw_lifecycle.get("rearm", fallback.rearm),
|
|
162
|
+
cooldown_seconds=float(raw_lifecycle.get("cooldown_seconds", 0) or 0),
|
|
163
|
+
)
|
|
164
|
+
return cls(
|
|
165
|
+
definition_id=definition_id, # type: ignore[arg-type]
|
|
166
|
+
version=version, # type: ignore[arg-type]
|
|
167
|
+
signal=signal, # type: ignore[arg-type]
|
|
168
|
+
condition_type=condition_type, # type: ignore[arg-type]
|
|
169
|
+
event_type=event_type, # type: ignore[arg-type]
|
|
170
|
+
enabled=bool(payload.get("enabled", True)),
|
|
171
|
+
field_name=source.get("field"),
|
|
172
|
+
operator=condition.get("operator"),
|
|
173
|
+
value=condition.get("value"),
|
|
174
|
+
lifecycle=lifecycle,
|
|
175
|
+
wake_enabled=bool(wake.get("enabled", True)),
|
|
176
|
+
subject_id=payload.get("subject_id"),
|
|
177
|
+
dedupe_field=(payload.get("deduplication") or {}).get("key", "source_event_id"),
|
|
178
|
+
params=dict(condition.get("params") or {}),
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@dataclass(frozen=True)
|
|
183
|
+
class RuleState:
|
|
184
|
+
"""一条规则在某个范围内的状态。
|
|
185
|
+
|
|
186
|
+
``scope_key`` 是"哪一天"(或 ``forever``)。换了一天就是一条新状态,
|
|
187
|
+
所以"今天已经触发过"不会影响明天 —— 这就是 ``rearm=next_scope``。
|
|
188
|
+
"""
|
|
189
|
+
|
|
190
|
+
previous_value: Any = None
|
|
191
|
+
fired_in_scope: bool = False
|
|
192
|
+
last_fired_at: str | None = None
|
|
193
|
+
#: ``occurrence`` 型见过哪些去重键。有上限,见 ``MAX_SEEN_KEYS``。
|
|
194
|
+
seen_keys: tuple[str, ...] = ()
|
|
195
|
+
|
|
196
|
+
def to_dict(self) -> dict[str, Any]:
|
|
197
|
+
return {
|
|
198
|
+
"previous_value": self.previous_value,
|
|
199
|
+
"fired_in_scope": self.fired_in_scope,
|
|
200
|
+
"last_fired_at": self.last_fired_at,
|
|
201
|
+
"seen_keys": list(self.seen_keys),
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
@classmethod
|
|
205
|
+
def from_dict(cls, raw: Mapping[str, Any] | None) -> "RuleState":
|
|
206
|
+
raw = raw or {}
|
|
207
|
+
return cls(
|
|
208
|
+
previous_value=raw.get("previous_value"),
|
|
209
|
+
fired_in_scope=bool(raw.get("fired_in_scope")),
|
|
210
|
+
last_fired_at=raw.get("last_fired_at"),
|
|
211
|
+
seen_keys=tuple(raw.get("seen_keys") or ()),
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
#: ``occurrence`` 型记住多少个去重键。不设上限的话,一个高频信号会让这条
|
|
216
|
+
#: 状态记录无限膨胀 —— 而它每次求值都要被读出来。
|
|
217
|
+
MAX_SEEN_KEYS = 200
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@dataclass(frozen=True)
|
|
221
|
+
class RuleResult:
|
|
222
|
+
"""一次求值的结果。"""
|
|
223
|
+
|
|
224
|
+
fired: bool
|
|
225
|
+
state: RuleState
|
|
226
|
+
previous: Any = None
|
|
227
|
+
current: Any = None
|
|
228
|
+
#: 为什么触发(或为什么没触发)。进事件信封,也进排查日志。
|
|
229
|
+
reason: str | None = None
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
__all__ = [
|
|
233
|
+
"SCOPES", "FIRE_MODES", "REARM_MODES", "MAX_SEEN_KEYS",
|
|
234
|
+
"Lifecycle", "default_lifecycle_for",
|
|
235
|
+
"EventDefinition", "RuleState", "RuleResult",
|
|
236
|
+
]
|