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:
AskaEth committed 2026-10-05 13:54:56 +08:00
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))
}