feat(urgency): 通用紧急度通道(Declarer/Level + 只抬不压折算)+ IoT 首个生产者 + 她自己的结论走结构化标记穿透安静时段
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

This commit is contained in:
AskaEth committed 2026-10-05 13:11:04 +08:00
1 parent 4a2d276881
commit f91a83ff81
14 files changed
+917 -15

No files matched your search

+15 -1
View File
@@ -11,7 +11,11 @@
// Home Assistant)后续通过同一套 Provider 接口接入。
package iot
import "errors"
import (
"errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/urgency"
)
// DeviceUpdateSubject 是设备状态跃迁事件发布所用的 bus 主题。订阅方使用该主题
// (或 "iot.>")观察设备变化。
@@ -81,4 +85,14 @@ type DeviceUpdate struct {
Params map[string]any `json:"params,omitempty"`
// Message 是跃迁的可读摘要,会呈现到前端的 device_update 信息流中。
Message string `json:"message,omitempty"`
// Severity 是本次跃迁的显式严重度(通用紧急度词汇 urgency.Level 的别名)。
// 零值 SeverityRoutine 表示例行变化,因此既有订阅方与既有的普通设备变化行为
// 逐字不变;命中安全规则(见 safetySeverity)的跃迁才被标成安全域
// (design/12 §6 安全域豁免)。
Severity AlertSeverity `json:"severity"`
}
// Urgency 实现 urgency.Declarer:DeviceUpdate 因此在**通用通道**上声明紧急度,
// 让 backthink(以及任何订阅方)无需认识 IoT 的专有字段就能承认安全域事件。
// 接线点见 backthink 的 triggerForEvent。
func (d DeviceUpdate) Urgency() urgency.Level { return d.Severity }
+10 -2
View File
@@ -147,22 +147,30 @@ func (m *Manager) Status(ctx context.Context, deviceID string) (*Device, error)
return nil, fmt.Errorf("iot: status %q: %w", deviceID, ErrDeviceNotFound)
}
// publishUpdate 把 DeviceUpdate 扇出到 bus。发布失败(例如 ctx 被取消)只记日志、
// publishUpdate 把 DeviceUpdate 扇出到 bus。发布的载荷带上本次跃迁的显式严重度:
// 命中安全规则(safetySeverity)的跃迁被标成安全域,其余一律 SeverityRoutine,
// 因此常规设备变化不会被误升级。发布失败(例如 ctx 被取消)只记日志、
// 不作为错误返回:设备状态已经改变,不应因为某个观察者没收到事件就把本次控制判为
// 失败。
func (m *Manager) publishUpdate(ctx context.Context, provider string, d Device, action string, params map[string]any) {
severity, safety := safetySeverity(d.Type, action)
msg := fmt.Sprintf("设备 %s · %s(%s)", d.Name, action, provider)
if safety {
msg = fmt.Sprintf("%s · 安全域 %s", msg, severity.String())
}
if err := m.bus.Publish(ctx, DeviceUpdateSubject, DeviceUpdate{
Provider: provider,
Device: d,
Action: action,
Params: params,
Message: msg,
Severity: severity,
}); err != nil {
m.log.Error("iot publish device update", "device", d.ID, "action", action, "error", err)
return
}
m.log.Debug("iot device update published", "device", d.ID, "action", action, "provider", provider)
m.log.Debug("iot device update published",
"device", d.ID, "action", action, "provider", provider, "severity", severity.String())
}
// discardLogger 在 log 非 nil 时原样返回,否则返回一个不写任何输出的 logger,
+33
View File
@@ -0,0 +1,33 @@
package iot
// 安全规则表所用的字面量集中在此,避免规则命中里出现魔法字符串。
const (
deviceTypeLock = "lock"
actionUnlock = "unlock"
)
// safetySeverity 报告一次 (设备类型, 动作) 跃迁是否命中已登记的显式安全规则,
// 命中时返回该规则声明的严重度。
//
// 这是 IoT 侧严重度升级的**唯一**起点,规则逐条可审且保守:只有语义上明确属于
// 安全域的动作才升级,未命中的动作一律落在 SeverityRoutine——因此常规设备变化
// 不会因为本机制获得穿透安静时段的资格。
//
// 目前登记的规则(一条):
// - lock/unlock —— 门锁开启。不论开启者是谁,门锁从锁闭变为开启都是一次安全域
// 事件(入侵 / 异常开启),标 critical。
//
// 烟雾、漏水、门窗磁等安全来源的规则属**设备来源**(Provider)自身的语义,待真实
// Provider(MQTT / Home Assistant)接入时在同一张表登记;本机制不预置猜测性的
// 阈值或设备语义。
func safetySeverity(deviceType, action string) (AlertSeverity, bool) {
if deviceType != deviceTypeLock {
return SeverityRoutine, false
}
switch action {
case actionUnlock:
return SeverityCritical, true
default:
return SeverityRoutine, false
}
}
+146
View File
@@ -0,0 +1,146 @@
package iot
import (
"context"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/urgency"
)
// captureUpdates 订阅 DeviceUpdateSubject 并把收到的载荷送进返回的通道。
func captureUpdates(t *testing.T, m *Manager) <-chan DeviceUpdate {
t.Helper()
ch := make(chan DeviceUpdate, 8)
unsub, err := m.Bus().Subscribe(context.Background(), DeviceUpdateSubject, func(_ context.Context, e bus.Event) error {
if d, ok := e.Payload.(DeviceUpdate); ok {
ch <- d
}
return nil
})
if err != nil {
t.Fatalf("Subscribe: %v", err)
}
t.Cleanup(unsub)
return ch
}
// TestControl_UnlockIsCriticalSafetyEvent 验证门锁开启被标成安全域:
// 这是 IoT 侧唯一的严重度升级出处,也是 quiet hours 豁免的生产端。
func TestControl_UnlockIsCriticalSafetyEvent(t *testing.T) {
t.Parallel()
m, err := New(config.IoTConfig{Provider: "mock"}, nil)
if err != nil {
t.Fatalf("New: %v", err)
}
updates := captureUpdates(t, m)
if _, err := m.Control(context.Background(), "lock.front", "unlock", nil); err != nil {
t.Fatalf("Control: %v", err)
}
got := recvIoT(t, updates, 2*time.Second)
if got.Severity != SeverityCritical {
t.Fatalf("Severity = %v, want %v (an unlocked door is a safety event)", got.Severity, SeverityCritical)
}
if !strings.Contains(got.Message, "安全域") {
t.Fatalf("Message = %q, want it to name the safety domain", got.Message)
}
}
// TestControl_RoutineStaysRoutine 是保守默认的反面断言:常规设备变化(以及
// 锁门这类非安全域动作)绝不被升级——安静时段照旧扣下它们。
func TestControl_RoutineStaysRoutine(t *testing.T) {
t.Parallel()
cases := []struct {
name string
device string
action string
}{
{name: "开灯", device: "light.living", action: "on"},
{name: "空调设定温度", device: "ac.living", action: "set_temperature"},
{name: "锁门", device: "lock.front", action: "lock"},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
m, err := New(config.IoTConfig{Provider: "mock"}, nil)
if err != nil {
t.Fatalf("New: %v", err)
}
updates := captureUpdates(t, m)
params := map[string]any{"temperature": 25}
if _, err := m.Control(context.Background(), tc.device, tc.action, params); err != nil {
t.Fatalf("Control: %v", err)
}
got := recvIoT(t, updates, 2*time.Second)
if got.Severity != SeverityRoutine {
t.Fatalf("Severity = %v, want %v (routine transitions must stay routine)", got.Severity, SeverityRoutine)
}
if strings.Contains(got.Message, "安全域") {
t.Fatalf("Message = %q, want no safety-domain wording", got.Message)
}
})
}
}
// TestAlertSeverity_String 验证严重度标签经通用词汇转发(日志与记录依赖它),
// 且 IoT 侧的类型就是通用紧急度的别名 —— 它没有自己的专有严重度体系。
func TestAlertSeverity_String(t *testing.T) {
t.Parallel()
cases := []AlertSeverity{SeverityRoutine, SeverityWarning, SeverityCritical, AlertSeverity(7)}
for _, in := range cases {
if got, want := in.String(), urgency.Level(int(in)).String(); got != want {
t.Fatalf("AlertSeverity(%d).String() = %q, want the generic label %q", int(in), got, want)
}
}
if got := SeverityCritical.String(); got != "critical" {
t.Fatalf("SeverityCritical.String() = %q, want %q", got, "critical")
}
}
// TestDeviceUpdate_DeclaresUrgencyOnGenericChannel 钉住 IoT 走的是**通用**通道:
// 设备跃迁载荷实现 urgency.Declarer,backthink 因此不必认识 IoT 的专有字段;
// 未标注严重度的跃迁声明零值 routine(默认保守)。
func TestDeviceUpdate_DeclaresUrgencyOnGenericChannel(t *testing.T) {
t.Parallel()
var declared urgency.Declarer = DeviceUpdate{Severity: SeverityCritical}
if got := declared.Urgency(); got != urgency.LevelCritical {
t.Fatalf("DeviceUpdate.Urgency() = %v, want %v", got, urgency.LevelCritical)
}
if got := (DeviceUpdate{}).Urgency(); got != urgency.LevelRoutine {
t.Fatalf("unlabelled DeviceUpdate.Urgency() = %v, want %v", got, urgency.LevelRoutine)
}
}
// TestSafetySeverity_RuleTable 直接钉住规则表:未登记的设备类型与动作一律不升级。
func TestSafetySeverity_RuleTable(t *testing.T) {
t.Parallel()
cases := []struct {
name string
deviceType string
action string
wantSev AlertSeverity
wantHit bool
}{
{name: "门锁开启_命中", deviceType: "lock", action: "unlock", wantSev: SeverityCritical, wantHit: true},
{name: "门锁锁闭_不命中", deviceType: "lock", action: "lock", wantSev: SeverityRoutine, wantHit: false},
{name: "门锁翻转_不命中", deviceType: "lock", action: "toggle", wantSev: SeverityRoutine, wantHit: false},
{name: "灯_不命中", deviceType: "light", action: "on", wantSev: SeverityRoutine, wantHit: false},
{name: "传感器_不命中", deviceType: "sensor", action: "unlock", wantSev: SeverityRoutine, wantHit: false},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
sev, hit := safetySeverity(tc.deviceType, tc.action)
if hit != tc.wantHit || sev != tc.wantSev {
t.Fatalf("safetySeverity(%q, %q) = (%v, %v), want (%v, %v)",
tc.deviceType, tc.action, sev, hit, tc.wantSev, tc.wantHit)
}
})
}
}
+30
View File
@@ -0,0 +1,30 @@
package iot
import "git.yeij.top/AskaEth/Cyrene_v2/internal/urgency"
// AlertSeverity 是 IoT 事件的显式严重度。它是**通用紧急度词汇**
// (internal/urgency)在 IoT 侧的别名 —— IoT 是那条通用通道的**第一个生产者**,
// 而不是拥有一条专用通道:任何别的事件生产者(外围插件、系统 / 能力模块)用同一个
// urgency.Level 声明紧急度,backthink 也一视同仁(design/12 §2 §6 安全域豁免,
// 裁决 1.8 / 07.4.2)。
//
// 零值是 SeverityRoutine,因此既有的设备状态跃迁(开灯、调温、窗帘移动)一律落在
// 例行档:本机制不会让常规设备变化获得穿透安静时段的资格
// (design/12 §6 安全域豁免的反面保证)。
//
// 它只回答「这次跃迁有多严重」,不表达身份、权限或能力边界 —— 身份与权限永远由
// channel 层的 actor 元数据与策略引擎决定。
type AlertSeverity = urgency.Level
// IoT 侧沿用的严重度档位名(与 urgency.Level* 逐一同义),让设备来源与既有调用方
// 继续按设备语义阅读。折算到思考轮重要度的口径见 backthink 的 importanceForUrgency。
const (
// SeverityRoutine 是例行设备变化:折算为 ImportanceNormal,安静时段照常扣下。
SeverityRoutine = urgency.LevelRoutine
// SeverityWarning 是值得注意但非安全域的异常(设备离线、耗材将尽):折算为
// ImportanceHigh,沿袭既有语义通过安静时段,但不穿透工作 / 忙碌。
SeverityWarning = urgency.LevelWarning
// SeverityCritical 是安全域事件(入侵、门锁异常开启、烟雾 / 漏水告警):折算为
// ImportanceCritical,穿透安静时段与工作 / 忙碌。
SeverityCritical = urgency.LevelCritical
)
+21 -1
View File
@@ -367,6 +367,7 @@ func (b *Backthink) think(parent context.Context, tr Trigger) {
// 以及发到哪个渠道。parseProactive 稳健地恢复该决策,
// 并失败安全(格式错乱/含糊 → 不推送)。
message, channelHint, push := parseProactive(conclusion)
roundImport := b.escalatedImport(tr, conclusion)
state := b.currentState(cctx)
lastActivity, hasActivity := b.lastUserActivity(cctx)
@@ -376,7 +377,7 @@ func (b *Backthink) think(parent context.Context, tr Trigger) {
// 频率门(PushGate)仍在推送器内部单独生效。
decision := b.Decide(DecisionInput{
Trigger: tr.Source,
Import: tr.Import,
Import: roundImport,
At: at,
UserState: state,
LastActivity: lastActivity,
@@ -422,6 +423,25 @@ func (b *Backthink) think(parent context.Context, tr Trigger) {
}
}
// escalatedImport 解出本轮打扰决策所用的重要度:触发自带的重要度,叠上她自己结论里
// 声明的紧急度(结构化标记 [紧急:...],见 parseProactiveUrgency)。这让她在主动思考轮
// 里读完外围插件数据、调过工具后,能声明「我认为这条紧急、要突破安静时段发出去」
// (design/12 §6 安全域豁免 + design/20 §2 P2)。
//
// 上抬会记一条日志,使这次穿透可追溯(谁、从哪档抬到哪档);声明只能上抬不能下压,
// 因此安全域事件既得的档位不会被一句 routine 抹掉。她自报不是无限通行证:频率门
// (PushGate)仍独立在推送器内部生效,心跳类轮次的推送照旧受限频约束。
func (b *Backthink) escalatedImport(tr Trigger, conclusion string) Importance {
declared := parseProactiveUrgency(conclusion)
escalated := escalateImport(tr.Import, declared)
if escalated != tr.Import {
b.log.Info("backthink declared urgency escalated round",
"source", tr.Source, "declared", declared.String(),
"from", tr.Import.String(), "to", escalated.String())
}
return escalated
}
// currentState 读取用户当前状况用于打扰决策,
// 提供者报错时退回中性状态(并记一条 warn 日志)。
func (b *Backthink) currentState(ctx context.Context) UserState {
+5 -3
View File
@@ -86,10 +86,11 @@ func thinkInput(recent []model.Message, trigger string) []model.Message {
// 授予的。
//
// 提示词要求一个紧凑的结构化收尾——push/no_push 决策、
// 可选的目标渠道与消息正文——好让服务尊重她自己的
// 可选的目标渠道、她自报的紧急度与消息正文——好让服务尊重她自己的
// 判断,而不是按触发的重要度把关(design/20 §2 P2)。该
// 格式是引导而非契约:她省略或
// 写乱该格式时,parseProactive 失败安全。
// 写乱该格式时,parseProactive / parseProactiveUrgency 失败安全
// (不推送、不上抬紧急度)。
//
// 启动轮(裁决 07.4.4,design/12 §1)走 startupPrompt:以角色口吻说一句拟人化的
// 「回来」问候。
@@ -107,7 +108,8 @@ func guidancePrompt(trigger string) string {
"请把「是否主动说」与「发到哪个渠道」写成一个简短、格式固定的结尾,解析规则如下:\n" +
"[我决定:push] 或 [我决定:no_push]\n" +
"[渠道:web|im|kook|telegram|weixin-ilink|...|auto](可省略,auto/省略即交给默认:每次只挑一个合适渠道——点名则优先,否则用她最近活跃的已连接渠道,含微信;不广播、不固定 web+Kook)\n" +
"上面两行之后,紧接你想说的话作为正文。\n" +
"[紧急:routine|warning|critical](可省略;只有当你判断这件事必须立刻告诉她、即便现在是她休息/忙碌的安静时段也要打扰时,才写 critical;普通情况省略)\n" +
"上面几行之后,紧接你想说的话作为正文。\n" +
"整轮思考保持简短、单轮,不反复自我触发。"
}
+2 -1
View File
@@ -78,7 +78,8 @@ func (s UserState) String() string {
type DecisionInput struct {
// Trigger 是可读的来源标签,为可追溯而记录。
Trigger string
// Import 是事件的估计重要度。
// Import 是事件的估计重要度,已叠上她自己在结论里声明的紧急度
// (见 Backthink.escalatedImport)。
Import Importance
// At 是当前墙钟时间,处于免打扰窗口所用的
// 本地时区。
@@ -0,0 +1,177 @@
package backthink
import (
"context"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/iot"
)
// 本文件守住 design/12 §6 安全域豁免的**生产端**闭环:IoT 事件的显式严重度 →
// Trigger.Import → 安静时段穿透。此前 ImportanceCritical 只有判定侧(decide.go)
// 而没有生产者,该豁免是死代码;这些用例覆盖整条路,摘掉映射即变红。
// TestImportanceForSeverity_Mapping 钉住严重度到思考轮重要度的映射:例行档保持
// Normal(默认档永不穿透安静时段),只有安全域拿到 Critical。
func TestImportanceForSeverity_Mapping(t *testing.T) {
t.Parallel()
cases := []struct {
in iot.AlertSeverity
want Importance
}{
{in: iot.SeverityRoutine, want: ImportanceNormal},
{in: iot.SeverityWarning, want: ImportanceHigh},
{in: iot.SeverityCritical, want: ImportanceCritical},
}
for _, tc := range cases {
tr := triggerForEvent(bus.Event{Subject: iot.DeviceUpdateSubject, Payload: iot.DeviceUpdate{
Device: iot.Device{ID: "lock.front", Type: "lock"},
Action: "unlock",
Severity: tc.in,
}})
if tr.Import != tc.want {
t.Fatalf("severity %v → Import = %v, want %v", tc.in, tr.Import, tc.want)
}
if tr.Kind != TriggerEvent {
t.Fatalf("severity %v → Kind = %v, want %v", tc.in, tr.Kind, TriggerEvent)
}
}
}
// TestDecide_NightSafetyEventBypassesQuietHours 断言凌晨的显式安全域 IoT 事件被
// 放行,且理由点明安全域穿透(裁决 1.8 / 07.4.2)。
func TestDecide_NightSafetyEventBypassesQuietHours(t *testing.T) {
t.Parallel()
tr := triggerForEvent(bus.Event{Subject: iot.DeviceUpdateSubject, Payload: iot.DeviceUpdate{
Device: iot.Device{ID: "lock.front", Type: "lock"},
Action: "unlock",
Severity: iot.SeverityCritical,
}})
in := DecisionInput{
Trigger: tr.Source, Import: tr.Import, At: nightAt(),
UserState: UserState{}, ConclusionPush: true,
}
if got := testBackthink().Decide(in); !got.ShouldPush {
t.Fatalf("Decide = %+v, want ShouldPush (critical safety event bypasses quiet hours)", got)
}
// Decide 放行时统一回泛化标签("proactive push approved"),安全域穿透这条
// 具体理由由硬门级联给出。两条一起断言,理由文案改动即会变红。
if got := testBackthink().baseDecide(in); !strings.Contains(got.Reason, "critical safety event bypasses") {
t.Fatalf("baseDecide reason = %q, want it to name the safety-domain bypass", got.Reason)
}
}
// TestDecide_NightRoutineEventHeld 是对照断言:同一条路上,未标注严重度的例行设备
// 变化在凌晨仍被安静时段扣下(默认档保守)。
func TestDecide_NightRoutineEventHeld(t *testing.T) {
t.Parallel()
tr := triggerForEvent(bus.Event{Subject: iot.DeviceUpdateSubject, Payload: iot.DeviceUpdate{
Device: iot.Device{ID: "washer", Type: "sensor"}, Action: "finished",
}})
if tr.Import != ImportanceNormal {
t.Fatalf("unlabelled device update → Import = %v, want %v", tr.Import, ImportanceNormal)
}
got := testBackthink().Decide(DecisionInput{
Trigger: tr.Source, Import: tr.Import, At: nightAt(),
UserState: UserState{}, ConclusionPush: true,
})
if got.ShouldPush {
t.Fatalf("Decide = %+v, want the routine event held in quiet hours", got)
}
if !strings.Contains(got.Reason, "quiet hours") {
t.Fatalf("Reason = %q, want it to mention quiet hours", got.Reason)
}
}
// newIoTWiredBackthink 用同一条进程内 bus 把真实的 iot.Manager 与 backthink 接起来:
// 测试因此走的是生产路径(Manager.Control → bus → onEvent → think → Decide),
// 而不是手搓一个 Trigger。
func newIoTWiredBackthink(t *testing.T, thinker Thinker, pusher Pusher) (*Backthink, *iot.Manager) {
t.Helper()
bs := bus.New()
mgr, err := iot.New(config.IoTConfig{Provider: "mock"}, nil, iot.WithBus(bs))
if err != nil {
t.Fatalf("iot.New: %v", err)
}
b, _ := newBackthink(t, Config{}, Deps{
Bus: bs,
Thinker: thinker,
State: &mockState{state: UserState{}},
Pusher: pusher,
Now: func() time.Time { return nightAt() },
})
b.Start(context.Background())
// 启动轮(07.4.4)先落库:它是低重要度心跳类,凌晨被照常扣下,
// 因此这之后出现的推送只可能来自安全域事件。
eventually(t, 2*time.Second, func() bool {
recs, _ := b.List(context.Background(), 10)
return len(recs) >= 1 && recs[0].Trigger == "startup"
})
return b, mgr
}
// TestBackthink_AcuteIoTEventNightPush 是主人原话「大半夜急性 IoT 事件就得主动发消息」
// 的端到端回归:凌晨开启门锁(安全域)→ 走过整条生产路径 → 主动推送发出。
func TestBackthink_AcuteIoTEventNightPush(t *testing.T) {
t.Parallel()
thinker := &mockThinker{text: "[我决定:push]\n门口有动静,我去确认一下"}
pusher := &mockPusher{result: PushResult{Pushed: true, Route: RouteActiveClient}}
b, mgr := newIoTWiredBackthink(t, thinker, pusher)
if _, err := mgr.Control(context.Background(), "lock.front", "unlock", nil); err != nil {
t.Fatalf("Control: %v", err)
}
eventually(t, 2*time.Second, func() bool { return pusher.count() >= 1 })
if got := pusher.count(); got != 1 {
t.Fatalf("pusher calls = %d, want 1 (one acute safety event)", got)
}
recs, err := b.List(context.Background(), 10)
if err != nil {
t.Fatalf("List: %v", err)
}
if recs[0].Trigger != iot.DeviceUpdateSubject || !recs[0].ShouldPush || !recs[0].PushResult.Pushed {
t.Fatalf("newest record = %+v, want a delivered push for the safety event", recs[0])
}
if !strings.Contains(recs[0].Conclusion, "门口有动静") {
t.Fatalf("record conclusion = %q, want her own words", recs[0].Conclusion)
}
}
// TestBackthink_RoutineIoTEventNightHeld 是对照:同一条生产路径上,凌晨的常规设备
// 变化(未命中安全规则)仍被安静时段扣下——本次改动没有把 IoT 事件整体升级。
func TestBackthink_RoutineIoTEventNightHeld(t *testing.T) {
t.Parallel()
thinker := &mockThinker{text: "[我决定:push]\n客厅灯开着呢"}
pusher := &mockPusher{result: PushResult{Pushed: true, Route: RouteActiveClient}}
b, mgr := newIoTWiredBackthink(t, thinker, pusher)
if _, err := mgr.Control(context.Background(), "light.living", "on", nil); err != nil {
t.Fatalf("Control: %v", err)
}
var rec Record
eventually(t, 2*time.Second, func() bool {
recs, _ := b.List(context.Background(), 10)
for _, r := range recs {
if r.Trigger == iot.DeviceUpdateSubject {
rec = r
return true
}
}
return false
})
if got := pusher.count(); got != 0 {
t.Fatalf("pusher calls = %d, want 0 (routine device change held in quiet hours)", got)
}
if rec.ShouldPush || rec.PushResult.Route != RouteNone {
t.Fatalf("record = %+v, want a held record", rec)
}
if !strings.Contains(rec.PushResult.Detail, "quiet hours") {
t.Fatalf("PushResult.Detail = %q, want it to mention quiet hours", rec.PushResult.Detail)
}
}
+41 -3
View File
@@ -4,6 +4,8 @@ import (
"regexp"
"strings"
"unicode"
"git.yeij.top/AskaEth/Cyrene_v2/internal/urgency"
)
// 自主思考被引导(context.go 的 guidancePrompt)以如下紧凑、
@@ -12,6 +14,7 @@ import (
//
// [我决定:push] (或 no_push)
// [渠道:web|im|kook|telegram|...|auto]
// [紧急:routine|warning|critical] (可省略;见 parseProactiveUrgency)
// <温暖的正文内容>
//
// 这些标记按纯文本解析,因此模型稍有不同地
@@ -25,6 +28,9 @@ var (
decideMarker = regexp.MustCompile(`(?i)\[\s*我决定\s*[::]\s*(no_push|push)\s*\]`)
// channelMarker 匹配决策行上的目标渠道词。
channelMarker = regexp.MustCompile(`(?i)\[\s*渠道\s*[::]\s*([a-z0-9_\-]+)\s*\]`)
// urgencyMarker 匹配她自报的紧急度档位词。它是**结构化**通道:只认固定枚举,
// 绝不从散文里解读意图(项目红线);解析严格(必须带括号)=失败安全。
urgencyMarker = regexp.MustCompile(`(?i)\[\s*紧急\s*[::]\s*(routine|warning|critical)\s*\]`)
// decidePrefixes 是「尝试了决策但无法解析」的标记形状,
// 这类轮次被视为含糊(失败安全)。它
// 刻意只含带括号形式,因此仅仅在散文中出现
@@ -36,8 +42,14 @@ var (
// 与全角括号形式处理,因此格式良好的决策永不泄漏。
stripDecideRe = regexp.MustCompile(`(?i)[【\[]\s*我决定\s*[::]\s*(no_push|push)\s*[】\]]`)
stripChannelRe = regexp.MustCompile(`(?i)[【\[]\s*渠道\s*[::]\s*[a-z0-9_\-]+\s*[】\]]`)
// stripUrgencyRe 删掉带括号的紧急度标记,档位值不限枚举:她写成枚举外的词时
// 解析侧不上抬(保守),面向用户的正文里也绝不能留着这段内部标记。
stripUrgencyRe = regexp.MustCompile(`(?i)[【\[]\s*紧急\s*[::]\s*[^】\]\s]+\s*[】\]]`)
bareDecideRe = regexp.MustCompile(`(?i)我决定\s*[::]\s*(no_push|push)`)
bareChannelRe = regexp.MustCompile(`(?i)渠道\s*[::]\s*[a-z0-9_\-]+`)
// bareUrgencyRe 只认枚举档位:散文里「紧急:」这类词很常见,
// 不该因为恰好紧跟一个英文词就被删掉。
bareUrgencyRe = regexp.MustCompile(`(?i)紧急\s*[::]\s*(routine|warning|critical)`)
)
// nonePhrases 是没有标记时表示「无可汇报」的结论。
@@ -61,6 +73,8 @@ var nonePhrases = []string{
// P2 之前的默认一致。
// - 渠道提示是 [渠道:...] 词,转小写;为空/auto 则保持
// 默认的活跃客户端优先、IM 降级路由。
// - 她自报的紧急度由 parseProactiveUrgency 单独恢复(固定枚举的
// 结构化标记,绝不从散文解读)。
// - 决策为推送但正文为空或仍读作「无」 → 不推送
// (失败安全),因此单独一个标记绝不会推出空消息。
func parseProactive(conclusion string) (message, channelHint string, push bool) {
@@ -83,6 +97,28 @@ func parseProactive(conclusion string) (message, channelHint string, push bool)
return message, channelHint, push
}
// parseProactiveUrgency 从结论的结构化尾段恢复她自报的紧急度 —— 这是通用紧急度通道
// 的一种生产者形式(「她自己」):主动思考轮可以读外围插件数据、调工具,因此她要能
// 声明「我认为这条紧急、要突破安静时段发出去」。
//
// 它只认固定枚举 [紧急:routine|warning|critical],绝不从散文/自由文本里解读意图
// (项目红线:判断要结构化)。没写、格式错乱或写了枚举外的档位一律得到零值
// LevelRoutine(即不上抬)—— 保守默认优于猜测,误判只可能少打扰、不会多打扰。
func parseProactiveUrgency(conclusion string) urgency.Level {
m := urgencyMarker.FindStringSubmatch(conclusion)
if m == nil {
return urgency.LevelRoutine
}
switch strings.ToLower(m[1]) {
case "critical":
return urgency.LevelCritical
case "warning":
return urgency.LevelWarning
default:
return urgency.LevelRoutine
}
}
// matchDecide 返回匹配到的决策词(已转小写)以及
// 是否存在格式良好的决策标记。
func matchDecide(text string) (string, bool) {
@@ -118,10 +154,10 @@ func matchChannel(text string) string {
return strings.ToLower(m[1])
}
// stripInternalMarkers 移除结构化的决策/渠道标记,因此
// stripInternalMarkers 移除结构化的决策/渠道/紧急度标记,因此
// 主动消息正文绝不会把它们带给用户。它处理预期的
// 带括号形式([我决定:push][渠道:kook])以及模型可能发出的漂移形式——
// 裸的 "我决定:push" / "渠道:kook"(无括号)与全角 【我决定:push】——然后
// 带括号形式([我决定:push][渠道:kook][紧急:critical])以及模型可能发出的漂移形式——
// 裸的 "我决定:push" / "渠道:kook" / "紧急:critical"(无括号)与全角 【我决定:push】——然后
// 丢弃因此变空的行并压缩剩余部分。它既在
// parseProactive 内部应用(使消息与记录干净),又作为
// buildProactiveMessage 里的最后一道守护,因此无论标记如何
@@ -133,8 +169,10 @@ func stripInternalMarkers(text string) string {
for _, ln := range lines {
ln = stripDecideRe.ReplaceAllString(ln, "")
ln = stripChannelRe.ReplaceAllString(ln, "")
ln = stripUrgencyRe.ReplaceAllString(ln, "")
ln = bareDecideRe.ReplaceAllString(ln, "")
ln = bareChannelRe.ReplaceAllString(ln, "")
ln = bareUrgencyRe.ReplaceAllString(ln, "")
ln = strings.TrimSpace(ln)
if ln == "" {
continue
+52 -4
View File
@@ -5,6 +5,7 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
"git.yeij.top/AskaEth/Cyrene_v2/internal/iot"
"git.yeij.top/AskaEth/Cyrene_v2/internal/urgency"
)
// TriggerKind 归类唤醒一轮思考的原因,好让推送路径套用
@@ -50,6 +51,10 @@ const (
// 平台噪声)被降级为仅登记一条记录,不唤醒
// 一整轮思考;只有高重要度(与主人相关)的系统事件才思考,
// 因此繁忙的公开平台无法触发昂贵反思的洪流。
//
// 它是「载荷自带重要度」这条通用通道的既有形式;生产者的紧急度若以
// urgency.Declarer 声明(IoT 的设备跃迁即如此),由 triggerForEvent 折算到
// 同一个 Trigger.Import 字段上,两条形式因此落到同一套判定。
type SystemEvent struct {
Summary string
Import Importance
@@ -84,11 +89,23 @@ func startupTrigger() Trigger {
return Trigger{Source: "startup", Summary: StartupSummary, Import: ImportanceLow, Kind: TriggerHeartbeat}
}
// triggerForEvent 把总线事件映射为 Trigger。IoT 设备变化按
// 普通重要度处理,提醒触发按高重要度,泛化的
// 系统事件按普通重要度。每个事件都打上 TriggerEvent,因此其
// 主动推送绕过心跳的 PushGate。
// triggerForEvent 把总线事件映射为 Trigger:主题给出可读摘要与主题级默认重要度,
// 随后**通用通道**统一解出重要度 —— 任何事件载荷只要实现 urgency.Declarer
// (IoT 的 DeviceUpdate 是第一个使用者),它声明的紧急度就被承认;泛化系统事件沿用
// 它既有的 SystemEvent.Import 口径。两条形式最终都落在同一个 Trigger.Import 字段上,
// 判定侧(decide.go)因此只认档位、不认生产者。每个事件都打上 TriggerEvent,
// 因此其主动推送绕过心跳的 PushGate。
func triggerForEvent(e bus.Event) Trigger {
tr := triggerForSubject(e)
if d, ok := e.Payload.(urgency.Declarer); ok {
tr.Import = escalateImport(tr.Import, d.Urgency())
}
return tr
}
// triggerForSubject 给出各主题的可读摘要与主题级默认重要度(未声明紧急度的事件就
// 停在这一档)。它刻意不对任何生产者做专有判定:重要度已由通用通道统一解出。
func triggerForSubject(e bus.Event) Trigger {
switch e.Subject {
case iot.DeviceUpdateSubject:
if d, ok := e.Payload.(iot.DeviceUpdate); ok {
@@ -115,3 +132,34 @@ func triggerForEvent(e bus.Event) Trigger {
return Trigger{Source: e.Subject, Summary: e.Subject, Import: ImportanceNormal, Kind: TriggerEvent}
}
}
// importanceForUrgency 把生产者声明的紧急度折算为思考轮的重要度。它是通用通道与
// 判定侧之间**唯一**的换算点(design/12 §6 安全域豁免,裁决 1.8 / 07.4.2):零值
// Routine 折算 Normal —— 默认档永不获得穿透安静时段的资格;只有生产者显式声明
// critical 的安全域事件拿到 ImportanceCritical,从而穿透安静时段与工作 / 忙碌。
func importanceForUrgency(l urgency.Level) Importance {
switch l {
case urgency.LevelCritical:
return ImportanceCritical
case urgency.LevelWarning:
return ImportanceHigh
default:
return ImportanceNormal
}
}
// escalateImport 把声明的紧急度叠到基础重要度上。声明只能**上抬**、绝不压低,因此
// 主题级默认(如提醒的 High)与安全域事件既得的档位不会被一句 routine 抹掉;
// 未声明(零值 LevelRoutine)时原样返回 base,没标紧急度的事件行为逐字不变。
//
// 两个调用方共用它:总线事件的载荷声明(triggerForEvent),以及她自己在主动思考轮
// 结论里声明「我认为这条紧急」(backthink.go 的 escalatedImport)。
func escalateImport(base Importance, declared urgency.Level) Importance {
if declared == urgency.LevelRoutine {
return base
}
if up := importanceForUrgency(declared); up > base {
return up
}
return base
}
@@ -0,0 +1,283 @@
package backthink
import (
"context"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
"git.yeij.top/AskaEth/Cyrene_v2/internal/urgency"
)
// 本文件守住 design/12 §6 安全域豁免的**通用性**:紧急度不是 IoT 专有通道 ——
// 任何事件生产者(外围插件、系统 / 能力模块)都能用同一个 urgency.Declarer 声明
// 紧急度,她自己也能在结论里结构化地声明「这条要穿透安静时段」;摘掉通用通道或
// 那条自我声明,这些用例即变红。
// stubProducer 是一个**非 IoT** 的事件生产者替身:它只用通用通道声明紧急度,
// backthink 完全不认识它的类型(没有任何为它写下的分支)。它经**泛化系统事件主题**
// 接入——那是契约里给任意生产者的入口(design/12 §1)。
type stubProducer struct {
Summary string
Level urgency.Level
}
func (s stubProducer) Urgency() urgency.Level { return s.Level }
// stubProducerSubject 是替身生产者自己的总线主题。它刻意不是 IoT 的主题,用来证明
// 紧急度折算与主题无关;注意:真要在总线上收到一个**新主题**,仍需在 backthink 的
// triggers() 里登记一行订阅(那是订阅面,不是紧急度通道的形状)。
const stubProducerSubject = "plugin.widget.state"
// TestTriggerForEvent_GenericProducerChannel 断言任意生产者的紧急度声明都经同一条
// 通用通道折算到 Trigger.Import,且与主题无关:未声明 → Normal(默认保守),
// warning → High,critical → Critical;声明只能上抬,绝不压低主题自带的默认档
// (提醒仍是 High)。
func TestTriggerForEvent_GenericProducerChannel(t *testing.T) {
t.Parallel()
cases := []struct {
name string
subject string
level urgency.Level
want Importance
}{
{name: "未声明_普通", subject: stubProducerSubject, level: urgency.LevelRoutine, want: ImportanceNormal},
{name: "warning", subject: stubProducerSubject, level: urgency.LevelWarning, want: ImportanceHigh},
{name: "critical", subject: stubProducerSubject, level: urgency.LevelCritical, want: ImportanceCritical},
{name: "critical_经系统事件主题", subject: SubjectSystemEvent, level: urgency.LevelCritical, want: ImportanceCritical},
{name: "未声明_不压低提醒的默认档", subject: SubjectReminderTrigger, level: urgency.LevelRoutine, want: ImportanceHigh},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
tr := triggerForEvent(bus.Event{Subject: tc.subject, Payload: stubProducer{Summary: "widget changed", Level: tc.level}})
if tr.Import != tc.want {
t.Fatalf("Import = %v, want %v", tr.Import, tc.want)
}
if tr.Kind != TriggerEvent {
t.Fatalf("Kind = %v, want %v", tr.Kind, TriggerEvent)
}
})
}
}
// TestDecide_GenericProducerQuietHours 断言非 IoT 生产者的声明在安静时段同样生效:
// critical 穿透,未声明照常扣下。判定侧不改,穿透靠的是事件真的走到了那一档。
func TestDecide_GenericProducerQuietHours(t *testing.T) {
t.Parallel()
cases := []struct {
name string
level urgency.Level
want bool
}{
{name: "critical_穿透", level: urgency.LevelCritical, want: true},
{name: "未声明_扣下", level: urgency.LevelRoutine, want: false},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
tr := triggerForEvent(bus.Event{Subject: SubjectSystemEvent, Payload: stubProducer{Level: tc.level}})
got := testBackthink().Decide(DecisionInput{
Trigger: tr.Source, Import: tr.Import, At: nightAt(),
UserState: UserState{}, ConclusionPush: true,
})
if got.ShouldPush != tc.want {
t.Fatalf("Decide = %+v, want ShouldPush=%v", got, tc.want)
}
})
}
}
// newStubWiredBackthink 把替身生产者接到同一条进程内 bus 与 backthink 上,用例因此
// 走生产路径(publish → onEvent → think → Decide → push)。
func newStubWiredBackthink(t *testing.T, thinker Thinker, pusher Pusher) (*Backthink, bus.Bus) {
t.Helper()
bs := bus.New()
b, _ := newBackthink(t, Config{}, Deps{
Bus: bs,
Thinker: thinker,
State: &mockState{state: UserState{}},
Pusher: pusher,
Now: func() time.Time { return nightAt() },
})
b.Start(context.Background())
// 启动轮(07.4.4)先落库:低重要度心跳类,凌晨被照常扣下 ——
// 因此这之后出现的推送只可能来自替身生产者的事件。
eventually(t, 2*time.Second, func() bool {
recs, _ := b.List(context.Background(), 10)
return len(recs) >= 1 && recs[0].Trigger == "startup"
})
return b, bs
}
// recordFor 等指定触发源的一轮思考落库并返回那条记录(最新在前)。
func recordFor(t *testing.T, b *Backthink, source string) Record {
t.Helper()
var rec Record
eventually(t, 2*time.Second, func() bool {
recs, _ := b.List(context.Background(), 10)
for _, r := range recs {
if r.Trigger == source {
rec = r
return true
}
}
return false
})
return rec
}
// TestBackthink_ThirdPartyCriticalNightPush 是「非 IoT 生产者也能穿透安静时段」的
// 端到端断言:替身生产者经泛化系统事件主题声明 critical → 主动推送发出。
func TestBackthink_ThirdPartyCriticalNightPush(t *testing.T) {
t.Parallel()
pusher := &mockPusher{result: PushResult{Pushed: true, Route: RouteActiveClient}}
b, bs := newStubWiredBackthink(t, &mockThinker{text: "[我决定:push]\n厨房的燃气报警响了,我去看看"}, pusher)
if err := bs.Publish(context.Background(), SubjectSystemEvent, stubProducer{Summary: "gas alarm", Level: urgency.LevelCritical}); err != nil {
t.Fatalf("Publish: %v", err)
}
rec := recordFor(t, b, SubjectSystemEvent)
if !rec.ShouldPush || !rec.PushResult.Pushed {
t.Fatalf("record = %+v, want a delivered push for the third-party critical event", rec)
}
}
// TestBackthink_ThirdPartyRoutineNightHeld 是对照:同一条通用通道上未声明紧急度的
// 第三方事件在凌晨既不穿透(层 2 的判定),也因 design/12 §6 策略一被降级为仅登记
// ——通用化没有把所有第三方事件都变成可穿透。
func TestBackthink_ThirdPartyRoutineNightHeld(t *testing.T) {
t.Parallel()
pusher := &mockPusher{result: PushResult{Pushed: true, Route: RouteActiveClient}}
b, bs := newStubWiredBackthink(t, &mockThinker{text: "[我决定:push]\n窗边的小灯闪了一下"}, pusher)
if err := bs.Publish(context.Background(), SubjectSystemEvent, stubProducer{Summary: "widget tick"}); err != nil {
t.Fatalf("Publish: %v", err)
}
rec := recordFor(t, b, SubjectSystemEvent)
if rec.ShouldPush || rec.PushResult.Route != RouteNone {
t.Fatalf("record = %+v, want a held record", rec)
}
if !strings.Contains(rec.PushResult.Detail, "downgraded") {
t.Fatalf("PushResult.Detail = %q, want the low-value downgrade to be named", rec.PushResult.Detail)
}
if pusher.count() != 0 {
t.Fatalf("pusher calls = %d, want 0", pusher.count())
}
}
// TestParseProactiveUrgency_StructuredOnly 钉住她自己那条声明是**结构化**通道:
// 只认固定枚举的带括号标记,散文里的「紧急」不触发(项目红线:绝不从自由文本
// 解读意图),枚举外的档位一律不上抬(保守)。
func TestParseProactiveUrgency_StructuredOnly(t *testing.T) {
t.Parallel()
cases := []struct {
name string
in string
want urgency.Level
}{
{name: "critical", in: "[我决定:push]\n[紧急:critical]\n院里有人影", want: urgency.LevelCritical},
{name: "warning_全角冒号大小写混写", in: "[紧急:Warning]\n设备离线了", want: urgency.LevelWarning},
{name: "routine", in: "[紧急:routine]\n例行汇报", want: urgency.LevelRoutine},
{name: "未声明", in: "[我决定:push]\n晚安", want: urgency.LevelRoutine},
{name: "散文里的紧急不算", in: "情况有点紧急,我得马上告诉你", want: urgency.LevelRoutine},
{name: "裸写不算", in: "紧急:critical\n有点事", want: urgency.LevelRoutine},
{name: "枚举外的档位不上抬", in: "[紧急:urgent]\n有点事", want: urgency.LevelRoutine},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := parseProactiveUrgency(tc.in); got != tc.want {
t.Fatalf("parseProactiveUrgency(%q) = %v, want %v", tc.in, got, tc.want)
}
})
}
// 该标记是内部决策标记:绝不能存活到面向用户的正文里。
msg, _, _ := parseProactive("[我决定:push]\n[紧急:critical]\n院里有人影")
if strings.Contains(msg, "紧急") {
t.Fatalf("visible message = %q, want the internal urgency marker stripped", msg)
}
}
// TestEscalateImport_OnlyRaises 钉住声明只能上抬:安全域事件既得的档位不会被
// 一句 routine 抹掉,未声明时基础档位逐字不变。
func TestEscalateImport_OnlyRaises(t *testing.T) {
t.Parallel()
cases := []struct {
name string
base Importance
declared urgency.Level
want Importance
}{
{name: "未声明_不变", base: ImportanceNormal, declared: urgency.LevelRoutine, want: ImportanceNormal},
{name: "心跳声明critical_上抬", base: ImportanceLow, declared: urgency.LevelCritical, want: ImportanceCritical},
{name: "声明warning_上抬", base: ImportanceNormal, declared: urgency.LevelWarning, want: ImportanceHigh},
{name: "声明routine_压不低critical", base: ImportanceCritical, declared: urgency.LevelRoutine, want: ImportanceCritical},
{name: "声明warning_不压低high", base: ImportanceHigh, declared: urgency.LevelWarning, want: ImportanceHigh},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := escalateImport(tc.base, tc.declared); got != tc.want {
t.Fatalf("escalateImport(%v, %v) = %v, want %v", tc.base, tc.declared, got, tc.want)
}
})
}
}
// TestBackthink_SelfDeclaredUrgencyBypassesQuietHoursButStaysGated 覆盖她那条通道的
// 两半:心跳轮里声明 critical 能穿透安静时段(design/12 §6 安全域豁免),但推送仍受
// 频率门(PushGate)兜底 —— 自我声明不是无限通行证;不声明则连安静时段都过不去。
func TestBackthink_SelfDeclaredUrgencyBypassesQuietHoursButStaysGated(t *testing.T) {
t.Parallel()
im := &mockIMAdapter{}
b, _ := newBackthink(t, Config{}, Deps{
Thinker: &mockThinker{text: "[我决定:push]\n[紧急:critical]\n院里的感应灯莫名亮了好几次"},
State: &mockState{state: UserState{}},
IM: im,
Now: func() time.Time { return nightAt() },
})
b.think(context.Background(), heartbeatTrigger())
b.think(context.Background(), heartbeatTrigger())
if got := im.count(); got != 1 {
t.Fatalf("IM pushes = %d, want 1 (first bypasses quiet hours, second held by the push gate)", got)
}
recs, err := b.List(context.Background(), 5)
if err != nil {
t.Fatalf("List: %v", err)
}
if !recs[0].ShouldPush || recs[0].PushResult.Route != RouteRateLimited {
t.Fatalf("second round = %+v, want a decision to push blocked by the rate gate", recs[0])
}
}
// TestBackthink_UndeclaredHeartbeatNightHeld 是对照:同一时段、同一心跳轮,
// 没有声明紧急度时照旧被安静时段扣下 —— 默认行为逐字不变。
func TestBackthink_UndeclaredHeartbeatNightHeld(t *testing.T) {
t.Parallel()
im := &mockIMAdapter{}
b, _ := newBackthink(t, Config{}, Deps{
Thinker: &mockThinker{text: "[我决定:push]\n我有点想她了"},
State: &mockState{state: UserState{}},
IM: im,
Now: func() time.Time { return nightAt() },
})
b.think(context.Background(), heartbeatTrigger())
recs, err := b.List(context.Background(), 5)
if err != nil {
t.Fatalf("List: %v", err)
}
if recs[0].ShouldPush || im.count() != 0 {
t.Fatalf("record = %+v, pushes = %d, want the undeclared heartbeat held in quiet hours", recs[0], im.count())
}
if !strings.Contains(recs[0].PushResult.Detail, "quiet hours") {
t.Fatalf("PushResult.Detail = %q, want it to mention quiet hours", recs[0].PushResult.Detail)
}
}
+61
View File
@@ -0,0 +1,61 @@
// Package urgency 定义 Cyrene 全局通用的「事件紧急度」词汇,以及事件生产者声明它
// 的契约。
//
// 它是一条**通用通道**:任何事件生产者 —— IoT(第一个使用者,规则表见
// internal/iot/safety.go)、其它外围插件、系统 / 能力模块 —— 都可以在发布到进程内
// bus 的载荷上实现 Declarer,声明「这次事件有多紧急」。backthink 在唯一一处
// (trigger.go 的 triggerForEvent)把该声明与既有的 SystemEvent.Import 口径一起折算
// 进 Trigger.Import,因此安全域(critical)事件能穿透安静时段与工作 / 忙碌
// (design/12 §2 §6 安全域豁免,裁决 1.8 / 07.4.2),而 backthink 绝不需要认识任何
// 生产者(IoT 或插件)的专有字段。
//
// 本包刻意是叶子包(只依赖 fmt):生产者不必为了声明紧急度而依赖 backthink
// (backthink 依赖 iot,那样会成环),backthink 也不必为每个生产者各加一条分支。
package urgency
import "fmt"
// Level 是一次事件的显式紧急度,由产生该事件的生产者 *显式* 声明。零值是
// LevelRoutine,因此未声明紧急度的事件一律落在例行档:本通道不会让常规事件获得
// 穿透安静时段的资格(默认保守,design/12 §6 安全域豁免的反面保证)。
//
// 它只回答「这次事件有多紧急」,不表达身份、权限或能力边界 —— 身份与权限永远由
// channel 层的 actor 元数据与策略引擎决定。
type Level int
const (
// LevelRoutine 是例行变化(普通设备状态跃迁、低价值系统噪声)。它折算为
// backthink 的 ImportanceNormal:安静时段照常扣下。
LevelRoutine Level = iota
// LevelWarning 是值得注意但非安全域的异常(设备离线、耗材将尽)。它折算为
// ImportanceHigh:沿袭既有级联语义通过安静时段,但不穿透工作 / 忙碌。
LevelWarning
// LevelCritical 是安全域事件(入侵、门锁异常开启、烟雾 / 漏水告警)。
// 它折算为 ImportanceCritical:穿透安静时段与工作 / 忙碌。
LevelCritical
)
// String 返回用于日志与记录(审计轨迹、思考记录、前端小字)的稳定标签。
func (l Level) String() string {
switch l {
case LevelRoutine:
return "routine"
case LevelWarning:
return "warning"
case LevelCritical:
return "critical"
default:
return fmt.Sprintf("urgency(%d)", int(l))
}
}
// Declarer 是「事件载荷自带紧急度」的通用契约。事件载荷实现它,backthink 就会在
// 通用通道上承认其声明的紧急度,无需为该生产者另加分支 —— 生产者有多少个都不影响
// 判定侧的形状。
//
// 实现方应当保持保守:只有语义上确属安全域的事件才返回 LevelCritical,其余一律
// 落在零值 LevelRoutine(那样安静时段照常扣下)。
type Declarer interface {
// Urgency 返回这次事件的紧急度;未声明时返回零值 LevelRoutine。
Urgency() Level
}
+41
View File
@@ -0,0 +1,41 @@
package urgency
import "testing"
// zeroDeclarer 是一个未声明紧急度的生产者替身:它只实现契约本身。
type zeroDeclarer struct{}
func (zeroDeclarer) Urgency() Level { return LevelRoutine }
// TestLevel_ZeroValueIsRoutine 钉住本通道的默认保守:未声明(零值)与显式声明
// routine 同义,因此既有的、没标紧急度的事件生产者行为逐字不变。
func TestLevel_ZeroValueIsRoutine(t *testing.T) {
t.Parallel()
var l Level
if l != LevelRoutine {
t.Fatalf("zero Level = %v, want %v", l, LevelRoutine)
}
var d Declarer = zeroDeclarer{}
if got := d.Urgency(); got != LevelRoutine {
t.Fatalf("undeclared urgency = %v, want %v", got, LevelRoutine)
}
}
// TestLevel_String 钉住标签稳定(日志、审计与思考记录依赖它)。
func TestLevel_String(t *testing.T) {
t.Parallel()
cases := []struct {
in Level
want string
}{
{in: LevelRoutine, want: "routine"},
{in: LevelWarning, want: "warning"},
{in: LevelCritical, want: "critical"},
{in: Level(7), want: "urgency(7)"},
}
for _, tc := range cases {
if got := tc.in.String(); got != tc.want {
t.Fatalf("Level(%d).String() = %q, want %q", int(tc.in), got, tc.want)
}
}
}