@reconcrap/boss-recommend-mcp 0.1.0 → 0.1.2

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
@@ -19,7 +19,10 @@ MCP 工具名:`run_recommend_pipeline`
19
19
  ## 设计特点
20
20
 
21
21
  - 页面目标固定为 `https://www.zhipin.com/web/chat/recommend`
22
- - 支持推荐页原生筛选:学校标签 / 性别 / 近14天没有
22
+ - 支持推荐页原生筛选:学校标签 / 学历 / 性别 / 近14天没有
23
+ - 学历支持单选与多选语义:如“本科及以上”会展开为 `本科/硕士/博士`;如“大专、本科”只勾选这两项
24
+ - 执行前会逐项确认筛选参数:学校标签 / 学历 / 性别 / 是否过滤近14天已看
25
+ - npm 全局安装后会自动执行 install:生成 Codex skill、导出 MCP 模板,并自动尝试写入已检测到的外部 agent MCP 配置(含 Trae / trae-cn / Cursor / Claude / OpenClaw)
23
26
  - `post_action` 必须在每次完整运行开始时确认一次
24
27
  - `target_count` 会在每次运行开始时询问一次(可留空,不设上限)
25
28
  - 当 `post_action=greet` 时,必须在运行开始时确认 `max_greet_count`
@@ -41,6 +44,13 @@ npm install
41
44
  node src/cli.js start
42
45
  ```
43
46
 
47
+ 可选环境变量(用于跨 agent 自动配置):
48
+
49
+ ```bash
50
+ BOSS_RECOMMEND_MCP_CONFIG_TARGETS # JSON 数组或系统 path 分隔路径列表,指定额外 mcp.json 目标文件
51
+ BOSS_RECOMMEND_EXTERNAL_SKILL_DIRS # JSON 数组或系统 path 分隔路径列表,指定额外 skills 根目录
52
+ ```
53
+
44
54
  或使用 CLI fallback:
45
55
 
46
56
  ```bash
@@ -92,6 +102,10 @@ node src/cli.js run --instruction-file request.txt --confirmation-file confirmat
92
102
  "instruction": "推荐页筛选211女生,近14天没有,有 AI Agent 经验,符合标准的直接沟通",
93
103
  "confirmation": {
94
104
  "filters_confirmed": true,
105
+ "school_tag_confirmed": true,
106
+ "degree_confirmed": true,
107
+ "gender_confirmed": true,
108
+ "recent_not_view_confirmed": true,
95
109
  "criteria_confirmed": true,
96
110
  "target_count_confirmed": true,
97
111
  "target_count_value": 20,
@@ -102,6 +116,7 @@ node src/cli.js run --instruction-file request.txt --confirmation-file confirmat
102
116
  },
