@reconcrap/boss-recommend-mcp 2.1.14 → 2.1.16

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 CHANGED
@@ -176,7 +176,7 @@ boss-recommend-mcp schedule-status --schedule-id <schedule_id>
176
176
  - 不会对每位候选人重复确认
177
177
  - 推荐页详情处理完成后,会强制关闭详情页并确认已关闭
178
178
  - 简历提取优先使用 Network 响应;没有可解析 Network CV 时,回退到完整滚动截图序列再交给多模态模型判断
179
- - recommend / search / chat 正式运行默认全部使用 `screening-config.json` 配置的 LLM 筛选;deterministic/local scorer 只保留给明确测试场景,必须显式传 `debug_test_mode=true` 且 `screening_mode=deterministic` 或 `use_llm=false`。
179
+ - recommend / search / 带 `criteria` 的 chat 正式运行默认全部使用 `screening-config.json` 配置的 LLM 筛选;chat 的 `criteria` 留空时进入收集简历模式,不需要 LLM 配置。deterministic/local scorer 只保留给明确测试场景,必须显式传 `debug_test_mode=true` 且 `screening_mode=deterministic` 或 `use_llm=false`。
180
180
  - `detail_limit=0`、`no_filter`、`filter_enabled=false`、后置动作 dry-run、chat 求简历 dry-run 等调试路径不会在正式 live run 默认启用;需要测试时必须显式传 `debug_test_mode=true`。
181
181
  - 提供显式运维自愈工具:只在手动调用 `run_recommend_self_heal` 时运行,不会接入正常 run / doctor / preflight 自动链路
182
182
  - 运行前会自动做依赖体检(Node.js、Python、Pillow、`chrome-remote-interface`、`ws`),缺失时会在 `doctor` 与流水线失败诊断中明确提示
@@ -391,6 +391,35 @@ config/screening-config.example.json
391
391
  - recommend / search / chat 图片简历 fallback 与主列表滚动都会在启用 `listScrollJitter` 时使用 coverage-safe scroll jitter:每次 wheel delta 在安全范围内变化,并保留截图重叠、重复检测、bottom-marker / stop-boundary 逻辑,实际 delta 和 settle 时间会写入 artifact metadata。
392
392
  - chat/recommend/search run 也兼容显式参数 `safe_pacing`、`batch_rest_enabled` 与 `human_behavior.restLevel`:run 参数优先于配置文件。AI harness/skill 启动每次 run 前必须让用户明确选择 `low/medium/high`,再把选择写入 `human_behavior.restLevel`。
393
393
 
394
+ ### 离线筛选延迟 benchmark
395
+
396
+ 可以用已保存的 recommend run JSON 和截图证据离线评估筛选策略,不打开 Boss、不重新点击候选人,也不改变 `简历来源`。benchmark 会读取每个 saved run 自己的 `result.screen_params.criteria`,缺失时再回退到 run context / instruction,避免用同一套 criteria 误测不同岗位。
397
+
398
+ ```bash
399
+ npm run benchmark:screening -- --dry-run
400
+ npm run benchmark:screening -- --max-candidates 20
401
+ ```
402
+
403
+ 默认会自动选择 `~/.boss-recommend-mcp/runs` 下最近 4 个仍有 `detail.image_evidence.llm_file_paths` 的 recommend run,并把输出写到 `.live-artifacts/screening-benchmark/<timestamp>/`。真实 LLM replay 会复用当前 `screening-config.json`;如需指定配置或 run:
404
+
405
+ ```bash
406
+ npm run benchmark:screening -- --config C:/Users/yaolin/.boss-recommend-mcp/screening-config.json --run mcp_recommend_mq92ltt5_3x9liodw
407
+ ```
408
+
409
+ 内置策略:
410
+
411
+ - `oracle_full_image_high`:完整图片筛选,`llmThinkingLevel=high`,作为 pass/fail 质量基准。
412
+ - `baseline_full_image_reasoning`:完整图片筛选,`llmThinkingLevel=low`,用于衡量当前低思考 baseline。
413
+ - `extract_then_reason`:先从图片抽取结构化简历事实,再用文本 reasoning 对每位候选人做最终判断;抽取/判断异常会升级到完整图片筛选。
414
+ - `extract_hard_gate_then_reason`:抽取后先做 cheap hard-fail gate;只有明确违反硬条件/排除项时直接 `passed=false`,否则继续文本 reasoning。
415
+ - `batch_extract_then_reason`:批量抽取多个候选人的结构化事实,再逐个文本 reasoning。
416
+ - `batch_extract_hard_gate_then_reason`:批量抽取后逐个执行 hard-fail gate,明显不合适的候选人不再进入后续 reasoning。
417
+ - `pipeline_simulation`:基于 saved timings 估算浏览器采集和 LLM 重叠后的理论下界;只用于判断是否值得后续改 workflow。
418
+
419
+ 为避免把“输出解释”本身的耗时算进策略,benchmark-native 的抽取/判断提示词不会要求模型输出筛选理由、summary 或 CoT;hard-fail gate 只要求 `{"hard_fail": true/false, "continue_reasoning": true/false, "uncertain": true/false}`,判断阶段只要求 `{"passed": true/false, "uncertain": true/false}`。
420
+
421
+ 输出文件包括 `benchmark-summary.json`、`benchmark-results.json`、`benchmark-results.csv`、`benchmark-disagreements.csv` 和 `benchmark-manifest.json`。所有非 oracle 策略都会和 `oracle_full_image_high` 对比 false negative / false positive / pass-rate drift,并用 `saved_non_llm_ms + benchmark_llm_ms` 计算 projected total time;hard-gate 策略会额外报告 `gate_ms` 和 `early_exit_count`;默认 eligibility 目标是平均 `<30000ms` 且 false negative 为 0。
422
+
394
423
  ## 常用命令
