oh-my-knowledge 0.38.0 → 0.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/agent-skills/omk/references/commands.md +2 -0
- package/dist/authoring/evolver.js +9 -5
- package/dist/cli/commands/list.d.ts +4 -1
- package/dist/cli/commands/list.js +12 -4
- package/dist/cli/commands/observe/index.d.ts +9 -0
- package/dist/cli/commands/observe/index.js +74 -1
- package/dist/cli/commands/sample.d.ts +13 -0
- package/dist/cli/commands/sample.js +113 -16
- package/dist/cli/lib/cmd-flags.d.ts +1 -0
- package/dist/cli/lib/i18n-dict/common.d.ts +1 -1
- package/dist/cli/lib/i18n-dict/common.js +8 -0
- package/dist/cli/lib/i18n-dict/gen.d.ts +1 -1
- package/dist/cli/lib/i18n-dict/gen.js +8 -0
- package/dist/cli/lib/i18n-dict/list.d.ts +1 -1
- package/dist/cli/lib/i18n-dict/list.js +4 -0
- package/dist/cli/lib/record-evolve-outcome.js +19 -10
- package/dist/eval-core/bootstrap.d.ts +53 -2
- package/dist/eval-core/bootstrap.js +82 -5
- package/dist/eval-core/evaluation-reporting.js +37 -15
- package/dist/eval-core/execution-strategy.js +1 -0
- package/dist/eval-core/schema.js +5 -3
- package/dist/eval-core/verdict.d.ts +30 -0
- package/dist/eval-core/verdict.js +71 -21
- package/dist/grading/assertions.js +28 -0
- package/dist/grading/debias-validate.js +6 -2
- package/dist/grading/index.js +6 -1
- package/dist/grading/layered-scores.d.ts +15 -0
- package/dist/grading/layered-scores.js +64 -38
- package/dist/inputs/load-samples.d.ts +5 -0
- package/dist/inputs/load-samples.js +4 -2
- package/dist/inputs/skill-loader.d.ts +8 -0
- package/dist/inputs/skill-loader.js +23 -0
- package/dist/managed/evidence.d.ts +2 -1
- package/dist/managed/evidence.js +15 -2
- package/dist/managed/index.d.ts +2 -0
- package/dist/managed/index.js +2 -0
- package/dist/managed/list-view.d.ts +15 -1
- package/dist/managed/list-view.js +9 -1
- package/dist/managed/observe-feedback.d.ts +60 -0
- package/dist/managed/observe-feedback.js +49 -0
- package/dist/managed/store.d.ts +38 -1
- package/dist/managed/store.js +116 -3
- package/dist/managed/version-scores.d.ts +33 -0
- package/dist/managed/version-scores.js +70 -0
- package/dist/observability/skill-health-analyzer.d.ts +5 -0
- package/dist/observability/skill-health-analyzer.js +3 -2
- package/dist/renderer/managed-history-renderer.d.ts +2 -2
- package/dist/renderer/managed-history-renderer.js +189 -7
- package/dist/renderer/summary.js +23 -22
- package/dist/server/report-server.js +11 -2
- package/dist/types/eval.d.ts +2 -0
- package/dist/types/judge.d.ts +8 -0
- package/dist/types/managed.d.ts +40 -0
- package/dist/types/report.d.ts +10 -0
- package/package.json +3 -3
|
@@ -403,6 +403,7 @@ omk observe [sessionsDir] [flags]
|
|
|
403
403
|
|
|
404
404
|
**Flags:**
|
|
405
405
|
|
|
406
|
+
- `--feedback` `boolean`:把生产健康观测反哺已纳管的同名 skill(--no-feedback 关闭)
|
|
406
407
|
- `--from` `option`:起始时间 ISO,优先级高于 --last
|
|
407
408
|
- `--global` `boolean`:写全局 ~/.oh-my-knowledge/observe-health,而非项目 .omk/observe-health
|
|
408
409
|
- `--kb` `option`:知识库 root,启用 KB-aware 分析
|
|
@@ -584,6 +585,7 @@ omk sample [skillPath] [flags]
|
|
|
584
585
|
|
|
585
586
|
**Flags:**
|
|
586
587
|
|
|
588
|
+
- `--append` `boolean`:在已有用例文件上追加新生成的用例(撞 sample_id 自动加后缀去重,保留原 json/yaml 格式)。仅单 skill 模式,不支持 --batch / --from-traces / --fix。不传则已有文件时报错保护。常配 --focus 补特定场景。
|
|
587
589
|
- `--batch` `boolean`:批量模式:扫 --skill-dir 下所有缺 samples 的 skill,逐个生成。
|
|
588
590
|
- `--count` `option`:生成用例条数。不传由 LLM 按 skill 类型自动决定。
|
|
589
591
|
- `--executor` `option`:执行器名,默认 claude(同 omk eval / doctor / evolve)。指定 codex 等其它执行器时,记得连带传一个该执行器能识别的 --model。
|
|
@@ -868,11 +868,15 @@ export async function evolveSkill({ skillPath, samplesPath, rounds = 5, target =
|
|
|
868
868
|
// Significance accept gate: accept only when the candidate is *significantly*
|
|
869
869
|
// above the current best on the decision (val) set, not merely numerically higher
|
|
870
870
|
// — rejecting gains indistinguishable from judge noise. `lastReport` is the current
|
|
871
|
-
// best's fresh eval and `candidateReport` the candidate's, over the same samples
|
|
872
|
-
//
|
|
873
|
-
//
|
|
874
|
-
//
|
|
875
|
-
//
|
|
871
|
+
// best's fresh eval and `candidateReport` the candidate's, over the same samples.
|
|
872
|
+
// **Deliberately UNPAIRED here** (independent resampling, `decideAccept` → `bootstrapDiffCI`),
|
|
873
|
+
// unlike the report verdict / debias which switched to `bootstrapPairedDiffCI`: this is an
|
|
874
|
+
// *optimization accept-gate*, and the unpaired bootstrap's wider, conservative CI is the
|
|
875
|
+
// wanted bias — it raises the bar to accept, so evolve does not chase paired-tightened,
|
|
876
|
+
// marginally-significant gains that risk overfitting the decision set. Power is not the goal
|
|
877
|
+
// for de-/escalation gates; not accepting noise is. Under-powered decision sets degrade to the
|
|
878
|
+
// legacy point-estimate accept (note: that path compares the prior-round best scalar, not this
|
|
879
|
+
// fresh re-eval) and flag `gate.underpowered`.
|
|
876
880
|
const valIds = split ? split.valIds : new Set(allSampleIds);
|
|
877
881
|
const bestScores = perSampleComposite(lastReport, lastVariantKey, valIds);
|
|
878
882
|
const candScores = perSampleComposite(candidateReport, candidateVariantKey, valIds);
|
|
@@ -2,7 +2,10 @@ import { BaseCommand } from '../oclif/base-command.js';
|
|
|
2
2
|
import { sanitizeCell } from '../lib/cell-format.js';
|
|
3
3
|
import { type ManagedListRow } from '../../managed/index.js';
|
|
4
4
|
import type { CliLang } from '../lib/i18n.js';
|
|
5
|
-
/** CJK
|
|
5
|
+
/** CJK 全角字符 + 星平面 emoji 按 2 列计宽,使含中文表头 / emoji 标记(如 🔬)的列也能对齐。
|
|
6
|
+
* 逐**码点**迭代(for...of),星平面 emoji 是单码点,加 `\u{1F300}-\u{1FAFF}`(含 🔬 U+1F52C)判 2 列。
|
|
7
|
+
* 注:`⚠️` 是「U+26A0 + U+FE0F」两码点、各 1 列合计 2,本就与 2 列渲染对齐,不另判(避免重复计数);
|
|
8
|
+
* `✓`(U+2713)终端按 1 列渲染、保持 1。 */
|
|
6
9
|
export declare function dispWidth(s: string): number;
|
|
7
10
|
/** 按**显示宽度**截断(不是 code unit):逐码点累加 dispWidth,绝不切断 surrogate 对、CJK 也不溢出列。 */
|
|
8
11
|
export declare function truncate(s: string, max: number): string;
|
|
@@ -4,11 +4,14 @@ import { BaseCommand } from '../oclif/base-command.js';
|
|
|
4
4
|
import { tCli } from '../lib/i18n.js';
|
|
5
5
|
import { sanitizeCell } from '../lib/cell-format.js';
|
|
6
6
|
import { globalManagedDir, listManagedRows, managedDir, resolveManagedDir, } from '../../managed/index.js';
|
|
7
|
-
/** CJK
|
|
7
|
+
/** CJK 全角字符 + 星平面 emoji 按 2 列计宽,使含中文表头 / emoji 标记(如 🔬)的列也能对齐。
|
|
8
|
+
* 逐**码点**迭代(for...of),星平面 emoji 是单码点,加 `\u{1F300}-\u{1FAFF}`(含 🔬 U+1F52C)判 2 列。
|
|
9
|
+
* 注:`⚠️` 是「U+26A0 + U+FE0F」两码点、各 1 列合计 2,本就与 2 列渲染对齐,不另判(避免重复计数);
|
|
10
|
+
* `✓`(U+2713)终端按 1 列渲染、保持 1。 */
|
|
8
11
|
export function dispWidth(s) {
|
|
9
12
|
let w = 0;
|
|
10
13
|
for (const ch of s)
|
|
11
|
-
w += /[
|
|
14
|
+
w += /[ᄀ-ᅟ⺀-가-힣豈-︰-﹏-⦆¢-₩\u{1F300}-\u{1FAFF}]/u.test(ch) ? 2 : 1;
|
|
12
15
|
return w;
|
|
13
16
|
}
|
|
14
17
|
function pad(s, width) {
|
|
@@ -45,7 +48,9 @@ export function renderTable(rows, lang) {
|
|
|
45
48
|
truncate(sanitizeCell(r.name), 40), // name 与 source 同为用户可控、可超长 → 同样按显示宽度截断,防撑爆表宽
|
|
46
49
|
r.kind, // ArtifactKind 枚举(validator 已收窄),无需洗
|
|
47
50
|
// 不可达 → 标「?」(drift 未核),绝不冒充 stale;reachable 且漂移才 stale ⚠️;已人工接受标 promoted ✓。
|
|
48
|
-
|
|
51
|
+
// 生产盲区 🔬 与生命周期**正交**(observe 量线上部署版),叠加在 state token 之后,不替换 state。
|
|
52
|
+
(!r.reachable ? `${r.state} ?` : r.state === 'stale' ? 'stale ⚠️' : r.state === 'promoted' ? 'promoted ✓' : r.state)
|
|
53
|
+
+ (r.productionGap ? ' 🔬' : ''),
|
|
49
54
|
r.latestVerdict ? sanitizeCell(r.latestVerdict) : '—',
|
|
50
55
|
`${r.currentEvidenceCount}/${r.totalEvidenceCount}`,
|
|
51
56
|
truncate(sanitizeCell(r.sourceLabel), 48),
|
|
@@ -99,7 +104,8 @@ export default class List extends BaseCommand {
|
|
|
99
104
|
const hasDrift = rows.some((r) => r.drifted);
|
|
100
105
|
const hasUnreachable = rows.some((r) => !r.reachable);
|
|
101
106
|
const hasPromoted = rows.some((r) => r.state === 'promoted');
|
|
102
|
-
|
|
107
|
+
const hasProductionGap = rows.some((r) => r.productionGap);
|
|
108
|
+
if (hasDrift || hasUnreachable || hasPromoted || hasProductionGap)
|
|
103
109
|
process.stderr.write('\n');
|
|
104
110
|
if (hasPromoted)
|
|
105
111
|
process.stderr.write(tCli('cli.list.promoted_note', lang));
|
|
@@ -107,6 +113,8 @@ export default class List extends BaseCommand {
|
|
|
107
113
|
process.stderr.write(tCli('cli.list.drift_note', lang));
|
|
108
114
|
if (hasUnreachable)
|
|
109
115
|
process.stderr.write(tCli('cli.list.unreachable_note', lang));
|
|
116
|
+
if (hasProductionGap)
|
|
117
|
+
process.stderr.write(tCli('cli.list.production_gap_note', lang));
|
|
110
118
|
process.stderr.write(tCli('cli.list.legend', lang));
|
|
111
119
|
});
|
|
112
120
|
}
|
|
@@ -9,6 +9,14 @@ export declare function persistObserveHealthReport(report: SkillHealthReport, ou
|
|
|
9
9
|
id: string;
|
|
10
10
|
jsonPath: string;
|
|
11
11
|
};
|
|
12
|
+
/**
|
|
13
|
+
* SkillHealthReport → managed 反哺的结构化最小入参(#235)。纯映射、可单测 —— 把「observe 报告 →
|
|
14
|
+
* ObserveReportView」这段层间胶水从 CLI 副作用里拆出来,免得 healthBand 取错字段 / observedAt 取错时刻
|
|
15
|
+
* 这类映射 bug 无人验。`observedAt` 取**流量窗口结束时刻**(timeRange.to,空则退 generatedAt),不是「此刻」
|
|
16
|
+
* 的 generatedAt —— 否则 latest-wins 会把所有观测当成一样新(见 ManagedObservation.observedAt)。
|
|
17
|
+
* `healthBand` 由 observability 的 `healthBandOf` 逐 skill 算(阈值单一来源,注入以保可测)。
|
|
18
|
+
*/
|
|
19
|
+
export declare function buildObserveReportView(report: SkillHealthReport, reportId: string, healthBandOf: (weightedGapRate: number) => 'green' | 'yellow' | 'red'): import('../../../managed/index.js').ObserveReportView;
|
|
12
20
|
export default class Observe extends BaseCommand {
|
|
13
21
|
static description: string;
|
|
14
22
|
static examples: {
|
|
@@ -27,6 +35,7 @@ export default class Observe extends BaseCommand {
|
|
|
27
35
|
skills: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
28
36
|
'output-dir': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
29
37
|
global: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
38
|
+
feedback: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
30
39
|
};
|
|
31
40
|
run(): Promise<void>;
|
|
32
41
|
}
|
|
@@ -23,6 +23,68 @@ export function persistObserveHealthReport(report, outDir) {
|
|
|
23
23
|
indexObserveWrite(report, jsonPath, outDir, id);
|
|
24
24
|
return { id, jsonPath };
|
|
25
25
|
}
|
|
26
|
+
// 盲区信号类型 → 人话标签(建议补样本提示用)。技术枚举键的展示名,zh/en 分列。
|
|
27
|
+
const GAP_AREA_LABELS = {
|
|
28
|
+
failed_search: { zh: '检索失败', en: 'failed search' },
|
|
29
|
+
explicit_marker: { zh: '显式缺口', en: 'explicit gap' },
|
|
30
|
+
hedging: { zh: '含糊回避', en: 'hedging' },
|
|
31
|
+
repeated_failure: { zh: '反复失败', en: 'repeated failure' },
|
|
32
|
+
};
|
|
33
|
+
/** 取盲区计数最高的前几类,组成「建议补哪类用例」的人话区域串(只展示信号所在,不生成具体用例)。
|
|
34
|
+
* 只迭代**四个已知盲区类型**(GAP_AREA_LABELS 的键),记录 / 报告里若混入额外键一律忽略,不进展示。 */
|
|
35
|
+
function topGapAreas(gapByType, lang) {
|
|
36
|
+
const sep = lang === 'zh' ? '、' : ', ';
|
|
37
|
+
const areas = Object.keys(GAP_AREA_LABELS)
|
|
38
|
+
.map((k) => [k, gapByType[k] ?? 0])
|
|
39
|
+
.filter(([, n]) => n > 0)
|
|
40
|
+
.sort((a, b) => b[1] - a[1])
|
|
41
|
+
.slice(0, 3)
|
|
42
|
+
.map(([k]) => GAP_AREA_LABELS[k][lang])
|
|
43
|
+
.join(sep);
|
|
44
|
+
return areas || (lang === 'zh' ? '未归类盲区' : 'uncategorized gaps');
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* SkillHealthReport → managed 反哺的结构化最小入参(#235)。纯映射、可单测 —— 把「observe 报告 →
|
|
48
|
+
* ObserveReportView」这段层间胶水从 CLI 副作用里拆出来,免得 healthBand 取错字段 / observedAt 取错时刻
|
|
49
|
+
* 这类映射 bug 无人验。`observedAt` 取**流量窗口结束时刻**(timeRange.to,空则退 generatedAt),不是「此刻」
|
|
50
|
+
* 的 generatedAt —— 否则 latest-wins 会把所有观测当成一样新(见 ManagedObservation.observedAt)。
|
|
51
|
+
* `healthBand` 由 observability 的 `healthBandOf` 逐 skill 算(阈值单一来源,注入以保可测)。
|
|
52
|
+
*/
|
|
53
|
+
export function buildObserveReportView(report, reportId, healthBandOf) {
|
|
54
|
+
return {
|
|
55
|
+
reportId,
|
|
56
|
+
observedAt: report.meta.timeRange?.to || report.meta.generatedAt,
|
|
57
|
+
skills: Object.values(report.bySkill).map((s) => ({
|
|
58
|
+
skillName: s.skillName,
|
|
59
|
+
segmentCount: s.segmentCount,
|
|
60
|
+
gapRate: s.gap.gapRate,
|
|
61
|
+
weightedGapRate: s.gap.weightedGapRate,
|
|
62
|
+
confidence: s.confidence,
|
|
63
|
+
healthBand: healthBandOf(s.gap.weightedGapRate),
|
|
64
|
+
gapByType: s.gap.byType,
|
|
65
|
+
})),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* observe → 管理支柱反哺(#235):把每个 skill 的生产健康落成观测追加进同名受管记录,并打印「已记录 / 生产
|
|
70
|
+
* 盲区警示」。**非致命**:管理是 observe 旁路,任何异常都不该让 observe 失败(try/catch 吞掉)。observability /
|
|
71
|
+
* managed 运行时函数动态 import,与 observe 主体一致、不拖累 CLI 启动。
|
|
72
|
+
*/
|
|
73
|
+
async function recordObserveFeedback(report, reportId, lang) {
|
|
74
|
+
try {
|
|
75
|
+
const { healthBandOf } = await import('../../../observability/skill-health-analyzer.js');
|
|
76
|
+
const { recordObserveHealth } = await import('../../../managed/index.js');
|
|
77
|
+
const written = recordObserveHealth(buildObserveReportView(report, reportId, healthBandOf));
|
|
78
|
+
for (const w of written) {
|
|
79
|
+
process.stdout.write(w.isProductionGap
|
|
80
|
+
? tCli('cli.observe.production_gap', lang, { name: w.name, areas: topGapAreas(w.gapByType, lang) })
|
|
81
|
+
: tCli('cli.observe.observation_recorded', lang, { name: w.name }));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
// 反哺是 observe 旁路,任何异常都不该让 observe 失败。
|
|
86
|
+
}
|
|
87
|
+
}
|
|
26
88
|
// `omk observe <sessions-dir>` 是默认命令 —— 分析 sessions 目录的 skill 调用健康度,产出 observe-health 报告(JSON),
|
|
27
89
|
// 由 Studio 健康报告页按需渲染。observe 这条线的另一条产物是观测收件箱(observe-inbox),走子命令 ingest / inbox / show。
|
|
28
90
|
export default class Observe extends BaseCommand {
|
|
@@ -80,6 +142,14 @@ export default class Observe extends BaseCommand {
|
|
|
80
142
|
en: 'Write to global ~/.oh-my-knowledge/observe-health instead of project .omk/observe-health',
|
|
81
143
|
}),
|
|
82
144
|
}),
|
|
145
|
+
feedback: Flags.boolean({
|
|
146
|
+
default: true,
|
|
147
|
+
allowNo: true,
|
|
148
|
+
description: bilingual({
|
|
149
|
+
zh: '把生产健康观测反哺已纳管的同名 skill(--no-feedback 关闭)',
|
|
150
|
+
en: 'Feed production-health observations back to managed skills of the same name (--no-feedback to disable)',
|
|
151
|
+
}),
|
|
152
|
+
}),
|
|
83
153
|
};
|
|
84
154
|
async run() {
|
|
85
155
|
const { args, flags } = await this.parse(Observe);
|
|
@@ -120,7 +190,7 @@ export default class Observe extends BaseCommand {
|
|
|
120
190
|
const outDir = flags['output-dir']
|
|
121
191
|
? resolve(flags['output-dir'])
|
|
122
192
|
: (flags.global ? globalObserveHealthDir() : projectObserveHealthDir());
|
|
123
|
-
const { jsonPath } = persistObserveHealthReport(report, outDir);
|
|
193
|
+
const { id, jsonPath } = persistObserveHealthReport(report, outDir);
|
|
124
194
|
const { sessionCount, segmentCount, toolCallCount, toolFailureRate } = report.meta;
|
|
125
195
|
console.log('');
|
|
126
196
|
console.log(`sessions: ${sessionCount} · segments: ${segmentCount} · tool calls: ${toolCallCount} · fail rate: ${(toolFailureRate * 100).toFixed(1)}%`);
|
|
@@ -139,6 +209,9 @@ export default class Observe extends BaseCommand {
|
|
|
139
209
|
console.log('');
|
|
140
210
|
console.log(`report written to: ${jsonPath}`);
|
|
141
211
|
console.log(tCli('cli.observe.view_hint', lang));
|
|
212
|
+
// #235 受管反哺:把生产健康观测落进同名受管 skill(--no-feedback 关)。非致命旁路。
|
|
213
|
+
if (flags.feedback)
|
|
214
|
+
await recordObserveFeedback(report, id, lang);
|
|
142
215
|
});
|
|
143
216
|
}
|
|
144
217
|
}
|
|
@@ -2,6 +2,18 @@ import { BaseCommand } from '../oclif/base-command.js';
|
|
|
2
2
|
import { type CliLang } from '../lib/i18n.js';
|
|
3
3
|
import { type LoadSamplesResult } from '../../inputs/load-samples.js';
|
|
4
4
|
import type { Report, Sample as SampleType } from '../../types/index.js';
|
|
5
|
+
/** --append 合并:已有用例原样保留,新用例逐条接在后面;sample_id 撞已有(或本批已用)时
|
|
6
|
+
* 自动加 `-2`/`-3` 后缀去重。模型每次从 s001 重编号,撞 id 不代表内容重复,所以是改名保留
|
|
7
|
+
* 而非丢弃(不做内容级去重)。`reserved` 为额外要避开的 id 集(目录模式跨同目录其它 sample
|
|
8
|
+
* 文件去重用,见 collectDirSampleIds)。 */
|
|
9
|
+
export declare function mergeAppendSamples(existing: SampleType[], fresh: SampleType[], reserved?: ReadonlySet<string>): SampleType[];
|
|
10
|
+
/** 目录模式 append 选写回目标:复用 listSampleFilesInDir 的排序/过滤(与 eval 目录合并同口径),
|
|
11
|
+
* 优先 canonical `samples.json`,否则排序后第一个 —— 确定性、不依赖文件系统枚举顺序,
|
|
12
|
+
* 用户可预测改哪个文件。无候选返回 null。 */
|
|
13
|
+
export declare function pickAppendTargetFile(dir: string): string | null;
|
|
14
|
+
/** 把新用例追加进已有 sample 文件:读 → 合并(撞 id 去重)→ 保留原 json/yaml 格式与
|
|
15
|
+
* `{samples:[...]}` wrapper 写回。返回合并后总条数。 */
|
|
16
|
+
export declare function appendSamplesToFile(existingFile: string, fresh: SampleType[], reserved?: ReadonlySet<string>): number;
|
|
5
17
|
export declare function collectSampleDesignFailureIds(report: Pick<Report, 'results'>, treatmentName: string): Set<string>;
|
|
6
18
|
export declare function assertFixReportMatchesCurrentInputs(params: {
|
|
7
19
|
report: Pick<Report, 'meta'>;
|
|
@@ -29,6 +41,7 @@ export default class Sample extends BaseCommand {
|
|
|
29
41
|
executor: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
30
42
|
'skill-dir': import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
31
43
|
focus: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
44
|
+
append: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
32
45
|
'no-mock': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
33
46
|
fix: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
34
47
|
'reports-dir': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
@@ -8,7 +8,7 @@ import { integerStringParser } from '../oclif/parsers.js';
|
|
|
8
8
|
import { CliExit } from '../lib/cli-exit.js';
|
|
9
9
|
import { tCli } from '../lib/i18n.js';
|
|
10
10
|
import { projectReportsDir, globalReportsDir } from '../../eval-core/measurement-dirs.js';
|
|
11
|
-
import { loadSamples, parseYaml } from '../../inputs/load-samples.js';
|
|
11
|
+
import { loadSamples, parseYaml, listSampleFilesInDir } from '../../inputs/load-samples.js';
|
|
12
12
|
import { hashSample } from '../../eval-core/evaluation-reporting.js';
|
|
13
13
|
import { hashArtifactSource } from '../../inputs/content-hash.js';
|
|
14
14
|
function isRecord(value) {
|
|
@@ -33,6 +33,78 @@ function stringifySampleDocument(filePath, document) {
|
|
|
33
33
|
return yaml.dump(document, { lineWidth: -1, noRefs: true });
|
|
34
34
|
return JSON.stringify(document, null, 2);
|
|
35
35
|
}
|
|
36
|
+
/** --append 合并:已有用例原样保留,新用例逐条接在后面;sample_id 撞已有(或本批已用)时
|
|
37
|
+
* 自动加 `-2`/`-3` 后缀去重。模型每次从 s001 重编号,撞 id 不代表内容重复,所以是改名保留
|
|
38
|
+
* 而非丢弃(不做内容级去重)。`reserved` 为额外要避开的 id 集(目录模式跨同目录其它 sample
|
|
39
|
+
* 文件去重用,见 collectDirSampleIds)。 */
|
|
40
|
+
export function mergeAppendSamples(existing, fresh, reserved) {
|
|
41
|
+
const used = new Set(existing.map((s) => s.sample_id));
|
|
42
|
+
if (reserved)
|
|
43
|
+
for (const id of reserved)
|
|
44
|
+
used.add(id);
|
|
45
|
+
const merged = [...existing];
|
|
46
|
+
for (const sample of fresh) {
|
|
47
|
+
let id = sample.sample_id;
|
|
48
|
+
if (used.has(id)) {
|
|
49
|
+
let n = 2;
|
|
50
|
+
while (used.has(`${id}-${n}`))
|
|
51
|
+
n += 1;
|
|
52
|
+
id = `${id}-${n}`;
|
|
53
|
+
}
|
|
54
|
+
used.add(id);
|
|
55
|
+
merged.push(id === sample.sample_id ? sample : { ...sample, sample_id: id });
|
|
56
|
+
}
|
|
57
|
+
return merged;
|
|
58
|
+
}
|
|
59
|
+
/** 目录模式 append:收集目录内所有 sample 文件的 sample_id,跨文件去重用 —— eval 走目录模式
|
|
60
|
+
* 会把目录下所有文件合并加载,跨文件撞 id 直接报错(load-samples 的 duplicate sample_id)。
|
|
61
|
+
* 复用 listSampleFilesInDir 的排序/过滤口径;best-effort:解析失败的文件跳过。 */
|
|
62
|
+
function collectDirSampleIds(dir) {
|
|
63
|
+
const ids = new Set();
|
|
64
|
+
let files;
|
|
65
|
+
try {
|
|
66
|
+
files = listSampleFilesInDir(dir);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return ids;
|
|
70
|
+
}
|
|
71
|
+
for (const f of files) {
|
|
72
|
+
const full = join(dir, f);
|
|
73
|
+
try {
|
|
74
|
+
for (const s of getSamplesArray(parseSampleDocument(full), full)) {
|
|
75
|
+
if (typeof s.sample_id === 'string')
|
|
76
|
+
ids.add(s.sample_id);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
catch { /* skip unparseable / 非 sample 文件 */ }
|
|
80
|
+
}
|
|
81
|
+
return ids;
|
|
82
|
+
}
|
|
83
|
+
/** 目录模式 append 选写回目标:复用 listSampleFilesInDir 的排序/过滤(与 eval 目录合并同口径),
|
|
84
|
+
* 优先 canonical `samples.json`,否则排序后第一个 —— 确定性、不依赖文件系统枚举顺序,
|
|
85
|
+
* 用户可预测改哪个文件。无候选返回 null。 */
|
|
86
|
+
export function pickAppendTargetFile(dir) {
|
|
87
|
+
let files;
|
|
88
|
+
try {
|
|
89
|
+
files = listSampleFilesInDir(dir);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
if (files.length === 0)
|
|
95
|
+
return null;
|
|
96
|
+
const chosen = files.includes('samples.json') ? 'samples.json' : files[0];
|
|
97
|
+
return join(dir, chosen);
|
|
98
|
+
}
|
|
99
|
+
/** 把新用例追加进已有 sample 文件:读 → 合并(撞 id 去重)→ 保留原 json/yaml 格式与
|
|
100
|
+
* `{samples:[...]}` wrapper 写回。返回合并后总条数。 */
|
|
101
|
+
export function appendSamplesToFile(existingFile, fresh, reserved) {
|
|
102
|
+
const doc = parseSampleDocument(existingFile);
|
|
103
|
+
const merged = mergeAppendSamples(getSamplesArray(doc, existingFile), fresh, reserved);
|
|
104
|
+
const nextDoc = Array.isArray(doc) ? merged : { ...doc, samples: merged };
|
|
105
|
+
writeFileSync(existingFile, stringifySampleDocument(existingFile, nextDoc));
|
|
106
|
+
return merged.length;
|
|
107
|
+
}
|
|
36
108
|
function formatIdList(ids) {
|
|
37
109
|
const shown = ids.slice(0, 5);
|
|
38
110
|
const suffix = ids.length > shown.length ? ` +${ids.length - shown.length}` : '';
|
|
@@ -328,6 +400,12 @@ async function runSampleFromTraces(flags, lang) {
|
|
|
328
400
|
}
|
|
329
401
|
}
|
|
330
402
|
async function runSample(args, flags, lang) {
|
|
403
|
+
// --append 目前只在单 skill 生成路径实现;batch / from-traces / fix 不处理它,
|
|
404
|
+
// 静默忽略会误导(用户以为在追加,实际没有)。提前互斥校验,明确报错。
|
|
405
|
+
if (flags.append && (flags.batch || flags['from-traces'] || flags.fix)) {
|
|
406
|
+
console.error(tCli('cli.gen.append_single_only', lang));
|
|
407
|
+
throw new CliExit(2);
|
|
408
|
+
}
|
|
331
409
|
if (flags['from-traces']) {
|
|
332
410
|
await runSampleFromTraces(flags, lang);
|
|
333
411
|
return;
|
|
@@ -425,23 +503,23 @@ async function runSample(args, flags, lang) {
|
|
|
425
503
|
}
|
|
426
504
|
const skillContent = readFileSync(resolved.skillPath, 'utf-8');
|
|
427
505
|
let outputPath;
|
|
506
|
+
let existingFile = null;
|
|
428
507
|
if (!extname(resolved.samplesPath)) {
|
|
429
508
|
const dir = resolved.samplesPath;
|
|
430
509
|
if (existsSync(dir) && statSync(dir).isDirectory()) {
|
|
431
|
-
|
|
432
|
-
if (existing) {
|
|
433
|
-
console.error(tCli('cli.gen.samples_already_exists', lang));
|
|
434
|
-
throw new CliExit(1);
|
|
435
|
-
}
|
|
510
|
+
existingFile = pickAppendTargetFile(dir);
|
|
436
511
|
}
|
|
437
|
-
outputPath = join(dir, 'samples.json');
|
|
512
|
+
outputPath = existingFile ?? join(dir, 'samples.json');
|
|
438
513
|
}
|
|
439
514
|
else {
|
|
440
515
|
outputPath = resolved.samplesPath;
|
|
441
|
-
if (existsSync(outputPath))
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
516
|
+
if (existsSync(outputPath))
|
|
517
|
+
existingFile = outputPath;
|
|
518
|
+
}
|
|
519
|
+
// 已有用例文件:默认报错保护;--append 时追加(下面合并),不报错。
|
|
520
|
+
if (existingFile && !flags.append) {
|
|
521
|
+
console.error(tCli('cli.gen.samples_already_exists', lang));
|
|
522
|
+
throw new CliExit(1);
|
|
445
523
|
}
|
|
446
524
|
if (count !== undefined) {
|
|
447
525
|
process.stderr.write(tCli('cli.gen.single_generating', lang, { count }));
|
|
@@ -451,12 +529,24 @@ async function runSample(args, flags, lang) {
|
|
|
451
529
|
}
|
|
452
530
|
try {
|
|
453
531
|
const { samples, costUSD } = await generateSamples({ skillContent, count, model, focus, noMock: flags['no-mock'], executorName: flags.executor });
|
|
454
|
-
mkdirSync(dirname(outputPath), { recursive: true });
|
|
455
|
-
writeFileSync(outputPath, JSON.stringify(samples, null, 2));
|
|
456
532
|
const cost = costUSD > 0 ? ` $${costUSD.toFixed(4)}` : '';
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
533
|
+
if (existingFile && flags.append) {
|
|
534
|
+
// 追加:读已有 → 合并(撞 id 去重)→ 保留原 json/yaml 格式与 wrapper 写回。
|
|
535
|
+
// 目录模式额外跨同目录其它 sample 文件去重,避免 eval 合并加载时撞 id 报错;
|
|
536
|
+
// 显式单文件路径无同目录合并语义,不需要。
|
|
537
|
+
const reserved = extname(resolved.samplesPath) ? undefined : collectDirSampleIds(dirname(existingFile));
|
|
538
|
+
const total = appendSamplesToFile(existingFile, samples, reserved);
|
|
539
|
+
process.stderr.write(tCli('cli.gen.append_done', lang, {
|
|
540
|
+
added: samples.length, total, path: existingFile, cost,
|
|
541
|
+
}));
|
|
542
|
+
}
|
|
543
|
+
else {
|
|
544
|
+
mkdirSync(dirname(outputPath), { recursive: true });
|
|
545
|
+
writeFileSync(outputPath, JSON.stringify(samples, null, 2));
|
|
546
|
+
process.stderr.write(tCli('cli.gen.single_done', lang, {
|
|
547
|
+
n: samples.length, path: outputPath, cost,
|
|
548
|
+
}));
|
|
549
|
+
}
|
|
460
550
|
console.log(tCli('cli.gen.review_hint', lang));
|
|
461
551
|
}
|
|
462
552
|
catch (err) {
|
|
@@ -553,6 +643,13 @@ export default class Sample extends BaseCommand {
|
|
|
553
643
|
en: 'Generation focus (NL hint). Steers LLM toward certain sample types.',
|
|
554
644
|
}),
|
|
555
645
|
}),
|
|
646
|
+
append: Flags.boolean({
|
|
647
|
+
description: bilingual({
|
|
648
|
+
zh: '在已有用例文件上追加新生成的用例(撞 sample_id 自动加后缀去重,保留原 json/yaml 格式)。仅单 skill 模式,不支持 --batch / --from-traces / --fix。不传则已有文件时报错保护。常配 --focus 补特定场景。',
|
|
649
|
+
en: 'Append newly generated samples to the existing samples file (colliding sample_id auto-suffixed, original json/yaml shape kept). Single-skill mode only; not supported with --batch / --from-traces / --fix. Without it, an existing file errors out. Often paired with --focus.',
|
|
650
|
+
}),
|
|
651
|
+
default: false,
|
|
652
|
+
}),
|
|
556
653
|
'no-mock': Flags.boolean({
|
|
557
654
|
description: bilingual({
|
|
558
655
|
zh: '不生成 mocks,eval 时所有工具调用真实执行。',
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import type { CliMessage } from './types.js';
|
|
2
|
-
export type CommonMessageKey = 'cli.common.unknown_domain' | 'cli.common.error_prefix' | 'cli.common.skill_dir_not_found' | 'cli.common.skill_file_not_found' | 'cli.common.skill_dir_no_skill_md' | 'cli.common.report_not_found' | 'cli.common.no_judge_model' | 'cli.common.judge_models_single_only' | 'cli.common.warn_load_samples_failed' | 'cli.update.new_version_available' | 'cli.update.box_title' | 'cli.update.box_version_line' | 'cli.update.box_upgrade_line' | 'cli.update.box_silence_line' | 'cli.observe.view_hint' | 'cli.studio.started' | 'cli.studio.stop_hint' | 'cli.studio.open_failed' | 'cli.doctor.no_skill_found' | 'cli.doctor.samples_detected' | 'cli.doctor.progress_skill_start' | 'cli.doctor.progress_skill_done';
|
|
2
|
+
export type CommonMessageKey = 'cli.common.unknown_domain' | 'cli.common.error_prefix' | 'cli.common.skill_dir_not_found' | 'cli.common.skill_file_not_found' | 'cli.common.skill_dir_no_skill_md' | 'cli.common.report_not_found' | 'cli.common.no_judge_model' | 'cli.common.judge_models_single_only' | 'cli.common.warn_load_samples_failed' | 'cli.update.new_version_available' | 'cli.update.box_title' | 'cli.update.box_version_line' | 'cli.update.box_upgrade_line' | 'cli.update.box_silence_line' | 'cli.observe.view_hint' | 'cli.observe.observation_recorded' | 'cli.observe.production_gap' | 'cli.studio.started' | 'cli.studio.stop_hint' | 'cli.studio.open_failed' | 'cli.doctor.no_skill_found' | 'cli.doctor.samples_detected' | 'cli.doctor.progress_skill_start' | 'cli.doctor.progress_skill_done';
|
|
3
3
|
export declare const commonDict: Record<CommonMessageKey, CliMessage>;
|
|
@@ -59,6 +59,14 @@ export const commonDict = {
|
|
|
59
59
|
zh: '分析 JSON 已写入 output-dir;后续可用 omk observe 持续生成健康报告。',
|
|
60
60
|
en: 'Analysis JSON written to output-dir; use omk observe to keep producing health reports.',
|
|
61
61
|
},
|
|
62
|
+
'cli.observe.observation_recorded': {
|
|
63
|
+
zh: '🔭 已为受管 skill「{name}」记录生产健康观测。\n',
|
|
64
|
+
en: '🔭 Recorded a production-health observation for managed skill "{name}".\n',
|
|
65
|
+
},
|
|
66
|
+
'cli.observe.production_gap': {
|
|
67
|
+
zh: '⚠️ 受管 skill「{name}」线上检测到生产盲区({areas})。建议补充对应用例后重跑 omk eval —— observe 只提示,不改样本集。\n',
|
|
68
|
+
en: '⚠️ Managed skill "{name}" shows production gaps in real traffic ({areas}). Consider adding matching samples and re-running omk eval — observe only suggests, it does not edit the sample set.\n',
|
|
69
|
+
},
|
|
62
70
|
'cli.studio.started': {
|
|
63
71
|
zh: 'studio 已启动:{url}',
|
|
64
72
|
en: 'Studio running at {url}',
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import type { CliMessage } from './types.js';
|
|
2
|
-
export type GenMessageKey = 'cli.gen.skill_skipped_existing' | 'cli.gen.skill_generating' | 'cli.gen.skill_generating_auto' | 'cli.gen.skill_done' | 'cli.gen.skill_failed' | 'cli.gen.batch_none_needed' | 'cli.gen.batch_summary' | 'cli.gen.specify_skill_path' | 'cli.gen.samples_already_exists' | 'cli.gen.single_generating' | 'cli.gen.single_generating_auto' | 'cli.gen.single_done' | 'cli.gen.review_hint' | 'cli.gen.failed' | 'cli.gen.focus_applied';
|
|
2
|
+
export type GenMessageKey = 'cli.gen.skill_skipped_existing' | 'cli.gen.skill_generating' | 'cli.gen.skill_generating_auto' | 'cli.gen.skill_done' | 'cli.gen.skill_failed' | 'cli.gen.batch_none_needed' | 'cli.gen.batch_summary' | 'cli.gen.specify_skill_path' | 'cli.gen.samples_already_exists' | 'cli.gen.single_generating' | 'cli.gen.single_generating_auto' | 'cli.gen.single_done' | 'cli.gen.append_done' | 'cli.gen.append_single_only' | 'cli.gen.review_hint' | 'cli.gen.failed' | 'cli.gen.focus_applied';
|
|
3
3
|
export declare const genDict: Record<GenMessageKey, CliMessage>;
|
|
@@ -47,6 +47,14 @@ export const genDict = {
|
|
|
47
47
|
zh: '✅ 已生成 {n} 条用例 → {path}{cost}\n',
|
|
48
48
|
en: '✅ Generated {n} samples → {path}{cost}\n',
|
|
49
49
|
},
|
|
50
|
+
'cli.gen.append_done': {
|
|
51
|
+
zh: '✅ 新增 {added} 条用例(撞 id 已自动改名),合并后共 {total} 条 → {path}{cost}\n',
|
|
52
|
+
en: '✅ Appended {added} samples (colliding ids auto-renamed), {total} total → {path}{cost}\n',
|
|
53
|
+
},
|
|
54
|
+
'cli.gen.append_single_only': {
|
|
55
|
+
zh: '--append 目前仅支持单 skill 模式,不能与 --batch / --from-traces / --fix 同用。\n',
|
|
56
|
+
en: '--append currently supports single-skill mode only; it cannot be combined with --batch / --from-traces / --fix.\n',
|
|
57
|
+
},
|
|
50
58
|
'cli.gen.review_hint': {
|
|
51
59
|
zh: '\n请审查生成的评测用例后运行: omk eval',
|
|
52
60
|
en: '\nReview the generated test cases, then run: omk eval',
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import type { CliMessage } from './types.js';
|
|
2
|
-
export type ListMessageKey = 'cli.list.header' | 'cli.list.empty' | 'cli.list.empty_hint' | 'cli.list.col_name' | 'cli.list.col_kind' | 'cli.list.col_state' | 'cli.list.col_verdict' | 'cli.list.col_evidence' | 'cli.list.col_source' | 'cli.list.drift_note' | 'cli.list.unreachable_note' | 'cli.list.promoted_note' | 'cli.list.legend';
|
|
2
|
+
export type ListMessageKey = 'cli.list.header' | 'cli.list.empty' | 'cli.list.empty_hint' | 'cli.list.col_name' | 'cli.list.col_kind' | 'cli.list.col_state' | 'cli.list.col_verdict' | 'cli.list.col_evidence' | 'cli.list.col_source' | 'cli.list.drift_note' | 'cli.list.unreachable_note' | 'cli.list.promoted_note' | 'cli.list.production_gap_note' | 'cli.list.legend';
|
|
3
3
|
export declare const listDict: Record<ListMessageKey, CliMessage>;
|
|
@@ -29,6 +29,10 @@ export const listDict = {
|
|
|
29
29
|
zh: '✓ = 当前版本已按证据人工接受为 promoted(omk promote)。\n',
|
|
30
30
|
en: '✓ = current version accepted as promoted on evidence (omk promote).\n',
|
|
31
31
|
},
|
|
32
|
+
'cli.list.production_gap_note': {
|
|
33
|
+
zh: '🔬 = observe 在线上检测到生产盲区(与生命周期无关的版本无关信号);建议补对应用例后重跑 omk eval。\n',
|
|
34
|
+
en: '🔬 = observe detected a production gap in real traffic (a version-agnostic signal, orthogonal to lifecycle); add matching samples and re-run omk eval.\n',
|
|
35
|
+
},
|
|
32
36
|
'cli.list.legend': {
|
|
33
37
|
zh: '证据列 = 当前有效 / 全部(历史含旧内容证据,供回滚)。\n',
|
|
34
38
|
en: 'EVIDENCE column = current / total (history keeps old-content evidence for rollback).\n',
|
|
@@ -2,26 +2,35 @@ import { resolve } from 'node:path';
|
|
|
2
2
|
import { createOverlayReportStore } from '../../server/report-store.js';
|
|
3
3
|
import { projectReportsDir, globalReportsDir } from '../../eval-core/measurement-dirs.js';
|
|
4
4
|
import { computeVerdict } from '../../eval-core/verdict.js';
|
|
5
|
-
import {
|
|
5
|
+
import { bootstrapPairedDiffCI, DEFAULT_BOOTSTRAP_ALPHA, DEFAULT_BOOTSTRAP_SAMPLES } from '../../eval-core/bootstrap.js';
|
|
6
6
|
import { resolveManagedDir, managedDir, loadAllManagedRecords, appendManagedEvidence, rebaselineManagedContentHash, buildEvidenceRef, probeSourceState, } from '../../managed/index.js';
|
|
7
7
|
/**
|
|
8
8
|
* round-bestRound vs round-0 的忠实 verdict:复刻 eval 管线(evaluation-reporting.ts)对两变体抽
|
|
9
|
-
* per-sample composite →
|
|
9
|
+
* per-sample composite → 按 sample 配对 bootstrapPairedDiffCI → computeVerdict,与 `omk eval --bootstrap`
|
|
10
|
+
* 同口径、同 α / 重采样数。
|
|
10
11
|
* 不自造门限:评委是否显著、是否 PROGRESS 全交给既有 computeVerdict。
|
|
11
12
|
*/
|
|
12
13
|
function winnerVerdict(report, winnerVariant) {
|
|
13
14
|
const baseline = 'round-0';
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
15
|
+
// 按 sample **配对**(与 evaluation-reporting 主 A/B 同口径 —— 本函数职责就是复刻 eval 管线):baseline 与
|
|
16
|
+
// winner 在同一 sample 上都可测(composite > 0)才入对。同一 sample 两版分数正相关,配对 bootstrap 收紧 diff
|
|
17
|
+
// CI;独立重采样会高估方差、保守失功效。diff = b − a = winner − baseline。
|
|
18
|
+
const compositeOf = (v, r) => {
|
|
19
|
+
const e = r.variants[v];
|
|
20
|
+
return e && typeof e.compositeScore === 'number' && e.compositeScore > 0 ? e.compositeScore : undefined;
|
|
21
|
+
};
|
|
22
|
+
const pairs = [];
|
|
23
|
+
for (const r of report.results) {
|
|
24
|
+
const a = compositeOf(baseline, r);
|
|
25
|
+
const b = compositeOf(winnerVariant, r);
|
|
26
|
+
if (a !== undefined && b !== undefined)
|
|
27
|
+
pairs.push({ a, b });
|
|
28
|
+
}
|
|
29
|
+
const pairComparisons = pairs.length >= 2
|
|
21
30
|
? [{
|
|
22
31
|
control: baseline,
|
|
23
32
|
treatment: winnerVariant,
|
|
24
|
-
diffBootstrapCI:
|
|
33
|
+
diffBootstrapCI: bootstrapPairedDiffCI(pairs, DEFAULT_BOOTSTRAP_ALPHA, DEFAULT_BOOTSTRAP_SAMPLES),
|
|
25
34
|
}]
|
|
26
35
|
: undefined;
|
|
27
36
|
const slice = {
|