按 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,已归因非本次引入)
93 lines
2.5 KiB
Go
93 lines
2.5 KiB
Go
package loader
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// ErrRequiredField is wrapped by validation errors so callers can detect a
|
|
// persona that is missing required fields.
|
|
var ErrRequiredField = errors.New("persona: required field missing")
|
|
|
|
// Load reads and unmarshals a single persona YAML file at path. Missing required
|
|
// fields produce a %w-wrapped validation error; IO and parse failures are also
|
|
// wrapped with their source path.
|
|
func Load(path string) (*Persona, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("persona: read %s: %w", path, err)
|
|
}
|
|
p := &Persona{}
|
|
if err := yaml.Unmarshal(data, p); err != nil {
|
|
return nil, fmt.Errorf("persona: parse %s: %w", path, err)
|
|
}
|
|
if err := p.validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
// LoadDir loads every *.yaml / *.yml file directly inside dir in lexical order.
|
|
// It fails atomically: the first file that errors aborts the whole load. The
|
|
// directory itself must exist.
|
|
func LoadDir(dir string) ([]*Persona, error) {
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("persona: read dir %s: %w", dir, err)
|
|
}
|
|
files := make([]string, 0, len(entries))
|
|
for _, e := range entries {
|
|
if e.IsDir() || !isYAMLExt(filepath.Ext(e.Name())) {
|
|
continue
|
|
}
|
|
files = append(files, e.Name())
|
|
}
|
|
personas := make([]*Persona, 0, len(files))
|
|
for _, name := range files {
|
|
p, err := Load(filepath.Join(dir, name))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("persona: dir %s: %w", dir, err)
|
|
}
|
|
personas = append(personas, p)
|
|
}
|
|
return personas, nil
|
|
}
|
|
|
|
// validate checks the persona for its required identity fields and reports all
|
|
// the missing ones at once.
|
|
func (p *Persona) validate() error {
|
|
missing := make([]string, 0, 5)
|
|
if p.ID == "" {
|
|
missing = append(missing, "id")
|
|
}
|
|
if p.Name == "" {
|
|
missing = append(missing, "name")
|
|
}
|
|
if p.CharacterRef == "" {
|
|
missing = append(missing, "characterRef")
|
|
}
|
|
if p.Identity.Role == "" {
|
|
missing = append(missing, "identity.role")
|
|
}
|
|
if p.Identity.SystemPrompt == "" {
|
|
missing = append(missing, "identity.systemPrompt")
|
|
}
|
|
if len(missing) > 0 {
|
|
return fmt.Errorf("%w %q: %s", ErrRequiredField, p.ID, strings.Join(missing, ", "))
|
|
}
|
|
if err := p.ValidateThinking(); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// isYAMLExt reports whether ext is one of the accepted YAML extensions.
|
|
func isYAMLExt(ext string) bool {
|
|
return ext == ".yaml" || ext == ".yml"
|
|
}
|