feat(gateway): 配置只读事实面 + 后台思考行「档位/穿透」(契约 v0.68)
- 新增 GET /api/v1/admin/config-facts:记忆整理 7 键 + 安静时段 2 键的**进程生效值** + 声明默认值(反射读 default 标签,不读 .env) - 面板:记忆整理卡 + 安静时段卡(Miuix 只读),文案按新语义「只服务主动搭话,不静默回复」 - 后台思考行加 urgency(**只能是下限**,存档不可分 high/critical)+ quietHoursBypassed(与既有 bypassed 是两件事) - 红线实测:往响应里塞 JWT ⇒ TestAdminConfigFacts_NeverLeaksSecretValues 打红(连断言消息都把整个响应打出来了) - 前端:configFacts 24 checks / ConfigFactsCards 29 / BackthinkUrgencyTags 19 全过 - 契约 v0.68(纯增量:B12 新端点 + B8 两字段 + §4.10 形状与语义 + §7.4-17 下限限定)
This commit is contained in:
1 parent
a057a0f61f
commit
34cef95e17
25 files changed
+1741
-14
No files matched your search
@@ -0,0 +1,232 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
)
|
||||
|
||||
// 本文件承载 P3「配置只读事实面」:把面板此前**看不见、只能照模板猜**的一组调参
|
||||
// 键做成只读事实展示(记忆整理 7 键 + 安静时段 2 键)。三条边界(AGENTS §10 与
|
||||
// 主人红线):
|
||||
//
|
||||
// 1. **键名单的权威来源是 `.env.example`**(不是 `.env`、也不是本文件自己发明
|
||||
// 一份清单):事实表逐条与模板键名对齐,由
|
||||
// `TestAdminConfigFacts_KeysMatchEnvExample` 钉住;加了键却忘了同步模板会被
|
||||
// 这条测试打红(协同 E6 的同一类固定动作)。
|
||||
// 2. **值只取「进程生效值」**:即 `config.Load` 解析后的字段(进程环境 → `.env`
|
||||
// → 默认标签的优先级链已经由配置层消化)。网关**绝不 fopen `.env`**,因此
|
||||
// 面板看到的是运行实例真正在用的数,而不是 `.env` 原文。每条事实另带**声明
|
||||
// 默认值**(从 `default:"…"` 标签反射读出)与 `isDefault`,于是「这项还是默认
|
||||
// 值」与「这项被改过」都能如实显示,不必假装知道 `.env` 里写过什么。
|
||||
// 3. **绝不回任何密钥 / 令牌值**:本端点只覆盖上述 9 个非敏感调参键,响应里连
|
||||
// 凭据类键的「是否已配置」都不出现。
|
||||
// `TestAdminConfigFacts_NeverLeaksSecretValues` 用哨兵值把这条钉死。
|
||||
//
|
||||
// 安静时段的**语义**(面板文案必须照此写):它**只服务「定时主动思考 → 主动搭话」**,
|
||||
// **不静默回复**(design/12 §6 策略五;主人裁决 10)。所以这里只报「窗口是多少、
|
||||
// 是否被关掉」,绝不在契约或文案里说「夜间全静默」。
|
||||
|
||||
// AdminConfigFact 是一条只读「配置事实」:键名 + 进程生效值 + 声明默认值。
|
||||
//
|
||||
// Value 与 Default 都是**归一化后的字符串**(bool → "true"/"false",整数 →
|
||||
// 十进制,比例 → 归一化小数如 "0.8"),因为面板把它当文本显示,且归一化后
|
||||
// 「生效值 == 声明默认值」才是一次可靠的比较。
|
||||
type AdminConfigFact struct {
|
||||
Key string `json:"key"`
|
||||
// Value 是进程生效值(已解析),不是 `.env` 原文。
|
||||
Value string `json:"value"`
|
||||
// Default 是该键的声明默认值(internal/config 的 default 标签,唯一真源)。
|
||||
Default string `json:"default"`
|
||||
// IsDefault 报告生效值是否等于声明默认值。它为 true 时面板显示「默认」;
|
||||
// 为 false 时显示「已改」。**它不是**「`.env` 没写过这个键」的断言 ——
|
||||
// 本端点不读 `.env`,两者不可区分,因此不做那个更强的声称。
|
||||
IsDefault bool `json:"isDefault"`
|
||||
}
|
||||
|
||||
// AdminConfigQuietHours 是安静时段窗口的**解释**(不是新配置):两个裸钟点不足以
|
||||
// 表达「跨午夜」「已关闭」与「两端写 0 视为未填」,而面板文案需要这些结论。
|
||||
type AdminConfigQuietHours struct {
|
||||
StartHour int `json:"startHour"`
|
||||
EndHour int `json:"endHour"`
|
||||
Disabled bool `json:"disabled"`
|
||||
// Label 是可直接显示的窗口文案("23:00-07:00" 或 "已关闭")。
|
||||
Label string `json:"label"`
|
||||
// FallbackToDefault 报告窗口两端都被写成 0。0/0 是**未填**而不是**关闭**:
|
||||
// backthink(cfg.go normalize)与记忆整理(tidy_config.go normalizeTidyConfig)
|
||||
// 都会在这个形状上回落到设计默认 23:00-07:00,因此这里的 StartHour/EndHour
|
||||
// 报的是**回落之后真正生效**的钟点,面板要把这层解释显示出来 —— 否则事实面会把
|
||||
// 「未填」谎报成「已关闭」。要关窗口请写两个相等的**非 0** 钟点(如 23/23)。
|
||||
FallbackToDefault bool `json:"fallbackToDefault,omitempty"`
|
||||
}
|
||||
|
||||
// AdminConfigFacts 是 GET /api/v1/admin/config-facts 的线上结构。
|
||||
type AdminConfigFacts struct {
|
||||
Facts []AdminConfigFact `json:"facts"`
|
||||
QuietHours AdminConfigQuietHours `json:"quietHours"`
|
||||
}
|
||||
|
||||
// 安静时段关闭时的展示文案(窗口两端相等即关闭,见 quietHoursWindow)。
|
||||
const quietHoursDisabledLabel = "已关闭"
|
||||
|
||||
// configFactSpec 是一条事实的取数说明:env 键名 + 从生效配置里取值。
|
||||
type configFactSpec struct {
|
||||
key string
|
||||
value func(cfg *config.Config) string
|
||||
}
|
||||
|
||||
// configFactSpecs 是事实表的**唯一真源**:顺序即面板显示顺序(先记忆整理,
|
||||
// 后安静时段)。键名必须与 `.env.example` 逐字一致(测试钉住)。
|
||||
func configFactSpecs() []configFactSpec {
|
||||
return []configFactSpec{
|
||||
{"MEMORY_TIDY_ENABLED", func(c *config.Config) string {
|
||||
return strconv.FormatBool(c.MemoryTidy.Enabled)
|
||||
}},
|
||||
{"MEMORY_TIDY_INTERVAL_SEC", func(c *config.Config) string {
|
||||
return strconv.Itoa(c.MemoryTidy.IntervalSec)
|
||||
}},
|
||||
{"MEMORY_TIDY_QUIET_START_HOUR", func(c *config.Config) string {
|
||||
return strconv.Itoa(c.MemoryTidy.QuietStartHour)
|
||||
}},
|
||||
{"MEMORY_TIDY_QUIET_END_HOUR", func(c *config.Config) string {
|
||||
return strconv.Itoa(c.MemoryTidy.QuietEndHour)
|
||||
}},
|
||||
{"MEMORY_TIDY_RECALL_THRESHOLD", func(c *config.Config) string {
|
||||
return formatRatio(c.MemoryTidy.RecallThreshold)
|
||||
}},
|
||||
{"MEMORY_TIDY_SCAN_LIMIT", func(c *config.Config) string {
|
||||
return strconv.Itoa(c.MemoryTidy.ScanLimit)
|
||||
}},
|
||||
{"MEMORY_TIDY_CANDIDATE_LIMIT", func(c *config.Config) string {
|
||||
return strconv.Itoa(c.MemoryTidy.CandidateLimit)
|
||||
}},
|
||||
{"QUIET_START_HOUR", func(c *config.Config) string {
|
||||
return strconv.Itoa(c.Background.QuietStartHour)
|
||||
}},
|
||||
{"QUIET_END_HOUR", func(c *config.Config) string {
|
||||
return strconv.Itoa(c.Background.QuietEndHour)
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// formatRatio 把比例值归一化成与 declaredEnvDefaults 相同的形状,使
|
||||
// 「生效值 == 声明默认值」的比较不受写法影响(标签写 `0.80`、值算出来 `0.8`)。
|
||||
func formatRatio(v float64) string {
|
||||
return strconv.FormatFloat(v, 'g', -1, 64)
|
||||
}
|
||||
|
||||
// buildConfigFacts 组装配置事实面。它是纯函数(不读文件、不读 env),因此可
|
||||
// 直接单测;cfg 为 nil 时回退到一个零值配置(此时 9 条事实全部照实报零值 +
|
||||
// 声明默认值,面板会看到 isDefault=false 的「已改」而不是一个编出来的值)。
|
||||
func buildConfigFacts(cfg *config.Config) AdminConfigFacts {
|
||||
if cfg == nil {
|
||||
cfg = &config.Config{}
|
||||
}
|
||||
defaults := declaredEnvDefaults(cfg)
|
||||
specs := configFactSpecs()
|
||||
out := AdminConfigFacts{Facts: make([]AdminConfigFact, 0, len(specs))}
|
||||
for _, spec := range specs {
|
||||
value := spec.value(cfg)
|
||||
def := defaults[spec.key]
|
||||
out.Facts = append(out.Facts, AdminConfigFact{
|
||||
Key: spec.key,
|
||||
Value: value,
|
||||
Default: def,
|
||||
IsDefault: def != "" && value == def,
|
||||
})
|
||||
}
|
||||
out.QuietHours = quietHoursFacts(cfg.Background.QuietStartHour, cfg.Background.QuietEndHour, defaults)
|
||||
return out
|
||||
}
|
||||
|
||||
// effectiveQuietHours 返回**运行时真正生效**的安静时段钟点,并报告是否发生了
|
||||
// 「未填 → 默认」的回落:
|
||||
//
|
||||
// - 两端相等且非 0 ⇒ 窗口关闭(既有三处同口径),返回原来的钟点;
|
||||
// - 两端都为 0 ⇒ **未填**:组合根把 `config.Background` 交给 backthink,而
|
||||
// backthink(cfg.go 的 normalize)与记忆整理(tidy_config.go 的
|
||||
// normalizeTidyConfig)都会在 0/0 上回落到设计默认 23:00-07:00。事实面与
|
||||
// 后台思考行的事实推导都必须按**回落之后**的窗口算,否则会把「未填」当成
|
||||
// 「没有安静时段」,从而漏报真正的穿透。回落值取**声明默认值**(env 标签),
|
||||
// 与事实行的 default 同源,不另抄一份常量。
|
||||
//
|
||||
// defaults 是 declaredEnvDefaults 的结果;缺值(或非法)时如实**不回落**,让调用方
|
||||
// 看到原始值,而不是编一个 23 点窗口。
|
||||
func effectiveQuietHours(cfg *config.Config, defaults map[string]string) (start, end int, fell bool) {
|
||||
if cfg == nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
return normalizeQuietHours(cfg.Background.QuietStartHour, cfg.Background.QuietEndHour, defaults)
|
||||
}
|
||||
|
||||
// normalizeQuietHours 是上面那条回落规则的纯函数内核(0/0 ⇒ 用声明默认)。
|
||||
func normalizeQuietHours(start, end int, defaults map[string]string) (int, int, bool) {
|
||||
if start != 0 || end != 0 {
|
||||
return start, end, false
|
||||
}
|
||||
ds, okS := parseHourDefault(defaults["QUIET_START_HOUR"])
|
||||
de, okE := parseHourDefault(defaults["QUIET_END_HOUR"])
|
||||
if !okS || !okE {
|
||||
return start, end, false
|
||||
}
|
||||
return ds, de, true
|
||||
}
|
||||
|
||||
// effectiveQuietHours 是 Server 侧取生效窗口的便捷入口(后台思考行的事实推导与
|
||||
// 配置事实面共用同一口径,避免两处漂移)。
|
||||
func (s *Server) effectiveQuietHours() (int, int) {
|
||||
start, end, _ := effectiveQuietHours(s.cfg, declaredEnvDefaults(s.cfg))
|
||||
return start, end
|
||||
}
|
||||
|
||||
// quietHoursFacts 把生效窗口解释成线上结构(跨午夜 / 已关闭 / 是否回落)。
|
||||
func quietHoursFacts(start, end int, defaults map[string]string) AdminConfigQuietHours {
|
||||
effStart, effEnd, fell := normalizeQuietHours(start, end, defaults)
|
||||
out := AdminConfigQuietHours{StartHour: effStart, EndHour: effEnd, FallbackToDefault: fell}
|
||||
// 关闭 = 两个相等的非 0 钟点(回落之后不可能出现 0/0,所以不必再特判)。
|
||||
out.Disabled = !fell && effStart == effEnd && effStart != 0
|
||||
if out.Disabled {
|
||||
out.Label = quietHoursDisabledLabel
|
||||
} else {
|
||||
out.Label = formatHour(effStart) + "-" + formatHour(effEnd)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// parseHourDefault 解析声明默认值里的钟点;缺失或不合法时如实报「不知道」
|
||||
// (调用方据此不做回落,而不是编一个 0 点窗口)。
|
||||
func parseHourDefault(raw string) (int, bool) {
|
||||
if raw == "" {
|
||||
return 0, false
|
||||
}
|
||||
hour, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return hour, true
|
||||
}
|
||||
|
||||
// formatHour 把钟点格式化成 "HH:00"(面板文案用)。
|
||||
func formatHour(hour int) string {
|
||||
if hour < 0 {
|
||||
hour = 0
|
||||
}
|
||||
return pad2(hour) + ":00"
|
||||
}
|
||||
|
||||
// pad2 把 0..99 补成两位;三位及以上的值原样返回(配置非法时也不吞掉信息)。
|
||||
func pad2(n int) string {
|
||||
if n >= 0 && n < 10 {
|
||||
return "0" + strconv.Itoa(n)
|
||||
}
|
||||
return strconv.Itoa(n)
|
||||
}
|
||||
|
||||
// adminConfigFacts 是 GET /api/v1/admin/config-facts 的处理函数:只读、不审计、
|
||||
// 不读 `.env`、不回凭据。它与消费它的两个面板页(记忆管理、后台思考)同门
|
||||
// (owner/admin),因为它们是**面板配置事实**,与 `/admin/memories`、`/admin/llm`
|
||||
// 同一档,而不是给 viewer 的运行时监控投影。
|
||||
func (s *Server) adminConfigFacts(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, buildConfigFacts(s.cfg))
|
||||
}
|
||||
Reference in new issue
Block a user