feat(prompts): prompt 版本化地基 —— 注册表 + 运行期文件覆盖
把散落各服务的硬编码 system prompt 收口为受管注册表,不重编译即可改/回滚/对比: - shared/prompts:内置默认(随代码) + 运行期覆盖(PROMPTS_FILE) + Get/Keys,并发安全,含单测 - 接入 9 处:mcp-go(graph.extract);dispatcher(eval.quality/eval.refine/guard.jailbreak/ coordinator.lead/memory.extract,按引用登记默认、无文本重复) - main 启动调 LoadFile 加载 PROMPTS_FILE 覆盖 - live A/B:覆盖 graph.extract → 图谱抽取 2 条→0 条、向量仍正常(覆盖生效、管道未坏) - v2(DB 控制面热切换 + 灰度)留后续 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
// Package prompts 是受管系统提示词的注册表:内置默认(随代码) + 运行期覆盖(文件/控制面)。
|
||||
// 让各服务散落硬编码的 system prompt 集中、可不重编译地改/回滚/对比/灰度 —— prompt 版本化的地基。
|
||||
package prompts
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"os"
|
||||
"sort"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// 受管 prompt 的稳定键(key 即版本化/覆盖的寻址单位)。
|
||||
const (
|
||||
GraphExtract = "graph.extract" // mcp-go:知识图谱三元组抽取
|
||||
EvalQuality = "eval.quality" // dispatcher:回答质量评审(harness)
|
||||
EvalRefine = "eval.refine" // dispatcher:低质回答自动修订
|
||||
GuardJailbreak = "guard.jailbreak" // dispatcher:输入越狱/注入分类
|
||||
CoordinatorLead = "coordinator.lead" // dispatcher:多智能体协调者(lead)
|
||||
MemoryExtract = "memory.extract" // dispatcher:长期记忆对账
|
||||
)
|
||||
|
||||
// Registry 持有默认与覆盖;Get 取覆盖优先、否则默认。并发安全。
|
||||
type Registry struct {
|
||||
mu sync.RWMutex
|
||||
defaults map[string]string
|
||||
overrides map[string]string
|
||||
}
|
||||
|
||||
func New() *Registry {
|
||||
return &Registry{defaults: map[string]string{}, overrides: map[string]string{}}
|
||||
}
|
||||
|
||||
var global = New()
|
||||
|
||||
// SetDefault 登记某 key 的内置默认(基线版本,随代码走)。各服务启动时登记自己的 prompt。
|
||||
func SetDefault(key, text string) { global.SetDefault(key, text) }
|
||||
func (r *Registry) SetDefault(key, text string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.defaults[key] = text
|
||||
}
|
||||
|
||||
// SetOverride 运行期覆盖某 key(文件/控制面下发)。空串=清覆盖、回退默认。
|
||||
func SetOverride(key, text string) { global.SetOverride(key, text) }
|
||||
func (r *Registry) SetOverride(key, text string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if text == "" {
|
||||
delete(r.overrides, key)
|
||||
return
|
||||
}
|
||||
r.overrides[key] = text
|
||||
}
|
||||
|
||||
// Get 取生效内容:有覆盖用覆盖,否则用默认(都没有则空串)。
|
||||
func Get(key string) string { return global.Get(key) }
|
||||
func (r *Registry) Get(key string) string {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
if v, ok := r.overrides[key]; ok && v != "" {
|
||||
return v
|
||||
}
|
||||
return r.defaults[key]
|
||||
}
|
||||
|
||||
// Keys 返回所有已登记默认的 key(排序,供管理面/启动日志)。
|
||||
func Keys() []string { return global.Keys() }
|
||||
func (r *Registry) Keys() []string {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
ks := make([]string, 0, len(r.defaults))
|
||||
for k := range r.defaults {
|
||||
ks = append(ks, k)
|
||||
}
|
||||
sort.Strings(ks)
|
||||
return ks
|
||||
}
|
||||
|
||||
// LoadFile 从 JSON 文件({key:content})加载覆盖 —— env PROMPTS_FILE,启动时调一次。
|
||||
// 不重编译即可改/回滚/对比 prompt;DB 控制面热切换是后续增量。
|
||||
func LoadFile() {
|
||||
path := os.Getenv("PROMPTS_FILE")
|
||||
if path == "" {
|
||||
return
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
log.Printf("[prompts] 读 PROMPTS_FILE=%s 失败(用默认): %v", path, err)
|
||||
return
|
||||
}
|
||||
var m map[string]string
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
log.Printf("[prompts] 解析 PROMPTS_FILE 失败(用默认): %v", err)
|
||||
return
|
||||
}
|
||||
n := 0
|
||||
for k, v := range m {
|
||||
SetOverride(k, v)
|
||||
n++
|
||||
}
|
||||
log.Printf("[prompts] 从 %s 加载 %d 条覆盖", path, n)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package prompts
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRegistry_DefaultAndOverride(t *testing.T) {
|
||||
r := New()
|
||||
r.SetDefault("k", "默认")
|
||||
if r.Get("k") != "默认" {
|
||||
t.Fatal("应返回默认")
|
||||
}
|
||||
r.SetOverride("k", "覆盖")
|
||||
if r.Get("k") != "覆盖" {
|
||||
t.Fatal("覆盖应优先")
|
||||
}
|
||||
r.SetOverride("k", "") // 空串=清覆盖
|
||||
if r.Get("k") != "默认" {
|
||||
t.Fatal("清覆盖后应回退默认")
|
||||
}
|
||||
if r.Get("missing") != "" {
|
||||
t.Fatal("未登记 key 应返回空串")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFile(t *testing.T) {
|
||||
SetDefault("eval.quality", "内置默认评审词")
|
||||
dir := t.TempDir()
|
||||
f := filepath.Join(dir, "p.json")
|
||||
os.WriteFile(f, []byte(`{"eval.quality":"文件覆盖的评审词"}`), 0o644)
|
||||
t.Setenv("PROMPTS_FILE", f)
|
||||
LoadFile()
|
||||
if Get("eval.quality") != "文件覆盖的评审词" {
|
||||
t.Fatalf("PROMPTS_FILE 覆盖未生效,got %q", Get("eval.quality"))
|
||||
}
|
||||
SetOverride("eval.quality", "") // 复位,避免污染其它测试
|
||||
}
|
||||
Reference in New Issue
Block a user