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