@reconcrap/boss-recommend-mcp 1.2.10 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +82 -1
- package/package.json +2 -1
- package/skills/boss-chat/README.md +5 -0
- package/skills/boss-chat/SKILL.md +69 -0
- package/skills/boss-recommend-pipeline/SKILL.md +40 -4
- package/src/adapters.js +19 -5
- package/src/boss-chat.js +436 -0
- package/src/cli.js +294 -129
- package/src/index.js +459 -108
- package/src/pipeline.js +605 -8
- package/src/run-state.js +5 -0
- package/src/test-adapters-runtime.js +69 -0
- package/src/test-boss-chat.js +399 -0
- package/src/test-index-async.js +238 -4
- package/src/test-pipeline.js +408 -1
- package/vendor/boss-chat-cli/README.md +134 -0
- package/vendor/boss-chat-cli/package.json +53 -0
- package/vendor/boss-chat-cli/src/app.js +769 -0
- package/vendor/boss-chat-cli/src/browser/chat-page.js +2681 -0
- package/vendor/boss-chat-cli/src/cli.js +1350 -0
- package/vendor/boss-chat-cli/src/mcp/server.js +149 -0
- package/vendor/boss-chat-cli/src/mcp/tool-runtime.js +193 -0
- package/vendor/boss-chat-cli/src/runtime/async-run-state.js +260 -0
- package/vendor/boss-chat-cli/src/runtime/interaction.js +102 -0
- package/vendor/boss-chat-cli/src/runtime/run-control.js +102 -0
- package/vendor/boss-chat-cli/src/services/chrome-client.js +97 -0
- package/vendor/boss-chat-cli/src/services/llm.js +352 -0
- package/vendor/boss-chat-cli/src/services/profile-store.js +157 -0
- package/vendor/boss-chat-cli/src/services/report-store.js +19 -0
- package/vendor/boss-chat-cli/src/services/resume-capture.js +554 -0
- package/vendor/boss-chat-cli/src/services/state-store.js +217 -0
- package/vendor/boss-chat-cli/src/utils/customer-key.js +82 -0
- package/vendor/boss-recommend-screen-cli/boss-recommend-screen-cli.cjs +902 -56
- package/vendor/boss-recommend-screen-cli/test-recoverable-resume-failures.cjs +387 -1
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
function sleep(ms) {
|
|
2
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export class StopRequestedError extends Error {
|
|
6
|
+
constructor(message = 'Run stop requested') {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = 'StopRequestedError';
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class RunControl {
|
|
13
|
+
constructor({ logger = console } = {}) {
|
|
14
|
+
this.logger = logger;
|
|
15
|
+
this.state = 'running';
|
|
16
|
+
this.stopRequested = false;
|
|
17
|
+
this.stopReason = '';
|
|
18
|
+
this.pausePromise = null;
|
|
19
|
+
this.resumePause = null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
isPaused() {
|
|
23
|
+
return this.state === 'paused';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
isStopping() {
|
|
27
|
+
return this.stopRequested;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
pause() {
|
|
31
|
+
if (this.stopRequested || this.state === 'paused') {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
this.state = 'paused';
|
|
36
|
+
this.pausePromise = new Promise((resolve) => {
|
|
37
|
+
this.resumePause = resolve;
|
|
38
|
+
});
|
|
39
|
+
this.logger.log('已暂停。按 p 或 r 继续,按 q 停止。');
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
resume() {
|
|
44
|
+
if (this.state !== 'paused') {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
this.state = 'running';
|
|
49
|
+
if (this.resumePause) {
|
|
50
|
+
this.resumePause();
|
|
51
|
+
}
|
|
52
|
+
this.pausePromise = null;
|
|
53
|
+
this.resumePause = null;
|
|
54
|
+
this.logger.log('已继续。');
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
togglePause() {
|
|
59
|
+
return this.isPaused() ? this.resume() : this.pause();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
requestStop(reason = 'User requested stop') {
|
|
63
|
+
if (this.stopRequested) {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
this.stopRequested = true;
|
|
68
|
+
this.stopReason = reason;
|
|
69
|
+
this.state = 'stopping';
|
|
70
|
+
|
|
71
|
+
if (this.resumePause) {
|
|
72
|
+
this.resumePause();
|
|
73
|
+
}
|
|
74
|
+
this.pausePromise = null;
|
|
75
|
+
this.resumePause = null;
|
|
76
|
+
|
|
77
|
+
this.logger.log(`已请求停止:${reason}`);
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async checkpoint() {
|
|
82
|
+
while (this.state === 'paused' && !this.stopRequested) {
|
|
83
|
+
await this.pausePromise;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (this.stopRequested) {
|
|
87
|
+
throw new StopRequestedError(this.stopReason || 'Run stop requested');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async delay(ms, options = {}) {
|
|
92
|
+
const chunkMs = options.chunkMs || 100;
|
|
93
|
+
let remaining = Math.max(0, ms);
|
|
94
|
+
|
|
95
|
+
while (remaining > 0) {
|
|
96
|
+
await this.checkpoint();
|
|
97
|
+
const waitMs = Math.min(chunkMs, remaining);
|
|
98
|
+
await sleep(waitMs);
|
|
99
|
+
remaining -= waitMs;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import CDP from 'chrome-remote-interface';
|
|
2
|
+
|
|
3
|
+
export class ChromeClient {
|
|
4
|
+
constructor(port = 9222) {
|
|
5
|
+
this.port = port;
|
|
6
|
+
this.client = null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
async connect(targetMatcher) {
|
|
10
|
+
const targets = await CDP.List({ port: this.port });
|
|
11
|
+
const target = targets.find((item) => targetMatcher(item));
|
|
12
|
+
|
|
13
|
+
if (!target) {
|
|
14
|
+
throw new Error('Could not find a matching Chrome tab. Make sure Boss chat is open.');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
this.client = await CDP({ port: this.port, target });
|
|
18
|
+
const { Runtime, DOM, Page, Input } = this.client;
|
|
19
|
+
|
|
20
|
+
await Promise.all([Runtime.enable(), DOM.enable(), Page.enable()]);
|
|
21
|
+
|
|
22
|
+
this.Runtime = Runtime;
|
|
23
|
+
this.DOM = DOM;
|
|
24
|
+
this.Page = Page;
|
|
25
|
+
this.Input = Input;
|
|
26
|
+
|
|
27
|
+
return target;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async evaluate(expression) {
|
|
31
|
+
const result = await this.Runtime.evaluate({
|
|
32
|
+
expression,
|
|
33
|
+
returnByValue: true,
|
|
34
|
+
awaitPromise: true,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
if (result.exceptionDetails) {
|
|
38
|
+
const description =
|
|
39
|
+
result.exceptionDetails.exception?.description ||
|
|
40
|
+
result.exceptionDetails.text ||
|
|
41
|
+
'Chrome evaluation failed';
|
|
42
|
+
throw new Error(description);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return result.result?.value;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async callFunction(fn, arg = null) {
|
|
49
|
+
const expression = `(${fn.toString()})(${JSON.stringify(arg)})`;
|
|
50
|
+
return this.evaluate(expression);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async pressEnter() {
|
|
54
|
+
const payload = {
|
|
55
|
+
windowsVirtualKeyCode: 13,
|
|
56
|
+
nativeVirtualKeyCode: 13,
|
|
57
|
+
code: 'Enter',
|
|
58
|
+
key: 'Enter',
|
|
59
|
+
unmodifiedText: '\r',
|
|
60
|
+
text: '\r',
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
await this.Input.dispatchKeyEvent({
|
|
64
|
+
type: 'keyDown',
|
|
65
|
+
...payload,
|
|
66
|
+
});
|
|
67
|
+
await this.Input.dispatchKeyEvent({
|
|
68
|
+
type: 'keyUp',
|
|
69
|
+
...payload,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async pressEscape() {
|
|
74
|
+
const payload = {
|
|
75
|
+
windowsVirtualKeyCode: 27,
|
|
76
|
+
nativeVirtualKeyCode: 27,
|
|
77
|
+
code: 'Escape',
|
|
78
|
+
key: 'Escape',
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
await this.Input.dispatchKeyEvent({
|
|
82
|
+
type: 'keyDown',
|
|
83
|
+
...payload,
|
|
84
|
+
});
|
|
85
|
+
await this.Input.dispatchKeyEvent({
|
|
86
|
+
type: 'keyUp',
|
|
87
|
+
...payload,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async close() {
|
|
92
|
+
if (this.client) {
|
|
93
|
+
await this.client.close();
|
|
94
|
+
this.client = null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
|
|
3
|
+
function getCompletionContent(data) {
|
|
4
|
+
const content = data?.choices?.[0]?.message?.content;
|
|
5
|
+
if (typeof content === 'string') {
|
|
6
|
+
return content;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
if (Array.isArray(content)) {
|
|
10
|
+
return content
|
|
11
|
+
.map((part) => {
|
|
12
|
+
if (typeof part === 'string') return part;
|
|
13
|
+
if (part?.type === 'text') return part.text || '';
|
|
14
|
+
return '';
|
|
15
|
+
})
|
|
16
|
+
.join('');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return '';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function getResponsesContent(data) {
|
|
23
|
+
if (typeof data?.output_text === 'string' && data.output_text.trim()) {
|
|
24
|
+
return data.output_text;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const output = Array.isArray(data?.output) ? data.output : [];
|
|
28
|
+
const parts = [];
|
|
29
|
+
for (const item of output) {
|
|
30
|
+
const content = Array.isArray(item?.content) ? item.content : [];
|
|
31
|
+
for (const chunk of content) {
|
|
32
|
+
if (typeof chunk?.text === 'string') {
|
|
33
|
+
parts.push(chunk.text);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return parts.join('\n').trim();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function normalizeBool(value, fallback = false) {
|
|
41
|
+
if (typeof value === 'boolean') return value;
|
|
42
|
+
if (typeof value === 'number') return value !== 0;
|
|
43
|
+
const normalized = String(value || '')
|
|
44
|
+
.trim()
|
|
45
|
+
.toLowerCase();
|
|
46
|
+
if (!normalized) return fallback;
|
|
47
|
+
if (['true', '1', 'yes', 'y', 'on'].includes(normalized)) return true;
|
|
48
|
+
if (['false', '0', 'no', 'n', 'off'].includes(normalized)) return false;
|
|
49
|
+
return fallback;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function parseLlmJson(content) {
|
|
53
|
+
const text = String(content || '').trim();
|
|
54
|
+
if (!text) {
|
|
55
|
+
throw new Error('LLM returned empty content');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const codeFenceMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
59
|
+
const candidate = codeFenceMatch ? codeFenceMatch[1] : text;
|
|
60
|
+
const jsonMatch = candidate.match(/\{[\s\S]*\}/);
|
|
61
|
+
if (!jsonMatch) {
|
|
62
|
+
throw new Error('LLM response did not contain JSON');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const parsed = JSON.parse(jsonMatch[0]);
|
|
66
|
+
const passed = typeof parsed.passed === 'boolean' ? parsed.passed : parsed.matched;
|
|
67
|
+
if (typeof passed !== 'boolean') {
|
|
68
|
+
throw new Error('LLM response missing boolean "passed"');
|
|
69
|
+
}
|
|
70
|
+
if (typeof parsed.reason !== 'string' || !parsed.reason.trim()) {
|
|
71
|
+
throw new Error('LLM response missing string "reason"');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
passed,
|
|
76
|
+
reason: parsed.reason.trim(),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function buildPrompt({ screeningCriteria, candidate }) {
|
|
81
|
+
const schools = Array.isArray(candidate?.resumeProfile?.schools)
|
|
82
|
+
? candidate.resumeProfile.schools.map((item) => String(item || '').trim()).filter(Boolean)
|
|
83
|
+
: [];
|
|
84
|
+
const majors = Array.isArray(candidate?.resumeProfile?.majors)
|
|
85
|
+
? candidate.resumeProfile.majors.map((item) => String(item || '').trim()).filter(Boolean)
|
|
86
|
+
: [];
|
|
87
|
+
const profileSchool = String(candidate?.resumeProfile?.primarySchool || '').trim();
|
|
88
|
+
const profileMajor = String(candidate?.resumeProfile?.major || '').trim();
|
|
89
|
+
const profileCompany = String(candidate?.resumeProfile?.company || '').trim();
|
|
90
|
+
const profilePosition = String(candidate?.resumeProfile?.position || '').trim();
|
|
91
|
+
const profileContext = [];
|
|
92
|
+
if (profileSchool || schools.length > 0 || profileMajor || majors.length > 0 || profileCompany || profilePosition) {
|
|
93
|
+
profileContext.push('简历结构化提取(仅来自当前候选人主简历区域):');
|
|
94
|
+
if (profileSchool) profileContext.push(`主学校:${profileSchool}`);
|
|
95
|
+
if (schools.length > 0) profileContext.push(`学校列表:${schools.join('、')}`);
|
|
96
|
+
if (profileMajor) profileContext.push(`主专业:${profileMajor}`);
|
|
97
|
+
if (majors.length > 0) profileContext.push(`专业列表:${majors.join('、')}`);
|
|
98
|
+
if (profileCompany) profileContext.push(`最近公司:${profileCompany}`);
|
|
99
|
+
if (profilePosition) profileContext.push(`最近职位:${profilePosition}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return [
|
|
103
|
+
'你是招聘筛选助手,请基于简历截图判断候选人是否符合筛选标准。',
|
|
104
|
+
'只能依据图片中可见信息判断,不得臆测。',
|
|
105
|
+
'只采信当前候选人的主简历内容(教育经历/工作经历/项目经历/专业技能)。',
|
|
106
|
+
'必须忽略推荐模块与匿名卡片信息(例如“其他名企大厂经历牛人”“相似牛人”“推荐牛人”)。',
|
|
107
|
+
'若无法在教育经历模块确认学校名称,不要编造学校名;按信息不足处理。',
|
|
108
|
+
'必须且只能返回 JSON,不要输出 Markdown。',
|
|
109
|
+
'返回格式:{"passed":true/false,"reason":"简短中文原因"}',
|
|
110
|
+
'当信息不足以支持通过时,返回 passed=false。',
|
|
111
|
+
'',
|
|
112
|
+
`筛选标准:${screeningCriteria}`,
|
|
113
|
+
'',
|
|
114
|
+
'候选人上下文(仅供辅助,不可覆盖图片事实):',
|
|
115
|
+
`姓名:${candidate.name || '未知'}`,
|
|
116
|
+
`投递职位:${candidate.sourceJob || '未知'}`,
|
|
117
|
+
...(profileContext.length > 0 ? ['', ...profileContext] : []),
|
|
118
|
+
].join('\n');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function shouldFallbackToCompletions(error) {
|
|
122
|
+
if (error?.code === 'RESPONSES_EMPTY_CONTENT') return true;
|
|
123
|
+
if (error?.code === 'RESPONSES_INCOMPLETE_LENGTH') return true;
|
|
124
|
+
if (error?.code === 'RESPONSES_UNPARSABLE') return true;
|
|
125
|
+
const message = String(error?.message || '').toLowerCase();
|
|
126
|
+
return (
|
|
127
|
+
message.includes('/responses') ||
|
|
128
|
+
message.includes('404') ||
|
|
129
|
+
message.includes('not found') ||
|
|
130
|
+
message.includes('unknown url') ||
|
|
131
|
+
message.includes('unsupported') ||
|
|
132
|
+
message.includes('input_image') ||
|
|
133
|
+
message.includes('response_format') ||
|
|
134
|
+
message.includes('empty content') ||
|
|
135
|
+
message.includes('incomplete=length') ||
|
|
136
|
+
message.includes('did not contain json')
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function shouldFallbackToResponses(error) {
|
|
141
|
+
if (error?.code === 'COMPLETIONS_EMPTY_CONTENT') return true;
|
|
142
|
+
if (error?.code === 'COMPLETIONS_UNPARSABLE') return true;
|
|
143
|
+
const message = String(error?.message || '').toLowerCase();
|
|
144
|
+
return (
|
|
145
|
+
message.includes('/chat/completions') ||
|
|
146
|
+
message.includes('404') ||
|
|
147
|
+
message.includes('not found') ||
|
|
148
|
+
message.includes('unknown url') ||
|
|
149
|
+
message.includes('unsupported') ||
|
|
150
|
+
message.includes('image_url') ||
|
|
151
|
+
message.includes('multimodal')
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export class LlmClient {
|
|
156
|
+
constructor(config, options = {}) {
|
|
157
|
+
this.baseUrl = String(config.baseUrl || '').replace(/\/+$/, '');
|
|
158
|
+
this.apiKey = config.apiKey;
|
|
159
|
+
this.model = config.model;
|
|
160
|
+
this.fetchImpl = options.fetchImpl || fetch;
|
|
161
|
+
this.maxRetries = options.maxRetries || 3;
|
|
162
|
+
this.timeoutMs = options.timeoutMs || 30000;
|
|
163
|
+
this.responseMaxOutputTokens = Number.isFinite(Number(options.responseMaxOutputTokens))
|
|
164
|
+
? Number(options.responseMaxOutputTokens)
|
|
165
|
+
: Number.isFinite(Number(config.responseMaxOutputTokens))
|
|
166
|
+
? Number(config.responseMaxOutputTokens)
|
|
167
|
+
: 1200;
|
|
168
|
+
this.completionMaxTokens = Number.isFinite(Number(options.completionMaxTokens))
|
|
169
|
+
? Number(options.completionMaxTokens)
|
|
170
|
+
: Number.isFinite(Number(config.completionMaxTokens))
|
|
171
|
+
? Number(config.completionMaxTokens)
|
|
172
|
+
: 800;
|
|
173
|
+
this.preferCompletions =
|
|
174
|
+
options.preferCompletions !== undefined
|
|
175
|
+
? normalizeBool(options.preferCompletions, false)
|
|
176
|
+
: config.preferCompletions !== undefined
|
|
177
|
+
? normalizeBool(config.preferCompletions, false)
|
|
178
|
+
: /doubao|seed/i.test(String(this.model || ''));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async readImageAsDataUrl(imagePath) {
|
|
182
|
+
const binary = await readFile(imagePath);
|
|
183
|
+
return `data:image/png;base64,${binary.toString('base64')}`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async withRetries(label, fn) {
|
|
187
|
+
let lastError = null;
|
|
188
|
+
for (let attempt = 1; attempt <= this.maxRetries; attempt += 1) {
|
|
189
|
+
try {
|
|
190
|
+
return await fn();
|
|
191
|
+
} catch (error) {
|
|
192
|
+
lastError = error;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
throw lastError || new Error(`${label} evaluation failed`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async requestResponses(prompt, imageDataUrl) {
|
|
200
|
+
const response = await this.fetchImpl(`${this.baseUrl}/responses`, {
|
|
201
|
+
method: 'POST',
|
|
202
|
+
headers: {
|
|
203
|
+
'Content-Type': 'application/json',
|
|
204
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
205
|
+
},
|
|
206
|
+
body: JSON.stringify({
|
|
207
|
+
model: this.model,
|
|
208
|
+
temperature: 0.1,
|
|
209
|
+
max_output_tokens: this.responseMaxOutputTokens,
|
|
210
|
+
input: [
|
|
211
|
+
{
|
|
212
|
+
role: 'user',
|
|
213
|
+
content: [
|
|
214
|
+
{ type: 'input_text', text: prompt },
|
|
215
|
+
{ type: 'input_image', image_url: imageDataUrl },
|
|
216
|
+
],
|
|
217
|
+
},
|
|
218
|
+
],
|
|
219
|
+
}),
|
|
220
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
if (!response.ok) {
|
|
224
|
+
const errorText = await response.text();
|
|
225
|
+
throw new Error(
|
|
226
|
+
`Responses API request failed: ${response.status} ${response.statusText} ${errorText}`,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const data = await response.json();
|
|
231
|
+
if (data?.error?.message) {
|
|
232
|
+
throw new Error(`Responses API error: ${data.error.message}`);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const content = getResponsesContent(data);
|
|
236
|
+
if (!content) {
|
|
237
|
+
const incompleteReason = String(data?.incomplete_details?.reason || '').trim();
|
|
238
|
+
const outputTypes = Array.isArray(data?.output)
|
|
239
|
+
? data.output
|
|
240
|
+
.map((item) => String(item?.type || '').trim())
|
|
241
|
+
.filter(Boolean)
|
|
242
|
+
: [];
|
|
243
|
+
const emptyError = new Error(
|
|
244
|
+
`Responses API empty textual content${
|
|
245
|
+
incompleteReason ? ` (incomplete=${incompleteReason})` : ''
|
|
246
|
+
}${outputTypes.length > 0 ? ` (outputTypes=${outputTypes.join(',')})` : ''}`,
|
|
247
|
+
);
|
|
248
|
+
emptyError.code =
|
|
249
|
+
incompleteReason.toLowerCase() === 'length'
|
|
250
|
+
? 'RESPONSES_INCOMPLETE_LENGTH'
|
|
251
|
+
: 'RESPONSES_EMPTY_CONTENT';
|
|
252
|
+
throw emptyError;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
try {
|
|
256
|
+
return parseLlmJson(content);
|
|
257
|
+
} catch (parseError) {
|
|
258
|
+
const wrapped = new Error(
|
|
259
|
+
`Responses API returned unparsable content: ${parseError?.message || parseError}`,
|
|
260
|
+
);
|
|
261
|
+
wrapped.code = 'RESPONSES_UNPARSABLE';
|
|
262
|
+
throw wrapped;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async requestCompletions(prompt, imageDataUrl) {
|
|
267
|
+
const response = await this.fetchImpl(`${this.baseUrl}/chat/completions`, {
|
|
268
|
+
method: 'POST',
|
|
269
|
+
headers: {
|
|
270
|
+
'Content-Type': 'application/json',
|
|
271
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
272
|
+
},
|
|
273
|
+
body: JSON.stringify({
|
|
274
|
+
model: this.model,
|
|
275
|
+
temperature: 0.1,
|
|
276
|
+
max_tokens: this.completionMaxTokens,
|
|
277
|
+
messages: [
|
|
278
|
+
{
|
|
279
|
+
role: 'user',
|
|
280
|
+
content: [
|
|
281
|
+
{ type: 'text', text: prompt },
|
|
282
|
+
{ type: 'image_url', image_url: { url: imageDataUrl } },
|
|
283
|
+
],
|
|
284
|
+
},
|
|
285
|
+
],
|
|
286
|
+
}),
|
|
287
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
if (!response.ok) {
|
|
291
|
+
const errorText = await response.text();
|
|
292
|
+
throw new Error(
|
|
293
|
+
`Completions API request failed: ${response.status} ${response.statusText} ${errorText}`,
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const data = await response.json();
|
|
298
|
+
if (data?.error?.message) {
|
|
299
|
+
throw new Error(`Completions API error: ${data.error.message}`);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const content = getCompletionContent(data);
|
|
303
|
+
if (!String(content || '').trim()) {
|
|
304
|
+
const emptyError = new Error('Completions API empty textual content');
|
|
305
|
+
emptyError.code = 'COMPLETIONS_EMPTY_CONTENT';
|
|
306
|
+
throw emptyError;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
try {
|
|
310
|
+
return parseLlmJson(content);
|
|
311
|
+
} catch (parseError) {
|
|
312
|
+
const wrapped = new Error(
|
|
313
|
+
`Completions API returned unparsable content: ${parseError?.message || parseError}`,
|
|
314
|
+
);
|
|
315
|
+
wrapped.code = 'COMPLETIONS_UNPARSABLE';
|
|
316
|
+
throw wrapped;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async evaluateResume({ screeningCriteria, candidate, imagePath }) {
|
|
321
|
+
const prompt = buildPrompt({ screeningCriteria, candidate });
|
|
322
|
+
const imageDataUrl = await this.readImageAsDataUrl(imagePath);
|
|
323
|
+
|
|
324
|
+
if (this.preferCompletions) {
|
|
325
|
+
try {
|
|
326
|
+
return await this.withRetries('completions', async () =>
|
|
327
|
+
this.requestCompletions(prompt, imageDataUrl),
|
|
328
|
+
);
|
|
329
|
+
} catch (completionsError) {
|
|
330
|
+
if (!shouldFallbackToResponses(completionsError)) {
|
|
331
|
+
throw completionsError;
|
|
332
|
+
}
|
|
333
|
+
return this.withRetries('responses', async () =>
|
|
334
|
+
this.requestResponses(prompt, imageDataUrl),
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
try {
|
|
340
|
+
return await this.withRetries('responses', async () =>
|
|
341
|
+
this.requestResponses(prompt, imageDataUrl),
|
|
342
|
+
);
|
|
343
|
+
} catch (responsesError) {
|
|
344
|
+
if (!shouldFallbackToCompletions(responsesError)) {
|
|
345
|
+
throw responsesError;
|
|
346
|
+
}
|
|
347
|
+
return this.withRetries('completions', async () =>
|
|
348
|
+
this.requestCompletions(prompt, imageDataUrl),
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
const DEFAULT_PROFILE = {
|
|
5
|
+
screeningCriteria: '',
|
|
6
|
+
targetCount: null,
|
|
7
|
+
startFrom: 'unread',
|
|
8
|
+
jobSelection: null,
|
|
9
|
+
llm: {
|
|
10
|
+
baseUrl: '',
|
|
11
|
+
apiKey: '',
|
|
12
|
+
model: '',
|
|
13
|
+
},
|
|
14
|
+
chrome: {
|
|
15
|
+
port: 9222,
|
|
16
|
+
},
|
|
17
|
+
runtime: {
|
|
18
|
+
batchRestEnabled: true,
|
|
19
|
+
safePacing: true,
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function cloneDefaultProfile() {
|
|
24
|
+
return JSON.parse(JSON.stringify(DEFAULT_PROFILE));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function mergeProfile(base, override) {
|
|
28
|
+
return {
|
|
29
|
+
...base,
|
|
30
|
+
...override,
|
|
31
|
+
llm: {
|
|
32
|
+
...base.llm,
|
|
33
|
+
...(override?.llm || {}),
|
|
34
|
+
},
|
|
35
|
+
chrome: {
|
|
36
|
+
...base.chrome,
|
|
37
|
+
...(override?.chrome || {}),
|
|
38
|
+
},
|
|
39
|
+
runtime: {
|
|
40
|
+
...base.runtime,
|
|
41
|
+
...(override?.runtime || {}),
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function normalizeNumber(value, fallback) {
|
|
47
|
+
const parsed = Number.parseInt(String(value), 10);
|
|
48
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function normalizeOptionalPositiveNumber(value, fallback = null) {
|
|
52
|
+
if (value === null || value === undefined || String(value).trim() === '') {
|
|
53
|
+
return fallback;
|
|
54
|
+
}
|
|
55
|
+
const parsed = Number.parseInt(String(value), 10);
|
|
56
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function normalizeJobSelection(jobSelection) {
|
|
60
|
+
if (!jobSelection || typeof jobSelection !== 'object') {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
const value = String(jobSelection.value || '').trim();
|
|
64
|
+
const label = String(jobSelection.label || '').trim();
|
|
65
|
+
if (!value && !label) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
value: value || null,
|
|
70
|
+
label: label || null,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function toPersistentProfile(profile = {}) {
|
|
75
|
+
const normalized = normalizeProfile(profile);
|
|
76
|
+
return {
|
|
77
|
+
llm: {
|
|
78
|
+
baseUrl: normalized.llm.baseUrl,
|
|
79
|
+
apiKey: normalized.llm.apiKey,
|
|
80
|
+
model: normalized.llm.model,
|
|
81
|
+
},
|
|
82
|
+
chrome: {
|
|
83
|
+
port: normalized.chrome.port,
|
|
84
|
+
},
|
|
85
|
+
runtime: {
|
|
86
|
+
batchRestEnabled: normalized.runtime.batchRestEnabled,
|
|
87
|
+
safePacing: normalized.runtime.safePacing,
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function normalizeProfile(profile = {}) {
|
|
93
|
+
const merged = mergeProfile(cloneDefaultProfile(), profile);
|
|
94
|
+
merged.screeningCriteria = String(merged.screeningCriteria || '').trim();
|
|
95
|
+
merged.startFrom = String(merged.startFrom || '').trim().toLowerCase() === 'all' ? 'all' : 'unread';
|
|
96
|
+
merged.targetCount = normalizeOptionalPositiveNumber(merged.targetCount, null);
|
|
97
|
+
merged.jobSelection = normalizeJobSelection(merged.jobSelection);
|
|
98
|
+
merged.chrome.port = normalizeNumber(merged.chrome.port, DEFAULT_PROFILE.chrome.port);
|
|
99
|
+
merged.llm.baseUrl = String(merged.llm.baseUrl || '').trim().replace(/\/+$/, '');
|
|
100
|
+
merged.llm.apiKey = String(merged.llm.apiKey || '').trim();
|
|
101
|
+
merged.llm.model = String(merged.llm.model || '').trim();
|
|
102
|
+
merged.runtime.batchRestEnabled = merged.runtime.batchRestEnabled !== false;
|
|
103
|
+
merged.runtime.safePacing = merged.runtime.safePacing !== false;
|
|
104
|
+
return merged;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function validateProfile(profile) {
|
|
108
|
+
const missing = [];
|
|
109
|
+
if (!profile.llm.baseUrl) missing.push('llm.baseUrl');
|
|
110
|
+
if (!profile.llm.apiKey) missing.push('llm.apiKey');
|
|
111
|
+
if (!profile.llm.model) missing.push('llm.model');
|
|
112
|
+
if (!profile.chrome.port) missing.push('chrome.port');
|
|
113
|
+
return missing;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export class ProfileStore {
|
|
117
|
+
constructor(baseDir) {
|
|
118
|
+
this.baseDir = baseDir;
|
|
119
|
+
this.profilesDir = path.join(baseDir, 'profiles');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
profilePath(profileName) {
|
|
123
|
+
return path.join(this.profilesDir, `${profileName}.json`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async ensureDir() {
|
|
127
|
+
await mkdir(this.profilesDir, { recursive: true });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async load(profileName) {
|
|
131
|
+
await this.ensureDir();
|
|
132
|
+
try {
|
|
133
|
+
const raw = await readFile(this.profilePath(profileName), 'utf8');
|
|
134
|
+
return normalizeProfile(JSON.parse(raw));
|
|
135
|
+
} catch (error) {
|
|
136
|
+
if (error && error.code === 'ENOENT') {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async save(profileName, profile) {
|
|
144
|
+
await this.ensureDir();
|
|
145
|
+
const normalized = toPersistentProfile(profile);
|
|
146
|
+
await writeFile(
|
|
147
|
+
this.profilePath(profileName),
|
|
148
|
+
`${JSON.stringify(normalized, null, 2)}\n`,
|
|
149
|
+
'utf8',
|
|
150
|
+
);
|
|
151
|
+
return normalizeProfile(normalized);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
mergeWithOverrides(profile, overrides) {
|
|
155
|
+
return normalizeProfile(mergeProfile(profile || cloneDefaultProfile(), overrides));
|
|
156
|
+
}
|
|
157
|
+
}
|