按 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,已归因非本次引入)
241 lines
8.3 KiB
Go
241 lines
8.3 KiB
Go
package main
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
|
|
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
|
|
)
|
|
|
|
// boolPtr returns a pointer to v, for the tri-state switch fields.
|
|
func boolPtr(v bool) *bool { return &v }
|
|
|
|
// TestInstructionsFromPersona_AllEmptyIsByteIdenticalToTheOldOutput locks the
|
|
// backward-compatibility promise of design/01 §2.2: a persona that sets none of
|
|
// the five previously-inert fields produces exactly what the old code produced
|
|
// (the identity block alone, in its own precedence order).
|
|
func TestInstructionsFromPersona_AllEmptyIsByteIdenticalToTheOldOutput(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cases := []struct {
|
|
name string
|
|
p loader.Persona
|
|
want string
|
|
}{
|
|
{
|
|
name: "system prompt wins",
|
|
p: loader.Persona{Identity: loader.Identity{SystemPrompt: "身份块", Role: "人设"}},
|
|
want: "身份块",
|
|
},
|
|
{
|
|
name: "role fallback",
|
|
p: loader.Persona{Identity: loader.Identity{Role: "人设"}},
|
|
want: "人设",
|
|
},
|
|
{
|
|
name: "minimal fallback",
|
|
p: loader.Persona{},
|
|
want: fallbackInstruction,
|
|
},
|
|
}
|
|
for _, tt := range cases {
|
|
tt := tt
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
if got := instructionsFromPersona(&tt.p); got != tt.want {
|
|
t.Fatalf("instructionsFromPersona() = %q, want %q (byte-identical)", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestInstructionsFromPersona_SectionOrderAndFormat pins the exact concatenation:
|
|
// identity block first, then personality → values → speechStyle → catchphrase →
|
|
// background, each with its bracketed header and no blank section.
|
|
func TestInstructionsFromPersona_SectionOrderAndFormat(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
p := loader.Persona{
|
|
Identity: loader.Identity{SystemPrompt: "你是昔涟。"},
|
|
Personality: "温柔体贴",
|
|
Values: []string{"陪伴", "真诚"},
|
|
SpeechStyle: "轻声细语",
|
|
Catchphrase: "嘿嘿♪",
|
|
Background: "来自翁法罗斯。",
|
|
}
|
|
want := "你是昔涟。" +
|
|
"\n\n【性格】\n温柔体贴" +
|
|
"\n\n【价值观】\n- 陪伴\n- 真诚" +
|
|
"\n\n【说话风格】\n轻声细语" +
|
|
"\n\n【口头禅】\n嘿嘿♪" +
|
|
"\n\n【背景】\n来自翁法罗斯。"
|
|
got := instructionsFromPersona(&p)
|
|
if got != want {
|
|
t.Fatalf("instructionsFromPersona() = %q\nwant %q", got, want)
|
|
}
|
|
// The identity block must remain the first section, unchanged.
|
|
if !strings.HasPrefix(got, "你是昔涟。\n\n") {
|
|
t.Fatalf("identity block is not the leading section: %q", got)
|
|
}
|
|
}
|
|
|
|
// TestInstructionsFromPersona_BlankFieldsProduceNoSection asserts an unset (or
|
|
// blank) field contributes no header and no empty paragraph, and that the
|
|
// sections keep their relative order when only some are set.
|
|
func TestInstructionsFromPersona_BlankFieldsProduceNoSection(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
p := loader.Persona{
|
|
Identity: loader.Identity{SystemPrompt: "身份"},
|
|
Values: []string{"", " ", "真诚"},
|
|
Background: " ",
|
|
}
|
|
got := instructionsFromPersona(&p)
|
|
want := "身份\n\n【价值观】\n- 真诚"
|
|
if got != want {
|
|
t.Fatalf("instructionsFromPersona() = %q, want %q", got, want)
|
|
}
|
|
for _, header := range []string{personalityHeader, speechStyleHeader, catchphraseHeader, backgroundHeader} {
|
|
if strings.Contains(got, header) {
|
|
t.Errorf("output %q contains %s for a blank field", got, header)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestInstructionsFromPersona_RealDefaultPersona checks the shipped default
|
|
// persona end to end: its five fields are now injected, in order, after the
|
|
// identity block — and the identity block alone is still a strict prefix (so the
|
|
// pre-existing prompt is preserved verbatim, nothing rewritten).
|
|
func TestInstructionsFromPersona_RealDefaultPersona(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
p, err := loader.Load("../../configs/personas/cyrene.yaml")
|
|
if err != nil {
|
|
t.Fatalf("Load(cyrene.yaml): %v", err)
|
|
}
|
|
got := instructionsFromPersona(p)
|
|
if !strings.HasPrefix(got, p.Identity.SystemPrompt) {
|
|
t.Fatal("identity.systemPrompt is not the leading block")
|
|
}
|
|
last := -1
|
|
for _, header := range []string{personalityHeader, valuesHeader, speechStyleHeader, catchphraseHeader, backgroundHeader} {
|
|
idx := strings.Index(got, header)
|
|
if idx < 0 {
|
|
t.Fatalf("output is missing %s", header)
|
|
}
|
|
if idx <= last {
|
|
t.Fatalf("%s is out of order (at %d, previous at %d)", header, idx, last)
|
|
}
|
|
last = idx
|
|
}
|
|
}
|
|
|
|
// TestPersonaThinking_MappingPinsTheKnobs asserts the persona → model.Thinking
|
|
// mapping: only request-affecting fields create a directive (so a bare
|
|
// showToUser, which is presentation-only, can never switch provider thinking on),
|
|
// and mode=default never becomes an explicit switch.
|
|
func TestPersonaThinking_MappingPinsTheKnobs(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
show := false
|
|
cases := []struct {
|
|
name string
|
|
thinking loader.ThinkingParams
|
|
wantNil bool
|
|
wantBudget int
|
|
wantEnable *bool
|
|
wantEffort string
|
|
}{
|
|
{name: "nothing", thinking: loader.ThinkingParams{}, wantNil: true},
|
|
{name: "default mode only", thinking: loader.ThinkingParams{Mode: loader.ThinkingModeDefault}, wantNil: true},
|
|
{name: "showToUser alone is presentation only", thinking: loader.ThinkingParams{ShowToUser: &show}, wantNil: true},
|
|
{name: "budget", thinking: loader.ThinkingParams{BudgetTokens: 4096}, wantBudget: 4096},
|
|
{name: "effort", thinking: loader.ThinkingParams{Effort: " medium "}, wantEffort: "medium"},
|
|
{name: "mode on", thinking: loader.ThinkingParams{Mode: loader.ThinkingModeOn}, wantEnable: boolPtr(true)},
|
|
{name: "mode off", thinking: loader.ThinkingParams{Mode: loader.ThinkingModeOff}, wantEnable: boolPtr(false)},
|
|
}
|
|
for _, tt := range cases {
|
|
tt := tt
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
p := &loader.Persona{Thinking: tt.thinking}
|
|
got, effort := personaThinking(p)
|
|
if tt.wantNil {
|
|
if got != nil {
|
|
t.Fatalf("personaThinking() = %+v, want nil", got)
|
|
}
|
|
if effort != "" {
|
|
t.Fatalf("effort = %q, want empty", effort)
|
|
}
|
|
return
|
|
}
|
|
if got == nil {
|
|
t.Fatal("personaThinking() = nil, want a directive")
|
|
}
|
|
if got.BudgetTokens != tt.wantBudget {
|
|
t.Errorf("BudgetTokens = %d, want %d", got.BudgetTokens, tt.wantBudget)
|
|
}
|
|
if effort != tt.wantEffort {
|
|
t.Errorf("effort = %q, want %q", effort, tt.wantEffort)
|
|
}
|
|
switch {
|
|
case tt.wantEnable == nil && got.Enable != nil:
|
|
t.Errorf("Enable = %v, want nil", *got.Enable)
|
|
case tt.wantEnable != nil && got.Enable == nil:
|
|
t.Errorf("Enable = nil, want %v", *tt.wantEnable)
|
|
case tt.wantEnable != nil && *got.Enable != *tt.wantEnable:
|
|
t.Errorf("Enable = %v, want %v", *got.Enable, *tt.wantEnable)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestDefaultThinkingOptions_FromRegistry walks the wiring the composition root
|
|
// uses: the default character's persona supplies the loop's knobs, and an
|
|
// unregistered persona yields the zero options (pre-existing behavior).
|
|
func TestDefaultThinkingOptions_FromRegistry(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
reg := personareg.New()
|
|
show := false
|
|
p := &loader.Persona{
|
|
ID: "xilian",
|
|
Name: "昔涟",
|
|
Identity: loader.Identity{Role: "r", SystemPrompt: "s"},
|
|
Thinking: loader.ThinkingParams{BudgetTokens: 2048, Effort: "high", ShowToUser: &show, Mode: loader.ThinkingModeOn},
|
|
}
|
|
if err := reg.RegisterPersona(p); err != nil {
|
|
t.Fatalf("RegisterPersona: %v", err)
|
|
}
|
|
if err := reg.RegisterCharacter(&personareg.Character{ID: "xilian", Name: "昔涟", Default: true, PersonaID: "xilian"}); err != nil {
|
|
t.Fatalf("RegisterCharacter: %v", err)
|
|
}
|
|
a := &app{personas: reg}
|
|
|
|
thinking, effort, gotShow := a.defaultThinkingOptions()
|
|
if thinking == nil || thinking.BudgetTokens != 2048 {
|
|
t.Fatalf("thinking = %+v, want BudgetTokens 2048", thinking)
|
|
}
|
|
if thinking.Enable == nil || !*thinking.Enable {
|
|
t.Fatalf("thinking.Enable = %v, want true (mode: on)", thinking.Enable)
|
|
}
|
|
if effort != "high" {
|
|
t.Fatalf("effort = %q, want high", effort)
|
|
}
|
|
if gotShow == nil || *gotShow {
|
|
t.Fatalf("showToUser = %v, want an explicit false", gotShow)
|
|
}
|
|
}
|
|
|
|
// TestDefaultThinkingOptions_NoPersona keeps the no-registry case inert.
|
|
func TestDefaultThinkingOptions_NoPersona(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
a := &app{personas: personareg.New()}
|
|
thinking, effort, show := a.defaultThinkingOptions()
|
|
if thinking != nil || effort != "" || show != nil {
|
|
t.Fatalf("defaultThinkingOptions() = %+v/%q/%v, want the zero options", thinking, effort, show)
|
|
}
|
|
}
|