395
424
 
396
425
  npm 包安装后可直接使用可执行命令 `boss-recommend-mcp`。以下示例展示源码模式(`node src/cli.js`):
@@ -433,7 +462,7 @@ node src/cli.js chat prepare-run --slow-live --port 9222
433
462
 
434
463
  说明:
435
464
 
436
- - `criteria` / `start_from` / `target_count` 为必填
465
+ - `start_from` / `target_count` 为必填;`criteria` 可选,留空时 chat run 进入收集简历模式:不触发 LLM 筛选,针对没有在线/附件简历的人选发送求简历消息并点击求简历按钮
437
466
  - `greeting_text` 可选(兼容 `greetingText`)
438
467
  - `profile` 可选,默认 `default`
439
468
  - `job` 与 `port` 继承 recommend run 已选岗位和调试端口
@@ -468,15 +497,15 @@ node src/cli.js chat prepare-run --slow-live --port 9222
468
497
  chat-only 交互建议:
469
498
 
470
499
  - 先调用一次 `list_boss_chat_jobs` 或 `prepare_boss_chat_run`(可不带参数),服务会先导航到 `https://www.zhipin.com/web/chat/index` 并返回 `NEED_INPUT`,其中包含岗位 `job_options` 与待补字段。
471
- - 然后基于 `job_options` 让用户选择 `job`,并补齐 `start_from`、`target_count`、`criteria` 后调用 `start_boss_chat_run` 启动任务。
500
+ - 然后基于 `job_options` 让用户选择 `job`,并补齐 `start_from`、`target_count` 后调用 `start_boss_chat_run` 启动任务;若要筛选再求简历,提供 `criteria`,若只想收集缺失简历则留空。
472
501
  - `greeting_text` 可选;未传时使用 `screening-config.json.greetingMessage`,若未配置则使用默认招呼语(`Hi同学,能麻烦发下简历吗?`)。
473
502
  - `target_count` 支持正整数、`all`、`-1`;若用户给出 `全部候选人` / `所有候选人`,会自动按不限(扫到底)处理。
474
503
 
475
504
  Trae-CN / 长对话防循环建议:
476
505
 
477
- - 固定流程:`boss_chat_health_check` -> `list_boss_chat_jobs(空参可)` / `prepare_boss_chat_run(空参可)` -> 一次性补齐 `job/start_from/target_count/criteria` -> `start_boss_chat_run`。
506
+ - 固定流程:`boss_chat_health_check` -> `list_boss_chat_jobs(空参可)` / `prepare_boss_chat_run(空参可)` -> 一次性补齐 `job/start_from/target_count`,筛选任务再带 `criteria` -> `start_boss_chat_run`。
478
507
  - chat-only 场景严禁调用 `list_recommend_jobs`、`run_recommend` 或 `start_recommend_pipeline_run`。
479
- - `start_boss_chat_run` 的工具 schema 已把 `job/start_from/target_count/criteria` 标记为必填;不要用它获取岗位列表。
508
+ - `start_boss_chat_run` 的工具 schema 已把 `job/start_from/target_count` 标记为必填;`criteria` 留空会进入收集简历模式。不要用它获取岗位列表。
480
509
  - 若 `pending_questions` / UI 选项里出现“扫到底(必须传 `target_count="all"`)”,下一次工具调用请直接照抄 `"target_count": "all"`,不要只保留“扫到底”这层自然语言语义。
481
510
  - `start_boss_chat_run` 返回 `ACCEPTED` 后直接结束当前回合,不要自动轮询。
482
511
  - 缺参或校验失败时,一次性列出全部缺失/错误项,避免重复同一句提示触发宿主“陷入循环”保护。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reconcrap/boss-recommend-mcp",
3
- "version": "2.1.14",
3
+ "version": "2.1.16",
4
4
  "description": "Unified MCP pipeline for recommend-page filtering and screening on Boss Zhipin",
5
5
  "keywords": [
6
6
  "boss",
@@ -76,14 +76,15 @@
76
76
  "live:chat-mcp": "node scripts/live-chat-mcp-smoke.js",
77
77
  "live:cv-capture-target": "node scripts/live-cv-capture-target-smoke.js",
78
78
  "live:chat-phase10-full": "node scripts/live-chat-phase10-full.js",
79
- "live:chat-image-screening": "node scripts/live-chat-image-screening-smoke.js"
79
+ "live:chat-image-screening": "node scripts/live-chat-image-screening-smoke.js",
80
+ "benchmark:screening": "node scripts/benchmark-screening-strategies.js"
80
81
  },
