Files
Cyrene_v2/cmd/cyrene/persona_prompt_test.go
AskaEth 38a403afca
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
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,已归因非本次引入)
2026-10-03 19:54:22 +08:00

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)
}
}