feat(persona,model,loop): 人格 thinking 配置 + 5 个死字段接线 + 推理旋钮(按 provider 映射)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

按 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:
AskaEth committed 2026-10-03 19:54:22 +08:00
1 parent e4e18ce2a2
commit 38a403afca
22 files changed
+1714 -56

No files matched your search

+136
View File
@@ -0,0 +1,136 @@
package main
import (
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// Section headers for the persona fields that design/01 §2.2 lists as previously
// inert. They are appended as separate system-prompt sections after the identity
// block so each one can be tuned without touching the identity text.
const (
personalityHeader = "【性格】"
valuesHeader = "【价值观】"
speechStyleHeader = "【说话风格】"
catchphraseHeader = "【口头禅】"
backgroundHeader = "【背景】"
)
// personaSection is one appended block: a bracketed header plus its body.
type personaSection struct {
header string
body string
}
// defaultPersona returns the default character's persona, or false when no
// character/persona is registered.
func (a *app) defaultPersona() (*loader.Persona, bool) {
c, ok := a.personas.DefaultCharacter()
if !ok {
return nil, false
}
p, ok := a.personas.Persona(c.PersonaID)
if !ok || p == nil {
return nil, false
}
return p, true
}
// identityInstructionFor returns the persona's identity block — the only part
// injected before design/01 §2.2: identity.systemPrompt, else identity.role, else
// the minimal fallback.
func identityInstructionFor(p *loader.Persona) string {
if p.Identity.SystemPrompt != "" {
return p.Identity.SystemPrompt
}
if p.Identity.Role != "" {
return p.Identity.Role
}
return fallbackInstruction
}
// instructionsFromPersona builds the persona's system instruction: the identity
// block, followed by the personality/values/speechStyle/catchphrase/background
// sections that used to have no consumer (design/01 §2.2). Blank fields are
// skipped so no empty section is emitted, and a persona with none of them yields
// exactly the identity block — byte for byte the pre-existing output.
func instructionsFromPersona(p *loader.Persona) string {
base := identityInstructionFor(p)
sections := personaSections(p)
if len(sections) == 0 {
return base
}
var b strings.Builder
b.WriteString(base)
for _, s := range sections {
b.WriteString("\n\n")
b.WriteString(s.header)
b.WriteString("\n")
b.WriteString(s.body)
}
return b.String()
}
// personaSections renders the five optional sections in their fixed order
// (personality → values → speechStyle → catchphrase → background), skipping any
// that is blank.
func personaSections(p *loader.Persona) []personaSection {
out := make([]personaSection, 0, 5)
appendSection := func(header, body string) {
if body = strings.TrimSpace(body); body == "" {
return
}
out = append(out, personaSection{header: header, body: body})
}
appendSection(personalityHeader, p.Personality)
appendSection(valuesHeader, bulletList(p.Values))
appendSection(speechStyleHeader, p.SpeechStyle)
appendSection(catchphraseHeader, p.Catchphrase)
appendSection(backgroundHeader, p.Background)
return out
}
// bulletList renders string items as a markdown bullet list, skipping blanks; an
// all-blank list yields "" so its section disappears entirely.
func bulletList(items []string) string {
lines := make([]string, 0, len(items))
for _, item := range items {
if item = strings.TrimSpace(item); item != "" {
lines = append(lines, "- "+item)
}
}
return strings.Join(lines, "\n")
}
// defaultThinkingOptions returns the reasoning knobs the loop should inject for
// the default persona: the thinking directive, the reasoning_effort value and the
// thinking-broadcast switch.
//
// The thinking directive is non-nil only when the persona configures something
// that affects the request (a budget, an effort, or an explicit on/off mode).
// showToUser deliberately does NOT create one: it is a presentation switch, and a
// non-nil directive with no switch would enable provider thinking as a side
// effect. All three are zero when no persona is registered, which leaves the
// loop's pre-existing behavior untouched.
func (a *app) defaultThinkingOptions() (*model.Thinking, string, *bool) {
p, ok := a.defaultPersona()
if !ok {
return nil, "", nil
}
thinking, effort := personaThinking(p)
return thinking, effort, p.Thinking.ShowToUser
}
// personaThinking maps the persona's `thinking` section onto the model-layer
// knobs (design/01 §2.1 / design/04 §3.2). It returns (nil, "") when nothing
// affects the request.
func personaThinking(p *loader.Persona) (*model.Thinking, string) {
effort := strings.TrimSpace(p.Thinking.Effort)
enable := p.Thinking.Enable()
if p.Thinking.BudgetTokens == 0 && effort == "" && enable == nil {
return nil, ""
}
return &model.Thinking{BudgetTokens: p.Thinking.BudgetTokens, Enable: enable}, effort
}
+240
View File
@@ -0,0 +1,240 @@
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)
}
}
+31 -31
View File
@@ -410,28 +410,33 @@ func (a *app) wireRuntime() error {
// emotion machine and affection move, and the returned mood label is written
// onto Result.Mood and the assistant message (design/16 §3.3).
a.emotion = a.buildEmotionObserver()
// Reasoning knobs from the default persona (budget / effort / thinking broadcast).
thinking, reasoningEffort, thinkingShow := a.defaultThinkingOptions()
a.loop = loop.New(loop.Options{
Agent: ag,
Tools: a.dispatcher,
Capacity: capDecider,
OnCapacity: a.onCapacity,
OnAudit: a.onToolAudit,
OnTurnEnd: a.onTurnEnd,
Memory: store,
Retriever: a.retriever,
Extractor: a.buildMemoryExtractor(personaID),
Checkpoint: checkpoint,
Tracer: tracer,
Flow: flowPub,
Guards: guards,
PersonaID: personaID,
Timezone: a.cfg.Timezone,
MaxRounds: defaultMaxRounds,
WorkingMemory: memory.NewWorkingMemory(0),
OnStream: a.streamChunk,
Vision: visionResolver,
Log: logger.WithSource(a.log, "runtime/loop"),
Agent: ag,
Tools: a.dispatcher,
Capacity: capDecider,
OnCapacity: a.onCapacity,
OnAudit: a.onToolAudit,
OnTurnEnd: a.onTurnEnd,
Memory: store,
Retriever: a.retriever,
Extractor: a.buildMemoryExtractor(personaID),
Checkpoint: checkpoint,
Tracer: tracer,
Flow: flowPub,
Guards: guards,
PersonaID: personaID,
Timezone: a.cfg.Timezone,
MaxRounds: defaultMaxRounds,
WorkingMemory: memory.NewWorkingMemory(0),
OnStream: a.streamChunk,
Vision: visionResolver,
Thinking: thinking,
ReasoningEffort: reasoningEffort,
ThinkingShowToUser: thinkingShow,
Log: logger.WithSource(a.log, "runtime/loop"),
})
a.scheduler = orchestration.New(orchestration.Options{
@@ -801,18 +806,13 @@ func (a *app) defaultPersonaID() string {
return "cyrene"
}
// defaultInstructions returns the default persona's system prompt, falling back
// to its role text or a minimal instruction when neither is set.
// defaultInstructions returns the default persona's system prompt followed by the
// personality/values/speechStyle/catchphrase/background sections (design/01
// §2.2), falling back to its role text or a minimal instruction when neither is
// set.
func (a *app) defaultInstructions() string {
if c, ok := a.personas.DefaultCharacter(); ok {
if p, ok := a.personas.Persona(c.PersonaID); ok {
if p.Identity.SystemPrompt != "" {
return p.Identity.SystemPrompt
}
if p.Identity.Role != "" {
return p.Identity.Role
}
}
if p, ok := a.defaultPersona(); ok {
return instructionsFromPersona(p)
}
return fallbackInstruction
}