@reconcrap/boss-recommend-mcp 1.3.30 → 1.3.32

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
  }
@@ -43,6 +43,22 @@ function getCompletionContent(data) {
43
43
  return '';
44
44
  }
45
45
 
46
+ function flattenChatMessageContent(content) {
47
+ if (Array.isArray(content)) {
48
+ return content
49
+ .map((item) => {
50
+ if (typeof item === 'string') return item;
51
+ if (item && typeof item === 'object') {
52
+ return item.text || item.content || item.reasoning_content || '';
53
+ }
54
+ return '';
55
+ })
56
+ .filter(Boolean)
57
+ .join('\n');
58
+ }
59
+ return String(content || '');
60
+ }
61
+
46
62
  function getResponsesContent(data) {
47
63
  if (typeof data?.output_text === 'string' && data.output_text.trim()) {
48
64
  return data.output_text;
@@ -163,6 +179,100 @@ function toStringArray(value, maxItems = 8) {
163
179
  return normalized;
164
180
  }
165
181
 
182
+ function collectNestedText(value, out = [], depth = 0) {
183
+ if (depth > 6 || value === null || value === undefined) return out;
184
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
185
+ const normalized = normalizeText(String(value));
186
+ if (normalized) out.push(normalized);
187
+ return out;
188
+ }
189
+ if (Array.isArray(value)) {
190
+ for (const item of value) {
191
+ collectNestedText(item, out, depth + 1);
192
+ }
193
+ return out;
194
+ }
195
+ if (typeof value === 'object') {
196
+ const priorityKeys = ['text', 'reasoning_content', 'summary_text', 'summary', 'content', 'cot', 'reason'];
197
+ const seen = new Set();
198
+ for (const key of priorityKeys) {
199
+ if (Object.prototype.hasOwnProperty.call(value, key)) {
200
+ seen.add(key);
201
+ collectNestedText(value[key], out, depth + 1);
202
+ }
203
+ }
204
+ for (const [key, nested] of Object.entries(value)) {
205
+ if (seen.has(key)) continue;
206
+ collectNestedText(nested, out, depth + 1);
207
+ }
208
+ }
209
+ return out;
210
+ }
211
+
212
+ function dedupeTextFragments(fragments = []) {
213
+ const deduped = [];
214
+ const seen = new Set();
215
+ for (const item of fragments) {
216
+ const normalized = normalizeText(item);
217
+ if (!normalized) continue;
218
+ if (seen.has(normalized)) continue;
219
+ seen.add(normalized);
220
+ deduped.push(normalized);
221
+ }
222
+ return deduped;
223
+ }
224
+
225
+ function joinTextFragments(fragments = []) {
226
+ return dedupeTextFragments(fragments).join('\n');
227
+ }
228
+
229
+ function extractCompletionReasoningText(data) {
230
+ const choice = data?.choices?.[0] || {};
231
+ const fragments = [];
232
+ const content = choice?.message?.content;
233
+ if (Array.isArray(content)) {
234
+ for (const part of content) {
235
+ const partType = normalizeText(part?.type || '').toLowerCase();
236
+ if (partType.includes('reason') || partType.includes('summary')) {
237
+ collectNestedText(part, fragments);
238
+ }
239
+ }
240
+ }
241
+ const candidates = [
242
+ choice?.message?.reasoning_content,
243
+ choice?.message?.reasoning,
244
+ choice?.reasoning_content,
245
+ choice?.reasoning,
246
+ ];
247
+ for (const candidate of candidates) {
248
+ collectNestedText(candidate, fragments);
249
+ }
250
+ return joinTextFragments(fragments);
251
+ }
252
+
253
+ function extractResponsesReasoningText(data) {
254
+ const fragments = [];
255
+ collectNestedText(data?.reasoning, fragments);
256
+ collectNestedText(data?.reasoning_content, fragments);
257
+
258
+ const output = Array.isArray(data?.output) ? data.output : [];
259
+ for (const item of output) {
260
+ const itemType = normalizeText(item?.type || '').toLowerCase();
261
+ if (itemType.includes('reason') || itemType.includes('summary')) {
262
+ collectNestedText(item, fragments);
263
+ }
264
+ const content = Array.isArray(item?.content) ? item.content : [];
265
+ for (const chunk of content) {
266
+ const chunkType = normalizeText(chunk?.type || '').toLowerCase();
267
+ if (chunkType.includes('reason') || chunkType.includes('summary')) {
268
+ collectNestedText(chunk, fragments);
269
+ }
270
+ }
271
+ }
272
+
273
+ return joinTextFragments(fragments);
274
+ }
275
+
166
276
  function extractEvidenceTokens(text, maxItems = MAX_EVIDENCE_TOKENS) {
167
277
  const normalized = normalizeText(text);
168
278
  if (!normalized) return [];
@@ -300,8 +410,9 @@ function buildImagePrompt({ screeningCriteria, candidate }) {
300
410
  '若无法在教育经历模块确认学校名称,不要编造学校名;按信息不足处理。',
301
411
  '必须完整阅读全部简历截图分段后再判断。',
302
412
  '必须且只能返回 JSON,不要输出 Markdown。',
303
- '返回格式:{"passed":true/false,"reason":"简短中文原因","summary":"简短总结","evidence":["证据原文1","证据原文2"]}',
304
- '当信息不足以支持通过时,返回 passed=false。',
413
+ '返回格式:{"passed":true} 或 {"passed":false}',
414
+ '不要返回理由、总结、证据、思维过程或额外字段。',
415
+ '当信息不足以支持通过时,返回 {"passed":false}。',
305
416
  '',
306
417
  `筛选标准:${screeningCriteria}`,
307
418
  '',
@@ -316,7 +427,7 @@ function buildTextPrompt({ screeningCriteria, candidate, resumeText, chunkIndex
316
427
  const profileContext = buildProfileContext(candidate);
317
428
  const chunkHint =
318
429
  chunkTotal > 1
319
- ? `\n\n当前输入是简历分段 ${chunkIndex}/${chunkTotal}。请严格基于本分段文本判断;如果本分段证据不足,必须返回 passed=false。`
430
+ ? `\n\n当前输入是简历分段 ${chunkIndex}/${chunkTotal}。请严格基于本分段文本判断;如果本分段证据不足,必须返回 {"passed":false}。`
320
431
  : '';
321
432
  return [
322
433
  '你是招聘筛选助手,请基于简历文本判断候选人是否符合筛选标准。',
@@ -325,8 +436,9 @@ function buildTextPrompt({ screeningCriteria, candidate, resumeText, chunkIndex
325
436
  '必须忽略推荐模块与匿名卡片信息(例如“其他名企大厂经历牛人”“相似牛人”“推荐牛人”)。',
326
437
  '若无法在教育经历模块确认学校名称,不要编造学校名;按信息不足处理。',
327
438
  '必须且只能返回 JSON,不要输出 Markdown。',
328
- '返回格式:{"passed":true/false,"reason":"简短中文原因","summary":"简短总结","evidence":["证据原文1","证据原文2"]}',
329
- '当信息不足以支持通过时,返回 passed=false。',
439
+ '返回格式:{"passed":true} 或 {"passed":false}',
440
+ '不要返回理由、总结、证据、思维过程或额外字段。',
441
+ '当信息不足以支持通过时,返回 {"passed":false}。',
330
442
  '',
331
443
  `筛选标准:${screeningCriteria}`,
332
444
  '',
@@ -339,12 +451,52 @@ function buildTextPrompt({ screeningCriteria, candidate, resumeText, chunkIndex
339
451
  ].join('\n');
340
452
  }
341
453
 
454
+ function pickFirstText(...values) {
455
+ for (const value of values) {
456
+ const normalized = normalizeText(value);
457
+ if (normalized) return normalized;
458
+ }
459
+ return '';
460
+ }
461
+
342
462
  export function parseLlmJson(content, options = {}) {
343
463
  const text = String(content || '').trim();
344
464
  if (!text) {
345
465
  throw new Error('LLM returned empty content');
346
466
  }
347
467
 
468
+ const normalizedText = normalizeText(text);
469
+ const chunkIndex = Number.isInteger(options.chunkIndex) && options.chunkIndex > 0 ? options.chunkIndex : 1;
470
+ const chunkTotal = Number.isInteger(options.chunkTotal) && options.chunkTotal > 0 ? options.chunkTotal : 1;
471
+
472
+ if (/^(pass|passed|true)$/i.test(normalizedText)) {
473
+ return {
474
+ passed: true,
475
+ rawOutputText: text,
476
+ rawReasoningText: normalizeText(options.reasoningText || ''),
477
+ cot: normalizeText(options.reasoningText || ''),
478
+ reason: '',
479
+ summary: '',
480
+ evidence: [],
481
+ chunkIndex,
482
+ chunkTotal,
483
+ };
484
+ }
485
+
486
+ if (/^(fail|failed|false)$/i.test(normalizedText)) {
487
+ return {
488
+ passed: false,
489
+ rawOutputText: text,
490
+ rawReasoningText: normalizeText(options.reasoningText || ''),
491
+ cot: normalizeText(options.reasoningText || ''),
492
+ reason: '',
493
+ summary: '',
494
+ evidence: [],
495
+ chunkIndex,
496
+ chunkTotal,
497
+ };
498
+ }
499
+
348
500
  const codeFenceMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
349
501
  const candidate = codeFenceMatch ? codeFenceMatch[1] : text;
350
502
  const jsonMatch = candidate.match(/\{[\s\S]*\}/);
@@ -353,54 +505,40 @@ export function parseLlmJson(content, options = {}) {
353
505
  }
354
506
 
355
507
  const parsed = JSON.parse(jsonMatch[0]);
356
- const parsedPassed = typeof parsed.passed === 'boolean' ? parsed.passed : parsed.matched;
508
+ const parsedPassed =
509
+ typeof parsed.passed === 'boolean'
510
+ ? parsed.passed
511
+ : typeof parsed.matched === 'boolean'
512
+ ? parsed.matched
513
+ : /^pass$/i.test(String(parsed.decision || '').trim())
514
+ ? true
515
+ : /^fail$/i.test(String(parsed.decision || '').trim())
516
+ ? false
517
+ : null;
357
518
  if (typeof parsedPassed !== 'boolean') {
358
519
  throw new Error('LLM response missing boolean "passed"');
359
520
  }
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
521
 
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;
522
+ const parsedReason = pickFirstText(parsed?.reason, parsed?.summary, parsed?.summary_text);
523
+ const parsedSummary = pickFirstText(parsed?.summary, parsed?.summary_text, parsed?.reason);
524
+ const parsedCot = pickFirstText(
525
+ options.reasoningText,
526
+ parsed?.cot,
527
+ parsed?.reasoning_content,
528
+ parsed?.reasoning,
529
+ parsedReason,
530
+ parsedSummary,
531
+ );
532
+ const parsedEvidence = toStringArray(parsed?.evidence);
394
533
 
395
534
  return {
396
- passed,
397
- rawPassed,
398
- reason: finalReason || '模型未返回有效理由。',
399
- summary: summary || finalReason || '模型未返回有效总结。',
400
- evidence,
401
- evidenceRawCount,
402
- evidenceMatchedCount,
403
- evidenceGateDemoted,
535
+ passed: parsedPassed,
536
+ rawOutputText: text,
537
+ rawReasoningText: normalizeText(options.reasoningText || ''),
538
+ cot: parsedCot,
539
+ reason: parsedReason || parsedCot,
540
+ summary: parsedSummary || parsedReason || parsedCot,
541
+ evidence: parsedEvidence,
404
542
  chunkIndex,
405
543
  chunkTotal,
406
544
  };
@@ -485,10 +623,16 @@ export class LlmClient {
485
623
  throw lastError || new Error(`${label} evaluation failed`);
486
624
  }
487
625
 
488
- async requestResponses({ prompt, imageDataUrl = null, evidenceCorpus = '', chunkIndex = 1, chunkTotal = 1 }) {
626
+ async requestResponses({ prompt, imageDataUrl = null, imageDataUrls = [], evidenceCorpus = '', chunkIndex = 1, chunkTotal = 1 }) {
489
627
  const content = [{ type: 'input_text', text: prompt }];
628
+ const normalizedImageDataUrls = Array.isArray(imageDataUrls)
629
+ ? imageDataUrls.map((item) => String(item || '').trim()).filter(Boolean)
630
+ : [];
490
631
  if (imageDataUrl) {
491
- content.push({ type: 'input_image', image_url: imageDataUrl });
632
+ normalizedImageDataUrls.unshift(String(imageDataUrl));
633
+ }
634
+ for (const item of normalizedImageDataUrls) {
635
+ content.push({ type: 'input_image', image_url: item });
492
636
  }
493
637
  const payload = {
494
638
  model: this.model,
@@ -526,6 +670,7 @@ export class LlmClient {
526
670
  }
527
671
 
528
672
  const outputContent = getResponsesContent(data);
673
+ const reasoningText = extractResponsesReasoningText(data);
529
674
  if (!outputContent) {
530
675
  const incompleteReason = String(data?.incomplete_details?.reason || '').trim();
531
676
  const outputTypes = Array.isArray(data?.output)
@@ -548,6 +693,7 @@ export class LlmClient {
548
693
  try {
549
694
  return parseLlmJson(outputContent, {
550
695
  evidenceCorpus,
696
+ reasoningText,
551
697
  chunkIndex,
552
698
  chunkTotal,
553
699
  });
@@ -560,10 +706,16 @@ export class LlmClient {
560
706
  }
561
707
  }
562
708
 
563
- async requestCompletions({ prompt, imageDataUrl = null, evidenceCorpus = '', chunkIndex = 1, chunkTotal = 1 }) {
709
+ async requestCompletions({ prompt, imageDataUrl = null, imageDataUrls = [], evidenceCorpus = '', chunkIndex = 1, chunkTotal = 1 }) {
564
710
  const content = [{ type: 'text', text: prompt }];
711
+ const normalizedImageDataUrls = Array.isArray(imageDataUrls)
712
+ ? imageDataUrls.map((item) => String(item || '').trim()).filter(Boolean)
713
+ : [];
565
714
  if (imageDataUrl) {
566
- content.push({ type: 'image_url', image_url: { url: imageDataUrl } });
715
+ normalizedImageDataUrls.unshift(String(imageDataUrl));
716
+ }
717
+ for (const item of normalizedImageDataUrls) {
718
+ content.push({ type: 'image_url', image_url: { url: item } });
567
719
  }
568
720
  const payload = {
569
721
  model: this.model,
@@ -605,6 +757,7 @@ export class LlmClient {
605
757
  }
606
758
 
607
759
  const outputContent = getCompletionContent(data);
760
+ const reasoningText = extractCompletionReasoningText(data);
608
761
  if (!String(outputContent || '').trim()) {
609
762
  const emptyError = new Error('Completions API empty textual content');
610
763
  emptyError.code = 'COMPLETIONS_EMPTY_CONTENT';
@@ -614,6 +767,7 @@ export class LlmClient {
614
767
  try {
615
768
  return parseLlmJson(outputContent, {
616
769
  evidenceCorpus,
770
+ reasoningText,
617
771
  chunkIndex,
618
772
  chunkTotal,
619
773
  });
@@ -648,20 +802,33 @@ export class LlmClient {
648
802
  }
649
803
  }
650
804
 
651
- async evaluateImageResume({ screeningCriteria, candidate, imagePath }) {
805
+ async evaluateImageResume({ screeningCriteria, candidate, imagePath, imagePaths = [] }) {
652
806
  const prompt = buildImagePrompt({ screeningCriteria, candidate });
653
- const imageDataUrl = await this.readImageAsDataUrl(imagePath);
807
+ const normalizedImagePaths = Array.isArray(imagePaths)
808
+ ? imagePaths.map((item) => String(item || '').trim()).filter(Boolean)
809
+ : [];
810
+ if (imagePath) {
811
+ normalizedImagePaths.unshift(String(imagePath));
812
+ }
813
+ const uniqueImagePaths = [...new Set(normalizedImagePaths)];
814
+ if (uniqueImagePaths.length <= 0) {
815
+ throw new Error('IMAGE_MODEL_FAILED: missing image paths');
816
+ }
817
+ const imageDataUrls = await Promise.all(
818
+ uniqueImagePaths.map((item) => this.readImageAsDataUrl(item)),
819
+ );
654
820
  const evidenceCorpus = normalizeText(candidate?.evidenceCorpus || candidate?.resumeText || '');
655
821
  const result = await this.requestByPreference({
656
822
  prompt,
657
- imageDataUrl,
823
+ imageDataUrls,
658
824
  evidenceCorpus,
659
825
  chunkIndex: 1,
660
826
  chunkTotal: 1,
661
827
  });
662
828
  return {
663
829
  ...result,
664
- evaluationMode: 'image',
830
+ evaluationMode: uniqueImagePaths.length > 1 ? 'image-multi-chunk' : 'image',
831
+ imageCount: uniqueImagePaths.length,
665
832
  };
666
833
  }
667
834
 
@@ -736,51 +903,52 @@ export class LlmClient {
736
903
  };
737
904
  }
738
905
 
739
- const firstReason = chunkResults.map((item) => normalizeText(item?.reason)).find(Boolean);
740
906
  return {
741
907
  passed: false,
742
- rawPassed: chunkResults.some((item) => item?.rawPassed === true),
743
- reason: firstReason || `分段筛选未找到满足标准的证据(共 ${chunks.length} 段)。`,
744
- summary: firstReason || `分段筛选未找到满足标准的证据(共 ${chunks.length} 段)。`,
908
+ rawOutputText:
909
+ chunkResults.map((item) => normalizeText(item?.rawOutputText)).find(Boolean) ||
910
+ `{"passed":false,"mode":"text-chunk-fallback","chunks":${chunks.length}}`,
911
+ rawReasoningText: chunkResults.map((item) => normalizeText(item?.rawReasoningText)).find(Boolean) || '',
912
+ cot: chunkResults.map((item) => normalizeText(item?.cot)).find(Boolean) || '',
913
+ reason: chunkResults.map((item) => normalizeText(item?.reason)).find(Boolean) || '',
914
+ summary: chunkResults.map((item) => normalizeText(item?.summary)).find(Boolean) || '',
745
915
  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),
757
916
  chunkIndex: null,
758
917
  chunkTotal: chunks.length,
759
918
  evaluationMode: 'text',
760
919
  };
761
920
  }
762
921
 
763
- async evaluateResume({ screeningCriteria, candidate, imagePath }) {
922
+ async evaluateResume({ screeningCriteria, candidate, imagePath, imagePaths = [] }) {
923
+ const normalizedImagePaths = Array.isArray(imagePaths)
924
+ ? imagePaths.map((item) => String(item || '').trim()).filter(Boolean)
925
+ : [];
926
+ if (imagePath) {
927
+ normalizedImagePaths.unshift(String(imagePath));
928
+ }
929
+ const uniqueImagePaths = [...new Set(normalizedImagePaths)];
930
+ if (uniqueImagePaths.length > 0) {
931
+ return this.evaluateImageResume({
932
+ screeningCriteria,
933
+ candidate,
934
+ imagePaths: uniqueImagePaths,
935
+ });
936
+ }
937
+
764
938
  const hasResumeText = Boolean(normalizeText(candidate?.resumeText || ''));
765
939
  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
- }
940
+ return this.evaluateTextResume({ screeningCriteria, candidate });
778
941
  }
779
- return this.evaluateImageResume({ screeningCriteria, candidate, imagePath });
942
+
943
+ throw new Error('LLM evaluation requires at least one resume image or non-empty resume text');
780
944
  }
781
945
  }
782
946
 
783
947
  export const __testables = {
948
+ flattenChatMessageContent,
949
+ collectNestedText,
950
+ extractCompletionReasoningText,
951
+ extractResponsesReasoningText,
784
952
  extractEvidenceTokens,
785
953
  matchEvidenceAgainstResume,
786
954
  splitTextByChunks,
@@ -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;