Files
ttstd-infinite-canvas/backend/internal/skills/skills_presets.go

110 lines
4.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package skills
import (
_ "embed"
"encoding/json"
"fmt"
"regexp"
"strings"
)
//go:embed seed/presets.json
var builtinSkillPresetsJSON []byte
// 场景预设(Scene Presets):把「说人话的场景」映射到一组已启用技能,
// 让用户不必在 120+ 个技能里盲挑。v1 为只读目录,与技能种子同源发布。
//
// 分层契约:内容层(组合知识)在 judian-skills 的 presets.json 独立演进,
// 本文件只做加载、校验与分发;preset 引用一律用 skillId(编号一编永不变),
// 因此技能改名不影响预设有效性。
type SkillPreset struct {
PresetID string `json:"presetId"`
Name string `json:"name"`
Scene string `json:"scene"`
SkillIDs []string `json:"skillIds"`
Rationale string `json:"rationale"`
Source string `json:"source"`
Evidence string `json:"evidence"`
Upgrade string `json:"upgrade"`
}
type skillPresetsFile struct {
Version int `json:"version"`
Updated string `json:"updated"`
Note string `json:"note"`
Presets []SkillPreset `json:"presets"`
}
var skillPresetIDPattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
var skillPresetScenes = map[string]struct{}{
"drama": {}, "creative": {}, "ecommerce": {}, "social": {}, "others": {},
}
var skillPresetEvidence = map[string]struct{}{
"E1": {}, "E2": {}, "E3": {}, "E4": {}, "E5": {},
}
// SkillPresets 返回校验合格的场景预设目录;数据非法时返回错误(启动即失败好过线上坏数据)。
func (s *Service) SkillPresets() ([]SkillPreset, error) {
var file skillPresetsFile
if err := json.Unmarshal(builtinSkillPresetsJSON, &file); err != nil {
return nil, fmt.Errorf("解析场景预设失败: %w", err)
}
if file.Version != 1 {
return nil, fmt.Errorf("场景预设版本必须为 1,当前 %d", file.Version)
}
if len(file.Presets) == 0 {
return nil, fmt.Errorf("场景预设不能为空")
}
seeded := make(map[string]struct{}, 128)
seedIDs, err := builtinSeedSkillIDs()
if err != nil {
return nil, fmt.Errorf("校验场景预设引用失败: %w", err)
}
for _, definition := range seedIDs {
seeded[definition] = struct{}{}
}
seen := make(map[string]struct{}, len(file.Presets))
for _, preset := range file.Presets {
id := strings.TrimSpace(preset.PresetID)
if !skillPresetIDPattern.MatchString(id) {
return nil, fmt.Errorf("场景预设 ID 非法: %q", preset.PresetID)
}
if _, dup := seen[id]; dup {
return nil, fmt.Errorf("场景预设 ID 重复: %s", id)
}
seen[id] = struct{}{}
if strings.TrimSpace(preset.Name) == "" {
return nil, fmt.Errorf("场景预设 %s 缺名称", id)
}
if _, ok := skillPresetScenes[preset.Scene]; !ok {
return nil, fmt.Errorf("场景预设 %s 的分类非法: %q", id, preset.Scene)
}
if len(preset.SkillIDs) == 0 || len(preset.SkillIDs) > 8 {
return nil, fmt.Errorf("场景预设 %s 的技能数 %d 超出 1-8(每轮激活上限)", id, len(preset.SkillIDs))
}
presetSeen := make(map[string]struct{}, len(preset.SkillIDs))
for _, skillID := range preset.SkillIDs {
if _, dup := presetSeen[skillID]; dup {
return nil, fmt.Errorf("场景预设 %s 的技能重复: %s", id, skillID)
}
presetSeen[skillID] = struct{}{}
// v1 硬约束:只允许引用种子市场已上架技能,避免用户一键挂载时遇到装不上的技能。
if _, ok := seeded[skillID]; !ok {
return nil, fmt.Errorf("场景预设 %s 引用了未上架技能: %s", id, skillID)
}
}
if preset.Source != "hand-curated" {
return nil, fmt.Errorf("场景预设 %s 的 source 必须为 hand-curated(v1 禁止遥测驱动)", id)
}
if _, ok := skillPresetEvidence[preset.Evidence]; !ok {
return nil, fmt.Errorf("场景预设 %s 的证据等级非法: %q", id, preset.Evidence)
}
if rationale := strings.TrimSpace(preset.Rationale); rationale == "" || len([]rune(rationale)) > 200 {
return nil, fmt.Errorf("场景预设 %s 的推荐理由为空或超过 200 字", id)
}
}
return file.Presets, nil
}