Files
Cyrene_v2/internal/services/backthink/state.go
T
AskaEth 321cc3b763
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(backthink): 思考轮工具面补齐(裁决 15)—— 能力面接入 + 只读 capability_list + 本轮状态快照
- 缺口 d:状态在**思考前**读一次,同一份快照既喂思考轮又喂 Decide(读不到 = Known=false,绝不伪装成中性状态)
- 缺口 c:新增只读工具 capability_list(owner-only、三重输出边界、读写隔离),她可自查当下真实可用面
- 缺口 b:内置允许集补 transcode / capability_list(此前 CAPACITY_ENABLED=true 时新工具会从模型可见面消失)
- 缺口 e:guidancePrompt 旧文案承诺的「IoT/日历/提醒」在全仓根本没有对应工具(chat 侧也没有)⇒ 按诚实化改为「与主人对话同等工具面 + 指名自查入口」,不再有空头支票
- (a) 未做且**不自创**:那三把工具无任何设计规格(名字/schema/读写边界/输出上限)⇒ 已报回并登记进 design/20(裁决:走能力/插件/MCP 暴露路线)
- 变异实测:允许集去掉新工具 ⇒ TestDecide_AllowedSets + 组合根跨层测试红;丢掉状态快照 ⇒ TestLoopThinker_Think_InjectsRoundStateBlock 红
- 已知弱项(如实登记):组合根那行 wireCapabilitySurface() 调用本身无测试覆盖(与既有 wireTranscode 同惯例)
2026-10-05 13:54:57 +08:00

164 lines
5.1 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 (
"context"
"fmt"
"log/slog"
"strconv"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/iot"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
)
// StateProvider 为打扰决策提供用户当前状况
// (design/12 §2)。生产接线 IoT/遥测与日历;测试注入
// mock。它必须并发安全。
type StateProvider interface {
// Current 返回用户当前的 UserState。可 nil 的错误表示「用
// 中性状态」;硬错误由调用方记日志并按
// 中性处理。
Current(ctx context.Context) (UserState, error)
}
// IoTStateProvider 是基于 iot.Manager 的尽力而为 StateProvider。它
// 扫描纳管设备,并从任何 id/名称/类型提到对应关键词的设备
// 读取心率与睡眠信号(例如 "heart"、
// "心率"、"sleep"、"睡眠")。工作/忙碌来自设备/状态标志;什么都不
// 匹配时它返回中性状态而不是错误,因此即便没有完整
// 遥测接线,服务也能运行。
type IoTStateProvider struct {
mgr *iot.Manager
log *slog.Logger
}
// NewIoTStateProvider 把 mgr 包装为 StateProvider。
func NewIoTStateProvider(mgr *iot.Manager, log *slog.Logger) *IoTStateProvider {
return &IoTStateProvider{mgr: mgr, log: logger.WithSource(baseLog(log), "services/backthink")}
}
// Current 实现 StateProvider。
func (p *IoTStateProvider) Current(ctx context.Context) (UserState, error) {
if p.mgr == nil {
return UserState{}, fmt.Errorf("backthink: state: %w", iot.ErrProviderNotRegistered)
}
devices, err := p.mgr.List(ctx)
if err != nil {
return UserState{}, fmt.Errorf("backthink: state: list: %w", err)
}
var state UserState
for _, d := range devices {
key := strings.ToLower(d.ID + " " + d.Name + " " + d.Type)
switch {
case strings.Contains(key, "heart") || strings.Contains(key, "心率"):
if v, ok := numState(d, "bpm", "heart_rate", "rate"); ok {
state.HeartRateBPM = v
}
case strings.Contains(key, "sleep") || strings.Contains(key, "睡眠"):
state.Sleeping = boolState(d, "sleeping", "asleep", "state")
}
if b, ok := boolStateOK(d, "working"); ok {
state.Working = b
}
if b, ok := boolStateOK(d, "busy"); ok {
state.Busy = b
}
}
if p.log != nil {
p.log.Debug("backthink iot state sampled", "devices", len(devices), "state", state.String())
}
return state, nil
}
// currentState 读取用户当前状况,并回答「到底读到了没」:打扰决策用它作为中性
// 回退(已知为 false 时按零值判定),思考轮用它决定要不要注入状态块(X1 缺口 d
// 让同一份快照同时服务两侧)。提供者为 nil 或读取失败都返回 Known=false,并记一条
// 带来源的 warn 日志 —— 绝不把「读不到」伪装成一份中性状态去误导她的判断。
func (b *Backthink) currentState(ctx context.Context) (UserState, bool) {
if b.deps.State == nil {
return UserState{}, false
}
s, err := b.deps.State.Current(ctx)
if err != nil {
b.log.Warn("backthink state provider failed", "error", err)
return UserState{}, false
}
return s, true
}
// lastUserActivity 报告主人最近一条消息的墙钟时间
// (在主会话里),以及是否找到了任何用户消息。它是活动
// 门的活动信号(design/12 §6):提供者为 nil、读取失败或
// 尾部为空都得到 has=false,因此活动门会落到当前
// 行为而不是靠猜。最近的一条用户消息是最近尾部里
// 最新的 RoleUser 条目(Recent 返回的是最旧在前)。
func (b *Backthink) lastUserActivity(ctx context.Context) (time.Time, bool) {
if b.deps.Recent == nil {
return time.Time{}, false
}
msgs, err := b.deps.Recent.Recent(ctx, history.MainSessionID, recentContextCount)
if err != nil {
return time.Time{}, false
}
for i := len(msgs) - 1; i >= 0; i-- {
if msgs[i].Role == history.RoleUser {
return time.UnixMilli(msgs[i].CreatedAt), true
}
}
return time.Time{}, false
}
// numState 从设备状态 map 中按 keys 读取首个数值。
func numState(d iot.Device, keys ...string) (int, bool) {
for _, k := range keys {
v, ok := d.State[k]
if !ok {
continue
}
switch n := v.(type) {
case int:
return n, true
case int64:
return int(n), true
case float64:
return int(n), true
case string:
if f, err := strconv.Atoi(n); err == nil {
return f, true
}
}
}
return 0, false
}
// boolState 在 keys 中读取一个 bool(或 "sleep"/"asleep" 字符串)。
func boolState(d iot.Device, keys ...string) bool {
b, _ := boolStateOK(d, keys...)
return b
}
// boolStateOK 报告是否找到了 bool 类型的状态标志及其取值。
func boolStateOK(d iot.Device, keys ...string) (bool, bool) {
for _, k := range keys {
v, ok := d.State[k]
if !ok {
continue
}
switch n := v.(type) {
case bool:
return n, true
case string:
switch strings.ToLower(n) {
case "sleep", "asleep", "busy", "working", "yes", "true":
return true, true
case "awake", "free", "idle", "no", "false":
return false, true
}
}
}
return false, false
}