Skip to content
Merged
16 changes: 16 additions & 0 deletions src/cli/cli-exit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* 命令退出信号。execute() / parse-strict / requireEvaluationReport 等想以
* 特定 exit code 终止时,throw 这个,不要直接 process.exit。
*
* dispatcher (`main()` 顶层 catch) 负责把 CliExit 转成 process.exit(code) —
* 这样 execute() 可以在单测里被 try/catch 捕获,不 kill 整个测试进程。
*
* 异步回调内的「子进程 exit code 透传」(commands/report.ts spawn handler)
* 不走这层,继续 process.exit — 那是子进程退出后的 cleanup,不在 main 调用栈。
*/
export class CliExit extends Error {
constructor(public readonly code: number) {
super(`CliExit(${code})`);
this.name = 'CliExit';
}
}
36 changes: 36 additions & 0 deletions src/cli/commands/_shared.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { CliExit } from '../cli-exit.js';
import { tCli, type CliLang } from '../i18n.js';
import type { EvaluationReport, ReportDocument } from '../../types/index.js';

export interface EvalResult {
report: ReportDocument;
filePath: string | null;
}

export interface ReportServer {
start: () => Promise<string>;
}

export function requireEvaluationReport(report: ReportDocument | null, id: string, lang: CliLang): EvaluationReport {
if (!report) {
console.error(tCli('cli.common.report_not_found', lang, { id }));
throw new CliExit(1);
}
if (report.kind === 'batch-evaluation') {
console.error(lang === 'zh'
? `报告 ${id} 是 BatchEvaluationReport。该命令需要单次 EvaluationReport;请使用其中的 child reportId。`
: `Report ${id} is a BatchEvaluationReport. This command requires an EvaluationReport; use a child reportId from the batch.`);
throw new CliExit(1);
}
return report;
}

export function parseLastWindow(spec: string): string | null {
// "7d" / "24h" / "30m" → ISO timestamp (from = now - spec)
const m = /^(\d+)([dhm])$/.exec(spec);
if (!m) return null;
const n = Number(m[1]);
const unit = m[2];
const ms = unit === 'd' ? n * 86400_000 : unit === 'h' ? n * 3600_000 : n * 60_000;
return new Date(Date.now() - ms).toISOString();
}
82 changes: 82 additions & 0 deletions src/cli/commands/analyze.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { CliExit } from '../cli-exit.js';
import { resolve, join } from 'node:path';
import { tCli, langFromArgv } from '../i18n.js';
import { COMMON_OPTIONS } from '../parse-run-config.js';
import { parseArgsStrictOrExit } from '../parse-strict.js';
import { parseLastWindow } from './_shared.js';

export async function execute(argv: string[]): Promise<void> {
const lang = langFromArgv(argv);
const { values: rawValues, positionals } = parseArgsStrictOrExit({
args: argv,
allowPositionals: true,
options: {
...COMMON_OPTIONS,
kb: { type: 'string' },
last: { type: 'string' },
from: { type: 'string' },
to: { type: 'string' },
skills: { type: 'string' },
'output-dir': { type: 'string' },
},
});
// 该 handler options 全是 string-typed (无 boolean), 收紧 cast 让 caller 直接 use values.xxx 当 string 用。
const values = rawValues as Record<string, string | undefined>;
const dir = positionals[0];
if (!dir) {
console.error(tCli('cli.help.analyze_usage', lang));
throw new CliExit(1);
}
const tracePath = resolve(dir);

const { existsSync, mkdirSync, writeFileSync } = await import('node:fs');
if (!existsSync(tracePath)) {
console.error(`Trace path does not exist: ${tracePath}`);
throw new CliExit(1);
}

// 时间窗: --from/--to 优先, --last fallback
let from: string | undefined = values.from;
if (!from && values.last) {
const inferred = parseLastWindow(values.last);
if (!inferred) {
console.error(`Invalid --last format: "${values.last}". Expected e.g. "7d" / "24h" / "30m".`);
throw new CliExit(1);
}
from = inferred;
}
const to: string | undefined = values.to;
const skills = values.skills ? values.skills.split(',').map((s) => s.trim()).filter(Boolean) : undefined;

console.log(`[omk] analyzing ${tracePath}...`);
const { computeSkillHealthReport } = await import('../../observability/skill-health-analyzer.js');
const report = computeSkillHealthReport(tracePath, {
kbRoot: values.kb ? resolve(values.kb) : undefined,
from,
to,
skills,
});

// JSON 是主产物; HTML 由 report server 的 /analyses/:id 按需渲染 (和 bench run 一致)
const outDir = resolve(values['output-dir'] || join(process.env.HOME || '.', '.oh-my-knowledge', 'analyses'));
mkdirSync(outDir, { recursive: true });
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const jsonPath = join(outDir, `${timestamp}-skill-health.json`);
writeFileSync(jsonPath, JSON.stringify(report, null, 2));

// 控制台摘要
const { sessionCount, segmentCount, toolCallCount, toolFailureRate } = report.meta;
console.log('');
console.log(`sessions: ${sessionCount} · segments: ${segmentCount} · tool calls: ${toolCallCount} · fail rate: ${(toolFailureRate * 100).toFixed(1)}%`);
console.log(`overall: gapRate ${(report.overall.gapRate * 100).toFixed(1)}% · weightedGapRate ${(report.overall.weightedGapRate * 100).toFixed(1)}% · health: ${report.overall.healthBand}`);
console.log('');
const skillRows = Object.values(report.bySkill)
.sort((a, b) => b.segmentCount - a.segmentCount)
.slice(0, 10)
.map((s) => ` ${s.skillName.padEnd(24)} segs=${String(s.segmentCount).padStart(4)} gapRate=${String(Math.round(s.gap.gapRate * 100) + '%').padStart(4)} weighted=${String(Math.round(s.gap.weightedGapRate * 100) + '%').padStart(4)}${s.coverage ? ` cov=${Math.round(s.coverage.fileCoverageRate * 100)}%` : ''}`);
console.log('top skills:');
console.log(skillRows.join('\n'));
console.log('');
console.log(`report written to: ${jsonPath}`);
console.log(tCli('cli.analyze.view_in_browser', lang));
}
97 changes: 97 additions & 0 deletions src/cli/commands/debias-validate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { CliExit } from '../cli-exit.js';
import { resolve } from 'node:path';
import { tCli, langFromArgv } from '../i18n.js';
import { COMMON_OPTIONS, DEFAULT_REPORTS_DIR } from '../parse-run-config.js';
import { parseArgsStrictOrExit } from '../parse-strict.js';
import type { ReportStore, JudgeConfig } from '../../types/index.js';
import { requireEvaluationReport } from './_shared.js';

