package gateway import ( "net/http" "strconv" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" ) // 本文件承载 P3「配置只读事实面」:把面板此前**看不见、只能照模板猜**的一组调参 // 键做成只读事实展示(记忆整理 8 键 + 安静时段 2 键 + 后台思考心跳 3 键)。三条边界 // (AGENTS §10 与主人红线): // // 1. **键名单的权威来源是 `.env.example`**(不是 `.env`、也不是本文件自己发明 // 一份清单):事实表逐条与模板键名对齐,由 // `TestAdminConfigFacts_KeysMatchEnvExample` 钉住;加了键却忘了同步模板会被 // 这条测试打红(协同 E6 的同一类固定动作)。 // 2. **值只取「进程生效值」**:即 `config.Load` 解析后的字段(进程环境 → `.env` // → 默认标签的优先级链已经由配置层消化)。网关**绝不 fopen `.env`**,因此 // 面板看到的是运行实例真正在用的数,而不是 `.env` 原文。每条事实另带**声明 // 默认值**(从 `default:"…"` 标签反射读出)与 `isDefault`,于是「这项还是默认 // 值」与「这项被改过」都能如实显示,不必假装知道 `.env` 里写过什么。 // 3. **绝不回任何密钥 / 令牌值**:本端点只覆盖上述 13 个非敏感调参键,响应里连 // 凭据类键的「是否已配置」都不出现。 // `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` 逐字一致(测试钉住)。 // 条数:记忆整理 8 + 后台思考心跳 3 + 安静时段 2 = 13。 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) }}, // 两两比对的性能护栏(切片 MT-A,2026-10-06):此前是写死的常量 400, // 459 条的分区被截到 400 ⇒ compared < scanned。开放成 env 键后,面板能看到 // 「这项是不是还是默认值」,运维放大配额也不必改代码。 {"MEMORY_TIDY_COMPARE_LIMIT", func(c *config.Config) string { return strconv.Itoa(c.MemoryTidy.CompareLimit) }}, // 后台思考心跳(design/12 §N A4):安静窗口只降低周期性心跳的频率, // 而 env 是**启动期**读的 ⇒ 面板照实列出「生效值 + 声明默认」并写明改需重启。 {"ENABLE_BACKGROUND_THINKING", func(c *config.Config) string { return strconv.FormatBool(c.Background.Enabled) }}, {"THINK_INTERVAL_SEC", func(c *config.Config) string { return strconv.Itoa(c.Background.IntervalSec) }}, {"THINK_QUIET_INTERVAL_SEC", func(c *config.Config) string { return strconv.Itoa(c.Background.QuietIntervalSec) }}, {"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 时回退到一个零值配置(此时 12 条事实全部照实报零值 + // 声明默认值,面板会看到 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)) }