package backthink import ( "fmt" "strings" "time" ) // Importance is the estimated severity of an event that woke a thinking pass. // Under design/20 §2 P2 it no longer decides *whether* she wants to speak (her // conclusion does); it decides the hard-gate exemptions: a critical safety event // punches through work/busy and quiet hours (裁决 07.4.2), and a high event still // passes quiet hours (the pre-existing cascade semantics). type Importance int const ( // ImportanceLow is a routine trigger (e.g. the periodic heartbeat). ImportanceLow Importance = iota // ImportanceNormal is an ordinary event worth reflecting on (device change). ImportanceNormal // ImportanceHigh is a notable event (a reminder the user set firing). ImportanceHigh // ImportanceCritical is a safety-critical event that punches through // work/busy and quiet hours (裁决 07.4.2 安全域豁免). ImportanceCritical ) // String returns a stable label for logs and records. func (i Importance) String() string { switch i { case ImportanceLow: return "low" case ImportanceNormal: return "normal" case ImportanceHigh: return "high" case ImportanceCritical: return "critical" default: return fmt.Sprintf("importance(%d)", int(i)) } } // longIdleThreshold is the gap after the owner's last message beyond which the // activity gate only lets a genuinely important event through — except heartbeat // passes, which are never held by a long-silent gap (裁决 07.4.3). Compile-time // so no magic number appears. const longIdleThreshold = 2 * time.Hour // UserState describes the user's current condition as observed from device // telemetry (heart rate, sleep) and schedule (work, busy). It is the negative // signal in the interrupt decision: a sleeping, working or busy user is left // alone. Boredom (0..1) is the soft positive dial that widens interruption. type UserState struct { // HeartRateBPM is the current heart rate, 0 when unknown. HeartRateBPM int // Sleeping reports whether the user is currently asleep from sleep tracking. Sleeping bool // Working reports whether the user is in a work/scheduled block. Working bool // Busy reports whether the user is marked busy (calendar / do-not-disturb). Busy bool // Boredom ranges 0..1. Higher boredom means the user is idle and more open // to a gentle interruption. Under design/20 §2 P2 the push intent is her own // conclusion, so this dial no longer gates the decision; it is kept as a // telemetry signal her judgment already absorbs. Boredom float64 } // String returns a concise human-readable summary for logs/records. func (s UserState) String() string { return fmt.Sprintf("bpm=%d sleeping=%v working=%v busy=%v boredom=%.2f", s.HeartRateBPM, s.Sleeping, s.Working, s.Busy, s.Boredom) } // DecisionInput is the set of signals Decide weighs: the event's importance, the // current wall-clock time, the user's current state and their boredom. type DecisionInput struct { // Trigger is a human-readable source label, recorded for traceability. Trigger string // Import is the event's estimated importance. Import Importance // At is the current wall-clock time, in the local timezone used for the // do-not-disturb window. At time.Time // UserState carries the user's physiological/schedule condition. UserState UserState // LastActivity is the wall-clock time of the owner's most recent message. // HasActivity reports whether a user message was found; when false the // activity gate falls through without guessing. LastActivity time.Time // HasActivity reports whether LastActivity is meaningful. HasActivity bool // ConclusionPush 是她自己思考结论的推送意愿(design/20 §2 P2):「想不想说」 // 由她的结论决定,本决策只回答「此刻能不能说」。 ConclusionPush bool // Heartbeat 标记本轮是否为心跳类触发(周期心跳或启动轮)。裁决 07.4.3: // 长期静默段不拦心跳——主人久未回应时,主动关心仍可照常发出。 Heartbeat bool } // Decision is the outcome of Decide. type Decision struct { // ShouldPush reports whether the user should be interrupted. ShouldPush bool // Reason is a short human-readable justification, surfaced on the panel. Reason string } // Decide 是生产路径的打扰决策(design/12 §2 + design/20 §2 P2 + 裁决 07.4.2/07.4.3)。 // 「想不想说」交给她的结论(ConclusionPush),本方法只判「此刻能不能说」,评估顺序 // 刻意 fail-closed(宁可不打扰): // // 0. 她的结论要求不推送 → 不推送。 // 1. 用户在睡 → 绝不打扰。 // 2. 上班/忙碌 → 仅 critical 安全事件可打扰。 // 3. 安静时段(深夜窗口)→ 挡常规推送;critical 安全事件穿透(裁决 07.4.2, // 与 design/12 §6 策略五同口径),high 沿袭原级联口径照常放行。 // 4. 其余时段 → 放行(是否值得说已由她的结论回答)。 // // 通过硬门后再过活动软门:刚聊过天或主人久未回应时酌情扣下;心跳类触发在 // 长期静默段不拦(裁决 07.4.3,主动关心照常发出)。频率门(PushGate)在推送器 // 内部单独生效,不在此处。 func (b *Backthink) Decide(in DecisionInput) Decision { if !in.ConclusionPush { return Decision{ShouldPush: false, Reason: "conclusion requested no push"} } if d := b.baseDecide(in); !d.ShouldPush { return d } if held, why := b.activityHeld(in); held { return Decision{ShouldPush: false, Reason: "proactive push held: " + why} } return Decision{ShouldPush: true, Reason: "proactive push approved"} } // baseDecide 是不打扰硬门级联(design/12 §2)。critical 安全事件走安全域分支: // 工作忙碌与安静时段都放行(裁决 07.4.2 安全域豁免);睡眠仍是绝对不打扰。 // 白天是否「值得说」不再由重要性/无聊度判定——那是她的结论(design/20 §2 P2) // 已经回答的问题。 func (b *Backthink) baseDecide(in DecisionInput) Decision { s := in.UserState if s.Sleeping { return Decision{ShouldPush: false, Reason: "user is asleep; not interrupting"} } if (s.Working || s.Busy) && in.Import < ImportanceCritical { return Decision{ShouldPush: false, Reason: "user is working/busy; not interrupting"} } if b.inQuietHours(in.At) { if in.Import >= ImportanceCritical { return Decision{ShouldPush: true, Reason: "quiet hours but critical safety event bypasses"} } if in.Import >= ImportanceHigh { return Decision{ShouldPush: true, Reason: "quiet hours but event is important"} } return Decision{ShouldPush: false, Reason: "quiet hours; not interrupting"} } return Decision{ShouldPush: true, Reason: "worth interrupting"} } // activityHeld 是活动感知软门(design/12 §6 + 裁决 07.4.3)。它把「不打扰」延伸到 // 对话自身的节奏,返回是否扣下与简短理由: // // - 最近 RecentChatCooldown 内主人刚说过话 → 对话正热,非 critical 扣下, // 不在聊天中途插话; // - 超过 longIdleThreshold 未说话 → 主人久未回应,仅真正重要的事件值得唤醒; // 心跳类触发(含启动轮)例外照常放行(裁决 07.4.3:长期静默段不拦心跳, // 主动关心仍可发出); // - 其间与活动状态未知 → 不软化,照硬门结论通过。 // // 活动是软信号:硬门(baseDecide)独立生效,频率门(PushGate)在推送器内部生效。 func (b *Backthink) activityHeld(in DecisionInput) (bool, string) { if !in.HasActivity { return false, "" } since := in.At.Sub(in.LastActivity) if since <= b.cfg.RecentChatCooldown { if in.Import >= ImportanceCritical { return false, "" } return true, "owner just chatted" } if since >= longIdleThreshold { if in.Heartbeat { return false, "" } if in.Import >= ImportanceHigh { return false, "" } return true, "owner away a long while" } return false, "" } // inQuietHours reports whether at falls inside the configured do-not-disturb // window. The window may wrap midnight (e.g. 23:00-07:00); two equal non-zero // hours disable it. func (b *Backthink) inQuietHours(at time.Time) bool { h := at.Hour() start, end := b.cfg.QuietStartHour, b.cfg.QuietEndHour if start <= end { return h >= start && h < end } return h >= start || h < end } // PushKind classifies a proactive message into one of a few flavors so the // frequency gate can limit each flavor within a window, avoiding same-style spam // (design/12 §6 策略二). It is a soft tag derived from the message body's // keywords — it affects pacing, never identity or permission. type PushKind int const ( // KindGreeting is a warm hello / good-morning / good-night opener. KindGreeting PushKind = iota // KindReminder is a nudge about a schedule, task or deadline. KindReminder // KindShare is a discovery, observation or interesting find. KindShare // KindCare is a warm, caring check-in about the owner's wellbeing. KindCare ) // String returns a stable label for logs and records. func (k PushKind) String() string { switch k { case KindGreeting: return "greeting" case KindReminder: return "reminder" case KindShare: return "share" case KindCare: return "care" default: return fmt.Sprintf("pushkind(%d)", int(k)) } } // classifyPush assigns a PushKind to a proactive message from its keyword // content. A message that matches no flavor defaults to KindCare (the warm, // generic intent of an unsolicited proactive note), which is the least // spam-prone grouping. Priority is reminder > care > greeting > share so the // dominant intent wins when several keywords co-occur. func classifyPush(message string) PushKind { switch { case hasAnyKeyword(message, kindReminderKeywords...): return KindReminder case hasAnyKeyword(message, kindCareKeywords...): return KindCare case hasAnyKeyword(message, kindGreetingKeywords...): return KindGreeting case hasAnyKeyword(message, kindShareKeywords...): return KindShare default: return KindCare } } // keyword sets for classifyPush. They are package vars (not consts) so the // classification stays flexible and testable, but they are immutable by // convention and never mutated at run time. var ( kindReminderKeywords = []string{"提醒", "记得", "别忘了", "该去", "日程", "会议", "体检", "吃药", "喝药", "到点", "几点", "还有", "迟到", "明天", "马上", "最后", "截止"} kindCareKeywords = []string{"关心", "最近", "辛苦", "注意身体", "照顾好", "好好休息", "心情", "还好", "想你了", "想我", "担心", "保重", "你还好", "陪你", "累"} kindGreetingKeywords = []string{"早安", "晚安", "早上好", "中午好", "晚上好", "午安", "回来", "吃饭", "睡了"} kindShareKeywords = []string{"发现", "分享", "看到", "听说", "注意到", "告诉你", "有个", "想到", "有意思", "有趣", "你知道吗"} ) // hasAnyKeyword reports whether s contains any of the given substrings. func hasAnyKeyword(s string, subs ...string) bool { for _, sub := range subs { if strings.Contains(s, sub) { return true } } return false }