export async function execute(argv: string[]): Promise<void> {
const lang = langFromArgv(argv);
const sub = argv[0];
const rest = argv.slice(1);
if (!sub) {
console.log(tCli('cli.help.debias_validate', lang));
throw new CliExit(1);
}

if (sub !== 'length') {
console.error(`Unknown debias-validate kind: ${sub}. Use "length".`);
throw new CliExit(1);
}

const reportId = rest[0];
if (!reportId) {
console.error('Usage: omk bench debias-validate length <reportId>');
throw new CliExit(1);
}
const { values } = parseArgsStrictOrExit({
args: rest.slice(1),
options: {
...COMMON_OPTIONS,
'reports-dir': { type: 'string', default: DEFAULT_REPORTS_DIR },
samples: { type: 'string' },
variant: { type: 'string' },
'judge-models': { type: 'string' },
'bootstrap-samples': { type: 'string', default: '1000' },
seed: { type: 'string' },
},
});

// Parse --judge-models 在 load report 之前 fail-fast。重复 entry / 缺 executor /
// 空串等参数错误应立即给 friendly error: + exit 2,不要等到 store IO 完成才暴露。
const { parseJudgeModelsArgOrExit: parseJudgesA } = await import('../parse-run-config.js');
const cliJudgeModelsA = (values['judge-models'] as string | undefined) !== undefined
? parseJudgesA(values['judge-models'] as string)
: undefined;
if (cliJudgeModelsA && cliJudgeModelsA.length > 1) {
console.error(tCli('cli.common.judge_models_single_only', lang, { cmd: 'debias-validate' }));
throw new CliExit(2);
}

const { createFileStore } = await import('../../server/report-store.js');
const store: ReportStore = createFileStore(resolve(values['reports-dir'] as string));
const report = requireEvaluationReport(await store.get(reportId), reportId, lang);

// Resolve samples path: --samples overrides; otherwise read from report.meta.request.
const samplesPath = (values.samples as string | undefined)
?? report.meta?.request?.samplesPath;
if (!samplesPath) {
console.error('Cannot find samples path. Pass --samples <path> or ensure report has request.samplesPath.');
throw new CliExit(1);
}
const { loadSamples } = await import('../../inputs/load-samples.js');
const { samples } = loadSamples(samplesPath);

const debiasJudges: JudgeConfig[] = cliJudgeModelsA
?? (report.meta?.judgeModels?.[0]
? [{ executor: report.meta.judgeModels[0].executor, model: report.meta.judgeModels[0].model }]
: []);
if (debiasJudges.length === 0) {
console.error(tCli('cli.common.no_judge_model', lang));
throw new CliExit(1);
}

process.stderr.write(tCli('cli.debias.warn_cost_doubles', lang));

const { createExecutor } = await import('../../executors/index.js');
const judgeExecutor = createExecutor(debiasJudges[0].executor);
const judgeModel = debiasJudges[0].model;
const { validateLengthDebias, formatDebiasValidate } = await import('../../grading/debias-validate.js');

const seedVal = values.seed != null ? Number(values.seed) : undefined;
const bsRaw = Number(values['bootstrap-samples']) || 1000;
const result = await validateLengthDebias({
report,
samples,
judgeExecutor,
judgeModel,
variant: values.variant as string | undefined,
bootstrapSamples: Math.max(100, bsRaw),
seed: Number.isFinite(seedVal) ? seedVal : undefined,
onProgress: ({ sample_id, completed, total }) => {
process.stderr.write(` judging ${completed}/${total}: ${sample_id}\n`);
},
});
console.log(formatDebiasValidate(result));
}
81 changes: 81 additions & 0 deletions src/cli/commands/diagnose.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { CliExit } from '../cli-exit.js';
import { resolve } from 'node:path';
import { existsSync } from 'node:fs';
import { tCli, langFromArgv } from '../i18n.js';
import { COMMON_OPTIONS, DEFAULT_REPORTS_DIR } from '../parse-run-config.js';
import { parseArgsStrictOrExit } from '../parse-strict.js';
import { renderSampleDesignCoverage } from '../coverage-renderer.js';
import type { ReportStore } from '../../types/index.js';
import { requireEvaluationReport } from './_shared.js';

