@reconcrap/boss-recruit-mcp 1.0.14 → 1.0.15
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 +5 -2
- package/package.json +3 -1
- package/src/adapters.js +84 -0
- package/src/pipeline.js +313 -96
- package/src/test-pipeline.js +434 -0
package/README.md
CHANGED
|
@@ -218,11 +218,14 @@ boss-recruit-mcp doctor --port <port>
|
|
|
218
218
|
- 正式执行前应先单独做一轮参数确认,把已识别参数、待确认项、缺失项、默认值风险分开给用户确认
|
|
219
219
|
- 若用户没提“是否过滤近14天查看”,会在 `pending_questions` 里返回该问题,调用方应先补问再继续
|
|
220
220
|
- 用户未补齐缺失参数时,只有在明确同意默认值及其质量风险后,才允许继续
|
|
221
|
-
- `target_count`
|
|
222
|
-
-
|
|
221
|
+
- `target_count` 表示“目标处理人数”,不是“目标通过人数”;会按“累计处理人数”自动多轮执行,直到达到目标处理人数,或新一轮搜索返回 0 个可筛选人选
|
|
222
|
+
- 若搜索页出现 `i.tip-nodata`(即使仍能看到候选人卡片),也会判定候选池已耗尽并结束多轮流程
|
|
223
|
+
- 第 2 轮及后续轮次会强制 `filter_recent_viewed=true`(即过滤近 14 天查看过的人选),不受首轮开关影响
|
|
224
|
+
- 确认后自动执行:搜索 CLI -> 点击搜索 -> 勾选“过滤近14天查看”(按轮次规则) -> 筛选 CLI
|
|
223
225
|
- 返回摘要:目标数、已处理、通过数、耗时、输出 CSV
|
|
224
226
|
- 执行前会先做本地依赖预检查,若目录 / 入口 / 配置文件缺失则返回 `PIPELINE_PREFLIGHT_FAILED`
|
|
225
227
|
- 若缺少 `favorite-calibration.json`,会返回 `CALIBRATION_REQUIRED`
|
|
228
|
+
- 若某轮搜索返回可筛选候选人但筛选 `processed_count` 非法或为 0,会先导出当前累计 CSV,再返回 `SCREEN_NO_PROGRESS`
|
|
226
229
|
- 若当前运行环境不允许启动子进程,会返回更明确的权限错误码而不是笼统失败
|
|
227
230
|
- 配置文件查找顺序:`BOSS_RECRUIT_SCREEN_CONFIG` > 工作区 `boss-recruit-mcp/config/screening-config.json` > 用户目录 `$CODEX_HOME/boss-recruit-mcp/screening-config.json` > 包内示例配置
|
|
228
231
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reconcrap/boss-recruit-mcp",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.15",
|
|
4
4
|
"description": "Unified MCP pipeline for boss-search-cli and boss-screen-cli",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"boss",
|
|
@@ -20,6 +20,8 @@
|
|
|
20
20
|
"install:local": "node src/cli.js install",
|
|
21
21
|
"postinstall": "node scripts/postinstall.cjs",
|
|
22
22
|
"test:parser": "node src/test-parser.js",
|
|
23
|
+
"test:pipeline": "node src/test-pipeline.js",
|
|
24
|
+
"test:protocol:win": "powershell -ExecutionPolicy Bypass -File scripts/verify-multi-round-protocol.ps1 -Mode unit",
|
|
23
25
|
"test:regression:win": "powershell -ExecutionPolicy Bypass -File scripts/regression.ps1 -Mode quick"
|
|
24
26
|
},
|
|
25
27
|
"files": [
|
package/src/adapters.js
CHANGED
|
@@ -3,6 +3,7 @@ import os from "node:os";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { spawn } from "node:child_process";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
|
+
import CDP from "chrome-remote-interface";
|
|
6
7
|
const currentFilePath = fileURLToPath(import.meta.url);
|
|
7
8
|
const packagedMcpDir = path.resolve(path.dirname(currentFilePath), "..");
|
|
8
9
|
const bossSearchUrl = "https://www.zhipin.com/web/chat/search";
|
|
@@ -161,6 +162,82 @@ function parseSearchCount(output) {
|
|
|
161
162
|
return Number.parseInt(m[1], 10);
|
|
162
163
|
}
|
|
163
164
|
|
|
165
|
+
async function detectSearchNoDataTip(debugPort) {
|
|
166
|
+
let client = null;
|
|
167
|
+
try {
|
|
168
|
+
const targets = await CDP.List({ port: debugPort });
|
|
169
|
+
const target = targets.find(
|
|
170
|
+
(item) => typeof item?.url === "string" && item.url.includes("/web/chat/search")
|
|
171
|
+
) || targets.find((item) => item?.type === "page");
|
|
172
|
+
if (!target) {
|
|
173
|
+
return {
|
|
174
|
+
ok: false,
|
|
175
|
+
exhausted: null,
|
|
176
|
+
error: "No page target found on Chrome DevTools"
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
client = await CDP({
|
|
181
|
+
port: debugPort,
|
|
182
|
+
target
|
|
183
|
+
});
|
|
184
|
+
const { Runtime } = client;
|
|
185
|
+
await Runtime.enable();
|
|
186
|
+
|
|
187
|
+
const expression = `(function () {
|
|
188
|
+
try {
|
|
189
|
+
var rootDoc = document;
|
|
190
|
+
var iframe = document.querySelector("iframe");
|
|
191
|
+
if (iframe && iframe.contentWindow && iframe.contentWindow.document) {
|
|
192
|
+
rootDoc = iframe.contentWindow.document;
|
|
193
|
+
}
|
|
194
|
+
var tip = rootDoc.querySelector("i.tip-nodata");
|
|
195
|
+
return {
|
|
196
|
+
exhausted: Boolean(tip),
|
|
197
|
+
selector: "i.tip-nodata"
|
|
198
|
+
};
|
|
199
|
+
} catch (err) {
|
|
200
|
+
return {
|
|
201
|
+
exhausted: false,
|
|
202
|
+
selector: "i.tip-nodata",
|
|
203
|
+
error: String(err && err.message ? err.message : err)
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
})()`;
|
|
207
|
+
const evaluated = await Runtime.evaluate({
|
|
208
|
+
expression,
|
|
209
|
+
returnByValue: true,
|
|
210
|
+
awaitPromise: true
|
|
211
|
+
});
|
|
212
|
+
if (evaluated.exceptionDetails) {
|
|
213
|
+
return {
|
|
214
|
+
ok: false,
|
|
215
|
+
exhausted: null,
|
|
216
|
+
error: evaluated.exceptionDetails.exception?.description || "Runtime.evaluate failed"
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const value = evaluated.result?.value || {};
|
|
221
|
+
return {
|
|
222
|
+
ok: true,
|
|
223
|
+
exhausted: value.exhausted === true,
|
|
224
|
+
details: value
|
|
225
|
+
};
|
|
226
|
+
} catch (error) {
|
|
227
|
+
return {
|
|
228
|
+
ok: false,
|
|
229
|
+
exhausted: null,
|
|
230
|
+
error: error.message
|
|
231
|
+
};
|
|
232
|
+
} finally {
|
|
233
|
+
if (client) {
|
|
234
|
+
try {
|
|
235
|
+
await client.close();
|
|
236
|
+
} catch {}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
164
241
|
function parseScreenSummary(output) {
|
|
165
242
|
const processed = output.match(/已处理:\s*(\d+)\s*人/);
|
|
166
243
|
const passed = output.match(/通过筛选:\s*(\d+)\s*人/);
|
|
@@ -569,11 +646,18 @@ export async function runSearchCli({ workspaceRoot, searchParams }) {
|
|
|
569
646
|
|
|
570
647
|
const combined = `${result.stdout}\n${result.stderr}`;
|
|
571
648
|
const candidateCount = parseSearchCount(combined);
|
|
649
|
+
const tipCheck = result.code === 0
|
|
650
|
+
? await detectSearchNoDataTip(debugPort)
|
|
651
|
+
: { ok: false, exhausted: null, error: null };
|
|
572
652
|
|
|
573
653
|
return {
|
|
574
654
|
ok: result.code === 0,
|
|
575
655
|
exit_code: result.code,
|
|
576
656
|
candidate_count: candidateCount,
|
|
657
|
+
no_data_tip_present: tipCheck.ok ? tipCheck.exhausted : null,
|
|
658
|
+
no_data_tip_check: tipCheck.ok
|
|
659
|
+
? { ok: true, details: tipCheck.details || null }
|
|
660
|
+
: { ok: false, error: tipCheck.error || null },
|
|
577
661
|
stdout: result.stdout,
|
|
578
662
|
stderr: result.stderr,
|
|
579
663
|
error_code: result.error_code || null
|
package/src/pipeline.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
1
3
|
import { parseRecruitInstruction } from "./parser.js";
|
|
2
4
|
import {
|
|
3
5
|
ensureBossSearchPageReady,
|
|
@@ -73,6 +75,92 @@ function buildFailedResponse(code, message, extra = {}) {
|
|
|
73
75
|
};
|
|
74
76
|
}
|
|
75
77
|
|
|
78
|
+
function normalizeCsvPath(csvPath) {
|
|
79
|
+
if (typeof csvPath !== "string") return null;
|
|
80
|
+
const trimmed = csvPath.trim();
|
|
81
|
+
if (!trimmed) return null;
|
|
82
|
+
return path.resolve(trimmed);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function isReadableFile(filePath) {
|
|
86
|
+
try {
|
|
87
|
+
if (!filePath || !fs.existsSync(filePath)) return false;
|
|
88
|
+
const stat = fs.statSync(filePath);
|
|
89
|
+
return stat.isFile();
|
|
90
|
+
} catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function collectReadableCsvPaths(csvPaths) {
|
|
96
|
+
const unique = new Set();
|
|
97
|
+
for (const rawPath of csvPaths || []) {
|
|
98
|
+
const normalized = normalizeCsvPath(rawPath);
|
|
99
|
+
if (!normalized || unique.has(normalized)) continue;
|
|
100
|
+
if (isReadableFile(normalized)) {
|
|
101
|
+
unique.add(normalized);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return Array.from(unique);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function parseCsvContent(content) {
|
|
108
|
+
const normalized = String(content || "").replace(/^\uFEFF/, "");
|
|
109
|
+
const lines = normalized.split(/\r?\n/).filter((line) => line.trim() !== "");
|
|
110
|
+
if (lines.length === 0) return null;
|
|
111
|
+
return {
|
|
112
|
+
header: lines[0],
|
|
113
|
+
rows: lines.slice(1)
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function mergeRoundCsvFiles(csvPaths) {
|
|
118
|
+
const readablePaths = collectReadableCsvPaths(csvPaths);
|
|
119
|
+
if (readablePaths.length === 0) return null;
|
|
120
|
+
|
|
121
|
+
let header = null;
|
|
122
|
+
const rows = [];
|
|
123
|
+
|
|
124
|
+
for (const csvPath of readablePaths) {
|
|
125
|
+
let content;
|
|
126
|
+
try {
|
|
127
|
+
content = fs.readFileSync(csvPath, "utf8");
|
|
128
|
+
} catch {
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
const parsed = parseCsvContent(content);
|
|
132
|
+
if (!parsed) continue;
|
|
133
|
+
if (!header) {
|
|
134
|
+
header = parsed.header;
|
|
135
|
+
}
|
|
136
|
+
rows.push(...parsed.rows);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!header) return null;
|
|
140
|
+
|
|
141
|
+
const outputDir = path.dirname(readablePaths[0]);
|
|
142
|
+
const outputPath = path.join(outputDir, `筛选结果_合并_${Date.now()}.csv`);
|
|
143
|
+
const mergedContent = `\uFEFF${header}\n${rows.join("\n")}${rows.length > 0 ? "\n" : ""}`;
|
|
144
|
+
fs.writeFileSync(outputPath, mergedContent, "utf8");
|
|
145
|
+
return outputPath;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function buildProgressDiagnostics({
|
|
149
|
+
preflight,
|
|
150
|
+
totalProcessedCount,
|
|
151
|
+
totalPassedCount,
|
|
152
|
+
roundCount,
|
|
153
|
+
extra = {}
|
|
154
|
+
}) {
|
|
155
|
+
return {
|
|
156
|
+
debug_port: preflight.debug_port,
|
|
157
|
+
total_processed_count: totalProcessedCount,
|
|
158
|
+
total_passed_count: totalPassedCount,
|
|
159
|
+
round_count: roundCount,
|
|
160
|
+
...extra
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
76
164
|
function classifySearchFailure(searchResult) {
|
|
77
165
|
const stderr = searchResult.stderr || "";
|
|
78
166
|
const errorCode = searchResult.error_code || "";
|
|
@@ -149,14 +237,29 @@ function classifyScreenFailure(screenResult) {
|
|
|
149
237
|
};
|
|
150
238
|
}
|
|
151
239
|
|
|
240
|
+
const defaultDependencies = {
|
|
241
|
+
parseRecruitInstruction,
|
|
242
|
+
ensureBossSearchPageReady,
|
|
243
|
+
runPipelinePreflight,
|
|
244
|
+
runSearchCli,
|
|
245
|
+
runScreenCli
|
|
246
|
+
};
|
|
247
|
+
|
|
152
248
|
export async function runRecruitPipeline({
|
|
153
249
|
workspaceRoot,
|
|
154
250
|
instruction,
|
|
155
251
|
confirmation,
|
|
156
252
|
overrides
|
|
157
|
-
}) {
|
|
253
|
+
}, dependencies = defaultDependencies) {
|
|
254
|
+
const {
|
|
255
|
+
parseRecruitInstruction: parseInstruction,
|
|
256
|
+
ensureBossSearchPageReady: ensureSearchPageReady,
|
|
257
|
+
runPipelinePreflight: runPreflight,
|
|
258
|
+
runSearchCli: searchCli,
|
|
259
|
+
runScreenCli: screenCli
|
|
260
|
+
} = dependencies;
|
|
158
261
|
const startedAt = Date.now();
|
|
159
|
-
const parsed =
|
|
262
|
+
const parsed = parseInstruction({
|
|
160
263
|
instruction,
|
|
161
264
|
confirmation,
|
|
162
265
|
overrides
|
|
@@ -175,7 +278,7 @@ export async function runRecruitPipeline({
|
|
|
175
278
|
return buildNeedConfirmationResponse(parsed);
|
|
176
279
|
}
|
|
177
280
|
|
|
178
|
-
const preflight =
|
|
281
|
+
const preflight = runPreflight(workspaceRoot);
|
|
179
282
|
if (!preflight.ok) {
|
|
180
283
|
return buildFailedResponse(
|
|
181
284
|
"PIPELINE_PREFLIGHT_FAILED",
|
|
@@ -192,127 +295,241 @@ export async function runRecruitPipeline({
|
|
|
192
295
|
);
|
|
193
296
|
}
|
|
194
297
|
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
});
|
|
198
|
-
if (!pageCheck.ok) {
|
|
199
|
-
if (
|
|
200
|
-
pageCheck.state === "LOGIN_REQUIRED"
|
|
201
|
-
|| pageCheck.state === "LOGIN_REQUIRED_AFTER_REDIRECT"
|
|
202
|
-
) {
|
|
203
|
-
return buildFailedResponse(
|
|
204
|
-
"BOSS_LOGIN_REQUIRED",
|
|
205
|
-
"Boss 页面未稳定停留在 search 页面,疑似未登录或登录态失效。请先在当前 Chrome 窗口手动登录 Boss,登录完成后再继续搜索和筛选。",
|
|
206
|
-
{
|
|
207
|
-
search_params: parsed.searchParams,
|
|
208
|
-
screen_params: parsed.screenParams,
|
|
209
|
-
diagnostics: {
|
|
210
|
-
debug_port: pageCheck.debug_port,
|
|
211
|
-
page_state: pageCheck.page_state
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
);
|
|
215
|
-
}
|
|
216
|
-
|
|
298
|
+
const initialTargetCount = parsed.screenParams.target_count;
|
|
299
|
+
if (!Number.isInteger(initialTargetCount) || initialTargetCount <= 0) {
|
|
217
300
|
return buildFailedResponse(
|
|
218
|
-
"
|
|
219
|
-
"
|
|
301
|
+
"INVALID_TARGET_COUNT",
|
|
302
|
+
"目标处理人数无效,请确认 target_count 为正整数后重试。",
|
|
220
303
|
{
|
|
221
304
|
search_params: parsed.searchParams,
|
|
222
|
-
screen_params: parsed.screenParams
|
|
223
|
-
diagnostics: {
|
|
224
|
-
debug_port: pageCheck.debug_port,
|
|
225
|
-
page_state: pageCheck.page_state
|
|
226
|
-
}
|
|
305
|
+
screen_params: parsed.screenParams
|
|
227
306
|
}
|
|
228
307
|
);
|
|
229
308
|
}
|
|
230
309
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
310
|
+
let totalProcessedCount = 0;
|
|
311
|
+
let totalPassedCount = 0;
|
|
312
|
+
let roundCount = 0;
|
|
313
|
+
const roundOutputCsvPaths = [];
|
|
235
314
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
315
|
+
while (totalProcessedCount < initialTargetCount) {
|
|
316
|
+
roundCount += 1;
|
|
317
|
+
|
|
318
|
+
const remainingTargetCount = Math.max(0, initialTargetCount - totalProcessedCount);
|
|
319
|
+
const roundSearchParams = {
|
|
320
|
+
...parsed.searchParams,
|
|
321
|
+
filter_recent_viewed: roundCount >= 2 ? true : parsed.searchParams.filter_recent_viewed
|
|
322
|
+
};
|
|
323
|
+
const roundScreenParams = {
|
|
324
|
+
...parsed.screenParams,
|
|
325
|
+
target_count: remainingTargetCount
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
const pageCheck = await ensureSearchPageReady(workspaceRoot, {
|
|
329
|
+
port: preflight.debug_port
|
|
330
|
+
});
|
|
331
|
+
if (!pageCheck.ok) {
|
|
332
|
+
if (
|
|
333
|
+
pageCheck.state === "LOGIN_REQUIRED"
|
|
334
|
+
|| pageCheck.state === "LOGIN_REQUIRED_AFTER_REDIRECT"
|
|
335
|
+
) {
|
|
336
|
+
return buildFailedResponse(
|
|
337
|
+
"BOSS_LOGIN_REQUIRED",
|
|
338
|
+
"Boss 页面未稳定停留在 search 页面,疑似未登录或登录态失效。请先在当前 Chrome 窗口手动登录 Boss,登录完成后再继续搜索和筛选。",
|
|
339
|
+
{
|
|
340
|
+
search_params: roundSearchParams,
|
|
341
|
+
screen_params: roundScreenParams,
|
|
342
|
+
diagnostics: buildProgressDiagnostics({
|
|
343
|
+
preflight,
|
|
344
|
+
totalProcessedCount,
|
|
345
|
+
totalPassedCount,
|
|
346
|
+
roundCount,
|
|
347
|
+
extra: {
|
|
348
|
+
page_state: pageCheck.page_state
|
|
349
|
+
}
|
|
350
|
+
})
|
|
351
|
+
}
|
|
352
|
+
);
|
|
249
353
|
}
|
|
250
|
-
);
|
|
251
|
-
}
|
|
252
354
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
355
|
+
return buildFailedResponse(
|
|
356
|
+
"BOSS_SEARCH_PAGE_NOT_READY",
|
|
357
|
+
"无法确认 Boss search 页面已就绪。请先确保 Chrome 调试端口可连,并且页面能稳定停留在 https://www.zhipin.com/web/chat/search。",
|
|
358
|
+
{
|
|
359
|
+
search_params: roundSearchParams,
|
|
360
|
+
screen_params: roundScreenParams,
|
|
361
|
+
diagnostics: buildProgressDiagnostics({
|
|
362
|
+
preflight,
|
|
363
|
+
totalProcessedCount,
|
|
364
|
+
totalPassedCount,
|
|
365
|
+
roundCount,
|
|
366
|
+
extra: {
|
|
367
|
+
page_state: pageCheck.page_state
|
|
368
|
+
}
|
|
369
|
+
})
|
|
264
370
|
}
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
}
|
|
371
|
+
);
|
|
372
|
+
}
|
|
268
373
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
374
|
+
const searchResult = await searchCli({
|
|
375
|
+
workspaceRoot,
|
|
376
|
+
searchParams: roundSearchParams
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
if (!searchResult.ok) {
|
|
380
|
+
const failure = classifySearchFailure(searchResult);
|
|
381
|
+
return buildFailedResponse(
|
|
382
|
+
failure.code,
|
|
383
|
+
failure.message,
|
|
384
|
+
{
|
|
385
|
+
search_params: roundSearchParams,
|
|
386
|
+
screen_params: roundScreenParams,
|
|
387
|
+
diagnostics: buildProgressDiagnostics({
|
|
388
|
+
preflight,
|
|
389
|
+
totalProcessedCount,
|
|
390
|
+
totalPassedCount,
|
|
391
|
+
roundCount,
|
|
392
|
+
extra: {
|
|
393
|
+
exit_code: searchResult.exit_code,
|
|
394
|
+
error_code: searchResult.error_code,
|
|
395
|
+
stderr: searchResult.stderr?.slice(0, 1200)
|
|
396
|
+
}
|
|
397
|
+
})
|
|
398
|
+
}
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (!Number.isInteger(searchResult.candidate_count)) {
|
|
403
|
+
return buildFailedResponse(
|
|
404
|
+
"SEARCH_RESULT_UNVERIFIED",
|
|
405
|
+
"搜索流程未能确认候选人数量,说明搜索步骤可能没有真正完成,已停止后续筛选。",
|
|
406
|
+
{
|
|
407
|
+
search_params: roundSearchParams,
|
|
408
|
+
screen_params: roundScreenParams,
|
|
409
|
+
diagnostics: buildProgressDiagnostics({
|
|
410
|
+
preflight,
|
|
411
|
+
totalProcessedCount,
|
|
412
|
+
totalPassedCount,
|
|
413
|
+
roundCount,
|
|
414
|
+
extra: {
|
|
415
|
+
candidate_count: searchResult.candidate_count,
|
|
416
|
+
stdout: searchResult.stdout?.slice(-1200),
|
|
417
|
+
stderr: searchResult.stderr?.slice(-1200)
|
|
418
|
+
}
|
|
419
|
+
})
|
|
420
|
+
}
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const exhaustedByTipNoData = searchResult.no_data_tip_present === true;
|
|
425
|
+
if (exhaustedByTipNoData || searchResult.candidate_count === 0) {
|
|
426
|
+
const durationSec = Math.max(1, Math.round((Date.now() - startedAt) / 1000));
|
|
427
|
+
const mergedCsvPath = mergeRoundCsvFiles(roundOutputCsvPaths);
|
|
428
|
+
return {
|
|
429
|
+
status: "COMPLETED",
|
|
274
430
|
search_params: parsed.searchParams,
|
|
275
431
|
screen_params: parsed.screenParams,
|
|
276
|
-
|
|
277
|
-
|
|
432
|
+
result: {
|
|
433
|
+
target_count: initialTargetCount,
|
|
434
|
+
processed_count: totalProcessedCount,
|
|
435
|
+
passed_count: totalPassedCount,
|
|
436
|
+
duration_sec: durationSec,
|
|
437
|
+
output_csv: mergedCsvPath,
|
|
438
|
+
round_count: roundCount,
|
|
439
|
+
completion_reason: "search_exhausted_no_candidates",
|
|
440
|
+
exhausted_by_tip_nodata: exhaustedByTipNoData,
|
|
441
|
+
target_count_semantics: "target_count means processed candidate count, not passed candidate count"
|
|
442
|
+
},
|
|
443
|
+
message: exhaustedByTipNoData
|
|
444
|
+
? "流水线已完成。检测到页面出现 tip-nodata(i.tip-nodata),判定候选池已耗尽并结束。"
|
|
445
|
+
: "流水线已完成。累计处理人数未达到目标前,会自动重跑搜索和筛选;当新一轮搜索无可筛选人选时,按候选池耗尽结束。"
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const screenResult = await screenCli({
|
|
450
|
+
workspaceRoot,
|
|
451
|
+
screenParams: roundScreenParams
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
if (!screenResult.ok) {
|
|
455
|
+
const failure = classifyScreenFailure(screenResult);
|
|
456
|
+
return buildFailedResponse(failure.code, failure.message, {
|
|
457
|
+
search_params: roundSearchParams,
|
|
458
|
+
screen_params: roundScreenParams,
|
|
459
|
+
diagnostics: buildProgressDiagnostics({
|
|
460
|
+
preflight,
|
|
461
|
+
totalProcessedCount,
|
|
462
|
+
totalPassedCount,
|
|
463
|
+
roundCount,
|
|
464
|
+
extra: {
|
|
465
|
+
exit_code: screenResult.exit_code,
|
|
466
|
+
error_code: screenResult.error_code,
|
|
467
|
+
stderr: screenResult.stderr?.slice(0, 1200)
|
|
468
|
+
}
|
|
469
|
+
})
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const summary = screenResult.summary || {};
|
|
474
|
+
const roundOutputCsvPath = normalizeCsvPath(summary.output_csv);
|
|
475
|
+
const roundProcessedCount = summary.processed_count;
|
|
476
|
+
|
|
477
|
+
if (!Number.isInteger(roundProcessedCount) || roundProcessedCount <= 0) {
|
|
478
|
+
const mergedCsvPath = mergeRoundCsvFiles([
|
|
479
|
+
...roundOutputCsvPaths,
|
|
480
|
+
roundOutputCsvPath
|
|
481
|
+
]);
|
|
482
|
+
return buildFailedResponse(
|
|
483
|
+
"SCREEN_NO_PROGRESS",
|
|
484
|
+
"本轮搜索返回了可筛选候选人,但筛选流程未产生有效处理进度。已先导出当前累计 CSV 结果,请检查页面状态或筛选工具日志后重试。",
|
|
485
|
+
{
|
|
486
|
+
search_params: roundSearchParams,
|
|
487
|
+
screen_params: roundScreenParams,
|
|
488
|
+
diagnostics: buildProgressDiagnostics({
|
|
489
|
+
preflight,
|
|
490
|
+
totalProcessedCount,
|
|
491
|
+
totalPassedCount,
|
|
492
|
+
roundCount,
|
|
493
|
+
extra: {
|
|
494
|
+
candidate_count: searchResult.candidate_count,
|
|
495
|
+
round_processed_count: roundProcessedCount ?? null,
|
|
496
|
+
round_passed_count: summary.passed_count ?? null,
|
|
497
|
+
round_output_csv: roundOutputCsvPath,
|
|
498
|
+
output_csv: mergedCsvPath
|
|
499
|
+
}
|
|
500
|
+
})
|
|
278
501
|
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
}
|
|
502
|
+
);
|
|
503
|
+
}
|
|
282
504
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
});
|
|
505
|
+
if (roundOutputCsvPath && isReadableFile(roundOutputCsvPath)) {
|
|
506
|
+
roundOutputCsvPaths.push(roundOutputCsvPath);
|
|
507
|
+
}
|
|
287
508
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
exit_code: screenResult.exit_code,
|
|
295
|
-
error_code: screenResult.error_code,
|
|
296
|
-
stderr: screenResult.stderr?.slice(0, 1200)
|
|
297
|
-
}
|
|
298
|
-
});
|
|
509
|
+
const roundPassedCount =
|
|
510
|
+
Number.isInteger(summary.passed_count) && summary.passed_count >= 0
|
|
511
|
+
? summary.passed_count
|
|
512
|
+
: 0;
|
|
513
|
+
totalProcessedCount += roundProcessedCount;
|
|
514
|
+
totalPassedCount += roundPassedCount;
|
|
299
515
|
}
|
|
300
516
|
|
|
301
517
|
const durationSec = Math.max(1, Math.round((Date.now() - startedAt) / 1000));
|
|
302
|
-
const
|
|
518
|
+
const mergedCsvPath = mergeRoundCsvFiles(roundOutputCsvPaths);
|
|
303
519
|
return {
|
|
304
520
|
status: "COMPLETED",
|
|
305
521
|
search_params: parsed.searchParams,
|
|
306
522
|
screen_params: parsed.screenParams,
|
|
307
523
|
result: {
|
|
308
|
-
target_count:
|
|
309
|
-
processed_count:
|
|
310
|
-
passed_count:
|
|
524
|
+
target_count: initialTargetCount,
|
|
525
|
+
processed_count: totalProcessedCount,
|
|
526
|
+
passed_count: totalPassedCount,
|
|
311
527
|
duration_sec: durationSec,
|
|
312
|
-
output_csv:
|
|
528
|
+
output_csv: mergedCsvPath,
|
|
529
|
+
round_count: roundCount,
|
|
313
530
|
completion_reason: "processed_target_reached",
|
|
314
531
|
target_count_semantics: "target_count means processed candidate count, not passed candidate count"
|
|
315
532
|
},
|
|
316
|
-
message: "流水线已完成。target_count
|
|
533
|
+
message: "流水线已完成。target_count 表示处理人数目标,而不是通过人数目标;当累计处理人数仍不足时会自动多轮执行,且从第2轮起会强制过滤近14天查看过的人选。"
|
|
317
534
|
};
|
|
318
535
|
}
|
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { runRecruitPipeline } from "./pipeline.js";
|
|
6
|
+
|
|
7
|
+
const CSV_HEADER = "姓名,最高学历学校,最高学历专业,最近工作公司,最近工作职位,评估通过详细原因";
|
|
8
|
+
|
|
9
|
+
function createTempDir(tag) {
|
|
10
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), `boss-pipeline-${tag}-`));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function writeCsv(filePath, rows) {
|
|
14
|
+
const body = rows.length > 0 ? `${rows.join("\n")}\n` : "";
|
|
15
|
+
fs.writeFileSync(filePath, `\uFEFF${CSV_HEADER}\n${body}`, "utf8");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function readCsvLines(filePath) {
|
|
19
|
+
return fs
|
|
20
|
+
.readFileSync(filePath, "utf8")
|
|
21
|
+
.replace(/^\uFEFF/, "")
|
|
22
|
+
.split(/\r?\n/)
|
|
23
|
+
.filter((line) => line.trim() !== "");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function createParsed(overrides = {}) {
|
|
27
|
+
return {
|
|
28
|
+
has_unresolved_missing_fields: false,
|
|
29
|
+
needs_keyword_confirmation: false,
|
|
30
|
+
needs_search_params_confirmation: false,
|
|
31
|
+
needs_recent_viewed_filter_confirmation: false,
|
|
32
|
+
needs_criteria_confirmation: false,
|
|
33
|
+
missing_fields: [],
|
|
34
|
+
proposed_keyword: null,
|
|
35
|
+
pending_questions: [],
|
|
36
|
+
review: {},
|
|
37
|
+
searchParams: {
|
|
38
|
+
city: "杭州",
|
|
39
|
+
degree: "本科",
|
|
40
|
+
schools: ["985院校"],
|
|
41
|
+
filter_recent_viewed: false,
|
|
42
|
+
keyword: "AI infra"
|
|
43
|
+
},
|
|
44
|
+
screenParams: {
|
|
45
|
+
criteria: "候选人需有 AI infra 相关经历",
|
|
46
|
+
target_count: 100
|
|
47
|
+
},
|
|
48
|
+
...overrides
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function buildSearchOk(candidateCount) {
|
|
53
|
+
return {
|
|
54
|
+
ok: true,
|
|
55
|
+
candidate_count: candidateCount,
|
|
56
|
+
no_data_tip_present: false,
|
|
57
|
+
no_data_tip_check: { ok: true, details: { exhausted: false } },
|
|
58
|
+
stdout: "",
|
|
59
|
+
stderr: "",
|
|
60
|
+
error_code: null
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function buildScreenOk({ processedCount, passedCount = 0, outputCsv = null }) {
|
|
65
|
+
return {
|
|
66
|
+
ok: true,
|
|
67
|
+
summary: {
|
|
68
|
+
processed_count: processedCount,
|
|
69
|
+
passed_count: passedCount,
|
|
70
|
+
output_csv: outputCsv
|
|
71
|
+
},
|
|
72
|
+
stdout: "",
|
|
73
|
+
stderr: "",
|
|
74
|
+
error_code: null
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function createDependencies({
|
|
79
|
+
parsed,
|
|
80
|
+
searchResults,
|
|
81
|
+
screenResults,
|
|
82
|
+
pageStates
|
|
83
|
+
}) {
|
|
84
|
+
const searchQueue = [...searchResults];
|
|
85
|
+
const screenQueue = [...screenResults];
|
|
86
|
+
const pageQueue = pageStates ? [...pageStates] : [];
|
|
87
|
+
const calls = {
|
|
88
|
+
searchParams: [],
|
|
89
|
+
screenParams: [],
|
|
90
|
+
pageChecks: 0,
|
|
91
|
+
preflightChecks: 0
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
calls,
|
|
96
|
+
deps: {
|
|
97
|
+
parseRecruitInstruction: () => parsed,
|
|
98
|
+
runPipelinePreflight: () => {
|
|
99
|
+
calls.preflightChecks += 1;
|
|
100
|
+
return {
|
|
101
|
+
ok: true,
|
|
102
|
+
checks: [],
|
|
103
|
+
debug_port: 9222,
|
|
104
|
+
calibration_path: null
|
|
105
|
+
};
|
|
106
|
+
},
|
|
107
|
+
ensureBossSearchPageReady: async () => {
|
|
108
|
+
calls.pageChecks += 1;
|
|
109
|
+
if (pageQueue.length > 0) {
|
|
110
|
+
return pageQueue.shift();
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
ok: true,
|
|
114
|
+
state: "SEARCH_READY",
|
|
115
|
+
debug_port: 9222,
|
|
116
|
+
page_state: {}
|
|
117
|
+
};
|
|
118
|
+
},
|
|
119
|
+
runSearchCli: async ({ searchParams }) => {
|
|
120
|
+
calls.searchParams.push({ ...searchParams });
|
|
121
|
+
if (searchQueue.length === 0) {
|
|
122
|
+
throw new Error("runSearchCli called more times than expected");
|
|
123
|
+
}
|
|
124
|
+
return searchQueue.shift();
|
|
125
|
+
},
|
|
126
|
+
runScreenCli: async ({ screenParams }) => {
|
|
127
|
+
calls.screenParams.push({ ...screenParams });
|
|
128
|
+
if (screenQueue.length === 0) {
|
|
129
|
+
throw new Error("runScreenCli called more times than expected");
|
|
130
|
+
}
|
|
131
|
+
return screenQueue.shift();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function testSearchExhaustedCompletesAndMergesCsv() {
|
|
138
|
+
const tempDir = createTempDir("exhausted");
|
|
139
|
+
const round1Csv = path.join(tempDir, "round-1.csv");
|
|
140
|
+
writeCsv(round1Csv, [
|
|
141
|
+
"张三,清华大学,计算机,甲公司,算法工程师,理由A"
|
|
142
|
+
]);
|
|
143
|
+
|
|
144
|
+
const parsed = createParsed({
|
|
145
|
+
screenParams: {
|
|
146
|
+
criteria: "候选人需有 AI infra 相关经历",
|
|
147
|
+
target_count: 100
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
const { deps, calls } = createDependencies({
|
|
151
|
+
parsed,
|
|
152
|
+
searchResults: [buildSearchOk(160), buildSearchOk(0)],
|
|
153
|
+
screenResults: [
|
|
154
|
+
buildScreenOk({
|
|
155
|
+
processedCount: 80,
|
|
156
|
+
passedCount: 12,
|
|
157
|
+
outputCsv: round1Csv
|
|
158
|
+
})
|
|
159
|
+
]
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const result = await runRecruitPipeline(
|
|
163
|
+
{
|
|
164
|
+
workspaceRoot: tempDir,
|
|
165
|
+
instruction: "test",
|
|
166
|
+
confirmation: {},
|
|
167
|
+
overrides: {}
|
|
168
|
+
},
|
|
169
|
+
deps
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
assert.equal(result.status, "COMPLETED");
|
|
173
|
+
assert.equal(result.result.completion_reason, "search_exhausted_no_candidates");
|
|
174
|
+
assert.equal(result.result.processed_count, 80);
|
|
175
|
+
assert.equal(result.result.passed_count, 12);
|
|
176
|
+
assert.equal(result.result.round_count, 2);
|
|
177
|
+
assert.equal(calls.searchParams[0].filter_recent_viewed, false);
|
|
178
|
+
assert.equal(calls.searchParams[1].filter_recent_viewed, true);
|
|
179
|
+
assert.ok(result.result.output_csv && fs.existsSync(result.result.output_csv));
|
|
180
|
+
const mergedLines = readCsvLines(result.result.output_csv);
|
|
181
|
+
assert.equal(mergedLines.length, 2);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function testSearchExhaustedByTipNodataEvenWhenCandidateCountPositive() {
|
|
185
|
+
const tempDir = createTempDir("exhausted-tip");
|
|
186
|
+
const round1Csv = path.join(tempDir, "round-1.csv");
|
|
187
|
+
writeCsv(round1Csv, [
|
|
188
|
+
"赵六,浙大,电子信息,戊公司,工程师,理由D"
|
|
189
|
+
]);
|
|
190
|
+
|
|
191
|
+
const parsed = createParsed({
|
|
192
|
+
screenParams: {
|
|
193
|
+
criteria: "候选人需有 AI infra 相关经历",
|
|
194
|
+
target_count: 100
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
const { deps, calls } = createDependencies({
|
|
198
|
+
parsed,
|
|
199
|
+
searchResults: [
|
|
200
|
+
buildSearchOk(120),
|
|
201
|
+
{
|
|
202
|
+
...buildSearchOk(87),
|
|
203
|
+
no_data_tip_present: true,
|
|
204
|
+
no_data_tip_check: {
|
|
205
|
+
ok: true,
|
|
206
|
+
details: {
|
|
207
|
+
exhausted: true,
|
|
208
|
+
selector: "i.tip-nodata"
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
],
|
|
213
|
+
screenResults: [
|
|
214
|
+
buildScreenOk({
|
|
215
|
+
processedCount: 80,
|
|
216
|
+
passedCount: 4,
|
|
217
|
+
outputCsv: round1Csv
|
|
218
|
+
})
|
|
219
|
+
]
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
const result = await runRecruitPipeline(
|
|
223
|
+
{
|
|
224
|
+
workspaceRoot: tempDir,
|
|
225
|
+
instruction: "test",
|
|
226
|
+
confirmation: {},
|
|
227
|
+
overrides: {}
|
|
228
|
+
},
|
|
229
|
+
deps
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
assert.equal(result.status, "COMPLETED");
|
|
233
|
+
assert.equal(result.result.completion_reason, "search_exhausted_no_candidates");
|
|
234
|
+
assert.equal(result.result.exhausted_by_tip_nodata, true);
|
|
235
|
+
assert.equal(result.result.processed_count, 80);
|
|
236
|
+
assert.equal(result.result.round_count, 2);
|
|
237
|
+
assert.equal(calls.searchParams[1].filter_recent_viewed, true);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function testTargetReachedAcrossRoundsAndDuplicateRowsKept() {
|
|
241
|
+
const tempDir = createTempDir("target");
|
|
242
|
+
const round1Csv = path.join(tempDir, "round-1.csv");
|
|
243
|
+
const round2Csv = path.join(tempDir, "round-2.csv");
|
|
244
|
+
const dupRow = "重复候选人,北大,计算机,乙公司,后端工程师,重复原因";
|
|
245
|
+
writeCsv(round1Csv, [
|
|
246
|
+
"张三,清华大学,计算机,甲公司,算法工程师,理由A",
|
|
247
|
+
dupRow
|
|
248
|
+
]);
|
|
249
|
+
writeCsv(round2Csv, [
|
|
250
|
+
dupRow,
|
|
251
|
+
"李四,复旦大学,软件工程,丙公司,推荐算法工程师,理由B"
|
|
252
|
+
]);
|
|
253
|
+
|
|
254
|
+
const parsed = createParsed({
|
|
255
|
+
screenParams: {
|
|
256
|
+
criteria: "候选人需有 AI infra 相关经历",
|
|
257
|
+
target_count: 100
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
const { deps, calls } = createDependencies({
|
|
261
|
+
parsed,
|
|
262
|
+
searchResults: [buildSearchOk(120), buildSearchOk(120)],
|
|
263
|
+
screenResults: [
|
|
264
|
+
buildScreenOk({
|
|
265
|
+
processedCount: 60,
|
|
266
|
+
passedCount: 5,
|
|
267
|
+
outputCsv: round1Csv
|
|
268
|
+
}),
|
|
269
|
+
buildScreenOk({
|
|
270
|
+
processedCount: 40,
|
|
271
|
+
passedCount: 8,
|
|
272
|
+
outputCsv: round2Csv
|
|
273
|
+
})
|
|
274
|
+
]
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
const result = await runRecruitPipeline(
|
|
278
|
+
{
|
|
279
|
+
workspaceRoot: tempDir,
|
|
280
|
+
instruction: "test",
|
|
281
|
+
confirmation: {},
|
|
282
|
+
overrides: {}
|
|
283
|
+
},
|
|
284
|
+
deps
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
assert.equal(result.status, "COMPLETED");
|
|
288
|
+
assert.equal(result.result.completion_reason, "processed_target_reached");
|
|
289
|
+
assert.equal(result.result.processed_count, 100);
|
|
290
|
+
assert.equal(result.result.passed_count, 13);
|
|
291
|
+
assert.equal(result.result.round_count, 2);
|
|
292
|
+
assert.equal(calls.searchParams[0].filter_recent_viewed, false);
|
|
293
|
+
assert.equal(calls.searchParams[1].filter_recent_viewed, true);
|
|
294
|
+
assert.equal(calls.screenParams[1].target_count, 40);
|
|
295
|
+
|
|
296
|
+
const mergedLines = readCsvLines(result.result.output_csv);
|
|
297
|
+
assert.equal(mergedLines.length, 5);
|
|
298
|
+
const duplicateCount = mergedLines.filter((line) => line === dupRow).length;
|
|
299
|
+
assert.equal(duplicateCount, 2);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async function testScreenNoProgressWithZeroProcessedExportsBeforeFail() {
|
|
303
|
+
const tempDir = createTempDir("no-progress-zero");
|
|
304
|
+
const round1Csv = path.join(tempDir, "round-1.csv");
|
|
305
|
+
const round2Csv = path.join(tempDir, "round-2.csv");
|
|
306
|
+
writeCsv(round1Csv, [
|
|
307
|
+
"张三,清华大学,计算机,甲公司,算法工程师,理由A"
|
|
308
|
+
]);
|
|
309
|
+
writeCsv(round2Csv, [
|
|
310
|
+
"王五,浙大,软件工程,丁公司,算法工程师,理由C"
|
|
311
|
+
]);
|
|
312
|
+
|
|
313
|
+
const parsed = createParsed({
|
|
314
|
+
screenParams: {
|
|
315
|
+
criteria: "候选人需有 AI infra 相关经历",
|
|
316
|
+
target_count: 100
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
const { deps } = createDependencies({
|
|
320
|
+
parsed,
|
|
321
|
+
searchResults: [buildSearchOk(120), buildSearchOk(120)],
|
|
322
|
+
screenResults: [
|
|
323
|
+
buildScreenOk({
|
|
324
|
+
processedCount: 80,
|
|
325
|
+
passedCount: 9,
|
|
326
|
+
outputCsv: round1Csv
|
|
327
|
+
}),
|
|
328
|
+
buildScreenOk({
|
|
329
|
+
processedCount: 0,
|
|
330
|
+
passedCount: 0,
|
|
331
|
+
outputCsv: round2Csv
|
|
332
|
+
})
|
|
333
|
+
]
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
const result = await runRecruitPipeline(
|
|
337
|
+
{
|
|
338
|
+
workspaceRoot: tempDir,
|
|
339
|
+
instruction: "test",
|
|
340
|
+
confirmation: {},
|
|
341
|
+
overrides: {}
|
|
342
|
+
},
|
|
343
|
+
deps
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
assert.equal(result.status, "FAILED");
|
|
347
|
+
assert.equal(result.error.code, "SCREEN_NO_PROGRESS");
|
|
348
|
+
assert.equal(result.diagnostics.total_processed_count, 80);
|
|
349
|
+
assert.equal(result.diagnostics.round_count, 2);
|
|
350
|
+
assert.ok(result.diagnostics.output_csv && fs.existsSync(result.diagnostics.output_csv));
|
|
351
|
+
const mergedLines = readCsvLines(result.diagnostics.output_csv);
|
|
352
|
+
assert.equal(mergedLines.length, 3);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function testScreenNoProgressWithInvalidProcessedAndNoCsv() {
|
|
356
|
+
const tempDir = createTempDir("no-progress-invalid");
|
|
357
|
+
const parsed = createParsed({
|
|
358
|
+
screenParams: {
|
|
359
|
+
criteria: "候选人需有 AI infra 相关经历",
|
|
360
|
+
target_count: 60
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
const { deps } = createDependencies({
|
|
364
|
+
parsed,
|
|
365
|
+
searchResults: [buildSearchOk(88)],
|
|
366
|
+
screenResults: [
|
|
367
|
+
buildScreenOk({
|
|
368
|
+
processedCount: null,
|
|
369
|
+
passedCount: 0,
|
|
370
|
+
outputCsv: null
|
|
371
|
+
})
|
|
372
|
+
]
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
const result = await runRecruitPipeline(
|
|
376
|
+
{
|
|
377
|
+
workspaceRoot: tempDir,
|
|
378
|
+
instruction: "test",
|
|
379
|
+
confirmation: {},
|
|
380
|
+
overrides: {}
|
|
381
|
+
},
|
|
382
|
+
deps
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
assert.equal(result.status, "FAILED");
|
|
386
|
+
assert.equal(result.error.code, "SCREEN_NO_PROGRESS");
|
|
387
|
+
assert.equal(result.diagnostics.round_count, 1);
|
|
388
|
+
assert.equal(result.diagnostics.output_csv, null);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async function testNeedInputGateStillWorks() {
|
|
392
|
+
const tempDir = createTempDir("need-input");
|
|
393
|
+
const parsed = createParsed({
|
|
394
|
+
has_unresolved_missing_fields: true,
|
|
395
|
+
missing_fields: ["city"]
|
|
396
|
+
});
|
|
397
|
+
let preflightCalled = false;
|
|
398
|
+
const deps = {
|
|
399
|
+
parseRecruitInstruction: () => parsed,
|
|
400
|
+
runPipelinePreflight: () => {
|
|
401
|
+
preflightCalled = true;
|
|
402
|
+
return { ok: true, checks: [], debug_port: 9222, calibration_path: null };
|
|
403
|
+
},
|
|
404
|
+
ensureBossSearchPageReady: async () => ({ ok: true, state: "SEARCH_READY", debug_port: 9222, page_state: {} }),
|
|
405
|
+
runSearchCli: async () => buildSearchOk(0),
|
|
406
|
+
runScreenCli: async () => buildScreenOk({ processedCount: 0, passedCount: 0, outputCsv: null })
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
const result = await runRecruitPipeline(
|
|
410
|
+
{
|
|
411
|
+
workspaceRoot: tempDir,
|
|
412
|
+
instruction: "test",
|
|
413
|
+
confirmation: {},
|
|
414
|
+
overrides: {}
|
|
415
|
+
},
|
|
416
|
+
deps
|
|
417
|
+
);
|
|
418
|
+
|
|
419
|
+
assert.equal(result.status, "NEED_INPUT");
|
|
420
|
+
assert.equal(preflightCalled, false);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
async function main() {
|
|
424
|
+
await testSearchExhaustedCompletesAndMergesCsv();
|
|
425
|
+
await testSearchExhaustedByTipNodataEvenWhenCandidateCountPositive();
|
|
426
|
+
await testTargetReachedAcrossRoundsAndDuplicateRowsKept();
|
|
427
|
+
await testScreenNoProgressWithZeroProcessedExportsBeforeFail();
|
|
428
|
+
await testScreenNoProgressWithInvalidProcessedAndNoCsv();
|
|
429
|
+
await testNeedInputGateStillWorks();
|
|
430
|
+
// eslint-disable-next-line no-console
|
|
431
|
+
console.log("pipeline tests passed");
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
await main();
|