103
117
  "overrides": {
104
118
  "school_tag": "211",
119
+ "degree": ["本科", "硕士", "博士"],
105
120
  "gender": "女",
106
121
  "recent_not_view": "近14天没有",
107
122
  "criteria": "候选人需要有 AI Agent 或 MCP 工具开发经验",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reconcrap/boss-recommend-mcp",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Unified MCP pipeline for recommend-page filtering and screening on Boss Zhipin",
5
5
  "keywords": [
6
6
  "boss",
@@ -22,7 +22,10 @@ description: "Use when users ask to run Boss recommend-page filtering and screen
22
22
 
23
23
  在真正执行前,必须先确认:
24
24
 
25
- - 推荐页 filters 是否正确
25
+ - 学校标签(`school_tag`)
26
+ - 学历(`degree`)
27
+ - 性别(`gender`)
28
+ - 是否过滤近14天已看(`recent_not_view`)
26
29
  - screening criteria 是否正确
27
30
  - `target_count`(目标筛选人数)是否需要设置(可不设上限)
28
31
  - `post_action` 是否确定为 `favorite` 或 `greet`
@@ -41,6 +44,10 @@ description: "Use when users ask to run Boss recommend-page filtering and screen
41
44
  - `instruction` (required)
42
45
  - `confirmation`
43
46
  - `filters_confirmed`
47
+ - `school_tag_confirmed`
48
+ - `degree_confirmed`
49
+ - `gender_confirmed`
50
+ - `recent_not_view_confirmed`
44
51
  - `criteria_confirmed`
45
52
  - `target_count_confirmed`
46
53
  - `target_count_value` (integer, optional)
@@ -50,6 +57,7 @@ description: "Use when users ask to run Boss recommend-page filtering and screen
50
57
  - `max_greet_count_value` (integer)
51
58
  - `overrides`
52
59
  - `school_tag`
60
+ - `degree`(可传单值或数组;如“本科及以上”应展开为 `["本科","硕士","博士"]`)
53
61
  - `gender`
54
62
  - `recent_not_view`
55
63
  - `criteria`
@@ -90,5 +98,6 @@ CLI fallback 的状态机与 MCP 保持一致:
90
98
 
91
99
  - 用结构化中文输出
92
100
  - 先给用户确认卡片,再正式执行
101
+ - 对 `school_tag/degree/gender/recent_not_view` 必须逐项提问并逐项确认,不可合并成一句“filters已确认”
93
102
  - 不要跳过 `post_action` 的首轮确认
94
- - 不要把 recommend 流程说成 search 流程
103
+ - 不要把 recommend 流程说成 search 流程
package/src/adapters.js CHANGED
@@ -36,6 +36,15 @@ function parsePositiveInteger(raw) {
36
36
  return Number.isFinite(value) && value > 0 ? value : null;
37
37
  }
38
38
 
39
+ function serializeDegreeSelection(value) {
40
+ if (Array.isArray(value)) {
41
+ const normalized = value.map((item) => String(item || "").trim()).filter(Boolean);
42
+ return normalized.length ? normalized.join(",") : "不限";
43
+ }
44
+ const normalized = String(value || "").trim();
45
+ return normalized || "不限";
46
+ }
47
+
39
48
  function resolveScreenConfigPath(workspaceRoot) {
40
49
  const envConfigPath = process.env.BOSS_RECOMMEND_SCREEN_CONFIG
41
50
  ? path.resolve(process.env.BOSS_RECOMMEND_SCREEN_CONFIG)
@@ -506,6 +515,8 @@ export async function runRecommendSearchCli({ workspaceRoot, searchParams }) {
506
515
  cliPath,
507
516
  "--school-tag",
508
517
  searchParams.school_tag,
518
+ "--degree",
519
+ serializeDegreeSelection(searchParams.degree),
509
520
  "--gender",
510
521
  searchParams.gender,
511
522
  "--recent-not-view",
package/src/cli.js CHANGED
@@ -27,6 +27,8 @@ const defaultMcpServerName = "boss-recommend";
27
27
  const defaultMcpCommand = "npx";
28
28
  const defaultMcpArgs = ["-y", "@reconcrap/boss-recommend-mcp@latest", "start"];
29
29
  const autoSyncSkipCommands = new Set(["install", "install-skill", "where", "help", "--help", "-h"]);
30
+ const externalMcpTargetsEnv = "BOSS_RECOMMEND_MCP_CONFIG_TARGETS";
31
+ const externalSkillDirsEnv = "BOSS_RECOMMEND_EXTERNAL_SKILL_DIRS";
30
32
 
31
33
  function getPackageVersion() {
32
34
  try {
@@ -49,6 +51,39 @@ function ensureDir(targetPath) {
49
51
  fs.mkdirSync(targetPath, { recursive: true });
50
52
  }
51
53
 
54
+ function pathExists(targetPath) {
55
+ try {
56
+ return fs.existsSync(targetPath);
57
+ } catch {
58
+ return false;
59
+ }
60
+ }
61
+
62
+ function readJsonObjectFileSafe(filePath) {
63
+ if (!pathExists(filePath)) return {};
64
+ try {
65
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
66
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
67
+ return parsed;
68
+ }
69
+ } catch {
70
+ // Fallback below.
71
+ }
72
+ return {};
73
+ }
74
+
75
+ function dedupePaths(items) {
76
+ const result = [];
77
+ const seen = new Set();
78
+ for (const item of items || []) {
79
+ const resolved = path.resolve(String(item || ""));
80
+ if (!resolved || seen.has(resolved)) continue;
81
+ seen.add(resolved);
82
+ result.push(resolved);
83
+ }
84
+ return result;
85
+ }
86
+
52
87
  function getDesktopDir() {
53
88
  return path.join(os.homedir(), "Desktop");
54
89
  }
@@ -154,6 +189,7 @@ function normalizeMcpClientName(value) {
154
189
  const raw = String(value || "").trim().toLowerCase();
155
190
  if (!raw) return "";
156
191
  if (raw === "claude-code") return "claudecode";
192
+ if (raw === "trae-cn") return "trae";
157
193
  return raw;
158
194
  }
159
195
 
@@ -219,6 +255,135 @@ function writeMcpConfigFiles(options = {}) {
219
255
  return { outputDir, files };
220
256
  }
221
257
 
258
+ function parsePathListFromEnv(raw) {
259
+ if (!raw) return [];
260
+ const text = String(raw).trim();
261
+ if (!text) return [];
262
+ try {
263
+ const parsed = JSON.parse(text);
264
+ if (Array.isArray(parsed)) {
265
+ return dedupePaths(parsed.filter(Boolean));
266
+ }
267
+ } catch {
268
+ // Fallback to delimiter split.
269
+ }
270
+ return dedupePaths(text.split(path.delimiter).map((item) => item.trim()).filter(Boolean));
271
+ }
272
+
273
+ function getKnownExternalMcpConfigPaths() {
274
+ const home = os.homedir();
275
+ const appData = process.env.APPDATA || path.join(home, "AppData", "Roaming");
276
+ return dedupePaths([
277
+ path.join(appData, "Cursor", "User", "mcp.json"),
278
+ path.join(appData, "Trae", "User", "mcp.json"),
279
+ path.join(appData, "Trae CN", "User", "mcp.json"),
280
+ path.join(home, ".trae", "mcp.json"),
281
+ path.join(home, ".trae-cn", "mcp.json"),
282
+ path.join(home, ".claude", "mcp.json"),
283
+ path.join(home, ".openclaw", "mcp.json")
284
+ ]);
285
+ }
286
+
287
+ function resolveExternalMcpConfigTargets() {
288
+ const fromEnv = parsePathListFromEnv(process.env[externalMcpTargetsEnv]);
289
+ const known = getKnownExternalMcpConfigPaths().filter((filePath) => {
290
+ if (pathExists(filePath)) return true;
291
+ return pathExists(path.dirname(filePath));
292
+ });
293
+ return dedupePaths([...fromEnv, ...known]);
294
+ }
295
+
296
+ function mergeMcpServerConfigFile(filePath, options = {}) {
297
+ const nextConfig = buildMcpConfigFileContent(options);
298
+ const serverName = Object.keys(nextConfig.mcpServers || {})[0] || defaultMcpServerName;
299
+ const launchConfig = nextConfig.mcpServers?.[serverName] || buildMcpLaunchConfig(options);
300
+ const current = readJsonObjectFileSafe(filePath);
301
+ const existingServers =
302
+ current?.mcpServers && typeof current.mcpServers === "object" && !Array.isArray(current.mcpServers)
303
+ ? current.mcpServers
304
+ : {};
305
+ const existingEntry = existingServers[serverName];
306
+ const merged = {
307
+ ...current,
308
+ mcpServers: {
309
+ ...existingServers,
310
+ [serverName]: launchConfig
311
+ }
312
+ };
313
+
314
+ ensureDir(path.dirname(filePath));
315
+ fs.writeFileSync(filePath, JSON.stringify(merged, null, 2), "utf8");
316
+ const updated = JSON.stringify(existingEntry || null) !== JSON.stringify(launchConfig);
317
+ return {
318
+ file: filePath,
319
+ server: serverName,
320
+ updated
321
+ };
322
+ }
323
+
324
+ function installExternalMcpConfigs(options = {}) {
325
+ const targets = resolveExternalMcpConfigTargets();
326
+ const applied = [];
327
+ const skipped = [];
328
+ for (const target of targets) {
329
+ try {
330
+ const existed = pathExists(target);
331
+ const merged = mergeMcpServerConfigFile(target, options);
332
+ applied.push({
333
+ file: target,
334
+ server: merged.server,
335
+ created: !existed,
336
+ updated: merged.updated
337
+ });
338
+ } catch (error) {
339
+ skipped.push({
340
+ file: target,
341
+ reason: error.message
342
+ });
343
+ }
344
+ }
345
+ return { targets, applied, skipped };
346
+ }
347
+
348
+ function getKnownExternalSkillBaseDirs() {
349
+ const home = os.homedir();
350
+ const appData = process.env.APPDATA || path.join(home, "AppData", "Roaming");
351
+ return dedupePaths([
352
+ path.join(home, ".cursor", "skills"),
353
+ path.join(home, ".trae", "skills"),
354
+ path.join(home, ".trae-cn", "skills"),
355
+ path.join(home, ".claude", "skills"),
356
+ path.join(home, ".openclaw", "skills"),
357
+ path.join(appData, "Cursor", "User", "skills"),
358
+ path.join(appData, "Trae", "User", "skills"),
359
+ path.join(appData, "Trae CN", "User", "skills"),
360
+ path.join(appData, "OpenClaw", "User", "skills")
361
+ ]);
362
+ }
363
+
364
+ function resolveExternalSkillBaseDirs() {
365
+ const fromEnv = parsePathListFromEnv(process.env[externalSkillDirsEnv]);
366
+ const known = getKnownExternalSkillBaseDirs().filter((dirPath) => pathExists(dirPath));
367
+ return dedupePaths([...fromEnv, ...known]);
368
+ }
369
+
370
+ function mirrorSkillToExternalDirs() {
371
+ const baseDirs = resolveExternalSkillBaseDirs();
372
+ const mirrored = [];
373
+ const skipped = [];
374
+ for (const baseDir of baseDirs) {
375
+ try {
376
+ const targetDir = path.join(baseDir, skillName);
377
+ ensureDir(path.dirname(targetDir));
378
+ fs.cpSync(skillSourceDir, targetDir, { recursive: true, force: true });
379
+ mirrored.push({ base_dir: baseDir, target_dir: targetDir });
380
+ } catch (error) {
381
+ skipped.push({ base_dir: baseDir, reason: error.message });
382
+ }
383
+ }
384
+ return { baseDirs, mirrored, skipped };
385
+ }
386
+
222
387
  function syncSkillAssets(options = {}) {
223
388
  const force = options.force === true;
224
389
  const targetDir = getSkillTargetDir();
@@ -421,7 +586,7 @@ function printHelp() {
421
586
  console.log(" boss-recommend-mcp install-skill Install only the Codex skill");
422
587
  console.log(" boss-recommend-mcp init-config Create ~/.codex/boss-recommend-mcp/screening-config.json if missing");
423
588
  console.log(" boss-recommend-mcp set-port Persist preferred Chrome debug port to screening-config.json");
424
- console.log(" boss-recommend-mcp mcp-config Generate MCP config JSON for Cursor/Trae/Claude Code/OpenClaw");
589
+ console.log(" boss-recommend-mcp mcp-config Generate MCP config JSON for Cursor/Trae(含 trae-cn)/Claude Code/OpenClaw");
425
590
  console.log(" boss-recommend-mcp doctor Check config and runtime prerequisites");
426
591
  console.log(" boss-recommend-mcp launch-chrome Launch or reuse Chrome debug instance and open Boss recommend page");
427
592
  console.log(" boss-recommend-mcp where Print installed package, skill, and config paths");
@@ -447,12 +612,31 @@ function installAll() {
447
612
  const skillTarget = installSkill();
448
613
  const configResult = ensureUserConfig();
449
614
  const mcpTemplateResult = writeMcpConfigFiles({ client: "all" });
615
+ const externalMcpResult = installExternalMcpConfigs({});
616
+ const externalSkillResult = mirrorSkillToExternalDirs();
450
617
  console.log(`Skill installed to: ${skillTarget}`);
451
618
  console.log(configResult.created ? `Config template created at: ${configResult.path}` : `Config already exists at: ${configResult.path}`);
452
619
  console.log(`MCP config templates exported to: ${mcpTemplateResult.outputDir}`);
453
620
  for (const item of mcpTemplateResult.files) {
454
621
  console.log(`- ${item.client}: ${item.file}`);
455
622
  }
623
+ if (externalMcpResult.targets.length > 0) {
624
+ console.log(`Auto-configured external MCP files: ${externalMcpResult.applied.length}`);
625
+ for (const item of externalMcpResult.applied) {
626
+ const action = item.created ? "created" : item.updated ? "updated" : "unchanged";
627
+ console.log(`- ${item.file} (${action})`);
628
+ }
629
+ } else {
630
+ console.log("No external MCP config target detected. Set BOSS_RECOMMEND_MCP_CONFIG_TARGETS to auto-configure custom agents.");
631
+ }
632
+ if (externalSkillResult.baseDirs.length > 0) {
633
+ console.log(`Mirrored skill to external dirs: ${externalSkillResult.mirrored.length}`);
634
+ for (const item of externalSkillResult.mirrored) {
635
+ console.log(`- ${item.target_dir}`);
636
+ }
637
+ } else {
638
+ console.log("No external skill dir detected. Set BOSS_RECOMMEND_EXTERNAL_SKILL_DIRS to mirror skill for non-Codex agents.");
639
+ }
456
640
  }
457
641
 
458
642
  async function runPipelineOnce(options) {
package/src/index.js CHANGED
@@ -38,6 +38,10 @@ function createToolSchema() {
38
38
  type: "object",
39
39
  properties: {
40
40
  filters_confirmed: { type: "boolean" },
41
+ school_tag_confirmed: { type: "boolean" },
42
+ degree_confirmed: { type: "boolean" },
43
+ gender_confirmed: { type: "boolean" },
44
+ recent_not_view_confirmed: { type: "boolean" },
41
45
  criteria_confirmed: { type: "boolean" },
42
46
  target_count_confirmed: { type: "boolean" },
43
47
  target_count_value: {
@@ -64,6 +68,23 @@ function createToolSchema() {
64
68
  type: "string",
65
69
  enum: ["不限", "985", "211", "双一流院校", "留学", "国内外名校", "公办本科"]
66
70
  },
71
+ degree: {
72
+ oneOf: [
73
+ {
74
+ type: "string",
75
+ enum: ["不限", "初中及以下", "中专/中技", "高中", "大专", "本科", "硕士", "博士"]
76
+ },
77
+ {
78
+ type: "array",
79
+ items: {
80
+ type: "string",
81
+ enum: ["不限", "初中及以下", "中专/中技", "高中", "大专", "本科", "硕士", "博士"]
82
+ },
83
+ minItems: 1,
84
+ uniqueItems: true
85
+ }
86
+ ]
87
+ },
67
88
  gender: {
68
89
  type: "string",
69
90
  enum: ["不限", "男", "女"]
package/src/parser.js CHANGED
@@ -7,6 +7,25 @@ const SCHOOL_TAG_OPTIONS = [
7
7
  "国内外名校",
8
8
  "公办本科"
9
9
  ];
10
+ const DEGREE_OPTIONS = [
11
+ "不限",
12
+ "初中及以下",
13
+ "中专/中技",
14
+ "高中",
15
+ "大专",
16
+ "本科",
17
+ "硕士",
18
+ "博士"
19
+ ];
20
+ const DEGREE_ORDER = [
21
+ "初中及以下",
22
+ "中专/中技",
23
+ "高中",
24
+ "大专",
25
+ "本科",
26
+ "硕士",
27
+ "博士"
28
+ ];
10
29
  const GENDER_OPTIONS = ["不限", "男", "女"];
11
30
  const RECENT_NOT_VIEW_OPTIONS = ["不限", "近14天没有"];
12
31
  const POST_ACTION_OPTIONS = ["favorite", "greet"];
@@ -30,6 +49,15 @@ const SCHOOL_TAG_PATTERNS = [
30
49
  { label: "国内外名校", pattern: /国内外名校|名校/i },
31
50
  { label: "公办本科", pattern: /公办本科/i }
32
51
  ];
52
+ const DEGREE_PATTERNS = [
53
+ { label: "初中及以下", pattern: /初中及以下|初中以下/i },
54
+ { label: "中专/中技", pattern: /中专\s*\/\s*中技|中专中技|中专|中技/i },
55
+ { label: "高中", pattern: /(?:学历|教育|要求)?[^。;;\n]{0,8}高中/i },
56
+ { label: "大专", pattern: /(?:学历|教育|要求)?[^。;;\n]{0,8}(?:大专|专科)/i },
57
+ { label: "本科", pattern: /(?:学历|教育|要求)?[^。;;\n]{0,8}(?:本科|学士)/i },
58
+ { label: "硕士", pattern: /(?:学历|教育|要求)?[^。;;\n]{0,8}(?:硕士|研究生)/i },
59
+ { label: "博士", pattern: /(?:学历|教育|要求)?[^。;;\n]{0,8}博士/i }
60
+ ];
33
61
  const GENDER_PATTERNS = [
34
62
  { label: "男", pattern: /(?:性别|候选人|人选)?[^。;;\n]{0,8}(?:男生|男性|男)/i },
35
63
  { label: "女", pattern: /(?:性别|候选人|人选)?[^。;;\n]{0,8}(?:女生|女性|女)/i }
@@ -57,6 +85,7 @@ const MAX_GREET_COUNT_PATTERNS = [
57
85
  ];
58
86
  const FILTER_CLAUSE_PATTERNS = [
59
87
  /学校标签|院校标签|985|211|双一流|留学|国内外名校|公办本科/i,
88
+ /学历|学位|教育|初中及以下|中专|中技|高中|大专|专科|本科|硕士|研究生|博士/i,
60
89
  /性别|男生|女生|男性|女性|男\b|女\b/i,
61
90
  /近?14天(?:内)?没有|近?14天(?:内)?没看过|近?14天(?:内)?未查看|过滤[^。;;\n]{0,12}14天|排除[^。;;\n]{0,12}14天/i,
62
91
  /目标(?:处理|筛选)?(?:人数|数量)?|至少(?:处理|筛选)|(?:处理|筛选)\s*\d+\s*(?:位|人)/i,
@@ -89,6 +118,75 @@ function normalizeSchoolTag(value) {
89
118
  return null;
90
119
  }
91
120
 
121
+ function normalizeDegree(value) {
122
+ const normalized = normalizeText(value);
123
+ if (!normalized) return null;
124
+ if (normalized === "专科") return "大专";
125
+ if (normalized === "研究生") return "硕士";
126
+ if (normalized === "中专" || normalized === "中技" || normalized === "中专中技") return "中专/中技";
127
+ return DEGREE_OPTIONS.includes(normalized) ? normalized : null;
128
+ }
129
+
130
+ function sortDegreeSelections(values) {
131
+ return uniqueList(values).sort((left, right) => {
132
+ const leftIndex = DEGREE_ORDER.indexOf(left);
133
+ const rightIndex = DEGREE_ORDER.indexOf(right);
134
+ return leftIndex - rightIndex;
135
+ });
136
+ }
137
+
138
+ function expandDegreeAtOrAbove(value) {
139
+ const normalized = normalizeDegree(value);
140
+ if (!normalized || normalized === "不限") return [];
141
+ const startIndex = DEGREE_ORDER.indexOf(normalized);
142
+ if (startIndex === -1) return [];
143
+ return DEGREE_ORDER.slice(startIndex);
144
+ }
145
+
146
+ function parseDegreeSelectionsFromText(text) {
147
+ const normalizedText = normalizeText(text);
148
+ if (!normalizedText) return [];
149
+ if (/(?:学历|学位|教育)[^。;;\n]{0,6}不限|不限[^。;;\n]{0,6}(?:学历|学位|教育)/i.test(normalizedText)) {
150
+ return ["不限"];
151
+ }
152
+
153
+ const selected = [];
154
+ const atOrAbovePattern = /(初中及以下|中专\/中技|中专中技|中专|中技|高中|大专|专科|本科|硕士|研究生|博士)\s*(?:及|或)?以上/g;
155
+ let match;
156
+ while ((match = atOrAbovePattern.exec(normalizedText)) !== null) {
157
+ selected.push(...expandDegreeAtOrAbove(match[1]));
158
+ }
159
+
160
+ for (const { label, pattern } of DEGREE_PATTERNS) {
161
+ if (pattern.test(normalizedText)) {
162
+ selected.push(label);
163
+ }
164
+ }
165
+ return sortDegreeSelections(selected);
166
+ }
167
+
168
+ function normalizeDegreeSelections(input) {
169
+ if (Array.isArray(input)) {
170
+ const normalized = sortDegreeSelections(input.map((item) => normalizeDegree(item)).filter(Boolean));
171
+ if (!normalized.length) return null;
172
+ return normalized.includes("不限") ? ["不限"] : normalized;
173
+ }
174
+
175
+ const text = normalizeText(input);
176
+ if (!text) return null;
177
+ if (text.includes("以上")) {
178
+ const fromText = parseDegreeSelectionsFromText(text);
179
+ if (fromText.length) return fromText;
180
+ }
181
+ const parts = text.split(/[,,、/|]/).map((item) => normalizeDegree(item)).filter(Boolean);
182
+ if (parts.length) {
183
+ const normalized = sortDegreeSelections(parts);
184
+ return normalized.includes("不限") ? ["不限"] : normalized;
185
+ }
186
+ const single = normalizeDegree(text);
187
+ return single ? [single] : null;
188
+ }
189
+
92
190
  function normalizeGender(value) {
93
191
  const normalized = normalizeText(value);
94
192
  if (!normalized) return null;
@@ -142,6 +240,10 @@ function extractGender(text) {
142
240
  return null;
143
241
  }
144
242
 
243
+ function extractDegrees(text) {
244
+ return parseDegreeSelectionsFromText(text);
245
+ }
246
+
145
247
  function extractRecentNotView(text) {
146
248
  for (const pattern of RECENT_NOT_VIEW_NEGATIVE_PATTERNS) {
147
249
  if (pattern.test(text)) {
@@ -298,13 +400,22 @@ function collectSuspiciousFields({ detectedSchoolTags }) {
298
400
  export function parseRecommendInstruction({ instruction, confirmation, overrides }) {
299
401
  const text = normalizeText(instruction);
300
402
  const detectedSchoolTags = extractSchoolTags(text);
403
+ const detectedDegrees = extractDegrees(text);
301
404
  const overrideSchoolTag = normalizeSchoolTag(overrides?.school_tag);
405
+ const overrideDegrees = normalizeDegreeSelections(overrides?.degree);
302
406
  const overrideGender = normalizeGender(overrides?.gender);
303
407
  const overrideRecentNotView = normalizeRecentNotView(overrides?.recent_not_view);
304
408
  const overrideCriteria = overrides?.criteria;
305
409
 
306
410
  const searchParams = {
307
411
  school_tag: overrideSchoolTag || detectedSchoolTags[0] || "不限",
412
+ degree: (
413
+ (Array.isArray(overrideDegrees) && overrideDegrees.length > 0
414
+ ? overrideDegrees
415
+ : Array.isArray(detectedDegrees) && detectedDegrees.length > 0
416
+ ? detectedDegrees
417
+ : ["不限"])
418
+ ),
308
419
  gender: overrideGender || extractGender(text) || "不限",
309
420
  recent_not_view: overrideRecentNotView || extractRecentNotView(text) || "不限"
310
421
  };
@@ -332,17 +443,63 @@ export function parseRecommendInstruction({ instruction, confirmation, overrides
332
443
  }
333
444
 
334
445
  const suspicious_fields = collectSuspiciousFields({ detectedSchoolTags });
335
- const needs_filters_confirmation = confirmation?.filters_confirmed !== true;
446
+ const needs_school_tag_confirmation = confirmation?.school_tag_confirmed !== true;
447
+ const needs_degree_confirmation = confirmation?.degree_confirmed !== true;
448
+ const needs_gender_confirmation = confirmation?.gender_confirmed !== true;
449
+ const needs_recent_not_view_confirmation = confirmation?.recent_not_view_confirmed !== true;
450
+ const needs_filters_confirmation = (
451
+ confirmation?.filters_confirmed !== true
452
+ || needs_school_tag_confirmation
453
+ || needs_degree_confirmation
454
+ || needs_gender_confirmation
455
+ || needs_recent_not_view_confirmation
456
+ );
336
457
  const needs_criteria_confirmation = confirmation?.criteria_confirmed !== true;
337
458
  const needs_target_count_confirmation = targetCountResolution.needs_target_count_confirmation;
338
459
  const needs_post_action_confirmation = postActionResolution.needs_post_action_confirmation;
339
460
  const needs_max_greet_count_confirmation = maxGreetCountResolution.needs_max_greet_count_confirmation;
340
461
  const pending_questions = [];
341
462
 
342
- if (needs_filters_confirmation) {
463
+ if (needs_school_tag_confirmation) {
464
+ pending_questions.push({
465
+ field: "school_tag",
466
+ question: "请确认学校标签筛选。",
467
+ value: searchParams.school_tag,
468
+ options: SCHOOL_TAG_OPTIONS
469
+ });
470
+ }
471
+
472
+ if (needs_degree_confirmation) {
473
+ pending_questions.push({
474
+ field: "degree",
475
+ question: "请确认学历筛选(可多选)。",
476
+ value: searchParams.degree,
477
+ options: DEGREE_OPTIONS
478
+ });
479
+ }
480
+
481
+ if (needs_gender_confirmation) {
482
+ pending_questions.push({
483
+ field: "gender",
484
+ question: "请确认性别筛选。",
485
+ value: searchParams.gender,
486
+ options: GENDER_OPTIONS
487
+ });
488
+ }
489
+
490
+ if (needs_recent_not_view_confirmation) {
491
+ pending_questions.push({
492
+ field: "recent_not_view",
493
+ question: "请确认是否过滤近14天内已看过的人选。",
494
+ value: searchParams.recent_not_view,
495
+ options: RECENT_NOT_VIEW_OPTIONS
496
+ });
497
+ }
498
+
499
+ if (needs_filters_confirmation && pending_questions.every((item) => item.field !== "filters")) {
343
500
  pending_questions.push({
344
501
  field: "filters",
345
- question: "请确认推荐页筛选项是否正确。",
502
+ question: "请确认以上推荐页筛选项整体无误。",
346
503
  value: searchParams
347
504
  });
348
505
  }
@@ -389,6 +546,10 @@ export function parseRecommendInstruction({ instruction, confirmation, overrides
389
546
  missing_fields,
390
547
  suspicious_fields,
391
548
  needs_filters_confirmation,
549
+ needs_school_tag_confirmation,
550
+ needs_degree_confirmation,
551
+ needs_gender_confirmation,
552
+ needs_recent_not_view_confirmation,
392
553
  needs_criteria_confirmation,
393
554
  needs_target_count_confirmation,
394
555
  needs_post_action_confirmation,
@@ -415,6 +576,7 @@ export function parseRecommendInstruction({ instruction, confirmation, overrides
415
576
  }
416
577
 
417
578
  export {
579
+ DEGREE_OPTIONS,
418
580
  GENDER_OPTIONS,
419
581
  POST_ACTION_LABELS,
420
582
  POST_ACTION_OPTIONS,
package/src/pipeline.js CHANGED
@@ -9,6 +9,10 @@ import {
9
9
  function buildRequiredConfirmations(parsedResult) {
10
10
  const confirmations = [];
11
11
  if (parsedResult.needs_filters_confirmation) confirmations.push("filters");
12
+ if (parsedResult.needs_school_tag_confirmation) confirmations.push("school_tag");
13
+ if (parsedResult.needs_degree_confirmation) confirmations.push("degree");
14
+ if (parsedResult.needs_gender_confirmation) confirmations.push("gender");
15
+ if (parsedResult.needs_recent_not_view_confirmation) confirmations.push("recent_not_view");
12
16
  if (parsedResult.needs_criteria_confirmation) confirmations.push("criteria");
13
17
  if (parsedResult.needs_target_count_confirmation) confirmations.push("target_count");
14
18
  if (parsedResult.needs_post_action_confirmation) confirmations.push("post_action");
@@ -89,6 +93,10 @@ export async function runRecommendPipeline(
89
93
 
90
94
  if (
91
95
  parsed.needs_filters_confirmation
96
+ || parsed.needs_school_tag_confirmation
97
+ || parsed.needs_degree_confirmation
98
+ || parsed.needs_gender_confirmation
99
+ || parsed.needs_recent_not_view_confirmation
92
100
  || parsed.needs_criteria_confirmation
93
101
  || parsed.needs_target_count_confirmation
94
102
  || parsed.needs_post_action_confirmation