export async function execute(argv: string[]): Promise<void> {
const lang = langFromArgv(argv);
const reportId = argv[0];
if (!reportId) {
console.log(tCli('cli.help.diagnose', lang));
throw new CliExit(1);
}

const { values } = parseArgsStrictOrExit({
args: argv.slice(1),
options: {
...COMMON_OPTIONS,
'reports-dir': { type: 'string', default: DEFAULT_REPORTS_DIR },
samples: { type: 'string' },
top: { type: 'string', default: '10' },
'duplicate-rouge': { type: 'string' },
'ambiguous-stddev': { type: 'string' },
'cost-k': { type: 'string' },
'latency-k': { type: 'string' },
flat: { type: 'string' },
},
});

const { createFileStore } = await import('../../server/report-store.js');
const store: ReportStore = createFileStore(resolve(values['reports-dir'] as string));
const report = requireEvaluationReport(await store.get(reportId), reportId, lang);

// Try to read the samples file for near-duplicate detection. Source order:
// 1. --samples <path> override
// 2. report.meta.request.samplesPath (recorded at run time)
// If neither resolves to a readable file, skip near-duplicate gracefully.
let samples: import('../../types/index.js').Sample[] | undefined;
const samplesPath = (values.samples as string | undefined) ?? report.meta?.request?.samplesPath;
if (samplesPath && existsSync(samplesPath)) {
try {
const { loadSamples } = await import('../../inputs/load-samples.js');
samples = loadSamples(samplesPath).samples;
} catch (err) {
process.stderr.write(tCli('cli.common.warn_load_samples_failed', lang, {
path: samplesPath, message: (err as Error).message,
}));
}
}

const topRaw = Number(values.top);
const topN = Number.isFinite(topRaw) && topRaw > 0 ? topRaw : undefined;

const { diagnoseSamples, formatSampleDiagnostics } = await import('../../analysis/sample-diagnostics.js');
const diag = diagnoseSamples(report, {
samples,
duplicateRouge: values['duplicate-rouge'] != null ? Number(values['duplicate-rouge']) : undefined,
ambiguousStddev: values['ambiguous-stddev'] != null ? Number(values['ambiguous-stddev']) : undefined,
costOutlierK: values['cost-k'] != null ? Number(values['cost-k']) : undefined,
latencyOutlierK: values['latency-k'] != null ? Number(values['latency-k']) : undefined,
flatThreshold: values.flat != null ? Number(values.flat) : undefined,
});
console.log(formatSampleDiagnostics(diag, { topN, lang }));

// Sample design science coverage block. Render after diagnose 主体,因为
// coverage 是声明式元数据(capability/difficulty/construct/provenance)的整体分布,
// 跟 issue list 是不同视角的两件事。优先从 samples (现场加载) 算,fallback 到
// report.analysis.sampleQuality(报告里持久化的数据)。
const coverageBlock = renderSampleDesignCoverage(samples, report.analysis?.sampleQuality, lang);
if (coverageBlock) console.log(coverageBlock);

// Exit code: 0 if health ≥ 70 and no errors; 1 otherwise. CI-friendly.
if (diag.totals.errors === 0 && diag.healthScore >= 70) {
throw new CliExit(0);
}
throw new CliExit(1);
}
Loading
Loading