@reconcrap/boss-recommend-mcp 1.2.7 → 1.2.9
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/package.json +1 -1
- package/skills/boss-recommend-pipeline/SKILL.md +101 -219
- package/src/parser.js +48 -15
- package/src/pipeline.js +56 -2
- package/src/test-parser.js +53 -2
- package/src/test-pipeline.js +70 -10
- package/vendor/boss-recommend-screen-cli/boss-recommend-screen-cli.cjs +311 -36
- package/vendor/boss-recommend-screen-cli/test-recoverable-resume-failures.cjs +218 -0
package/src/test-parser.js
CHANGED
|
@@ -250,7 +250,7 @@ function testSchoolTagOverrideMixedValidAndInvalidShouldKeepValidOnes() {
|
|
|
250
250
|
instruction: "推荐页筛选候选人,有算法经验",
|
|
251
251
|
confirmation: null,
|
|
252
252
|
overrides: {
|
|
253
|
-
school_tag: ["985", "211", "
|
|
253
|
+
school_tag: ["985", "211", "foo_tag"]
|
|
254
254
|
}
|
|
255
255
|
});
|
|
256
256
|
|
|
@@ -263,13 +263,37 @@ function testSchoolTagOverrideAllInvalidShouldFallbackToUnlimited() {
|
|
|
263
263
|
instruction: "推荐页筛选候选人,有算法经验",
|
|
264
264
|
confirmation: null,
|
|
265
265
|
overrides: {
|
|
266
|
-
school_tag: ["
|
|
266
|
+
school_tag: ["abc", "foo"]
|
|
267
267
|
}
|
|
268
268
|
});
|
|
269
269
|
|
|
270
270
|
assert.deepEqual(result.searchParams.school_tag, ["不限"]);
|
|
271
271
|
}
|
|
272
272
|
|
|
273
|
+
function testSchoolTagQsAliasShouldNormalizeToDomesticAndOverseasTop() {
|
|
274
|
+
const result = parseRecommendInstruction({
|
|
275
|
+
instruction: "推荐页筛选候选人,有算法经验",
|
|
276
|
+
confirmation: null,
|
|
277
|
+
overrides: {
|
|
278
|
+
school_tag: ["985", "QS前200"]
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
assert.deepEqual(result.searchParams.school_tag, ["985", "国内外名校"]);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function testRecentNotViewSpacedOverrideShouldNormalize() {
|
|
286
|
+
const result = parseRecommendInstruction({
|
|
287
|
+
instruction: "推荐页筛选985男生,有算法经验",
|
|
288
|
+
confirmation: null,
|
|
289
|
+
overrides: {
|
|
290
|
+
recent_not_view: "近 14 天没有"
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
assert.equal(result.searchParams.recent_not_view, "近14天没有");
|
|
295
|
+
}
|
|
296
|
+
|
|
273
297
|
function testCriteriaCanBeProvidedViaOverrides() {
|
|
274
298
|
const result = parseRecommendInstruction({
|
|
275
299
|
instruction: "推荐页筛选211女生",
|
|
@@ -488,6 +512,30 @@ function testFeaturedKeywordShouldProposeFeaturedPageScope() {
|
|
|
488
512
|
assert.equal(result.pending_questions.some((item) => item.field === "page_scope"), true);
|
|
489
513
|
}
|
|
490
514
|
|
|
515
|
+
function testClosedQuestionsShouldExposeStructuredOptions() {
|
|
516
|
+
const result = parseRecommendInstruction({
|
|
517
|
+
instruction: "推荐页筛选候选人,有 Agent 经验,符合标准收藏",
|
|
518
|
+
confirmation: null,
|
|
519
|
+
overrides: null
|
|
520
|
+
});
|
|
521
|
+
const schoolTagQuestion = result.pending_questions.find((item) => item.field === "school_tag");
|
|
522
|
+
const recentNotViewQuestion = result.pending_questions.find((item) => item.field === "recent_not_view");
|
|
523
|
+
const filtersQuestion = result.pending_questions.find((item) => item.field === "filters");
|
|
524
|
+
|
|
525
|
+
assert.equal(Boolean(schoolTagQuestion), true);
|
|
526
|
+
assert.equal(Array.isArray(schoolTagQuestion.options), true);
|
|
527
|
+
assert.equal(schoolTagQuestion.options.some((item) => item.value === "国内外名校"), true);
|
|
528
|
+
assert.equal(schoolTagQuestion.options.every((item) => typeof item.label === "string" && typeof item.value === "string"), true);
|
|
529
|
+
|
|
530
|
+
assert.equal(Boolean(recentNotViewQuestion), true);
|
|
531
|
+
assert.equal(Array.isArray(recentNotViewQuestion.options), true);
|
|
532
|
+
assert.equal(recentNotViewQuestion.options.some((item) => item.value === "近14天没有"), true);
|
|
533
|
+
|
|
534
|
+
assert.equal(Boolean(filtersQuestion), true);
|
|
535
|
+
assert.equal(Array.isArray(filtersQuestion.options), true);
|
|
536
|
+
assert.equal(filtersQuestion.options.some((item) => item.value === "confirm"), true);
|
|
537
|
+
}
|
|
538
|
+
|
|
491
539
|
function testLatestKeywordShouldProposeLatestPageScope() {
|
|
492
540
|
const result = parseRecommendInstruction({
|
|
493
541
|
instruction: "在推荐页最新里筛选候选人,有 Agent 经验,符合标准收藏",
|
|
@@ -547,6 +595,8 @@ function main() {
|
|
|
547
595
|
testSchoolTagOverrideCanBeArray();
|
|
548
596
|
testSchoolTagOverrideMixedValidAndInvalidShouldKeepValidOnes();
|
|
549
597
|
testSchoolTagOverrideAllInvalidShouldFallbackToUnlimited();
|
|
598
|
+
testSchoolTagQsAliasShouldNormalizeToDomesticAndOverseasTop();
|
|
599
|
+
testRecentNotViewSpacedOverrideShouldNormalize();
|
|
550
600
|
testCriteriaCanBeProvidedViaOverrides();
|
|
551
601
|
testMissingCriteriaTriggersNeedInput();
|
|
552
602
|
testMcpMentionShouldStayInCriteria();
|
|
@@ -558,6 +608,7 @@ function main() {
|
|
|
558
608
|
testPostActionNoneCanBeConfirmed();
|
|
559
609
|
testJobSelectionHintCanComeFromOverrides();
|
|
560
610
|
testFeaturedKeywordShouldProposeFeaturedPageScope();
|
|
611
|
+
testClosedQuestionsShouldExposeStructuredOptions();
|
|
561
612
|
testLatestKeywordShouldProposeLatestPageScope();
|
|
562
613
|
testConfirmedPageScopeShouldBeResolved();
|
|
563
614
|
testPageScopeOverrideShouldNotBypassConfirmation();
|
package/src/test-pipeline.js
CHANGED
|
@@ -1432,6 +1432,7 @@ async function testCompletedPipeline() {
|
|
|
1432
1432
|
}
|
|
1433
1433
|
|
|
1434
1434
|
async function testSearchFailure() {
|
|
1435
|
+
let searchCallCount = 0;
|
|
1435
1436
|
const result = await runRecommendPipeline(
|
|
1436
1437
|
{
|
|
1437
1438
|
workspaceRoot: process.cwd(),
|
|
@@ -1444,22 +1445,80 @@ async function testSearchFailure() {
|
|
|
1444
1445
|
runPipelinePreflight: () => ({ ok: true, checks: [], debug_port: 9222 }),
|
|
1445
1446
|
ensureBossRecommendPageReady: async () => ({ ok: true, state: "RECOMMEND_READY", page_state: {} }),
|
|
1446
1447
|
listRecommendJobs: async () => createJobListResult(),
|
|
1447
|
-
runRecommendSearchCli: async () =>
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1448
|
+
runRecommendSearchCli: async () => {
|
|
1449
|
+
searchCallCount += 1;
|
|
1450
|
+
return {
|
|
1451
|
+
ok: false,
|
|
1452
|
+
stdout: "",
|
|
1453
|
+
stderr: "boom",
|
|
1454
|
+
structured: null,
|
|
1455
|
+
error: {
|
|
1456
|
+
code: "RECOMMEND_FILTER_PANEL_UNAVAILABLE",
|
|
1457
|
+
message: "筛选面板不可用。"
|
|
1458
|
+
}
|
|
1459
|
+
};
|
|
1460
|
+
},
|
|
1457
1461
|
runRecommendScreenCli: async () => ({ ok: true, summary: {} })
|
|
1458
1462
|
}
|
|
1459
1463
|
);
|
|
1460
1464
|
|
|
1461
1465
|
assert.equal(result.status, "FAILED");
|
|
1462
1466
|
assert.equal(result.error.code, "RECOMMEND_FILTER_PANEL_UNAVAILABLE");
|
|
1467
|
+
assert.equal(searchCallCount, 3);
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
async function testSearchFilterFailureShouldRetryAndRecover() {
|
|
1471
|
+
let searchCallCount = 0;
|
|
1472
|
+
const result = await runRecommendPipeline(
|
|
1473
|
+
{
|
|
1474
|
+
workspaceRoot: process.cwd(),
|
|
1475
|
+
instruction: "test",
|
|
1476
|
+
confirmation: createJobConfirmedConfirmation(),
|
|
1477
|
+
overrides: {}
|
|
1478
|
+
},
|
|
1479
|
+
{
|
|
1480
|
+
parseRecommendInstruction: () => createParsed(),
|
|
1481
|
+
runPipelinePreflight: () => ({ ok: true, checks: [], debug_port: 9222 }),
|
|
1482
|
+
ensureBossRecommendPageReady: async () => ({ ok: true, state: "RECOMMEND_READY", page_state: {} }),
|
|
1483
|
+
listRecommendJobs: async () => createJobListResult(),
|
|
1484
|
+
runRecommendSearchCli: async () => {
|
|
1485
|
+
searchCallCount += 1;
|
|
1486
|
+
if (searchCallCount === 1) {
|
|
1487
|
+
return {
|
|
1488
|
+
ok: false,
|
|
1489
|
+
stdout: "",
|
|
1490
|
+
stderr: "FILTER_CONFIRM_FAILED",
|
|
1491
|
+
structured: null,
|
|
1492
|
+
error: {
|
|
1493
|
+
code: "FILTER_CONFIRM_FAILED",
|
|
1494
|
+
message: "FILTER_CONFIRM_FAILED"
|
|
1495
|
+
}
|
|
1496
|
+
};
|
|
1497
|
+
}
|
|
1498
|
+
return {
|
|
1499
|
+
ok: true,
|
|
1500
|
+
summary: {
|
|
1501
|
+
candidate_count: 6,
|
|
1502
|
+
applied_filters: {}
|
|
1503
|
+
}
|
|
1504
|
+
};
|
|
1505
|
+
},
|
|
1506
|
+
runRecommendScreenCli: async () => ({
|
|
1507
|
+
ok: true,
|
|
1508
|
+
summary: {
|
|
1509
|
+
processed_count: 3,
|
|
1510
|
+
passed_count: 2,
|
|
1511
|
+
skipped_count: 1,
|
|
1512
|
+
output_csv: "C:/temp/search-filter-retry.csv"
|
|
1513
|
+
}
|
|
1514
|
+
})
|
|
1515
|
+
}
|
|
1516
|
+
);
|
|
1517
|
+
|
|
1518
|
+
assert.equal(result.status, "COMPLETED");
|
|
1519
|
+
assert.equal(searchCallCount, 2);
|
|
1520
|
+
assert.equal(result.result.auto_recovery.trigger, "SEARCH_FILTER_RETRY");
|
|
1521
|
+
assert.equal(result.result.auto_recovery.attempt, 1);
|
|
1463
1522
|
}
|
|
1464
1523
|
|
|
1465
1524
|
async function testSearchNoIframeWithLoginShouldReturnLoginRequired() {
|
|
@@ -2005,6 +2064,7 @@ async function main() {
|
|
|
2005
2064
|
await testNeedFinalReviewConfirmationGate();
|
|
2006
2065
|
await testCompletedPipeline();
|
|
2007
2066
|
await testSearchFailure();
|
|
2067
|
+
await testSearchFilterFailureShouldRetryAndRecover();
|
|
2008
2068
|
await testSearchNoIframeWithLoginShouldReturnLoginRequired();
|
|
2009
2069
|
await testSearchNoIframeShouldRetryOnceWhenPageRecheckReady();
|
|
2010
2070
|
await testJobTriggerNotFoundShouldMapToLoginRequiredWhenRecheckShowsLogin();
|
|
@@ -15,6 +15,9 @@ const RESUME_CAPTURE_RETRY_DELAY_MS = 1200;
|
|
|
15
15
|
const MAX_CONSECUTIVE_RESUME_CAPTURE_FAILURES = 10;
|
|
16
16
|
const DEFAULT_VISION_MAX_IMAGE_PIXELS = 36000000;
|
|
17
17
|
const DEFAULT_VISION_RETRY_MAX_IMAGE_PIXELS = 30000000;
|
|
18
|
+
const DEFAULT_TEXT_MODEL_CHUNK_SIZE_CHARS = 24000;
|
|
19
|
+
const DEFAULT_TEXT_MODEL_CHUNK_OVERLAP_CHARS = 1200;
|
|
20
|
+
const DEFAULT_TEXT_MODEL_MAX_CHUNKS = 12;
|
|
18
21
|
let visionSharpFactory = null;
|
|
19
22
|
const PAGE_SCOPE_TAB_STATUS = {
|
|
20
23
|
recommend: "0",
|
|
@@ -333,6 +336,60 @@ function isVisionImageSizeLimitMessage(message) {
|
|
|
333
336
|
);
|
|
334
337
|
}
|
|
335
338
|
|
|
339
|
+
function isTextContextLimitMessage(message) {
|
|
340
|
+
const text = normalizeText(message).toLowerCase();
|
|
341
|
+
if (!text) return false;
|
|
342
|
+
return (
|
|
343
|
+
/context length|maximum context|too many tokens|max(?:imum)? token|prompt is too long|input is too long|token limit|上下文|超出.*token|超过.*token|输入过长/i.test(text)
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function toStringArray(value, maxItems = 8) {
|
|
348
|
+
if (!Array.isArray(value)) return [];
|
|
349
|
+
const normalized = [];
|
|
350
|
+
for (const item of value) {
|
|
351
|
+
const text = normalizeText(item);
|
|
352
|
+
if (!text) continue;
|
|
353
|
+
normalized.push(text);
|
|
354
|
+
if (normalized.length >= maxItems) break;
|
|
355
|
+
}
|
|
356
|
+
return normalized;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function splitTextByChunks(text, chunkSize, overlap, maxChunks) {
|
|
360
|
+
const source = String(text || "");
|
|
361
|
+
if (!source) return [];
|
|
362
|
+
|
|
363
|
+
const safeChunkSize = Math.max(1000, parsePositiveInteger(chunkSize) || DEFAULT_TEXT_MODEL_CHUNK_SIZE_CHARS);
|
|
364
|
+
const safeOverlap = Math.max(0, Math.min(safeChunkSize - 1, parsePositiveInteger(overlap) || DEFAULT_TEXT_MODEL_CHUNK_OVERLAP_CHARS));
|
|
365
|
+
const safeMaxChunks = Math.max(1, parsePositiveInteger(maxChunks) || DEFAULT_TEXT_MODEL_MAX_CHUNKS);
|
|
366
|
+
|
|
367
|
+
const chunks = [];
|
|
368
|
+
let start = 0;
|
|
369
|
+
while (start < source.length && chunks.length < safeMaxChunks) {
|
|
370
|
+
const end = Math.min(source.length, start + safeChunkSize);
|
|
371
|
+
chunks.push({
|
|
372
|
+
text: source.slice(start, end),
|
|
373
|
+
start,
|
|
374
|
+
end
|
|
375
|
+
});
|
|
376
|
+
if (end >= source.length) break;
|
|
377
|
+
start = Math.max(0, end - safeOverlap);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (chunks.length > 0) {
|
|
381
|
+
const last = chunks[chunks.length - 1];
|
|
382
|
+
if (last.end < source.length) {
|
|
383
|
+
chunks[chunks.length - 1] = {
|
|
384
|
+
text: source.slice(last.start),
|
|
385
|
+
start: last.start,
|
|
386
|
+
end: source.length
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return chunks;
|
|
391
|
+
}
|
|
392
|
+
|
|
336
393
|
function normalizePostAction(value) {
|
|
337
394
|
const normalized = normalizeText(value).toLowerCase();
|
|
338
395
|
if (!normalized) return null;
|
|
@@ -795,7 +852,7 @@ async function promptMissingInputs(args) {
|
|
|
795
852
|
if (args.targetCount === null) {
|
|
796
853
|
const targetCount = await askWithValidation(
|
|
797
854
|
ask,
|
|
798
|
-
"
|
|
855
|
+
"请输入目标通过人数(--targetCount,可留空表示不设上限): ",
|
|
799
856
|
(value) => parsePositiveInteger(value),
|
|
800
857
|
{ allowEmpty: true }
|
|
801
858
|
);
|
|
@@ -3241,9 +3298,9 @@ class RecommendScreenCli {
|
|
|
3241
3298
|
DEFAULT_VISION_MAX_IMAGE_PIXELS
|
|
3242
3299
|
);
|
|
3243
3300
|
const retryLimit = resolveVisionRetryPixelLimit(primaryLimit);
|
|
3244
|
-
const preparedPrimary = await this.
|
|
3301
|
+
const preparedPrimary = await this.prepareVisionImageSegmentsForModel(imagePath, primaryLimit, "primary");
|
|
3245
3302
|
try {
|
|
3246
|
-
return await this.requestVisionModel(preparedPrimary.
|
|
3303
|
+
return await this.requestVisionModel(preparedPrimary.imagePaths);
|
|
3247
3304
|
} catch (error) {
|
|
3248
3305
|
if (!isVisionImageSizeLimitMessage(error?.message || "")) {
|
|
3249
3306
|
throw error;
|
|
@@ -3251,12 +3308,13 @@ class RecommendScreenCli {
|
|
|
3251
3308
|
log(
|
|
3252
3309
|
`[VISION] 检测到图片尺寸超限,准备降采样重试: ` +
|
|
3253
3310
|
`primary_limit=${primaryLimit} source=${preparedPrimary.source} ` +
|
|
3254
|
-
`source_pixels=${preparedPrimary.sourcePixels ?? "unknown"}`
|
|
3311
|
+
`source_pixels=${preparedPrimary.sourcePixels ?? "unknown"} ` +
|
|
3312
|
+
`segments=${preparedPrimary.imagePaths?.length || 1}`
|
|
3255
3313
|
);
|
|
3256
3314
|
}
|
|
3257
|
-
const preparedRetry = await this.
|
|
3315
|
+
const preparedRetry = await this.prepareVisionImageSegmentsForModel(imagePath, retryLimit, "retry");
|
|
3258
3316
|
try {
|
|
3259
|
-
return await this.requestVisionModel(preparedRetry.
|
|
3317
|
+
return await this.requestVisionModel(preparedRetry.imagePaths);
|
|
3260
3318
|
} catch (retryError) {
|
|
3261
3319
|
if (!isVisionImageSizeLimitMessage(retryError?.message || "")) {
|
|
3262
3320
|
throw retryError;
|
|
@@ -3267,11 +3325,106 @@ class RecommendScreenCli {
|
|
|
3267
3325
|
`primary_limit=${primaryLimit}; retry_limit=${retryLimit}; ` +
|
|
3268
3326
|
`source_pixels=${preparedRetry.sourcePixels ?? "unknown"}; ` +
|
|
3269
3327
|
`retry_pixels=${preparedRetry.currentPixels ?? "unknown"}; ` +
|
|
3328
|
+
`segments=${preparedRetry.imagePaths?.length || 1}; ` +
|
|
3270
3329
|
`last_error=${normalizeText(retryError?.message || retryError)}`
|
|
3271
3330
|
);
|
|
3272
3331
|
}
|
|
3273
3332
|
}
|
|
3274
3333
|
|
|
3334
|
+
async prepareVisionImageSegmentsForModel(imagePath, maxPixels, attemptTag = "primary") {
|
|
3335
|
+
const resolvedMaxPixels = parsePositiveInteger(maxPixels);
|
|
3336
|
+
if (!resolvedMaxPixels) {
|
|
3337
|
+
return {
|
|
3338
|
+
imagePaths: [imagePath],
|
|
3339
|
+
source: "no_limit",
|
|
3340
|
+
sourcePixels: null,
|
|
3341
|
+
currentPixels: null
|
|
3342
|
+
};
|
|
3343
|
+
}
|
|
3344
|
+
|
|
3345
|
+
let sharp;
|
|
3346
|
+
try {
|
|
3347
|
+
sharp = loadVisionSharp();
|
|
3348
|
+
} catch (error) {
|
|
3349
|
+
log(`[VISION] 加载 sharp 失败,回退到单图模式: ${error?.message || error}`);
|
|
3350
|
+
const single = await this.prepareVisionImageForModel(imagePath, resolvedMaxPixels, attemptTag);
|
|
3351
|
+
return {
|
|
3352
|
+
imagePaths: [single.imagePath],
|
|
3353
|
+
source: `single_${single.source}`,
|
|
3354
|
+
sourcePixels: single.sourcePixels ?? null,
|
|
3355
|
+
currentPixels: single.currentPixels ?? null
|
|
3356
|
+
};
|
|
3357
|
+
}
|
|
3358
|
+
|
|
3359
|
+
let metadata;
|
|
3360
|
+
try {
|
|
3361
|
+
metadata = await sharp(imagePath).metadata();
|
|
3362
|
+
} catch (error) {
|
|
3363
|
+
log(`[VISION] 读取图片尺寸失败,回退到单图模式: ${error?.message || error}`);
|
|
3364
|
+
const single = await this.prepareVisionImageForModel(imagePath, resolvedMaxPixels, attemptTag);
|
|
3365
|
+
return {
|
|
3366
|
+
imagePaths: [single.imagePath],
|
|
3367
|
+
source: `single_${single.source}`,
|
|
3368
|
+
sourcePixels: single.sourcePixels ?? null,
|
|
3369
|
+
currentPixels: single.currentPixels ?? null
|
|
3370
|
+
};
|
|
3371
|
+
}
|
|
3372
|
+
|
|
3373
|
+
const width = Number(metadata?.width || 0);
|
|
3374
|
+
const height = Number(metadata?.height || 0);
|
|
3375
|
+
const sourcePixels = width > 0 && height > 0 ? width * height : null;
|
|
3376
|
+
if (!sourcePixels || sourcePixels <= resolvedMaxPixels) {
|
|
3377
|
+
return {
|
|
3378
|
+
imagePaths: [imagePath],
|
|
3379
|
+
source: "within_limit",
|
|
3380
|
+
sourcePixels,
|
|
3381
|
+
currentPixels: sourcePixels
|
|
3382
|
+
};
|
|
3383
|
+
}
|
|
3384
|
+
|
|
3385
|
+
const maxTileHeight = Math.floor(resolvedMaxPixels / Math.max(1, width));
|
|
3386
|
+
if (!Number.isFinite(maxTileHeight) || maxTileHeight < 64) {
|
|
3387
|
+
const single = await this.prepareVisionImageForModel(imagePath, resolvedMaxPixels, attemptTag);
|
|
3388
|
+
return {
|
|
3389
|
+
imagePaths: [single.imagePath],
|
|
3390
|
+
source: `single_${single.source}`,
|
|
3391
|
+
sourcePixels: single.sourcePixels ?? sourcePixels,
|
|
3392
|
+
currentPixels: single.currentPixels ?? sourcePixels
|
|
3393
|
+
};
|
|
3394
|
+
}
|
|
3395
|
+
|
|
3396
|
+
const parsedPath = path.parse(imagePath);
|
|
3397
|
+
const imagePaths = [];
|
|
3398
|
+
for (let top = 0, index = 0; top < height; top += maxTileHeight, index += 1) {
|
|
3399
|
+
const segmentHeight = Math.min(maxTileHeight, height - top);
|
|
3400
|
+
const segmentPath = path.join(
|
|
3401
|
+
parsedPath.dir,
|
|
3402
|
+
`${parsedPath.name}.${attemptTag}.seg${String(index + 1).padStart(3, "0")}.png`
|
|
3403
|
+
);
|
|
3404
|
+
await sharp(imagePath)
|
|
3405
|
+
.extract({
|
|
3406
|
+
left: 0,
|
|
3407
|
+
top,
|
|
3408
|
+
width,
|
|
3409
|
+
height: segmentHeight
|
|
3410
|
+
})
|
|
3411
|
+
.png()
|
|
3412
|
+
.toFile(segmentPath);
|
|
3413
|
+
imagePaths.push(segmentPath);
|
|
3414
|
+
}
|
|
3415
|
+
|
|
3416
|
+
log(
|
|
3417
|
+
`[VISION] 长简历按分段输入模型: ${width}x${height}(${sourcePixels}) ` +
|
|
3418
|
+
`-> segments=${imagePaths.length}, max_pixels_per_segment=${resolvedMaxPixels}, attempt=${attemptTag}`
|
|
3419
|
+
);
|
|
3420
|
+
return {
|
|
3421
|
+
imagePaths,
|
|
3422
|
+
source: "segmented",
|
|
3423
|
+
sourcePixels,
|
|
3424
|
+
currentPixels: resolvedMaxPixels
|
|
3425
|
+
};
|
|
3426
|
+
}
|
|
3427
|
+
|
|
3275
3428
|
async prepareVisionImageForModel(imagePath, maxPixels, attemptTag = "primary") {
|
|
3276
3429
|
const resolvedMaxPixels = parsePositiveInteger(maxPixels);
|
|
3277
3430
|
if (!resolvedMaxPixels) {
|
|
@@ -3360,7 +3513,38 @@ class RecommendScreenCli {
|
|
|
3360
3513
|
}
|
|
3361
3514
|
|
|
3362
3515
|
async requestVisionModel(imagePath) {
|
|
3363
|
-
const
|
|
3516
|
+
const imagePaths = Array.isArray(imagePath) ? imagePath.filter(Boolean) : [imagePath].filter(Boolean);
|
|
3517
|
+
if (imagePaths.length <= 0) {
|
|
3518
|
+
throw this.buildError("VISION_MODEL_FAILED", "No vision image input provided.");
|
|
3519
|
+
}
|
|
3520
|
+
const userContent = [
|
|
3521
|
+
{
|
|
3522
|
+
type: "text",
|
|
3523
|
+
text:
|
|
3524
|
+
"请根据以下标准判断候选人是否通过筛选。\n\n" +
|
|
3525
|
+
`筛选标准:\n${this.args.criteria}\n\n` +
|
|
3526
|
+
"你将收到候选人完整简历的一个或多个顺序分段图片。必须完整阅读全部分段后再判断," +
|
|
3527
|
+
"严禁编造任何不存在的经历、项目、学校、公司或时间线;证据不足时必须判定为不通过。\n\n" +
|
|
3528
|
+
"请返回严格 JSON: " +
|
|
3529
|
+
"{\"passed\": true/false, \"reason\": \"...\", \"summary\": \"...\", \"evidence\": [\"证据原文1\", \"证据原文2\"]}"
|
|
3530
|
+
}
|
|
3531
|
+
];
|
|
3532
|
+
for (let index = 0; index < imagePaths.length; index += 1) {
|
|
3533
|
+
const segmentPath = imagePaths[index];
|
|
3534
|
+
const imageBase64 = fs.readFileSync(segmentPath, "base64");
|
|
3535
|
+
if (imagePaths.length > 1) {
|
|
3536
|
+
userContent.push({
|
|
3537
|
+
type: "text",
|
|
3538
|
+
text: `简历分段 ${index + 1}/${imagePaths.length}`
|
|
3539
|
+
});
|
|
3540
|
+
}
|
|
3541
|
+
userContent.push({
|
|
3542
|
+
type: "image_url",
|
|
3543
|
+
image_url: {
|
|
3544
|
+
url: `data:image/png;base64,${imageBase64}`
|
|
3545
|
+
}
|
|
3546
|
+
});
|
|
3547
|
+
}
|
|
3364
3548
|
const rawBaseUrl = this.args.baseUrl;
|
|
3365
3549
|
log(`[callVisionModel] baseUrl 原始值类型=${typeof rawBaseUrl}, 长度=${rawBaseUrl != null ? String(rawBaseUrl).length : "null/undefined"}, JSON编码=${JSON.stringify(rawBaseUrl)}`);
|
|
3366
3550
|
const baseUrl = String(rawBaseUrl || "").replace(/\/$/, "");
|
|
@@ -3370,22 +3554,13 @@ class RecommendScreenCli {
|
|
|
3370
3554
|
messages: [
|
|
3371
3555
|
{
|
|
3372
3556
|
role: "system",
|
|
3373
|
-
content:
|
|
3557
|
+
content:
|
|
3558
|
+
"你是一位严谨的招聘筛选助手。必须完整阅读所有输入材料,严禁臆造不存在的简历经历。" +
|
|
3559
|
+
"只能返回 JSON,不要输出任何额外文字。"
|
|
3374
3560
|
},
|
|
3375
3561
|
{
|
|
3376
3562
|
role: "user",
|
|
3377
|
-
content:
|
|
3378
|
-
{
|
|
3379
|
-
type: "text",
|
|
3380
|
-
text: `请根据以下标准判断候选人是否通过筛选。\n\n筛选标准:\n${this.args.criteria}\n\n你看到的是整份候选人简历长图。请返回严格 JSON: {\"passed\": true/false, \"reason\": \"...\", \"summary\": \"...\"}`
|
|
3381
|
-
},
|
|
3382
|
-
{
|
|
3383
|
-
type: "image_url",
|
|
3384
|
-
image_url: {
|
|
3385
|
-
url: `data:image/png;base64,${imageBase64}`
|
|
3386
|
-
}
|
|
3387
|
-
}
|
|
3388
|
-
]
|
|
3563
|
+
content: userContent
|
|
3389
3564
|
}
|
|
3390
3565
|
]
|
|
3391
3566
|
};
|
|
@@ -3410,15 +3585,79 @@ class RecommendScreenCli {
|
|
|
3410
3585
|
? json.choices[0].message.content.map((item) => item?.text || "").join("\n")
|
|
3411
3586
|
: json?.choices?.[0]?.message?.content || "";
|
|
3412
3587
|
const parsed = extractJsonObject(content);
|
|
3588
|
+
const reason = normalizeText(parsed.reason);
|
|
3589
|
+
const summary = normalizeText(parsed.summary || reason);
|
|
3590
|
+
const evidence = toStringArray(parsed.evidence);
|
|
3413
3591
|
return {
|
|
3414
3592
|
passed: parsed.passed === true,
|
|
3415
|
-
reason:
|
|
3416
|
-
summary:
|
|
3593
|
+
reason: reason || "未满足筛选标准。",
|
|
3594
|
+
summary: summary || reason || "未满足筛选标准。",
|
|
3595
|
+
evidence
|
|
3417
3596
|
};
|
|
3418
3597
|
}
|
|
3419
3598
|
|
|
3420
3599
|
async callTextModel(resumeText) {
|
|
3421
|
-
const
|
|
3600
|
+
const fullResumeText = String(resumeText || "");
|
|
3601
|
+
if (!normalizeText(fullResumeText)) {
|
|
3602
|
+
throw this.buildError("TEXT_MODEL_FAILED", "Resume text is empty.");
|
|
3603
|
+
}
|
|
3604
|
+
try {
|
|
3605
|
+
return await this.requestTextModel(fullResumeText, {
|
|
3606
|
+
chunkIndex: 1,
|
|
3607
|
+
chunkTotal: 1
|
|
3608
|
+
});
|
|
3609
|
+
} catch (error) {
|
|
3610
|
+
if (!isTextContextLimitMessage(error?.message || "")) {
|
|
3611
|
+
throw error;
|
|
3612
|
+
}
|
|
3613
|
+
log("[TEXT_MODEL] 检测到上下文长度限制,启用分段筛选模式。");
|
|
3614
|
+
}
|
|
3615
|
+
|
|
3616
|
+
const chunkSize = parsePositiveInteger(process.env.BOSS_RECOMMEND_TEXT_CHUNK_SIZE_CHARS) || DEFAULT_TEXT_MODEL_CHUNK_SIZE_CHARS;
|
|
3617
|
+
const overlap = parsePositiveInteger(process.env.BOSS_RECOMMEND_TEXT_CHUNK_OVERLAP_CHARS) || DEFAULT_TEXT_MODEL_CHUNK_OVERLAP_CHARS;
|
|
3618
|
+
const maxChunks = parsePositiveInteger(process.env.BOSS_RECOMMEND_TEXT_MAX_CHUNKS) || DEFAULT_TEXT_MODEL_MAX_CHUNKS;
|
|
3619
|
+
const chunks = splitTextByChunks(fullResumeText, chunkSize, overlap, maxChunks);
|
|
3620
|
+
if (!chunks.length) {
|
|
3621
|
+
throw this.buildError("TEXT_MODEL_FAILED", "Resume text is empty after chunk split.");
|
|
3622
|
+
}
|
|
3623
|
+
|
|
3624
|
+
const chunkResults = [];
|
|
3625
|
+
for (let index = 0; index < chunks.length; index += 1) {
|
|
3626
|
+
const chunk = chunks[index];
|
|
3627
|
+
const result = await this.requestTextModel(chunk.text, {
|
|
3628
|
+
chunkIndex: index + 1,
|
|
3629
|
+
chunkTotal: chunks.length
|
|
3630
|
+
});
|
|
3631
|
+
chunkResults.push(result);
|
|
3632
|
+
}
|
|
3633
|
+
|
|
3634
|
+
const passedChunks = chunkResults.filter((item) => item?.passed === true);
|
|
3635
|
+
if (passedChunks.length > 0) {
|
|
3636
|
+
const best = passedChunks[0];
|
|
3637
|
+
return {
|
|
3638
|
+
passed: true,
|
|
3639
|
+
reason: best.reason || `分段筛选命中(${best.chunkIndex}/${chunks.length})。`,
|
|
3640
|
+
summary: best.summary || best.reason || "分段筛选命中",
|
|
3641
|
+
evidence: Array.isArray(best.evidence) ? best.evidence : []
|
|
3642
|
+
};
|
|
3643
|
+
}
|
|
3644
|
+
|
|
3645
|
+
const firstReason = chunkResults.map((item) => normalizeText(item?.reason)).find(Boolean);
|
|
3646
|
+
return {
|
|
3647
|
+
passed: false,
|
|
3648
|
+
reason: firstReason || `分段筛选未找到满足标准的证据(共 ${chunks.length} 段)。`,
|
|
3649
|
+
summary: firstReason || `分段筛选未找到满足标准的证据(共 ${chunks.length} 段)。`,
|
|
3650
|
+
evidence: []
|
|
3651
|
+
};
|
|
3652
|
+
}
|
|
3653
|
+
|
|
3654
|
+
async requestTextModel(resumeText, options = {}) {
|
|
3655
|
+
const safeResumeText = String(resumeText || "");
|
|
3656
|
+
const chunkIndex = Number.isInteger(options.chunkIndex) && options.chunkIndex > 0 ? options.chunkIndex : 1;
|
|
3657
|
+
const chunkTotal = Number.isInteger(options.chunkTotal) && options.chunkTotal > 0 ? options.chunkTotal : 1;
|
|
3658
|
+
const chunkHint = chunkTotal > 1
|
|
3659
|
+
? `\n\n当前输入是简历分段 ${chunkIndex}/${chunkTotal}。请严格基于本分段文本判断;如果本分段证据不足,必须返回 passed=false。`
|
|
3660
|
+
: "";
|
|
3422
3661
|
const rawBaseUrl = this.args.baseUrl;
|
|
3423
3662
|
log(`[callTextModel] baseUrl 原始值类型=${typeof rawBaseUrl}, 长度=${rawBaseUrl != null ? String(rawBaseUrl).length : "null/undefined"}, JSON编码=${JSON.stringify(rawBaseUrl)}`);
|
|
3424
3663
|
const baseUrl = String(rawBaseUrl || "").replace(/\/$/, "");
|
|
@@ -3428,11 +3667,21 @@ class RecommendScreenCli {
|
|
|
3428
3667
|
messages: [
|
|
3429
3668
|
{
|
|
3430
3669
|
role: "system",
|
|
3431
|
-
content:
|
|
3670
|
+
content:
|
|
3671
|
+
"你是一位严谨的招聘筛选助手。必须完整阅读输入内容,严禁编造不存在的简历经历。" +
|
|
3672
|
+
"只能返回 JSON,不要输出任何额外文字。"
|
|
3432
3673
|
},
|
|
3433
3674
|
{
|
|
3434
3675
|
role: "user",
|
|
3435
|
-
content:
|
|
3676
|
+
content:
|
|
3677
|
+
`请根据以下标准判断候选人是否通过筛选。\n\n筛选标准:\n${this.args.criteria}\n\n` +
|
|
3678
|
+
`简历内容:\n${safeResumeText}${chunkHint}\n\n` +
|
|
3679
|
+
"要求:\n" +
|
|
3680
|
+
"1) 必须完整阅读上面的全部简历文本。\n" +
|
|
3681
|
+
"2) 只能依据简历中真实出现的信息判断,严禁编造不存在的经历/项目/学历/公司。\n" +
|
|
3682
|
+
"3) 若证据不足,必须返回 passed=false。\n\n" +
|
|
3683
|
+
"请返回严格 JSON: " +
|
|
3684
|
+
"{\"passed\": true/false, \"reason\": \"...\", \"summary\": \"...\", \"evidence\": [\"证据原文1\", \"证据原文2\"]}"
|
|
3436
3685
|
}
|
|
3437
3686
|
]
|
|
3438
3687
|
};
|
|
@@ -3457,10 +3706,28 @@ class RecommendScreenCli {
|
|
|
3457
3706
|
? json.choices[0].message.content.map((item) => item?.text || "").join("\n")
|
|
3458
3707
|
: json?.choices?.[0]?.message?.content || "";
|
|
3459
3708
|
const parsed = extractJsonObject(content);
|
|
3709
|
+
const reason = normalizeText(parsed.reason);
|
|
3710
|
+
const summary = normalizeText(parsed.summary || reason);
|
|
3711
|
+
const normalizedResume = normalizeText(safeResumeText);
|
|
3712
|
+
const parsedEvidence = toStringArray(parsed.evidence);
|
|
3713
|
+
const evidence = parsedEvidence.filter((item) => {
|
|
3714
|
+
const normalizedEvidence = normalizeText(item);
|
|
3715
|
+
if (!normalizedEvidence) return false;
|
|
3716
|
+
return safeResumeText.includes(item) || normalizedResume.includes(normalizedEvidence);
|
|
3717
|
+
});
|
|
3718
|
+
let passed = parsed.passed === true;
|
|
3719
|
+
let finalReason = reason || (passed ? "满足筛选标准。" : "不满足筛选标准。");
|
|
3720
|
+
if (passed && evidence.length <= 0) {
|
|
3721
|
+
passed = false;
|
|
3722
|
+
finalReason = `模型未给出可在简历原文中校验的证据,按安全策略判为不通过。${reason ? ` 原始原因: ${reason}` : ""}`;
|
|
3723
|
+
}
|
|
3460
3724
|
return {
|
|
3461
|
-
passed
|
|
3462
|
-
reason:
|
|
3463
|
-
summary:
|
|
3725
|
+
passed,
|
|
3726
|
+
reason: finalReason,
|
|
3727
|
+
summary: summary || finalReason,
|
|
3728
|
+
evidence,
|
|
3729
|
+
chunkIndex,
|
|
3730
|
+
chunkTotal
|
|
3464
3731
|
};
|
|
3465
3732
|
}
|
|
3466
3733
|
|
|
@@ -3686,8 +3953,13 @@ class RecommendScreenCli {
|
|
|
3686
3953
|
}
|
|
3687
3954
|
|
|
3688
3955
|
state = await this.getDetailClosedState();
|
|
3689
|
-
|
|
3690
|
-
|
|
3956
|
+
const listState = await this.evaluate(jsGetListState);
|
|
3957
|
+
if (listState?.ok) {
|
|
3958
|
+
log(`[关闭详情] 连续 ESC 后仍未确认关闭(${state?.reason || "unknown"}),但候选人列表已可用,按就绪状态继续。`);
|
|
3959
|
+
return true;
|
|
3960
|
+
}
|
|
3961
|
+
log(`[关闭详情] 连续 ESC 后仍未确认关闭(${state?.reason || "unknown"}),且候选人列表未恢复,判定关闭失败。`);
|
|
3962
|
+
return false;
|
|
3691
3963
|
}
|
|
3692
3964
|
|
|
3693
3965
|
async waitForListReady(maxRounds = 30) {
|
|
@@ -3756,7 +4028,10 @@ class RecommendScreenCli {
|
|
|
3756
4028
|
|
|
3757
4029
|
const restoredFromCheckpoint = this.loadCheckpointIfNeeded();
|
|
3758
4030
|
if (restoredFromCheckpoint) {
|
|
3759
|
-
log(
|
|
4031
|
+
log(
|
|
4032
|
+
`[恢复] 已从 checkpoint 恢复,已处理 ${this.processedCount} 位候选人,` +
|
|
4033
|
+
`其中通过 ${this.passedCandidates.length} 位。`
|
|
4034
|
+
);
|
|
3760
4035
|
}
|
|
3761
4036
|
|
|
3762
4037
|
await this.connect();
|
|
@@ -3790,7 +4065,7 @@ class RecommendScreenCli {
|
|
|
3790
4065
|
}
|
|
3791
4066
|
|
|
3792
4067
|
let pageExhaustion = null;
|
|
3793
|
-
while (!this.args.targetCount || this.
|
|
4068
|
+
while (!this.args.targetCount || this.passedCandidates.length < this.args.targetCount) {
|
|
3794
4069
|
if (this.shouldPauseAtBoundary()) {
|
|
3795
4070
|
this.saveCsv();
|
|
3796
4071
|
this.saveCheckpoint();
|
|
@@ -4048,10 +4323,10 @@ class RecommendScreenCli {
|
|
|
4048
4323
|
}
|
|
4049
4324
|
}
|
|
4050
4325
|
|
|
4051
|
-
if (this.args.targetCount && this.
|
|
4326
|
+
if (this.args.targetCount && this.passedCandidates.length < this.args.targetCount) {
|
|
4052
4327
|
throw this.buildError(
|
|
4053
4328
|
"TARGET_COUNT_NOT_REACHED_PAGE_EXHAUSTED",
|
|
4054
|
-
|
|
4329
|
+
`推荐列表已到底,但当前仅通过 ${this.passedCandidates.length} 位,尚未达到目标 ${this.args.targetCount} 位。`,
|
|
4055
4330
|
true,
|
|
4056
4331
|
{
|
|
4057
4332
|
partial_result: this.buildProgressSnapshot("page_exhausted_before_target_count"),
|
|
@@ -4070,11 +4345,11 @@ class RecommendScreenCli {
|
|
|
4070
4345
|
status: "COMPLETED",
|
|
4071
4346
|
result: {
|
|
4072
4347
|
...this.buildProgressSnapshot(
|
|
4073
|
-
this.args.targetCount && this.
|
|
4348
|
+
this.args.targetCount && this.passedCandidates.length >= this.args.targetCount
|
|
4074
4349
|
? "target_count_reached"
|
|
4075
4350
|
: "page_exhausted"
|
|
4076
4351
|
),
|
|
4077
|
-
completion_reason: this.args.targetCount && this.
|
|
4352
|
+
completion_reason: this.args.targetCount && this.passedCandidates.length >= this.args.targetCount
|
|
4078
4353
|
? "target_count_reached"
|
|
4079
4354
|
: "page_exhausted",
|
|
4080
4355
|
}
|