From f91a83ff81aa2fd65aa35e061646966e2b279434 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 5 Oct 2026 13:11:04 +0800 Subject: [PATCH] =?UTF-8?q?feat(urgency):=20=E9=80=9A=E7=94=A8=E7=B4=A7?= =?UTF-8?q?=E6=80=A5=E5=BA=A6=E9=80=9A=E9=81=93=EF=BC=88Declarer/Level=20+?= =?UTF-8?q?=20=E5=8F=AA=E6=8A=AC=E4=B8=8D=E5=8E=8B=E6=8A=98=E7=AE=97?= =?UTF-8?q?=EF=BC=89+=20IoT=20=E9=A6=96=E4=B8=AA=E7=94=9F=E4=BA=A7?= =?UTF-8?q?=E8=80=85=20+=20=E5=A5=B9=E8=87=AA=E5=B7=B1=E7=9A=84=E7=BB=93?= =?UTF-8?q?=E8=AE=BA=E8=B5=B0=E7=BB=93=E6=9E=84=E5=8C=96=E6=A0=87=E8=AE=B0?= =?UTF-8?q?=E7=A9=BF=E9=80=8F=E5=AE=89=E9=9D=99=E6=97=B6=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/iot/device.go | 16 +- internal/iot/manager.go | 12 +- internal/iot/safety.go | 33 ++ internal/iot/safety_test.go | 146 +++++++++ internal/iot/severity.go | 30 ++ internal/services/backthink/backthink.go | 22 +- internal/services/backthink/context.go | 8 +- internal/services/backthink/decide.go | 3 +- .../services/backthink/iot_safety_test.go | 177 +++++++++++ internal/services/backthink/proactive.go | 44 ++- internal/services/backthink/trigger.go | 56 +++- .../backthink/urgency_channel_test.go | 283 ++++++++++++++++++ internal/urgency/urgency.go | 61 ++++ internal/urgency/urgency_test.go | 41 +++ 14 files changed, 917 insertions(+), 15 deletions(-) create mode 100644 internal/iot/safety.go create mode 100644 internal/iot/safety_test.go create mode 100644 internal/iot/severity.go create mode 100644 internal/services/backthink/iot_safety_test.go create mode 100644 internal/services/backthink/urgency_channel_test.go create mode 100644 internal/urgency/urgency.go create mode 100644 internal/urgency/urgency_test.go diff --git a/internal/iot/device.go b/internal/iot/device.go index 748eeee..eba5801 100644 --- a/internal/iot/device.go +++ b/internal/iot/device.go @@ -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 } diff --git a/internal/iot/manager.go b/internal/iot/manager.go index 626bd44..ddc16fd 100644 --- a/internal/iot/manager.go +++ b/internal/iot/manager.go @@ -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, diff --git a/internal/iot/safety.go b/internal/iot/safety.go new file mode 100644 index 0000000..047f6ff --- /dev/null +++ b/internal/iot/safety.go @@ -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 + } +} diff --git a/internal/iot/safety_test.go b/internal/iot/safety_test.go new file mode 100644 index 0000000..e98811c --- /dev/null +++ b/internal/iot/safety_test.go @@ -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) + } + }) + } +} diff --git a/internal/iot/severity.go b/internal/iot/severity.go new file mode 100644 index 0000000..c2bf23b --- /dev/null +++ b/internal/iot/severity.go @@ -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 +) diff --git a/internal/services/backthink/backthink.go b/internal/services/backthink/backthink.go index e973210..5ad1fa8 100644 --- a/internal/services/backthink/backthink.go +++ b/internal/services/backthink/backthink.go @@ -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 { diff --git a/internal/services/backthink/context.go b/internal/services/backthink/context.go index 7209c64..2dea57e 100644 --- a/internal/services/backthink/context.go +++ b/internal/services/backthink/context.go @@ -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" + "整轮思考保持简短、单轮,不反复自我触发。" } diff --git a/internal/services/backthink/decide.go b/internal/services/backthink/decide.go index 7b04ec0..680c747 100644 --- a/internal/services/backthink/decide.go +++ b/internal/services/backthink/decide.go @@ -78,7 +78,8 @@ func (s UserState) String() string { type DecisionInput struct { // Trigger 是可读的来源标签,为可追溯而记录。 Trigger string - // Import 是事件的估计重要度。 + // Import 是事件的估计重要度,已叠上她自己在结论里声明的紧急度 + // (见 Backthink.escalatedImport)。 Import Importance // At 是当前墙钟时间,处于免打扰窗口所用的 // 本地时区。 diff --git a/internal/services/backthink/iot_safety_test.go b/internal/services/backthink/iot_safety_test.go new file mode 100644 index 0000000..c1a0689 --- /dev/null +++ b/internal/services/backthink/iot_safety_test.go @@ -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) + } +} diff --git a/internal/services/backthink/proactive.go b/internal/services/backthink/proactive.go index 2e08fc7..85a434b 100644 --- a/internal/services/backthink/proactive.go +++ b/internal/services/backthink/proactive.go @@ -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 diff --git a/internal/services/backthink/trigger.go b/internal/services/backthink/trigger.go index e65eb05..77b28d4 100644 --- a/internal/services/backthink/trigger.go +++ b/internal/services/backthink/trigger.go @@ -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 +} diff --git a/internal/services/backthink/urgency_channel_test.go b/internal/services/backthink/urgency_channel_test.go new file mode 100644 index 0000000..6041202 --- /dev/null +++ b/internal/services/backthink/urgency_channel_test.go @@ -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) + } +} diff --git a/internal/urgency/urgency.go b/internal/urgency/urgency.go new file mode 100644 index 0000000..75f4f88 --- /dev/null +++ b/internal/urgency/urgency.go @@ -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 +} diff --git a/internal/urgency/urgency_test.go b/internal/urgency/urgency_test.go new file mode 100644 index 0000000..0ae6a24 --- /dev/null +++ b/internal/urgency/urgency_test.go @@ -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) + } + } +}