@reconcrap/boss-recommend-mcp 1.3.31 → 1.3.33

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,798 +1,1292 @@
1
- import { readFile } from 'node:fs/promises';
2
-
1
+ import { readFile } from 'node:fs/promises';
2
+
3
3
  const DEFAULT_TEXT_MODEL_CHUNK_SIZE_CHARS = 24000;
4
4
  const DEFAULT_TEXT_MODEL_CHUNK_OVERLAP_CHARS = 1200;
5
5
  const DEFAULT_TEXT_MODEL_MAX_CHUNKS = 12;
6
+ const LONG_RESUME_AGGREGATE_LIMITS_STANDARD = {
7
+ summaryMaxLength: 180,
8
+ evidenceMaxItems: 3,
9
+ blockerMaxItems: 3,
10
+ uncertaintyMaxItems: 2,
11
+ quoteMaxItems: 2,
12
+ itemMaxLength: 160,
13
+ quoteMaxLength: 120,
14
+ };
15
+ const LONG_RESUME_AGGREGATE_LIMITS_COMPACT = {
16
+ summaryMaxLength: 120,
17
+ evidenceMaxItems: 2,
18
+ blockerMaxItems: 2,
19
+ uncertaintyMaxItems: 1,
20
+ quoteMaxItems: 1,
21
+ itemMaxLength: 96,
22
+ quoteMaxLength: 80,
23
+ };
6
24
  const MAX_EVIDENCE_TOKENS = 12;
7
- const LLM_THINKING_ENV_KEYS = [
8
- 'BOSS_CHAT_LLM_THINKING_LEVEL',
9
- 'BOSS_RECOMMEND_LLM_THINKING_LEVEL',
10
- 'BOSS_LLM_THINKING_LEVEL',
11
- 'LLM_THINKING_LEVEL',
12
- ];
13
-
25
+ const LLM_THINKING_ENV_KEYS = [
26
+ 'BOSS_CHAT_LLM_THINKING_LEVEL',
27
+ 'BOSS_RECOMMEND_LLM_THINKING_LEVEL',
28
+ 'BOSS_LLM_THINKING_LEVEL',
29
+ 'LLM_THINKING_LEVEL',
30
+ ];
31
+
14
32
  function normalizeText(value) {
15
33
  return String(value || '').replace(/\s+/g, ' ').trim();
16
34
  }
17
35
 
