fix(loop,gateway,persona): awakening 逐 turn 按角色生效 + 修「面板保存角色抹掉 5 个块」的数据丢失(裁决 14.4.3)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 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 仍写不出这五个块
This commit is contained in:
AskaEth committed 2026-10-04 13:07:52 +08:00
1 parent 58cba0b8d2
commit 7afef8d037
11 files changed
+778 -142

No files matched your search

@@ -0,0 +1,137 @@
package gateway
import (
"encoding/json"
"errors"
"net/http"
"os"
"strings"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
)
// 本文件拥有主人档案的读写(裁决 10.4.5),从 admin_write_personas_users.go 拆出
// (AGENTS §10 文件 ≤500 行)。
// --- 主人档案(裁决 10.4.5)--------------------------------------------------
//
// 档案是 design/11 §0 的身份红线:渠道层用它的 accounts 白名单比对 actor 元数据
// 认出主人,所以读写都只给 owner。落盘 configs/users/owner.yaml(0600 原子写)
// 后热替换活档案 —— 面板的「保存」因此是真保存,不再是只改本地 state 的假成功。
// ownerProfileResponse 是主人档案的线上形式,字段与 user.OwnerProfile 的 YAML
// 一一对应,面板可原样往返回填。
type ownerProfileResponse struct {
ID string `json:"id"`
DisplayName string `json:"displayName"`
DefaultPersona string `json:"defaultPersona,omitempty"`
IdentityFile string `json:"identityFile,omitempty"`
Notes string `json:"notes,omitempty"`
Accounts []ownerAccountRequest `json:"accounts"`
}
// ownerAccountRequest 是一条渠道账号白名单项(平台 + 该平台的账号 id)。
type ownerAccountRequest struct {
Platform string `json:"platform"`
AccountID string `json:"accountId"`
}
// ownerProfileFrom 把档案映射为线上形式,accounts 一律是空数组而不是 null。
func ownerProfileFrom(p *user.OwnerProfile) ownerProfileResponse {
out := ownerProfileResponse{
ID: p.ID, DisplayName: p.DisplayName, DefaultPersona: p.DefaultPersona,
IdentityFile: p.IdentityFile, Notes: p.Notes,
Accounts: make([]ownerAccountRequest, 0, len(p.Accounts)),
}
for _, a := range p.Accounts {
out.Accounts = append(out.Accounts, ownerAccountRequest{Platform: a.Platform, AccountID: a.AccountID})
}
return out
}
// toOwnerProfile 把线上档案映射回 user.OwnerProfile,并去掉各字段首尾空白
// (面板里手打的平台名/账号 id 常带空格,带进白名单就永远匹配不上)。
func (r ownerProfileResponse) toOwnerProfile() *user.OwnerProfile {
p := &user.OwnerProfile{
ID: strings.TrimSpace(r.ID), DisplayName: strings.TrimSpace(r.DisplayName),
DefaultPersona: strings.TrimSpace(r.DefaultPersona), IdentityFile: strings.TrimSpace(r.IdentityFile),
Notes: strings.TrimSpace(r.Notes),
Accounts: make([]user.OwnerAccount, 0, len(r.Accounts)),
}
for _, a := range r.Accounts {
p.Accounts = append(p.Accounts, user.OwnerAccount{
Platform: strings.TrimSpace(a.Platform), AccountID: strings.TrimSpace(a.AccountID),
})
}
return p
}
// adminGetOwnerProfile 返回落盘的主人档案。文件不存在(全新安装,还没人配过
// 主人)时返回一份空白档案而不是 404 —— 面板据此渲染一张空表单让主人首次填写。
func (s *Server) adminGetOwnerProfile(w http.ResponseWriter, r *http.Request) {
p, err := user.LoadOwnerProfile(ownerProfilePath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
writeJSON(w, http.StatusOK, ownerProfileFrom(&user.OwnerProfile{}))
return
}
// 坏档案(IO 失败或必填字段缺失)如实报告,不返回一份看似正常的空档案:
// 那会让「白名单是空的」和「档案读不出来」看起来一模一样。
s.log.Error("owner profile read", "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "owner_profile_unreadable", "failed to read owner profile: %v", err))
return
}
writeJSON(w, http.StatusOK, ownerProfileFrom(p))
}
// adminPutOwnerProfile 校验并落盘主人档案(0600 原子写),随后热替换活档案,使
// 新白名单**立即**参与渠道层的 actor 判定,不必重启(design/11 §9「可编辑、
// 热重载」)。校验失败或落盘失败都以明确的错误作答,绝不假装保存成功。
func (s *Server) adminPutOwnerProfile(w http.ResponseWriter, r *http.Request) {
var req ownerProfileResponse
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
p := req.toOwnerProfile()
if err := p.Validate(); err != nil {
s.auditClaims(r, "owner.profile.update", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid owner profile: %v", err))
return
}
if err := user.SaveOwnerProfile(ownerProfilePath, p); err != nil {
s.auditClaims(r, "owner.profile.update", "error")
s.log.Error("owner profile persist", "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "owner_profile_persist_failed", "failed to persist owner profile"))
return
}
s.hotSwapOwnerProfile(p)
s.auditClaims(r, "owner.profile.update", "ok")
writeJSON(w, http.StatusOK, ownerProfileFrom(p))
}
// liveOwnerProfile 是 OwnerRegistrar 的**可选**扩展:组合根额外实现它时(其
// ownerRegistrar 持有启动时加载的活 *user.OwnerProfile,也就是渠道 bridge 用来
// 解析 actor 的同一个指针),admin 写路径就能原地热替换那份档案。
type liveOwnerProfile interface {
// LiveOwnerProfile 返回活的档案;启动时未能加载档案时为 nil。
LiveOwnerProfile() *user.OwnerProfile
}
// hotSwapOwnerProfile 把刚落盘的档案原地装进活档案。拿不到活档案时只记一条
// WARN 说明「已落盘、需重启生效」,绝不假装热重载成功。
func (s *Server) hotSwapOwnerProfile(p *user.OwnerProfile) {
holder, ok := s.ownerRegistrar.(liveOwnerProfile)
if !ok {
s.log.Warn("owner profile saved; live profile not exposed by composition root, restart to apply")
return
}
live := holder.LiveOwnerProfile()
if live == nil {
s.log.Warn("owner profile saved; no live profile was loaded at boot, restart to apply")
return
}
live.Replace(p)
s.log.Info("owner profile hot-reloaded", "accounts", len(p.Accounts))
}