Merge pull request 'Add system-managed setting scope contract to SettingDesc' (#30) from feature/rdm-increment-settings-duties into main
This commit was merged in pull request #30.
This commit is contained in:
+125
-1
@@ -1,6 +1,9 @@
|
||||
package core
|
||||
|
||||
import "encoding/json"
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 智能体画像(4.7):核心定制机制,全部数据化、可导入导出。
|
||||
type AgentProfile struct {
|
||||
@@ -107,6 +110,16 @@ const (
|
||||
EffectRestart SettingEffect = "restart" // 需重启
|
||||
)
|
||||
|
||||
// SettingScope 声明设置项的写入归属(提案 2026-10-13-rdm-settings-members-duties-perms-increment P2)。
|
||||
// 空串 = 默认:user 与 project 层可写(向后兼容既有 schema)。
|
||||
type SettingScope string
|
||||
|
||||
const (
|
||||
// SettingScopeSystem 受管项:仅系统管理面(admin)可写;user/project 写入被拒,
|
||||
// 生效链忽略这两层的存量覆盖值。
|
||||
SettingScopeSystem SettingScope = "system"
|
||||
)
|
||||
|
||||
// SettingDesc 描述一个设置项;设置页按此渲染并标注生效时机(schema 驱动)。
|
||||
type SettingDesc struct {
|
||||
Key string `json:"key"`
|
||||
@@ -118,6 +131,117 @@ type SettingDesc struct {
|
||||
Options []string `json:"options,omitempty"` // enum
|
||||
Effect SettingEffect `json:"effect"`
|
||||
Secret bool `json:"secret,omitempty"`
|
||||
Scope SettingScope `json:"scope,omitempty"` // 空 = user/project 可写;system = 受管
|
||||
}
|
||||
|
||||
// ManagedPaths 返回 schema 中 scope=system 的键路径(点路径形式)。
|
||||
func ManagedPaths(descs []SettingDesc) []string {
|
||||
out := make([]string, 0, len(descs))
|
||||
for _, d := range descs {
|
||||
if d.Scope == SettingScopeSystem {
|
||||
out = append(out, d.Key)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// FlattenSettingLeafPaths 列出 values 的全部叶子点路径。子树为标量或数组时该子树
|
||||
// 根即叶子。用于受管触碰检测:深合并语义下叶子决定最终覆盖面。
|
||||
func FlattenSettingLeafPaths(values map[string]any) []string {
|
||||
var out []string
|
||||
var walk func(prefix string, m map[string]any)
|
||||
walk = func(prefix string, m map[string]any) {
|
||||
for k, v := range m {
|
||||
path := k
|
||||
if prefix != "" {
|
||||
path = prefix + "." + k
|
||||
}
|
||||
if vm, ok := v.(map[string]any); ok && len(vm) > 0 {
|
||||
walk(path, vm)
|
||||
continue
|
||||
}
|
||||
out = append(out, path)
|
||||
}
|
||||
}
|
||||
walk("", values)
|
||||
return out
|
||||
}
|
||||
|
||||
// pathTouchesManaged 判断 path 是否触碰 managed:等于受管路径、位于受管子树内
|
||||
// (受管路径是 path 的前缀),或整体覆盖受管子树(path 是受管路径的前缀——
|
||||
// 标量覆盖整棵受管 map 同样破坏受管值)。
|
||||
func pathTouchesManaged(path string, managed []string) bool {
|
||||
for _, mp := range managed {
|
||||
if path == mp ||
|
||||
strings.HasPrefix(path, mp+".") ||
|
||||
strings.HasPrefix(mp, path+".") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TouchesManagedSettings 返回 values 中触碰受管路径的叶子;空切片 = 无触碰。
|
||||
// 调用方须传入已展开点路径的嵌套 map(normalize 后)。
|
||||
func TouchesManagedSettings(values map[string]any, managed []string) []string {
|
||||
var hits []string
|
||||
for _, p := range FlattenSettingLeafPaths(values) {
|
||||
if pathTouchesManaged(p, managed) {
|
||||
hits = append(hits, p)
|
||||
}
|
||||
}
|
||||
return hits
|
||||
}
|
||||
|
||||
// StripManagedSettings 返回删除受管路径后的浅拷贝(不改入参)。受管路径的祖先
|
||||
// 若因此只剩空 map 一并删除。入参须为嵌套形式(点路径已展开)。
|
||||
func StripManagedSettings(values map[string]any, managed []string) map[string]any {
|
||||
if len(values) == 0 || len(managed) == 0 {
|
||||
return values
|
||||
}
|
||||
// 叶子命中或祖先整体命中都删;逐叶子重建最简单可靠。
|
||||
leaves := FlattenSettingLeafPaths(values)
|
||||
keep := map[string]any{}
|
||||
for _, leaf := range leaves {
|
||||
if pathTouchesManaged(leaf, managed) {
|
||||
continue
|
||||
}
|
||||
setPath(keep, leaf, lookupAny(values, leaf))
|
||||
}
|
||||
return keep
|
||||
}
|
||||
|
||||
func setPath(m map[string]any, path string, val any) {
|
||||
parts := strings.Split(path, ".")
|
||||
cur := m
|
||||
for i, p := range parts {
|
||||
if i == len(parts)-1 {
|
||||
cur[p] = val
|
||||
return
|
||||
}
|
||||
next, ok := cur[p].(map[string]any)
|
||||
if !ok {
|
||||
next = map[string]any{}
|
||||
cur[p] = next
|
||||
}
|
||||
cur = next
|
||||
}
|
||||
}
|
||||
|
||||
func lookupAny(m map[string]any, path string) any {
|
||||
parts := strings.Split(path, ".")
|
||||
var cur any = m
|
||||
for _, p := range parts {
|
||||
cm, ok := cur.(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
cur, ok = cm[p]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return cur
|
||||
}
|
||||
|
||||
// EffectiveSettings 是覆盖链(system → user → project → conversation)深合并结果。
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"sort"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestManagedPathsFiltersSystemScope(t *testing.T) {
|
||||
descs := []SettingDesc{
|
||||
{Key: "models.chat.model", Scope: SettingScopeSystem},
|
||||
{Key: "ui.theme"},
|
||||
{Key: "tools.tavily.apiKeys", Scope: SettingScopeSystem},
|
||||
}
|
||||
got := ManagedPaths(descs)
|
||||
sort.Strings(got)
|
||||
want := []string{"models.chat.model", "tools.tavily.apiKeys"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ManagedPaths = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTouchesManagedSettings(t *testing.T) {
|
||||
managed := []string{"models.chat.model", "tools.tavily.apiKeys", "telemetry.otlpEndpoint"}
|
||||
cases := []struct {
|
||||
name string
|
||||
in map[string]any
|
||||
want []string
|
||||
}{
|
||||
{"exact leaf", map[string]any{"models": map[string]any{"chat": map[string]any{"model": "x"}}}, []string{"models.chat.model"}},
|
||||
{"sibling untouched", map[string]any{"models": map[string]any{"chat": map[string]any{"baseUrl": "x"}}}, nil},
|
||||
{"ancestor scalar overwrite", map[string]any{"models": "oops"}, []string{"models"}},
|
||||
{"subtree inside managed", map[string]any{"telemetry": map[string]any{"otlpEndpoint": "http://x"}}, []string{"telemetry.otlpEndpoint"}},
|
||||
{"other group untouched", map[string]any{"ui": map[string]any{"theme": "light"}}, nil},
|
||||
{"empty map", map[string]any{}, nil},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := TouchesManagedSettings(tc.in, managed)
|
||||
sort.Strings(got)
|
||||
if tc.want == nil && len(got) != 0 {
|
||||
t.Fatalf("TouchesManagedSettings = %v, want none", got)
|
||||
}
|
||||
if tc.want != nil && !reflect.DeepEqual(got, tc.want) {
|
||||
t.Fatalf("TouchesManagedSettings = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripManagedSettings(t *testing.T) {
|
||||
managed := []string{"models.chat.model", "models.chat.apiKey"}
|
||||
in := map[string]any{
|
||||
"models": map[string]any{
|
||||
"chat": map[string]any{"model": "x", "baseUrl": "https://b"},
|
||||
"lightweight": map[string]any{"model": "y"},
|
||||
},
|
||||
"ui": map[string]any{"theme": "dark"},
|
||||
}
|
||||
got := StripManagedSettings(in, managed)
|
||||
want := map[string]any{
|
||||
"models": map[string]any{
|
||||
"chat": map[string]any{"baseUrl": "https://b"},
|
||||
"lightweight": map[string]any{"model": "y"},
|
||||
},
|
||||
"ui": map[string]any{"theme": "dark"},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("StripManagedSettings = %v, want %v", got, want)
|
||||
}
|
||||
if in["models"].(map[string]any)["chat"].(map[string]any)["model"] != "x" {
|
||||
t.Fatal("StripManagedSettings 不应修改入参")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripManagedSettingsWholeSubtree(t *testing.T) {
|
||||
managed := []string{"models.chat.model", "models.chat.baseUrl", "models.chat.apiKey", "models.chat.fallbacks"}
|
||||
in := map[string]any{
|
||||
"models": map[string]any{"chat": map[string]any{"model": "x", "baseUrl": "b", "apiKey": "k"}},
|
||||
}
|
||||
got := StripManagedSettings(in, managed)
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("整棵受管子树应被剥除(含空父),got %v", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user