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,137 @@
// 配置只读事实面(契约 v0.68 §4.10(a))的**纯逻辑**测试:形状 → 文案的映射。
// 这里盯的是三条不变量:值只取后端给的生效值、默认/已改只由 isDefault 决定、
// 后端没给的键如实显示「未报告」而不是编一个 0。
//
// 跑法(项目无前端测试运行器,用 vite 自带的 esbuild;见同目录既有测试):
//
// cd frontend/web \
// && ./node_modules/.bin/esbuild src/admin/modules/configFacts.test.ts \
// --bundle --platform=node --format=esm \
// --outfile=/tmp/cyrene-config-facts.test.mjs \
// && node /tmp/cyrene-config-facts.test.mjs
//
// `tsc --noEmit`(npm run build 的一部分)会类型检查本文件,因此它不 import node:*。
import type { AdminConfigFacts } from "../../lib/types";
import {
FACT_MISSING_VALUE,
MEMORY_TIDY_KEYS,
QUIET_HOURS_KEYS,
factRows,
memoryTidyRows,
quietHoursPurpose,
quietHoursRows,
quietHoursSummary,
} from "./configFacts";
let checks = 0;
function ok(cond: boolean, what: string): void {
checks += 1;
if (!cond) throw new Error(`FAILED: ${what}`);
}
function same<T>(got: T, want: T, what: string): void {
ok(
JSON.stringify(got) === JSON.stringify(want),
`${what} — got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`,
);
}
function has(haystack: string, needle: string): boolean {
return haystack.includes(needle);
}
/** 生效值全为默认的常态响应(后端在默认部署下会这么回)。 */
const DEFAULTS: AdminConfigFacts = {
facts: [
{ key: "MEMORY_TIDY_ENABLED", value: "true", default: "true", isDefault: true },
{ key: "MEMORY_TIDY_INTERVAL_SEC", value: "86400", default: "86400", isDefault: true },
{ key: "MEMORY_TIDY_QUIET_START_HOUR", value: "23", default: "23", isDefault: true },
{ key: "MEMORY_TIDY_QUIET_END_HOUR", value: "7", default: "7", isDefault: true },
{ key: "MEMORY_TIDY_RECALL_THRESHOLD", value: "0.8", default: "0.8", isDefault: true },
{ key: "MEMORY_TIDY_SCAN_LIMIT", value: "5000", default: "5000", isDefault: true },
{ key: "MEMORY_TIDY_CANDIDATE_LIMIT", value: "20", default: "20", isDefault: true },
{ key: "QUIET_START_HOUR", value: "23", default: "23", isDefault: true },
{ key: "QUIET_END_HOUR", value: "7", default: "7", isDefault: true },
],
quietHours: { startHour: 23, endHour: 7, disabled: false, label: "23:00-07:00" },
};
// ── ① 值就是后端给的生效值(含单位),不重算 ─────────────────────────────────
const tidy = memoryTidyRows(DEFAULTS);
same(tidy.length, 7, "记忆整理恰好 7 行(后端的 7 个键)");
same(
tidy.map((r) => r.key),
[...MEMORY_TIDY_KEYS],
"键序与后端事实表一致",
);
const interval = tidy.find((r) => r.key === "MEMORY_TIDY_INTERVAL_SEC");
same(interval?.value, "86400 秒", "周期带单位显示,数值原样取后端");
same(interval?.state, "默认", "isDefault=true ⇒ 显示「默认」");
const threshold = tidy.find((r) => r.key === "MEMORY_TIDY_RECALL_THRESHOLD");
same(threshold?.value, "0.8", "比例值原样显示(后端已归一化)");
// ── ② 「已改」只由 isDefault 决定(不反推 .env) ─────────────────────────────
const changed: AdminConfigFacts = {
...DEFAULTS,
facts: DEFAULTS.facts.map((f) =>
f.key === "MEMORY_TIDY_INTERVAL_SEC" ? { ...f, value: "3600", isDefault: false } : f,
),
};
const changedRow = memoryTidyRows(changed).find((r) => r.key === "MEMORY_TIDY_INTERVAL_SEC");
same(changedRow?.value, "3600 秒", "改过的值如实显示");
same(changedRow?.state, "已改", "isDefault=false ⇒ 显示「已改」");
same(
memoryTidyRows(changed).filter((r) => r.state === "已改").length,
1,
"只有真的改过的那一行是「已改」",
);
// ── ③ 后端没给的键如实显示「未报告」,绝不编值 ──────────────────────────────
const partial: AdminConfigFacts = {
facts: DEFAULTS.facts.filter((f) => f.key !== "MEMORY_TIDY_SCAN_LIMIT"),
quietHours: DEFAULTS.quietHours,
};
const missing = memoryTidyRows(partial).find((r) => r.key === "MEMORY_TIDY_SCAN_LIMIT");
same(missing?.value, FACT_MISSING_VALUE, "缺的键显示「未报告」而不是 0 或默认值");
same(missing?.state, FACT_MISSING_VALUE, "缺的键状态也是「未报告」");
same(memoryTidyRows(null).length, 7, "整份响应都没拿到时,7 行仍然都在(全为未报告)");
ok(
memoryTidyRows(null).every((r) => r.state === FACT_MISSING_VALUE),
"没有响应时不留空白行,全部如实标「未报告」",
);
// ── ④ 安静时段:窗口摘要 + 用途文案(新语义,不得说「夜间全静默」) ─────────
same(quietHoursRows(DEFAULTS).map((r) => r.key), [...QUIET_HOURS_KEYS], "安静时段两行");
same(quietHoursSummary(DEFAULTS.quietHours), "23:00-07:00(跨午夜)", "跨午夜窗口如实标注");
same(
quietHoursSummary({ startHour: 1, endHour: 5, disabled: false, label: "01:00-05:00" }),
"01:00-05:00",
"非跨午夜窗口不加多余后缀",
);
same(
quietHoursSummary({ startHour: 23, endHour: 23, disabled: true, label: "已关闭" }),
"已关闭(安静时段从不生效)",
"窗口关闭必须说「从不生效」,不能画成 23:00-23:00 的假窗口",
);
same(
quietHoursSummary({ startHour: 23, endHour: 7, disabled: false, label: "23:00-07:00", fallbackToDefault: true }),
"23:00-07:00(两端写 0 = 未填,运行时回落到默认窗口)",
"0/0 被当成「未填」时必须说明回落,不能谎报成「已关闭」",
);
same(quietHoursSummary(null), FACT_MISSING_VALUE, "没有 quietHours 时如实报未报告");
const purpose = quietHoursPurpose();
ok(has(purpose, "定时主动思考"), "用途文案必须点明它服务「定时主动思考 → 主动搭话」");
ok(has(purpose, "不静默回复") || has(purpose, "不会因为到了安静时段就被静默"), "用途文案必须说清回复侧不被静默");
ok(!has(purpose, "夜间全静默"), "不得沿用已废弃的旧语义「夜间全静默」");
ok(!has(purpose, "群聊静默"), "群聊夜间静默已被取消,不得写成有");
// ── ⑤ factRows 对未知键不崩、原样透出键名 ───────────────────────────────────
const unknown = factRows(DEFAULTS, ["NOT_A_REAL_KEY"]);
same(unknown[0].label, "NOT_A_REAL_KEY", "没有元数据的键用键名兜底(不编中文标签)");
same(unknown[0].value, FACT_MISSING_VALUE, "没有元数据的键如实「未报告」");
// eslint-disable-next-line no-console
console.log(`configFacts: ${checks} checks passed`);