- per-role 注入补齐:Options.PersonaAwakening(personaID) → Loop.personaAwakening → l.awakeningFor(ctx) → capacity.Input.Awakening(toolSchemas 是全仓唯一构造该 Input 处) · 按 turn 解析(不绑 Loop 构造期,避免并发会话数据竞争),与既有 personaFor/knobsFor 同构 · 未知角色/空角色/档案无该块 ⇒ 零值 ⇒ 回落 Config.Awakening(不声明者行为与今天构造性一致) - **数据丢失修复**:persona PUT 是整块替换,此前只保留 thinking ⇒ 保存一次会同时抹掉 awakening / visual(头像·背景) / triggerWords(群聊提醒词) / emotion(情绪机参数) / affection(好感度曲线)。 新增 applyAwakeningEdit + applyPersonaReadOnlyBlocks 逐一保留(cyrene.yaml 五块全写,属必现故障) - 文件守 500 行:主人档案段原样搬移到 admin_owner_profile.go(零逻辑变更) - 复核:build/vet 绿 + 全树 `go test ./... -count=1` 0 FAIL;端到端=真 YAML→loader→buildCapacity→真 loop 流式请求→断言工具面(tasky 声明 force_state 唤醒 → 含 web-search;cyrene 不声明 → 与无解析器对照逐字相同); **8 条变异反证**先红后还原(含逐块删除各自的保留逻辑) - 残留缺口(登记):wire_runtime.go:254 那一行本身无用例覆盖;loader 非 strict(未知 YAML 键静默忽略, 需主人裁决是否开 KnownFields);POST /admin/personas 仍写不出这五个块
203 lines
8.5 KiB
Go
203 lines
8.5 KiB
Go
package main
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"log/slog"
|
||
"strings"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/capacity"
|
||
)
|
||
|
||
// 本文件拥有唤醒式能力(capacity)决策层的装配:判定器构造、工具集 JSON 解析、
|
||
// L3 缓存 TTL 解析与 capacity_awaken 事件出口。它从 wire_runtime.go 拆出
|
||
// (AGENTS §10 文件 ≤500 行;wire.go 拆分建议同样适用于本文件)。
|
||
|
||
// buildCapacity constructs the awakened-capacity decider when enabled, or nil
|
||
// (which keeps the loop's default all-tools behavior). It is opt-in via
|
||
// CAPACITY_ENABLED; the allowed-set mapping comes from CAPACITY_TOOL_SETS and
|
||
// falls back to built-in defaults on a parse error.
|
||
//
|
||
// The model-assisted L3 layer (裁决 14.4.4) is a second, independent switch
|
||
// (CAPACITY_L3_ENABLED, default false): even with capacity on, L3 stays silent
|
||
// unless it was explicitly asked for, and its verdict cache TTL
|
||
// (CAPACITY_L3_CACHE_TTL, default 5m) is parsed here because the config loader
|
||
// only carries scalars.
|
||
//
|
||
// The persona-level awakening override (裁决 14.4.3) enters the decision layer in
|
||
// two places. This function fills the *global* fallback (Config.Awakening) from the
|
||
// current main persona's YAML block (see defaultAwakening): it is what a persona
|
||
// declaring no block inherits. The per-turn value is resolved by awakeningFor,
|
||
// which the loop calls for every turn's own role through
|
||
// loop.Options.PersonaAwakening — without it, a non-default character's
|
||
// `awakening` block would be parsed, validated and then silently ignored
|
||
// (forbidden by design/01 §2.1).
|
||
func (a *app) buildCapacity() *capacity.Decider {
|
||
if !a.cfg.Capacity.Enabled {
|
||
return nil
|
||
}
|
||
sets, err := parseCapacityToolSets(a.cfg.Capacity.ToolSetsJSON)
|
||
if err != nil {
|
||
a.log.Warn("capacity tool sets parse failed, using built-in defaults", "error", err)
|
||
sets = nil
|
||
}
|
||
awakening := a.defaultAwakening()
|
||
if !awakening.IsZero() {
|
||
logger.WithSource(a.log, "cmd/cyrene").Info("capacity awakening override applied",
|
||
"default_state", awakening.DefaultState, "min_confidence", awakening.MinConfidence,
|
||
"force_state", awakening.ForceState, "persona", a.defaultPersonaID())
|
||
}
|
||
cfg := capacity.Config{
|
||
Enabled: true,
|
||
MinConfidence: a.cfg.Capacity.MinConfidence,
|
||
ToolSets: sets,
|
||
Awakening: awakening,
|
||
}
|
||
if a.cfg.Capacity.L3Enabled {
|
||
cfg.L3Enabled = true
|
||
cfg.L3Classifier = capacity.NewLLMClassifier(a.modelAdapter, a.log)
|
||
cfg.L3CacheTTL = parseL3CacheTTL(a.cfg.Capacity.L3CacheTTL, a.log)
|
||
}
|
||
return capacity.New(cfg, capacity.WithLogger(a.log))
|
||
}
|
||
|
||
// parseL3CacheTTL parses the CAPACITY_L3_CACHE_TTL duration string. An empty
|
||
// value selects the code default (5m, the landing suggestion of 裁决 14.4.4); an
|
||
// unparsable value is reported and falls back to the same default rather than
|
||
// silently disabling the cache.
|
||
func parseL3CacheTTL(raw string, log *slog.Logger) time.Duration {
|
||
raw = strings.TrimSpace(raw)
|
||
if raw == "" {
|
||
return capacity.DefaultL3CacheTTL
|
||
}
|
||
d, err := time.ParseDuration(raw)
|
||
if err != nil {
|
||
logger.WithSource(log, "cmd/cyrene").Warn("capacity L3 cache ttl unparsable, using the default",
|
||
"value", raw, "default", capacity.DefaultL3CacheTTL.String(), "error", err)
|
||
return capacity.DefaultL3CacheTTL
|
||
}
|
||
return d
|
||
}
|
||
|
||
// parseCapacityToolSets decodes the CAPACITY_TOOL_SETS JSON map of
|
||
// state→allowed tool name list. A blank string yields nil (built-in defaults); a
|
||
// null value for a state means unrestricted (all tools).
|
||
func parseCapacityToolSets(raw string) (map[capacity.State][]string, error) {
|
||
if strings.TrimSpace(raw) == "" {
|
||
return nil, nil
|
||
}
|
||
var m map[string][]string
|
||
if err := json.Unmarshal([]byte(raw), &m); err != nil {
|
||
return nil, fmt.Errorf("capacity tool sets: %w", err)
|
||
}
|
||
out := make(map[capacity.State][]string, len(m))
|
||
for k, v := range m {
|
||
out[capacity.State(k)] = v
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// defaultAwakening 取当前主角色(`configs/users/owner.yaml` 的 `defaultPersona`,
|
||
// 未配置时回落 `cyrene`)档案里的 `awakening` 块。它填的是决策层的**全局兜底**
|
||
// (capacity.Config.Awakening);每个 turn 真正生效的档位由 awakeningFor 按该
|
||
// turn 的角色解析(裁决 14.4.3 的后半)。两者共用同一处映射,因此「全局兜底」
|
||
// 与「按角色取值」的口径绝不会分叉。
|
||
//
|
||
// 未装配人格注册表(降级启动 / 单测构造的 app)时返回零值 —— 注册表缺席时连
|
||
// 默认角色都问不出来,此时不补任何档位。
|
||
func (a *app) defaultAwakening() capacity.Awakening {
|
||
if a.personas == nil {
|
||
return capacity.Awakening{}
|
||
}
|
||
return a.awakeningFor(a.defaultPersonaID())
|
||
}
|
||
|
||
// awakeningFor 取**该角色**档案里的 `awakening` 块,映射为决策层的人格档位覆盖
|
||
// (design/14 §3,裁决 14.4.3)。它是 loop.Options.PersonaAwakening 的实现:循环
|
||
// 每个 turn 按该 turn 的角色 id 调它,因此绑定非默认角色的会话用**自己**的档位
|
||
// 判定能力面,而不是只吃默认角色的全局兜底。
|
||
//
|
||
// 下列情形一律返回零值 = **无覆盖**,由决策层走全局默认(Config.Awakening +
|
||
// 全局阈值),绝不在此处凭空补档位:
|
||
// - 尚未装配人格注册表(降级启动 / 单测构造的 app);
|
||
// - 角色 id 为空或该角色不在注册表里(未知/已删除人格);
|
||
// - 该角色档案里没写 `awakening` 块(缺席就是没有)。
|
||
//
|
||
// 档位是倾向/呈现,不是能力边界——它只影响本轮暴露的工具面与唤醒阈值。
|
||
func (a *app) awakeningFor(personaID string) capacity.Awakening {
|
||
if a.personas == nil || personaID == "" {
|
||
return capacity.Awakening{}
|
||
}
|
||
p, ok := a.personas.Persona(personaID)
|
||
if !ok || p == nil {
|
||
return capacity.Awakening{}
|
||
}
|
||
return a.awakeningOf(p)
|
||
}
|
||
|
||
// awakeningOf 把一份人格档案的 `awakening` 块映射为决策层的档位覆盖。映射不了
|
||
// 的档位名按「无该档位」处理(awakeningStateOf 会告警),因此非法值绝不会被塞进
|
||
// 决策层(未知态会落进「不受限工具集」分支)。
|
||
func (a *app) awakeningOf(p *loader.Persona) capacity.Awakening {
|
||
if p == nil || p.Awakening == nil {
|
||
return capacity.Awakening{}
|
||
}
|
||
aw := capacity.Awakening{
|
||
DefaultState: a.awakeningStateOf(p.Awakening.DefaultState, p.ID, "default_state"),
|
||
}
|
||
if ov := p.Awakening.Overrides; ov != nil {
|
||
aw.MinConfidence = ov.MinConfidence
|
||
aw.ForceState = a.awakeningStateOf(ov.ForceState, p.ID, "overrides.force_state")
|
||
}
|
||
return aw
|
||
}
|
||
|
||
// awakeningStateOf 映射单个档位字面量;映射不了就**告警**再按「无该档位」处理。
|
||
// 经 loader.Load 进来的人格不可能走到告警分支(载入期已归一化并拒绝非法值),
|
||
// 因此它是给「代码构造的人格」留的响铃:宁可吵一声,也不让配置静默失效
|
||
// (design/01 §2.1 禁止静默无效的配置)。
|
||
func (a *app) awakeningStateOf(raw, personaID, field string) capacity.State {
|
||
if raw == "" {
|
||
return ""
|
||
}
|
||
state := awakeningState(raw)
|
||
if state == "" {
|
||
logger.WithSource(a.log, "cmd/cyrene").Warn("capacity awakening state unrecognized, treated as no override",
|
||
"value", raw, "field", field, "persona", personaID)
|
||
}
|
||
return state
|
||
}
|
||
|
||
// awakeningState 把 loader 归一化后的档位名映射到决策层的态。空串(未声明、或未知
|
||
// 值)返回零值态——映射层不做默认填充,缺失的档位就是缺失。
|
||
func awakeningState(raw string) capacity.State {
|
||
switch raw {
|
||
case loader.AwakeningStateQuiet:
|
||
return capacity.StateQuiet
|
||
case loader.AwakeningStateAwake:
|
||
return capacity.StateAwake
|
||
case loader.AwakeningStateFull:
|
||
return capacity.StateFull
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
// onCapacity is the loop's OnCapacity sink: it observes a capacity_awaken
|
||
// transition and pushes it to the Web UI (design/14 §5) as a `capacity_awaken`
|
||
// frame. The loop only emits on a real state transition (never every round), and
|
||
// the event carries the originating session id so it routes to that session, not
|
||
// globally. The payload is the frontend AwakenPayload {fromState,toState,tool}.
|
||
func (a *app) onCapacity(ev capacity.Event) {
|
||
logger.WithSource(a.log, "cmd/cyrene").Debug("capacity event",
|
||
"type", ev.Type, "from", ev.FromState, "to", ev.ToState,
|
||
"tool", ev.Tool, "at", ev.At, "session", ev.SessionID)
|
||
if a.capacitySink == nil {
|
||
return
|
||
}
|
||
a.capacitySink.BroadcastCapacityAwaken(ev.SessionID, string(ev.FromState), string(ev.ToState), ev.Tool)
|
||
}
|