// Package loader loads persona definitions from YAML files using gopkg.in/yaml.v3. // A persona is the self-awareness/identity bound to a character and, per // DevDocs/design/01, it also carries the emotion state-machine parameters, the // affection curve, and a per-persona memory namespace (memory isolation). package loader import ( "errors" "fmt" "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/emotion" ) // Default affection bounds and step, applied when a persona defines no // affection curve. const ( defaultAffectionMin = 0 defaultAffectionMax = 100 defaultAffectionInit = 50 defaultAffectionDelta = 5 ) // Thinking-move values accepted by ThinkingParams.Mode (design/01 §2.1). The // empty string is equivalent to ThinkingModeDefault. const ( // ThinkingModeDefault leaves the provider's own reasoning behavior alone. ThinkingModeDefault = "default" // ThinkingModeOn explicitly enables reasoning where the provider has a switch. ThinkingModeOn = "on" // ThinkingModeOff explicitly disables reasoning where the provider has a switch. ThinkingModeOff = "off" ) // ErrInvalidThinking is wrapped by the thinking-config validation error so a // caller can detect a bad `thinking` block (unknown mode, negative budget) // instead of seeing it silently ignored. var ErrInvalidThinking = errors.New("persona: invalid thinking config") // defaultMemoryNS is the namespace prefix used when a persona omits a concrete // memory namespace; the full namespace becomes prefix + "." + persona id. const defaultMemoryNS = "persona" // Identity is the persona's self-awareness: the 人设 (who she is, e.g. a // character role) plus the system prompt that carries that identity at runtime. type Identity struct { // Role is the 人设 — the characterization the persona assumes. Role string `yaml:"role"` // SystemPrompt is the identity block injected as the system prompt. SystemPrompt string `yaml:"systemPrompt"` } // EmotionParams configures the persona's emotion state machine and its per- // persona memory isolation namespace. Transitions may be empty, in which case // the emotion package installs its default transition table. type EmotionParams struct { InitialState emotion.EmotionState `yaml:"initialState"` DecayRate float64 `yaml:"decayRate"` Transitions map[emotion.EmotionState][]emotion.EmotionState `yaml:"transitions"` // MemoryNamespace is the memory isolation namespace for this persona. MemoryNamespace string `yaml:"memoryNamespace"` } // AffectionParams defines this persona's affection curve bounds and the default // step applied to an Adjust call that carries no delta. type AffectionParams struct { Initial int `yaml:"initial"` Min int `yaml:"min"` Max int `yaml:"max"` Delta int `yaml:"delta"` } // ThinkingParams is the persona's optional `thinking` section (design/01 §2.1). // It describes presentation/resource knobs only — never an ability bound — and // defaults are chosen so an absent section changes nothing. It is marshaled by // the admin persona write path, so every field is omitempty: a persona that // configures no thinking gets no `thinking:` key in its YAML. type ThinkingParams struct { // BudgetTokens is the reasoning budget (Anthropic thinking.budget_tokens; the // official semantic is a target, not a hard cap). Zero = unset. BudgetTokens int `yaml:"budgetTokens,omitempty"` // Effort is the OpenAI-compatible reasoning strength (reasoning_effort). The // accepted values depend on the provider; the model layer validates them // against its per-provider whitelist (design/04 §3.2). Effort string `yaml:"effort,omitempty"` // ShowToUser says whether streamed thinking frames are pushed to the client. // It is a POINTER on purpose: a missing key means "keep the current behavior" // (thinking frames are shown), while an explicit false means "store it, but // do not push it". A plain bool would conflate "unset" with "off". ShowToUser *bool `yaml:"showToUser,omitempty"` // Mode is the explicit hard switch: default | on | off. Empty = default. Mode string `yaml:"mode,omitempty"` } // Enable maps Mode onto the model layer's tri-state switch: "on" → true, "off" → // false, ""/"default" → nil (no explicit switch, leave it to the provider). The // reasoning knobs the model layer consumes never include a null switch, so // "default" cannot accidentally turn provider thinking on. func (t ThinkingParams) Enable() *bool { switch t.Mode { case ThinkingModeOn: v := true return &v case ThinkingModeOff: v := false return &v default: return nil } } // Persona is a loaded persona definition. It describes who a character is (the // identity, personality, speech style, catchphrase, background, values) and // never restricts what that character can do. // PersonaVisual carries the persona-owned visual assets (per-role icon/avatar // and optional chat background). Each persona configures its OWN visuals; the UI // renders the current persona's resources, so one role never inherits another's. type PersonaVisual struct { // Avatar is the persona's avatar image path (e.g. /images/Cyrene_Avatar/...). Avatar string `yaml:"avatar,omitempty"` // ChatBackground optionally sets the persona's chat backdrop, per orientation. ChatBackground ChatBackground `yaml:"chatBackground,omitempty"` } // ChatBackground holds per-orientation chat wallpaper paths (landscape/portrait). type ChatBackground struct { Landscape string `yaml:"landscape,omitempty"` Portrait string `yaml:"portrait,omitempty"` } // DefaultAvatar is the neutral fallback avatar shown when a persona has no // avatar configured (so a role never silently borrows another persona's view). const DefaultAvatar = "/images/User_Avatar/Default_Avatar.png" type Persona struct { ID string `yaml:"id"` Name string `yaml:"name"` CharacterRef string `yaml:"characterRef"` Identity Identity `yaml:"identity"` Personality string `yaml:"personality"` SpeechStyle string `yaml:"speechStyle"` Catchphrase string `yaml:"catchphrase"` Background string `yaml:"background"` Values []string `yaml:"values"` Emotion EmotionParams `yaml:"emotion"` Affection AffectionParams `yaml:"affection"` // Thinking is the optional reasoning/presentation config (design/01 §2.1); // omitted when the persona configures none, so no existing YAML gains noise. Thinking ThinkingParams `yaml:"thinking,omitempty"` // Awakening 是该人格可选的唤醒档位覆盖(design/14 §3,裁决 14.4.3)。 // nil = 角色档案里没写该块 = 没有该档位:不补默认值,由决策层走全局默认。 Awakening *AwakeningParams `yaml:"awakening,omitempty"` Visual PersonaVisual `yaml:"visual,omitempty"` // TriggerWords are the role-name words that, when a public-platform group // message mentions one, route the message into the participation PROBING // stage even without a direct @ (design/18 §2 触发②). Each persona defines its // own reminder words; they never imply identity (that stays structural). TriggerWords []string `yaml:"triggerWords,omitempty"` } // EmotionConfig converts this persona's own fields into the params consumed by // the emotion machine constructor. func (p *Persona) EmotionConfig() emotion.Params { return emotion.Params{ InitialState: p.Emotion.InitialState, DecayRate: p.Emotion.DecayRate, Transitions: p.Emotion.Transitions, } } // MemoryNamespace returns the effective memory isolation namespace for this // persona — the configured one, or a derived default of "persona." when none // is set. The derived default guarantees per-persona isolation. func (p *Persona) MemoryNamespace() string { if p.Emotion.MemoryNamespace != "" { return p.Emotion.MemoryNamespace } return defaultMemoryNS + "." + p.ID } // NewMachine builds the persona's emotion state machine from its config. func (p *Persona) NewMachine() (*emotion.Machine, error) { return emotion.NewMachine(p.EmotionConfig()) } // NewAffection builds the persona's affection tracker from its curve config, // applying default bounds when no curve was defined. func (p *Persona) NewAffection() (*emotion.Affection, error) { params := p.Affection if params.Min == 0 && params.Max == 0 && params.Initial == 0 && params.Delta == 0 { params = AffectionParams{ Initial: defaultAffectionInit, Min: defaultAffectionMin, Max: defaultAffectionMax, Delta: defaultAffectionDelta, } } return emotion.NewAffection(params.Initial, params.Min, params.Max) } // AffectionStep returns the per-turn affection magnitude of this persona's // 好感度曲线 (design/01 §2) — the configured `affection.delta`, or the loader // default when the persona declares no curve. It is the magnitude applied once // per emotional turn; the sign comes from the turn's signal. func (p *Persona) AffectionStep() int { if p.Affection.Delta != 0 { return p.Affection.Delta } return defaultAffectionDelta } // ValidateThinking checks the optional `thinking` section and returns a // %w-wrapped ErrInvalidThinking error for an unrecognized mode or a negative // budget, so a bad value is reported instead of silently ignored. func (p *Persona) ValidateThinking() error { switch p.Thinking.Mode { case "", ThinkingModeDefault, ThinkingModeOn, ThinkingModeOff: default: return fmt.Errorf("%w: mode %q (want %s|%s|%s)", ErrInvalidThinking, p.Thinking.Mode, ThinkingModeDefault, ThinkingModeOn, ThinkingModeOff) } if p.Thinking.BudgetTokens < 0 { return fmt.Errorf("%w: budgetTokens %d must not be negative", ErrInvalidThinking, p.Thinking.BudgetTokens) } return nil }