@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.
- package/config/screening-config.example.json +2 -0
- package/package.json +1 -1
- package/src/adapters.js +22 -0
- package/src/boss-chat.js +14 -1
- package/src/test-adapters-runtime.js +90 -0
- package/src/test-boss-chat.js +651 -60
- package/vendor/boss-chat-cli/src/app.js +395 -178
- package/vendor/boss-chat-cli/src/cli.js +20 -0
- package/vendor/boss-chat-cli/src/services/chrome-client.js +8 -2
- package/vendor/boss-chat-cli/src/services/llm.js +96 -86
- package/vendor/boss-chat-cli/src/services/profile-store.js +6 -0
- package/vendor/boss-chat-cli/src/services/report-store.js +267 -3
- package/vendor/boss-chat-cli/src/services/resume-capture.js +41 -126
- package/vendor/boss-chat-cli/src/services/resume-network.js +727 -0
- package/vendor/boss-recommend-screen-cli/boss-recommend-screen-cli.cjs +44 -2
|
@@ -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([
|
|
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
|
|
304
|
-
'
|
|
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
|
|
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
|
|
329
|
-
'
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
743
|
-
|
|
744
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
16
|
-
|
|
17
|
-
|
|
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
|
}
|