@reconcrap/boss-recommend-mcp 1.3.30 → 1.3.31

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.
@@ -38,6 +38,7 @@ import {
38
38
  } from './services/profile-store.js';
39
39
  import { ReportStore } from './services/report-store.js';
40
40
  import { ResumeCaptureService } from './services/resume-capture.js';
41
+ import { ResumeNetworkTracker } from './services/resume-network.js';
41
42
  import { NoopStateStore, StateStore } from './services/state-store.js';
42
43
 
43
44
  const CLI_FILE_PATH = fileURLToPath(import.meta.url);
@@ -264,6 +265,14 @@ function parseArgs(argv) {
264
265
  case 'reasoning-effort':
265
266
  args.overrides.llm.thinkingLevel = value || '';
266
267
  break;
268
+ case 'llm-timeout-ms':
269
+ case 'timeout-ms':
270
+ args.overrides.llm.timeoutMs = Number.parseInt(value, 10);
271
+ break;
272
+ case 'llm-max-retries':
273
+ case 'max-retries':
274
+ args.overrides.llm.maxRetries = Number.parseInt(value, 10);
275
+ break;
267
276
  case 'port':
268
277
  args.overrides.chrome.port = Number.parseInt(value, 10);
269
278
  break;
@@ -315,6 +324,8 @@ function printUsage() {
315
324
  console.log(' --apikey <key> Override LLM API key');
316
325
  console.log(' --model <name> Override LLM model');
317
326
  console.log(' --thinking-level <level> LLM thinking level: off|low|medium|high|current');
327
+ console.log(' --llm-timeout-ms <n> Override per-request LLM timeout (default: 60000)');
328
+ console.log(' --llm-max-retries <n> Override per-request LLM retry count (default: 3)');
318
329
  console.log(' --port <n> Override Chrome remote debugging port');
319
330
  }
320
331
 
@@ -925,6 +936,12 @@ function buildDetachedRunArgs(args, runId) {
925
936
  if (args.overrides.llm.thinkingLevel) {
926
937
  workerArgs.push('--thinking-level', String(args.overrides.llm.thinkingLevel));
927
938
  }
939
+ if (Number.isFinite(args.overrides.llm.timeoutMs) && args.overrides.llm.timeoutMs > 0) {
940
+ workerArgs.push('--llm-timeout-ms', String(args.overrides.llm.timeoutMs));
941
+ }
942
+ if (Number.isFinite(args.overrides.llm.maxRetries) && args.overrides.llm.maxRetries > 0) {
943
+ workerArgs.push('--llm-max-retries', String(args.overrides.llm.maxRetries));
944
+ }
928
945
  if (Number.isFinite(args.overrides.chrome.port)) {
929
946
  workerArgs.push('--port', String(args.overrides.chrome.port));
930
947
  }
@@ -1341,6 +1358,7 @@ async function executeRunCommand(args, dataDir) {
1341
1358
  });
1342
1359
  const llmClient = new LlmClient(runProfile.llm);
1343
1360
  const resumeCaptureService = new ResumeCaptureService({ chromeClient, logger });
1361
+ const resumeNetworkTracker = new ResumeNetworkTracker({ chromeClient, logger });
1344
1362
  const stateStore = args.noState ? new NoopStateStore() : new StateStore(dataDir, args.profile);
1345
1363
  const reportStore = new ReportStore(dataDir);
1346
1364
  const app = new BossChatApp({
@@ -1350,6 +1368,7 @@ async function executeRunCommand(args, dataDir) {
1350
1368
  resumeCaptureService,
1351
1369
  stateStore,
1352
1370
  reportStore,
1371
+ resumeNetworkTracker,
1353
1372
  runControl,
1354
1373
  logger,
1355
1374
  dryRun: args.dryRun,
@@ -1540,6 +1559,7 @@ async function main() {
1540
1559
  }
1541
1560
 
1542
1561
  export const __testables = {
1562
+ parseArgs,
1543
1563
  connectBossChatPage,
1544
1564
  hasHydratedChatShell,
1545
1565
  promptRunProfile,
@@ -15,14 +15,20 @@ export class ChromeClient {
15
15
  }
16
16
 
17
17
  this.client = await CDP({ port: this.port, target });
18
- const { Runtime, DOM, Page, Input } = this.client;
18
+ const { Runtime, DOM, Page, Input, Network } = this.client;
19
19
 
20
- await Promise.all([Runtime.enable(), DOM.enable(), Page.enable()]);
20
+ await Promise.all([
21
+ Runtime.enable(),
22
+ DOM.enable(),
23
+ Page.enable(),
24
+ Network && typeof Network.enable === 'function' ? Network.enable() : Promise.resolve(),
25
+ ]);
21
26
 
22
27
  this.Runtime = Runtime;
23
28
  this.DOM = DOM;
24
29
  this.Page = Page;
25
30
  this.Input = Input;
31
+ this.Network = Network || null;
26
32
 
27
33
  return target;
28
34
  }
@@ -300,8 +300,9 @@ function buildImagePrompt({ screeningCriteria, candidate }) {
300
300
  '若无法在教育经历模块确认学校名称,不要编造学校名;按信息不足处理。',
301
301
  '必须完整阅读全部简历截图分段后再判断。',
302
302
  '必须且只能返回 JSON,不要输出 Markdown。',
303
- '返回格式:{"passed":true/false,"reason":"简短中文原因","summary":"简短总结","evidence":["证据原文1","证据原文2"]}',
304
- '当信息不足以支持通过时,返回 passed=false。',
303
+ '返回格式:{"passed":true} 或 {"passed":false}',
304
+ '不要返回理由、总结、证据、思维过程或额外字段。',
305
+ '当信息不足以支持通过时,返回 {"passed":false}。',
305
306
  '',
306
307
  `筛选标准:${screeningCriteria}`,
307
308
  '',
@@ -316,7 +317,7 @@ function buildTextPrompt({ screeningCriteria, candidate, resumeText, chunkIndex
316
317
  const profileContext = buildProfileContext(candidate);
317
318
  const chunkHint =
318
319
  chunkTotal > 1
319
- ? `\n\n当前输入是简历分段 ${chunkIndex}/${chunkTotal}。请严格基于本分段文本判断;如果本分段证据不足,必须返回 passed=false。`
320
+ ? `\n\n当前输入是简历分段 ${chunkIndex}/${chunkTotal}。请严格基于本分段文本判断;如果本分段证据不足,必须返回 {"passed":false}。`
320
321
  : '';
321
322
  return [
322
323
  '你是招聘筛选助手,请基于简历文本判断候选人是否符合筛选标准。',
@@ -325,8 +326,9 @@ function buildTextPrompt({ screeningCriteria, candidate, resumeText, chunkIndex
325
326
  '必须忽略推荐模块与匿名卡片信息(例如“其他名企大厂经历牛人”“相似牛人”“推荐牛人”)。',
326
327
  '若无法在教育经历模块确认学校名称,不要编造学校名;按信息不足处理。',
327
328
  '必须且只能返回 JSON,不要输出 Markdown。',
328
- '返回格式:{"passed":true/false,"reason":"简短中文原因","summary":"简短总结","evidence":["证据原文1","证据原文2"]}',
329
- '当信息不足以支持通过时,返回 passed=false。',
329
+ '返回格式:{"passed":true} 或 {"passed":false}',
330
+ '不要返回理由、总结、证据、思维过程或额外字段。',
331
+ '当信息不足以支持通过时,返回 {"passed":false}。',
330
332
  '',
331
333
  `筛选标准:${screeningCriteria}`,
332
334
  '',
@@ -345,6 +347,28 @@ export function parseLlmJson(content, options = {}) {
345
347
  throw new Error('LLM returned empty content');
346
348
  }
347
349
 
350
+ const normalizedText = normalizeText(text);
351
+ const chunkIndex = Number.isInteger(options.chunkIndex) && options.chunkIndex > 0 ? options.chunkIndex : 1;
352
+ const chunkTotal = Number.isInteger(options.chunkTotal) && options.chunkTotal > 0 ? options.chunkTotal : 1;
353
+
354
+ if (/^(pass|passed|true)$/i.test(normalizedText)) {
355
+ return {
356
+ passed: true,
357
+ rawOutputText: text,
358
+ chunkIndex,
359
+ chunkTotal,
360
+ };
361
+ }
362
+
363
+ if (/^(fail|failed|false)$/i.test(normalizedText)) {
364
+ return {
365
+ passed: false,
366
+ rawOutputText: text,
367
+ chunkIndex,
368
+ chunkTotal,
369
+ };
370
+ }
371
+
348
372
  const codeFenceMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
349
373
  const candidate = codeFenceMatch ? codeFenceMatch[1] : text;
350
374
  const jsonMatch = candidate.match(/\{[\s\S]*\}/);
@@ -353,54 +377,23 @@ export function parseLlmJson(content, options = {}) {
353
377
  }
354
378
 
355
379
  const parsed = JSON.parse(jsonMatch[0]);
356
- const parsedPassed = typeof parsed.passed === 'boolean' ? parsed.passed : parsed.matched;
380
+ const parsedPassed =
381
+ typeof parsed.passed === 'boolean'
382
+ ? parsed.passed
383
+ : typeof parsed.matched === 'boolean'
384
+ ? parsed.matched
385
+ : /^pass$/i.test(String(parsed.decision || '').trim())
386
+ ? true
387
+ : /^fail$/i.test(String(parsed.decision || '').trim())
388
+ ? false
389
+ : null;
357
390
  if (typeof parsedPassed !== 'boolean') {
358
391
  throw new Error('LLM response missing boolean "passed"');
359
392
  }
360
- if (typeof parsed.reason !== 'string' || !parsed.reason.trim()) {
361
- throw new Error('LLM response missing string "reason"');
362
- }
363
-
364
- const reason = normalizeText(parsed.reason);
365
- const summary = normalizeText(parsed.summary || reason);
366
- const parsedEvidence = toStringArray(parsed.evidence);
367
-
368
- const evidenceCorpus = normalizeText(options.evidenceCorpus || options.rawResumeText || '');
369
- const chunkIndex = Number.isInteger(options.chunkIndex) && options.chunkIndex > 0 ? options.chunkIndex : 1;
370
- const chunkTotal = Number.isInteger(options.chunkTotal) && options.chunkTotal > 0 ? options.chunkTotal : 1;
371
-
372
- let evidence = parsedEvidence;
373
- let evidenceMatchedCount = parsedEvidence.length;
374
- if (evidenceCorpus) {
375
- const normalizedCorpus = normalizeText(evidenceCorpus);
376
- const normalizedCorpusLower = toLowerSafe(normalizedCorpus);
377
- evidence = [];
378
- for (const item of parsedEvidence) {
379
- const matched = matchEvidenceAgainstResume(item, evidenceCorpus, normalizedCorpus, normalizedCorpusLower);
380
- if (matched.matched) {
381
- evidence.push(item);
382
- }
383
- }
384
- evidenceMatchedCount = evidence.length;
385
- }
386
-
387
- const rawPassed = parsedPassed === true;
388
- const evidenceRawCount = parsedEvidence.length;
389
- const evidenceGateDemoted = rawPassed && evidenceMatchedCount <= 0;
390
- const passed = evidenceGateDemoted ? false : rawPassed;
391
- const finalReason = evidenceGateDemoted
392
- ? `模型未给出可在简历原文中校验的证据,按安全策略判为不通过。${reason ? ` 原始原因: ${reason}` : ''}`
393
- : reason;
394
393
 
395
394
  return {
396
- passed,
397
- rawPassed,
398
- reason: finalReason || '模型未返回有效理由。',
399
- summary: summary || finalReason || '模型未返回有效总结。',
400
- evidence,
401
- evidenceRawCount,
402
- evidenceMatchedCount,
403
- evidenceGateDemoted,
395
+ passed: parsedPassed,
396
+ rawOutputText: text,
404
397
  chunkIndex,
405
398
  chunkTotal,
406
399
  };
@@ -485,10 +478,16 @@ export class LlmClient {
485
478
  throw lastError || new Error(`${label} evaluation failed`);
486
479
  }
487
480
 
488
- async requestResponses({ prompt, imageDataUrl = null, evidenceCorpus = '', chunkIndex = 1, chunkTotal = 1 }) {
481
+ async requestResponses({ prompt, imageDataUrl = null, imageDataUrls = [], evidenceCorpus = '', chunkIndex = 1, chunkTotal = 1 }) {
489
482
  const content = [{ type: 'input_text', text: prompt }];
483
+ const normalizedImageDataUrls = Array.isArray(imageDataUrls)
484
+ ? imageDataUrls.map((item) => String(item || '').trim()).filter(Boolean)
485
+ : [];
490
486
  if (imageDataUrl) {
491
- content.push({ type: 'input_image', image_url: imageDataUrl });
487
+ normalizedImageDataUrls.unshift(String(imageDataUrl));
488
+ }
489
+ for (const item of normalizedImageDataUrls) {
490
+ content.push({ type: 'input_image', image_url: item });
492
491
  }
493
492
  const payload = {
494
493
  model: this.model,
@@ -560,10 +559,16 @@ export class LlmClient {
560
559
  }
561
560
  }
562
561
 
563
- async requestCompletions({ prompt, imageDataUrl = null, evidenceCorpus = '', chunkIndex = 1, chunkTotal = 1 }) {
562
+ async requestCompletions({ prompt, imageDataUrl = null, imageDataUrls = [], evidenceCorpus = '', chunkIndex = 1, chunkTotal = 1 }) {
564
563
  const content = [{ type: 'text', text: prompt }];
564
+ const normalizedImageDataUrls = Array.isArray(imageDataUrls)
565
+ ? imageDataUrls.map((item) => String(item || '').trim()).filter(Boolean)
566
+ : [];
565
567
  if (imageDataUrl) {
566
- content.push({ type: 'image_url', image_url: { url: imageDataUrl } });
568
+ normalizedImageDataUrls.unshift(String(imageDataUrl));
569
+ }
570
+ for (const item of normalizedImageDataUrls) {
571
+ content.push({ type: 'image_url', image_url: { url: item } });
567
572
  }
568
573
  const payload = {
569
574
  model: this.model,
@@ -648,20 +653,33 @@ export class LlmClient {
648
653
  }
649
654
  }
650
655
 
651
- async evaluateImageResume({ screeningCriteria, candidate, imagePath }) {
656
+ async evaluateImageResume({ screeningCriteria, candidate, imagePath, imagePaths = [] }) {
652
657
  const prompt = buildImagePrompt({ screeningCriteria, candidate });
653
- const imageDataUrl = await this.readImageAsDataUrl(imagePath);
658
+ const normalizedImagePaths = Array.isArray(imagePaths)
659
+ ? imagePaths.map((item) => String(item || '').trim()).filter(Boolean)
660
+ : [];
661
+ if (imagePath) {
662
+ normalizedImagePaths.unshift(String(imagePath));
663
+ }
664
+ const uniqueImagePaths = [...new Set(normalizedImagePaths)];
665
+ if (uniqueImagePaths.length <= 0) {
666
+ throw new Error('IMAGE_MODEL_FAILED: missing image paths');
667
+ }
668
+ const imageDataUrls = await Promise.all(
669
+ uniqueImagePaths.map((item) => this.readImageAsDataUrl(item)),
670
+ );
654
671
  const evidenceCorpus = normalizeText(candidate?.evidenceCorpus || candidate?.resumeText || '');
655
672
  const result = await this.requestByPreference({
656
673
  prompt,
657
- imageDataUrl,
674
+ imageDataUrls,
658
675
  evidenceCorpus,
659
676
  chunkIndex: 1,
660
677
  chunkTotal: 1,
661
678
  });
662
679
  return {
663
680
  ...result,
664
- evaluationMode: 'image',
681
+ evaluationMode: uniqueImagePaths.length > 1 ? 'image-multi-chunk' : 'image',
682
+ imageCount: uniqueImagePaths.length,
665
683
  };
666
684
  }
667
685
 
@@ -736,47 +754,39 @@ export class LlmClient {
736
754
  };
737
755
  }
738
756
 
739
- const firstReason = chunkResults.map((item) => normalizeText(item?.reason)).find(Boolean);
740
757
  return {
741
758
  passed: false,
742
- rawPassed: chunkResults.some((item) => item?.rawPassed === true),
743
- reason: firstReason || `分段筛选未找到满足标准的证据(共 ${chunks.length} 段)。`,
744
- summary: firstReason || `分段筛选未找到满足标准的证据(共 ${chunks.length} 段)。`,
745
- evidence: [],
746
- evidenceRawCount: chunkResults.reduce(
747
- (acc, item) =>
748
- acc + (Number.isFinite(Number(item?.evidenceRawCount)) ? Number(item.evidenceRawCount) : 0),
749
- 0,
750
- ),
751
- evidenceMatchedCount: chunkResults.reduce(
752
- (acc, item) =>
753
- acc + (Number.isFinite(Number(item?.evidenceMatchedCount)) ? Number(item.evidenceMatchedCount) : 0),
754
- 0,
755
- ),
756
- evidenceGateDemoted: chunkResults.some((item) => item?.evidenceGateDemoted === true),
759
+ rawOutputText:
760
+ chunkResults.map((item) => normalizeText(item?.rawOutputText)).find(Boolean) ||
761
+ `{"passed":false,"mode":"text-chunk-fallback","chunks":${chunks.length}}`,
757
762
  chunkIndex: null,
758
763
  chunkTotal: chunks.length,
759
764
  evaluationMode: 'text',
760
765
  };
761
766
  }
762
767
 
763
- async evaluateResume({ screeningCriteria, candidate, imagePath }) {
768
+ async evaluateResume({ screeningCriteria, candidate, imagePath, imagePaths = [] }) {
769
+ const normalizedImagePaths = Array.isArray(imagePaths)
770
+ ? imagePaths.map((item) => String(item || '').trim()).filter(Boolean)
771
+ : [];
772
+ if (imagePath) {
773
+ normalizedImagePaths.unshift(String(imagePath));
774
+ }
775
+ const uniqueImagePaths = [...new Set(normalizedImagePaths)];
776
+ if (uniqueImagePaths.length > 0) {
777
+ return this.evaluateImageResume({
778
+ screeningCriteria,
779
+ candidate,
780
+ imagePaths: uniqueImagePaths,
781
+ });
782
+ }
783
+
764
784
  const hasResumeText = Boolean(normalizeText(candidate?.resumeText || ''));
765
785
  if (hasResumeText) {
766
- try {
767
- return await this.evaluateTextResume({ screeningCriteria, candidate });
768
- } catch (textError) {
769
- if (!imagePath) {
770
- throw textError;
771
- }
772
- const imageResult = await this.evaluateImageResume({ screeningCriteria, candidate, imagePath });
773
- return {
774
- ...imageResult,
775
- textFallbackError: normalizeText(textError?.message || textError),
776
- };
777
- }
786
+ return this.evaluateTextResume({ screeningCriteria, candidate });
778
787
  }
779
- return this.evaluateImageResume({ screeningCriteria, candidate, imagePath });
788
+
789
+ throw new Error('LLM evaluation requires at least one resume image or non-empty resume text');
780
790
  }
781
791
  }
782
792
 
@@ -11,6 +11,8 @@ const DEFAULT_PROFILE = {
11
11
  apiKey: '',
12
12
  model: '',
13
13
  thinkingLevel: '',
14
+ timeoutMs: 60000,
15
+ maxRetries: 3,
14
16
  },
15
17
  chrome: {
16
18
  port: 9222,
@@ -80,6 +82,8 @@ export function toPersistentProfile(profile = {}) {
80
82
  apiKey: normalized.llm.apiKey,
81
83
  model: normalized.llm.model,
82
84
  thinkingLevel: normalized.llm.thinkingLevel,
85
+ timeoutMs: normalized.llm.timeoutMs,
86
+ maxRetries: normalized.llm.maxRetries,
83
87
  },
84
88
  chrome: {
85
89
  port: normalized.chrome.port,
@@ -104,6 +108,8 @@ export function normalizeProfile(profile = {}) {
104
108
  merged.llm.thinkingLevel = String(
105
109
  merged.llm.thinkingLevel || merged.llm.llmThinkingLevel || merged.llm.reasoningEffort || merged.llm.reasoning_effort || '',
106
110
  ).trim();
111
+ merged.llm.timeoutMs = normalizeNumber(merged.llm.timeoutMs, DEFAULT_PROFILE.llm.timeoutMs);
112
+ merged.llm.maxRetries = normalizeNumber(merged.llm.maxRetries, DEFAULT_PROFILE.llm.maxRetries);
107
113
  merged.runtime.batchRestEnabled = merged.runtime.batchRestEnabled !== false;
108
114
  merged.runtime.safePacing = merged.runtime.safePacing !== false;
109
115
  return merged;
@@ -1,10 +1,255 @@
1
1
  import { mkdir, writeFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
 
4
+ const TIMING_BUCKETS = [
5
+ ['initialNetworkWaitMs', '初始 network 等待'],
6
+ ['networkRetryMs', 'network 重试'],
7
+ ['imageCaptureMs', '简历截图'],
8
+ ['imageModelMs', '图片模型'],
9
+ ['lateNetworkRetryMs', '晚到 network 重试'],
10
+ ['domFallbackMs', 'DOM 兜底'],
11
+ ['textModelMs', '文本模型'],
12
+ ];
13
+
14
+ const CSV_HEADER = [
15
+ 'index',
16
+ 'name',
17
+ 'source_job',
18
+ 'decision',
19
+ 'passed',
20
+ 'requested',
21
+ 'resume_acquisition_mode',
22
+ 'resume_acquisition_reason',
23
+ 'evaluation_mode',
24
+ 'evaluation_image_count',
25
+ 'initial_network_wait_ms',
26
+ 'network_retry_ms',
27
+ 'image_capture_ms',
28
+ 'image_model_ms',
29
+ 'late_network_retry_ms',
30
+ 'dom_fallback_ms',
31
+ 'text_model_ms',
32
+ 'timing_summary',
33
+ 'reason',
34
+ 'error_message',
35
+ 'llm_raw_output_preview',
36
+ ];
37
+
4
38
  function timestampToken(date = new Date()) {
5
39
  return date.toISOString().replace(/[:.]/g, '-');
6
40
  }
7
41
 
42
+ function normalizeText(value) {
43
+ if (value === null || value === undefined) return '';
44
+ return String(value).replace(/\s+/g, ' ').trim();
45
+ }
46
+
47
+ function previewText(value, maxLength = 160) {
48
+ const normalized = normalizeText(value);
49
+ if (!normalized) return '';
50
+ if (normalized.length <= maxLength) return normalized;
51
+ return `${normalized.slice(0, Math.max(0, maxLength - 1))}…`;
52
+ }
53
+
54
+ function normalizeMs(value) {
55
+ const parsed = Number(value);
56
+ if (!Number.isFinite(parsed) || parsed < 0) return null;
57
+ return Math.round(parsed);
58
+ }
59
+
60
+ function formatDurationMs(startedAt, finishedAt) {
61
+ const started = startedAt ? Date.parse(startedAt) : NaN;
62
+ const finished = finishedAt ? Date.parse(finishedAt) : NaN;
63
+ if (!Number.isFinite(started) || !Number.isFinite(finished) || finished < started) {
64
+ return '-';
65
+ }
66
+ const totalMs = Math.round(finished - started);
67
+ if (totalMs < 1000) return `${totalMs}ms`;
68
+ if (totalMs < 60_000) return `${(totalMs / 1000).toFixed(1)}s`;
69
+ return `${(totalMs / 60_000).toFixed(1)}m`;
70
+ }
71
+
72
+ function csvEscape(value) {
73
+ return `"${String(value ?? '').replace(/"/g, '""')}"`;
74
+ }
75
+
76
+ function toResults(summary) {
77
+ return Array.isArray(summary?.results) ? summary.results : [];
78
+ }
79
+
80
+ function toOutcome(result) {
81
+ if (normalizeText(result?.decision)) return normalizeText(result.decision);
82
+ if (result?.passed) return 'passed';
83
+ if (normalizeText(result?.error)) return 'error';
84
+ return 'skipped';
85
+ }
86
+
87
+ function getArtifacts(result) {
88
+ return result?.artifacts && typeof result.artifacts === 'object' ? result.artifacts : {};
89
+ }
90
+
91
+ function getAcquisitionMode(result) {
92
+ return normalizeText(getArtifacts(result).resumeAcquisitionMode);
93
+ }
94
+
95
+ function getAcquisitionReason(result) {
96
+ return normalizeText(getArtifacts(result).resumeAcquisitionReason);
97
+ }
98
+
99
+ function getTimingValue(result, key) {
100
+ return normalizeMs(getArtifacts(result)[key]);
101
+ }
102
+
103
+ function formatTimingSummary(result) {
104
+ const parts = [];
105
+ for (const [key, label] of TIMING_BUCKETS) {
106
+ const value = getTimingValue(result, key);
107
+ if (value === null) continue;
108
+ parts.push(`${label} ${value}ms`);
109
+ }
110
+ return parts.length > 0 ? parts.join(' | ') : '-';
111
+ }
112
+
113
+ function formatResultNotes(result) {
114
+ const parts = [];
115
+ const reason = previewText(result?.reason, 120);
116
+ const errorMessage = previewText(result?.error, 120);
117
+ const llmRawOutput = previewText(getArtifacts(result).llmRawOutput, 180);
118
+ if (reason) parts.push(`原因: ${reason}`);
119
+ if (errorMessage) parts.push(`错误: ${errorMessage}`);
120
+ if (llmRawOutput) parts.push(`LLM: ${llmRawOutput}`);
121
+ return parts.length > 0 ? parts.join(' | ') : '-';
122
+ }
123
+
124
+ function buildAcquisitionSummaryRows(results) {
125
+ const counts = new Map();
126
+ for (const result of results) {
127
+ const mode = getAcquisitionMode(result) || 'unknown';
128
+ const reason = getAcquisitionReason(result) || 'unspecified';
129
+ const key = `${mode}__${reason}`;
130
+ const current = counts.get(key) || { mode, reason, count: 0 };
131
+ current.count += 1;
132
+ counts.set(key, current);
133
+ }
134
+ return [...counts.values()].sort((left, right) => right.count - left.count || left.mode.localeCompare(right.mode));
135
+ }
136
+
137
+ function buildTimingSummaryRows(results) {
138
+ return TIMING_BUCKETS.map(([key, label]) => {
139
+ let count = 0;
140
+ let total = 0;
141
+ for (const result of results) {
142
+ const value = getTimingValue(result, key);
143
+ if (value === null) continue;
144
+ count += 1;
145
+ total += value;
146
+ }
147
+ return {
148
+ key,
149
+ label,
150
+ count,
151
+ total,
152
+ average: count > 0 ? Math.round(total / count) : null,
153
+ };
154
+ }).filter((item) => item.count > 0);
155
+ }
156
+
157
+ function buildMarkdownSummary(summary) {
158
+ const results = toResults(summary);
159
+ const acquisitionRows = buildAcquisitionSummaryRows(results);
160
+ const timingRows = buildTimingSummaryRows(results);
161
+ const lines = [
162
+ '# Boss Chat 运行报告',
163
+ '',
164
+ '## 概览',
165
+ `- 开始时间: ${summary?.startedAt || '-'}`,
166
+ `- 结束时间: ${summary?.finishedAt || '-'}`,
167
+ `- 总耗时: ${formatDurationMs(summary?.startedAt, summary?.finishedAt)}`,
168
+ `- 处理进度: inspected=${Number(summary?.inspected || 0)} / target=${summary?.profile?.targetCount || '∞'}`,
169
+ `- 结果统计: passed=${Number(summary?.passed || 0)} | requested=${Number(summary?.requested || 0)} | skipped=${Number(summary?.skipped || 0)} | errors=${Number(summary?.errors || 0)}`,
170
+ `- 停止状态: ${summary?.stopped ? `stopped (${summary?.stopReason || 'unknown'})` : 'completed'}`,
171
+ `- 穷尽列表: ${summary?.exhausted === true ? 'yes' : 'no'}`,
172
+ `- 报告文件: JSON=${summary?.reportPath || '-'} | Markdown=${summary?.reportMarkdownPath || '-'} | CSV=${summary?.reportCsvPath || '-'}`,
173
+ '',
174
+ '## Resume Acquisition 汇总',
175
+ '',
176
+ '| mode | retry_reason | count |',
177
+ '| --- | --- | ---: |',
178
+ ];
179
+
180
+ if (acquisitionRows.length === 0) {
181
+ lines.push('| - | - | 0 |');
182
+ } else {
183
+ for (const row of acquisitionRows) {
184
+ lines.push(`| ${row.mode} | ${row.reason} | ${row.count} |`);
185
+ }
186
+ }
187
+
188
+ lines.push('');
189
+ lines.push('## Timing 汇总');
190
+ lines.push('');
191
+ lines.push('| bucket | hits | total | avg |');
192
+ lines.push('| --- | ---: | ---: | ---: |');
193
+ if (timingRows.length === 0) {
194
+ lines.push('| - | 0 | - | - |');
195
+ } else {
196
+ for (const row of timingRows) {
197
+ lines.push(`| ${row.label} | ${row.count} | ${row.total}ms | ${row.average === null ? '-' : `${row.average}ms`} |`);
198
+ }
199
+ }
200
+
201
+ lines.push('');
202
+ lines.push('## 候选人明细');
203
+ lines.push('');
204
+ lines.push('| # | 姓名 | 结论 | acquisition | retry_reason | timing | notes |');
205
+ lines.push('| ---: | --- | --- | --- | --- | --- | --- |');
206
+
207
+ if (results.length === 0) {
208
+ lines.push('| 1 | - | - | - | - | - | - |');
209
+ } else {
210
+ results.forEach((result, index) => {
211
+ lines.push(
212
+ `| ${index + 1} | ${previewText(result?.name || '未知', 32) || '未知'} | ${toOutcome(result)} | ${getAcquisitionMode(result) || '-'} | ${getAcquisitionReason(result) || '-'} | ${formatTimingSummary(result)} | ${formatResultNotes(result)} |`,
213
+ );
214
+ });
215
+ }
216
+
217
+ lines.push('');
218
+ return `${lines.join('\n')}\n`;
219
+ }
220
+
221
+ function buildCsvSummary(summary) {
222
+ const results = toResults(summary);
223
+ const lines = [CSV_HEADER.join(',')];
224
+ results.forEach((result, index) => {
225
+ const artifacts = getArtifacts(result);
226
+ lines.push([
227
+ csvEscape(index + 1),
228
+ csvEscape(result?.name || ''),
229
+ csvEscape(result?.sourceJob || ''),
230
+ csvEscape(toOutcome(result)),
231
+ csvEscape(result?.passed === true ? 'true' : 'false'),
232
+ csvEscape(result?.requested === true ? 'true' : 'false'),
233
+ csvEscape(getAcquisitionMode(result)),
234
+ csvEscape(getAcquisitionReason(result)),
235
+ csvEscape(artifacts.evaluationMode || ''),
236
+ csvEscape(Number.isFinite(Number(artifacts.evaluationImageCount)) ? Number(artifacts.evaluationImageCount) : ''),
237
+ csvEscape(getTimingValue(result, 'initialNetworkWaitMs') ?? ''),
238
+ csvEscape(getTimingValue(result, 'networkRetryMs') ?? ''),
239
+ csvEscape(getTimingValue(result, 'imageCaptureMs') ?? ''),
240
+ csvEscape(getTimingValue(result, 'imageModelMs') ?? ''),
241
+ csvEscape(getTimingValue(result, 'lateNetworkRetryMs') ?? ''),
242
+ csvEscape(getTimingValue(result, 'domFallbackMs') ?? ''),
243
+ csvEscape(getTimingValue(result, 'textModelMs') ?? ''),
244
+ csvEscape(formatTimingSummary(result)),
245
+ csvEscape(result?.reason || ''),
246
+ csvEscape(result?.error || ''),
247
+ csvEscape(previewText(artifacts.llmRawOutput, 500)),
248
+ ].join(','));
249
+ });
250
+ return `\uFEFF${lines.join('\n')}\n`;
251
+ }
252
+
8
253
  export class ReportStore {
9
254
  constructor(baseDir) {
10
255
  this.reportsDir = path.join(baseDir, 'reports');
@@ -12,8 +257,27 @@ export class ReportStore {
12
257
 
13
258
  async write(summary) {
14
259
  await mkdir(this.reportsDir, { recursive: true });
15
- const filePath = path.join(this.reportsDir, `run-${timestampToken()}.json`);
16
- await writeFile(filePath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8');
17
- return filePath;
260
+ const baseName = `run-${timestampToken()}`;
261
+ const jsonPath = path.join(this.reportsDir, `${baseName}.json`);
262
+ const markdownPath = path.join(this.reportsDir, `${baseName}.md`);
263
+ const csvPath = path.join(this.reportsDir, `${baseName}.csv`);
264
+
265
+ if (summary && typeof summary === 'object') {
266
+ summary.reportPath = jsonPath;
267
+ summary.reportMarkdownPath = markdownPath;
268
+ summary.reportCsvPath = csvPath;
269
+ summary.reportArtifacts = {
270
+ jsonPath,
271
+ markdownPath,
272
+ csvPath,
273
+ };
274
+ }
275
+
276
+ await Promise.all([
277
+ writeFile(jsonPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8'),
278
+ writeFile(markdownPath, buildMarkdownSummary(summary), 'utf8'),
279
+ writeFile(csvPath, buildCsvSummary(summary), 'utf8'),
280
+ ]);
281
+ return jsonPath;
18
282
  }
19
283
  }