@reconcrap/boss-recommend-mcp 1.3.39 → 2.0.1

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.
Files changed (88) hide show
  1. package/README.md +86 -33
  2. package/package.json +62 -9
  3. package/skills/boss-chat/SKILL.md +5 -4
  4. package/skills/boss-recommend-pipeline/SKILL.md +21 -31
  5. package/skills/boss-recruit-pipeline/README.md +17 -0
  6. package/skills/boss-recruit-pipeline/SKILL.md +55 -0
  7. package/src/chat-mcp.js +1333 -0
  8. package/src/chat-runtime-config.js +559 -0
  9. package/src/cli.js +1254 -225
  10. package/src/core/browser/index.js +378 -0
  11. package/src/core/capture/index.js +298 -0
  12. package/src/core/cv-acquisition/index.js +219 -0
  13. package/src/core/greet-quota/index.js +54 -0
  14. package/src/core/infinite-list/index.js +459 -0
  15. package/src/core/reporting/legacy-csv.js +332 -0
  16. package/src/core/run/index.js +286 -0
  17. package/src/core/screening/index.js +1166 -0
  18. package/src/core/self-heal/index.js +848 -0
  19. package/src/domains/chat/cards.js +129 -0
  20. package/src/domains/chat/constants.js +183 -0
  21. package/src/domains/chat/detail.js +1369 -0
  22. package/src/domains/chat/index.js +7 -0
  23. package/src/domains/chat/jobs.js +334 -0
  24. package/src/domains/chat/page-guard.js +88 -0
  25. package/src/domains/chat/roots.js +56 -0
  26. package/src/domains/chat/run-service.js +1101 -0
  27. package/src/domains/recommend/actions.js +457 -0
  28. package/src/domains/recommend/cards.js +228 -0
  29. package/src/domains/recommend/constants.js +141 -0
  30. package/src/domains/recommend/detail.js +341 -0
  31. package/src/domains/recommend/filters.js +581 -0
  32. package/src/domains/recommend/index.js +10 -0
  33. package/src/domains/recommend/jobs.js +232 -0
  34. package/src/domains/recommend/refresh.js +204 -0
  35. package/src/domains/recommend/roots.js +78 -0
  36. package/src/domains/recommend/run-service.js +903 -0
  37. package/src/domains/recommend/scopes.js +245 -0
  38. package/src/domains/recruit/actions.js +277 -0
  39. package/src/domains/recruit/cards.js +66 -0
  40. package/src/domains/recruit/constants.js +130 -0
  41. package/src/domains/recruit/detail.js +414 -0
  42. package/src/domains/recruit/index.js +9 -0
  43. package/src/domains/recruit/instruction-parser.js +451 -0
  44. package/src/domains/recruit/refresh.js +40 -0
  45. package/src/domains/recruit/roots.js +67 -0
  46. package/src/domains/recruit/run-service.js +580 -0
  47. package/src/domains/recruit/search.js +1149 -0
  48. package/src/index.js +578 -419
  49. package/src/recommend-mcp.js +1257 -0
  50. package/src/recruit-mcp.js +1035 -0
  51. package/src/adapters.js +0 -3079
  52. package/src/boss-chat.js +0 -1037
  53. package/src/pipeline.js +0 -2249
  54. package/src/recommend-healing-config.js +0 -131
  55. package/src/recommend-healing-rules.json +0 -261
  56. package/src/self-heal.js +0 -2237
  57. package/src/test-adapters-runtime.js +0 -628
  58. package/src/test-boss-chat.js +0 -3196
  59. package/src/test-index-async.js +0 -498
  60. package/src/test-parser.js +0 -742
  61. package/src/test-pipeline.js +0 -2703
  62. package/src/test-run-state.js +0 -152
  63. package/src/test-self-heal.js +0 -224
  64. package/vendor/boss-chat-cli/README.md +0 -134
  65. package/vendor/boss-chat-cli/package.json +0 -53
  66. package/vendor/boss-chat-cli/src/app.js +0 -1501
  67. package/vendor/boss-chat-cli/src/browser/chat-page.js +0 -3562
  68. package/vendor/boss-chat-cli/src/cli.js +0 -1713
  69. package/vendor/boss-chat-cli/src/mcp/server.js +0 -149
  70. package/vendor/boss-chat-cli/src/mcp/tool-runtime.js +0 -193
  71. package/vendor/boss-chat-cli/src/runtime/async-run-state.js +0 -260
  72. package/vendor/boss-chat-cli/src/runtime/interaction.js +0 -102
  73. package/vendor/boss-chat-cli/src/runtime/run-control.js +0 -102
  74. package/vendor/boss-chat-cli/src/services/chrome-client.js +0 -107
  75. package/vendor/boss-chat-cli/src/services/llm.js +0 -1292
  76. package/vendor/boss-chat-cli/src/services/llm.test.js +0 -326
  77. package/vendor/boss-chat-cli/src/services/profile-store.js +0 -173
  78. package/vendor/boss-chat-cli/src/services/report-store.js +0 -317
  79. package/vendor/boss-chat-cli/src/services/resume-capture.js +0 -469
  80. package/vendor/boss-chat-cli/src/services/resume-network.js +0 -727
  81. package/vendor/boss-chat-cli/src/services/state-store.js +0 -90
  82. package/vendor/boss-chat-cli/src/utils/customer-key.js +0 -82
  83. package/vendor/boss-recommend-screen-cli/boss-recommend-screen-cli.cjs +0 -7072
  84. package/vendor/boss-recommend-screen-cli/scripts/capture-full-resume-canvas.cjs +0 -817
  85. package/vendor/boss-recommend-screen-cli/scripts/stitch_resume_chunks.py +0 -141
  86. package/vendor/boss-recommend-screen-cli/test-recoverable-resume-failures.cjs +0 -2423
  87. package/vendor/boss-recommend-search-cli/src/cli.js +0 -1698
  88. package/vendor/boss-recommend-search-cli/src/test-job-selection.js +0 -211
