oh-my-knowledge 0.43.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.
@@ -134,13 +134,8 @@ function renderPanel(a, lang) {
134
134
  // renderSkillModal 已移除:首页点行直接进 skill 详情页(doctor/eval/observe 已在该页合一),弹框预览冗余。
135
135
  function renderRow(entry, insights, langQ, lang) {
136
136
  const h = assessHealth(entry, insights, lang);
137
- // 点行直接进该 skill 最新 eval 报告(无 eval 则最新 doctor);跨维度/历史在报告页内切换。
138
- const amp = lang === DEFAULT_LANG ? '' : `&lang=${lang}`;
139
- const href = entry.eval
140
- ? `/reports/${encodeURIComponent(entry.eval.reportId)}${langQ}`
141
- : entry.doctor
142
- ? `/doctors/${encodeURIComponent(entry.doctor.reportId)}?skill=${encodeURIComponent(entry.skillName)}${amp}`
143
- : `/runs${langQ}`;
137
+ // 点行先进入 skill hub:Skill Map / 三阶段状态 / Evidence Card 是用户第一视角。
138
+ const href = `/skills/${encodeURIComponent(entry.skillName)}${langQ}`;
144
139
  const dT = entry.doctor ? entry.doctor.passCount + entry.doctor.warnCount + entry.doctor.failCount : 0;
145
140
  const dP = dT > 0 ? Math.round(((entry.doctor.passCount + entry.doctor.warnCount * 0.5) / dT) * 100) : null;
146
141
  const eP = entry.eval?.compositeScore != null ? Math.round((entry.eval.compositeScore / 5) * 100) : null;
@@ -6,13 +6,14 @@ import { renderReportDocumentDetail, renderTrendsPage, renderRunList } from '../
6
6
  import { renderSkillList } from '../renderer/skill-list-renderer.js';
7
7
  import { renderSkillHealthReport } from '../renderer/skill-health-renderer.js';
8
8
  import { renderDoctorDetail } from '../renderer/doctor-detail-renderer.js';
9
- import { assessHealth } from '../renderer/skill-detail-renderer.js';
9
+ import { assessHealth, renderSkillDetail } from '../renderer/skill-detail-renderer.js';
10
10
  import { renderObservationInboxPage } from '../renderer/observation-inbox-renderer.js';
11
11
  import { DEFAULT_LANG, t, layout } from '../renderer/layout.js';
12
12
  import { loadAllManagedRecords, resolveManagedDir, managedDir as projectManagedDir, listManagedRows, buildVersionScores } from '../managed/index.js';
13
13
  import { renderManagedList, renderManagedHistory } from '../renderer/managed-history-renderer.js';
14
14
  import { DEFAULT_JOBS_DIR } from '../eval-core/default-dirs.js';
15
15
  import { resolveObserveHealthDir, projectObserveHealthDir, resolveDoctorsDir, projectDoctorsDir, projectReportsDir, globalReportsDir } from '../eval-core/measurement-dirs.js';
16
+ import { evalGraphDirForReportOutput } from '../artifact-graph/eval.js';
16
17
  import { listObserveCards, listDoctorCards, listLiveObserveCards } from '../eval-core/artifact-index.js';
17
18
  import { isReportFileName, reportFilePath, reportFileStem } from '../eval-core/artifact-file-names.js';
18
19
  import { migrateLegacyReportFiles } from '../eval-core/report-file-migration.js';
@@ -602,6 +603,18 @@ export function createReportServer({ port, host: hostOption, reportsDir, analyse
602
603
  : doctorsDir !== undefined
603
604
  ? () => doctorsDir
604
605
  : () => resolveDoctorsDir(projectDoctorsDir());
606
+ const includeReportCards = reportsDir === undefined && store == null;
607
+ const evalGraphDirs = reportsDir !== undefined
608
+ ? [evalGraphDirForReportOutput(reportsDir)]
609
+ : store
610
+ ? []
611
+ : [evalGraphDirForReportOutput(projectReportsDir()), evalGraphDirForReportOutput(globalReportsDir())];
612
+ const skillIndexOptions = () => ({
613
+ includeObserveCards,
614
+ includeDoctorCards,
615
+ includeReportCards,
616
+ evalGraphDirs,
617
+ });
605
618
  async function handleRequest(req, res) {
606
619
  try {
607
620
  const parsed = new URL(req.url || '/', 'http://127.0.0.1');
@@ -752,7 +765,7 @@ export function createReportServer({ port, host: hostOption, reportsDir, analyse
752
765
  }
753
766
  if (path === '/api/observe-inbox/diagnostics') {
754
767
  const runs = await reportStore.list();
755
- const idx = buildSkillIndex(runs, analysesDir, doctorsDir, observationsDir, { includeObserveCards, includeDoctorCards });
768
+ const idx = buildSkillIndex(runs, analysesDir, doctorsDir, observationsDir, skillIndexOptions());
756
769
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
757
770
  res.end(JSON.stringify({
758
771
  sourceCoverage: idx.diagnosisSummary.sourceCoverage,
@@ -838,7 +851,7 @@ export function createReportServer({ port, host: hostOption, reportsDir, analyse
838
851
  let ctx;
839
852
  if (skillName) {
840
853
  const runs = await reportStore.list();
841
- const idx = buildSkillIndex(runs, analysesDir, doctorsDir, observationsDir, { includeObserveCards, includeDoctorCards });
854
+ const idx = buildSkillIndex(runs, analysesDir, doctorsDir, observationsDir, skillIndexOptions());
842
855
  const entry = idx.entries.find((en) => en.skillName === skillName);
843
856
  if (entry)
844
857
  ctx = buildSkillContext(entry, 'doctor', id, idx.insightsBySkill.get(entry.skillName) ?? [], lang);
@@ -1000,7 +1013,7 @@ export function createReportServer({ port, host: hostOption, reportsDir, analyse
1000
1013
  let ctx;
1001
1014
  if (report && report.kind === 'evaluation') {
1002
1015
  const runs = await reportStore.list();
1003
- const idx = buildSkillIndex(runs, analysesDir, doctorsDir, observationsDir, { includeObserveCards, includeDoctorCards });
1016
+ const idx = buildSkillIndex(runs, analysesDir, doctorsDir, observationsDir, skillIndexOptions());
1004
1017
  // 按 evalHistory 匹配(非仅最新),历史 eval 报告也能定位到所属 skill。
1005
1018
  const entry = idx.entries.find((en) => en.evalHistory.some((h) => h.reportId === reportId));
1006
1019
  if (entry)
@@ -1022,14 +1035,14 @@ export function createReportServer({ port, host: hostOption, reportsDir, analyse
1022
1035
  // list renderer 直接消费,不再每请求 N×detectInsights 重算。
1023
1036
  if (path === '/') {
1024
1037
  const runs = await reportStore.list();
1025
- const idx = buildSkillIndex(runs, analysesDir, doctorsDir, observationsDir, { includeObserveCards, includeDoctorCards });
1038
+ const idx = buildSkillIndex(runs, analysesDir, doctorsDir, observationsDir, skillIndexOptions());
1026
1039
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
1027
1040
  res.end(renderSkillList(idx, lang));
1028
1041
  return;
1029
1042
  }
1030
1043
  if (path === '/api/skills') {
1031
1044
  const runs = await reportStore.list();
1032
- const idx = buildSkillIndex(runs, analysesDir, doctorsDir, observationsDir, { includeObserveCards, includeDoctorCards });
1045
+ const idx = buildSkillIndex(runs, analysesDir, doctorsDir, observationsDir, skillIndexOptions());
1033
1046
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
1034
1047
  res.end(JSON.stringify({
1035
1048
  entries: idx.entries.map((entry) => ({
@@ -1042,15 +1055,36 @@ export function createReportServer({ port, host: hostOption, reportsDir, analyse
1042
1055
  }));
1043
1056
  return;
1044
1057
  }
1045
- // 注:/skills/<name> 详情页(hub)已下线 — 首页点行直接进 eval/doctor 报告,跨维度靠
1046
- // 报告页 chip 条 +「全部历史」弹框,不再有中间汇总页。该路由不做兼容重定向:Studio
1047
- // 是本地临时服务器、端口每次启动都变,跨会话书签 localhost:<port> 本就失效,且 PR 内
1048
- // 已无任何链接指向它。未匹配的 /skills/<name> 直接落到下方通用 404。
1058
+ const skillHubMatch = path.match(/^\/skills\/(.+)$/);
1059
+ if (skillHubMatch) {
1060
+ let skillName;
1061
+ try {
1062
+ skillName = decodeURIComponent(skillHubMatch[1]);
1063
+ }
1064
+ catch {
1065
+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
1066
+ res.end(lang === 'en' ? 'skill not found' : '未找到该 skill');
1067
+ return;
1068
+ }
1069
+ const runs = await reportStore.list();
1070
+ const idx = buildSkillIndex(runs, analysesDir, doctorsDir, observationsDir, skillIndexOptions());
1071
+ const entry = idx.entries.find((en) => en.skillName === skillName);
1072
+ if (!entry) {
1073
+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
1074
+ res.end(lang === 'en' ? 'skill not found' : '未找到该 skill');
1075
+ return;
1076
+ }
1077
+ const report = entry.eval ? await queryRun(reportStore, entry.eval.reportId) : null;
1078
+ const evalReport = report?.kind === 'evaluation' ? report : null;
1079
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
1080
+ res.end(renderSkillDetail(entry, evalReport, lang, idx.insightsBySkill.get(entry.skillName) ?? []));
1081
+ return;
1082
+ }
1049
1083
  const skillDiagnosticsApiMatch = path.match(/^\/api\/skills\/(.+)\/diagnostics$/);
1050
1084
  if (skillDiagnosticsApiMatch) {
1051
1085
  const skillName = decodeURIComponent(skillDiagnosticsApiMatch[1]);
1052
1086
  const runs = await reportStore.list();
1053
- const idx = buildSkillIndex(runs, analysesDir, doctorsDir, observationsDir, { includeObserveCards, includeDoctorCards });
1087
+ const idx = buildSkillIndex(runs, analysesDir, doctorsDir, observationsDir, skillIndexOptions());
1054
1088
  const diagnostics = idx.diagnosticsBySkill.get(skillName);
1055
1089
  if (!diagnostics) {
1056
1090
  res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' });
@@ -13,6 +13,12 @@ export interface BuildSkillIndexOptions {
13
13
  includeObserveCards?: boolean;
14
14
  /** 合并别项目 doctor 卡片(机器级模式)。固定 --doctors-dir / --global 时为 false,只看该目录。 */
15
15
  includeDoctorCards?: boolean;
16
+ /** 合并别项目 eval report 卡片对应的 graph sidecar。 */
17
+ includeReportCards?: boolean;
18
+ /** 当前可见 eval graph sidecar 目录。 */
19
+ evalGraphDirs?: string[];
20
+ /** 当前可见 doctor graph sidecar 目录。默认跟随 doctorsDir 推导。 */
21
+ doctorGraphDirs?: string[];
16
22
  }
17
23
  export declare function buildSkillIndex(reports: ReportDocument[], analysesDir: string, doctorsDir: string, observationsDir?: string, opts?: BuildSkillIndexOptions): SkillIndex;
18
24
  /** 单 skill 详情查询(用于 /skills/&lt;name&gt; 详情页)— 复用 buildSkillIndex 的扫描结果。 */
@@ -15,11 +15,13 @@
15
15
  */
16
16
  import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
17
17
  import { join, isAbsolute, basename, dirname } from 'node:path';
18
- import { isReportFileName, reportFileStem } from '../eval-core/artifact-file-names.js';
18
+ import { GRAPH_FILE_SUFFIX, graphFileName, isReportFileName, reportFileStem } from '../eval-core/artifact-file-names.js';
19
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';
@@ -64,7 +66,59 @@ function safeDirJsonContentFingerprint(dir) {
64
66
  });
65
67
  return `${dir}|${dirMtimeMs}|${fileParts.join(',')}`;
66
68
  }
67
- function buildIndexFingerprint(reports, analysesDir, doctorsDir, observationsDir, includeObserveCards, includeDoctorCards) {
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}" 双标量升级成
@@ -83,7 +137,7 @@ function buildIndexFingerprint(reports, analysesDir, doctorsDir, observationsDir
83
137
  // 卡片目录指纹 + 真身存在性 sentinel 都纳入:后者使「卡片真身被带外删」也能让缓存失效(否则悬空卡片继续展示)。
84
138
  const doctorCardsFp = includeDoctorCards ? `${safeDirJsonContentFingerprint(artifactIndexDir('doctor'))}|t:${cardTargetSentinel('doctor')}` : '';
85
139
  const observeCardsFp = includeObserveCards ? `${safeDirJsonContentFingerprint(artifactIndexDir('observe-health'))}|t:${cardTargetSentinel('observe-health')}` : '';
86
- 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' : ''}`;
87
141
  }
88
142
  /** 测试 / 调试用:强制清掉 in-process skill-index 缓存。 */
89
143
  export function _resetSkillIndexCache() {
@@ -227,6 +281,313 @@ function latestEvalSnapshot(list) {
227
281
  return null;
228
282
  return list[list.length - 1];
229
283
  }
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
+ }
230
591
  /** 扫 doctorsDir/*.report.json,按 skill 名分桶,**返回该 skill 的所有历史 snapshot**(asc 时序)。
231
592
  * renderer 用最后一项做"当前",前面项画 sparkline。 */
232
593
  function scanDoctorReports(dir) {
@@ -265,8 +626,15 @@ function scanDoctorReports(dir) {
265
626
  export function buildSkillIndex(reports, analysesDir, doctorsDir, observationsDir = DEFAULT_OBSERVATIONS_DIR, opts = {}) {
266
627
  const includeObserveCards = opts.includeObserveCards ?? false;
267
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);
268
636
  // 命中缓存就直接返回(fingerprint 覆盖 reports + 两个 dir + 卡片 include 模式的变化信号)
269
- const fp = buildIndexFingerprint(reports, analysesDir, doctorsDir, observationsDir, includeObserveCards, includeDoctorCards);
637
+ const fp = buildIndexFingerprint(reports, analysesDir, doctorsDir, observationsDir, includeObserveCards, includeDoctorCards, graphFingerprint);
270
638
  if (_indexCache && _indexCache.fingerprint === fp) {
271
639
  return _indexCache.result;
272
640
  }
@@ -409,6 +777,17 @@ export function buildSkillIndex(reports, analysesDir, doctorsDir, observationsDi
409
777
  else if (hasMed)
410
778
  ent.band = 'yellow';
411
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
+ }
412
791
  summary.red = entries.filter((e) => e.band === 'red').length;
413
792
  summary.yellow = entries.filter((e) => e.band === 'yellow').length;
414
793
  summary.green = entries.filter((e) => e.band === 'green').length;
@@ -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.43.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",