@reconcrap/boss-recruit-mcp 1.0.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 -0
- package/bin/boss-recruit-mcp.js +2 -0
- package/config/screening-config.example.json +7 -0
- package/package.json +42 -0
- package/skills/boss-recruit-pipeline/README.md +20 -0
- package/skills/boss-recruit-pipeline/SKILL.md +83 -0
- package/src/adapters.js +352 -0
- package/src/cli.js +112 -0
- package/src/index.js +210 -0
- package/src/parser.js +221 -0
- package/src/pipeline.js +215 -0
- package/src/test-parser.js +79 -0
- package/vendor/boss-screen-cli/boss-screen-cli.cjs +1646 -0
- package/vendor/boss-screen-cli/favorite-calibration.json +9 -0
- package/vendor/boss-search-cli/src/boss-searcher.js +667 -0
- package/vendor/boss-search-cli/src/chrome-connector.js +79 -0
- package/vendor/boss-search-cli/src/cli.js +132 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { runRecruitPipeline } from "./pipeline.js";
|
|
5
|
+
|
|
6
|
+
const TOOL_NAME = "run_recruit_pipeline";
|
|
7
|
+
const SERVER_NAME = "boss-recruit-mcp";
|
|
8
|
+
const SERVER_VERSION = "1.0.0";
|
|
9
|
+
|
|
10
|
+
function writeMessage(message) {
|
|
11
|
+
const body = JSON.stringify(message);
|
|
12
|
+
const header = `Content-Length: ${Buffer.byteLength(body, "utf8")}\r\n\r\n`;
|
|
13
|
+
process.stdout.write(header + body);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function createJsonRpcError(id, code, message) {
|
|
17
|
+
return {
|
|
18
|
+
jsonrpc: "2.0",
|
|
19
|
+
id: id ?? null,
|
|
20
|
+
error: { code, message }
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function createToolSchema() {
|
|
25
|
+
return {
|
|
26
|
+
name: TOOL_NAME,
|
|
27
|
+
description: "统一招聘流水线:解析招聘指令、校验条件、执行搜索与筛选并返回摘要。",
|
|
28
|
+
inputSchema: {
|
|
29
|
+
type: "object",
|
|
30
|
+
properties: {
|
|
31
|
+
instruction: {
|
|
32
|
+
type: "string",
|
|
33
|
+
description: "用户自然语言招聘指令"
|
|
34
|
+
},
|
|
35
|
+
confirmation: {
|
|
36
|
+
type: "object",
|
|
37
|
+
properties: {
|
|
38
|
+
keyword_confirmed: { type: "boolean" },
|
|
39
|
+
keyword_value: { type: "string" }
|
|
40
|
+
},
|
|
41
|
+
additionalProperties: false
|
|
42
|
+
},
|
|
43
|
+
overrides: {
|
|
44
|
+
type: "object",
|
|
45
|
+
properties: {
|
|
46
|
+
target_count: { type: "integer", minimum: 1 }
|
|
47
|
+
},
|
|
48
|
+
additionalProperties: false
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
required: ["instruction"],
|
|
52
|
+
additionalProperties: false
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function handleRequest(message, workspaceRoot) {
|
|
58
|
+
if (!message || message.jsonrpc !== "2.0") {
|
|
59
|
+
return createJsonRpcError(null, -32600, "Invalid JSON-RPC request");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const { id, method, params } = message;
|
|
63
|
+
|
|
64
|
+
if (method === "initialize") {
|
|
65
|
+
return {
|
|
66
|
+
jsonrpc: "2.0",
|
|
67
|
+
id,
|
|
68
|
+
result: {
|
|
69
|
+
protocolVersion: "2024-11-05",
|
|
70
|
+
capabilities: {
|
|
71
|
+
tools: {}
|
|
72
|
+
},
|
|
73
|
+
serverInfo: {
|
|
74
|
+
name: SERVER_NAME,
|
|
75
|
+
version: SERVER_VERSION
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (method === "notifications/initialized") {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (method === "tools/list") {
|
|
86
|
+
return {
|
|
87
|
+
jsonrpc: "2.0",
|
|
88
|
+
id,
|
|
89
|
+
result: {
|
|
90
|
+
tools: [createToolSchema()]
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (method === "tools/call") {
|
|
96
|
+
if (!params || params.name !== TOOL_NAME) {
|
|
97
|
+
return createJsonRpcError(id, -32602, `Unknown tool: ${params?.name || ""}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const args = params.arguments || {};
|
|
101
|
+
if (!args.instruction || typeof args.instruction !== "string") {
|
|
102
|
+
return createJsonRpcError(id, -32602, "instruction is required and must be a string");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
const result = await runRecruitPipeline({
|
|
107
|
+
workspaceRoot,
|
|
108
|
+
instruction: args.instruction,
|
|
109
|
+
confirmation: args.confirmation,
|
|
110
|
+
overrides: args.overrides
|
|
111
|
+
});
|
|
112
|
+
return {
|
|
113
|
+
jsonrpc: "2.0",
|
|
114
|
+
id,
|
|
115
|
+
result: {
|
|
116
|
+
content: [
|
|
117
|
+
{
|
|
118
|
+
type: "text",
|
|
119
|
+
text: JSON.stringify(result, null, 2)
|
|
120
|
+
}
|
|
121
|
+
],
|
|
122
|
+
structuredContent: result
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
} catch (error) {
|
|
126
|
+
const failed = {
|
|
127
|
+
status: "FAILED",
|
|
128
|
+
error: {
|
|
129
|
+
code: "UNEXPECTED_ERROR",
|
|
130
|
+
message: error.message || "Unexpected error",
|
|
131
|
+
retryable: true
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
return {
|
|
135
|
+
jsonrpc: "2.0",
|
|
136
|
+
id,
|
|
137
|
+
result: {
|
|
138
|
+
content: [
|
|
139
|
+
{
|
|
140
|
+
type: "text",
|
|
141
|
+
text: JSON.stringify(failed, null, 2)
|
|
142
|
+
}
|
|
143
|
+
],
|
|
144
|
+
structuredContent: failed,
|
|
145
|
+
isError: true
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (method === "ping") {
|
|
152
|
+
return { jsonrpc: "2.0", id, result: {} };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (id === undefined || id === null) {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
return createJsonRpcError(id, -32601, `Method not found: ${method}`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function startServer() {
|
|
162
|
+
const envRoot = process.env.BOSS_WORKSPACE_ROOT;
|
|
163
|
+
const thisFile = fileURLToPath(import.meta.url);
|
|
164
|
+
const mcpRoot = path.resolve(path.dirname(thisFile), "..");
|
|
165
|
+
const workspaceRoot = envRoot ? path.resolve(envRoot) : path.resolve(mcpRoot, "..");
|
|
166
|
+
let buffer = Buffer.alloc(0);
|
|
167
|
+
|
|
168
|
+
process.stdin.on("data", async (chunk) => {
|
|
169
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
170
|
+
|
|
171
|
+
while (true) {
|
|
172
|
+
const headerEnd = buffer.indexOf("\r\n\r\n");
|
|
173
|
+
if (headerEnd === -1) break;
|
|
174
|
+
|
|
175
|
+
const headerText = buffer.slice(0, headerEnd).toString("utf8");
|
|
176
|
+
const contentLengthLine = headerText
|
|
177
|
+
.split("\r\n")
|
|
178
|
+
.find((line) => line.toLowerCase().startsWith("content-length:"));
|
|
179
|
+
|
|
180
|
+
if (!contentLengthLine) {
|
|
181
|
+
buffer = buffer.slice(headerEnd + 4);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const contentLength = Number.parseInt(contentLengthLine.split(":")[1].trim(), 10);
|
|
186
|
+
const bodyStart = headerEnd + 4;
|
|
187
|
+
const bodyEnd = bodyStart + contentLength;
|
|
188
|
+
if (buffer.length < bodyEnd) break;
|
|
189
|
+
|
|
190
|
+
const body = buffer.slice(bodyStart, bodyEnd).toString("utf8");
|
|
191
|
+
buffer = buffer.slice(bodyEnd);
|
|
192
|
+
|
|
193
|
+
let message;
|
|
194
|
+
try {
|
|
195
|
+
message = JSON.parse(body);
|
|
196
|
+
} catch {
|
|
197
|
+
writeMessage(createJsonRpcError(null, -32700, "Parse error"));
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const response = await handleRequest(message, workspaceRoot);
|
|
202
|
+
if (response) writeMessage(response);
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const thisFilePath = fileURLToPath(import.meta.url);
|
|
208
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === thisFilePath) {
|
|
209
|
+
startServer();
|
|
210
|
+
}
|
package/src/parser.js
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
const SEARCH_SCHOOL_MAP = {
|
|
2
|
+
"985": "985院校",
|
|
3
|
+
"211": "211院校",
|
|
4
|
+
"qs100": "QS 100"
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
function normalizeText(input) {
|
|
8
|
+
return String(input || "").replace(/\s+/g, " ").trim();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function uniqueList(items) {
|
|
12
|
+
return Array.from(new Set(items.filter(Boolean)));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function extractCity(text) {
|
|
16
|
+
const explicitPatterns = [
|
|
17
|
+
/地点(?:在|是|为|:|:)?\s*([^\s,。;;、]+)/i,
|
|
18
|
+
/城市(?:在|是|为|:|:)?\s*([^\s,。;;、]+)/i
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
for (const pattern of explicitPatterns) {
|
|
22
|
+
const m = text.match(pattern);
|
|
23
|
+
if (m && m[1]) {
|
|
24
|
+
return m[1].trim();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function extractDegree(text) {
|
|
32
|
+
if (/(博士及以上|博士)/.test(text)) return "博士";
|
|
33
|
+
if (/(硕士及以上|硕士以上)/.test(text)) return "硕士及以上";
|
|
34
|
+
if (/(本科及以上|本科以上)/.test(text)) return "本科及以上";
|
|
35
|
+
if (/本科/.test(text)) return "本科";
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function extractSchools(text) {
|
|
40
|
+
const schools = [];
|
|
41
|
+
if (/(^|[^0-9])985([^0-9]|$)/.test(text)) schools.push(SEARCH_SCHOOL_MAP["985"]);
|
|
42
|
+
if (/(^|[^0-9])211([^0-9]|$)/.test(text)) schools.push(SEARCH_SCHOOL_MAP["211"]);
|
|
43
|
+
if (/(qs\s*100|QS\s*100|qs100|QS100)/.test(text)) schools.push(SEARCH_SCHOOL_MAP["qs100"]);
|
|
44
|
+
return uniqueList(schools);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function extractKeywordExplicit(text) {
|
|
48
|
+
const patterns = [
|
|
49
|
+
/搜索关键词(?:为|是|:|:)?\s*([^\n,。;;]+)/i,
|
|
50
|
+
/关键词(?:为|是|:|:)?\s*([^\n,。;;]+)/i,
|
|
51
|
+
/keyword(?:\s*[::=]\s*|\s+is\s+)([^\n,。;;]+)/i
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
for (const pattern of patterns) {
|
|
55
|
+
const m = text.match(pattern);
|
|
56
|
+
if (m && m[1]) {
|
|
57
|
+
const kw = m[1].trim();
|
|
58
|
+
if (kw) return kw;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function extractKeywordAuto(text) {
|
|
65
|
+
const patterns = [
|
|
66
|
+
/做过\s*([A-Za-z0-9+#./\-\s]{2,40}?)(?:的人选|的人|相关|并且|且|,|。|,|$)/i,
|
|
67
|
+
/有过\s*([A-Za-z0-9+#./\-\s]{2,40}?)(?:经验|背景|的人选|并且|且|,|。|,|$)/i,
|
|
68
|
+
/从事过\s*([A-Za-z0-9+#./\-\s]{2,40}?)(?:相关|的人选|并且|且|,|。|,|$)/i
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
for (const pattern of patterns) {
|
|
72
|
+
const m = text.match(pattern);
|
|
73
|
+
if (m && m[1]) {
|
|
74
|
+
const kw = m[1].replace(/\s+/g, " ").trim();
|
|
75
|
+
if (kw.length >= 2) return kw;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function extractTargetCount(text) {
|
|
82
|
+
const patterns = [
|
|
83
|
+
/至少筛选\s*(\d+)\s*位?/i,
|
|
84
|
+
/目标(?:筛选)?(?:人数|数量)?(?:为|是|:|:)?\s*(\d+)/i,
|
|
85
|
+
/目标(?:筛选)?(?:人数|数量)?\s*(\d+)\s*人/i,
|
|
86
|
+
/筛选\s*(\d+)\s*位/i
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
for (const pattern of patterns) {
|
|
90
|
+
const m = text.match(pattern);
|
|
91
|
+
if (m && m[1]) {
|
|
92
|
+
const n = Number.parseInt(m[1], 10);
|
|
93
|
+
if (Number.isFinite(n) && n > 0) return n;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function sanitizeClause(clause) {
|
|
100
|
+
return clause
|
|
101
|
+
.replace(/^使用boss-recruit-pipeline skills/i, "")
|
|
102
|
+
.replace(/^帮我(?:在boss上)?(?:找|筛选)/i, "")
|
|
103
|
+
.replace(/^请(?:在boss上)?(?:帮我)?(?:找|筛选)/i, "")
|
|
104
|
+
.replace(/^在boss上(?:帮我)?(?:找|筛选)/i, "")
|
|
105
|
+
.replace(/的人选$/, "")
|
|
106
|
+
.replace(/的人$/, "")
|
|
107
|
+
.trim();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function buildScreenCriteria(text, searchParams) {
|
|
111
|
+
const clauses = text
|
|
112
|
+
.split(/[,,。;;\n]/)
|
|
113
|
+
.map((s) => sanitizeClause(s))
|
|
114
|
+
.filter(Boolean);
|
|
115
|
+
|
|
116
|
+
const filtered = clauses.filter((clause) => {
|
|
117
|
+
if (/搜索关键词|关键词|keyword/i.test(clause)) return false;
|
|
118
|
+
if (/地点|城市/.test(clause)) return false;
|
|
119
|
+
if (/学历|本科|硕士|博士/.test(clause) && !/论文|项目|经验/.test(clause)) return false;
|
|
120
|
+
if (/985|211|qs\s*100|QS\s*100|院校/.test(clause) && !/论文|经验|项目/.test(clause)) return false;
|
|
121
|
+
if (/至少筛选|目标人数|目标数量|筛选\d+位/.test(clause)) return false;
|
|
122
|
+
return true;
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const normalized = filtered
|
|
126
|
+
.map((clause) => clause.replace(/\s+/g, " ").trim())
|
|
127
|
+
.filter(Boolean);
|
|
128
|
+
|
|
129
|
+
if (searchParams?.keyword) {
|
|
130
|
+
const keywordClause = `候选人需有${searchParams.keyword}相关经历`;
|
|
131
|
+
const alreadyCovered = normalized.some((clause) =>
|
|
132
|
+
clause.toLowerCase().includes(String(searchParams.keyword).toLowerCase())
|
|
133
|
+
);
|
|
134
|
+
if (!alreadyCovered) {
|
|
135
|
+
normalized.unshift(keywordClause);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (normalized.length === 0) {
|
|
140
|
+
return searchParams?.keyword
|
|
141
|
+
? `候选人需有${searchParams.keyword}相关经历`
|
|
142
|
+
: text;
|
|
143
|
+
}
|
|
144
|
+
return uniqueList(normalized).join(";");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function resolveKeyword(parsed, confirmation) {
|
|
148
|
+
const explicit = parsed.keyword_explicit;
|
|
149
|
+
const auto = parsed.keyword_auto;
|
|
150
|
+
const confirmed = confirmation && confirmation.keyword_confirmed === true;
|
|
151
|
+
const rejected = confirmation && confirmation.keyword_confirmed === false;
|
|
152
|
+
const value = confirmation && typeof confirmation.keyword_value === "string"
|
|
153
|
+
? confirmation.keyword_value.trim()
|
|
154
|
+
: "";
|
|
155
|
+
|
|
156
|
+
if (confirmed && value) {
|
|
157
|
+
return { keyword: value, needsConfirmation: false, proposedKeyword: null };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (explicit) {
|
|
161
|
+
return { keyword: explicit, needsConfirmation: false, proposedKeyword: null };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (rejected) {
|
|
165
|
+
return { keyword: value || null, needsConfirmation: false, proposedKeyword: null };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (auto) {
|
|
169
|
+
if (confirmed) {
|
|
170
|
+
return { keyword: auto, needsConfirmation: false, proposedKeyword: null };
|
|
171
|
+
}
|
|
172
|
+
return { keyword: null, needsConfirmation: true, proposedKeyword: auto };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return { keyword: null, needsConfirmation: false, proposedKeyword: null };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function parseRecruitInstruction({ instruction, confirmation, overrides }) {
|
|
179
|
+
const text = normalizeText(instruction);
|
|
180
|
+
const parsed = {
|
|
181
|
+
city: extractCity(text),
|
|
182
|
+
degree: extractDegree(text),
|
|
183
|
+
schools: extractSchools(text),
|
|
184
|
+
keyword_explicit: extractKeywordExplicit(text),
|
|
185
|
+
keyword_auto: extractKeywordAuto(text),
|
|
186
|
+
target_count: extractTargetCount(text)
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
if (overrides && Number.isFinite(overrides.target_count) && overrides.target_count > 0) {
|
|
190
|
+
parsed.target_count = Number.parseInt(String(overrides.target_count), 10);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const keywordResolution = resolveKeyword(parsed, confirmation);
|
|
194
|
+
const searchParams = {
|
|
195
|
+
city: parsed.city,
|
|
196
|
+
degree: parsed.degree,
|
|
197
|
+
schools: parsed.schools,
|
|
198
|
+
keyword: keywordResolution.keyword
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const screenParams = {
|
|
202
|
+
criteria: buildScreenCriteria(text, searchParams),
|
|
203
|
+
target_count: parsed.target_count
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const missing = [];
|
|
207
|
+
if (!searchParams.city) missing.push("city");
|
|
208
|
+
if (!searchParams.degree) missing.push("degree");
|
|
209
|
+
if (!searchParams.schools || searchParams.schools.length === 0) missing.push("schools");
|
|
210
|
+
if (!searchParams.keyword) missing.push("keyword");
|
|
211
|
+
if (!screenParams.target_count) missing.push("target_count");
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
parsed,
|
|
215
|
+
searchParams,
|
|
216
|
+
screenParams,
|
|
217
|
+
missing_fields: missing,
|
|
218
|
+
needs_keyword_confirmation: keywordResolution.needsConfirmation,
|
|
219
|
+
proposed_keyword: keywordResolution.proposedKeyword
|
|
220
|
+
};
|
|
221
|
+
}
|
package/src/pipeline.js
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { parseRecruitInstruction } from "./parser.js";
|
|
2
|
+
import { runPipelinePreflight, runSearchCli, runScreenCli } from "./adapters.js";
|
|
3
|
+
|
|
4
|
+
function buildNeedInputResponse(parsedResult) {
|
|
5
|
+
return {
|
|
6
|
+
status: "NEED_INPUT",
|
|
7
|
+
missing_fields: parsedResult.missing_fields,
|
|
8
|
+
search_params: parsedResult.searchParams,
|
|
9
|
+
screen_params: parsedResult.screenParams,
|
|
10
|
+
error: {
|
|
11
|
+
code: "MISSING_REQUIRED_FIELDS",
|
|
12
|
+
message: "缺少必要字段,请一次性补充缺失项后再执行。",
|
|
13
|
+
retryable: true
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function buildNeedConfirmationResponse(parsedResult) {
|
|
19
|
+
return {
|
|
20
|
+
status: "NEED_CONFIRMATION",
|
|
21
|
+
proposed_keyword: parsedResult.proposed_keyword,
|
|
22
|
+
search_params: {
|
|
23
|
+
...parsedResult.searchParams,
|
|
24
|
+
keyword: parsedResult.proposed_keyword
|
|
25
|
+
},
|
|
26
|
+
screen_params: parsedResult.screenParams
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function buildFailedResponse(code, message, extra = {}) {
|
|
31
|
+
return {
|
|
32
|
+
status: "FAILED",
|
|
33
|
+
error: {
|
|
34
|
+
code,
|
|
35
|
+
message,
|
|
36
|
+
retryable: true
|
|
37
|
+
},
|
|
38
|
+
...extra
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function classifySearchFailure(searchResult) {
|
|
43
|
+
const stderr = searchResult.stderr || "";
|
|
44
|
+
const errorCode = searchResult.error_code || "";
|
|
45
|
+
|
|
46
|
+
if (errorCode === "EPERM" || /spawn EPERM/i.test(stderr)) {
|
|
47
|
+
return {
|
|
48
|
+
code: "SEARCH_PROCESS_PERMISSION_DENIED",
|
|
49
|
+
message: "搜索工具无法启动子进程,当前运行环境拒绝了进程创建权限。请在本地终端直接运行 MCP 或放宽运行权限后重试。"
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (errorCode === "TIMEOUT" || /timed out/i.test(stderr)) {
|
|
54
|
+
return {
|
|
55
|
+
code: "SEARCH_TIMEOUT",
|
|
56
|
+
message: "搜索工具执行超时,可能是 Chrome 远程调试未就绪、Boss 页面未打开,或页面交互卡住。"
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (errorCode === "ENOENT" || /not recognized|Cannot find|MODULE_NOT_FOUND/i.test(stderr)) {
|
|
61
|
+
return {
|
|
62
|
+
code: "SEARCH_CLI_MISSING",
|
|
63
|
+
message: "搜索工具入口不存在或 Node 环境不可用,请检查 boss-search-cli 安装与路径配置。"
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
code: "SEARCH_CLI_FAILED",
|
|
69
|
+
message: "搜索工具执行失败,请检查 Chrome 远程调试、Boss 登录状态和页面可访问性。"
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function classifyScreenFailure(screenResult) {
|
|
74
|
+
const stderr = screenResult.stderr || "";
|
|
75
|
+
const errorCode = screenResult.error_code || "";
|
|
76
|
+
|
|
77
|
+
if (screenResult.config_error) {
|
|
78
|
+
return {
|
|
79
|
+
code: "SCREEN_CONFIG_ERROR",
|
|
80
|
+
message: "筛选工具配置缺失或格式错误,请检查 screening-config.json。"
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (errorCode === "EPERM" || /spawn EPERM/i.test(stderr)) {
|
|
85
|
+
return {
|
|
86
|
+
code: "SCREEN_PROCESS_PERMISSION_DENIED",
|
|
87
|
+
message: "筛选工具无法启动子进程,当前运行环境拒绝了进程创建权限。请在本地终端直接运行 MCP 或放宽运行权限后重试。"
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (errorCode === "TIMEOUT" || /timed out/i.test(stderr)) {
|
|
92
|
+
return {
|
|
93
|
+
code: "SCREEN_TIMEOUT",
|
|
94
|
+
message: "筛选工具执行超时,可能是 Boss 页面交互卡住、LLM 接口响应过慢,或候选人列表处理速度异常。"
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (errorCode === "ENOENT" || /not recognized|Cannot find|MODULE_NOT_FOUND/i.test(stderr)) {
|
|
99
|
+
return {
|
|
100
|
+
code: "SCREEN_CLI_MISSING",
|
|
101
|
+
message: "筛选工具入口不存在或 Node 环境不可用,请检查 boss-screen-cli 安装与路径配置。"
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
code: "SCREEN_CLI_FAILED",
|
|
107
|
+
message: "筛选工具执行失败,请检查模型配置、Chrome 远程调试和页面状态。"
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function runRecruitPipeline({
|
|
112
|
+
workspaceRoot,
|
|
113
|
+
instruction,
|
|
114
|
+
confirmation,
|
|
115
|
+
overrides
|
|
116
|
+
}) {
|
|
117
|
+
const startedAt = Date.now();
|
|
118
|
+
const parsed = parseRecruitInstruction({
|
|
119
|
+
instruction,
|
|
120
|
+
confirmation,
|
|
121
|
+
overrides
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
if (parsed.needs_keyword_confirmation) {
|
|
125
|
+
return buildNeedConfirmationResponse(parsed);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (parsed.missing_fields.length > 0) {
|
|
129
|
+
return buildNeedInputResponse(parsed);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const preflight = runPipelinePreflight(workspaceRoot);
|
|
133
|
+
if (!preflight.ok) {
|
|
134
|
+
return buildFailedResponse(
|
|
135
|
+
"PIPELINE_PREFLIGHT_FAILED",
|
|
136
|
+
"招聘流水线运行前检查失败,请先修复缺失的本地依赖或配置文件。",
|
|
137
|
+
{
|
|
138
|
+
search_params: parsed.searchParams,
|
|
139
|
+
screen_params: parsed.screenParams,
|
|
140
|
+
diagnostics: {
|
|
141
|
+
checks: preflight.checks
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const searchResult = await runSearchCli({
|
|
148
|
+
workspaceRoot,
|
|
149
|
+
searchParams: parsed.searchParams
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
if (!searchResult.ok) {
|
|
153
|
+
const failure = classifySearchFailure(searchResult);
|
|
154
|
+
return buildFailedResponse(
|
|
155
|
+
failure.code,
|
|
156
|
+
failure.message,
|
|
157
|
+
{
|
|
158
|
+
search_params: parsed.searchParams,
|
|
159
|
+
screen_params: parsed.screenParams,
|
|
160
|
+
diagnostics: {
|
|
161
|
+
exit_code: searchResult.exit_code,
|
|
162
|
+
error_code: searchResult.error_code,
|
|
163
|
+
stderr: searchResult.stderr?.slice(0, 1200)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (searchResult.candidate_count === 0) {
|
|
170
|
+
return buildFailedResponse(
|
|
171
|
+
"SEARCH_EMPTY_RESULT",
|
|
172
|
+
"搜索结果为空,已停止后续筛选。请调整搜索条件后重试。",
|
|
173
|
+
{
|
|
174
|
+
search_params: parsed.searchParams,
|
|
175
|
+
screen_params: parsed.screenParams,
|
|
176
|
+
diagnostics: {
|
|
177
|
+
candidate_count: 0
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const screenResult = await runScreenCli({
|
|
184
|
+
workspaceRoot,
|
|
185
|
+
screenParams: parsed.screenParams
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
if (!screenResult.ok) {
|
|
189
|
+
const failure = classifyScreenFailure(screenResult);
|
|
190
|
+
return buildFailedResponse(failure.code, failure.message, {
|
|
191
|
+
search_params: parsed.searchParams,
|
|
192
|
+
screen_params: parsed.screenParams,
|
|
193
|
+
diagnostics: {
|
|
194
|
+
exit_code: screenResult.exit_code,
|
|
195
|
+
error_code: screenResult.error_code,
|
|
196
|
+
stderr: screenResult.stderr?.slice(0, 1200)
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const durationSec = Math.max(1, Math.round((Date.now() - startedAt) / 1000));
|
|
202
|
+
const summary = screenResult.summary || {};
|
|
203
|
+
return {
|
|
204
|
+
status: "COMPLETED",
|
|
205
|
+
search_params: parsed.searchParams,
|
|
206
|
+
screen_params: parsed.screenParams,
|
|
207
|
+
result: {
|
|
208
|
+
target_count: summary.target_count ?? parsed.screenParams.target_count,
|
|
209
|
+
processed_count: summary.processed_count ?? null,
|
|
210
|
+
passed_count: summary.passed_count ?? null,
|
|
211
|
+
duration_sec: durationSec,
|
|
212
|
+
output_csv: summary.output_csv
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
}
|