@reconcrap/boss-recommend-mcp 1.3.32 → 1.3.34

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