Files
Cyrene_v2/internal/services/backthink/trigger.go
T
AskaEth afef75f640
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(backthink): 安全域穿透安静时段 + 长静默不拦心跳 + 启动即思考(裁决 1.8/07.4.2/07.4.3/07.4.4/07.4.5)
执行方案阶段 2 · P2-2(`DevDocs/audit/_plan.md`)。四条裁决:
- 1.8/07.4.2:`think()` 不再走忽略 importance 的 `disturbAllowed`(已删),**接回 `Decide` 级联**;
  安静时段只挡常规推送、**critical 安全域穿透**;`DecisionInput` 增 `ConclusionPush`/`Heartbeat`。
  睡眠仍绝对不打扰(裁决只豁免安静时段,此点已在设计与报告写明)。
- 07.4.3:长静默(>2h)分支对**心跳类**放行(常规事件仍需 ≥high),刚聊过天 10min 内扣留不变。
- 07.4.4:`tickLoop` **启动立即先思考一轮** + 拟人化启动消息;启动轮与心跳**同门同限频**
  (重启风暴不会刷屏),ctx 已取消则跳过。
- 07.4.5:核对一致(`THINK_INTERVAL_SEC` 默认 900s 与 design/20 §0 权威口径一致)⇒ **代码零改动**。

验证:go build 绿;backthink 包 vet/测试绿;`-count=20`(抗 flake)+ `-race` 绿;
linux/amd64 + darwin/arm64 交叉编译绿;gofmt 干净;文件 ≤500 行、无 >80 行函数。
变异验证(三条,均带还原证明):① 安静时段改回"全拦" → 安全域穿透断言 FAIL(首次只删 critical
分支时仍绿——`critical >= high` 会从 high 分支漏过,故真实变异必须是"全拦");
② 删心跳豁免 → 长静默心跳断言 FAIL;③ 删启动思考 → 启动即思考断言 FAIL。

未验证:无真实模型凭据 ⇒ 启动轮问候与 `[我决定:…]` 解析仅经 mock + 真 loop 验证,端到端 LLM 未验。
2026-10-04 11:21:44 +08:00

118 lines
4.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package backthink
import (
"fmt"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
"git.yeij.top/AskaEth/Cyrene_v2/internal/iot"
)
// TriggerKind classifies what woke a thinking pass, so the push path can apply the
// right policy: a heartbeat is gate-governed (60min≤1 / cooldown / per-flavor), an
// event is a real signal the owner wants to hear about and bypasses the rate gate.
type TriggerKind int
const (
// TriggerHeartbeat is the periodic autonomous reflection. Its proactive push is
// throttled by the PushGate so the owner is never spammed by empty reflections.
TriggerHeartbeat TriggerKind = iota
// TriggerEvent is a real event (IoT device change, reminder, owner-relevant
// system event). Its push bypasses the PushGate — "该发就发" — subject only to
// same-event dedup.
TriggerEvent
)
// String returns a stable label for logs and records.
func (k TriggerKind) String() string {
switch k {
case TriggerEvent:
return "event"
case TriggerHeartbeat:
return "heartbeat"
default:
return fmt.Sprintf("triggerkind(%d)", int(k))
}
}
// Bus subjects the service subscribes to for event-triggered thinking. They are
// the contract defined by design/12 §1: an IoT state change, a reminder firing,
// or a generic system event.
const (
// SubjectReminderTrigger is the bus subject on which reminders fire.
SubjectReminderTrigger = "reminder.trigger"
// SubjectSystemEvent is the bus subject for generic system events.
SubjectSystemEvent = "system.event"
)
// SystemEvent is the optional typed payload a caller may publish to
// SubjectSystemEvent to convey a system notice. Import defaults to Normal. Under
// design/12 §6 策略一, a low-value system notice (a join/leave ping, generic
// platform noise) is downgraded to a registration-only record and does not wake a
// full thinking pass; only a high-importance (owner-relevant) system event thinks,
// so a busy public platform cannot trigger a flood of expensive reflections.
type SystemEvent struct {
Summary string
Import Importance
}
// Trigger describes what woke a thinking pass: the source subsystem, a
// human-readable summary and the event's estimated importance.
type Trigger struct {
Source string
Summary string
Import Importance
// Kind records whether this pass was the periodic heartbeat or a real event, so
// the push path can bypass the rate gate for a genuine event (owner mandate).
Kind TriggerKind
}
// heartbeatTrigger is the periodic, low-importance trigger. Low importance means
// the pass rarely interrupts the user, honouring "默认不发送 chat 类型消息".
func heartbeatTrigger() Trigger {
return Trigger{Source: "heartbeat", Summary: "periodic autonomous reflection", Import: ImportanceLow, Kind: TriggerHeartbeat}
}
// StartupSummary 是启动轮思考(裁决 07.4.4,design/12 §1)的触发摘要。它同时是
// context.go 里切换「拟人化启动消息」专属引导提示词的标识。
const StartupSummary = "process startup"
// startupTrigger 是进程启动后的第一轮思考(裁决 07.4.4):不等满一个心跳周期,
// 启动立即触发,并引导她以角色口吻产出一条拟人化启动问候(如「我回来啦」)。
// Kind 仍为 TriggerHeartbeat:启动轮的推送与心跳走完全相同的门与限频
// (PushGate / 打扰级联 / 活动门),进程反复重启也不会刷屏主人。
func startupTrigger() Trigger {
return Trigger{Source: "startup", Summary: StartupSummary, Import: ImportanceLow, Kind: TriggerHeartbeat}
}
// triggerForEvent maps a bus event to a Trigger. An IoT device change is treated
// as normal importance, a reminder firing as high importance, and a generic
// system event as normal importance. Every event is tagged TriggerEvent so its
// proactive push bypasses the heartbeat PushGate.
func triggerForEvent(e bus.Event) Trigger {
switch e.Subject {
case iot.DeviceUpdateSubject:
if d, ok := e.Payload.(iot.DeviceUpdate); ok {
return Trigger{
Source: e.Subject,
Summary: fmt.Sprintf("device %s (%s) %s", d.Device.ID, d.Device.Type, d.Action),
Import: ImportanceNormal,
Kind: TriggerEvent,
}
}
return Trigger{Source: e.Subject, Summary: e.Subject, Import: ImportanceNormal, Kind: TriggerEvent}
case SubjectReminderTrigger:
return Trigger{Source: e.Subject, Summary: e.Subject, Import: ImportanceHigh, Kind: TriggerEvent}
case SubjectSystemEvent:
tr := Trigger{Source: e.Subject, Summary: e.Subject, Import: ImportanceNormal, Kind: TriggerEvent}
if sy, ok := e.Payload.(SystemEvent); ok {
if sy.Summary != "" {
tr.Summary = sy.Summary
}
tr.Import = sy.Import
}
return tr
default:
return Trigger{Source: e.Subject, Summary: e.Subject, Import: ImportanceNormal, Kind: TriggerEvent}
}
}