// 生成链指标:每一次生成都留一行事实,让"提示词到底行不行"变成可查证的数字。 // // 为什么(doc/soma 细节与生产化强化.md §5):项目合格线写着"兜底触发 ≤ 2/8", // 但在此之前没有任何数据能证实或证伪它——FailureJournal 只在**失败**时落样本, // 成功的调用连一行记录都不留。于是最关键的两个问题永远没有答案: // 1. 首轮就过闸门的比例是多少?(实弹里连着两次"第 2 次尝试才过",是偶然还是常态?) // 2. 兜底修补的触发率是多少?(合格线的分母必须来自真实调用数,不能靠回忆) // 本模块补的就是这条"成功也记"的通道:失败样本回流看**为什么坏**, // 生成链指标看**整体有多好**,两者互补而不重叠。 // // 与 FailureJournal 同风格,三条纪律一字不差: // - JSONL 按天追加落盘,不建库、不起聚合服务,查询用 jq / readGenerationStats; // - 环境变量可关(SOMA_AI_METRICS=off),默认开——指标要有连续性才有价值; // - 落盘故障静默降级:可观测性绝不能变成新的故障源。 // // 落盘内容刻意克制:只记可聚合的标量与兜底类别,**不记需求原文、不记模型产物** // (那是 FailureJournal 的职责,重复存储既浪费又扩大隐私面)。 import fs from 'node:fs'; import path from 'node:path'; import { pruneJsonlDirLazily } from '../ai-shared/JsonlRetention'; /** 一次生成调用的结局 */ export type GenerationOutcome = | 'passed' // 闸门通过(issues 为空),产物可落盘渲染 | 'failed' // 尝试用尽仍有残留问题 | 'error'; // 上游异常/超时/输入非法,链路提前中断 export interface GenerationMetricRecord { at: string; /** standard / preview / visualize / revise */ mode: string; outcome: GenerationOutcome; /** 实际打模型的轮次(1 = 一次到位) */ attempts: number; /** 首轮即通过闸门(过闸率的分子) */ firstAttemptPassed: boolean; /** * 第几轮过的闸(0 = 始终没过)。 * 比 firstAttemptPassed 多一层信息:提示词改动是把"第 3 轮才过"压到第 2 轮, * 还是真的压到了第 1 轮——只看布尔量分不出这两种进步。 */ gatePassedAtAttempt: number; /** 确定性兜底修补是否触发(兜底率的分子) */ repaired: boolean; /** * 兜底类别清单(已去掉括号里的具体锚点/路径)。 * 归类而非原文:统计要回答的是"哪一类兜底最常触发、提示词该改哪一段", * 具体到哪个锚点是排障问题,那份细节在 FailureJournal 与响应体 meta.repairs 里。 */ repairKinds: string[]; /** 残留问题数(outcome=passed 时恒为 0) */ issueCount: number; /** 走蓝图路径(true)还是四件套文本路径 —— 两条路径的兜底率没有可比性,必须分桶 */ blueprint: boolean; durationMs: number; inputTokens: number; outputTokens: number; model: string; /** outcome=error 时的错误码(AiErrorCodes),其余为 undefined */ errorCode?: string; } /** 关(SOMA_AI_METRICS ∈ {off,0,false})之外一律记 —— 指标断档就失去趋势价值 */ function metricsEnabled(): boolean { const flag = (process.env.SOMA_AI_METRICS ?? '').trim().toLowerCase(); return flag !== 'off' && flag !== '0' && flag !== 'false'; } /** 落盘目录:环境变量优先(避免 cwd 陷阱),缺省 /runtime/ai-metrics */ function metricsDir(): string { return process.env.SOMA_AI_METRICS_DIR ?? path.join(process.cwd(), 'runtime', 'ai-metrics'); } function metricsPath(now: Date): string { const day = now.toISOString().slice(0, 10); return path.join(metricsDir(), `generation-${day}.jsonl`); } /** * 兜底条目归类:剥掉括号里的具体对象,只留类别。 * `initProps补位(page.a, page.b)` → `initProps补位` */ export function repairKind(repair: string): string { const cut = repair.search(/[((]/); return (cut < 0 ? repair : repair.slice(0, cut)).trim(); } /** * 记录一次生成调用(成功、失败、异常都记)。 * 返回落盘的记录供调用方断言;关闭或落盘失败时返回 null,主流程无感知。 */ export function recordGeneration(input: Omit & { repairs: string[]; now?: Date; }): GenerationMetricRecord | null { if (!metricsEnabled()) { return null; } const now = input.now ?? new Date(); const entry: GenerationMetricRecord = { at: now.toISOString(), mode: input.mode, outcome: input.outcome, attempts: input.attempts, firstAttemptPassed: input.firstAttemptPassed, gatePassedAtAttempt: input.gatePassedAtAttempt, repaired: input.repaired, repairKinds: [...new Set(input.repairs.map(repairKind))].slice(0, 10), issueCount: input.issueCount, blueprint: input.blueprint, durationMs: input.durationMs, inputTokens: input.inputTokens, outputTokens: input.outputTokens, model: input.model, ...(input.errorCode ? { errorCode: input.errorCode } : {}), }; try { fs.mkdirSync(metricsDir(), { recursive: true }); fs.appendFileSync(metricsPath(now), `${JSON.stringify(entry)}\n`, 'utf-8'); // 保留策略惰性触发(O-5):写完顺带看一眼过期与超限,不起定时器、失败静默 // 与记录本身使用同一时钟:测试/回放传入 `now` 时,清理不能再回到真实当前时间, // 否则会把回放日期视为过期样本并提前删除。生产路径未传 `now`,仍等价于 Date.now()。 pruneJsonlDirLazily({ dir: metricsDir(), prefix: 'generation-', now: () => now.getTime(), }); } catch { // 落盘失败静默:宁可丢一条指标,也不让生成链因日志目录问题失败 } return entry; } /** 一个分桶的核心四指标(与 doc §5.2 的口径表逐项对齐) */ export interface GenerationRates { total: number; /** 首轮通过闸门的比例(0~1,两位小数) */ firstAttemptPassRate: number; /** 最终通过闸门的比例 */ finalPassRate: number; /** 平均尝试次数 */ avgAttempts: number; /** 兜底触发率 —— 合格线 ≤ 0.25(2/8) */ repairRate: number; /** F-56 对外命名别名:确定性兜底触发率(与 repairRate 同值)。 */ fallbackTriggerRate: number; /** 第几轮过闸的分布(键为「第N轮」/「未过闸」) */ gatePassAttempts: Record; } export interface GenerationStats extends GenerationRates { /** 耗时分位(毫秒;样本不足时取现有样本的最大值) */ durationMs: { p50: number; p95: number }; /** token 累计(成本口径) */ tokens: { input: number; output: number }; /** 按模式分桶:preview 与 standard 的难度不同,合并统计会互相稀释 */ byMode: Record; /** 兜底类别频次降序 —— 直接指出提示词/组件目录下一步该补哪一段 */ topRepairKinds: Array<{ kind: string; count: number }>; /** 结局分布(passed/failed/error) */ byOutcome: Record; } function rate(numerator: number, denominator: number): number { return denominator > 0 ? Number((numerator / denominator).toFixed(2)) : 0; } /** 线性插值免了:样本量小的时候取"至少覆盖 p 比例"的那个值更好解释 */ function percentile(sorted: number[], p: number): number { if (sorted.length === 0) { return 0; } const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(p * sorted.length) - 1)); return sorted[index]; } function ratesOf(records: GenerationMetricRecord[]): GenerationRates { const total = records.length; const repairRate = rate(records.filter((r) => r.repaired).length, total); return { total, firstAttemptPassRate: rate(records.filter((r) => r.firstAttemptPassed).length, total), finalPassRate: rate(records.filter((r) => r.outcome === 'passed').length, total), avgAttempts: total > 0 ? Number((records.reduce((sum, r) => sum + (r.attempts ?? 0), 0) / total).toFixed(2)) : 0, repairRate, fallbackTriggerRate: repairRate, // 第几轮过闸的分布:提示词改动是把"第 3 轮才过"压到第 2 轮,还是真压到第 1 轮, // 只看 firstAttemptPassRate 这个布尔率分不出这两种进步 gatePassAttempts: records.reduce>((acc, r) => { const key = (r.gatePassedAtAttempt ?? 0) === 0 ? '未过闸' : `第${r.gatePassedAtAttempt}轮`; acc[key] = (acc[key] ?? 0) + 1; return acc; }, {}), }; } /** 读取落盘指标(可按天/按模式过滤)并汇总——提示词改动前后的对照基准 */ export function readGenerationStats(options: { day?: string; mode?: string } = {}): GenerationStats { const dir = metricsDir(); let files: string[] = []; try { files = fs.readdirSync(dir).filter((name) => name.endsWith('.jsonl')); } catch { files = []; // 目录还没建 = 一条都没记,返回空统计而不是报错 } if (options.day) { files = files.filter((name) => name.includes(options.day!)); } const records: GenerationMetricRecord[] = []; for (const file of files) { let lines: string[] = []; try { lines = fs.readFileSync(path.join(dir, file), 'utf-8').split('\n').filter(Boolean); } catch { continue; } for (const line of lines) { try { const record = JSON.parse(line) as GenerationMetricRecord; if (options.mode && record.mode !== options.mode) { continue; } records.push(record); } catch { // 跳过坏行(进程被杀可能留下半行) } } } const durations = records.map((r) => r.durationMs ?? 0).sort((a, b) => a - b); const byMode: Record = {}; for (const mode of new Set(records.map((r) => r.mode))) { byMode[mode] = ratesOf(records.filter((r) => r.mode === mode)); } const byOutcome: Record = {}; const kindCounts = new Map(); for (const record of records) { byOutcome[record.outcome] = (byOutcome[record.outcome] ?? 0) + 1; for (const kind of record.repairKinds ?? []) { kindCounts.set(kind, (kindCounts.get(kind) ?? 0) + 1); } } return { ...ratesOf(records), durationMs: { p50: percentile(durations, 0.5), p95: percentile(durations, 0.95) }, tokens: { input: records.reduce((sum, r) => sum + (r.inputTokens ?? 0), 0), output: records.reduce((sum, r) => sum + (r.outputTokens ?? 0), 0), }, byMode, topRepairKinds: [...kindCounts.entries()] .map(([kind, count]) => ({ kind, count })) .sort((a, b) => b.count - a.count || a.kind.localeCompare(b.kind)), byOutcome, }; }