Files
AskaEth 6e28b38b08
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(memory): 护栏抬高+时间预算闸+试算加性异步 与 D3 入库前审查(耦合两片一起落)
【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 无该文件权限,由我按其代码语义补写)。
2026-10-06 08:04:12 +08:00

251 lines
12 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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))
}