18
- function toLowerSafe(text) {
19
- return String(text || '').toLowerCase();
20
- }
21
-
22
- function parsePositiveInteger(value) {
23
- const parsed = Number.parseInt(String(value ?? ''), 10);
24
- return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
25
- }
26
-
27
- function getCompletionContent(data) {
28
- const content = data?.choices?.[0]?.message?.content;
29
- if (typeof content === 'string') {
30
- return content;
31
- }
32
-
33
- if (Array.isArray(content)) {
34
- return content
35
- .map((part) => {
36
- if (typeof part === 'string') return part;
37
- if (part?.type === 'text') return part.text || '';
38
- return '';
39
- })
40
- .join('');
41
- }
42
-
43
- return '';
44
- }
45
-
46
- function getResponsesContent(data) {
47
- if (typeof data?.output_text === 'string' && data.output_text.trim()) {
48
- return data.output_text;
49
- }
50
-
51
- const output = Array.isArray(data?.output) ? data.output : [];
52
- const parts = [];
53
- for (const item of output) {
54
- const content = Array.isArray(item?.content) ? item.content : [];
55
- for (const chunk of content) {
56
- if (typeof chunk?.text === 'string') {
57
- parts.push(chunk.text);
58
- }
59
- }
60
- }
61
- return parts.join('\n').trim();
62
- }
63
-
64
- function normalizeBool(value, fallback = false) {
65
- if (typeof value === 'boolean') return value;
66
- if (typeof value === 'number') return value !== 0;
67
- const normalized = String(value || '')
68
- .trim()
69
- .toLowerCase();
70
- if (!normalized) return fallback;
71
- if (['true', '1', 'yes', 'y', 'on'].includes(normalized)) return true;
72
- if (['false', '0', 'no', 'n', 'off'].includes(normalized)) return false;
73
- return fallback;
74
- }
75
-
76
- function normalizeLlmThinkingLevel(value) {
77
- const normalized = normalizeText(value).toLowerCase().replace(/[_\s]+/g, '-');
78
- if (!normalized) return '';
79
- if (['off', 'disabled', 'disable', 'minimal', 'none', 'false', '0'].includes(normalized)) return 'off';
80
- if (
81
- ['low', 'medium', 'high', 'auto', 'current', 'default', 'provider-default', 'unchanged', 'inherit'].includes(
82
- normalized,
83
- )
84
- ) {
85
- return normalized;
86
- }
87
- return '';
88
- }
89
-
90
- function getEnvLlmThinkingLevel() {
91
- for (const key of LLM_THINKING_ENV_KEYS) {
92
- const normalized = normalizeLlmThinkingLevel(process.env[key]);
93
- if (normalized) return normalized;
94
- }
95
- return '';
96
- }
97
-
98
- function resolveLlmThinkingLevel(config = {}, options = {}) {
99
- return (
100
- normalizeLlmThinkingLevel(options.thinkingLevel) ||
101
- normalizeLlmThinkingLevel(options.llmThinkingLevel) ||
102
- normalizeLlmThinkingLevel(config.llmThinkingLevel) ||
103
- normalizeLlmThinkingLevel(config.thinkingLevel) ||
104
- normalizeLlmThinkingLevel(config.reasoningEffort) ||
105
- normalizeLlmThinkingLevel(config.reasoning_effort) ||
106
- getEnvLlmThinkingLevel() ||
107
- 'low'
108
- );
109
- }
110
-
111
- function isProviderDefaultThinkingLevel(level) {
112
- return ['current', 'default', 'provider-default', 'unchanged', 'inherit'].includes(level);
113
- }
114
-
115
- function isVolcengineModel(baseUrl, model) {
116
- const combined = `${baseUrl || ''} ${model || ''}`;
117
- return /volces\.com|volcengine|ark\.cn-|doubao|seed/i.test(combined);
118
- }
119
-
120
- function applyChatCompletionThinking(payload, { baseUrl = '', model = '', thinkingLevel = '' } = {}) {
121
- const level = normalizeLlmThinkingLevel(thinkingLevel) || 'low';
122
- if (isProviderDefaultThinkingLevel(level)) return payload;
123
- const isVolc = isVolcengineModel(baseUrl, model);
124
- if (isVolc) {
125
- if (level === 'auto') {
126
- payload.thinking = { type: 'auto' };
127
- return payload;
128
- }
129
- if (level === 'off') {
130
- payload.thinking = { type: 'disabled' };
131
- payload.reasoning_effort = 'minimal';
132
- return payload;
133
- }
134
- payload.thinking = { type: 'enabled' };
135
- payload.reasoning_effort = level;
136
- return payload;
137
- }
138
- if (level !== 'auto') {
139
- payload.reasoning_effort = level === 'off' ? 'minimal' : level;
140
- }
141
- return payload;
142
- }
143
-
144
- function applyResponsesThinking(payload, { thinkingLevel = '' } = {}) {
145
- const level = normalizeLlmThinkingLevel(thinkingLevel) || 'low';
146
- if (isProviderDefaultThinkingLevel(level) || level === 'auto') return payload;
147
- payload.reasoning = {
148
- ...(payload.reasoning || {}),
149
- effort: level === 'off' ? 'minimal' : level,
150
- };
151
- return payload;
36
+ function truncateText(value, maxLength = 96) {
37
+ const text = normalizeText(value);
38
+ if (text.length <= maxLength) return text;
39
+ return `${text.slice(0, Math.max(12, maxLength - 1))}…`;
152
40
  }
153
-
41
+
42
+ function toLowerSafe(text) {
43
+ return String(text || '').toLowerCase();
44
+ }
45
+
46
+ function parsePositiveInteger(value) {
47
+ const parsed = Number.parseInt(String(value ?? ''), 10);
48
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
49
+ }
50
+
51
+ function getCompletionContent(data) {
52
+ const content = data?.choices?.[0]?.message?.content;
53
+ if (typeof content === 'string') {
54
+ return content;
55
+ }
56
+
57
+ if (Array.isArray(content)) {
58
+ return content
59
+ .map((part) => {
60
+ if (typeof part === 'string') return part;
61
+ if (part?.type === 'text') return part.text || '';
62
+ return '';
63
+ })
64
+ .join('');
65
+ }
66
+
67
+ return '';
68
+ }
69
+
70
+ function flattenChatMessageContent(content) {
71
+ if (Array.isArray(content)) {
72
+ return content
73
+ .map((item) => {
74
+ if (typeof item === 'string') return item;
75
+ if (item && typeof item === 'object') {
76
+ return item.text || item.content || item.reasoning_content || '';
77
+ }
78
+ return '';
79
+ })
80
+ .filter(Boolean)
81
+ .join('\n');
82
+ }
83
+ return String(content || '');
84
+ }
85
+
86
+ function getResponsesContent(data) {
87
+ if (typeof data?.output_text === 'string' && data.output_text.trim()) {
88
+ return data.output_text;
89
+ }
90
+
91
+ const output = Array.isArray(data?.output) ? data.output : [];
92
+ const parts = [];
93
+ for (const item of output) {
94
+ const content = Array.isArray(item?.content) ? item.content : [];
95
+ for (const chunk of content) {
96
+ if (typeof chunk?.text === 'string') {
97
+ parts.push(chunk.text);
98
+ }
99
+ }
100
+ }
101
+ return parts.join('\n').trim();
102
+ }
103
+
104
+ function normalizeBool(value, fallback = false) {
105
+ if (typeof value === 'boolean') return value;
106
+ if (typeof value === 'number') return value !== 0;
107
+ const normalized = String(value || '')
108
+ .trim()
109
+ .toLowerCase();
110
+ if (!normalized) return fallback;
111
+ if (['true', '1', 'yes', 'y', 'on'].includes(normalized)) return true;
112
+ if (['false', '0', 'no', 'n', 'off'].includes(normalized)) return false;
113
+ return fallback;
114
+ }
115
+
116
+ function normalizeLlmThinkingLevel(value) {
117
+ const normalized = normalizeText(value).toLowerCase().replace(/[_\s]+/g, '-');
118
+ if (!normalized) return '';
119
+ if (['off', 'disabled', 'disable', 'minimal', 'none', 'false', '0'].includes(normalized)) return 'off';
120
+ if (
121
+ ['low', 'medium', 'high', 'auto', 'current', 'default', 'provider-default', 'unchanged', 'inherit'].includes(
122
+ normalized,
123
+ )
124
+ ) {
125
+ return normalized;
126
+ }
127
+ return '';
128
+ }
129
+
130
+ function getEnvLlmThinkingLevel() {
131
+ for (const key of LLM_THINKING_ENV_KEYS) {
132
+ const normalized = normalizeLlmThinkingLevel(process.env[key]);
133
+ if (normalized) return normalized;
134
+ }
135
+ return '';
136
+ }
137
+
138
+ function resolveLlmThinkingLevel(config = {}, options = {}) {
139
+ return (
140
+ normalizeLlmThinkingLevel(options.thinkingLevel) ||
141
+ normalizeLlmThinkingLevel(options.llmThinkingLevel) ||
142
+ normalizeLlmThinkingLevel(config.llmThinkingLevel) ||
143
+ normalizeLlmThinkingLevel(config.thinkingLevel) ||
144
+ normalizeLlmThinkingLevel(config.reasoningEffort) ||
145
+ normalizeLlmThinkingLevel(config.reasoning_effort) ||
146
+ getEnvLlmThinkingLevel() ||
147
+ 'low'
148
+ );
149
+ }
150
+
151
+ function isProviderDefaultThinkingLevel(level) {
152
+ return ['current', 'default', 'provider-default', 'unchanged', 'inherit'].includes(level);
153
+ }
154
+
155
+ function isVolcengineModel(baseUrl, model) {
156
+ const combined = `${baseUrl || ''} ${model || ''}`;
157
+ return /volces\.com|volcengine|ark\.cn-|doubao|seed/i.test(combined);
158
+ }
159
+
160
+ function applyChatCompletionThinking(payload, { baseUrl = '', model = '', thinkingLevel = '' } = {}) {
161
+ const level = normalizeLlmThinkingLevel(thinkingLevel) || 'low';
162
+ if (isProviderDefaultThinkingLevel(level)) return payload;
163
+ const isVolc = isVolcengineModel(baseUrl, model);
164
+ if (isVolc) {
165
+ if (level === 'auto') {
166
+ payload.thinking = { type: 'auto' };
167
+ return payload;
168
+ }
169
+ if (level === 'off') {
170
+ payload.thinking = { type: 'disabled' };
171
+ payload.reasoning_effort = 'minimal';
172
+ return payload;
173
+ }
174
+ payload.thinking = { type: 'enabled' };
175
+ payload.reasoning_effort = level;
176
+ return payload;
177
+ }
178
+ if (level !== 'auto') {
179
+ payload.reasoning_effort = level === 'off' ? 'minimal' : level;
180
+ }
181
+ return payload;
182
+ }
183
+
184
+ function applyResponsesThinking(payload, { thinkingLevel = '' } = {}) {
185
+ const level = normalizeLlmThinkingLevel(thinkingLevel) || 'low';
186
+ if (isProviderDefaultThinkingLevel(level) || level === 'auto') return payload;
187
+ payload.reasoning = {
188
+ ...(payload.reasoning || {}),
189
+ effort: level === 'off' ? 'minimal' : level,
190
+ };
191
+ return payload;
192
+ }
193
+
154
194
  function toStringArray(value, maxItems = 8) {
155
195
  if (!Array.isArray(value)) return [];
196
+ const normalized = [];
197
+ for (const item of value) {
198
+ const text = normalizeText(item);
199
+ if (!text) continue;
200
+ normalized.push(text);
201
+ if (normalized.length >= maxItems) break;
202
+ }
203
+ return normalized;
204
+ }
205
+
206
+ function dedupeNormalizedList(value, maxItems = 8, maxLength = 160) {
207
+ const source = Array.isArray(value) ? value : [];
156
208
  const normalized = [];
157
- for (const item of value) {
158
- const text = normalizeText(item);
159
- if (!text) continue;
209
+ const seen = new Set();
210
+ for (const item of source) {
211
+ const text = truncateText(item, maxLength);
212
+ const key = toLowerSafe(text);
213
+ if (!text || seen.has(key)) continue;
214
+ seen.add(key);
160
215
  normalized.push(text);
161
216
  if (normalized.length >= maxItems) break;
162
217
  }
163
218
  return normalized;
164
219
  }
220
+
221
+ function collectNestedText(value, out = [], depth = 0) {
222
+ if (depth > 6 || value === null || value === undefined) return out;
223
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
224
+ const normalized = normalizeText(String(value));
225
+ if (normalized) out.push(normalized);
226
+ return out;
227
+ }
228
+ if (Array.isArray(value)) {
229
+ for (const item of value) {
230
+ collectNestedText(item, out, depth + 1);
231
+ }
232
+ return out;
233
+ }
234
+ if (typeof value === 'object') {
235
+ const priorityKeys = ['text', 'reasoning_content', 'summary_text', 'summary', 'content', 'cot', 'reason'];
236
+ const seen = new Set();
237
+ for (const key of priorityKeys) {
238
+ if (Object.prototype.hasOwnProperty.call(value, key)) {
239
+ seen.add(key);
240
+ collectNestedText(value[key], out, depth + 1);
241
+ }
242
+ }
243
+ for (const [key, nested] of Object.entries(value)) {
244
+ if (seen.has(key)) continue;
245
+ collectNestedText(nested, out, depth + 1);
246
+ }
247
+ }
248
+ return out;
249
+ }
250
+
251
+ function dedupeTextFragments(fragments = []) {
252
+ const deduped = [];
253
+ const seen = new Set();
254
+ for (const item of fragments) {
255
+ const normalized = normalizeText(item);
256
+ if (!normalized) continue;
257
+ if (seen.has(normalized)) continue;
258
+ seen.add(normalized);
259
+ deduped.push(normalized);
260
+ }
261
+ return deduped;
262
+ }
263
+
264
+ function joinTextFragments(fragments = []) {
265
+ return dedupeTextFragments(fragments).join('\n');
266
+ }
267
+
268
+ function extractCompletionReasoningText(data) {
269
+ const choice = data?.choices?.[0] || {};
270
+ const fragments = [];
271
+ const content = choice?.message?.content;
272
+ if (Array.isArray(content)) {
273
+ for (const part of content) {
274
+ const partType = normalizeText(part?.type || '').toLowerCase();
275
+ if (partType.includes('reason') || partType.includes('summary')) {
276
+ collectNestedText(part, fragments);
277
+ }
278
+ }
279
+ }
280
+ const candidates = [
281
+ choice?.message?.reasoning_content,
282
+ choice?.message?.reasoning,
283
+ choice?.reasoning_content,
284
+ choice?.reasoning,
285
+ ];
286
+ for (const candidate of candidates) {
287
+ collectNestedText(candidate, fragments);
288
+ }
289
+ return joinTextFragments(fragments);
290
+ }
291
+
292
+ function extractResponsesReasoningText(data) {
293
+ const fragments = [];
294
+ collectNestedText(data?.reasoning, fragments);
295
+ collectNestedText(data?.reasoning_content, fragments);
296
+
297
+ const output = Array.isArray(data?.output) ? data.output : [];
298
+ for (const item of output) {
299
+ const itemType = normalizeText(item?.type || '').toLowerCase();
300
+ if (itemType.includes('reason') || itemType.includes('summary')) {
301
+ collectNestedText(item, fragments);
302
+ }
303
+ const content = Array.isArray(item?.content) ? item.content : [];
304
+ for (const chunk of content) {
305
+ const chunkType = normalizeText(chunk?.type || '').toLowerCase();
306
+ if (chunkType.includes('reason') || chunkType.includes('summary')) {
307
+ collectNestedText(chunk, fragments);
308
+ }
309
+ }
310
+ }
311
+
312
+ return joinTextFragments(fragments);
313
+ }
314
+
315
+ function extractEvidenceTokens(text, maxItems = MAX_EVIDENCE_TOKENS) {
316
+ const normalized = normalizeText(text);
317
+ if (!normalized) return [];
318
+ const matched = normalized.match(/[\u4e00-\u9fff]{2,}|[A-Za-z][A-Za-z0-9.+#_-]{2,}|\d{3,}/g) || [];
319
+ const seen = new Set();
320
+ const picked = [];
321
+ const sorted = matched
322
+ .map((item) => normalizeText(item))
323
+ .filter(Boolean)
324
+ .sort((a, b) => b.length - a.length);
325
+ for (const token of sorted) {
326
+ const key = toLowerSafe(token);
327
+ if (seen.has(key)) continue;
328
+ seen.add(key);
329
+ picked.push(token);
330
+ if (picked.length >= maxItems) break;
331
+ }
332
+ return picked;
333
+ }
334
+
335
+ function matchEvidenceAgainstResume(evidenceText, rawResumeText, normalizedResumeText, normalizedResumeLowerText) {
336
+ const normalizedEvidence = normalizeText(evidenceText);
337
+ if (!normalizedEvidence) {
338
+ return {
339
+ matched: false,
340
+ mode: 'empty',
341
+ matchedTokens: [],
342
+ };
343
+ }
344
+ if (rawResumeText.includes(evidenceText) || normalizedResumeText.includes(normalizedEvidence)) {
345
+ return {
346
+ matched: true,
347
+ mode: 'exact',
348
+ matchedTokens: [normalizedEvidence],
349
+ };
350
+ }
351
+ const evidenceTokens = extractEvidenceTokens(normalizedEvidence, MAX_EVIDENCE_TOKENS);
352
+ if (evidenceTokens.length <= 0) {
353
+ return {
354
+ matched: false,
355
+ mode: 'token_empty',
356
+ matchedTokens: [],
357
+ };
358
+ }
359
+ const matchedTokens = [];
360
+ for (const token of evidenceTokens) {
361
+ if (normalizedResumeLowerText.includes(toLowerSafe(token))) {
362
+ matchedTokens.push(token);
363
+ }
364
+ }
365
+ const requiredHits = evidenceTokens.length >= 4 ? 2 : 1;
366
+ return {
367
+ matched: matchedTokens.length >= requiredHits,
368
+ mode: 'token_fuzzy',
369
+ matchedTokens,
370
+ };
371
+ }
165
372
 
166
- function extractEvidenceTokens(text, maxItems = MAX_EVIDENCE_TOKENS) {
167
- const normalized = normalizeText(text);
168
- if (!normalized) return [];
169
- const matched = normalized.match(/[\u4e00-\u9fff]{2,}|[A-Za-z][A-Za-z0-9.+#_-]{2,}|\d{3,}/g) || [];
373
+ function filterEvidenceListAgainstText(value, sourceText, maxItems = 8, maxLength = 160) {
374
+ const rawSource = String(sourceText || '');
375
+ const normalizedSource = normalizeText(rawSource);
376
+ const normalizedSourceLower = toLowerSafe(normalizedSource);
377
+ const result = [];
170
378
  const seen = new Set();
171
- const picked = [];
172
- const sorted = matched
173
- .map((item) => normalizeText(item))
174
- .filter(Boolean)
175
- .sort((a, b) => b.length - a.length);
176
- for (const token of sorted) {
177
- const key = toLowerSafe(token);
379
+ for (const item of Array.isArray(value) ? value : []) {
380
+ const text = truncateText(item, maxLength);
381
+ if (!text) continue;
382
+ const match = matchEvidenceAgainstResume(text, rawSource, normalizedSource, normalizedSourceLower);
383
+ if (!match.matched) continue;
384
+ const key = toLowerSafe(text);
178
385
  if (seen.has(key)) continue;
179
386
  seen.add(key);
180
- picked.push(token);
181
- if (picked.length >= maxItems) break;
387
+ result.push(text);
388
+ if (result.length >= maxItems) break;
182
389
  }
183
- return picked;
390
+ return result;
184
391
  }
185
392
 
186
- function matchEvidenceAgainstResume(evidenceText, rawResumeText, normalizedResumeText, normalizedResumeLowerText) {
187
- const normalizedEvidence = normalizeText(evidenceText);
188
- if (!normalizedEvidence) {
189
- return {
190
- matched: false,
191
- mode: 'empty',
192
- matchedTokens: [],
193
- };
194
- }
195
- if (rawResumeText.includes(evidenceText) || normalizedResumeText.includes(normalizedEvidence)) {
196
- return {
197
- matched: true,
198
- mode: 'exact',
199
- matchedTokens: [normalizedEvidence],
200
- };
201
- }
202
- const evidenceTokens = extractEvidenceTokens(normalizedEvidence, MAX_EVIDENCE_TOKENS);
203
- if (evidenceTokens.length <= 0) {
204
- return {
205
- matched: false,
206
- mode: 'token_empty',
207
- matchedTokens: [],
208
- };
209
- }
210
- const matchedTokens = [];
211
- for (const token of evidenceTokens) {
212
- if (normalizedResumeLowerText.includes(toLowerSafe(token))) {
213
- matchedTokens.push(token);
214
- }
215
- }
216
- const requiredHits = evidenceTokens.length >= 4 ? 2 : 1;
217
- return {
218
- matched: matchedTokens.length >= requiredHits,
219
- mode: 'token_fuzzy',
220
- matchedTokens,
221
- };
222
- }
223
-
224
- function splitTextByChunks(text, chunkSize, overlap, maxChunks) {
225
- const source = String(text || '');
226
- if (!source) return [];
227
-
228
- const safeChunkSize = Math.max(1000, parsePositiveInteger(chunkSize) || DEFAULT_TEXT_MODEL_CHUNK_SIZE_CHARS);
229
- const safeOverlap = Math.max(
230
- 0,
231
- Math.min(safeChunkSize - 1, parsePositiveInteger(overlap) || DEFAULT_TEXT_MODEL_CHUNK_OVERLAP_CHARS),
232
- );
233
- const safeMaxChunks = Math.max(1, parsePositiveInteger(maxChunks) || DEFAULT_TEXT_MODEL_MAX_CHUNKS);
234
-
235
- const chunks = [];
236
- let start = 0;
237
- while (start < source.length && chunks.length < safeMaxChunks) {
238
- const end = Math.min(source.length, start + safeChunkSize);
239
- chunks.push({
240
- text: source.slice(start, end),
241
- start,
242
- end,
243
- });
244
- if (end >= source.length) break;
245
- start = Math.max(0, end - safeOverlap);
246
- }
247
-
248
- if (chunks.length > 0) {
249
- const last = chunks[chunks.length - 1];
250
- if (last.end < source.length) {
251
- chunks[chunks.length - 1] = {
252
- text: source.slice(last.start),
253
- start: last.start,
254
- end: source.length,
255
- };
256
- }
393
+ function filterQuotedSpansAgainstText(value, sourceText, maxItems = 6, maxLength = 120) {
394
+ const rawSource = String(sourceText || '');
395
+ const normalizedSource = normalizeText(rawSource);
396
+ const result = [];
397
+ const seen = new Set();
398
+ for (const item of Array.isArray(value) ? value : []) {
399
+ const text = truncateText(item, maxLength);
400
+ if (!text) continue;
401
+ const normalized = normalizeText(text);
402
+ if (!normalized) continue;
403
+ const matched = rawSource.includes(text) || normalizedSource.includes(normalized);
404
+ if (!matched) continue;
405
+ const key = toLowerSafe(normalized);
406
+ if (seen.has(key)) continue;
407
+ seen.add(key);
408
+ result.push(text);
409
+ if (result.length >= maxItems) break;
257
410
  }
258
- return chunks;
411
+ return result;
259
412
  }
260
-
261
- function isTextContextLimitMessage(message) {
262
- const text = normalizeText(message).toLowerCase();
263
- if (!text) return false;
264
- 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(
265
- text,
266
- );
413
+
414
+ function splitTextByChunks(text, chunkSize, overlap, maxChunks) {
415
+ const source = String(text || '');
416
+ if (!source) return [];
417
+
418
+ const safeChunkSize = Math.max(1000, parsePositiveInteger(chunkSize) || DEFAULT_TEXT_MODEL_CHUNK_SIZE_CHARS);
419
+ const safeOverlap = Math.max(
420
+ 0,
421
+ Math.min(safeChunkSize - 1, parsePositiveInteger(overlap) || DEFAULT_TEXT_MODEL_CHUNK_OVERLAP_CHARS),
422
+ );
423
+ const safeMaxChunks = Math.max(1, parsePositiveInteger(maxChunks) || DEFAULT_TEXT_MODEL_MAX_CHUNKS);
424
+
425
+ const chunks = [];
426
+ let start = 0;
427
+ while (start < source.length && chunks.length < safeMaxChunks) {
428
+ const end = Math.min(source.length, start + safeChunkSize);
429
+ chunks.push({
430
+ text: source.slice(start, end),
431
+ start,
432
+ end,
433
+ });
434
+ if (end >= source.length) break;
435
+ start = Math.max(0, end - safeOverlap);
436
+ }
437
+
438
+ if (chunks.length > 0) {
439
+ const last = chunks[chunks.length - 1];
440
+ if (last.end < source.length) {
441
+ chunks[chunks.length - 1] = {
442
+ text: source.slice(last.start),
443
+ start: last.start,
444
+ end: source.length,
445
+ };
446
+ }
447
+ }
448
+ return chunks;
449
+ }
450
+
451
+ function isTextContextLimitMessage(message) {
452
+ const text = normalizeText(message).toLowerCase();
453
+ if (!text) return false;
454
+ 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(
455
+ text,
456
+ );
457
+ }
458
+
459
+ function buildProfileContext(candidate) {
460
+ const schools = Array.isArray(candidate?.resumeProfile?.schools)
461
+ ? candidate.resumeProfile.schools.map((item) => String(item || '').trim()).filter(Boolean)
462
+ : [];
463
+ const majors = Array.isArray(candidate?.resumeProfile?.majors)
464
+ ? candidate.resumeProfile.majors.map((item) => String(item || '').trim()).filter(Boolean)
465
+ : [];
466
+ const profileSchool = String(candidate?.resumeProfile?.primarySchool || '').trim();
467
+ const profileMajor = String(candidate?.resumeProfile?.major || '').trim();
468
+ const profileCompany = String(candidate?.resumeProfile?.company || '').trim();
469
+ const profilePosition = String(candidate?.resumeProfile?.position || '').trim();
470
+ const profileContext = [];
471
+ if (profileSchool || schools.length > 0 || profileMajor || majors.length > 0 || profileCompany || profilePosition) {
472
+ profileContext.push('简历结构化提取(仅来自当前候选人主简历区域):');
473
+ if (profileSchool) profileContext.push(`主学校:${profileSchool}`);
474
+ if (schools.length > 0) profileContext.push(`学校列表:${schools.join('、')}`);
475
+ if (profileMajor) profileContext.push(`主专业:${profileMajor}`);
476
+ if (majors.length > 0) profileContext.push(`专业列表:${majors.join('、')}`);
477
+ if (profileCompany) profileContext.push(`最近公司:${profileCompany}`);
478
+ if (profilePosition) profileContext.push(`最近职位:${profilePosition}`);
479
+ }
480
+ return profileContext;
267
481
  }
268
482
 
269
- function buildProfileContext(candidate) {
483
+ function buildAggregateCandidateProfile(candidate, compact = false) {
484
+ const maxLength = compact ? 80 : 120;
270
485
  const schools = Array.isArray(candidate?.resumeProfile?.schools)
271
- ? candidate.resumeProfile.schools.map((item) => String(item || '').trim()).filter(Boolean)
486
+ ? dedupeNormalizedList(candidate.resumeProfile.schools, compact ? 2 : 3, maxLength)
272
487
  : [];
273
488
  const majors = Array.isArray(candidate?.resumeProfile?.majors)
274
- ? candidate.resumeProfile.majors.map((item) => String(item || '').trim()).filter(Boolean)
489
+ ? dedupeNormalizedList(candidate.resumeProfile.majors, compact ? 2 : 3, maxLength)
275
490
  : [];
276
- const profileSchool = String(candidate?.resumeProfile?.primarySchool || '').trim();
277
- const profileMajor = String(candidate?.resumeProfile?.major || '').trim();
278
- const profileCompany = String(candidate?.resumeProfile?.company || '').trim();
279
- const profilePosition = String(candidate?.resumeProfile?.position || '').trim();
280
- const profileContext = [];
281
- if (profileSchool || schools.length > 0 || profileMajor || majors.length > 0 || profileCompany || profilePosition) {
282
- profileContext.push('简历结构化提取(仅来自当前候选人主简历区域):');
283
- if (profileSchool) profileContext.push(`主学校:${profileSchool}`);
284
- if (schools.length > 0) profileContext.push(`学校列表:${schools.join('、')}`);
285
- if (profileMajor) profileContext.push(`主专业:${profileMajor}`);
286
- if (majors.length > 0) profileContext.push(`专业列表:${majors.join('、')}`);
287
- if (profileCompany) profileContext.push(`最近公司:${profileCompany}`);
288
- if (profilePosition) profileContext.push(`最近职位:${profilePosition}`);
289
- }
290
- return profileContext;
491
+ const profile = {
492
+ name: truncateText(candidate?.name || '', maxLength),
493
+ sourceJob: truncateText(candidate?.sourceJob || '', maxLength),
494
+ primarySchool: truncateText(candidate?.resumeProfile?.primarySchool || '', maxLength),
495
+ primaryMajor: truncateText(candidate?.resumeProfile?.major || '', maxLength),
496
+ company: truncateText(candidate?.resumeProfile?.company || '', maxLength),
497
+ position: truncateText(candidate?.resumeProfile?.position || '', maxLength),
498
+ schools,
499
+ majors,
500
+ };
501
+ return Object.fromEntries(Object.entries(profile).filter(([, value]) => {
502
+ if (Array.isArray(value)) return value.length > 0;
503
+ return Boolean(normalizeText(value));
504
+ }));
505
+ }
506
+
507
+ function buildImagePrompt({ screeningCriteria, candidate }) {
508
+ const profileContext = buildProfileContext(candidate);
509
+ return [
510
+ '你是招聘筛选助手,请基于简历截图判断候选人是否符合筛选标准。',
511
+ '只能依据图片中可见信息判断,不得臆测。',
512
+ '只采信当前候选人的主简历内容(教育经历/工作经历/项目经历/专业技能)。',
513
+ '必须忽略推荐模块与匿名卡片信息(例如“其他名企大厂经历牛人”“相似牛人”“推荐牛人”)。',
514
+ '若无法在教育经历模块确认学校名称,不要编造学校名;按信息不足处理。',
515
+ '必须完整阅读全部简历截图分段后再判断。',
516
+ '必须且只能返回 JSON,不要输出 Markdown。',
517
+ '返回格式:{"passed":true} 或 {"passed":false}。',
518
+ '不要返回理由、总结、证据、思维过程或额外字段。',
519
+ '当信息不足以支持通过时,返回 {"passed":false}。',
520
+ '',
521
+ `筛选标准:${screeningCriteria}`,
522
+ '',
523
+ '候选人上下文(仅供辅助,不可覆盖图片事实):',
524
+ `姓名:${candidate.name || '未知'}`,
525
+ `投递职位:${candidate.sourceJob || '未知'}`,
526
+ ...(profileContext.length > 0 ? ['', ...profileContext] : []),
527
+ ].join('\n');
528
+ }
529
+
530
+ function buildTextPrompt({ screeningCriteria, candidate, resumeText, chunkIndex = 1, chunkTotal = 1 }) {
531
+ const profileContext = buildProfileContext(candidate);
532
+ const chunkHint =
533
+ chunkTotal > 1
534
+ ? `\n\n当前输入是简历分段 ${chunkIndex}/${chunkTotal}。请严格基于本分段文本判断;如果本分段证据不足,必须返回 {"passed":false}。`
535
+ : '';
536
+ return [
537
+ '你是招聘筛选助手,请基于简历文本判断候选人是否符合筛选标准。',
538
+ '只能依据输入文本中可见信息判断,不得臆测。',
539
+ '只采信当前候选人的主简历内容(教育经历/工作经历/项目经历/专业技能)。',
540
+ '必须忽略推荐模块与匿名卡片信息(例如“其他名企大厂经历牛人”“相似牛人”“推荐牛人”)。',
541
+ '若无法在教育经历模块确认学校名称,不要编造学校名;按信息不足处理。',
542
+ '必须且只能返回 JSON,不要输出 Markdown。',
543
+ '返回格式:{"passed":true} 或 {"passed":false}。',
544
+ '不要返回理由、总结、证据、思维过程或额外字段。',
545
+ '当信息不足以支持通过时,返回 {"passed":false}。',
546
+ '',
547
+ `筛选标准:${screeningCriteria}`,
548
+ '',
549
+ '候选人上下文(仅供辅助,不可覆盖简历事实):',
550
+ `姓名:${candidate.name || '未知'}`,
551
+ `投递职位:${candidate.sourceJob || '未知'}`,
552
+ ...(profileContext.length > 0 ? ['', ...profileContext] : []),
553
+ '',
554
+ `简历文本:\n${String(resumeText || '')}${chunkHint}`,
555
+ ].join('\n');
291
556
  }
292
557
 
293
- function buildImagePrompt({ screeningCriteria, candidate }) {
558
+ function buildChunkAnalysisPrompt({ screeningCriteria, candidate, resumeText, chunkIndex = 1, chunkTotal = 1 }) {
294
559
  const profileContext = buildProfileContext(candidate);
295
560
  return [
296
- '你是招聘筛选助手,请基于简历截图判断候选人是否符合筛选标准。',
297
- '只能依据图片中可见信息判断,不得臆测。',
561
+ '你是招聘筛选助手,请对长简历的当前文本分段提取结构化筛选证据。',
562
+ '只能依据当前分段文本中可见信息判断,不得臆测其他分段内容。',
298
563
  '只采信当前候选人的主简历内容(教育经历/工作经历/项目经历/专业技能)。',
299
564
  '必须忽略推荐模块与匿名卡片信息(例如“其他名企大厂经历牛人”“相似牛人”“推荐牛人”)。',
300
565
  '若无法在教育经历模块确认学校名称,不要编造学校名;按信息不足处理。',
301
- '必须完整阅读全部简历截图分段后再判断。',
302
566
  '必须且只能返回 JSON,不要输出 Markdown。',
303
- '返回格式:{"passed":true} {"passed":false}。',
304
- '不要返回理由、总结、证据、思维过程或额外字段。',
305
- '当信息不足以支持通过时,返回 {"passed":false}。',
567
+ 'hard_evidence / soft_evidence / hard_blockers / quoted_spans 中每项都必须来自当前分段原文。',
568
+ '如果当前分段单独不足以支持通过,chunk_passed 必须为 false。',
306
569
  '',
307
570
  `筛选标准:${screeningCriteria}`,
308
571
  '',
309
- '候选人上下文(仅供辅助,不可覆盖图片事实):',
572
+ '候选人上下文(仅供辅助,不可覆盖简历事实):',
310
573
  `姓名:${candidate.name || '未知'}`,
311
574
  `投递职位:${candidate.sourceJob || '未知'}`,
312
575
  ...(profileContext.length > 0 ? ['', ...profileContext] : []),
576
+ '',
577
+ `当前分段:${chunkIndex}/${chunkTotal}`,
578
+ '',
579
+ `分段文本:\n${String(resumeText || '')}`,
580
+ '',
581
+ '请返回严格 JSON:{"chunk_passed":true/false,"chunk_summary":"","hard_evidence":[],"soft_evidence":[],"hard_blockers":[],"missing_or_uncertain":[],"quoted_spans":[],"chunk_index":1,"chunk_total":1}',
313
582
  ].join('\n');
314
583
  }
315
584
 
316
- function buildTextPrompt({ screeningCriteria, candidate, resumeText, chunkIndex = 1, chunkTotal = 1 }) {
317
- const profileContext = buildProfileContext(candidate);
318
- const chunkHint =
319
- chunkTotal > 1
320
- ? `\n\n当前输入是简历分段 ${chunkIndex}/${chunkTotal}。请严格基于本分段文本判断;如果本分段证据不足,必须返回 {"passed":false}。`
321
- : '';
585
+ function buildLongResumeAggregatePrompt({ screeningCriteria, candidate, aggregateInput }) {
322
586
  return [
323
- '你是招聘筛选助手,请基于简历文本判断候选人是否符合筛选标准。',
324
- '只能依据输入文本中可见信息判断,不得臆测。',
587
+ '你是招聘筛选助手,请基于长简历各分段的结构化分析结果,对整份简历做最终综合判断。',
588
+ '必须综合全部 chunk 的信息后再判断,允许跨 chunk 拼接教育、项目、工作经历证据。',
325
589
  '只采信当前候选人的主简历内容(教育经历/工作经历/项目经历/专业技能)。',
326
590
  '必须忽略推荐模块与匿名卡片信息(例如“其他名企大厂经历牛人”“相似牛人”“推荐牛人”)。',
327
- '若无法在教育经历模块确认学校名称,不要编造学校名;按信息不足处理。',
591
+ '若结构化证据仍不足以支持通过,返回 {"passed":false}。',
328
592
  '必须且只能返回 JSON,不要输出 Markdown。',
329
- '返回格式:{"passed":true} 或 {"passed":false}。',
330
- '不要返回理由、总结、证据、思维过程或额外字段。',
331
- '当信息不足以支持通过时,返回 {"passed":false}。',
593
+ '返回格式:{"passed":true/false,"reason":"","summary":"","evidence":[]}。',
332
594
  '',
333
595
  `筛选标准:${screeningCriteria}`,
334
596
  '',
335
- '候选人上下文(仅供辅助,不可覆盖简历事实):',
336
- `姓名:${candidate.name || '未知'}`,
337
- `投递职位:${candidate.sourceJob || '未知'}`,
338
- ...(profileContext.length > 0 ? ['', ...profileContext] : []),
597
+ '候选人上下文(仅供辅助,不可覆盖结构化证据事实):',
598
+ `姓名:${candidate?.name || '未知'}`,
599
+ `投递职位:${candidate?.sourceJob || '未知'}`,
339
600
  '',
340
- `简历文本:\n${String(resumeText || '')}${chunkHint}`,
601
+ `长简历结构化输入:\n${JSON.stringify(aggregateInput, null, 2)}`,
341
602
  ].join('\n');
342
603
  }
604
+
605
+ function pickFirstText(...values) {
606
+ for (const value of values) {
607
+ const normalized = normalizeText(value);
608
+ if (normalized) return normalized;
609
+ }
610
+ return '';
611
+ }
343
612
 
344
- export function parseLlmJson(content, options = {}) {
345
- const text = String(content || '').trim();
346
- if (!text) {
347
- throw new Error('LLM returned empty content');
348
- }
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
- }
613
+ function parsePassedDecision(value) {
614
+ if (typeof value === 'boolean') return value;
615
+ if (typeof value === 'number') return value !== 0;
616
+ const normalized = normalizeText(value).toLowerCase();
617
+ if (!normalized) return null;
618
+ if (['true', '1', 'yes', 'y', 'pass', 'passed', 'match', 'matched'].includes(normalized)) return true;
619
+ if (['false', '0', 'no', 'n', 'fail', 'failed', 'unmatched'].includes(normalized)) return false;
620
+ return null;
621
+ }
362
622
 
363
- if (/^(fail|failed|false)$/i.test(normalizedText)) {
364
- return {
365
- passed: false,
366
- rawOutputText: text,
367
- chunkIndex,
368
- chunkTotal,
369
- };
623
+ function extractJsonPayload(text) {
624
+ const raw = String(text || '').trim();
625
+ if (!raw) {
626
+ throw new Error('LLM returned empty content');
370
627
  }
371
-
372
- const codeFenceMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
373
- const candidate = codeFenceMatch ? codeFenceMatch[1] : text;
628
+ const codeFenceMatch = raw.match(/```(?:json)?\s*([\s\S]*?)```/i);
629
+ const candidate = codeFenceMatch ? codeFenceMatch[1] : raw;
374
630
  const jsonMatch = candidate.match(/\{[\s\S]*\}/);
375
631
  if (!jsonMatch) {
376
632
  throw new Error('LLM response did not contain JSON');
377
633
  }
378
-
379
- const parsed = JSON.parse(jsonMatch[0]);
634
+ return {
635
+ text: raw,
636
+ parsed: JSON.parse(jsonMatch[0]),
637
+ };
638
+ }
639
+
640
+ export function parseLlmJson(content, options = {}) {
641
+ const text = String(content || '').trim();
642
+ if (!text) {
643
+ throw new Error('LLM returned empty content');
644
+ }
645
+
646
+ const normalizedText = normalizeText(text);
647
+ const chunkIndex = Number.isInteger(options.chunkIndex) && options.chunkIndex > 0 ? options.chunkIndex : 1;
648
+ const chunkTotal = Number.isInteger(options.chunkTotal) && options.chunkTotal > 0 ? options.chunkTotal : 1;
649
+
650
+ if (/^(pass|passed|true)$/i.test(normalizedText)) {
651
+ return {
652
+ passed: true,
653
+ rawOutputText: text,
654
+ rawReasoningText: normalizeText(options.reasoningText || ''),
655
+ cot: normalizeText(options.reasoningText || ''),
656
+ reason: '',
657
+ summary: '',
658
+ evidence: [],
659
+ chunkIndex,
660
+ chunkTotal,
661
+ };
662
+ }
663
+
664
+ if (/^(fail|failed|false)$/i.test(normalizedText)) {
665
+ return {
666
+ passed: false,
667
+ rawOutputText: text,
668
+ rawReasoningText: normalizeText(options.reasoningText || ''),
669
+ cot: normalizeText(options.reasoningText || ''),
670
+ reason: '',
671
+ summary: '',
672
+ evidence: [],
673
+ chunkIndex,
674
+ chunkTotal,
675
+ };
676
+ }
677
+
678
+ const { parsed } = extractJsonPayload(text);
380
679
  const parsedPassed =
381
680
  typeof parsed.passed === 'boolean'
382
681
  ? 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;
390
- if (typeof parsedPassed !== 'boolean') {
391
- throw new Error('LLM response missing boolean "passed"');
392
- }
682
+ : typeof parsed.matched === 'boolean'
683
+ ? parsed.matched
684
+ : /^pass$/i.test(String(parsed.decision || '').trim())
685
+ ? true
686
+ : /^fail$/i.test(String(parsed.decision || '').trim())
687
+ ? false
688
+ : null;
689
+ if (typeof parsedPassed !== 'boolean') {
690
+ throw new Error('LLM response missing boolean "passed"');
691
+ }
692
+
693
+ const parsedReason = pickFirstText(parsed?.reason, parsed?.summary, parsed?.summary_text);
694
+ const parsedSummary = pickFirstText(parsed?.summary, parsed?.summary_text, parsed?.reason);
695
+ const parsedCot = pickFirstText(
696
+ options.reasoningText,
697
+ parsed?.cot,
698
+ parsed?.reasoning_content,
699
+ parsed?.reasoning,
700
+ parsedReason,
701
+ parsedSummary,
702
+ );
703
+ const parsedEvidence = toStringArray(parsed?.evidence);
704
+
705
+ return {
706
+ passed: parsedPassed,
707
+ rawOutputText: text,
708
+ rawReasoningText: normalizeText(options.reasoningText || ''),
709
+ cot: parsedCot,
710
+ reason: parsedReason || parsedCot,
711
+ summary: parsedSummary || parsedReason || parsedCot,
712
+ evidence: parsedEvidence,
713
+ chunkIndex,
714
+ chunkTotal,
715
+ };
716
+ }
393
717
 
718
+ function normalizeChunkAnalysisResult(content, options = {}) {
719
+ const { text, parsed } = extractJsonPayload(content);
720
+ const chunkIndex = Number.isInteger(options.chunkIndex) && options.chunkIndex > 0 ? options.chunkIndex : 1;
721
+ const chunkTotal = Number.isInteger(options.chunkTotal) && options.chunkTotal > 0 ? options.chunkTotal : 1;
722
+ const resumeText = String(options.resumeText || '');
723
+ const chunkPassed =
724
+ parsePassedDecision(parsed?.chunk_passed) !== null
725
+ ? parsePassedDecision(parsed?.chunk_passed)
726
+ : parsePassedDecision(parsed?.passed);
727
+ if (chunkPassed === null) {
728
+ throw new Error('LLM chunk analysis response missing boolean "chunk_passed"');
729
+ }
394
730
  return {
395
- passed: parsedPassed,
396
731
  rawOutputText: text,
397
- chunkIndex,
398
- chunkTotal,
732
+ chunk_passed: chunkPassed,
733
+ chunk_summary: truncateText(
734
+ parsed?.chunk_summary || parsed?.summary || (chunkPassed ? '当前分段命中相关证据。' : '当前分段证据不足。'),
735
+ 220,
736
+ ),
737
+ hard_evidence: filterEvidenceListAgainstText(parsed?.hard_evidence, resumeText, 4, 180),
738
+ soft_evidence: filterEvidenceListAgainstText(parsed?.soft_evidence, resumeText, 3, 180),
739
+ hard_blockers: filterEvidenceListAgainstText(parsed?.hard_blockers, resumeText, 3, 180),
740
+ missing_or_uncertain: dedupeNormalizedList(parsed?.missing_or_uncertain, 3, 140),
741
+ quoted_spans: filterQuotedSpansAgainstText(parsed?.quoted_spans, resumeText, 4, 140),
742
+ chunk_index: chunkIndex,
743
+ chunk_total: chunkTotal,
399
744
  };
400
745
  }
401
746
 
402
- function shouldFallbackToCompletions(error) {
403
- if (error?.code === 'RESPONSES_EMPTY_CONTENT') return true;
404
- if (error?.code === 'RESPONSES_INCOMPLETE_LENGTH') return true;
405
- if (error?.code === 'RESPONSES_UNPARSABLE') return true;
406
- const message = String(error?.message || '').toLowerCase();
407
- return (
408
- message.includes('/responses') ||
409
- message.includes('404') ||
410
- message.includes('not found') ||
411
- message.includes('unknown url') ||
412
- message.includes('unsupported') ||
413
- message.includes('input_image') ||
414
- message.includes('response_format') ||
415
- message.includes('empty content') ||
416
- message.includes('incomplete=length') ||
417
- message.includes('did not contain json')
418
- );
419
- }
420
-
421
- function shouldFallbackToResponses(error) {
422
- if (error?.code === 'COMPLETIONS_EMPTY_CONTENT') return true;
423
- if (error?.code === 'COMPLETIONS_UNPARSABLE') return true;
424
- const message = String(error?.message || '').toLowerCase();
425
- return (
426
- message.includes('/chat/completions') ||
427
- message.includes('404') ||
428
- message.includes('not found') ||
429
- message.includes('unknown url') ||
430
- message.includes('unsupported') ||
431
- message.includes('image_url') ||
432
- message.includes('multimodal')
433
- );
434
- }
435
-
436
- export class LlmClient {
437
- constructor(config, options = {}) {
438
- this.baseUrl = String(config.baseUrl || '').replace(/\/+$/, '');
439
- this.apiKey = config.apiKey;
440
- this.model = config.model;
441
- this.fetchImpl = options.fetchImpl || fetch;
442
- this.maxRetries = options.maxRetries || 3;
443
- this.timeoutMs = options.timeoutMs || 30000;
444
- this.responseMaxOutputTokens = Number.isFinite(Number(options.responseMaxOutputTokens))
445
- ? Number(options.responseMaxOutputTokens)
446
- : Number.isFinite(Number(config.responseMaxOutputTokens))
447
- ? Number(config.responseMaxOutputTokens)
448
- : 1200;
449
- this.completionMaxTokens = Number.isFinite(Number(options.completionMaxTokens))
450
- ? Number(options.completionMaxTokens)
451
- : Number.isFinite(Number(config.completionMaxTokens))
452
- ? Number(config.completionMaxTokens)
453
- : 800;
454
- this.preferCompletions =
455
- options.preferCompletions !== undefined
456
- ? normalizeBool(options.preferCompletions, false)
457
- : config.preferCompletions !== undefined
458
- ? normalizeBool(config.preferCompletions, false)
459
- : /doubao|seed/i.test(String(this.model || ''));
460
- this.thinkingLevel = resolveLlmThinkingLevel(config, options);
461
- }
462
-
463
- async readImageAsDataUrl(imagePath) {
464
- const binary = await readFile(imagePath);
465
- return `data:image/png;base64,${binary.toString('base64')}`;
466
- }
467
-
468
- async withRetries(label, fn) {
469
- let lastError = null;
470
- for (let attempt = 1; attempt <= this.maxRetries; attempt += 1) {
471
- try {
472
- return await fn();
473
- } catch (error) {
474
- lastError = error;
747
+ function buildLongResumeAggregateInput(chunkAnalyses = [], candidate = {}, options = {}) {
748
+ const compact = options?.compact === true;
749
+ const limits = compact ? LONG_RESUME_AGGREGATE_LIMITS_COMPACT : LONG_RESUME_AGGREGATE_LIMITS_STANDARD;
750
+ const seenByBucket = {
751
+ hard_evidence: new Set(),
752
+ soft_evidence: new Set(),
753
+ hard_blockers: new Set(),
754
+ missing_or_uncertain: new Set(),
755
+ quoted_spans: new Set(),
756
+ };
757
+ const normalizedChunks = (Array.isArray(chunkAnalyses) ? chunkAnalyses : [])
758
+ .filter((item) => item && typeof item === 'object')
759
+ .map((item, index) => ({
760
+ chunk_passed: item.chunk_passed === true,
761
+ chunk_summary: truncateText(
762
+ item.chunk_summary || (item.chunk_passed ? '当前分段命中相关证据。' : '当前分段证据不足。'),
763
+ limits.summaryMaxLength,
764
+ ),
765
+ hard_evidence: dedupeNormalizedList(item.hard_evidence, limits.evidenceMaxItems * 2, limits.itemMaxLength),
766
+ soft_evidence: dedupeNormalizedList(item.soft_evidence, limits.evidenceMaxItems * 2, limits.itemMaxLength),
767
+ hard_blockers: dedupeNormalizedList(item.hard_blockers, limits.blockerMaxItems * 2, limits.itemMaxLength),
768
+ missing_or_uncertain: dedupeNormalizedList(
769
+ item.missing_or_uncertain,
770
+ limits.uncertaintyMaxItems * 2,
771
+ limits.itemMaxLength,
772
+ ),
773
+ quoted_spans: dedupeNormalizedList(item.quoted_spans, limits.quoteMaxItems * 2, limits.quoteMaxLength),
774
+ chunk_index: Number.isFinite(Number(item.chunk_index)) ? Number(item.chunk_index) : index + 1,
775
+ chunk_total: Number.isFinite(Number(item.chunk_total)) ? Number(item.chunk_total) : null,
776
+ }))
777
+ .sort((left, right) => left.chunk_index - right.chunk_index)
778
+ .map((item) => {
779
+ const chunk = {
780
+ chunk_index: item.chunk_index,
781
+ chunk_total: item.chunk_total,
782
+ chunk_passed: item.chunk_passed,
783
+ chunk_summary: item.chunk_summary,
784
+ };
785
+ for (const [field, maxItems] of [
786
+ ['hard_evidence', limits.evidenceMaxItems],
787
+ ['soft_evidence', limits.evidenceMaxItems],
788
+ ['hard_blockers', limits.blockerMaxItems],
789
+ ['missing_or_uncertain', limits.uncertaintyMaxItems],
790
+ ['quoted_spans', limits.quoteMaxItems],
791
+ ]) {
792
+ const bucket = [];
793
+ for (const entry of item[field]) {
794
+ const key = toLowerSafe(entry);
795
+ if (!entry || seenByBucket[field].has(key)) continue;
796
+ seenByBucket[field].add(key);
797
+ bucket.push(entry);
798
+ if (bucket.length >= maxItems) break;
799
+ }
800
+ if (bucket.length > 0) {
801
+ chunk[field] = bucket;
802
+ }
475
803
  }
476
- }
477
-
478
- throw lastError || new Error(`${label} evaluation failed`);
479
- }
480
-
481
- async requestResponses({ prompt, imageDataUrl = null, imageDataUrls = [], evidenceCorpus = '', chunkIndex = 1, chunkTotal = 1 }) {
482
- const content = [{ type: 'input_text', text: prompt }];
483
- const normalizedImageDataUrls = Array.isArray(imageDataUrls)
484
- ? imageDataUrls.map((item) => String(item || '').trim()).filter(Boolean)
485
- : [];
486
- if (imageDataUrl) {
487
- normalizedImageDataUrls.unshift(String(imageDataUrl));
488
- }
489
- for (const item of normalizedImageDataUrls) {
490
- content.push({ type: 'input_image', image_url: item });
491
- }
492
- const payload = {
493
- model: this.model,
494
- temperature: 0.1,
495
- max_output_tokens: this.responseMaxOutputTokens,
496
- input: [
497
- {
498
- role: 'user',
499
- content,
500
- },
501
- ],
502
- };
503
- applyResponsesThinking(payload, { thinkingLevel: this.thinkingLevel });
504
-
505
- const response = await this.fetchImpl(`${this.baseUrl}/responses`, {
506
- method: 'POST',
507
- headers: {
508
- 'Content-Type': 'application/json',
509
- Authorization: `Bearer ${this.apiKey}`,
510
- },
511
- body: JSON.stringify(payload),
512
- signal: AbortSignal.timeout(this.timeoutMs),
804
+ return chunk;
513
805
  });
514
-
515
- if (!response.ok) {
516
- const errorText = await response.text();
517
- throw new Error(
518
- `Responses API request failed: ${response.status} ${response.statusText} ${errorText}`,
519
- );
520
- }
521
-
522
- const data = await response.json();
523
- if (data?.error?.message) {
524
- throw new Error(`Responses API error: ${data.error.message}`);
525
- }
526
-
527
- const outputContent = getResponsesContent(data);
528
- if (!outputContent) {
529
- const incompleteReason = String(data?.incomplete_details?.reason || '').trim();
530
- const outputTypes = Array.isArray(data?.output)
531
- ? data.output
532
- .map((item) => String(item?.type || '').trim())
533
- .filter(Boolean)
534
- : [];
535
- const emptyError = new Error(
536
- `Responses API empty textual content${
537
- incompleteReason ? ` (incomplete=${incompleteReason})` : ''
538
- }${outputTypes.length > 0 ? ` (outputTypes=${outputTypes.join(',')})` : ''}`,
539
- );
540
- emptyError.code =
541
- incompleteReason.toLowerCase() === 'length'
542
- ? 'RESPONSES_INCOMPLETE_LENGTH'
543
- : 'RESPONSES_EMPTY_CONTENT';
544
- throw emptyError;
545
- }
546
-
806
+ return {
807
+ compression_mode: compact ? 'compact' : 'standard',
808
+ chunk_count: normalizedChunks.length,
809
+ candidate_profile: buildAggregateCandidateProfile(candidate, compact),
810
+ chunks: normalizedChunks,
811
+ };
812
+ }
813
+
814
+ function shouldFallbackToCompletions(error) {
815
+ if (error?.code === 'RESPONSES_EMPTY_CONTENT') return true;
816
+ if (error?.code === 'RESPONSES_INCOMPLETE_LENGTH') return true;
817
+ if (error?.code === 'RESPONSES_UNPARSABLE') return true;
818
+ const message = String(error?.message || '').toLowerCase();
819
+ return (
820
+ message.includes('/responses') ||
821
+ message.includes('404') ||
822
+ message.includes('not found') ||
823
+ message.includes('unknown url') ||
824
+ message.includes('unsupported') ||
825
+ message.includes('input_image') ||
826
+ message.includes('response_format') ||
827
+ message.includes('empty content') ||
828
+ message.includes('incomplete=length') ||
829
+ message.includes('did not contain json')
830
+ );
831
+ }
832
+
833
+ function shouldFallbackToResponses(error) {
834
+ if (error?.code === 'COMPLETIONS_EMPTY_CONTENT') return true;
835
+ if (error?.code === 'COMPLETIONS_UNPARSABLE') return true;
836
+ const message = String(error?.message || '').toLowerCase();
837
+ return (
838
+ message.includes('/chat/completions') ||
839
+ message.includes('404') ||
840
+ message.includes('not found') ||
841
+ message.includes('unknown url') ||
842
+ message.includes('unsupported') ||
843
+ message.includes('image_url') ||
844
+ message.includes('multimodal')
845
+ );
846
+ }
847
+
848
+ export class LlmClient {
849
+ constructor(config, options = {}) {
850
+ this.baseUrl = String(config.baseUrl || '').replace(/\/+$/, '');
851
+ this.apiKey = config.apiKey;
852
+ this.model = config.model;
853
+ this.fetchImpl = options.fetchImpl || fetch;
854
+ this.maxRetries = options.maxRetries || 3;
855
+ this.timeoutMs = options.timeoutMs || 30000;
856
+ this.responseMaxOutputTokens = Number.isFinite(Number(options.responseMaxOutputTokens))
857
+ ? Number(options.responseMaxOutputTokens)
858
+ : Number.isFinite(Number(config.responseMaxOutputTokens))
859
+ ? Number(config.responseMaxOutputTokens)
860
+ : 1200;
861
+ this.completionMaxTokens = Number.isFinite(Number(options.completionMaxTokens))
862
+ ? Number(options.completionMaxTokens)
863
+ : Number.isFinite(Number(config.completionMaxTokens))
864
+ ? Number(config.completionMaxTokens)
865
+ : 800;
866
+ this.preferCompletions =
867
+ options.preferCompletions !== undefined
868
+ ? normalizeBool(options.preferCompletions, false)
869
+ : config.preferCompletions !== undefined
870
+ ? normalizeBool(config.preferCompletions, false)
871
+ : /doubao|seed/i.test(String(this.model || ''));
872
+ this.thinkingLevel = resolveLlmThinkingLevel(config, options);
873
+ }
874
+
875
+ async readImageAsDataUrl(imagePath) {
876
+ const binary = await readFile(imagePath);
877
+ return `data:image/png;base64,${binary.toString('base64')}`;
878
+ }
879
+
880
+ async withRetries(label, fn) {
881
+ let lastError = null;
882
+ for (let attempt = 1; attempt <= this.maxRetries; attempt += 1) {
883
+ try {
884
+ return await fn();
885
+ } catch (error) {
886
+ lastError = error;
887
+ }
888
+ }
889
+
890
+ throw lastError || new Error(`${label} evaluation failed`);
891
+ }
892
+
893
+ async requestResponses({
894
+ prompt,
895
+ imageDataUrl = null,
896
+ imageDataUrls = [],
897
+ evidenceCorpus = '',
898
+ chunkIndex = 1,
899
+ chunkTotal = 1,
900
+ parser = parseLlmJson,
901
+ }) {
902
+ const content = [{ type: 'input_text', text: prompt }];
903
+ const normalizedImageDataUrls = Array.isArray(imageDataUrls)
904
+ ? imageDataUrls.map((item) => String(item || '').trim()).filter(Boolean)
905
+ : [];
906
+ if (imageDataUrl) {
907
+ normalizedImageDataUrls.unshift(String(imageDataUrl));
908
+ }
909
+ for (const item of normalizedImageDataUrls) {
910
+ content.push({ type: 'input_image', image_url: item });
911
+ }
912
+ const payload = {
913
+ model: this.model,
914
+ temperature: 0.1,
915
+ max_output_tokens: this.responseMaxOutputTokens,
916
+ input: [
917
+ {
918
+ role: 'user',
919
+ content,
920
+ },
921
+ ],
922
+ };
923
+ applyResponsesThinking(payload, { thinkingLevel: this.thinkingLevel });
924
+
925
+ const response = await this.fetchImpl(`${this.baseUrl}/responses`, {
926
+ method: 'POST',
927
+ headers: {
928
+ 'Content-Type': 'application/json',
929
+ Authorization: `Bearer ${this.apiKey}`,
930
+ },
931
+ body: JSON.stringify(payload),
932
+ signal: AbortSignal.timeout(this.timeoutMs),
933
+ });
934
+
935
+ if (!response.ok) {
936
+ const errorText = await response.text();
937
+ throw new Error(
938
+ `Responses API request failed: ${response.status} ${response.statusText} ${errorText}`,
939
+ );
940
+ }
941
+
942
+ const data = await response.json();
943
+ if (data?.error?.message) {
944
+ throw new Error(`Responses API error: ${data.error.message}`);
945
+ }
946
+
947
+ const outputContent = getResponsesContent(data);
948
+ const reasoningText = extractResponsesReasoningText(data);
949
+ if (!outputContent) {
950
+ const incompleteReason = String(data?.incomplete_details?.reason || '').trim();
951
+ const outputTypes = Array.isArray(data?.output)
952
+ ? data.output
953
+ .map((item) => String(item?.type || '').trim())
954
+ .filter(Boolean)
955
+ : [];
956
+ const emptyError = new Error(
957
+ `Responses API empty textual content${
958
+ incompleteReason ? ` (incomplete=${incompleteReason})` : ''
959
+ }${outputTypes.length > 0 ? ` (outputTypes=${outputTypes.join(',')})` : ''}`,
960
+ );
961
+ emptyError.code =
962
+ incompleteReason.toLowerCase() === 'length'
963
+ ? 'RESPONSES_INCOMPLETE_LENGTH'
964
+ : 'RESPONSES_EMPTY_CONTENT';
965
+ throw emptyError;
966
+ }
967
+
547
968
  try {
548
- return parseLlmJson(outputContent, {
969
+ return parser(outputContent, {
549
970
  evidenceCorpus,
971
+ reasoningText,
550
972
  chunkIndex,
551
973
  chunkTotal,
552
974
  });
553
- } catch (parseError) {
554
- const wrapped = new Error(
555
- `Responses API returned unparsable content: ${parseError?.message || parseError}`,
556
- );
557
- wrapped.code = 'RESPONSES_UNPARSABLE';
558
- throw wrapped;
559
- }
560
- }
561
-
562
- async requestCompletions({ prompt, imageDataUrl = null, imageDataUrls = [], evidenceCorpus = '', chunkIndex = 1, chunkTotal = 1 }) {
563
- const content = [{ type: 'text', text: prompt }];
564
- const normalizedImageDataUrls = Array.isArray(imageDataUrls)
565
- ? imageDataUrls.map((item) => String(item || '').trim()).filter(Boolean)
566
- : [];
567
- if (imageDataUrl) {
568
- normalizedImageDataUrls.unshift(String(imageDataUrl));
569
- }
570
- for (const item of normalizedImageDataUrls) {
571
- content.push({ type: 'image_url', image_url: { url: item } });
572
- }
573
- const payload = {
574
- model: this.model,
575
- temperature: 0.1,
576
- max_tokens: this.completionMaxTokens,
577
- messages: [
578
- {
579
- role: 'user',
580
- content,
581
- },
582
- ],
583
- };
584
- applyChatCompletionThinking(payload, {
585
- baseUrl: this.baseUrl,
586
- model: this.model,
587
- thinkingLevel: this.thinkingLevel,
588
- });
589
-
590
- const response = await this.fetchImpl(`${this.baseUrl}/chat/completions`, {
591
- method: 'POST',
592
- headers: {
593
- 'Content-Type': 'application/json',
594
- Authorization: `Bearer ${this.apiKey}`,
595
- },
596
- body: JSON.stringify(payload),
597
- signal: AbortSignal.timeout(this.timeoutMs),
598
- });
599
-
600
- if (!response.ok) {
601
- const errorText = await response.text();
602
- throw new Error(
603
- `Completions API request failed: ${response.status} ${response.statusText} ${errorText}`,
604
- );
605
- }
606
-
607
- const data = await response.json();
608
- if (data?.error?.message) {
609
- throw new Error(`Completions API error: ${data.error.message}`);
610
- }
611
-
612
- const outputContent = getCompletionContent(data);
613
- if (!String(outputContent || '').trim()) {
614
- const emptyError = new Error('Completions API empty textual content');
615
- emptyError.code = 'COMPLETIONS_EMPTY_CONTENT';
616
- throw emptyError;
617
- }
618
-
975
+ } catch (parseError) {
976
+ const wrapped = new Error(
977
+ `Responses API returned unparsable content: ${parseError?.message || parseError}`,
978
+ );
979
+ wrapped.code = 'RESPONSES_UNPARSABLE';
980
+ throw wrapped;
981
+ }
982
+ }
983
+
984
+ async requestCompletions({
985
+ prompt,
986
+ imageDataUrl = null,
987
+ imageDataUrls = [],
988
+ evidenceCorpus = '',
989
+ chunkIndex = 1,
990
+ chunkTotal = 1,
991
+ parser = parseLlmJson,
992
+ }) {
993
+ const content = [{ type: 'text', text: prompt }];
994
+ const normalizedImageDataUrls = Array.isArray(imageDataUrls)
995
+ ? imageDataUrls.map((item) => String(item || '').trim()).filter(Boolean)
996
+ : [];
997
+ if (imageDataUrl) {
998
+ normalizedImageDataUrls.unshift(String(imageDataUrl));
999
+ }
1000
+ for (const item of normalizedImageDataUrls) {
1001
+ content.push({ type: 'image_url', image_url: { url: item } });
1002
+ }
1003
+ const payload = {
1004
+ model: this.model,
1005
+ temperature: 0.1,
1006
+ max_tokens: this.completionMaxTokens,
1007
+ messages: [
1008
+ {
1009
+ role: 'user',
1010
+ content,
1011
+ },
1012
+ ],
1013
+ };
1014
+ applyChatCompletionThinking(payload, {
1015
+ baseUrl: this.baseUrl,
1016
+ model: this.model,
1017
+ thinkingLevel: this.thinkingLevel,
1018
+ });
1019
+
1020
+ const response = await this.fetchImpl(`${this.baseUrl}/chat/completions`, {
1021
+ method: 'POST',
1022
+ headers: {
1023
+ 'Content-Type': 'application/json',
1024
+ Authorization: `Bearer ${this.apiKey}`,
1025
+ },
1026
+ body: JSON.stringify(payload),
1027
+ signal: AbortSignal.timeout(this.timeoutMs),
1028
+ });
1029
+
1030
+ if (!response.ok) {
1031
+ const errorText = await response.text();
1032
+ throw new Error(
1033
+ `Completions API request failed: ${response.status} ${response.statusText} ${errorText}`,
1034
+ );
1035
+ }
1036
+
1037
+ const data = await response.json();
1038
+ if (data?.error?.message) {
1039
+ throw new Error(`Completions API error: ${data.error.message}`);
1040
+ }
1041
+
1042
+ const outputContent = getCompletionContent(data);
1043
+ const reasoningText = extractCompletionReasoningText(data);
1044
+ if (!String(outputContent || '').trim()) {
1045
+ const emptyError = new Error('Completions API empty textual content');
1046
+ emptyError.code = 'COMPLETIONS_EMPTY_CONTENT';
1047
+ throw emptyError;
1048
+ }
1049
+
619
1050
  try {
620
- return parseLlmJson(outputContent, {
1051
+ return parser(outputContent, {
621
1052
  evidenceCorpus,
1053
+ reasoningText,
622
1054
  chunkIndex,
623
1055
  chunkTotal,
624
1056
  });
625
- } catch (parseError) {
626
- const wrapped = new Error(
627
- `Completions API returned unparsable content: ${parseError?.message || parseError}`,
628
- );
629
- wrapped.code = 'COMPLETIONS_UNPARSABLE';
630
- throw wrapped;
631
- }
632
- }
633
-
1057
+ } catch (parseError) {
1058
+ const wrapped = new Error(
1059
+ `Completions API returned unparsable content: ${parseError?.message || parseError}`,
1060
+ );
1061
+ wrapped.code = 'COMPLETIONS_UNPARSABLE';
1062
+ throw wrapped;
1063
+ }
1064
+ }
1065
+
634
1066
  async requestByPreference(payload) {
635
1067
  if (this.preferCompletions) {
636
1068
  try {
637
1069
  return await this.withRetries('completions', async () => this.requestCompletions(payload));
638
- } catch (completionsError) {
639
- if (!shouldFallbackToResponses(completionsError)) {
640
- throw completionsError;
641
- }
642
- return this.withRetries('responses', async () => this.requestResponses(payload));
643
- }
644
- }
1070
+ } catch (completionsError) {
1071
+ if (!shouldFallbackToResponses(completionsError)) {
1072
+ throw completionsError;
1073
+ }
1074
+ return this.withRetries('responses', async () => this.requestResponses(payload));
1075
+ }
1076
+ }
1077
+
1078
+ try {
1079
+ return await this.withRetries('responses', async () => this.requestResponses(payload));
1080
+ } catch (responsesError) {
1081
+ if (!shouldFallbackToCompletions(responsesError)) {
1082
+ throw responsesError;
1083
+ }
1084
+ return this.withRetries('completions', async () => this.requestCompletions(payload));
1085
+ }
1086
+ }
1087
+
1088
+ async evaluateImageResume({ screeningCriteria, candidate, imagePath, imagePaths = [] }) {
1089
+ const prompt = buildImagePrompt({ screeningCriteria, candidate });
1090
+ const normalizedImagePaths = Array.isArray(imagePaths)
1091
+ ? imagePaths.map((item) => String(item || '').trim()).filter(Boolean)
1092
+ : [];
1093
+ if (imagePath) {
1094
+ normalizedImagePaths.unshift(String(imagePath));
1095
+ }
1096
+ const uniqueImagePaths = [...new Set(normalizedImagePaths)];
1097
+ if (uniqueImagePaths.length <= 0) {
1098
+ throw new Error('IMAGE_MODEL_FAILED: missing image paths');
1099
+ }
1100
+ const imageDataUrls = await Promise.all(
1101
+ uniqueImagePaths.map((item) => this.readImageAsDataUrl(item)),
1102
+ );
1103
+ const evidenceCorpus = normalizeText(candidate?.evidenceCorpus || candidate?.resumeText || '');
1104
+ const result = await this.requestByPreference({
1105
+ prompt,
1106
+ imageDataUrls,
1107
+ evidenceCorpus,
1108
+ chunkIndex: 1,
1109
+ chunkTotal: 1,
1110
+ });
1111
+ return {
1112
+ ...result,
1113
+ evaluationMode: uniqueImagePaths.length > 1 ? 'image-multi-chunk' : 'image',
1114
+ imageCount: uniqueImagePaths.length,
1115
+ };
1116
+ }
645
1117
 
646
- try {
647
- return await this.withRetries('responses', async () => this.requestResponses(payload));
648
- } catch (responsesError) {
649
- if (!shouldFallbackToCompletions(responsesError)) {
650
- throw responsesError;
651
- }
652
- return this.withRetries('completions', async () => this.requestCompletions(payload));
653
- }
1118
+ async requestTextChunkAnalysis({ screeningCriteria, candidate, resumeText, chunkIndex = 1, chunkTotal = 1 }) {
1119
+ return this.requestByPreference({
1120
+ prompt: buildChunkAnalysisPrompt({
1121
+ screeningCriteria,
1122
+ candidate,
1123
+ resumeText,
1124
+ chunkIndex,
1125
+ chunkTotal,
1126
+ }),
1127
+ imageDataUrl: null,
1128
+ evidenceCorpus: resumeText,
1129
+ chunkIndex,
1130
+ chunkTotal,
1131
+ parser: (content, parserOptions) =>
1132
+ normalizeChunkAnalysisResult(content, {
1133
+ resumeText,
1134
+ chunkIndex,
1135
+ chunkTotal,
1136
+ ...parserOptions,
1137
+ }),
1138
+ });
654
1139
  }
655
1140
 
656
- async evaluateImageResume({ screeningCriteria, candidate, imagePath, imagePaths = [] }) {
657
- const prompt = buildImagePrompt({ screeningCriteria, candidate });
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
- );
671
- const evidenceCorpus = normalizeText(candidate?.evidenceCorpus || candidate?.resumeText || '');
1141
+ async requestLongResumeAggregateDecision({
1142
+ screeningCriteria,
1143
+ candidate,
1144
+ aggregateInput,
1145
+ aggregateRetryUsed = false,
1146
+ }) {
672
1147
  const result = await this.requestByPreference({
673
- prompt,
674
- imageDataUrls,
675
- evidenceCorpus,
1148
+ prompt: buildLongResumeAggregatePrompt({
1149
+ screeningCriteria,
1150
+ candidate,
1151
+ aggregateInput,
1152
+ }),
1153
+ imageDataUrl: null,
1154
+ evidenceCorpus: JSON.stringify(aggregateInput),
676
1155
  chunkIndex: 1,
677
- chunkTotal: 1,
1156
+ chunkTotal: Number.isFinite(Number(aggregateInput?.chunk_count))
1157
+ ? Number(aggregateInput.chunk_count)
1158
+ : 1,
678
1159
  });
679
1160
  return {
680
1161
  ...result,
681
- evaluationMode: uniqueImagePaths.length > 1 ? 'image-multi-chunk' : 'image',
682
- imageCount: uniqueImagePaths.length,
1162
+ evaluationMode: 'text-chunk-aggregate',
1163
+ aggregateRetryUsed,
1164
+ chunkIndex: null,
1165
+ chunkTotal: Number.isFinite(Number(aggregateInput?.chunk_count))
1166
+ ? Number(aggregateInput.chunk_count)
1167
+ : result.chunkTotal,
683
1168
  };
684
1169
  }
685
1170
 
686
1171
  async evaluateTextResume({ screeningCriteria, candidate }) {
687
1172
  const fullResumeText = String(candidate?.resumeText || '');
688
- const normalizedResumeText = normalizeText(fullResumeText);
689
- if (!normalizedResumeText) {
690
- throw new Error('TEXT_MODEL_FAILED: resume text is empty');
691
- }
692
- const evidenceCorpus = normalizeText(candidate?.evidenceCorpus || fullResumeText);
693
-
694
- const requestSingleChunk = () =>
695
- this.requestByPreference({
696
- prompt: buildTextPrompt({
697
- screeningCriteria,
698
- candidate,
699
- resumeText: fullResumeText,
700
- chunkIndex: 1,
701
- chunkTotal: 1,
702
- }),
703
- imageDataUrl: null,
704
- evidenceCorpus,
705
- chunkIndex: 1,
706
- chunkTotal: 1,
707
- });
708
-
1173
+ const normalizedResumeText = normalizeText(fullResumeText);
1174
+ if (!normalizedResumeText) {
1175
+ throw new Error('TEXT_MODEL_FAILED: resume text is empty');
1176
+ }
1177
+ const evidenceCorpus = normalizeText(candidate?.evidenceCorpus || fullResumeText);
1178
+
1179
+ const requestSingleChunk = () =>
1180
+ this.requestByPreference({
1181
+ prompt: buildTextPrompt({
1182
+ screeningCriteria,
1183
+ candidate,
1184
+ resumeText: fullResumeText,
1185
+ chunkIndex: 1,
1186
+ chunkTotal: 1,
1187
+ }),
1188
+ imageDataUrl: null,
1189
+ evidenceCorpus,
1190
+ chunkIndex: 1,
1191
+ chunkTotal: 1,
1192
+ });
1193
+
709
1194
  try {
710
1195
  const single = await requestSingleChunk();
711
1196
  return {
712
1197
  ...single,
713
1198
  evaluationMode: 'text',
1199
+ aggregateRetryUsed: false,
714
1200
  };
715
1201
  } catch (error) {
716
1202
  if (!isTextContextLimitMessage(error?.message || '')) {
717
1203
  throw error;
718
1204
  }
719
- }
720
-
721
- const chunkSize = parsePositiveInteger(process.env.BOSS_CHAT_TEXT_CHUNK_SIZE_CHARS) || DEFAULT_TEXT_MODEL_CHUNK_SIZE_CHARS;
722
- const overlap = parsePositiveInteger(process.env.BOSS_CHAT_TEXT_CHUNK_OVERLAP_CHARS) || DEFAULT_TEXT_MODEL_CHUNK_OVERLAP_CHARS;
723
- const maxChunks = parsePositiveInteger(process.env.BOSS_CHAT_TEXT_MAX_CHUNKS) || DEFAULT_TEXT_MODEL_MAX_CHUNKS;
724
- const chunks = splitTextByChunks(fullResumeText, chunkSize, overlap, maxChunks);
725
- if (!chunks.length) {
726
- throw new Error('TEXT_MODEL_FAILED: resume text is empty after chunk split');
727
- }
1205
+ }
1206
+
1207
+ const chunkSize = parsePositiveInteger(process.env.BOSS_CHAT_TEXT_CHUNK_SIZE_CHARS) || DEFAULT_TEXT_MODEL_CHUNK_SIZE_CHARS;
1208
+ const overlap = parsePositiveInteger(process.env.BOSS_CHAT_TEXT_CHUNK_OVERLAP_CHARS) || DEFAULT_TEXT_MODEL_CHUNK_OVERLAP_CHARS;
1209
+ const maxChunks = parsePositiveInteger(process.env.BOSS_CHAT_TEXT_MAX_CHUNKS) || DEFAULT_TEXT_MODEL_MAX_CHUNKS;
1210
+ const chunks = splitTextByChunks(fullResumeText, chunkSize, overlap, maxChunks);
1211
+ if (!chunks.length) {
1212
+ throw new Error('TEXT_MODEL_FAILED: resume text is empty after chunk split');
1213
+ }
728
1214
 
729
1215
  const chunkResults = [];
730
1216
  for (let index = 0; index < chunks.length; index += 1) {
731
1217
  const chunk = chunks[index];
732
- const result = await this.requestByPreference({
733
- prompt: buildTextPrompt({
734
- screeningCriteria,
735
- candidate,
736
- resumeText: chunk.text,
737
- chunkIndex: index + 1,
738
- chunkTotal: chunks.length,
739
- }),
740
- imageDataUrl: null,
741
- evidenceCorpus: chunk.text,
1218
+ const result = await this.requestTextChunkAnalysis({
1219
+ screeningCriteria,
1220
+ candidate,
1221
+ resumeText: chunk.text,
742
1222
  chunkIndex: index + 1,
743
1223
  chunkTotal: chunks.length,
744
1224
  });
745
1225
  chunkResults.push(result);
746
1226
  }
747
1227
 
748
- const passedChunks = chunkResults.filter((item) => item?.passed === true);
749
- if (passedChunks.length > 0) {
750
- const best = passedChunks[0];
751
- return {
752
- ...best,
753
- evaluationMode: 'text',
754
- };
755
- }
756
-
757
- return {
758
- passed: false,
759
- rawOutputText:
760
- chunkResults.map((item) => normalizeText(item?.rawOutputText)).find(Boolean) ||
761
- `{"passed":false,"mode":"text-chunk-fallback","chunks":${chunks.length}}`,
762
- chunkIndex: null,
763
- chunkTotal: chunks.length,
764
- evaluationMode: 'text',
765
- };
766
- }
767
-
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({
1228
+ let aggregateInput = buildLongResumeAggregateInput(chunkResults, candidate);
1229
+ try {
1230
+ return await this.requestLongResumeAggregateDecision({
778
1231
  screeningCriteria,
779
1232
  candidate,
780
- imagePaths: uniqueImagePaths,
1233
+ aggregateInput,
1234
+ aggregateRetryUsed: false,
781
1235
  });
1236
+ } catch (error) {
1237
+ if (!isTextContextLimitMessage(error?.message || '')) {
1238
+ throw error;
1239
+ }
782
1240
  }
783
1241
 
784
- const hasResumeText = Boolean(normalizeText(candidate?.resumeText || ''));
785
- if (hasResumeText) {
786
- return this.evaluateTextResume({ screeningCriteria, candidate });
787
- }
788
-
789
- throw new Error('LLM evaluation requires at least one resume image or non-empty resume text');
1242
+ aggregateInput = buildLongResumeAggregateInput(chunkResults, candidate, { compact: true });
1243
+ return this.requestLongResumeAggregateDecision({
1244
+ screeningCriteria,
1245
+ candidate,
1246
+ aggregateInput,
1247
+ aggregateRetryUsed: true,
1248
+ });
790
1249
  }
791
- }
792
-
1250
+
1251
+ async evaluateResume({ screeningCriteria, candidate, imagePath, imagePaths = [] }) {
1252
+ const normalizedImagePaths = Array.isArray(imagePaths)
1253
+ ? imagePaths.map((item) => String(item || '').trim()).filter(Boolean)
1254
+ : [];
1255
+ if (imagePath) {
1256
+ normalizedImagePaths.unshift(String(imagePath));
1257
+ }
1258
+ const uniqueImagePaths = [...new Set(normalizedImagePaths)];
1259
+ if (uniqueImagePaths.length > 0) {
1260
+ return this.evaluateImageResume({
1261
+ screeningCriteria,
1262
+ candidate,
1263
+ imagePaths: uniqueImagePaths,
1264
+ });
1265
+ }
1266
+
1267
+ const hasResumeText = Boolean(normalizeText(candidate?.resumeText || ''));
1268
+ if (hasResumeText) {
1269
+ return this.evaluateTextResume({ screeningCriteria, candidate });
1270
+ }
1271
+
1272
+ throw new Error('LLM evaluation requires at least one resume image or non-empty resume text');
1273
+ }
1274
+ }
1275
+
793
1276
  export const __testables = {
1277
+ flattenChatMessageContent,
1278
+ collectNestedText,
1279
+ extractCompletionReasoningText,
1280
+ extractResponsesReasoningText,
794
1281
  extractEvidenceTokens,
795
1282
  matchEvidenceAgainstResume,
796
1283
  splitTextByChunks,
797
1284
  isTextContextLimitMessage,
1285
+ buildChunkAnalysisPrompt,
1286
+ buildLongResumeAggregatePrompt,
1287
+ normalizeChunkAnalysisResult,
1288
+ buildLongResumeAggregateInput,
1289
+ dedupeNormalizedList,
1290
+ filterEvidenceListAgainstText,
1291
+ filterQuotedSpansAgainstText,
798
1292
  };