feat(persona,model,loop): 人格 thinking 配置 + 5 个死字段接线 + 推理旋钮(按 provider 映射)
按 DevDocs/design/01 §2.1/§2.2 与 design/04 §3.2 实现(规格含联网核过的官方依据:思考内容刻意不角色化、不写分步思考指令、思考控制只走参数级)。
- 人格 schema 新增 thinking:budgetTokens / effort / showToUser(*bool,省略=nil=保持现行为,显式 false=只落库) / mode(default|on|off→Enable 三态);非法 mode/负预算 → ErrInvalidThinking(不静默)
- 5 个死字段接上注入:personality→values→speechStyle→catchphrase→background 拼为独立段落(带【】小标题,空值不拼);**全空时输出与旧实现逐字节一致**(有专门测试)
- model.Request 新增 Thinking{ BudgetTokens, Enable } 与 ReasoningEffort;新增 model/reasoning.go:厂商识别(模型名→baseURL 兜底)、取值白名单(OpenAI 系 none…max / DeepSeek low|high|max)、非法值降级+WARN、**不支持一律 WARN 而非静默**;Anthropic 映射 thinking{budget_tokens} 并**保证 budget<max_tokens**(不足时抬高 max_tokens+WARN,正是历史上思考吃光预算→空回复那个坑)
- loop:新增 Options.ThinkingShowToUser(*bool) 仅门控 thinking_* 帧(正文/工具帧不受影响);场景级 mute 语义不变
- 补一处任务清单外但必需的接线:persona→model.Request 的 thinking 映射(否则 budget/effort/mode 会变成新的死字段);showToUser 刻意不产生 Thinking(纯呈现开关)
- 管理端:personaUpsertRequest 补 thinking DTO + 校验(400);PUT 省略时**保留已存块**(防旧客户端抹配置);写侧 yaml round-trip 已验证
- Docs/api-client v0.40→v0.41(可选请求字段)
- 测试:26 个新用例(loader 解析/拼接逐字节/映射、anthropic 非流式+流式预算约束、openai 透传/降级/丢弃、Qwen 诚实 WARN、loop 门控、DTO round-trip);3 次变异验证(省略与 false 混同 / 去掉预算约束 / 短路门控 各自 FAIL)
- 验收:go build/vet/test 全绿(含 57 包)+ linux/darwin 交叉编译 + 前端 build;唯一 FAIL 是既有 flake TestInterruptCancelsTurnAndClearsPending(3/20 新代码 vs 3/20 纯 HEAD,已归因非本次引入)
This commit is contained in:
1 parent
e4e18ce2a2
commit
38a403afca
22 files changed
+1714
-56
No files matched your search
@@ -0,0 +1,102 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
|
||||
)
|
||||
|
||||
// TestPersonaUpsertRequest_ThinkingRoundTripsThroughYAML pins the admin persona
|
||||
// write path for the optional `thinking` block: the hand-written DTO maps into
|
||||
// the loader struct, yaml.Marshal emits the documented keys, and the reload of
|
||||
// that YAML yields the same values — i.e. the field really round-trips instead of
|
||||
// being dropped by an un-whitelisted DTO.
|
||||
func TestPersonaUpsertRequest_ThinkingRoundTripsThroughYAML(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
show := false
|
||||
req := personaUpsertRequest{
|
||||
ID: "xilian", Name: "昔涟", Role: "r", SystemPrompt: "s",
|
||||
Thinking: &personaThinkingRequest{BudgetTokens: 4096, Effort: " medium ", ShowToUser: &show, Mode: " on "},
|
||||
}
|
||||
p := personaFromRequest(req)
|
||||
if err := p.ValidateThinking(); err != nil {
|
||||
t.Fatalf("ValidateThinking() error: %v", err)
|
||||
}
|
||||
data, err := yaml.Marshal(p)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal: %v", err)
|
||||
}
|
||||
back := &loader.Persona{}
|
||||
if err := yaml.Unmarshal(data, back); err != nil {
|
||||
t.Fatalf("Unmarshal: %v", err)
|
||||
}
|
||||
if back.Thinking.BudgetTokens != 4096 || back.Thinking.Mode != loader.ThinkingModeOn || back.Thinking.Effort != "medium" {
|
||||
t.Fatalf("thinking = %+v, want 4096/on/medium", back.Thinking)
|
||||
}
|
||||
if back.Thinking.ShowToUser == nil || *back.Thinking.ShowToUser {
|
||||
t.Fatalf("showToUser = %v, want an explicit false", back.Thinking.ShowToUser)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPersonaUpsertRequest_ThinkingInvalidRejected asserts an invalid thinking
|
||||
// block is rejected through the same validation the loader uses, so the admin API
|
||||
// cannot persist a mode the runtime would refuse to load.
|
||||
func TestPersonaUpsertRequest_ThinkingInvalidRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
p := personaFromRequest(personaUpsertRequest{
|
||||
ID: "x", Name: "x", Role: "r", Thinking: &personaThinkingRequest{Mode: "always"},
|
||||
})
|
||||
err := p.ValidateThinking()
|
||||
if err == nil {
|
||||
t.Fatal("ValidateThinking() = nil, want an error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "always") {
|
||||
t.Fatalf("error %q does not name the offending mode", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPersonaUpsertRequest_NoThinkingBlockOmitsTheKey asserts a request without a
|
||||
// thinking block produces none in the YAML, so no existing persona file gains a
|
||||
// noise section.
|
||||
func TestPersonaUpsertRequest_NoThinkingBlockOmitsTheKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
data, err := yaml.Marshal(personaFromRequest(personaUpsertRequest{ID: "x", Name: "x", Role: "r", SystemPrompt: "s"}))
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal: %v", err)
|
||||
}
|
||||
if strings.Contains(string(data), "thinking") {
|
||||
t.Fatalf("marshaled persona carries a thinking block:\n%s", data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyThinkingEdit_PreservesOnOmission is the non-destructive update rule: a
|
||||
// client that omits `thinking` keeps the stored block, an explicit block replaces
|
||||
// it, and an all-default block clears it.
|
||||
func TestApplyThinkingEdit_PreservesOnOmission(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
show := false
|
||||
stored := &loader.Persona{Thinking: loader.ThinkingParams{BudgetTokens: 8192, Mode: loader.ThinkingModeOn, ShowToUser: &show}}
|
||||
|
||||
kept := applyThinkingEdit(nil, stored)
|
||||
if kept.BudgetTokens != 8192 || kept.Mode != loader.ThinkingModeOn || kept.ShowToUser == nil {
|
||||
t.Fatalf("applyThinkingEdit(nil, stored) = %+v, want the stored block", kept)
|
||||
}
|
||||
replaced := applyThinkingEdit(&personaThinkingRequest{BudgetTokens: 1024, Mode: "off"}, stored)
|
||||
if replaced.BudgetTokens != 1024 || replaced.Mode != loader.ThinkingModeOff || replaced.ShowToUser != nil {
|
||||
t.Fatalf("applyThinkingEdit(explicit, stored) = %+v, want the request block", replaced)
|
||||
}
|
||||
cleared := applyThinkingEdit(&personaThinkingRequest{}, stored)
|
||||
if cleared.BudgetTokens != 0 || cleared.Mode != "" || cleared.ShowToUser != nil {
|
||||
t.Fatalf("applyThinkingEdit(empty object, stored) = %+v, want the zero block", cleared)
|
||||
}
|
||||
if created := applyThinkingEdit(nil, nil); created.BudgetTokens != 0 || created.ShowToUser != nil {
|
||||
t.Fatalf("applyThinkingEdit(nil, nil) = %+v, want the zero block (create path)", created)
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,19 @@ type personaUpsertRequest struct {
|
||||
Modes []string `json:"modes"`
|
||||
Mode string `json:"mode"`
|
||||
MemoryNS string `json:"memoryNamespace"`
|
||||
// Thinking is the optional reasoning/presentation block (design/01 §2.1). It
|
||||
// is persisted into the persona YAML as-is; omitting it on an update keeps the
|
||||
// stored block (see adminUpdatePersona).
|
||||
Thinking *personaThinkingRequest `json:"thinking,omitempty"`
|
||||
}
|
||||
|
||||
// personaThinkingRequest is the wire form of the persona `thinking` section. The
|
||||
// pointers/naming mirror the YAML so a panel can round-trip it verbatim.
|
||||
type personaThinkingRequest struct {
|
||||
BudgetTokens int `json:"budgetTokens,omitempty"`
|
||||
Effort string `json:"effort,omitempty"`
|
||||
ShowToUser *bool `json:"showToUser,omitempty"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
}
|
||||
|
||||
// adminCreatePersona registers a new persona in the registry (real runtime
|
||||
@@ -65,6 +78,11 @@ func (s *Server) adminCreatePersona(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
p := personaFromRequest(req)
|
||||
if err := p.ValidateThinking(); err != nil {
|
||||
s.auditClaims(r, "persona.create", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid thinking config: %v", err))
|
||||
return
|
||||
}
|
||||
if err := savePersonaYAML(p); err != nil {
|
||||
s.auditClaims(r, "persona.create", "error")
|
||||
s.log.Error("persona persist", "id", req.ID, "err", err)
|
||||
@@ -112,6 +130,16 @@ func (s *Server) adminUpdatePersona(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
p := personaFromRequest(req)
|
||||
if err := p.ValidateThinking(); err != nil {
|
||||
s.auditClaims(r, "persona.update", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid thinking config: %v", err))
|
||||
return
|
||||
}
|
||||
// A client that does not know the optional `thinking` block (an older panel, a
|
||||
// script) omits it; keep the stored one instead of silently wiping a
|
||||
// hand-edited config. Sending the object replaces it, and sending it with all
|
||||
// fields default clears it.
|
||||
p.Thinking = applyThinkingEdit(req.Thinking, existingPersona(reg, id))
|
||||
if err := savePersonaYAML(p); err != nil {
|
||||
s.auditClaims(r, "persona.update", "error")
|
||||
s.log.Error("persona persist", "id", id, "err", err)
|
||||
@@ -223,9 +251,46 @@ func personaFromRequest(req personaUpsertRequest) *loader.Persona {
|
||||
Background: req.Background,
|
||||
Values: req.Values,
|
||||
Emotion: loader.EmotionParams{MemoryNamespace: strings.TrimSpace(req.MemoryNS)},
|
||||
Thinking: thinkingFromRequest(req.Thinking),
|
||||
}
|
||||
}
|
||||
|
||||
// thinkingFromRequest maps the wire `thinking` block onto the persona field; a
|
||||
// missing block yields the zero value, which yaml.Marshal omits entirely.
|
||||
func thinkingFromRequest(req *personaThinkingRequest) loader.ThinkingParams {
|
||||
if req == nil {
|
||||
return loader.ThinkingParams{}
|
||||
}
|
||||
return loader.ThinkingParams{
|
||||
BudgetTokens: req.BudgetTokens,
|
||||
Effort: strings.TrimSpace(req.Effort),
|
||||
ShowToUser: req.ShowToUser,
|
||||
Mode: strings.TrimSpace(req.Mode),
|
||||
}
|
||||
}
|
||||
|
||||
// existingPersona returns the stored persona for id, or nil when it is absent.
|
||||
func existingPersona(reg *personareg.Registry, id string) *loader.Persona {
|
||||
if p, ok := reg.Persona(id); ok {
|
||||
return p
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyThinkingEdit returns the `thinking` block an update should persist: the
|
||||
// request's own block when it carries one, otherwise the stored persona's block —
|
||||
// so a client that does not know the optional field can never wipe it. An update
|
||||
// that sends the object replaces the block outright (all-default fields clear it).
|
||||
func applyThinkingEdit(req *personaThinkingRequest, existing *loader.Persona) loader.ThinkingParams {
|
||||
if req != nil {
|
||||
return thinkingFromRequest(req)
|
||||
}
|
||||
if existing != nil {
|
||||
return existing.Thinking
|
||||
}
|
||||
return loader.ThinkingParams{}
|
||||
}
|
||||
|
||||
// characterFromRequest builds the registry.Character bound to the persona.
|
||||
func characterFromRequest(req personaUpsertRequest) *personareg.Character {
|
||||
modes := append([]string(nil), req.Modes...)
|
||||
|
||||
Reference in new issue
Block a user