81
82
  "files": [
82
- "bin",
83
- "config/screening-config.example.json",
84
- "skills",
85
- "scripts/install-macos.sh",
86
- "scripts/postinstall.cjs",
83
+ "bin",
84
+ "config/screening-config.example.json",
85
+ "skills",
86
+ "scripts/install-macos.sh",
87
+ "scripts/postinstall.cjs",
87
88
  "src/core",
88
89
  "src/domains",
89
90
  "src/chat-mcp.js",
@@ -17,7 +17,7 @@ Please run a Boss chat-only task (do not switch to recommend flow).
17
17
  Execution order:
18
18
  1) Call boss_chat_health_check.
19
19
  2) Call list_boss_chat_jobs once (empty params allowed), or prepare_boss_chat_run once, to fetch job_options and missing fields.
20
- 3) Ask for these required fields in one shot: job, start_from (unread/all), target_count, criteria, rest_level (low/medium/high).
20
+ 3) Ask for these required fields in one shot: job, start_from (unread/all), target_count, rest_level (low/medium/high). Ask for criteria only when the user wants screening; leave it blank for collect-CV runs.
21
21
  4) After user reply, call start_boss_chat_run exactly once to start the run.
22
22
  5) If ACCEPTED, reply only with run_id and "task started"; no auto polling.
23
23
 
@@ -26,7 +26,7 @@ Anti-loop rules:
26
26
  - On validation errors, list all missing/invalid fields once.
27
27
  - Do not call list_recommend_jobs for chat-only tasks; it is recommend-page only and will switch the browser to /web/chat/recommend.
28
28
  - Do not call run_recommend or start_recommend_pipeline_run for chat-only tasks; use start_boss_chat_run.
29
- - Do not use start_boss_chat_run for preflight. It is only for the final start call and must include job/start_from/target_count/criteria.
29
+ - Do not use start_boss_chat_run for preflight. It is only for the final start call and must include job/start_from/target_count. Include criteria for screening; leave criteria blank to collect CVs from candidates without an online/attachment CV.
30
30
  - Do not call start_boss_chat_run repeatedly in one turn.
31
31
  - Do not call get_boss_chat_run unless user explicitly asks for progress.
32
32
  - Do not choose rest_level for the user. Pass the user's choice as `human_behavior.restLevel`.
@@ -29,11 +29,11 @@ Trae/Trae-CN split-server config exposes these under the `boss-chat` MCP server.
29
29
  - `job`
30
30
  - `start_from`: `unread|all`
31
31
  - `target_count`
32
- - `criteria`
33
32
  - `rest_level`: `low|medium|high`
34
33
 
35
34
  可选:
36
35
 
36
+ - `criteria`(留空时进入收集简历模式:不触发 LLM 筛选,只向没有在线/附件简历的人选求简历)
37
37
  - `profile`(默认 `default`)
38
38
  - `greeting_text`(兼容 `greetingText`,可选自定义首条打招呼消息)
39
39
  - `port`
@@ -62,7 +62,7 @@ Trae/Trae-CN split-server config exposes these under the `boss-chat` MCP server.
62
62
 
63
63
  ## Hard Rules
64
64
 
65
- - LLM 配置必须复用 `boss-recommend-mcp` 的 `screening-config.json`;不要再向用户单独要 `baseUrl/apiKey/model`。
65
+ - 有筛选 `criteria` 时,LLM 配置必须复用 `boss-recommend-mcp` 的 `screening-config.json`;不要再向用户单独要 `baseUrl/apiKey/model`。`criteria` 留空的收集简历模式不需要 LLM 配置。
66
66
  - 路由护栏(强制):
67
67
  - 只在用户明确是 chat-only 任务时使用本 skill。
68
68
  - 只要用户提到推荐页、先找人后沟通、或需要推荐筛选阶段,禁止直接调用 `start_boss_chat_run`;必须先交给 `boss-recommend-pipeline` 完成推荐页任务。
@@ -70,7 +70,7 @@ Trae/Trae-CN split-server config exposes these under the `boss-chat` MCP server.
70
70
  - 启动或准备 chat run 时,若本机默认 `127.0.0.1:9222` Chrome DevTools 端口不可连,工具会自动打开 Chrome 并导航到 `https://www.zhipin.com/web/chat/index`。
71
71
  - 只有工具返回 `BOSS_LOGIN_REQUIRED` / `requires_login=true` 时,才要求用户在自动打开的 Chrome 窗口人工登录 Boss 后重试;不要把“没开 9222 Chrome”当作缺参。
72
72
  - 若本机找不到 Chrome,可提示用户设置 `BOSS_MCP_CHROME_PATH` 或 `BOSS_RECOMMEND_CHROME_PATH`;非本机 debug host 不自动启动。
73
- - `job` / `start_from` / `criteria` 缺一不可;缺参时只补缺口。
73
+ - `job` / `start_from` 缺一不可;`criteria` 可留空,留空时表示收集缺失简历而不是筛选。缺参时只补缺口。
74
74
  - `target_count` 在 chat-only 启动前也是必填项,不能默认省略。
75
75
  - chat-only 岗位列表只能通过 `list_boss_chat_jobs` 或 `prepare_boss_chat_run` 获取;严禁调用 `list_recommend_jobs`,因为它会切到推荐页。
76
76
  - chat-only 启动只能调用 `start_boss_chat_run`;严禁调用 `run_recommend` 或 `start_recommend_pipeline_run`。
