oh-my-knowledge 0.42.0 → 0.44.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/README.md +1 -1
- package/README.zh.md +1 -1
- package/dist/artifact-graph/doctor.d.ts +21 -0
- package/dist/artifact-graph/doctor.js +569 -0
- package/dist/artifact-graph/eval.d.ts +16 -0
- package/dist/artifact-graph/eval.js +351 -0
- package/dist/assets/agent-skills/omk/SKILL.md +9 -9
- package/dist/assets/agent-skills/omk/references/commands.md +1 -1
- package/dist/cli/commands/doctor.js +62 -61
- package/dist/cli/commands/eval/index.js +20 -2
- package/dist/cli/commands/init.js +1 -0
- package/dist/cli/commands/observe/index.d.ts +2 -2
- package/dist/cli/commands/observe/index.js +8 -7
- package/dist/cli/commands/sample.js +22 -16
- 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/help.js +12 -10
- package/dist/cli/lib/parse-run-config/samples-discovery.d.ts +5 -7
- package/dist/cli/lib/parse-run-config/samples-discovery.js +10 -32
- package/dist/cli/lib/parse-run-config.js +4 -4
- package/dist/cli/lib/resolve-skill-input.js +10 -12
- package/dist/doctor/index.js +2 -7
- package/dist/doctor/messages.js +2 -2
- package/dist/eval-core/artifact-file-names.d.ts +15 -0
- package/dist/eval-core/artifact-file-names.js +45 -0
- package/dist/eval-core/evaluation-reporting.d.ts +1 -1
- package/dist/eval-core/evaluation-reporting.js +32 -12
- package/dist/eval-core/measurement-dirs.js +13 -7
- package/dist/eval-core/report-file-migration.d.ts +10 -0
- package/dist/eval-core/report-file-migration.js +90 -0
- package/dist/inputs/sample-locator.d.ts +23 -0
- package/dist/inputs/sample-locator.js +195 -0
- package/dist/inputs/skill-loader.js +7 -17
- package/dist/observability/inbox.js +7 -3
- package/dist/renderer/html-renderer.js +3 -2
- package/dist/renderer/skill-detail-renderer.js +832 -0
- package/dist/renderer/skill-list-renderer.js +2 -7
- package/dist/server/report-server.js +55 -15
- package/dist/server/report-store.js +17 -9
- package/dist/server/skill-index.d.ts +6 -0
- package/dist/server/skill-index.js +399 -15
- package/dist/types/artifact-graph.d.ts +93 -0
- package/dist/types/artifact-graph.js +1 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.js +1 -0
- package/dist/types/skill-index.d.ts +38 -0
- package/package.json +1 -1
|
@@ -9,30 +9,32 @@
|
|
|
9
9
|
* 因为同一份 EvaluationReport 同时驱动两个 view tab。
|
|
10
10
|
* - observe 报告(analysesDir, SkillHealthReport):data.bySkill[name] 每个键作为
|
|
11
11
|
* 一个 skill 的 observe snapshot,取最新 generatedAt。
|
|
12
|
-
* - doctor
|
|
13
|
-
* 该字段保留 null,渲染层显示"未独立运行 omk doctor"。后续 commit 加默认
|
|
14
|
-
* ~/.oh-my-knowledge/doctors/ 持久化路径再回填。
|
|
12
|
+
* - doctor:读取 `.omk/doctors/*.report.json`,按 skill 名聚合体检历史。
|
|
15
13
|
*
|
|
16
14
|
* 综合 band:eval / observe 任一红 → 红,任一黄 → 黄,全绿 → 绿,皆未跑 → gray。
|
|
17
15
|
*/
|
|
18
16
|
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
19
17
|
import { join, isAbsolute, basename, dirname } from 'node:path';
|
|
18
|
+
import { GRAPH_FILE_SUFFIX, graphFileName, isReportFileName, reportFileStem } from '../eval-core/artifact-file-names.js';
|
|
19
|
+
import { migrateLegacyReportFiles } from '../eval-core/report-file-migration.js';
|
|
20
|
+
import { doctorGraphDirForDoctorOutput } from '../artifact-graph/doctor.js';
|
|
21
|
+
import { evalGraphDirForReportOutput } from '../artifact-graph/eval.js';
|
|
20
22
|
import { confidenceOf } from '../observability/skill-health-analyzer.js';
|
|
21
23
|
import { computeVerdict } from '../eval-core/verdict.js';
|
|
22
|
-
import { artifactIndexDir, listLiveDoctorCards, cardToDoctorSnapshot, listLiveObserveCards, cardTargetSentinel } from '../eval-core/artifact-index.js';
|
|
24
|
+
import { artifactIndexDir, listLiveDoctorCards, cardToDoctorSnapshot, listLiveObserveCards, listLiveReportCards, cardTargetSentinel } from '../eval-core/artifact-index.js';
|
|
23
25
|
import { detectInsights } from './skill-insights.js';
|
|
24
26
|
import { DEFAULT_OBSERVATIONS_DIR, loadLatestObservationInboxReports } from '../observability/inbox.js';
|
|
25
27
|
import { buildStudioDiagnosisSummary, mergeDiagnosisBundles } from '../diagnosis/studio-projection.js';
|
|
26
28
|
let _indexCache = null;
|
|
27
29
|
/**
|
|
28
30
|
* Sync 版 dir-content fingerprint helper,仿 `src/server/report-store.ts:80-92`
|
|
29
|
-
* 的 async `computeListFingerprint` — 把目录本身的 mtime 跟目录下每个 `.json`
|
|
31
|
+
* 的 async `computeListFingerprint` — 把目录本身的 mtime 跟目录下每个 `.report.json`
|
|
30
32
|
* 文件的 "filename:mtimeMs:size" 三元组排序拼接成 stable 字符串作为 dir-level
|
|
31
33
|
* 的 content-aware fingerprint。
|
|
32
34
|
*
|
|
33
|
-
* - 目录下任何 .json 文件**新增 / 删除 / 重命名** → 目录 mtime 跟着变,且
|
|
35
|
+
* - 目录下任何 .report.json 文件**新增 / 删除 / 重命名** → 目录 mtime 跟着变,且
|
|
34
36
|
* sorted-filenames 列表变,字符串变 → cache invalidate。
|
|
35
|
-
* - 任何**已有同名 .json 文件被外部进程原地覆写内容** → 该文件自己的 mtimeMs
|
|
37
|
+
* - 任何**已有同名 .report.json 文件被外部进程原地覆写内容** → 该文件自己的 mtimeMs
|
|
36
38
|
* 跟通常情况下 size 都变(byte 长度跟内容相关),字符串里那一 entry 的后缀
|
|
37
39
|
* 变,整体字符串变 → cache invalidate。这是 pre-fix 的 fingerprint(只看
|
|
38
40
|
* dir mtime+文件数)漏掉的信号(reviewer 2026-05-11 P2-a)。
|
|
@@ -48,7 +50,7 @@ function safeDirJsonContentFingerprint(dir) {
|
|
|
48
50
|
let jsonFiles;
|
|
49
51
|
try {
|
|
50
52
|
dirMtimeMs = statSync(dir).mtimeMs;
|
|
51
|
-
jsonFiles = readdirSync(dir).filter(
|
|
53
|
+
jsonFiles = readdirSync(dir).filter(isReportFileName).sort();
|
|
52
54
|
}
|
|
53
55
|
catch {
|
|
54
56
|
return `missing:${dir}`;
|
|
@@ -64,13 +66,68 @@ function safeDirJsonContentFingerprint(dir) {
|
|
|
64
66
|
});
|
|
65
67
|
return `${dir}|${dirMtimeMs}|${fileParts.join(',')}`;
|
|
66
68
|
}
|
|
67
|
-
function
|
|
69
|
+
function safeDirGraphContentFingerprint(dir) {
|
|
70
|
+
let dirMtimeMs;
|
|
71
|
+
let graphFiles;
|
|
72
|
+
try {
|
|
73
|
+
dirMtimeMs = statSync(dir).mtimeMs;
|
|
74
|
+
graphFiles = readdirSync(dir).filter((f) => f.endsWith(GRAPH_FILE_SUFFIX)).sort();
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return `missing:${dir}`;
|
|
78
|
+
}
|
|
79
|
+
const fileParts = graphFiles.map((f) => {
|
|
80
|
+
try {
|
|
81
|
+
const fStat = statSync(join(dir, f));
|
|
82
|
+
return `${f}:${fStat.mtimeMs}:${fStat.size}`;
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return `${f}:?`;
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
return `${dir}|${dirMtimeMs}|${fileParts.join(',')}`;
|
|
89
|
+
}
|
|
90
|
+
function fileFingerprint(path) {
|
|
91
|
+
try {
|
|
92
|
+
const stat = statSync(path);
|
|
93
|
+
return `${path}:${stat.mtimeMs}:${stat.size}`;
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return `${path}:missing`;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
function uniqueStrings(values) {
|
|
100
|
+
return [...new Set(values.filter(Boolean))].sort();
|
|
101
|
+
}
|
|
102
|
+
function graphSidecarPathForReportPath(reportPath, reportId) {
|
|
103
|
+
return join(evalGraphDirForReportOutput(dirname(reportPath)), graphFileName(reportId));
|
|
104
|
+
}
|
|
105
|
+
function graphSidecarPathForDoctorCard(cardPath, cardId) {
|
|
106
|
+
return join(doctorGraphDirForDoctorOutput(dirname(cardPath)), graphFileName(cardId));
|
|
107
|
+
}
|
|
108
|
+
function buildGraphFingerprint(evalGraphDirs, doctorGraphDirs, includeReportCards, includeDoctorCards) {
|
|
109
|
+
const dirPart = [
|
|
110
|
+
...uniqueStrings(evalGraphDirs).map((dir) => `eg:${safeDirGraphContentFingerprint(dir)}`),
|
|
111
|
+
...uniqueStrings(doctorGraphDirs).map((dir) => `dg:${safeDirGraphContentFingerprint(dir)}`),
|
|
112
|
+
].join('|');
|
|
113
|
+
const reportCardPart = includeReportCards
|
|
114
|
+
? listLiveReportCards().map((card) => fileFingerprint(graphSidecarPathForReportPath(card.path, card.id))).sort().join('|')
|
|
115
|
+
: '';
|
|
116
|
+
const doctorCardPart = includeDoctorCards
|
|
117
|
+
? listLiveDoctorCards().map((card) => fileFingerprint(graphSidecarPathForDoctorCard(card.path, card.id))).sort().join('|')
|
|
118
|
+
: '';
|
|
119
|
+
return `${dirPart}|erc:${reportCardPart}|drc:${doctorCardPart}`;
|
|
120
|
+
}
|
|
121
|
+
function buildIndexFingerprint(reports, analysesDir, doctorsDir, observationsDir, includeObserveCards, includeDoctorCards, graphFingerprint) {
|
|
68
122
|
// `d:` 跟 `o:` 前缀("d for doctors / o for observability analyses")沿用 pre-fix
|
|
69
123
|
// 字面格式,避免外部 logging / debug 时 grep fingerprint 字符串的格式漂移。
|
|
70
124
|
// 每一段的 right-hand-side 从旧的 "{dir-mtime}-{file-count}" 双标量升级成
|
|
71
125
|
// safeDirJsonContentFingerprint 返回的 "{dir-mtime}|{file1}:{m}:{s},..."
|
|
72
126
|
// content-aware 字符串。
|
|
73
127
|
const reportIds = reports.map((r) => `${r.id}:${r.meta?.timestamp ?? ''}:${r.kind === 'evaluation' ? r.meta.evolve?.skillName ?? '' : ''}`).join(',');
|
|
128
|
+
migrateLegacyReportFiles(doctorsDir, 'doctor');
|
|
129
|
+
migrateLegacyReportFiles(analysesDir, 'observe-health');
|
|
130
|
+
migrateLegacyReportFiles(observationsDir, 'observe-inbox');
|
|
74
131
|
const doctorsFp = safeDirJsonContentFingerprint(doctorsDir);
|
|
75
132
|
const analysesFp = safeDirJsonContentFingerprint(analysesDir);
|
|
76
133
|
const observationsFp = safeDirJsonContentFingerprint(observationsDir);
|
|
@@ -80,7 +137,7 @@ function buildIndexFingerprint(reports, analysesDir, doctorsDir, observationsDir
|
|
|
80
137
|
// 卡片目录指纹 + 真身存在性 sentinel 都纳入:后者使「卡片真身被带外删」也能让缓存失效(否则悬空卡片继续展示)。
|
|
81
138
|
const doctorCardsFp = includeDoctorCards ? `${safeDirJsonContentFingerprint(artifactIndexDir('doctor'))}|t:${cardTargetSentinel('doctor')}` : '';
|
|
82
139
|
const observeCardsFp = includeObserveCards ? `${safeDirJsonContentFingerprint(artifactIndexDir('observe-health'))}|t:${cardTargetSentinel('observe-health')}` : '';
|
|
83
|
-
return `${reportIds}|d:${doctorsFp}|o:${analysesFp}|obs:${observationsFp}|dc:${doctorCardsFp}|oc:${observeCardsFp}|inc:${includeObserveCards ? 'o' : ''}${includeDoctorCards ? 'd' : ''}`;
|
|
140
|
+
return `${reportIds}|d:${doctorsFp}|o:${analysesFp}|obs:${observationsFp}|dc:${doctorCardsFp}|oc:${observeCardsFp}|g:${graphFingerprint}|inc:${includeObserveCards ? 'o' : ''}${includeDoctorCards ? 'd' : ''}`;
|
|
84
141
|
}
|
|
85
142
|
/** 测试 / 调试用:强制清掉 in-process skill-index 缓存。 */
|
|
86
143
|
export function _resetSkillIndexCache() {
|
|
@@ -224,14 +281,322 @@ function latestEvalSnapshot(list) {
|
|
|
224
281
|
return null;
|
|
225
282
|
return list[list.length - 1];
|
|
226
283
|
}
|
|
227
|
-
|
|
284
|
+
function isArtifactGraphDocument(value) {
|
|
285
|
+
if (!value || typeof value !== 'object')
|
|
286
|
+
return false;
|
|
287
|
+
const graph = value;
|
|
288
|
+
return graph.documentKind === 'artifact-graph'
|
|
289
|
+
&& graph.schemaVersion === 1
|
|
290
|
+
&& typeof graph.graphId === 'string'
|
|
291
|
+
&& !!graph.source
|
|
292
|
+
&& (graph.source.sourceKind === 'doctor' || graph.source.sourceKind === 'eval' || graph.source.sourceKind === 'observe')
|
|
293
|
+
&& Array.isArray(graph.nodes)
|
|
294
|
+
&& Array.isArray(graph.edges);
|
|
295
|
+
}
|
|
296
|
+
function readArtifactGraph(path) {
|
|
297
|
+
try {
|
|
298
|
+
const parsed = JSON.parse(readFileSync(path, 'utf-8'));
|
|
299
|
+
if (!isArtifactGraphDocument(parsed))
|
|
300
|
+
return null;
|
|
301
|
+
return { graph: parsed, path };
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
function scanGraphDir(dir) {
|
|
308
|
+
if (!existsSync(dir))
|
|
309
|
+
return [];
|
|
310
|
+
const out = [];
|
|
311
|
+
for (const file of readdirSync(dir).sort()) {
|
|
312
|
+
if (!file.endsWith(GRAPH_FILE_SUFFIX))
|
|
313
|
+
continue;
|
|
314
|
+
const loaded = readArtifactGraph(join(dir, file));
|
|
315
|
+
if (loaded)
|
|
316
|
+
out.push(loaded);
|
|
317
|
+
}
|
|
318
|
+
return out;
|
|
319
|
+
}
|
|
320
|
+
function loadGraphsForSkillIndex(options) {
|
|
321
|
+
const byPath = new Map();
|
|
322
|
+
const add = (loaded) => {
|
|
323
|
+
if (loaded)
|
|
324
|
+
byPath.set(loaded.path, loaded);
|
|
325
|
+
};
|
|
326
|
+
for (const dir of uniqueStrings(options.evalGraphDirs)) {
|
|
327
|
+
for (const graph of scanGraphDir(dir))
|
|
328
|
+
add(graph);
|
|
329
|
+
}
|
|
330
|
+
for (const dir of uniqueStrings(options.doctorGraphDirs)) {
|
|
331
|
+
for (const graph of scanGraphDir(dir))
|
|
332
|
+
add(graph);
|
|
333
|
+
}
|
|
334
|
+
if (options.includeReportCards) {
|
|
335
|
+
for (const card of listLiveReportCards()) {
|
|
336
|
+
add(readArtifactGraph(graphSidecarPathForReportPath(card.path, card.id)));
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
if (options.includeDoctorCards) {
|
|
340
|
+
for (const card of listLiveDoctorCards()) {
|
|
341
|
+
add(readArtifactGraph(graphSidecarPathForDoctorCard(card.path, card.id)));
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return [...byPath.values()];
|
|
345
|
+
}
|
|
346
|
+
function graphSkillNodes(graph) {
|
|
347
|
+
return graph.nodes.filter((node) => node.nodeKind === 'skill');
|
|
348
|
+
}
|
|
349
|
+
function graphSkillNames(graph) {
|
|
350
|
+
return uniqueStrings([
|
|
351
|
+
graph.scope.skillName ?? '',
|
|
352
|
+
...graphSkillNodes(graph).map((node) => node.label),
|
|
353
|
+
]);
|
|
354
|
+
}
|
|
355
|
+
function nodeArtifactHash(node) {
|
|
356
|
+
const hash = node?.binding?.keys?.artifactHash;
|
|
357
|
+
return hash && hash !== 'no-skill' ? hash : undefined;
|
|
358
|
+
}
|
|
359
|
+
function nodeSourceLocator(node) {
|
|
360
|
+
const locator = node?.attrs?.display?.sourceLocator;
|
|
361
|
+
return typeof locator === 'string' && locator.trim() ? locator : undefined;
|
|
362
|
+
}
|
|
363
|
+
function graphSourceLocator(graph, skillNode) {
|
|
364
|
+
if (graph.source.sourceKind === 'eval') {
|
|
365
|
+
// Eval graph 的 scope.sourceLocator 是 samplesPath;skill 路径在 skill node 上。
|
|
366
|
+
return nodeSourceLocator(skillNode);
|
|
367
|
+
}
|
|
368
|
+
return graph.scope.sourceLocator ?? nodeSourceLocator(skillNode);
|
|
369
|
+
}
|
|
370
|
+
function graphNodeMap(graph) {
|
|
371
|
+
return new Map(graph.nodes.map((node) => [node.id, node]));
|
|
372
|
+
}
|
|
373
|
+
function graphHasSkill(graph, entry) {
|
|
374
|
+
const names = graphSkillNames(graph);
|
|
375
|
+
return names.includes(entry.skillName) || (entry.eval ? names.includes(entry.eval.variantName) : false);
|
|
376
|
+
}
|
|
377
|
+
function graphMatchesDoctorEntry(graph, entry) {
|
|
378
|
+
return graphHasSkill(graph, entry);
|
|
379
|
+
}
|
|
380
|
+
function evalVariantName(entry) {
|
|
381
|
+
return entry.eval?.variantName ?? null;
|
|
382
|
+
}
|
|
383
|
+
function evalVariantNode(graph, entry) {
|
|
384
|
+
const variantName = evalVariantName(entry);
|
|
385
|
+
if (!variantName)
|
|
386
|
+
return undefined;
|
|
387
|
+
return graph.nodes.find((node) => node.nodeKind === 'variant' && node.label === variantName);
|
|
388
|
+
}
|
|
389
|
+
function evalSkillNodeForVariant(graph, variantNode) {
|
|
390
|
+
const nodesById = graphNodeMap(graph);
|
|
391
|
+
return graph.edges
|
|
392
|
+
.filter((edge) => edge.fromNodeId === variantNode.id && edge.edgeKind === 'derived_from')
|
|
393
|
+
.map((edge) => nodesById.get(edge.toNodeId))
|
|
394
|
+
.find((node) => node?.nodeKind === 'skill');
|
|
395
|
+
}
|
|
396
|
+
function graphMatchesEvalEntry(graph, entry) {
|
|
397
|
+
return evalVariantNode(graph, entry) !== undefined;
|
|
398
|
+
}
|
|
399
|
+
function pickLatestGraph(graphs) {
|
|
400
|
+
if (graphs.length === 0)
|
|
401
|
+
return null;
|
|
402
|
+
return [...graphs].sort((a, b) => {
|
|
403
|
+
const at = a.graph.generatedAt || '';
|
|
404
|
+
const bt = b.graph.generatedAt || '';
|
|
405
|
+
if (at !== bt)
|
|
406
|
+
return at.localeCompare(bt);
|
|
407
|
+
return a.graph.graphId.localeCompare(b.graph.graphId);
|
|
408
|
+
})[graphs.length - 1];
|
|
409
|
+
}
|
|
410
|
+
function countGraphNodes(graph, nodeKind) {
|
|
411
|
+
return graph.nodes.filter((node) => node.nodeKind === nodeKind).length;
|
|
412
|
+
}
|
|
413
|
+
function graphNodePreview(node) {
|
|
414
|
+
return {
|
|
415
|
+
nodeKind: node.nodeKind,
|
|
416
|
+
label: node.label,
|
|
417
|
+
...(node.status ? { status: node.status } : {}),
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
function graphNodePreviews(graph, nodeKinds, nodeIds) {
|
|
421
|
+
const kinds = new Set(nodeKinds);
|
|
422
|
+
return graph.nodes
|
|
423
|
+
.filter((node) => kinds.has(node.nodeKind) && (!nodeIds || nodeIds.has(node.id)))
|
|
424
|
+
.map(graphNodePreview);
|
|
425
|
+
}
|
|
426
|
+
function projectDoctorStage(graph, path) {
|
|
427
|
+
if (graph.source.sourceKind !== 'doctor')
|
|
428
|
+
return undefined;
|
|
429
|
+
const skillNode = graphSkillNodes(graph)[0];
|
|
430
|
+
const sourceLocator = graphSourceLocator(graph, skillNode);
|
|
431
|
+
const base = {
|
|
432
|
+
sourceKind: 'doctor',
|
|
433
|
+
sourceId: graph.source.sourceId,
|
|
434
|
+
graphId: graph.graphId,
|
|
435
|
+
generatedAt: graph.generatedAt,
|
|
436
|
+
graphPath: path,
|
|
437
|
+
nodeCount: graph.nodes.length,
|
|
438
|
+
edgeCount: graph.edges.length,
|
|
439
|
+
};
|
|
440
|
+
return {
|
|
441
|
+
stage: {
|
|
442
|
+
...base,
|
|
443
|
+
references: countGraphNodes(graph, 'reference'),
|
|
444
|
+
scripts: countGraphNodes(graph, 'script'),
|
|
445
|
+
workflows: countGraphNodes(graph, 'workflow'),
|
|
446
|
+
workflowNodes: countGraphNodes(graph, 'workflow_node'),
|
|
447
|
+
hardRules: countGraphNodes(graph, 'hard_rule'),
|
|
448
|
+
definitionNodes: graphNodePreviews(graph, [
|
|
449
|
+
'skill_file',
|
|
450
|
+
'frontmatter',
|
|
451
|
+
'reference',
|
|
452
|
+
'script',
|
|
453
|
+
'preflight',
|
|
454
|
+
'tool',
|
|
455
|
+
'hard_rule',
|
|
456
|
+
'workflow',
|
|
457
|
+
'workflow_node',
|
|
458
|
+
'doctor_rule_result',
|
|
459
|
+
]),
|
|
460
|
+
},
|
|
461
|
+
...(graph.scope.artifactHash ? { artifactHash: graph.scope.artifactHash } : {}),
|
|
462
|
+
...(sourceLocator ? { sourceLocator } : {}),
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
function projectEvalStage(graph, path, entry) {
|
|
466
|
+
if (graph.source.sourceKind !== 'eval')
|
|
467
|
+
return undefined;
|
|
468
|
+
const variantNode = evalVariantNode(graph, entry);
|
|
469
|
+
if (!variantNode)
|
|
470
|
+
return undefined;
|
|
471
|
+
const skillNode = evalSkillNodeForVariant(graph, variantNode);
|
|
472
|
+
const artifactHash = nodeArtifactHash(variantNode) ?? nodeArtifactHash(skillNode);
|
|
473
|
+
const sourceLocator = graphSourceLocator(graph, skillNode);
|
|
474
|
+
const nodesById = graphNodeMap(graph);
|
|
475
|
+
const projectedNodeIds = new Set([variantNode.id]);
|
|
476
|
+
const projectedEdgeIds = new Set();
|
|
477
|
+
if (skillNode)
|
|
478
|
+
projectedNodeIds.add(skillNode.id);
|
|
479
|
+
const addEdge = (edge) => {
|
|
480
|
+
projectedEdgeIds.add(edge.id);
|
|
481
|
+
projectedNodeIds.add(edge.fromNodeId);
|
|
482
|
+
projectedNodeIds.add(edge.toNodeId);
|
|
483
|
+
};
|
|
484
|
+
for (const edge of graph.edges) {
|
|
485
|
+
if (edge.fromNodeId === variantNode.id && (edge.edgeKind === 'evaluates' || edge.edgeKind === 'derived_from')) {
|
|
486
|
+
addEdge(edge);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
const evalResultNodes = graph.edges
|
|
490
|
+
.filter((edge) => edge.toNodeId === variantNode.id && edge.edgeKind === 'derived_from')
|
|
491
|
+
.map((edge) => nodesById.get(edge.fromNodeId))
|
|
492
|
+
.filter((node) => node?.nodeKind === 'eval_result');
|
|
493
|
+
for (const node of evalResultNodes)
|
|
494
|
+
projectedNodeIds.add(node.id);
|
|
495
|
+
for (const edge of graph.edges) {
|
|
496
|
+
if (!evalResultNodes.some((node) => node.id === edge.fromNodeId || node.id === edge.toNodeId))
|
|
497
|
+
continue;
|
|
498
|
+
if (edge.edgeKind === 'derived_from' || edge.edgeKind === 'evaluates' || edge.edgeKind === 'passes' || edge.edgeKind === 'fails' || edge.edgeKind === 'diagnoses') {
|
|
499
|
+
addEdge(edge);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
const sampleIds = new Set();
|
|
503
|
+
const assertionIds = new Set();
|
|
504
|
+
const diagnosticIds = new Set();
|
|
505
|
+
let failedAssertionEdges = 0;
|
|
506
|
+
for (const edge of graph.edges) {
|
|
507
|
+
if (!projectedEdgeIds.has(edge.id))
|
|
508
|
+
continue;
|
|
509
|
+
const from = nodesById.get(edge.fromNodeId);
|
|
510
|
+
const to = nodesById.get(edge.toNodeId);
|
|
511
|
+
if (from?.nodeKind === 'sample')
|
|
512
|
+
sampleIds.add(from.id);
|
|
513
|
+
if (to?.nodeKind === 'sample')
|
|
514
|
+
sampleIds.add(to.id);
|
|
515
|
+
if (from?.nodeKind === 'assertion')
|
|
516
|
+
assertionIds.add(from.id);
|
|
517
|
+
if (to?.nodeKind === 'assertion')
|
|
518
|
+
assertionIds.add(to.id);
|
|
519
|
+
if (from?.nodeKind === 'diagnostic')
|
|
520
|
+
diagnosticIds.add(from.id);
|
|
521
|
+
if (to?.nodeKind === 'diagnostic')
|
|
522
|
+
diagnosticIds.add(to.id);
|
|
523
|
+
if (edge.edgeKind === 'fails')
|
|
524
|
+
failedAssertionEdges += 1;
|
|
525
|
+
}
|
|
526
|
+
const measurementNodes = graphNodePreviews(graph, [
|
|
527
|
+
'sample',
|
|
528
|
+
'assertion',
|
|
529
|
+
'eval_result',
|
|
530
|
+
'diagnostic',
|
|
531
|
+
], projectedNodeIds);
|
|
532
|
+
return {
|
|
533
|
+
stage: {
|
|
534
|
+
sourceKind: 'eval',
|
|
535
|
+
sourceId: graph.source.sourceId,
|
|
536
|
+
graphId: graph.graphId,
|
|
537
|
+
generatedAt: graph.generatedAt,
|
|
538
|
+
graphPath: path,
|
|
539
|
+
nodeCount: projectedNodeIds.size,
|
|
540
|
+
edgeCount: projectedEdgeIds.size,
|
|
541
|
+
variantName: variantNode.label,
|
|
542
|
+
samples: sampleIds.size,
|
|
543
|
+
assertions: assertionIds.size,
|
|
544
|
+
failedAssertionEdges,
|
|
545
|
+
diagnostics: diagnosticIds.size,
|
|
546
|
+
measurementNodes,
|
|
547
|
+
},
|
|
548
|
+
...(artifactHash ? { artifactHash } : {}),
|
|
549
|
+
...(sourceLocator ? { sourceLocator } : {}),
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
function graphSnapshotBinding(stages) {
|
|
553
|
+
const hashes = uniqueStrings(stages.map((stage) => stage.artifactHash ?? ''));
|
|
554
|
+
const locators = uniqueStrings(stages.map((stage) => stage.sourceLocator ?? ''));
|
|
555
|
+
if (stages.length > 0 && hashes.length === 1 && stages.every((stage) => stage.artifactHash === hashes[0])) {
|
|
556
|
+
return { bindingStrength: 'content-hash', artifactHash: hashes[0], ...(locators[0] ? { sourceLocator: locators[0] } : {}) };
|
|
557
|
+
}
|
|
558
|
+
if (hashes.length > 0) {
|
|
559
|
+
return { bindingStrength: 'mixed', ...(locators[0] ? { sourceLocator: locators[0] } : {}) };
|
|
560
|
+
}
|
|
561
|
+
if (locators.length > 0) {
|
|
562
|
+
return { bindingStrength: 'source-locator', sourceLocator: locators[0] };
|
|
563
|
+
}
|
|
564
|
+
return { bindingStrength: 'name-only' };
|
|
565
|
+
}
|
|
566
|
+
function graphSnapshotForEntry(entry, graphs) {
|
|
567
|
+
const doctorCandidates = graphs.filter(({ graph }) => graph.source.sourceKind === 'doctor'
|
|
568
|
+
&& (!entry.doctor || graph.source.sourceId === entry.doctor.reportId)
|
|
569
|
+
&& graphMatchesDoctorEntry(graph, entry));
|
|
570
|
+
const evalCandidates = graphs.filter(({ graph }) => graph.source.sourceKind === 'eval'
|
|
571
|
+
&& (!entry.eval || graph.source.sourceId === entry.eval.reportId)
|
|
572
|
+
&& graphMatchesEvalEntry(graph, entry));
|
|
573
|
+
const doctor = pickLatestGraph(doctorCandidates);
|
|
574
|
+
const evalGraph = pickLatestGraph(evalCandidates);
|
|
575
|
+
if (!doctor && !evalGraph)
|
|
576
|
+
return undefined;
|
|
577
|
+
const doctorStage = doctor ? projectDoctorStage(doctor.graph, doctor.path) : undefined;
|
|
578
|
+
const evalStage = evalGraph ? projectEvalStage(evalGraph.graph, evalGraph.path, entry) : undefined;
|
|
579
|
+
const projectedStages = [];
|
|
580
|
+
if (doctorStage)
|
|
581
|
+
projectedStages.push(doctorStage);
|
|
582
|
+
if (evalStage)
|
|
583
|
+
projectedStages.push(evalStage);
|
|
584
|
+
const binding = graphSnapshotBinding(projectedStages);
|
|
585
|
+
return {
|
|
586
|
+
...binding,
|
|
587
|
+
...(doctorStage ? { doctor: doctorStage.stage } : {}),
|
|
588
|
+
...(evalStage ? { eval: evalStage.stage } : {}),
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
/** 扫 doctorsDir/*.report.json,按 skill 名分桶,**返回该 skill 的所有历史 snapshot**(asc 时序)。
|
|
228
592
|
* renderer 用最后一项做"当前",前面项画 sparkline。 */
|
|
229
593
|
function scanDoctorReports(dir) {
|
|
230
594
|
const out = {};
|
|
595
|
+
migrateLegacyReportFiles(dir, 'doctor');
|
|
231
596
|
if (!existsSync(dir))
|
|
232
597
|
return out;
|
|
233
598
|
for (const file of readdirSync(dir)) {
|
|
234
|
-
if (!file
|
|
599
|
+
if (!isReportFileName(file))
|
|
235
600
|
continue;
|
|
236
601
|
try {
|
|
237
602
|
const data = JSON.parse(readFileSync(join(dir, file), 'utf-8'));
|
|
@@ -261,8 +626,15 @@ function scanDoctorReports(dir) {
|
|
|
261
626
|
export function buildSkillIndex(reports, analysesDir, doctorsDir, observationsDir = DEFAULT_OBSERVATIONS_DIR, opts = {}) {
|
|
262
627
|
const includeObserveCards = opts.includeObserveCards ?? false;
|
|
263
628
|
const includeDoctorCards = opts.includeDoctorCards ?? false;
|
|
629
|
+
const includeReportCards = opts.includeReportCards ?? false;
|
|
630
|
+
const evalGraphDirs = uniqueStrings(opts.evalGraphDirs ?? []);
|
|
631
|
+
const doctorGraphDirs = uniqueStrings([
|
|
632
|
+
doctorGraphDirForDoctorOutput(doctorsDir),
|
|
633
|
+
...(opts.doctorGraphDirs ?? []),
|
|
634
|
+
]);
|
|
635
|
+
const graphFingerprint = buildGraphFingerprint(evalGraphDirs, doctorGraphDirs, includeReportCards, includeDoctorCards);
|
|
264
636
|
// 命中缓存就直接返回(fingerprint 覆盖 reports + 两个 dir + 卡片 include 模式的变化信号)
|
|
265
|
-
const fp = buildIndexFingerprint(reports, analysesDir, doctorsDir, observationsDir, includeObserveCards, includeDoctorCards);
|
|
637
|
+
const fp = buildIndexFingerprint(reports, analysesDir, doctorsDir, observationsDir, includeObserveCards, includeDoctorCards, graphFingerprint);
|
|
266
638
|
if (_indexCache && _indexCache.fingerprint === fp) {
|
|
267
639
|
return _indexCache.result;
|
|
268
640
|
}
|
|
@@ -288,16 +660,17 @@ export function buildSkillIndex(reports, analysesDir, doctorsDir, observationsDi
|
|
|
288
660
|
list.sort((a, b) => evalSnapshotSortKey(a).localeCompare(evalSnapshotSortKey(b)));
|
|
289
661
|
// ── observe 聚合(历史 list)──────────────────────────────
|
|
290
662
|
const observeBy = {};
|
|
663
|
+
migrateLegacyReportFiles(analysesDir, 'observe-health');
|
|
291
664
|
if (existsSync(analysesDir)) {
|
|
292
665
|
for (const file of readdirSync(analysesDir)) {
|
|
293
|
-
|
|
666
|
+
const id = reportFileStem(file);
|
|
667
|
+
if (!id)
|
|
294
668
|
continue;
|
|
295
669
|
try {
|
|
296
670
|
const data = JSON.parse(readFileSync(join(analysesDir, file), 'utf-8'));
|
|
297
671
|
if (!data?.bySkill || !data.meta)
|
|
298
672
|
continue;
|
|
299
673
|
const generatedAt = data.meta.generatedAt;
|
|
300
|
-
const id = file.replace(/\.json$/, '');
|
|
301
674
|
for (const [skill, h] of Object.entries(data.bySkill)) {
|
|
302
675
|
const snap = {
|
|
303
676
|
analysisId: id, generatedAt,
|
|
@@ -404,6 +777,17 @@ export function buildSkillIndex(reports, analysesDir, doctorsDir, observationsDi
|
|
|
404
777
|
else if (hasMed)
|
|
405
778
|
ent.band = 'yellow';
|
|
406
779
|
}
|
|
780
|
+
const graphs = loadGraphsForSkillIndex({
|
|
781
|
+
evalGraphDirs,
|
|
782
|
+
doctorGraphDirs,
|
|
783
|
+
includeReportCards,
|
|
784
|
+
includeDoctorCards,
|
|
785
|
+
});
|
|
786
|
+
for (const ent of entries) {
|
|
787
|
+
const graph = graphSnapshotForEntry(ent, graphs);
|
|
788
|
+
if (graph)
|
|
789
|
+
ent.graph = graph;
|
|
790
|
+
}
|
|
407
791
|
summary.red = entries.filter((e) => e.band === 'red').length;
|
|
408
792
|
summary.yellow = entries.filter((e) => e.band === 'yellow').length;
|
|
409
793
|
summary.green = entries.filter((e) => e.band === 'green').length;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
export interface ArtifactGraphDocument {
|
|
2
|
+
documentKind: 'artifact-graph';
|
|
3
|
+
schemaVersion: 1;
|
|
4
|
+
graphId: string;
|
|
5
|
+
generatedAt: string;
|
|
6
|
+
source: ArtifactGraphSource;
|
|
7
|
+
scope: ArtifactGraphScope;
|
|
8
|
+
nodes: ArtifactGraphNode[];
|
|
9
|
+
edges: ArtifactGraphEdge[];
|
|
10
|
+
summaries?: ArtifactGraphSummary[];
|
|
11
|
+
}
|
|
12
|
+
export type ArtifactGraphSourceKind = 'doctor' | 'eval' | 'observe';
|
|
13
|
+
export interface ArtifactGraphSource {
|
|
14
|
+
sourceKind: ArtifactGraphSourceKind;
|
|
15
|
+
sourceId: string;
|
|
16
|
+
sourcePath?: string;
|
|
17
|
+
cliVersion?: string;
|
|
18
|
+
}
|
|
19
|
+
export interface ArtifactGraphScope {
|
|
20
|
+
cwd: string;
|
|
21
|
+
artifactKind?: 'skill' | 'prompt' | 'agent' | 'workflow';
|
|
22
|
+
skillName?: string;
|
|
23
|
+
artifactHash?: string;
|
|
24
|
+
sourceLocator?: string;
|
|
25
|
+
sampleSetHash?: string;
|
|
26
|
+
}
|
|
27
|
+
export type ArtifactGraphLayer = 'definition' | 'measurement' | 'production';
|
|
28
|
+
export type ArtifactGraphNodeRole = 'entity' | 'observation' | 'aggregate';
|
|
29
|
+
export type ArtifactGraphNodeKind = 'skill' | 'skill_file' | 'frontmatter' | 'reference' | 'script' | 'tool' | 'env' | 'preflight' | 'hard_rule' | 'workflow' | 'workflow_node' | 'sample' | 'assertion' | 'variant' | 'doctor_rule_result' | 'eval_result' | 'judge_dimension' | 'diagnostic' | 'trace_session' | 'skill_invocation' | 'tool_call' | 'gap_signal';
|
|
30
|
+
export type ArtifactGraphStatus = 'ok' | 'warning' | 'failed' | 'skipped' | 'unknown' | 'not_measured';
|
|
31
|
+
export interface ArtifactGraphBinding {
|
|
32
|
+
bindingStrength: 'content-hash' | 'source-locator' | 'runtime-trace' | 'name-only' | 'aggregate';
|
|
33
|
+
keys: Record<string, string>;
|
|
34
|
+
}
|
|
35
|
+
export interface ArtifactGraphAttrs {
|
|
36
|
+
display?: Record<string, unknown>;
|
|
37
|
+
producer?: Record<string, unknown>;
|
|
38
|
+
experimental?: Record<string, unknown>;
|
|
39
|
+
}
|
|
40
|
+
export interface ArtifactGraphNode {
|
|
41
|
+
id: string;
|
|
42
|
+
stableKey: string;
|
|
43
|
+
nodeKind: ArtifactGraphNodeKind;
|
|
44
|
+
nodeRole: ArtifactGraphNodeRole;
|
|
45
|
+
layer: ArtifactGraphLayer;
|
|
46
|
+
label: string;
|
|
47
|
+
status?: ArtifactGraphStatus;
|
|
48
|
+
confidence?: number;
|
|
49
|
+
binding?: ArtifactGraphBinding;
|
|
50
|
+
metrics?: Record<string, number>;
|
|
51
|
+
attrs?: ArtifactGraphAttrs;
|
|
52
|
+
evidenceRefs?: ArtifactGraphEvidenceRef[];
|
|
53
|
+
}
|
|
54
|
+
export type ArtifactGraphEdgeKind = 'contains' | 'declares' | 'requires' | 'references' | 'defines_workflow' | 'next_step' | 'covers' | 'evaluates' | 'passes' | 'fails' | 'diagnoses' | 'invokes' | 'calls_tool' | 'observes' | 'signals_gap' | 'derived_from';
|
|
55
|
+
export interface ArtifactGraphEdge {
|
|
56
|
+
id: string;
|
|
57
|
+
fromNodeId: string;
|
|
58
|
+
toNodeId: string;
|
|
59
|
+
edgeKind: ArtifactGraphEdgeKind;
|
|
60
|
+
layer: ArtifactGraphLayer;
|
|
61
|
+
label?: string;
|
|
62
|
+
status?: ArtifactGraphStatus;
|
|
63
|
+
confidence?: number;
|
|
64
|
+
weight?: number;
|
|
65
|
+
binding?: ArtifactGraphBinding;
|
|
66
|
+
metrics?: Record<string, number>;
|
|
67
|
+
attrs?: ArtifactGraphAttrs;
|
|
68
|
+
evidenceRefs?: ArtifactGraphEvidenceRef[];
|
|
69
|
+
}
|
|
70
|
+
export type ArtifactGraphEvidenceSourceKind = 'skill-file' | 'doctor-report' | 'eval-report' | 'observe-report' | 'trace' | 'sample' | 'managed-record';
|
|
71
|
+
export type ArtifactGraphEvidenceSelectorKind = 'json-pointer' | 'line-range' | 'sample-id' | 'rule-id' | 'trace-event-id' | 'node-id';
|
|
72
|
+
export interface ArtifactGraphEvidenceSelector {
|
|
73
|
+
selectorKind: ArtifactGraphEvidenceSelectorKind;
|
|
74
|
+
value: string;
|
|
75
|
+
}
|
|
76
|
+
export interface ArtifactGraphEvidenceRef {
|
|
77
|
+
sourceKind: ArtifactGraphEvidenceSourceKind;
|
|
78
|
+
sourceId?: string;
|
|
79
|
+
path?: string;
|
|
80
|
+
selector?: ArtifactGraphEvidenceSelector;
|
|
81
|
+
contentHash?: string;
|
|
82
|
+
label?: string;
|
|
83
|
+
snippet?: string;
|
|
84
|
+
redaction?: 'none' | 'truncated' | 'redacted';
|
|
85
|
+
}
|
|
86
|
+
export interface ArtifactGraphSummary {
|
|
87
|
+
summaryKind: 'structure' | 'coverage' | 'risk' | 'gap' | 'workflow' | 'collection';
|
|
88
|
+
title: string;
|
|
89
|
+
severity: 'info' | 'low' | 'medium' | 'high';
|
|
90
|
+
nodeIds?: string[];
|
|
91
|
+
edgeIds?: string[];
|
|
92
|
+
evidenceRefs?: ArtifactGraphEvidenceRef[];
|
|
93
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/types/index.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ export * from './report.js';
|
|
|
6
6
|
export * from './storage.js';
|
|
7
7
|
export * from './doctor.js';
|
|
8
8
|
export * from './diagnosis.js';
|
|
9
|
+
export * from './artifact-graph.js';
|
|
9
10
|
export * from './dependencies.js';
|
|
10
11
|
export * from './skill-index.js';
|
|
11
12
|
export * from './observability.js';
|
package/dist/types/index.js
CHANGED
|
@@ -6,6 +6,7 @@ export * from './report.js';
|
|
|
6
6
|
export * from './storage.js';
|
|
7
7
|
export * from './doctor.js';
|
|
8
8
|
export * from './diagnosis.js';
|
|
9
|
+
export * from './artifact-graph.js';
|
|
9
10
|
export * from './dependencies.js';
|
|
10
11
|
export * from './skill-index.js';
|
|
11
12
|
export * from './observability.js';
|
|
@@ -42,6 +42,42 @@ export interface SkillObserveSnapshot {
|
|
|
42
42
|
* 历史快照缺此字段时由 segmentCount 兜底推导。 */
|
|
43
43
|
confidence: 'high' | 'low' | 'underpowered';
|
|
44
44
|
}
|
|
45
|
+
export interface SkillGraphStageSnapshot {
|
|
46
|
+
sourceKind: 'doctor' | 'eval' | 'observe';
|
|
47
|
+
sourceId: string;
|
|
48
|
+
graphId: string;
|
|
49
|
+
generatedAt: string;
|
|
50
|
+
graphPath?: string;
|
|
51
|
+
nodeCount: number;
|
|
52
|
+
edgeCount: number;
|
|
53
|
+
}
|
|
54
|
+
export interface SkillGraphNodePreview {
|
|
55
|
+
nodeKind: string;
|
|
56
|
+
label: string;
|
|
57
|
+
status?: string;
|
|
58
|
+
}
|
|
59
|
+
export interface SkillGraphSnapshot {
|
|
60
|
+
/** Studio 聚合 graph sidecar 时实际采用的绑定强度。 */
|
|
61
|
+
bindingStrength: 'content-hash' | 'source-locator' | 'name-only' | 'mixed';
|
|
62
|
+
artifactHash?: string;
|
|
63
|
+
sourceLocator?: string;
|
|
64
|
+
doctor?: SkillGraphStageSnapshot & {
|
|
65
|
+
references: number;
|
|
66
|
+
scripts: number;
|
|
67
|
+
workflows: number;
|
|
68
|
+
workflowNodes: number;
|
|
69
|
+
hardRules: number;
|
|
70
|
+
definitionNodes: SkillGraphNodePreview[];
|
|
71
|
+
};
|
|
72
|
+
eval?: SkillGraphStageSnapshot & {
|
|
73
|
+
variantName?: string;
|
|
74
|
+
samples: number;
|
|
75
|
+
assertions: number;
|
|
76
|
+
failedAssertionEdges: number;
|
|
77
|
+
diagnostics: number;
|
|
78
|
+
measurementNodes: SkillGraphNodePreview[];
|
|
79
|
+
};
|
|
80
|
+
}
|
|
45
81
|
export interface SkillIndexEntry {
|
|
46
82
|
skillName: string;
|
|
47
83
|
/** 当前(最新)snapshot — 等价于对应 history 的最后一项,空时为 null。renderer
|
|
@@ -56,6 +92,8 @@ export interface SkillIndexEntry {
|
|
|
56
92
|
/** 综合健康灯。doctor / eval / observe 任一红 → red;任一黄 → yellow;
|
|
57
93
|
* 全绿 → green;皆未跑 → gray。 */
|
|
58
94
|
band: 'green' | 'yellow' | 'red' | 'gray';
|
|
95
|
+
/** doctor/eval/observe graph sidecar 的轻量 Studio 投影。 */
|
|
96
|
+
graph?: SkillGraphSnapshot;
|
|
59
97
|
}
|
|
60
98
|
export interface SkillIndexSummary {
|
|
61
99
|
totalSkills: number;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "oh-my-knowledge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.44.0",
|
|
4
4
|
"packageManager": "yarn@4.16.0",
|
|
5
5
|
"description": "Evaluation framework for LLM knowledge inputs — prompts, RAG corpora, skills, agent workflows. Fix the model, vary the artifact. Built-in statistical rigor: bootstrap CI, Krippendorff α, length-debias, saturation curves.",
|
|
6
6
|
"type": "module",
|