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