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