@reconcrap/boss-recommend-mcp 1.3.9 → 1.3.11

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.
@@ -1,5 +1,23 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
 
3
+ const DEFAULT_TEXT_MODEL_CHUNK_SIZE_CHARS = 24000;
4
+ const DEFAULT_TEXT_MODEL_CHUNK_OVERLAP_CHARS = 1200;
5
+ const DEFAULT_TEXT_MODEL_MAX_CHUNKS = 12;
6
+ const MAX_EVIDENCE_TOKENS = 12;
7
+
8
+ function normalizeText(value) {
9
+ return String(value || '').replace(/\s+/g, ' ').trim();
10
+ }
11
+
12
+ function toLowerSafe(text) {
13
+ return String(text || '').toLowerCase();
14
+ }
15
+
16
+ function parsePositiveInteger(value) {
17
+ const parsed = Number.parseInt(String(value ?? ''), 10);
18
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
19
+ }
20
+
3
21
  function getCompletionContent(data) {
4
22
  const content = data?.choices?.[0]?.message?.content;
5
23
  if (typeof content === 'string') {
@@ -49,35 +67,122 @@ function normalizeBool(value, fallback = false) {
49
67
  return fallback;
50
68
  }
51
69
 
52
- export function parseLlmJson(content) {
53
- const text = String(content || '').trim();
54
- if (!text) {
55
- throw new Error('LLM returned empty content');
70
+ function toStringArray(value, maxItems = 8) {
71
+ if (!Array.isArray(value)) return [];
72
+ const normalized = [];
73
+ for (const item of value) {
74
+ const text = normalizeText(item);
75
+ if (!text) continue;
76
+ normalized.push(text);
77
+ if (normalized.length >= maxItems) break;
56
78
  }
79
+ return normalized;
80
+ }
57
81
 
58
- const codeFenceMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
59
- const candidate = codeFenceMatch ? codeFenceMatch[1] : text;
60
- const jsonMatch = candidate.match(/\{[\s\S]*\}/);
61
- if (!jsonMatch) {
62
- throw new Error('LLM response did not contain JSON');
82
+ function extractEvidenceTokens(text, maxItems = MAX_EVIDENCE_TOKENS) {
83
+ const normalized = normalizeText(text);
84
+ if (!normalized) return [];
85
+ const matched = normalized.match(/[\u4e00-\u9fff]{2,}|[A-Za-z][A-Za-z0-9.+#_-]{2,}|\d{3,}/g) || [];
86
+ const seen = new Set();
87
+ const picked = [];
88
+ const sorted = matched
89
+ .map((item) => normalizeText(item))
90
+ .filter(Boolean)
91
+ .sort((a, b) => b.length - a.length);
92
+ for (const token of sorted) {
93
+ const key = toLowerSafe(token);
94
+ if (seen.has(key)) continue;
95
+ seen.add(key);
96
+ picked.push(token);
97
+ if (picked.length >= maxItems) break;
63
98
  }
99
+ return picked;
100
+ }
64
101
 
65
- const parsed = JSON.parse(jsonMatch[0]);
66
- const passed = typeof parsed.passed === 'boolean' ? parsed.passed : parsed.matched;
67
- if (typeof passed !== 'boolean') {
68
- throw new Error('LLM response missing boolean "passed"');
102
+ function matchEvidenceAgainstResume(evidenceText, rawResumeText, normalizedResumeText, normalizedResumeLowerText) {
103
+ const normalizedEvidence = normalizeText(evidenceText);
104
+ if (!normalizedEvidence) {
105
+ return {
106
+ matched: false,
107
+ mode: 'empty',
108
+ matchedTokens: [],
109
+ };
69
110
  }
70
- if (typeof parsed.reason !== 'string' || !parsed.reason.trim()) {
71
- throw new Error('LLM response missing string "reason"');
111
+ if (rawResumeText.includes(evidenceText) || normalizedResumeText.includes(normalizedEvidence)) {
112
+ return {
113
+ matched: true,
114
+ mode: 'exact',
115
+ matchedTokens: [normalizedEvidence],
116
+ };
72
117
  }
73
-
118
+ const evidenceTokens = extractEvidenceTokens(normalizedEvidence, MAX_EVIDENCE_TOKENS);
119
+ if (evidenceTokens.length <= 0) {
120
+ return {
121
+ matched: false,
122
+ mode: 'token_empty',
123
+ matchedTokens: [],
124
+ };
125
+ }
126
+ const matchedTokens = [];
127
+ for (const token of evidenceTokens) {
128
+ if (normalizedResumeLowerText.includes(toLowerSafe(token))) {
129
+ matchedTokens.push(token);
130
+ }
131
+ }
132
+ const requiredHits = evidenceTokens.length >= 4 ? 2 : 1;
74
133
  return {
75
- passed,
76
- reason: parsed.reason.trim(),
134
+ matched: matchedTokens.length >= requiredHits,
135
+ mode: 'token_fuzzy',
136
+ matchedTokens,
77
137
  };
78
138
  }
79
139
 
80
- function buildPrompt({ screeningCriteria, candidate }) {
140
+ function splitTextByChunks(text, chunkSize, overlap, maxChunks) {
141
+ const source = String(text || '');
142
+ if (!source) return [];
143
+
144
+ const safeChunkSize = Math.max(1000, parsePositiveInteger(chunkSize) || DEFAULT_TEXT_MODEL_CHUNK_SIZE_CHARS);
145
+ const safeOverlap = Math.max(
146
+ 0,
147
+ Math.min(safeChunkSize - 1, parsePositiveInteger(overlap) || DEFAULT_TEXT_MODEL_CHUNK_OVERLAP_CHARS),
148
+ );
149
+ const safeMaxChunks = Math.max(1, parsePositiveInteger(maxChunks) || DEFAULT_TEXT_MODEL_MAX_CHUNKS);
150
+
151
+ const chunks = [];
152
+ let start = 0;
153
+ while (start < source.length && chunks.length < safeMaxChunks) {
154
+ const end = Math.min(source.length, start + safeChunkSize);
155
+ chunks.push({
156
+ text: source.slice(start, end),
157
+ start,
158
+ end,
159
+ });
160
+ if (end >= source.length) break;
161
+ start = Math.max(0, end - safeOverlap);
162
+ }
163
+
164
+ if (chunks.length > 0) {
165
+ const last = chunks[chunks.length - 1];
166
+ if (last.end < source.length) {
167
+ chunks[chunks.length - 1] = {
168
+ text: source.slice(last.start),
169
+ start: last.start,
170
+ end: source.length,
171
+ };
172
+ }
173
+ }
174
+ return chunks;
175
+ }
176
+
177
+ function isTextContextLimitMessage(message) {
178
+ const text = normalizeText(message).toLowerCase();
179
+ if (!text) return false;
180
+ return /context length|maximum context|too many tokens|max(?:imum)? token|prompt is too long|input is too long|token limit|上下文|超出.*token|超过.*token|输入过长/i.test(
181
+ text,
182
+ );
183
+ }
184
+
185
+ function buildProfileContext(candidate) {
81
186
  const schools = Array.isArray(candidate?.resumeProfile?.schools)
82
187
  ? candidate.resumeProfile.schools.map((item) => String(item || '').trim()).filter(Boolean)
83
188
  : [];
@@ -98,15 +203,20 @@ function buildPrompt({ screeningCriteria, candidate }) {
98
203
  if (profileCompany) profileContext.push(`最近公司:${profileCompany}`);
99
204
  if (profilePosition) profileContext.push(`最近职位:${profilePosition}`);
100
205
  }
206
+ return profileContext;
207
+ }
101
208
 
209
+ function buildImagePrompt({ screeningCriteria, candidate }) {
210
+ const profileContext = buildProfileContext(candidate);
102
211
  return [
103
212
  '你是招聘筛选助手,请基于简历截图判断候选人是否符合筛选标准。',
104
213
  '只能依据图片中可见信息判断,不得臆测。',
105
214
  '只采信当前候选人的主简历内容(教育经历/工作经历/项目经历/专业技能)。',
106
215
  '必须忽略推荐模块与匿名卡片信息(例如“其他名企大厂经历牛人”“相似牛人”“推荐牛人”)。',
107
216
  '若无法在教育经历模块确认学校名称,不要编造学校名;按信息不足处理。',
217
+ '必须完整阅读全部简历截图分段后再判断。',
108
218
  '必须且只能返回 JSON,不要输出 Markdown。',
109
- '返回格式:{"passed":true/false,"reason":"简短中文原因"}',
219
+ '返回格式:{"passed":true/false,"reason":"简短中文原因","summary":"简短总结","evidence":["证据原文1","证据原文2"]}',
110
220
  '当信息不足以支持通过时,返回 passed=false。',
111
221
  '',
112
222
  `筛选标准:${screeningCriteria}`,
@@ -118,6 +228,100 @@ function buildPrompt({ screeningCriteria, candidate }) {
118
228
  ].join('\n');
119
229
  }
120
230
 
231
+ function buildTextPrompt({ screeningCriteria, candidate, resumeText, chunkIndex = 1, chunkTotal = 1 }) {
232
+ const profileContext = buildProfileContext(candidate);
233
+ const chunkHint =
234
+ chunkTotal > 1
235
+ ? `\n\n当前输入是简历分段 ${chunkIndex}/${chunkTotal}。请严格基于本分段文本判断;如果本分段证据不足,必须返回 passed=false。`
236
+ : '';
237
+ return [
238
+ '你是招聘筛选助手,请基于简历文本判断候选人是否符合筛选标准。',
239
+ '只能依据输入文本中可见信息判断,不得臆测。',
240
+ '只采信当前候选人的主简历内容(教育经历/工作经历/项目经历/专业技能)。',
241
+ '必须忽略推荐模块与匿名卡片信息(例如“其他名企大厂经历牛人”“相似牛人”“推荐牛人”)。',
242
+ '若无法在教育经历模块确认学校名称,不要编造学校名;按信息不足处理。',
243
+ '必须且只能返回 JSON,不要输出 Markdown。',
244
+ '返回格式:{"passed":true/false,"reason":"简短中文原因","summary":"简短总结","evidence":["证据原文1","证据原文2"]}',
245
+ '当信息不足以支持通过时,返回 passed=false。',
246
+ '',
247
+ `筛选标准:${screeningCriteria}`,
248
+ '',
249
+ '候选人上下文(仅供辅助,不可覆盖简历事实):',
250
+ `姓名:${candidate.name || '未知'}`,
251
+ `投递职位:${candidate.sourceJob || '未知'}`,
252
+ ...(profileContext.length > 0 ? ['', ...profileContext] : []),
253
+ '',
254
+ `简历文本:\n${String(resumeText || '')}${chunkHint}`,
255
+ ].join('\n');
256
+ }
257
+
258
+ export function parseLlmJson(content, options = {}) {
259
+ const text = String(content || '').trim();
260
+ if (!text) {
261
+ throw new Error('LLM returned empty content');
262
+ }
263
+
264
+ const codeFenceMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
265
+ const candidate = codeFenceMatch ? codeFenceMatch[1] : text;
266
+ const jsonMatch = candidate.match(/\{[\s\S]*\}/);
267
+ if (!jsonMatch) {
268
+ throw new Error('LLM response did not contain JSON');
269
+ }
270
+
271
+ const parsed = JSON.parse(jsonMatch[0]);
272
+ const parsedPassed = typeof parsed.passed === 'boolean' ? parsed.passed : parsed.matched;
273
+ if (typeof parsedPassed !== 'boolean') {
274
+ throw new Error('LLM response missing boolean "passed"');
275
+ }
276
+ if (typeof parsed.reason !== 'string' || !parsed.reason.trim()) {
277
+ throw new Error('LLM response missing string "reason"');
278
+ }
279
+
280
+ const reason = normalizeText(parsed.reason);
281
+ const summary = normalizeText(parsed.summary || reason);
282
+ const parsedEvidence = toStringArray(parsed.evidence);
283
+
284
+ const evidenceCorpus = normalizeText(options.evidenceCorpus || options.rawResumeText || '');
285
+ const chunkIndex = Number.isInteger(options.chunkIndex) && options.chunkIndex > 0 ? options.chunkIndex : 1;
286
+ const chunkTotal = Number.isInteger(options.chunkTotal) && options.chunkTotal > 0 ? options.chunkTotal : 1;
287
+
288
+ let evidence = parsedEvidence;
289
+ let evidenceMatchedCount = parsedEvidence.length;
290
+ if (evidenceCorpus) {
291
+ const normalizedCorpus = normalizeText(evidenceCorpus);
292
+ const normalizedCorpusLower = toLowerSafe(normalizedCorpus);
293
+ evidence = [];
294
+ for (const item of parsedEvidence) {
295
+ const matched = matchEvidenceAgainstResume(item, evidenceCorpus, normalizedCorpus, normalizedCorpusLower);
296
+ if (matched.matched) {
297
+ evidence.push(item);
298
+ }
299
+ }
300
+ evidenceMatchedCount = evidence.length;
301
+ }
302
+
303
+ const rawPassed = parsedPassed === true;
304
+ const evidenceRawCount = parsedEvidence.length;
305
+ const evidenceGateDemoted = rawPassed && evidenceMatchedCount <= 0;
306
+ const passed = evidenceGateDemoted ? false : rawPassed;
307
+ const finalReason = evidenceGateDemoted
308
+ ? `模型未给出可在简历原文中校验的证据,按安全策略判为不通过。${reason ? ` 原始原因: ${reason}` : ''}`
309
+ : reason;
310
+
311
+ return {
312
+ passed,
313
+ rawPassed,
314
+ reason: finalReason || '模型未返回有效理由。',
315
+ summary: summary || finalReason || '模型未返回有效总结。',
316
+ evidence,
317
+ evidenceRawCount,
318
+ evidenceMatchedCount,
319
+ evidenceGateDemoted,
320
+ chunkIndex,
321
+ chunkTotal,
322
+ };
323
+ }
324
+
121
325
  function shouldFallbackToCompletions(error) {
122
326
  if (error?.code === 'RESPONSES_EMPTY_CONTENT') return true;
123
327
  if (error?.code === 'RESPONSES_INCOMPLETE_LENGTH') return true;
@@ -196,7 +400,11 @@ export class LlmClient {
196
400
  throw lastError || new Error(`${label} evaluation failed`);
197
401
  }
198
402
 
199
- async requestResponses(prompt, imageDataUrl) {
403
+ async requestResponses({ prompt, imageDataUrl = null, evidenceCorpus = '', chunkIndex = 1, chunkTotal = 1 }) {
404
+ const content = [{ type: 'input_text', text: prompt }];
405
+ if (imageDataUrl) {
406
+ content.push({ type: 'input_image', image_url: imageDataUrl });
407
+ }
200
408
  const response = await this.fetchImpl(`${this.baseUrl}/responses`, {
201
409
  method: 'POST',
202
410
  headers: {
@@ -210,10 +418,7 @@ export class LlmClient {
210
418
  input: [
211
419
  {
212
420
  role: 'user',
213
- content: [
214
- { type: 'input_text', text: prompt },
215
- { type: 'input_image', image_url: imageDataUrl },
216
- ],
421
+ content,
217
422
  },
218
423
  ],
219
424
  }),
@@ -232,8 +437,8 @@ export class LlmClient {
232
437
  throw new Error(`Responses API error: ${data.error.message}`);
233
438
  }
234
439
 
235
- const content = getResponsesContent(data);
236
- if (!content) {
440
+ const outputContent = getResponsesContent(data);
441
+ if (!outputContent) {
237
442
  const incompleteReason = String(data?.incomplete_details?.reason || '').trim();
238
443
  const outputTypes = Array.isArray(data?.output)
239
444
  ? data.output
@@ -253,7 +458,11 @@ export class LlmClient {
253
458
  }
254
459
 
255
460
  try {
256
- return parseLlmJson(content);
461
+ return parseLlmJson(outputContent, {
462
+ evidenceCorpus,
463
+ chunkIndex,
464
+ chunkTotal,
465
+ });
257
466
  } catch (parseError) {
258
467
  const wrapped = new Error(
259
468
  `Responses API returned unparsable content: ${parseError?.message || parseError}`,
@@ -263,7 +472,11 @@ export class LlmClient {
263
472
  }
264
473
  }
265
474
 
266
- async requestCompletions(prompt, imageDataUrl) {
475
+ async requestCompletions({ prompt, imageDataUrl = null, evidenceCorpus = '', chunkIndex = 1, chunkTotal = 1 }) {
476
+ const content = [{ type: 'text', text: prompt }];
477
+ if (imageDataUrl) {
478
+ content.push({ type: 'image_url', image_url: { url: imageDataUrl } });
479
+ }
267
480
  const response = await this.fetchImpl(`${this.baseUrl}/chat/completions`, {
268
481
  method: 'POST',
269
482
  headers: {
@@ -277,10 +490,7 @@ export class LlmClient {
277
490
  messages: [
278
491
  {
279
492
  role: 'user',
280
- content: [
281
- { type: 'text', text: prompt },
282
- { type: 'image_url', image_url: { url: imageDataUrl } },
283
- ],
493
+ content,
284
494
  },
285
495
  ],
286
496
  }),
@@ -299,15 +509,19 @@ export class LlmClient {
299
509
  throw new Error(`Completions API error: ${data.error.message}`);
300
510
  }
301
511
 
302
- const content = getCompletionContent(data);
303
- if (!String(content || '').trim()) {
512
+ const outputContent = getCompletionContent(data);
513
+ if (!String(outputContent || '').trim()) {
304
514
  const emptyError = new Error('Completions API empty textual content');
305
515
  emptyError.code = 'COMPLETIONS_EMPTY_CONTENT';
306
516
  throw emptyError;
307
517
  }
308
518
 
309
519
  try {
310
- return parseLlmJson(content);
520
+ return parseLlmJson(outputContent, {
521
+ evidenceCorpus,
522
+ chunkIndex,
523
+ chunkTotal,
524
+ });
311
525
  } catch (parseError) {
312
526
  const wrapped = new Error(
313
527
  `Completions API returned unparsable content: ${parseError?.message || parseError}`,
@@ -317,36 +531,163 @@ export class LlmClient {
317
531
  }
318
532
  }
319
533
 
320
- async evaluateResume({ screeningCriteria, candidate, imagePath }) {
321
- const prompt = buildPrompt({ screeningCriteria, candidate });
322
- const imageDataUrl = await this.readImageAsDataUrl(imagePath);
323
-
534
+ async requestByPreference(payload) {
324
535
  if (this.preferCompletions) {
325
536
  try {
326
- return await this.withRetries('completions', async () =>
327
- this.requestCompletions(prompt, imageDataUrl),
328
- );
537
+ return await this.withRetries('completions', async () => this.requestCompletions(payload));
329
538
  } catch (completionsError) {
330
539
  if (!shouldFallbackToResponses(completionsError)) {
331
540
  throw completionsError;
332
541
  }
333
- return this.withRetries('responses', async () =>
334
- this.requestResponses(prompt, imageDataUrl),
335
- );
542
+ return this.withRetries('responses', async () => this.requestResponses(payload));
336
543
  }
337
544
  }
338
545
 
339
546
  try {
340
- return await this.withRetries('responses', async () =>
341
- this.requestResponses(prompt, imageDataUrl),
342
- );
547
+ return await this.withRetries('responses', async () => this.requestResponses(payload));
343
548
  } catch (responsesError) {
344
549
  if (!shouldFallbackToCompletions(responsesError)) {
345
550
  throw responsesError;
346
551
  }
347
- return this.withRetries('completions', async () =>
348
- this.requestCompletions(prompt, imageDataUrl),
349
- );
552
+ return this.withRetries('completions', async () => this.requestCompletions(payload));
553
+ }
554
+ }
555
+
556
+ async evaluateImageResume({ screeningCriteria, candidate, imagePath }) {
557
+ const prompt = buildImagePrompt({ screeningCriteria, candidate });
558
+ const imageDataUrl = await this.readImageAsDataUrl(imagePath);
559
+ const evidenceCorpus = normalizeText(candidate?.evidenceCorpus || candidate?.resumeText || '');
560
+ const result = await this.requestByPreference({
561
+ prompt,
562
+ imageDataUrl,
563
+ evidenceCorpus,
564
+ chunkIndex: 1,
565
+ chunkTotal: 1,
566
+ });
567
+ return {
568
+ ...result,
569
+ evaluationMode: 'image',
570
+ };
571
+ }
572
+
573
+ async evaluateTextResume({ screeningCriteria, candidate }) {
574
+ const fullResumeText = String(candidate?.resumeText || '');
575
+ const normalizedResumeText = normalizeText(fullResumeText);
576
+ if (!normalizedResumeText) {
577
+ throw new Error('TEXT_MODEL_FAILED: resume text is empty');
578
+ }
579
+ const evidenceCorpus = normalizeText(candidate?.evidenceCorpus || fullResumeText);
580
+
581
+ const requestSingleChunk = () =>
582
+ this.requestByPreference({
583
+ prompt: buildTextPrompt({
584
+ screeningCriteria,
585
+ candidate,
586
+ resumeText: fullResumeText,
587
+ chunkIndex: 1,
588
+ chunkTotal: 1,
589
+ }),
590
+ imageDataUrl: null,
591
+ evidenceCorpus,
592
+ chunkIndex: 1,
593
+ chunkTotal: 1,
594
+ });
595
+
596
+ try {
597
+ const single = await requestSingleChunk();
598
+ return {
599
+ ...single,
600
+ evaluationMode: 'text',
601
+ };
602
+ } catch (error) {
603
+ if (!isTextContextLimitMessage(error?.message || '')) {
604
+ throw error;
605
+ }
350
606
  }
607
+
608
+ const chunkSize = parsePositiveInteger(process.env.BOSS_CHAT_TEXT_CHUNK_SIZE_CHARS) || DEFAULT_TEXT_MODEL_CHUNK_SIZE_CHARS;
609
+ const overlap = parsePositiveInteger(process.env.BOSS_CHAT_TEXT_CHUNK_OVERLAP_CHARS) || DEFAULT_TEXT_MODEL_CHUNK_OVERLAP_CHARS;
610
+ const maxChunks = parsePositiveInteger(process.env.BOSS_CHAT_TEXT_MAX_CHUNKS) || DEFAULT_TEXT_MODEL_MAX_CHUNKS;
611
+ const chunks = splitTextByChunks(fullResumeText, chunkSize, overlap, maxChunks);
612
+ if (!chunks.length) {
613
+ throw new Error('TEXT_MODEL_FAILED: resume text is empty after chunk split');
614
+ }
615
+
616
+ const chunkResults = [];
617
+ for (let index = 0; index < chunks.length; index += 1) {
618
+ const chunk = chunks[index];
619
+ const result = await this.requestByPreference({
620
+ prompt: buildTextPrompt({
621
+ screeningCriteria,
622
+ candidate,
623
+ resumeText: chunk.text,
624
+ chunkIndex: index + 1,
625
+ chunkTotal: chunks.length,
626
+ }),
627
+ imageDataUrl: null,
628
+ evidenceCorpus: chunk.text,
629
+ chunkIndex: index + 1,
630
+ chunkTotal: chunks.length,
631
+ });
632
+ chunkResults.push(result);
633
+ }
634
+
635
+ const passedChunks = chunkResults.filter((item) => item?.passed === true);
636
+ if (passedChunks.length > 0) {
637
+ const best = passedChunks[0];
638
+ return {
639
+ ...best,
640
+ evaluationMode: 'text',
641
+ };
642
+ }
643
+
644
+ const firstReason = chunkResults.map((item) => normalizeText(item?.reason)).find(Boolean);
645
+ return {
646
+ passed: false,
647
+ rawPassed: chunkResults.some((item) => item?.rawPassed === true),
648
+ reason: firstReason || `分段筛选未找到满足标准的证据(共 ${chunks.length} 段)。`,
649
+ summary: firstReason || `分段筛选未找到满足标准的证据(共 ${chunks.length} 段)。`,
650
+ evidence: [],
651
+ evidenceRawCount: chunkResults.reduce(
652
+ (acc, item) =>
653
+ acc + (Number.isFinite(Number(item?.evidenceRawCount)) ? Number(item.evidenceRawCount) : 0),
654
+ 0,
655
+ ),
656
+ evidenceMatchedCount: chunkResults.reduce(
657
+ (acc, item) =>
658
+ acc + (Number.isFinite(Number(item?.evidenceMatchedCount)) ? Number(item.evidenceMatchedCount) : 0),
659
+ 0,
660
+ ),
661
+ evidenceGateDemoted: chunkResults.some((item) => item?.evidenceGateDemoted === true),
662
+ chunkIndex: null,
663
+ chunkTotal: chunks.length,
664
+ evaluationMode: 'text',
665
+ };
666
+ }
667
+
668
+ async evaluateResume({ screeningCriteria, candidate, imagePath }) {
669
+ const hasResumeText = Boolean(normalizeText(candidate?.resumeText || ''));
670
+ if (hasResumeText) {
671
+ try {
672
+ return await this.evaluateTextResume({ screeningCriteria, candidate });
673
+ } catch (textError) {
674
+ if (!imagePath) {
675
+ throw textError;
676
+ }
677
+ const imageResult = await this.evaluateImageResume({ screeningCriteria, candidate, imagePath });
678
+ return {
679
+ ...imageResult,
680
+ textFallbackError: normalizeText(textError?.message || textError),
681
+ };
682
+ }
683
+ }
684
+ return this.evaluateImageResume({ screeningCriteria, candidate, imagePath });
351
685
  }
352
686
  }
687
+
688
+ export const __testables = {
689
+ extractEvidenceTokens,
690
+ matchEvidenceAgainstResume,
691
+ splitTextByChunks,
692
+ isTextContextLimitMessage,
693
+ };