@@ -80,10 +80,10 @@ Trae/Trae-CN split-server config exposes these under the `boss-chat` MCP server.
80
80
  - 当用户选择“扫到底/全部候选人/所有候选人”时,调用参数优先写:`"target_count": "all"`;`-1` 只作为兼容输入和内部 CLI 表示。
81
81
  - `greeting_text` 是可选项,不能因为缺少它阻塞启动或追加必填追问。
82
82
  - 若工具或提问选项里出现“扫到底(必须传 `target_count=\"all\"`)”之类字样,下一次工具调用时必须直接照抄这个字面量,不要只保留“扫到底”语义。
83
- - 禁止 agent 自行补全 `job/start_from/criteria` 并直接执行,必须由用户明确给出或确认。
83
+ - 禁止 agent 自行补全 `job/start_from` 并直接执行,必须由用户明确给出或确认。`criteria` 只有在用户要筛选时才必须明确给出;用户要收集简历时可留空。
84
84
  - chat-only 启动流程必须先进入聊天页并拉取岗位列表,再让用户从列表中选择 `job`。
85
85
  - 必须先用空参调用 `list_boss_chat_jobs` 或 `prepare_boss_chat_run` 获取 `job_options`;不要用 `start_boss_chat_run` 做预备调用。
86
- - `start_boss_chat_run` 只能用于真正启动,必须一次性传齐 `job` / `start_from` / `target_count` / `criteria`。
86
+ - `start_boss_chat_run` 只能用于真正启动,必须一次性传齐 `job` / `start_from` / `target_count`;`criteria` 留空会进入收集简历模式。
87
87
  - 若 `start_boss_chat_run` 返回 `NEED_INPUT` 且 `missing_fields` 包含 `target_count`,说明你没有把用户选择写入工具参数;下一次调用必须照 `next_call_example` 原样补上 `"target_count": "all"` 或正整数,不要重复空调用。
88
88
  - 默认不自动轮询;只有用户要求查进度时才调用 `get_boss_chat_run`。
89
89
  - `start_boss_chat_run` 返回 `ACCEPTED` 后,默认立即结束当前回合,不得主动连续调用 `get_boss_chat_run`。
@@ -95,12 +95,12 @@ Trae/Trae-CN split-server config exposes these under the `boss-chat` MCP server.
95
95
 
96
96
  - 若用户先运行了 recommend 流水线,并在手动状态检查时确认 recommend 已完成,且用户目标是“立即进入聊天沟通”:
97
97
  - 先调用 `prepare_boss_chat_run` 获取聊天页岗位列表与缺参。
98
- - 显式确认 `job/start_from/target_count/criteria` 后再调用 `start_boss_chat_run`。
98
+ - 显式确认 `job/start_from/target_count` 后再调用 `start_boss_chat_run`;若要继续筛选再求简历,还要确认 `criteria`。
99
99
  - 不要查找或依赖 `follow_up.chat`;该自动衔接路径属于 legacy-only 行为。
100
100
 
101
101
  ## Response Style
102
102
 
103
103
  - 用结构化中文。
104
104
  - 首轮建议先调用一次 `list_boss_chat_jobs`(可空参)或 `prepare_boss_chat_run`(可空参)获取 `job_options` 与 `pending_questions`。
105
- - 缺参时必须逐项确认:`job`(来自岗位列表)、`start_from`(`unread|all`)、`target_count`、`criteria`、`rest_level`。
105
+ - 缺参时必须逐项确认:`job`(来自岗位列表)、`start_from`(`unread|all`)、`target_count`、`rest_level`;只有用户要筛选时才追问 `criteria`。
106
106
  - 若健康检查失败,明确提示共享配置文件 `screening-config.json` 不可用。
@@ -20,6 +20,8 @@ Trae/Trae-CN split-server config exposes these under the `boss-recruit` MCP serv
20
20
  - 继续:`resume_recruit_pipeline_run`
21
21
  - 取消:`cancel_recruit_pipeline_run`
22
22
 
23
+ If the visible tool surface only offers `boss-recommend/*` for a search/recruit task, stop and report a tool-surface/config error. Do not call `boss-recommend/list_recommend_jobs`, `boss-recommend/run_recommend`, or `boss-recommend/start_recommend_pipeline_run` as a fallback for search.
24
+
23
25
  ## Hard Rules
24
26
 
25
27
  - 只在用户明确说搜索页、search、recruit、招聘搜索、`/web/chat/search` 时使用本 skill。
@@ -31,9 +33,14 @@ Trae/Trae-CN split-server config exposes these under the `boss-recruit` MCP serv
31
33
  - 只有工具返回 `BOSS_LOGIN_REQUIRED` / `requires_login=true` 时,才要求用户在自动打开的 Chrome 窗口人工登录 Boss 后重试;不要把“没开 9222 Chrome”当作缺参。
32
34
  - 若本机找不到 Chrome,可提示用户设置 `BOSS_MCP_CHROME_PATH` 或 `BOSS_RECOMMEND_CHROME_PATH`;非本机 debug host 不自动启动。
33
35
  - 若用户未提供岗位,必须先询问岗位。搜索页岗位选择在关键词输入框旁边;不要猜测默认岗位。
36
+ - 搜索页任务不要调用 `list_recommend_jobs` 获取岗位;推荐页岗位列表和搜索页岗位选择不是同一个工具面。用户已经给出岗位时直接传 `overrides.job`。
34
37
  - 若用户提供城市、学历、学校、关键词、过滤已看、人选目标数、筛选条件等参数,必须逐项传入或确认。
