【MT-A:护栏 + 诚实截断 + 试算异步】
- maxTidyCompareItems 400(写死)→2000 并开放为 MEMORY_TIDY_COMPARE_LIMIT;
MEMORY_TIDY_CANDIDATE_LIMIT 默认 20→200(语义不变,仍每分区)
- 依据是首跑实测:459 条的分区被 400 截断 ⇒ compared(705) < scanned(784),
候选数(77)由上限而不是规则决定;三处上限的 WARN 都补齐了总数/上限/分区名
- 新增「上限 + 时间预算」双闸(实测 2000 条/分区 ≈234s > 3 分钟墙钟):
整分区跳过 / 两两比对中途收手,一律如实记 (budget) + WARN
- ?async=1 ⇒ 202 {runId,status};GET …/{runId} 轮询(writeAuth 同档);
done 的 result 与同步 data 逐字节同形;并发=复用既有 runId、TTL 30min 自完成起算、
重启即如实 404 tidy_run_not_found;不带 async 时同步路径逐字不变
【MT-B:D3 入库前审查(design/23 §15.1)】
- 新记忆经 Store.Add(仓库里唯一 INSERT INTO memory_items)落库后,由独立记忆管理
会话(sess-memorytidy)在有界后台队列异步判定 skip/merge/modify/create
- 软合并回写(status + merged_into/merged_from/merge_count,可 revert、绝不改 content)
- 隔离来源(群聊/陌生人)**概不审查**、召回/超时失败**零改写**(fail-safe 结构性成立)
- 写入侧仅 +62.7µs/条(非阻塞入队);默认 ENABLED=true(主人裁定:入库前先审查)
【耦合说明(重要)】两片同包且互相依赖:MT-B 的 preinsert_recall.go 用 MT-A 的
collectTidyPairs 新签名 + tidyBudget ⇒ **各自单独都 build 不过**,故一并落地。
MT-A 为让整包编译,最小幅度适配了 MT-B 的 preinsert_recall.go / preinsert_test.go
(零值预算=行为不变;符号重名改名)。
【我的验收】隔离 worktree(37 文件)build/vet/linux/darwin 全过,切片包全绿;
我亲手打红两个变异:① 去掉 (compare) 截断标记 ⇒ TestTidyCompareLimit_HitIsLoud 红;
② 中和隔离来源守卫 ⇒ TestReviewRestrictedSourceIsNeverTouched 红。
唯一红是 internal/runtime/tool 的两条超时用例(该包无人改)⇒ 主仓库 -count=8 全绿,
判定为负载敏感 flake(当时两个整树验收 + 子代理并发),已记入开发习惯与约定 §7。
.env.example 补 MEMORY_REVIEW_* 四键(MT-B 无该文件权限,由我按其代码语义补写)。
251 lines
12 KiB
Go
251 lines
12 KiB
Go
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))
|
||
}
|