@reconcrap/boss-recommend-mcp 1.3.9 → 1.3.11

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.
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
2
2
  import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
+ import { mkdir } from "node:fs/promises";
5
6
 
6
7
  import {
7
8
  cancelBossChatRun,
@@ -14,6 +15,8 @@ import {
14
15
  } from "./boss-chat.js";
15
16
  import { __testables as cliTestables } from "./cli.js";
16
17
  import { __testables as indexTestables } from "./index.js";
18
+ import { BossChatApp } from "../vendor/boss-chat-cli/src/app.js";
19
+ import { LlmClient, parseLlmJson } from "../vendor/boss-chat-cli/src/services/llm.js";
17
20
 
18
21
  const { handleRequest } = indexTestables;
19
22
 
@@ -273,6 +276,51 @@ async function testBossChatAdapterShouldResolveSharedConfigAndInvokeLocalCli() {
273
276
  const stateAfterStartAll = readStubState(workspaceRoot);
274
277
  assert.equal(stateAfterStartAll.last_start_args.targetCount, "-1");
275
278
 
279
+ for (const target_count of ["all", -1, "-1", { value: "all" }, "all(扫到底)"]) {
280
+ const startedVariant = await startBossChatRun({
281
+ workspaceRoot,
282
+ input: {
283
+ profile: "default",
284
+ job: "算法工程师",
285
+ start_from: "all",
286
+ criteria: "全部候选人都过一遍",
287
+ target_count
288
+ }
289
+ });
290
+ assert.equal(startedVariant.status, "ACCEPTED");
291
+ assert.equal(readStubState(workspaceRoot).last_start_args.targetCount, "-1");
292
+ }
293
+
294
+ const startedCamelCase = await startBossChatRun({
295
+ workspaceRoot,
296
+ input: {
297
+ profile: "default",
298
+ job: "算法工程师",
299
+ start_from: "all",
300
+ criteria: "全部候选人都过一遍",
301
+ targetCount: { targetCount: "all" }
302
+ }
303
+ });
304
+ assert.equal(startedCamelCase.status, "ACCEPTED");
305
+ assert.equal(readStubState(workspaceRoot).last_start_args.targetCount, "-1");
306
+
307
+ const invalidTarget = await startBossChatRun({
308
+ workspaceRoot,
309
+ input: {
310
+ profile: "default",
311
+ job: "算法工程师",
312
+ start_from: "all",
313
+ criteria: "全部候选人都过一遍",
314
+ target_count: "not a target"
315
+ }
316
+ });
317
+ assert.equal(invalidTarget.status, "NEED_INPUT");
318
+ assert.deepEqual(invalidTarget.missing_fields, ["target_count"]);
319
+ assert.equal(invalidTarget.received_target_count, "not a target");
320
+ assert.equal(Boolean(invalidTarget.target_count_parse_error), true);
321
+ assert.equal(invalidTarget.next_call_example.target_count, "all");
322
+ assert.equal(invalidTarget.accepted_examples.includes("all"), true);
323
+
276
324
  const running = await getBossChatRun({
277
325
  workspaceRoot,
278
326
  input: {
@@ -323,7 +371,9 @@ async function testBossChatMcpToolsShouldValidateAndRoute() {
323
371
  const prepareToolSchema = tools.find((item) => item.name === TOOL_BOSS_CHAT_PREPARE_RUN).inputSchema;
324
372
  const startToolSchema = tools.find((item) => item.name === TOOL_BOSS_CHAT_START_RUN).inputSchema;
325
373
  assert.equal(prepareToolSchema.required, undefined);
326
- assert.deepEqual(startToolSchema.required, ["job", "start_from", "target_count", "criteria"]);
374
+ assert.deepEqual(startToolSchema.required, ["job", "start_from", "criteria"]);
375
+ assert.equal(startToolSchema.anyOf.some((item) => item.required?.includes("target_count")), true);
376
+ assert.equal(startToolSchema.anyOf.some((item) => item.required?.includes("targetCount")), true);
327
377
 
328
378
  const prepared = await callTool(workspaceRoot, TOOL_BOSS_CHAT_PREPARE_RUN, {}, 101);
329
379
  assert.equal(prepared.status, "NEED_INPUT");
@@ -348,6 +398,19 @@ async function testBossChatMcpToolsShouldValidateAndRoute() {
348
398
  assert.equal(missingTargetOnly.status, "NEED_INPUT");
349
399
  assert.deepEqual(missingTargetOnly.missing_fields, ["target_count"]);
350
400
  assert.equal(missingTargetOnly.next_call_example.target_count, "all");
401
+ assert.equal(missingTargetOnly.accepted_examples.includes(-1), true);
402
+
403
+ const invalidTargetOnly = await callTool(workspaceRoot, TOOL_BOSS_CHAT_START_RUN, {
404
+ job: "算法工程师",
405
+ start_from: "all",
406
+ criteria: "全部候选人都过一遍",
407
+ target_count: "not a target"
408
+ }, 112);
409
+ assert.equal(invalidTargetOnly.status, "NEED_INPUT");
410
+ assert.deepEqual(invalidTargetOnly.missing_fields, ["target_count"]);
411
+ assert.equal(invalidTargetOnly.received_target_count, "not a target");
412
+ assert.equal(Boolean(invalidTargetOnly.target_count_parse_error), true);
413
+ assert.equal(invalidTargetOnly.next_call_example.target_count, "all");
351
414
 
352
415
  const invalidStartResponse = await handleRequest(
353
416
  makeToolCall(11, TOOL_BOSS_CHAT_START_RUN, {
@@ -384,6 +447,15 @@ async function testBossChatMcpToolsShouldValidateAndRoute() {
384
447
  const stateAfterStartAll = readStubState(workspaceRoot);
385
448
  assert.equal(stateAfterStartAll.last_start_args.targetCount, "-1");
386
449
 
450
+ const startedCamelCase = await callTool(workspaceRoot, TOOL_BOSS_CHAT_START_RUN, {
451
+ job: "算法工程师",
452
+ start_from: "all",
453
+ criteria: "全部候选人都过一遍",
454
+ targetCount: "all"
455
+ }, 141);
456
+ assert.equal(startedCamelCase.status, "ACCEPTED");
457
+ assert.equal(readStubState(workspaceRoot).last_start_args.targetCount, "-1");
458
+
387
459
  const running = await callTool(workspaceRoot, TOOL_BOSS_CHAT_GET_RUN, {
388
460
  run_id: started.run_id,
389
461
  profile: "default"
@@ -481,10 +553,265 @@ async function testBossChatCliShouldSupportRunAndFollowUpParsing() {
481
553
  });
482
554
  }
483
555
 
556
+ function testBossChatLlmEvidenceGateShouldDemoteMissingEvidence() {
557
+ const parsed = parseLlmJson(
558
+ JSON.stringify({
559
+ passed: true,
560
+ reason: "命中标准",
561
+ summary: "命中",
562
+ evidence: [],
563
+ }),
564
+ {
565
+ evidenceCorpus: "南京大学 机器学习 项目经历",
566
+ },
567
+ );
568
+ assert.equal(parsed.rawPassed, true);
569
+ assert.equal(parsed.passed, false);
570
+ assert.equal(parsed.evidenceGateDemoted, true);
571
+ assert.equal(parsed.evidenceMatchedCount, 0);
572
+ }
573
+
574
+ function testBossChatLlmEvidenceGateShouldDemoteUnmatchedEvidence() {
575
+ const parsed = parseLlmJson(
576
+ JSON.stringify({
577
+ passed: true,
578
+ reason: "命中标准",
579
+ summary: "命中",
580
+ evidence: ["十年金融风控投研经验"],
581
+ }),
582
+ {
583
+ evidenceCorpus: "南京大学 机器学习 项目经历",
584
+ },
585
+ );
586
+ assert.equal(parsed.rawPassed, true);
587
+ assert.equal(parsed.passed, false);
588
+ assert.equal(parsed.evidenceGateDemoted, true);
589
+ assert.equal(parsed.evidenceRawCount, 1);
590
+ assert.equal(parsed.evidenceMatchedCount, 0);
591
+ }
592
+
593
+ async function testBossChatLlmTextChunkFallbackShouldWork() {
594
+ const originalChunkSize = process.env.BOSS_CHAT_TEXT_CHUNK_SIZE_CHARS;
595
+ const originalChunkOverlap = process.env.BOSS_CHAT_TEXT_CHUNK_OVERLAP_CHARS;
596
+ const originalMaxChunks = process.env.BOSS_CHAT_TEXT_MAX_CHUNKS;
597
+ process.env.BOSS_CHAT_TEXT_CHUNK_SIZE_CHARS = "1000";
598
+ process.env.BOSS_CHAT_TEXT_CHUNK_OVERLAP_CHARS = "120";
599
+ process.env.BOSS_CHAT_TEXT_MAX_CHUNKS = "6";
600
+ try {
601
+ class FakeChunkFallbackClient extends LlmClient {
602
+ constructor() {
603
+ super({
604
+ baseUrl: "https://api.example.com/v1",
605
+ apiKey: "sk-test",
606
+ model: "gpt-test",
607
+ });
608
+ this.calls = [];
609
+ }
610
+
611
+ async requestByPreference(payload) {
612
+ this.calls.push(payload);
613
+ if (payload.chunkTotal === 1 && !payload.imageDataUrl) {
614
+ const error = new Error("maximum context length exceeded");
615
+ throw error;
616
+ }
617
+ if (payload.chunkTotal > 1) {
618
+ if (payload.chunkIndex === 2) {
619
+ return {
620
+ passed: true,
621
+ rawPassed: true,
622
+ reason: "命中分段证据",
623
+ summary: "命中",
624
+ evidence: ["PASS_MARKER_ABC"],
625
+ evidenceRawCount: 1,
626
+ evidenceMatchedCount: 1,
627
+ evidenceGateDemoted: false,
628
+ chunkIndex: payload.chunkIndex,
629
+ chunkTotal: payload.chunkTotal,
630
+ };
631
+ }
632
+ return {
633
+ passed: false,
634
+ rawPassed: false,
635
+ reason: "本段证据不足",
636
+ summary: "不足",
637
+ evidence: [],
638
+ evidenceRawCount: 0,
639
+ evidenceMatchedCount: 0,
640
+ evidenceGateDemoted: false,
641
+ chunkIndex: payload.chunkIndex,
642
+ chunkTotal: payload.chunkTotal,
643
+ };
644
+ }
645
+ return {
646
+ passed: false,
647
+ rawPassed: false,
648
+ reason: "unexpected",
649
+ summary: "unexpected",
650
+ evidence: [],
651
+ evidenceRawCount: 0,
652
+ evidenceMatchedCount: 0,
653
+ evidenceGateDemoted: false,
654
+ chunkIndex: 1,
655
+ chunkTotal: 1,
656
+ };
657
+ }
658
+ }
659
+
660
+ const client = new FakeChunkFallbackClient();
661
+ const longResume = `${"A".repeat(1200)} PASS_MARKER_ABC ${"B".repeat(1200)} PASS_MARKER_DEF`;
662
+ const result = await client.evaluateResume({
663
+ screeningCriteria: "有 AI 项目经验",
664
+ candidate: {
665
+ name: "候选人A",
666
+ sourceJob: "算法工程师",
667
+ resumeText: longResume,
668
+ evidenceCorpus: longResume,
669
+ },
670
+ imagePath: null,
671
+ });
672
+ assert.equal(result.passed, true);
673
+ assert.equal(result.evaluationMode, "text");
674
+ assert.equal(result.chunkIndex, 2);
675
+ assert.equal(Number(result.chunkTotal) > 1, true);
676
+ } finally {
677
+ if (originalChunkSize === undefined) delete process.env.BOSS_CHAT_TEXT_CHUNK_SIZE_CHARS;
678
+ else process.env.BOSS_CHAT_TEXT_CHUNK_SIZE_CHARS = originalChunkSize;
679
+ if (originalChunkOverlap === undefined) delete process.env.BOSS_CHAT_TEXT_CHUNK_OVERLAP_CHARS;
680
+ else process.env.BOSS_CHAT_TEXT_CHUNK_OVERLAP_CHARS = originalChunkOverlap;
681
+ if (originalMaxChunks === undefined) delete process.env.BOSS_CHAT_TEXT_MAX_CHUNKS;
682
+ else process.env.BOSS_CHAT_TEXT_MAX_CHUNKS = originalMaxChunks;
683
+ }
684
+ }
685
+
686
+ async function testBossChatAppShouldPersistEvidenceArtifacts() {
687
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "boss-chat-artifacts-"));
688
+ await mkdir(tempDir, { recursive: true });
689
+ const records = [];
690
+ const page = {
691
+ async closeResumeModalDomOnce() {
692
+ return {
693
+ closed: true,
694
+ method: "dom",
695
+ finalState: { scopeCount: 0, iframeCount: 0, closeCount: 0, topScopeClass: "" },
696
+ };
697
+ },
698
+ async waitForConversationReady() {
699
+ return {
700
+ hasOnlineResume: true,
701
+ hasAskResume: true,
702
+ hasAttachmentResume: false,
703
+ attachmentResumeEnabled: false,
704
+ };
705
+ },
706
+ async openOnlineResume() {
707
+ return { clicked: true, detectedOpen: true, by: "dom" };
708
+ },
709
+ async getResumeRateLimitWarning() {
710
+ return { hit: false, text: "" };
711
+ },
712
+ async getResumeProfileFromDom() {
713
+ return {
714
+ ok: true,
715
+ primarySchool: "南京大学",
716
+ schools: ["南京大学"],
717
+ major: "计算机",
718
+ majors: ["计算机"],
719
+ company: "OpenAI",
720
+ position: "工程师",
721
+ resumeText: "南京大学 计算机 PASS_MARKER_ABC",
722
+ evidenceCorpus: "南京大学 计算机 PASS_MARKER_ABC",
723
+ };
724
+ },
725
+ async getResumeModalState() {
726
+ return { open: true, iframeCount: 1, scopeCount: 1, closeCount: 1 };
727
+ },
728
+ };
729
+ const llmClient = {
730
+ async evaluateResume() {
731
+ return {
732
+ passed: false,
733
+ rawPassed: true,
734
+ reason: "模型未给出可在简历原文中校验的证据,按安全策略判为不通过。",
735
+ summary: "降级",
736
+ evidence: [],
737
+ evidenceRawCount: 1,
738
+ evidenceMatchedCount: 0,
739
+ evidenceGateDemoted: true,
740
+ evaluationMode: "text",
741
+ chunkIndex: 1,
742
+ chunkTotal: 1,
743
+ };
744
+ },
745
+ };
746
+ const interaction = {
747
+ async sleepRange() {},
748
+ async clickRect() {},
749
+ };
750
+ const resumeCaptureService = {
751
+ async captureResume({ artifactDir }) {
752
+ return {
753
+ stitchedImage: path.join(artifactDir, "resume.png"),
754
+ metadataFile: path.join(artifactDir, "chunks.json"),
755
+ chunkDir: path.join(artifactDir, "chunks"),
756
+ chunkCount: 1,
757
+ quality: { likelyBlank: false },
758
+ };
759
+ },
760
+ };
761
+ const stateStore = {
762
+ async record(_key, result) {
763
+ records.push(result);
764
+ },
765
+ };
766
+ const app = new BossChatApp({
767
+ page,
768
+ llmClient,
769
+ interaction,
770
+ resumeCaptureService,
771
+ stateStore,
772
+ reportStore: { async write() { return ""; } },
773
+ dryRun: true,
774
+ artifactRootDir: tempDir,
775
+ resumeOpenCooldownMs: 0,
776
+ logger: { log() {} },
777
+ });
778
+ app.waitResumeOpenCooldown = async () => {};
779
+
780
+ const result = await app.processCustomer(
781
+ {
782
+ customerKey: "candidate-key",
783
+ name: "候选人A",
784
+ sourceJob: "算法工程师",
785
+ domIndex: 0,
786
+ customerId: "1001",
787
+ textSnippet: "",
788
+ },
789
+ {
790
+ screeningCriteria: "有 AI 项目经验",
791
+ },
792
+ "run-test",
793
+ { skipCardClick: true },
794
+ );
795
+
796
+ assert.equal(result.passed, false);
797
+ assert.equal(result.artifacts.rawPassed, true);
798
+ assert.equal(result.artifacts.finalPassed, false);
799
+ assert.equal(result.artifacts.evidenceRawCount, 1);
800
+ assert.equal(result.artifacts.evidenceMatchedCount, 0);
801
+ assert.equal(result.artifacts.evidenceGateDemoted, true);
802
+ assert.equal(result.artifacts.evaluationMode, "text");
803
+ assert.equal(Array.isArray(records), true);
804
+ assert.equal(records.length, 1);
805
+ }
806
+
484
807
  async function main() {
485
808
  await testBossChatAdapterShouldResolveSharedConfigAndInvokeLocalCli();
486
809
  await testBossChatMcpToolsShouldValidateAndRoute();
487
810
  await testBossChatCliShouldSupportRunAndFollowUpParsing();
811
+ testBossChatLlmEvidenceGateShouldDemoteMissingEvidence();
812
+ testBossChatLlmEvidenceGateShouldDemoteUnmatchedEvidence();
813
+ await testBossChatLlmTextChunkFallbackShouldWork();
814
+ await testBossChatAppShouldPersistEvidenceArtifacts();
488
815
  console.log("boss-chat tests passed");
489
816
  }
490
817
 
@@ -2224,6 +2224,94 @@ async function testFollowUpChatMissingTargetCountShouldNeedInput() {
2224
2224
  assert.equal(result.pending_questions.some((item) => item.field === "follow_up.chat.target_count"), true);
2225
2225
  }
2226
2226
 
2227
+ async function testFollowUpChatInvalidTargetCountShouldNeedInputWithDiagnostics() {
2228
+ const result = await runRecommendPipeline(
2229
+ {
2230
+ workspaceRoot: process.cwd(),
2231
+ instruction: "test",
2232
+ confirmation: {},
2233
+ overrides: {},
2234
+ followUp: createFollowUpChat({ target_count: "not a target" })
2235
+ },
2236
+ {
2237
+ parseRecommendInstruction: () => createParsed()
2238
+ }
2239
+ );
2240
+
2241
+ assert.equal(result.status, "NEED_INPUT");
2242
+ assert.equal(result.missing_fields.includes("follow_up.chat.target_count"), true);
2243
+ const targetQuestion = result.pending_questions.find((item) => item.field === "follow_up.chat.target_count");
2244
+ assert.equal(targetQuestion?.received_target_count, "not a target");
2245
+ assert.equal(Boolean(targetQuestion?.target_count_parse_error), true);
2246
+ assert.equal(targetQuestion?.accepted_examples.includes("all"), true);
2247
+ }
2248
+
2249
+ async function testFollowUpChatAllTargetCountShouldLaunchUnlimited() {
2250
+ let capturedChatInput = null;
2251
+ const result = await runRecommendPipeline(
2252
+ {
2253
+ workspaceRoot: process.cwd(),
2254
+ instruction: "test",
2255
+ confirmation: createJobConfirmedConfirmation(),
2256
+ overrides: {},
2257
+ followUp: createFollowUpChat({ target_count: "all" })
2258
+ },
2259
+ {
2260
+ parseRecommendInstruction: () => createParsed(),
2261
+ runPipelinePreflight: () => ({ ok: true, checks: [], debug_port: 9555 }),
2262
+ ensureBossRecommendPageReady: async () => ({ ok: true, state: "RECOMMEND_READY", page_state: {} }),
2263
+ listRecommendJobs: async () => createJobListResult(),
2264
+ readRecommendTabState: async () => ({ ok: true, active_tab_status: "0" }),
2265
+ switchRecommendTab: async () => ({ ok: true, state: "TAB_READY" }),
2266
+ runRecommendSearchCli: async () => ({
2267
+ ok: true,
2268
+ summary: {
2269
+ candidate_count: 8,
2270
+ applied_filters: { degree: ["本科"] },
2271
+ selected_job: DEFAULT_JOB_OPTIONS[0]
2272
+ }
2273
+ }),
2274
+ runRecommendScreenCli: async () => ({
2275
+ ok: true,
2276
+ summary: {
2277
+ processed_count: 6,
2278
+ passed_count: 2,
2279
+ skipped_count: 4,
2280
+ output_csv: "C:/temp/recommend.csv",
2281
+ completion_reason: "screen_completed"
2282
+ }
2283
+ }),
2284
+ startBossChatRun: async ({ input }) => {
2285
+ capturedChatInput = input;
2286
+ return {
2287
+ status: "ACCEPTED",
2288
+ run_id: "chat-run-unlimited",
2289
+ message: "chat started"
2290
+ };
2291
+ },
2292
+ getBossChatRun: async () => ({
2293
+ status: "RUN_STATUS",
2294
+ run: {
2295
+ runId: "chat-run-unlimited",
2296
+ state: "completed",
2297
+ lastMessage: "chat completed",
2298
+ progress: {
2299
+ inspected: 3,
2300
+ passed: 1,
2301
+ requested: 1,
2302
+ skipped: 2,
2303
+ errors: 0
2304
+ }
2305
+ }
2306
+ })
2307
+ }
2308
+ );
2309
+
2310
+ assert.equal(result.status, "COMPLETED");
2311
+ assert.equal(capturedChatInput.target_count, "all");
2312
+ assert.equal(result.follow_up?.chat?.target_count, "all");
2313
+ }
2314
+
2227
2315
  async function testFinalReviewShouldIncludeFollowUpChatSummary() {
2228
2316
  const result = await runRecommendPipeline(
2229
2317
  {
@@ -2479,8 +2567,10 @@ async function main() {
2479
2567
  await testFollowUpChatMissingFieldsShouldExposeRecommendDefaults();
2480
2568
  await testFollowUpChatMissingStartFromShouldNeedInput();
2481
2569
  await testFollowUpChatMissingTargetCountShouldNeedInput();
2570
+ await testFollowUpChatInvalidTargetCountShouldNeedInputWithDiagnostics();
2482
2571
  await testFinalReviewShouldIncludeFollowUpChatSummary();
2483
2572
  await testCompletedPipelineShouldRunChatFollowUp();
2573
+ await testFollowUpChatAllTargetCountShouldLaunchUnlimited();
2484
2574
  await testCompletedPipelineShouldFailWhenChatLaunchFails();
2485
2575
  await testCompletedPipelineShouldFailWhenChatRunFails();
2486
2576
  console.log("pipeline tests passed");
@@ -233,7 +233,11 @@ export class BossChatApp {
233
233
 
234
234
  let consecutiveErrors = 0;
235
235
  let exhaustedScrolls = 0;
236
- const exhaustedScrollLimit = targetCount ? 3 : 8;
236
+ let noMoreMarkerHits = 0;
237
+ let fallbackBottomHits = 0;
238
+ const noMoreMarkerConfirmations = 2;
239
+ const exhaustedScrollLimit = targetCount ? 10 : 60;
240
+ const fallbackBottomLimit = targetCount ? 4 : 12;
237
241
 
238
242
  try {
239
243
  while (shouldContinue(summary, targetCount)) {
@@ -324,6 +328,9 @@ export class BossChatApp {
324
328
  stage: 'running',
325
329
  message: `已处理候选人:${result.name || '未知'}`,
326
330
  });
331
+ exhaustedScrolls = 0;
332
+ noMoreMarkerHits = 0;
333
+ fallbackBottomHits = 0;
327
334
  if (consecutiveErrors >= 3) {
328
335
  this.logger.log('连续 3 位候选人处理失败,提前停止本轮运行。');
329
336
  break;
@@ -340,13 +347,33 @@ export class BossChatApp {
340
347
  if (!nextCustomer) {
341
348
  const ratio = 0.52 + Math.random() * 0.34;
342
349
  const scrollResult = await this.page.scrollCustomerList(ratio);
350
+ const noMoreDetected =
351
+ Boolean(scrollResult.noMoreDetectedAfter) || Boolean(scrollResult.noMoreDetectedBefore);
343
352
  this.logger.log(
344
- `列表滚动:ratio=${ratio.toFixed(2)} | didScroll=${Boolean(scrollResult.didScroll)} | top=${scrollResult.after?.top ?? 'n/a'} | scrollRetry=${exhaustedScrolls + 1}`,
353
+ `列表滚动:ratio=${ratio.toFixed(2)} | didScroll=${Boolean(scrollResult.didScroll)} | top=${scrollResult.after?.top ?? 'n/a'} | atBottom=${Boolean(scrollResult.atBottom)} | noMore=${noMoreDetected}${scrollResult.noMoreTextAfter ? `(${scrollResult.noMoreTextAfter})` : ''} | scrollRetry=${exhaustedScrolls + 1}`,
345
354
  );
355
+ if (noMoreDetected) {
356
+ noMoreMarkerHits += 1;
357
+ if (noMoreMarkerHits >= noMoreMarkerConfirmations) {
358
+ summary.exhausted = true;
359
+ this.logger.log('列表滚动终止:检测到“没有更多了”标识,判定为 exhausted。');
360
+ break;
361
+ }
362
+ await this.interaction.sleepRange(920, 260);
363
+ continue;
364
+ }
365
+
366
+ noMoreMarkerHits = 0;
346
367
  exhaustedScrolls = scrollResult.didScroll ? exhaustedScrolls + 1 : exhaustedScrolls + 2;
368
+ fallbackBottomHits = scrollResult.atBottom ? fallbackBottomHits + 1 : 0;
369
+ if (fallbackBottomHits >= fallbackBottomLimit && exhaustedScrolls >= Math.ceil(exhaustedScrollLimit / 2)) {
370
+ summary.exhausted = true;
371
+ this.logger.log('列表滚动终止:未发现“没有更多了”标识,但已多次触底且无可处理候选人,判定为 exhausted。');
372
+ break;
373
+ }
347
374
  if (exhaustedScrolls >= exhaustedScrollLimit) {
348
375
  summary.exhausted = true;
349
- this.logger.log('列表滚动终止:连续无可处理候选人,判定为 exhausted。');
376
+ this.logger.log('列表滚动终止:连续无可处理候选人达到保护上限,判定为 exhausted。');
350
377
  break;
351
378
  }
352
379
  await this.interaction.sleepRange(920, 260);
@@ -354,6 +381,8 @@ export class BossChatApp {
354
381
  }
355
382
 
356
383
  exhaustedScrolls = 0;
384
+ noMoreMarkerHits = 0;
385
+ fallbackBottomHits = 0;
357
386
  this.logger.log(
358
387
  `准备处理候选人:name=${nextCustomer.name || '未知'} | key=${nextCustomer.customerKey} | job=${nextCustomer.sourceJob || '未知'} | domIndex=${nextCustomer.domIndex}`,
359
388
  );
@@ -578,6 +607,8 @@ export class BossChatApp {
578
607
  majors: Array.isArray(resumeProfile.majors) ? resumeProfile.majors : [],
579
608
  company: resumeProfile.company || '',
580
609
  position: resumeProfile.position || '',
610
+ resumeTextLength: String(resumeProfile.resumeText || '').length,
611
+ evidenceCorpusLength: String(resumeProfile.evidenceCorpus || '').length,
581
612
  };
582
613
  } else {
583
614
  this.logger.log(`简历结构化提取未命中:${resumeProfile?.error || 'unknown'}`);
@@ -638,6 +669,8 @@ export class BossChatApp {
638
669
  company: resumeProfile.company || '',
639
670
  position: resumeProfile.position || '',
640
671
  } : null,
672
+ resumeText: resumeProfile?.ok ? String(resumeProfile.resumeText || '') : '',
673
+ evidenceCorpus: resumeProfile?.ok ? String(resumeProfile.evidenceCorpus || '') : '',
641
674
  },
642
675
  imagePath: capture.stitchedImage,
643
676
  });
@@ -647,13 +680,37 @@ export class BossChatApp {
647
680
  `评估理由学校字段已按主简历纠偏:rawReason=${evaluation.reason} | finalReason=${finalReason}`,
648
681
  );
649
682
  }
683
+ if (evaluation.evidenceGateDemoted === true) {
684
+ this.logger.log(
685
+ `证据闸门降级:rawPassed=${Boolean(evaluation.rawPassed)} | evidenceRawCount=${Number(evaluation.evidenceRawCount || 0)} | evidenceMatchedCount=${Number(evaluation.evidenceMatchedCount || 0)} | mode=${evaluation.evaluationMode || 'unknown'}`,
686
+ );
687
+ }
650
688
  this.logger.log(
651
- `LLM评估完成:passed=${evaluation.passed} | reason=${finalReason}`,
689
+ `LLM评估完成:passed=${evaluation.passed} | rawPassed=${Boolean(evaluation.rawPassed)} | mode=${evaluation.evaluationMode || 'unknown'} | reason=${finalReason}`,
652
690
  );
653
691
 
654
692
  baseResult.reason = finalReason;
655
693
  baseResult.passed = evaluation.passed;
656
694
  baseResult.decision = evaluation.passed ? 'passed' : 'skipped';
695
+ baseResult.artifacts.rawPassed = Boolean(evaluation.rawPassed);
696
+ baseResult.artifacts.finalPassed = Boolean(evaluation.passed);
697
+ baseResult.artifacts.evidenceRawCount = Number.isFinite(Number(evaluation.evidenceRawCount))
698
+ ? Number(evaluation.evidenceRawCount)
699
+ : 0;
700
+ baseResult.artifacts.evidenceMatchedCount = Number.isFinite(Number(evaluation.evidenceMatchedCount))
701
+ ? Number(evaluation.evidenceMatchedCount)
702
+ : 0;
703
+ baseResult.artifacts.evidenceGateDemoted = evaluation.evidenceGateDemoted === true;
704
+ baseResult.artifacts.evaluationMode = String(evaluation.evaluationMode || '');
705
+ baseResult.artifacts.evaluationChunkIndex = Number.isFinite(Number(evaluation.chunkIndex))
706
+ ? Number(evaluation.chunkIndex)
707
+ : null;
708
+ baseResult.artifacts.evaluationChunkTotal = Number.isFinite(Number(evaluation.chunkTotal))
709
+ ? Number(evaluation.chunkTotal)
710
+ : null;
711
+ baseResult.artifacts.evaluationEvidence = Array.isArray(evaluation.evidence)
712
+ ? evaluation.evidence.slice(0, 5).map((item) => String(item || '').trim()).filter(Boolean)
713
+ : [];
657
714
 
658
715
  await this.checkpoint();
659
716
  const closeResult =