35
38
  - 搜索页和推荐页一样支持多选筛选条件;不要把多选降级成单选。
36
39
  - 每次 run 必须明确询问用户本次休息强度 `rest_level`:`low`(旧策略)/ `medium`(约 5 小时或 700 人累计休息 30 分钟)/ `high`(约 5 小时或 700 人累计休息 1 小时);不得默认使用配置文件里的值替用户决定。
40
+ - 启动前展示一次包含岗位、关键词、城市、学历、学校标签、是否过滤已看、criteria、目标人数、后置动作和休息强度的总确认;用户确认后,`confirmation` 只需要 `{ "final_confirmed": true }`。
41
+ - 不要让工具重写用户的 `criteria`。用户给出 `筛选条件` / `筛选标准` / `硬条件` 时,逐字写入 `overrides.criteria`;不要传系统简化版。
42
+ - 用户说学校类型“不限”时,在 `overrides.school_tag` 显式传 `"不限"` 或在 `overrides.schools` 传 `[]`;不要因为 criteria 里出现 `985/211/双一流` 就把它们当作搜索页学校过滤器。
43
+ - 用户说只看未查看“不限”时,在 `overrides.recent_not_view` 显式传 `"不限"` 或在 `overrides.filter_recent_viewed` 传 `false`。
37
44
 
38
45
  ## Required Inputs
39
46
 
@@ -51,10 +58,25 @@ Trae/Trae-CN split-server config exposes these under the `boss-recruit` MCP serv
51
58
  - `recent_not_view`
52
59
  - `port`
53
60
 
61
+ ## Confirmation Flow
62
+
63
+ 1. 先从用户原始消息里提取参数;缺什么只问什么。不要先调用 MCP 让工具猜缺参。
64
+ 2. `criteria` 必须来自用户明确提供的筛选条件,或来自你向用户追问后得到的完整自然语言标准。禁止把岗位、关键词、学历等字段拼成 criteria。
65
+ 3. 如果用户一开始已经给齐 `job`、`keyword`、`criteria`、`target_count`、`rest_level`,并且可选筛选项也已明确或确认不限,直接展示一次总确认。
66
+ 4. 用户确认后调用 `boss-recruit/start_recruit_pipeline_run` 或 `boss-recruit/run_recruit_pipeline`,传:
67
+ - `confirmation: { "final_confirmed": true }`
68
+ - 所有规范化字段放在 `overrides`
69
+ - 完整 criteria 放在 `overrides.criteria`
70
+ - 本次休息强度放在 `human_behavior.restLevel`
71
+ 5. 工具返回 `NEED_INPUT` 时,按 `pending_questions` 只补具体缺口;不要接受或转述工具生成的简化 criteria。
72
+
54
73
  启动工具时,把用户确认的休息强度写入 `human_behavior.restLevel`,例如:
55
74
 
56
75
  ```json
57
- { "human_behavior": { "restLevel": "medium" } }
76
+ {
77
+ "confirmation": { "final_confirmed": true },
78
+ "human_behavior": { "restLevel": "medium" }
79
+ }
58
80
  ```
59
81
 
60
82
  ## Response Style
package/src/chat-mcp.js CHANGED
@@ -66,12 +66,11 @@ const RUN_MODE_ASYNC = "async";
66
66
  const DETACHED_WORKER_SCRIPT = fileURLToPath(new URL("./detached-worker.js", import.meta.url));
67
67
  const DETACHED_WORKER_POLL_MS = 1000;
68
68
 
69
- const CHAT_REQUIRED_FIELDS = Object.freeze([
70
- "job",
71
- "start_from",
72
- "target_count",
73
- "criteria"
74
- ]);
69
+ const CHAT_REQUIRED_FIELDS = Object.freeze([
70
+ "job",
71
+ "start_from",
72
+ "target_count"
73
+ ]);
75
74
 
