// 失败样本回流:把每一次生成失败变成资产,而不是一条转瞬即逝的日志。 // // 为什么(doc/soma 架构强化.md §2):今天闸门/冒烟发现问题→回喂→重试,信息用完就丢。 // 于是同一类问题会反复出现,提示词改动也没有回归基准。把失败样本落盘并归因, // 系统就有了自我进化的燃料:改提示词/改目录前先重放历史失败,不回退才准合入。 // // 落盘内容刻意克制:需求/指令原文、模式、每轮 issues 与兜底记录、归因标签、 // 耗时与 token。**不记录模型完整输出**(体积与隐私),但记录足以复现与统计的字段。 import fs from 'node:fs'; import path from 'node:path'; import { pruneJsonlDirLazily } from '../ai-shared/JsonlRetention'; /** 九类归因(规则归因;拿不准留 unclassified,宁缺勿错) */ export type FailureAttribution = | 'syntax_hallucination' // 幻觉语法/非白名单键 | 'binding_missing' // 绑定路径没有数据 | 'catalog_violation' // 用了目录外组件/prop/emit | 'output_malformed' // JSON 未成形/被截断 | 'upstream_empty' // 上游什么都没返回(outputTokens=0),与"写坏了"是两种病 | 'render_smoke' // 静态过闸但挂载/渲染出问题 | 'drift' // revise 越界改动 | 'timeout' // 上游超时 | 'engine_bug' // 引擎侧缺陷(需人工确认) | 'unclassified'; export interface FailureRecord { at: string; mode: string; /** 需求或修改指令原文(截断保存) */ input: string; attempts: number; issues: string[]; repairs: string[]; attribution: FailureAttribution; durationMs: number; outputTokens: number; model: string; } const RULES: Array<{ tag: FailureAttribution; test: RegExp }> = [ { tag: 'render_smoke', test: /^\[冒烟\]/ }, // 空输出必须排在 output_malformed 之前:它同样带 [输出] 前缀,但要的是完全不同的处置 // (查上游/配额/模型可用性,而不是回头改提示词) { tag: 'upstream_empty', test: /空输出/ }, { tag: 'drift', test: /^\[漂移对账\]/ }, { tag: 'output_malformed', test: /^\[输出\]|截断|无法解析/ }, { tag: 'catalog_violation', test: /不在组件目录|不存在的 (prop|emit)|未在目录/ }, { tag: 'binding_missing', test: /路径 ".+" 不存在|initProps|未提供数据/ }, { tag: 'syntax_hallucination', test: /无法识别|非法|不支持的|白名单/ }, { tag: 'timeout', test: /超时|timeout/i }, ]; /** 规则归因:命中首条规则即定性;都不中留 unclassified(不猜) */ export function classifyFailure(issues: string[]): FailureAttribution { const text = issues.join('\n'); for (const rule of RULES) { if (issues.some((issue) => rule.test.test(issue)) || rule.test.test(text)) { return rule.tag; } } return 'unclassified'; } /** 落盘目录:环境变量优先(避免 cwd 陷阱),缺省 /runtime/ai-failures */ function journalDir(): string { return process.env.SOMA_AI_FAILURE_DIR ?? path.join(process.cwd(), 'runtime', 'ai-failures'); } function journalPath(now: Date): string { const day = now.toISOString().slice(0, 10); return path.join(journalDir(), `failures-${day}.jsonl`); } /** * 记录一次失败(仅在 issues 非空时调用)。 * 落盘失败绝不影响主流程——可观测性不能变成新的故障源。 */ export function recordFailure(record: Omit & { attribution?: FailureAttribution; now?: Date; }): FailureRecord | null { const now = record.now ?? new Date(); const entry: FailureRecord = { at: now.toISOString(), mode: record.mode, input: record.input.slice(0, 500), attempts: record.attempts, issues: record.issues.slice(0, 5), repairs: record.repairs.slice(0, 5), attribution: record.attribution ?? classifyFailure(record.issues), durationMs: record.durationMs, outputTokens: record.outputTokens, model: record.model, }; try { const dir = journalDir(); fs.mkdirSync(dir, { recursive: true }); fs.appendFileSync(journalPath(now), `${JSON.stringify(entry)}\n`, 'utf-8'); // 保留策略惰性触发(O-5):写完顺带看一眼过期与超限,不起定时器、失败静默 pruneJsonlDirLazily({ dir, prefix: 'failures-' }); } catch { // 落盘失败静默:宁可丢一条样本,也不让生成链因日志目录问题失败 } return entry; } export interface FailureStats { total: number; byAttribution: Record; byMode: Record; avgAttempts: number; } /** 读取某日(或全部)失败样本,给出统计——提示词改动前后的对照基准 */ export function readFailureStats(options: { day?: string } = {}): FailureStats { const dir = journalDir(); const stats: FailureStats = { total: 0, byAttribution: {}, byMode: {}, avgAttempts: 0 }; let attemptsSum = 0; let files: string[] = []; try { files = fs.readdirSync(dir).filter((name) => name.endsWith('.jsonl')); } catch { return stats; } if (options.day) { files = files.filter((name) => name.includes(options.day!)); } for (const file of files) { const lines = fs.readFileSync(path.join(dir, file), 'utf-8').split('\n').filter(Boolean); for (const line of lines) { try { const record = JSON.parse(line) as FailureRecord; stats.total += 1; attemptsSum += record.attempts ?? 0; stats.byAttribution[record.attribution] = (stats.byAttribution[record.attribution] ?? 0) + 1; stats.byMode[record.mode] = (stats.byMode[record.mode] ?? 0) + 1; } catch { // 跳过坏行 } } } stats.avgAttempts = stats.total > 0 ? Number((attemptsSum / stats.total).toFixed(2)) : 0; return stats; }