@@ -1,1292 +0,0 @@
1
- import { readFile } from 'node:fs/promises';
2
-
3
- const DEFAULT_TEXT_MODEL_CHUNK_SIZE_CHARS = 24000;
4
- const DEFAULT_TEXT_MODEL_CHUNK_OVERLAP_CHARS = 1200;
5
- const DEFAULT_TEXT_MODEL_MAX_CHUNKS = 12;
6
- const 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
- };
24
- const MAX_EVIDENCE_TOKENS = 12;
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
-
32
- function normalizeText(value) {
33
- return String(value || '').replace(/\s+/g, ' ').trim();
34
- }
35
-
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))}…`;
40
- }
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
-
194
- function toStringArray(value, maxItems = 8) {
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 : [];
208
- const normalized = [];
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);
215
- normalized.push(text);
216
- if (normalized.length >= maxItems) break;
217
- }
218
- return normalized;
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
- }
372
-
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 = [];
378
- const seen = new Set();
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;
389
- }
390
- return result;
391
- }
392
-
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;
410
- }
411
- return result;
412
- }
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;
481
- }
482
-
483
- function buildAggregateCandidateProfile(candidate, compact = false) {
484
- const maxLength = compact ? 80 : 120;
485
- const schools = Array.isArray(candidate?.resumeProfile?.schools)
486
- ? dedupeNormalizedList(candidate.resumeProfile.schools, compact ? 2 : 3, maxLength)
487
- : [];
488
- const majors = Array.isArray(candidate?.resumeProfile?.majors)
489
- ? dedupeNormalizedList(candidate.resumeProfile.majors, compact ? 2 : 3, maxLength)
490
- : [];
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');
556
- }
557
-
558
- function buildChunkAnalysisPrompt({ screeningCriteria, candidate, resumeText, chunkIndex = 1, chunkTotal = 1 }) {
559
- const profileContext = buildProfileContext(candidate);
560
- return [
561
- '你是招聘筛选助手,请对长简历的当前文本分段提取结构化筛选证据。',
562
- '只能依据当前分段文本中可见信息判断,不得臆测其他分段内容。',
563
- '只采信当前候选人的主简历内容(教育经历/工作经历/项目经历/专业技能)。',
564
- '必须忽略推荐模块与匿名卡片信息(例如“其他名企大厂经历牛人”“相似牛人”“推荐牛人”)。',
565
- '若无法在教育经历模块确认学校名称,不要编造学校名;按信息不足处理。',
566
- '必须且只能返回 JSON,不要输出 Markdown。',
567
- 'hard_evidence / soft_evidence / hard_blockers / quoted_spans 中每项都必须来自当前分段原文。',
568
- '如果当前分段单独不足以支持通过,chunk_passed 必须为 false。',
569
- '',
570
- `筛选标准:${screeningCriteria}`,
571
- '',
572
- '候选人上下文(仅供辅助,不可覆盖简历事实):',
573
- `姓名:${candidate.name || '未知'}`,
574
- `投递职位:${candidate.sourceJob || '未知'}`,
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}',
582
- ].join('\n');
583
- }
584
-
585
- function buildLongResumeAggregatePrompt({ screeningCriteria, candidate, aggregateInput }) {
586
- return [
587
- '你是招聘筛选助手,请基于长简历各分段的结构化分析结果,对整份简历做最终综合判断。',
588
- '必须综合全部 chunk 的信息后再判断,允许跨 chunk 拼接教育、项目、工作经历证据。',
589
- '只采信当前候选人的主简历内容(教育经历/工作经历/项目经历/专业技能)。',
590
- '必须忽略推荐模块与匿名卡片信息(例如“其他名企大厂经历牛人”“相似牛人”“推荐牛人”)。',
591
- '若结构化证据仍不足以支持通过,返回 {"passed":false}。',
592
- '必须且只能返回 JSON,不要输出 Markdown。',
593
- '返回格式:{"passed":true/false,"reason":"","summary":"","evidence":[]}。',
594
- '',
595
- `筛选标准:${screeningCriteria}`,
596
- '',
597
- '候选人上下文(仅供辅助,不可覆盖结构化证据事实):',
598
- `姓名:${candidate?.name || '未知'}`,
599
- `投递职位:${candidate?.sourceJob || '未知'}`,
600
- '',
601
- `长简历结构化输入:\n${JSON.stringify(aggregateInput, null, 2)}`,
602
- ].join('\n');
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
- }
612
-
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
- }
622
-
623
- function extractJsonPayload(text) {
624
- const raw = String(text || '').trim();
625
- if (!raw) {
626
- throw new Error('LLM returned empty content');
627
- }
628
- const codeFenceMatch = raw.match(/```(?:json)?\s*([\s\S]*?)```/i);
629
- const candidate = codeFenceMatch ? codeFenceMatch[1] : raw;
630
- const jsonMatch = candidate.match(/\{[\s\S]*\}/);
631
- if (!jsonMatch) {
632
- throw new Error('LLM response did not contain JSON');
633
- }
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);
679
- const parsedPassed =
680
- typeof parsed.passed === 'boolean'
681
- ? parsed.passed
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
- }
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
- }
730
- return {
731
- rawOutputText: text,
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,
744
- };
745
- }
746
-
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
- }
803
- }
804
- return chunk;
805
- });
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
-
968
- try {
969
- return parser(outputContent, {
970
- evidenceCorpus,
971
- reasoningText,
972
- chunkIndex,
973
- chunkTotal,
974
- });
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
-
1050
- try {
1051
- return parser(outputContent, {
1052
- evidenceCorpus,
1053
- reasoningText,
1054
- chunkIndex,
1055
- chunkTotal,
1056
- });
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
-
1066
- async requestByPreference(payload) {
1067
- if (this.preferCompletions) {
1068
- try {
1069
- return await this.withRetries('completions', async () => this.requestCompletions(payload));
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
- }
1117
-
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
- });
1139
- }
1140
-
1141
- async requestLongResumeAggregateDecision({
1142
- screeningCriteria,
1143
- candidate,
1144
- aggregateInput,
1145
- aggregateRetryUsed = false,
1146
- }) {
1147
- const result = await this.requestByPreference({
1148
- prompt: buildLongResumeAggregatePrompt({
1149
- screeningCriteria,
1150
- candidate,
1151
- aggregateInput,
1152
- }),
1153
- imageDataUrl: null,
1154
- evidenceCorpus: JSON.stringify(aggregateInput),
1155
- chunkIndex: 1,
1156
- chunkTotal: Number.isFinite(Number(aggregateInput?.chunk_count))
1157
- ? Number(aggregateInput.chunk_count)
1158
- : 1,
1159
- });
1160
- return {
1161
- ...result,
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,
1168
- };
1169
- }
1170
-
1171
- async evaluateTextResume({ screeningCriteria, candidate }) {
1172
- const fullResumeText = String(candidate?.resumeText || '');
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
-
1194
- try {
1195
- const single = await requestSingleChunk();
1196
- return {
1197
- ...single,
1198
- evaluationMode: 'text',
1199
- aggregateRetryUsed: false,
1200
- };
1201
- } catch (error) {
1202
- if (!isTextContextLimitMessage(error?.message || '')) {
1203
- throw error;
1204
- }
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
- }
1214
-
1215
- const chunkResults = [];
1216
- for (let index = 0; index < chunks.length; index += 1) {
1217
- const chunk = chunks[index];
1218
- const result = await this.requestTextChunkAnalysis({
1219
- screeningCriteria,
1220
- candidate,
1221
- resumeText: chunk.text,
1222
- chunkIndex: index + 1,
1223
- chunkTotal: chunks.length,
1224
- });
1225
- chunkResults.push(result);
1226
- }
1227
-
1228
- let aggregateInput = buildLongResumeAggregateInput(chunkResults, candidate);
1229
- try {
1230
- return await this.requestLongResumeAggregateDecision({
1231
- screeningCriteria,
1232
- candidate,
1233
- aggregateInput,
1234
- aggregateRetryUsed: false,
1235
- });
1236
- } catch (error) {
1237
- if (!isTextContextLimitMessage(error?.message || '')) {
1238
- throw error;
1239
- }
1240
- }
1241
-
1242
- aggregateInput = buildLongResumeAggregateInput(chunkResults, candidate, { compact: true });
1243
- return this.requestLongResumeAggregateDecision({
1244
- screeningCriteria,
1245
- candidate,
1246
- aggregateInput,
1247
- aggregateRetryUsed: true,
1248
- });
1249
- }
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
-
1276
- export const __testables = {
1277
- flattenChatMessageContent,
1278
- collectNestedText,
1279
- extractCompletionReasoningText,
1280
- extractResponsesReasoningText,
1281
- extractEvidenceTokens,
1282
- matchEvidenceAgainstResume,
1283
- splitTextByChunks,
1284
- isTextContextLimitMessage,
1285
- buildChunkAnalysisPrompt,
1286
- buildLongResumeAggregatePrompt,
1287
- normalizeChunkAnalysisResult,
1288
- buildLongResumeAggregateInput,
1289
- dedupeNormalizedList,
1290
- filterEvidenceListAgainstText,
1291
- filterQuotedSpansAgainstText,
1292
- };