76
75
  const TERMINAL_STATUSES = new Set([
77
76
  RUN_STATUS_COMPLETED,
@@ -757,8 +756,8 @@ function buildLegacyChatResult(snapshot) {
757
756
  };
758
757
  }
759
758
 
760
- function normalizeRunSnapshot(snapshot) {
761
- if (!snapshot) return null;
759
+ function normalizeRunSnapshot(snapshot) {
760
+ if (!snapshot) return null;
762
761
  const meta = getChatRunMeta(snapshot.runId);
763
762
  const artifacts = getChatRunArtifacts(snapshot.runId);
764
763
  const summary = snapshot.summary && typeof snapshot.summary === "object" ? snapshot.summary : null;
@@ -813,19 +812,61 @@ function normalizeRunSnapshot(snapshot) {
813
812
  },
814
813
  result: legacyResult,
815
814
  artifacts
816
- };
817
- }
818
-
819
- function persistChatRunSnapshot(snapshot, {
820
- persistActiveCheckpoint = false
821
- } = {}) {
822
- const normalized = normalizeRunSnapshot(snapshot);
823
- if (!normalized?.run_id) return normalized;
824
- const artifacts = getChatRunArtifacts(normalized.run_id);
825
- if (!artifacts) return normalized;
826
- if (persistActiveCheckpoint) {
827
- persistChatCheckpointSnapshot(normalized);
828
- }
815
+ };
816
+ }
817
+
818
+ function plainRecord(value) {
819
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
820
+ }
821
+
822
+ function mergePersistedChatControlRequest(normalized, existing) {
823
+ const control = {
824
+ ...(normalized?.control || {})
825
+ };
826
+ if (!normalized) return control;
827
+ const existingControl = plainRecord(existing?.control);
828
+ if (TERMINAL_STATUSES.has(normalized.state)) return control;
829
+ if (existingControl.cancel_requested === true) {
830
+ return {
831
+ ...control,
832
+ pause_requested: true,
833
+ pause_requested_at: existingControl.pause_requested_at || control.pause_requested_at || new Date().toISOString(),
834
+ pause_requested_by: existingControl.pause_requested_by || control.pause_requested_by || "cancel_boss_chat_run",
835
+ cancel_requested: true
836
+ };
837
+ }
838
+ if (existingControl.pause_requested === true && normalized.state !== RUN_STATUS_PAUSED) {
839
+ return {
840
+ ...control,
841
+ pause_requested: true,
842
+ pause_requested_at: existingControl.pause_requested_at || control.pause_requested_at || new Date().toISOString(),
843
+ pause_requested_by: existingControl.pause_requested_by || control.pause_requested_by || "pause_boss_chat_run"
844
+ };
845
+ }
846
+ if (existingControl.pause_requested === false && normalized.state === RUN_STATUS_PAUSED) {
847
+ return {
848
+ ...control,
849
+ pause_requested: false,
850
+ pause_requested_at: null,
851
+ pause_requested_by: null,
852
+ cancel_requested: false
853
+ };
854
+ }
855
+ return control;
856
+ }
857
+
858
+ function persistChatRunSnapshot(snapshot, {
859
+ persistActiveCheckpoint = false
860
+ } = {}) {
861
+ const normalized = normalizeRunSnapshot(snapshot);
862
+ if (!normalized?.run_id) return normalized;
863
+ const artifacts = getChatRunArtifacts(normalized.run_id);
864
+ if (!artifacts) return normalized;
865
+ const existing = readJsonFile(artifacts.run_state_path);
866
+ normalized.control = mergePersistedChatControlRequest(normalized, existing);
867
+ if (persistActiveCheckpoint) {
868
+ persistChatCheckpointSnapshot(normalized);
869
+ }
829
870
  const payload = {
830
871
  run_id: normalized.run_id,
831
872
  mode: normalized.mode,
@@ -1097,14 +1138,13 @@ function buildChatNextCallExample(args, missingFields, normalized) {
1097
1138
  return Object.keys(example).length ? example : null;
1098
1139
  }
1099
1140
 
1100
- function getMissingChatStartFields(args = {}, normalized = normalizeChatStartInput(args)) {
1101
- const missing = [];
1102
- if (!normalized.job) missing.push("job");
1103
- if (!["unread", "all"].includes(normalized.startFrom)) missing.push("start_from");
1104
- if (!normalized.target.provided || normalized.target.parseError) missing.push("target_count");
1105
- if (!normalized.criteria) missing.push("criteria");
1106
- return missing;
1107
- }
1141
+ function getMissingChatStartFields(args = {}, normalized = normalizeChatStartInput(args)) {
1142
+ const missing = [];
1143
+ if (!normalized.job) missing.push("job");
1144
+ if (!["unread", "all"].includes(normalized.startFrom)) missing.push("start_from");
1145
+ if (!normalized.target.provided || normalized.target.parseError) missing.push("target_count");
1146
+ return missing;
1147
+ }
1108
1148
 
1109
1149
  function buildTargetCountDiagnostics(args, missingFields, normalized) {
1110
1150
  if (!missingFields.includes("target_count")) return {};
@@ -1168,17 +1208,10 @@ function buildPendingChatQuestions({ args, missingFields, normalized, jobOptions
1168
1208
  parse_error: normalized.target.parseError || null
1169
1209
  };
1170
1210
  }
1171
- if (field === "criteria") {
1172
- return {
1173
- field,
1174
- question: "请提供自然语言筛选 criteria。",
1175
- value: normalized.criteria || null
1176
- };
1177
- }
1178
- return {
1179
- field,
1180
- question: `请提供 ${field}。`,
1181
- value: null
1211
+ return {
1212
+ field,
1213
+ question: `请提供 ${field}。`,
1214
+ value: null
1182
1215
  };
1183
1216
  });
1184
1217
  }
@@ -1186,19 +1219,21 @@ function buildPendingChatQuestions({ args, missingFields, normalized, jobOptions
1186
1219
  async function buildNeedInputResponse({ args, missingFields, normalized }) {
1187
1220
  const diagnostics = buildTargetCountDiagnostics(args, missingFields, normalized);
1188
1221
  return {
1189
- status: "NEED_INPUT",
1190
- required_fields: CHAT_REQUIRED_FIELDS.slice(),
1191
- missing_fields: missingFields,
1192
- ...diagnostics,
1222
+ status: "NEED_INPUT",
1223
+ required_fields: CHAT_REQUIRED_FIELDS.slice(),
1224
+ missing_fields: missingFields,
1225
+ criteria_optional: true,
1226
+ empty_criteria_mode: "collect_cv",
1227
+ ...diagnostics,
1193
1228
  pending_questions: buildPendingChatQuestions({ args, missingFields, normalized }),
1194
1229
  job_options: [],
1195
- error: {
1196
- code: "MISSING_REQUIRED_FIELDS",
1197
- message: "缺少必要字段。请补齐 job、start_from、target_count、criteria 后再启动 Boss chat CDP-only run。",
1198
- retryable: true
1199
- }
1200
- };
1201
- }
1230
+ error: {
1231
+ code: "MISSING_REQUIRED_FIELDS",
1232
+ message: "缺少必要字段。请补齐 job、start_from、target_count 后再启动 Boss chat CDP-only run。criteria 可留空;留空时会进入收集简历模式。",
1233
+ retryable: true
1234
+ }
1235
+ };
1236
+ }
1202
1237
 
1203
1238
  function shouldRequestChatResume(args = {}, context = {}) {
1204
1239
  const action = normalizeText(args.post_action || args.action).toLowerCase();
@@ -1232,38 +1267,40 @@ function isDebugTestMode(args = {}) {
1232
1267
  return args.debug_test_mode === true || args.allow_debug_test_mode === true;
1233
1268
  }
1234
1269
 
1235
- function normalizeScreeningModeArg(args = {}) {
1236
- const raw = normalizeText(args.screening_mode || args.screeningMode || "");
1237
- if (args.use_llm === false) return "deterministic";
1238
- return ["deterministic", "local", "local_scorer"].includes(raw.toLowerCase())
1239
- ? "deterministic"
1240
- : "llm";
1241
- }
1242
-
1243
- function collectChatDebugTestOptions(args = {}) {
1244
- const reasons = [];
1245
- if (normalizeScreeningModeArg(args) === "deterministic") reasons.push("deterministic_screening");
1246
- if (parseNonNegativeInteger(args.detail_limit, null) === 0) reasons.push("detail_limit=0");
1247
- if (args.dry_run === true || args.dry_run_request_cv === true) reasons.push("dry_run_request_cv");
1248
- return reasons;
1249
- }
1250
-
1251
- function shouldUseChatLlm(args = {}) {
1252
- return normalizeScreeningModeArg(args) !== "deterministic";
1253
- }
1254
-
1255
- function getRunOptions(args, normalized, session, { workspaceRoot = "", configResolution = null } = {}) {
1270
+ function normalizeScreeningModeArg(args = {}, normalized = normalizeChatStartInput(args)) {
1271
+ if (!normalized.criteria) return "collect_cv";
1272
+ const raw = normalizeText(args.screening_mode || args.screeningMode || "");
1273
+ if (args.use_llm === false) return "deterministic";
1274
+ return ["deterministic", "local", "local_scorer"].includes(raw.toLowerCase())
1275
+ ? "deterministic"
1276
+ : "llm";
1277
+ }
1278
+
1279
+ function collectChatDebugTestOptions(args = {}) {
1280
+ const reasons = [];
1281
+ if (normalizeScreeningModeArg(args) === "deterministic") reasons.push("deterministic_screening");
1282
+ if (parseNonNegativeInteger(args.detail_limit, null) === 0) reasons.push("detail_limit=0");
1283
+ if (args.dry_run === true || args.dry_run_request_cv === true) reasons.push("dry_run_request_cv");
1284
+ return reasons;
1285
+ }
1286
+
1287
+ function shouldUseChatLlm(args = {}, normalized = normalizeChatStartInput(args)) {
1288
+ return normalizeScreeningModeArg(args, normalized) === "llm";
1289
+ }
1290
+
1291
+ function getRunOptions(args, normalized, session, { workspaceRoot = "", configResolution = null } = {}) {
1256
1292
  const slowLive = args.slow_live === true;
1257
1293
  const isAllTarget = normalized.publicTargetCount === "all";
1258
1294
  const processedLimit = parsePositiveInteger(
1259
1295
  args.max_candidates,
1260
1296
  isAllTarget ? CHAT_ALL_MAX_CANDIDATES : CHAT_ALL_MAX_CANDIDATES
1261
- );
1262
- const shouldRequestResume = shouldRequestChatResume(args);
1263
- const useLlm = shouldUseChatLlm(args);
1264
- const resolvedConfig = configResolution || (useLlm ? resolveBossScreeningConfig(workspaceRoot) : { ok: false });
1265
- const humanBehavior = resolveHumanBehaviorForRun(args, resolvedConfig?.config || {});
1266
- return {
1297
+ );
1298
+ const shouldRequestResume = shouldRequestChatResume(args);
1299
+ const screeningMode = normalizeScreeningModeArg(args, normalized);
1300
+ const useLlm = shouldUseChatLlm(args, normalized);
1301
+ const resolvedConfig = configResolution || (useLlm ? resolveBossScreeningConfig(workspaceRoot) : { ok: false });
1302
+ const humanBehavior = resolveHumanBehaviorForRun(args, resolvedConfig?.config || {});
1303
+ return {
1267
1304
  client: session.client,
1268
1305
  targetUrl: CHAT_TARGET_URL,
1269
1306
  job: normalized.job,
@@ -1302,7 +1339,7 @@ function getRunOptions(args, normalized, session, { workspaceRoot = "", configRe
1302
1339
  llmImageDetail: normalizeText(
1303
1340
  args.llm_image_detail || resolvedConfig.config?.llmImageDetail || resolvedConfig.config?.imageDetail
1304
1341
  ) || "low",
1305
- screeningMode: normalizeScreeningModeArg(args),
1342
+ screeningMode,
1306
1343
  listMaxScrolls: parsePositiveInteger(args.list_max_scrolls, 200),
1307
1344
  listStableSignatureLimit: parsePositiveInteger(args.list_stable_signature_limit, 2),
1308
1345
  listWheelDeltaY: parsePositiveInteger(args.list_wheel_delta_y, 850),
@@ -1373,7 +1410,7 @@ async function startBossChatRunInternal(args = {}, { workspaceRoot = "", runId =
1373
1410
  }
1374
1411
 
1375
1412
  const shouldRequestResume = shouldRequestChatResume(args);
1376
- const useLlm = shouldUseChatLlm(args);
1413
+ const useLlm = shouldUseChatLlm(args, normalized);
1377
1414
  const debugTestOptions = collectChatDebugTestOptions(args);
1378
1415
  if (debugTestOptions.length && !isDebugTestMode(args)) {
1379
1416
  return {
@@ -1538,9 +1575,11 @@ export async function prepareBossChatRunTool({ workspaceRoot = "", args = {} } =
1538
1575
  status: missingFields.length ? "NEED_INPUT" : "READY",
1539
1576
  stage: "chat_run_setup",
1540
1577
  page_url: session.navigation?.url || session.target?.url || CHAT_TARGET_URL,
1541
- required_fields: CHAT_REQUIRED_FIELDS.slice(),
1542
- missing_fields: missingFields,
1543
- job_options: jobOptions,
1578
+ required_fields: CHAT_REQUIRED_FIELDS.slice(),
1579
+ missing_fields: missingFields,
1580
+ criteria_optional: true,
1581
+ empty_criteria_mode: "collect_cv",
1582
+ job_options: jobOptions,
1544
1583
  selected_job: selectedJob,
1545
1584
  selected_job_label: jobs?.selected_label || selectedJob?.label || "",
1546
1585
  job_options_source: jobs?.source || "",
@@ -1552,10 +1591,10 @@ export async function prepareBossChatRunTool({ workspaceRoot = "", args = {} } =
1552
1591
  jobOptions
1553
1592
  }),
1554
1593
  ...diagnostics,
1555
- ...(nextCallExample ? { next_call_example: nextCallExample } : {}),
1556
- message: missingFields.length
1557
- ? "已通过 CDP-only 读取 Boss 聊天页岗位列表,请补齐 job / start_from / target_count / criteria。"
1558
- : "Boss chat CDP-only preflight is ready. Use start_boss_chat_run to start screening.",
1594
+ ...(nextCallExample ? { next_call_example: nextCallExample } : {}),
1595
+ message: missingFields.length
1596
+ ? "已通过 CDP-only 读取 Boss 聊天页岗位列表,请补齐 job / start_from / target_count。criteria 可留空;留空会进入收集简历模式。"
1597
+ : "Boss chat CDP-only preflight is ready. Use start_boss_chat_run to start screening or collect CVs.",
1559
1598
  runtime_evaluate_used: false,
1560
1599
  method_summary: methodSummary(session.methodLog || []),
1561
1600
  method_log: session.methodLog || [],
@@ -1723,7 +1762,7 @@ export async function startBossChatDetachedRunTool({ workspaceRoot = "", args =
1723
1762
  });
1724
1763
  }
1725
1764
 
1726
- const useLlm = shouldUseChatLlm(args);
1765
+ const useLlm = shouldUseChatLlm(args, normalized);
1727
1766
  const debugTestOptions = collectChatDebugTestOptions(args);
1728
1767
  if (debugTestOptions.length && !isDebugTestMode(args)) {
1729
1768
  return {
@@ -52,3 +52,20 @@ export function assertGreetQuotaAvailable(source = "") {
52
52
  }
53
53
  return quota;
54
54
  }
55
+
56
+ export function describeGreetQuotaAfterSpend(source = "") {
57
+ const quota = normalizeGreetQuotaSource(source);
58
+ if (!quota.found || quota.numerator === null || quota.denominator === null) {
59
+ return {
60
+ ...quota,
61
+ remaining_after_spend: null,
62
+ exhausted_after_spend: false
63
+ };
64
+ }
65
+ const remaining = quota.denominator - quota.numerator;
66
+ return {
67
+ ...quota,
68
+ remaining_after_spend: remaining,
69
+ exhausted_after_spend: remaining < quota.numerator
70
+ };
71
+ }
@@ -50,6 +50,7 @@ const SEARCH_PARAM_ORDER = [
50
50
  "schools",
51
51
  "keyword",
52
52
  "filter_recent_viewed",
53
+ "skip_recent_colleague_contacted",
53
54
  "job",
54
55
  "start_from",
55
56
  "target_count",
@@ -60,7 +61,10 @@ const SCREEN_PARAM_ORDER = [
60
61
  "criteria",
61
62
  "target_count",
62
63
  "post_action",
63
- "max_greet_count"
64
+ "max_greet_count",
65
+ "skip_recent_colleague_contacted",
66
+ "colleague_contact_window_days",
67
+ "search_exchange_resume_filter_days"
64
68
  ];
65
69
 
66
70
  function normalizeText(value) {