offerguard-mcp-server 0.3.0 → 0.4.0
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 +86 -70
- package/docs/SCORING.md +42 -0
- package/docs/WORKFLOW_INTEGRATION.md +64 -0
- package/package.json +8 -3
- package/scripts/validate-release.js +53 -0
- package/src/decision-engine.js +330 -0
- package/src/offerguard-tools.js +6 -476
- package/src/server.js +41 -12
- package/test/decision-engine.test.js +210 -0
- package/test/fixtures.js +10 -0
- package/test/mcp-protocol.test.js +84 -0
- package/workflow/retest-prompts.json +16 -0
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export const TOOL_VERSION = "0.4.0";
|
|
4
|
+
const RULE_VERSION = "2026-09-evidence-1";
|
|
5
|
+
const hash = s => createHash("sha256").update(s).digest("hex").slice(0, 16);
|
|
6
|
+
const text = s => typeof s === "string" ? s.trim() : "";
|
|
7
|
+
const record = (source, value) => ({ id: hash(`${source}:${value}`), source, text: value });
|
|
8
|
+
const clauses = s => s.match(/[^。;;!!??\n]+[。;;!!??]?/g)?.map(x => x.trim()).filter(Boolean) ?? [];
|
|
9
|
+
|
|
10
|
+
// A source clause, polarity and quotation accompany every scored fact.
|
|
11
|
+
const RULES = [
|
|
12
|
+
["unpaid_trial", "无薪试岗", "critical", /无薪(?:试岗|实习)|试岗无薪|不发薪|不支付(?:薪资|报酬)|unpaid (?:trial|internship)/i],
|
|
13
|
+
["deposit_or_fee", "入职押金或收费", "critical", /押金|保证金|服装费|工牌费|入职收费|security deposit|deposit/i],
|
|
14
|
+
["training_loan", "培训贷", "critical", /培训贷|贷款培训|分期培训|培训分期|training loan/i],
|
|
15
|
+
["overtime", "加班与响应边界", "medium", /加班|夜间值班|晚间在线|周末响应|随叫随到|996|大小周|overtime|on call/i],
|
|
16
|
+
["salary_opaque", "薪资面议", "medium", /薪资面议|待遇面议|薪酬面议|工资面议|薪资.*面议|salary negotiable/i],
|
|
17
|
+
["flexible_work", "弹性工作", "medium", /弹性工作|弹性上班|时间灵活|flexible (?:work|schedule)/i],
|
|
18
|
+
["pressure", "工作节奏", "medium", /抗压(?:能力)?强|高压环境|能吃苦|高强度|fast pace/i],
|
|
19
|
+
["broad_duties", "职责边界", "medium", /其他临时工作|完成领导安排|综合事务|临时行政|杂务/i],
|
|
20
|
+
["mentor", "导师机制", null, /导师|带教|mentor|代码评审|主管指导/i],
|
|
21
|
+
["agreement", "实习协议", null, /实习协议|三方协议|劳动合同|书面协议|written agreement/i],
|
|
22
|
+
["conversion", "转正标准", null, /转正标准|转正流程|考核指标|转正名额|conversion criteria/i],
|
|
23
|
+
["project", "项目产出", null, /真实项目|核心项目|项目开发|参与.*开发|数据复盘|活动复盘|内容选题|上线项目/i],
|
|
24
|
+
["portfolio", "展示或证明材料", null, /作品集|实习证明|项目证明|脱敏展示|portfolio/i],
|
|
25
|
+
["ip_restriction", "成果展示限制", "medium", /不得对外展示|禁止.*展示|不允许.*展示|portfolio not allowed/i]
|
|
26
|
+
];
|
|
27
|
+
const QUESTIONS = {
|
|
28
|
+
jd: "请补充岗位职责原文。", student: "请补充专业和相关项目或实习经历。",
|
|
29
|
+
preferences: "你的薪资底线和每周可到岗时间是什么?", city: "岗位城市或远程安排是什么?",
|
|
30
|
+
salary: "请确认日薪或月薪金额,未知也可直接说明。", agreement: "入职前是否签署实习协议?",
|
|
31
|
+
hours: "日常工作时间、夜间和周末响应安排是什么?", mentor: "谁负责带教,多久反馈一次?",
|
|
32
|
+
conversion: "是否有转正安排?若有,标准和周期是什么?", portfolio: "是否提供项目证明或经授权的脱敏展示材料?",
|
|
33
|
+
project: "前三周的主要交付物和实际项目任务是什么?"
|
|
34
|
+
};
|
|
35
|
+
const ROLES = [/后端|java|mysql|redis|接口开发|backend/i, /新媒体|公众号|小红书|新闻|内容运营|传播|短视频/i,
|
|
36
|
+
/产品经理|原型|prd|需求文档|用户访谈|信息管理/i, /数据分析|数据科学|sql|python|统计/i, /视觉设计|平面设计|交互设计|figma|设计专业/i];
|
|
37
|
+
|
|
38
|
+
function profileText(value) {
|
|
39
|
+
if (typeof value === "string") return value;
|
|
40
|
+
if (!value || typeof value !== "object") return "";
|
|
41
|
+
return Object.entries(value).filter(([, v]) => ["string", "number"].includes(typeof v)).map(([k, v]) => `${k}: ${v}`).join(";");
|
|
42
|
+
}
|
|
43
|
+
function splitRaw(raw) {
|
|
44
|
+
const out = [];
|
|
45
|
+
const marker = /(目标岗位JD|岗位JD|JD文本|JD|岗位信息|目标岗位|岗位|简历\/经历|个人简历|学生画像|学生背景|学生|简历|求职偏好|个人偏好|偏好|HR回复|HR回答|HR说|主管回复)\s*[::]/gi;
|
|
46
|
+
const markers = [...raw.matchAll(marker)];
|
|
47
|
+
function loose(s) {
|
|
48
|
+
for (const line of clauses(s)) {
|
|
49
|
+
const personal = /^(我|本人|个人|学生|不接受|不希望|希望|想找|城市优先|风险承受度|薪资底线|到岗时间|大[一二三四])/.test(line);
|
|
50
|
+
out.push(record(personal ? "student" : "unattributed", line));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (!markers.length) { loose(raw); return out; }
|
|
54
|
+
loose(raw.slice(0, markers[0].index));
|
|
55
|
+
for (let i = 0; i < markers.length; i++) {
|
|
56
|
+
const m = markers[i];
|
|
57
|
+
const part = raw.slice(m.index + m[0].length, markers[i + 1]?.index ?? raw.length).trim();
|
|
58
|
+
if (part) out.push(record(/HR|主管/i.test(m[1]) ? "hr" : /学生|简历|偏好/.test(m[1]) ? "student" : "jd", part));
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
function collect(input) {
|
|
63
|
+
const out = [];
|
|
64
|
+
const s = input.structured_input ?? {};
|
|
65
|
+
if (input.previous_state) {
|
|
66
|
+
if (input.previous_state.schema_version !== 1 || !Array.isArray(input.previous_state.records)) throw new Error("Invalid previous_state schema; restart assessment.");
|
|
67
|
+
for (const r of input.previous_state.records) {
|
|
68
|
+
if (!["jd", "student", "hr", "unattributed"].includes(r.source) || typeof r.text !== "string") throw new Error("Invalid source record.");
|
|
69
|
+
out.push(record(r.source, r.text));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
for (const [source, value] of [["jd", s.jd_text ?? input.jdText ?? input.jobProfile], ["student", s.student_profile ?? input.studentProfile],
|
|
73
|
+
["hr", s.hr_reply ?? input.hr_reply ?? input.offerText], ["student", input.extraText]]) {
|
|
74
|
+
const v = profileText(value).trim(); if (v) out.push(record(source, v));
|
|
75
|
+
}
|
|
76
|
+
const raw = text(input.raw_input ?? input.AGENT_USER_INPUT ?? input.rawInput ?? input.text);
|
|
77
|
+
if (raw) out.push(...splitRaw(raw));
|
|
78
|
+
const unique = [...new Map(out.map(r => [r.id, r])).values()];
|
|
79
|
+
if (unique.length > 120 || unique.reduce((n, r) => n + r.text.length, 0) > 40000) throw new Error("Assessment context too long; start a new assessment.");
|
|
80
|
+
return unique;
|
|
81
|
+
}
|
|
82
|
+
function polarity(clause, pattern) {
|
|
83
|
+
const m = clause.match(pattern); if (!m) return null;
|
|
84
|
+
const before = clause.slice(0, m.index), after = clause.slice(m.index + m[0].length);
|
|
85
|
+
if (/不接受|不希望|底线|希望有|想要|期望/.test(before)) return "unknown";
|
|
86
|
+
if (/[??]$/.test(clause) || /是否|有没有|未说明|未明确|未确认|未知|不清楚|不清晰|不明|不详|待定|待确认|待验证|可能|不确定|不保证|如果|假如|假设|计划|有望|预计/.test(clause)) return "unknown";
|
|
87
|
+
if (/并非不|不是不|不能不|不得不|不排除|没有说|未承诺|未表示/.test(before)) return "unknown";
|
|
88
|
+
if (/\bnot only\b/i.test(before)) return "unknown";
|
|
89
|
+
if (/(?:\bno|\bwithout|\bnot(?:\s+(?:require|required|provide|provided|offer|offered|charge|charged|any))*)\s*$/i.test(before)) return "negated";
|
|
90
|
+
if (/^\s+(?:is\s+)?not\s+(?:provided|available|allowed|required)/i.test(after)) return "negated";
|
|
91
|
+
if (/(?:不|未|无|没有|无需|不需|不必|禁止|拒绝|不会|不再|不涉及)(?:收取|收|缴纳|缴|提供|设置|设|安排|签署|签|任何|要求|进行|支付|有|配备|固定|专门|专属|需要|包含|承担|支持|允许|长期|额外|强制|实行|参与|接触|负责|产出|形成|沉淀|获得|出具|开具|学习|掌握|押金|保证金|培训贷|无薪试岗|导师|实习协议|转正标准|和|与|及|、|\s){0,12}$/.test(before)) return "negated";
|
|
92
|
+
if (/^(?:为零|不存在|不提供|没有|不安排|不收取)/.test(after)) return "negated";
|
|
93
|
+
return "affirmed";
|
|
94
|
+
}
|
|
95
|
+
function evidence(records, pattern) {
|
|
96
|
+
const found = [];
|
|
97
|
+
for (const r of records.filter(r => ["jd", "hr"].includes(r.source))) {
|
|
98
|
+
for (const sentence of clauses(r.text)) for (const clause of sentence.split(/[,,]/).map(s => s.trim()).filter(Boolean)) {
|
|
99
|
+
const status = polarity(clause, pattern);
|
|
100
|
+
if (status) found.push({ source_id: r.id, source: r.source, quote: clause, status });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const known = new Set(found.map(o => o.status).filter(s => s !== "unknown"));
|
|
104
|
+
return { status: known.size > 1 ? "conflict" : known.size === 1 ? [...known][0] : "unknown", evidence: found };
|
|
105
|
+
}
|
|
106
|
+
function salaryFact(records) {
|
|
107
|
+
const found = [];
|
|
108
|
+
for (const r of records.filter(r => ["jd", "hr"].includes(r.source))) for (const clause of clauses(r.text)) {
|
|
109
|
+
if (/是否|未知|未确认|待确认|希望|期望|底线/.test(clause)) continue;
|
|
110
|
+
const m = clause.match(/(\d{2,6})(?:\s*[-~至到]\s*(\d{2,6}))?\s*元\s*(?:\/|每)?\s*(天|日|月)/);
|
|
111
|
+
if (m) found.push({ source_id: r.id, source: r.source, quote: clause, status: "affirmed", value: { min: +m[1], max: +(m[2] ?? m[1]), unit: m[3] === "日" ? "天" : m[3] } });
|
|
112
|
+
}
|
|
113
|
+
const values = new Set(found.map(f => JSON.stringify(f.value)));
|
|
114
|
+
return { label: "薪资金额", status: values.size > 1 ? "conflict" : values.size ? "affirmed" : "unknown", value: values.size === 1 ? found[0].value : null, evidence: found };
|
|
115
|
+
}
|
|
116
|
+
function factsFor(records) {
|
|
117
|
+
const facts = Object.fromEntries(RULES.map(([key, label, severity, pattern]) => [key, { label, severity, ...evidence(records, pattern) }]));
|
|
118
|
+
facts.salary = salaryFact(records);
|
|
119
|
+
for (const [key, label, pattern] of [
|
|
120
|
+
["hours", "工作时间", /\d{1,2}(?::\d{2})?\s*[-至到]\s*\d{1,2}:\d{2}|每天\d+小时|每周\d+小时|朝九晚六|周末随叫随到/],
|
|
121
|
+
["city", "岗位城市", /北京|上海|广州|深圳|杭州|成都|南京|武汉|天津|西安|苏州|远程|城市[::]\s*\S+/],
|
|
122
|
+
["duties", "岗位职责", /负责|参与|开发|运营|分析|设计|实习生|岗位职责/],
|
|
123
|
+
["conversion_offer", "转正安排", /转正(?!标准|流程|名额|指标)|留用/]
|
|
124
|
+
]) facts[key] = { label, ...evidence(records, pattern) };
|
|
125
|
+
return facts;
|
|
126
|
+
}
|
|
127
|
+
function risks(facts) {
|
|
128
|
+
return RULES.filter(([, , severity]) => severity).flatMap(([key, term, severity]) => facts[key].status !== "affirmed" ? [] : [{
|
|
129
|
+
id: key, term, severity, status: "affirmed", evidence: facts[key].evidence.filter(e => e.status === "affirmed"), trigger_reason: "岗位或 HR 原文明确提及,需结合具体安排核实。"
|
|
130
|
+
}]);
|
|
131
|
+
}
|
|
132
|
+
function decision(records) {
|
|
133
|
+
const facts = factsFor(records), risk_terms = risks(facts);
|
|
134
|
+
const red = risk_terms.filter(r => r.severity === "critical");
|
|
135
|
+
const students = records.filter(r => r.source === "student"), jobs = records.filter(r => ["jd", "hr"].includes(r.source));
|
|
136
|
+
const st = students.map(r => r.text).join(";"), jd = jobs.map(r => r.text).join(";");
|
|
137
|
+
const studentEvidence = students.map(r => ({ source_id: r.id, source: r.source, quote: r.text }));
|
|
138
|
+
const present = /专业|大[一二三四]|研究生|年级|经历|项目|做过|实习|学生/.test(st);
|
|
139
|
+
const preferences = /薪资底线|最低|至少|期望.*元|希望.*元/.test(st) && /到岗|每周.*天|暑假|可实习/.test(st);
|
|
140
|
+
const conflicts = Object.keys(facts).filter(k => facts[k].status === "conflict");
|
|
141
|
+
const floor = st.match(/(?:薪资底线|最低|至少)\s*[::]?\s*(\d+)\s*元\s*\/?\s*(天|日|月)/);
|
|
142
|
+
const salaryFit = floor && facts.salary.value && (floor[2] === "日" ? "天" : floor[2]) === facts.salary.value.unit
|
|
143
|
+
? facts.salary.value.min >= +floor[1] ? "meets_floor" : "below_floor_or_range" : "unknown";
|
|
144
|
+
const missing = [];
|
|
145
|
+
if (!jobs.length || facts.duties.status === "unknown") missing.push("jd");
|
|
146
|
+
if (!present) missing.push("student"); if (!preferences) missing.push("preferences");
|
|
147
|
+
for (const k of ["city", "salary", "agreement", "hours"]) if (facts[k].status === "unknown") missing.push(k);
|
|
148
|
+
const yes = k => facts[k].status === "affirmed";
|
|
149
|
+
function dim(key, name, keys, calc, extra = []) {
|
|
150
|
+
const known = keys.every(k => ["affirmed", "negated"].includes(facts[k].status));
|
|
151
|
+
return { key, name, score: known ? calc() : null, status: known ? "assessed" : "unknown", evidence: [...keys.flatMap(k => facts[k].evidence), ...extra] };
|
|
152
|
+
}
|
|
153
|
+
const groups = ROLES.filter(p => jobs.some(r => clauses(r.text).some(c => polarity(c, p) === "affirmed")));
|
|
154
|
+
const matches = groups.filter(p => students.some(r => clauses(r.text).some(c => polarity(c, p) === "affirmed")));
|
|
155
|
+
const dimensions = [
|
|
156
|
+
{ key: "role_match", name: "岗位匹配", score: present && groups.length ? Math.round(40 + 45 * matches.length / groups.length) : null, status: present && groups.length ? "assessed" : "unknown", evidence: jobs.map(r => ({ source_id: r.id, source: r.source, quote: r.text })).concat(studentEvidence), rationale: "独立比较岗位方向与学生经历关键词;规则估计不是录用概率。" },
|
|
157
|
+
dim("growth_value", "成长价值", ["mentor", "project"], () => (yes("mentor") ? 40 : 10) + (yes("project") ? 45 : 10)),
|
|
158
|
+
dim("salary_transparency", "薪资透明", ["salary"], () => yes("salary_opaque") ? 45 : /每月\d+日|月结|发放/.test(jd) ? 85 : 65),
|
|
159
|
+
dim("work_intensity", "工作强度", ["hours"], () => yes("overtime") ? /不接受.*加班|不希望.*夜间|长期高强度/.test(st) ? 25 : 45 : facts.overtime.status === "negated" ? 85 : 65, studentEvidence),
|
|
160
|
+
dim("compliance_safety", "合规安全", ["agreement"], () => red.length ? 0 : !yes("agreement") ? 20 : 75),
|
|
161
|
+
dim("conversion_clarity", "转正清晰", ["conversion"], () => yes("conversion") ? 80 : 30),
|
|
162
|
+
dim("resume_value", "简历增益", ["portfolio", "project"], () => (yes("portfolio") ? 40 : 10) + (yes("project") ? 45 : 10))
|
|
163
|
+
];
|
|
164
|
+
if (facts.conversion_offer.status === "negated" && /不要求转正|不以转正|不需要转正/.test(st)) dimensions[5] = { ...dimensions[5], score: null, status: "not_applicable", evidence: [...facts.conversion_offer.evidence, ...studentEvidence] };
|
|
165
|
+
if (red.length) dimensions[4] = { ...dimensions[4], score: 0, status: "assessed", evidence: red.flatMap(r => r.evidence) };
|
|
166
|
+
const scored = dimensions.filter(d => d.score !== null);
|
|
167
|
+
const ready = !missing.length && !conflicts.length && dimensions.every(d => d.score !== null || d.status === "not_applicable");
|
|
168
|
+
const overall_score = ready ? Math.round(scored.reduce((n, d) => n + d.score, 0) / scored.length) : null;
|
|
169
|
+
let route = "followup", risk_level = "unknown", one_sentence_reason = "资料或证据仍有缺口,暂不生成综合分。";
|
|
170
|
+
if (red.length) { route = "reject"; risk_level = "critical"; one_sentence_reason = "岗位或 HR 明确提出收费、贷款或无薪安排,建议暂停推进并核实原文。"; }
|
|
171
|
+
else if (conflicts.length) { route = "verify"; risk_level = "uncertain"; one_sentence_reason = "已有说明相互矛盾,需要核实后才能形成结论。"; }
|
|
172
|
+
else if (!missing.length) {
|
|
173
|
+
route = ready && salaryFit !== "below_floor_or_range" && !risk_terms.length && !scored.some(d => d.score < 50) && overall_score >= 75 ? "advance" : "verify";
|
|
174
|
+
risk_level = route === "advance" ? "low" : "medium";
|
|
175
|
+
one_sentence_reason = route === "advance" ? "已提供的证据支持继续推进,接受前仍需书面确认。" : "已有部分判断依据,仍需核实风险与未知项。";
|
|
176
|
+
if (salaryFit === "below_floor_or_range") one_sentence_reason = "岗位薪资或区间下限低于你的底线,需要确认可接受金额后再决定。";
|
|
177
|
+
}
|
|
178
|
+
const questions = [...conflicts.map(k => `关于“${facts[k].label}”的说明相互矛盾,请提供最终书面安排。`), ...(salaryFit === "below_floor_or_range" ? ["对方能否书面确认达到你的薪资底线?"] : []), ...missing.map(k => QUESTIONS[k]),
|
|
179
|
+
...["mentor", "project", "portfolio", "conversion"].filter(k => facts[k].status === "unknown").map(k => QUESTIONS[k])];
|
|
180
|
+
return { facts, risk_terms, compliance_red_lines: red, compliance_missing_items: missing.map(id => ({ id, label: QUESTIONS[id] })),
|
|
181
|
+
conflicts: conflicts.map(key => ({ key, ...facts[key] })), dimensions, overall_score, score_coverage: { assessed: scored.length, total: dimensions.filter(d => d.status !== "not_applicable").length },
|
|
182
|
+
route, risk_level, one_sentence_reason, preference_checks: { salary_fit: salaryFit, evidence: [...facts.salary.evidence, ...studentEvidence] }, required_followup_questions: [...new Set(questions)].slice(0, 3),
|
|
183
|
+
decision_rules_triggered: { critical_compliance_first: red.length > 0, missing_information_requires_followup: missing.length > 0, conflicting_evidence: conflicts.length > 0 } };
|
|
184
|
+
}
|
|
185
|
+
function modeFor(raw, explicit) {
|
|
186
|
+
if (explicit) return explicit;
|
|
187
|
+
if (/怎么和.*HR|怎么.*说|谈薪|谈判|沟通话术|^(?:回复)?\s*2$/i.test(raw)) return "negotiation";
|
|
188
|
+
if (/面试追问|面试准备|反问|^(?:回复)?\s*1$/.test(raw)) return "interview_prep";
|
|
189
|
+
if (/接受条件|^(?:回复)?\s*3$/.test(raw)) return "accept_conditions";
|
|
190
|
+
return /详细报告/.test(raw) ? "full_report" : "single_check";
|
|
191
|
+
}
|
|
192
|
+
function dateNow() {
|
|
193
|
+
const parts = new Intl.DateTimeFormat("en-US", { timeZone: "Asia/Shanghai", year: "numeric", month: "2-digit", day: "2-digit" }).formatToParts(new Date());
|
|
194
|
+
const get = k => parts.find(p => p.type === k).value;
|
|
195
|
+
return `${get("year")}-${get("month")}-${get("day")}`;
|
|
196
|
+
}
|
|
197
|
+
function cards(result, raw, mode) {
|
|
198
|
+
const requested = [
|
|
199
|
+
[/薪资|谈薪|工资/, "能否说明薪资金额、发放时间及绩效或扣款规则?"],
|
|
200
|
+
[/转正|留用/, QUESTIONS.conversion], [/加班|工时|工作时间/, QUESTIONS.hours],
|
|
201
|
+
[/导师|带教/, QUESTIONS.mentor], [/协议|合同/, QUESTIONS.agreement]
|
|
202
|
+
].filter(([p]) => p.test(raw)).map(([, q]) => q);
|
|
203
|
+
let topics = result.required_followup_questions.filter(q => ![QUESTIONS.jd, QUESTIONS.student, QUESTIONS.preferences, QUESTIONS.city].includes(q));
|
|
204
|
+
if (mode === "negotiation" && requested.length) topics = requested;
|
|
205
|
+
if (mode === "interview_prep") topics = [QUESTIONS.salary, QUESTIONS.project, QUESTIONS.mentor];
|
|
206
|
+
if (!topics.length) topics = [QUESTIONS.salary, QUESTIONS.agreement, QUESTIONS.hours];
|
|
207
|
+
return topics.slice(0, 3).map((question, i) => ({ role: mode === "interview_prep" ? ["HR", "直属主管", "业务团队"][i] : "HR", question, polite: `您好,为便于安排实习,我想确认:${question}`, firm: `这会影响我的决定,方便入职前书面确认吗?${question}`,
|
|
208
|
+
followup: "目前已确定的是哪部分,剩余部分由谁在什么时间确认?", acceptable_answer: "明确的安排、负责人和确认时间;未确定的部分应如实说明。" }));
|
|
209
|
+
}
|
|
210
|
+
function changes(previous, current) {
|
|
211
|
+
if (!previous?.records?.length) return [];
|
|
212
|
+
const before = decision(previous.records.map(r => record(r.source, r.text)));
|
|
213
|
+
return Object.keys(current.facts).filter(k => JSON.stringify([before.facts[k].status, before.facts[k].value]) !== JSON.stringify([current.facts[k].status, current.facts[k].value]))
|
|
214
|
+
.map(key => ({ key, before: before.facts[key].status, after: current.facts[key].status, previous_value: before.facts[key].value ?? null, current_value: current.facts[key].value ?? null, evidence: current.facts[key].evidence }));
|
|
215
|
+
}
|
|
216
|
+
const safeQuote = s => s.replace(/[`|]/g, "").replace(/([\[\]()*_<>#!])/g, "\\$1");
|
|
217
|
+
function render(r) {
|
|
218
|
+
const labels = { affirmed: "已明确", negated: "已明确不存在", unknown: "未知", conflict: "证据冲突" };
|
|
219
|
+
const titles = { negotiation: "HR 沟通话术", interview_prep: "面试追问", accept_conditions: "Offer 接受条件", full_report: "完整决策报告", single_check: "决策摘要" };
|
|
220
|
+
const lines = [`# OfferGuard ${titles[r.mode] ?? "决策摘要"}`, `报告日期:${r.report_date}`];
|
|
221
|
+
if (["negotiation", "interview_prep"].includes(r.mode)) {
|
|
222
|
+
if (r.compliance_red_lines.length) lines.push(`重大风险提示:${r.one_sentence_reason}`);
|
|
223
|
+
for (const c of r.negotiation_cards) lines.push(`- ${c.role}:${c.polite}\n\n对方模糊回应时:${c.followup}\n\n可接受答案:${c.acceptable_answer}`);
|
|
224
|
+
return lines.join("\n\n");
|
|
225
|
+
}
|
|
226
|
+
if (r.mode === "accept_conditions") return lines.concat(r.accept_conditions.map(x => `- ${x}`)).join("\n\n");
|
|
227
|
+
lines.push(`建议:${{ advance: "继续推进", verify: "先核实", reject: "暂停推进", followup: "补充资料" }[r.route]}`, r.one_sentence_reason,
|
|
228
|
+
`风险:${{ low: "低", medium: "中", critical: "重大风险信号", unknown: "信息不足", uncertain: "证据冲突" }[r.risk_level]};综合分:${r.overall_score ?? "暂不评分"}。`);
|
|
229
|
+
const items = r.compliance_red_lines.flatMap(x => x.evidence).concat(r.risk_terms.flatMap(x => x.evidence), r.dimensions.flatMap(d => d.evidence));
|
|
230
|
+
const seen = new Set();
|
|
231
|
+
for (const e of items.filter(e => ["jd", "hr"].includes(e.source))) {
|
|
232
|
+
if (seen.size >= 3) break; if (seen.has(e.quote)) continue;
|
|
233
|
+
seen.add(e.quote); lines.push(`- 依据(${e.source === "hr" ? "HR 回复" : "岗位原文"}):${safeQuote(e.quote)}`);
|
|
234
|
+
}
|
|
235
|
+
if (r.changes.length) lines.push("本轮更新:", ...r.changes.map(c => `- ${r.facts[c.key].label}:${labels[c.before]} → ${labels[c.after]},依据补充材料。`));
|
|
236
|
+
if (r.required_followup_questions.length) lines.push("优先确认:", ...r.required_followup_questions.map(q => `- ${q}`));
|
|
237
|
+
if (r.mode === "full_report") lines.push("七维评分(未知项不计分):", ...r.dimensions.map(d => `- ${d.name}:${d.status === "not_applicable" ? "不适用" : d.score ?? "未知"}`),
|
|
238
|
+
"接受条件:", ...r.accept_conditions.map(x => `- ${x}`), `MCP工具审计摘要:${r.tool} ${TOOL_VERSION};规则 ${RULE_VERSION};命中 ${r.risk_terms.length} 项风险信号。`);
|
|
239
|
+
lines.push("下一步:回复1准备面试追问;回复2生成HR话术;回复3查看接受条件;也可以直接补充HR回复。", "基于你提供的信息辅助判断,未独立核验企业事实;评分是规则估计。");
|
|
240
|
+
return lines.join("\n\n");
|
|
241
|
+
}
|
|
242
|
+
export function auditOfferGuardInput(input = {}) {
|
|
243
|
+
const start = performance.now();
|
|
244
|
+
const envelopeText = text(input.raw_input);
|
|
245
|
+
if (envelopeText.startsWith("{")) {
|
|
246
|
+
let envelope;
|
|
247
|
+
try { envelope = JSON.parse(envelopeText); } catch { /* Ordinary non-JSON user text is handled below. */ }
|
|
248
|
+
if (envelope?.offerguard_schema === 1) {
|
|
249
|
+
if (envelopeText.length > 40000) throw new Error("Assessment context too long.");
|
|
250
|
+
input = { raw_input: envelope.user_message, structured_input: envelope.structured_input, previous_state: envelope.previous_state, mode: envelope.mode, new_assessment: envelope.new_assessment };
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
validateInput(input);
|
|
254
|
+
if (input.new_assessment) input = { ...input, previous_state: undefined };
|
|
255
|
+
if (input.structured_input?.offers?.length) return compareOffers(input);
|
|
256
|
+
const raw = text(input.raw_input ?? input.AGENT_USER_INPUT);
|
|
257
|
+
const offerMarkers = [...raw.matchAll(/(?:^|\n)\s*(?:Offer\s*)?([AB])\s*[::]/gi)];
|
|
258
|
+
if (offerMarkers.length === 2 && offerMarkers[0][1].toUpperCase() !== offerMarkers[1][1].toUpperCase()) {
|
|
259
|
+
const profileParts = splitRaw(raw.slice(0, offerMarkers[0].index)).filter(r => r.source === "student").map(r => r.text);
|
|
260
|
+
const offers = offerMarkers.map((m, i) => {
|
|
261
|
+
const docs = splitRaw(`JD:${raw.slice(m.index + m[0].length, offerMarkers[i + 1]?.index ?? raw.length)}`);
|
|
262
|
+
profileParts.push(...docs.filter(r => r.source === "student").map(r => r.text));
|
|
263
|
+
return { id: m[1].toUpperCase(), jd_text: docs.filter(r => r.source === "jd").map(r => r.text).join("\n"), hr_reply: docs.filter(r => r.source === "hr").map(r => r.text).join("\n") };
|
|
264
|
+
});
|
|
265
|
+
const extraProfile = input.structured_input?.student_profile ?? input.studentProfile;
|
|
266
|
+
if (extraProfile) profileParts.push(profileText(extraProfile));
|
|
267
|
+
return compareOffers({ structured_input: { offers, student_profile: profileParts.join("\n") } });
|
|
268
|
+
}
|
|
269
|
+
if (/Offer对比|两个岗位|两个实习|帮我选哪个|(?:^|\n)\s*A\s*[::]/i.test(raw)) return {
|
|
270
|
+
tool: "offerguard_audit", version: TOOL_VERSION, mcp_ok: true, mode: "compare_offers", route: "followup", route_marker: "ROUTE: followup", route_real_marker: "ROUTE_REAL: followup", overall_score: null, risk_level: "unknown", report_date: dateNow(),
|
|
271
|
+
required_followup_questions: ["请分别提供 A、B 岗位原文和共同学生画像,由工作流拆成独立 offers 输入。"], report_text: "请分别提供 A、B 岗位信息和学生画像,以便独立审计;暂不评分。"
|
|
272
|
+
};
|
|
273
|
+
const records = collect(input), result = decision(records), mode = modeFor(raw, input.mode);
|
|
274
|
+
const out = { tool: "offerguard_audit", version: TOOL_VERSION, rule_version: RULE_VERSION, mcp_ok: true, report_date: dateNow(), mode, ...result,
|
|
275
|
+
route_marker: `ROUTE: ${result.route}`, route_real_marker: `ROUTE_REAL: ${result.route}`, changes: changes(input.previous_state, result), negotiation_cards: cards(result, raw, mode),
|
|
276
|
+
accept_conditions: [...(result.compliance_red_lines.length ? ["先解决已明确的收费、贷款或无薪安排,核实后再决定。"] : []), "书面确认薪资、发放周期和工作时间,并符合自己的底线。", "确认实习协议、核心任务、带教安排和可获得的证明材料。", ...(result.conflicts.length ? ["对矛盾说法取得可核对的最终书面说明。"] : []), "尚未全部确认时,不视为条件已经满足。"],
|
|
277
|
+
next_state: { schema_version: 1, records },
|
|
278
|
+
mcp_audit_summary: { risk_hit_count: result.risk_terms.length, red_line_count: result.compliance_red_lines.length, missing_item_count: result.compliance_missing_items.length, score_route: result.route, score_risk_level: result.risk_level, rule_version: RULE_VERSION },
|
|
279
|
+
timing_ms: Math.round((performance.now() - start) * 100) / 100 };
|
|
280
|
+
out.report_text = render(out); return out;
|
|
281
|
+
}
|
|
282
|
+
function validateInput(input) {
|
|
283
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error("Assessment input must be an object.");
|
|
284
|
+
if (input.mode && !["single_check", "negotiation", "interview_prep", "accept_conditions", "full_report"].includes(input.mode)) throw new Error("Invalid assessment mode.");
|
|
285
|
+
for (const field of ["raw_input", "AGENT_USER_INPUT", "hr_reply"]) {
|
|
286
|
+
if (input[field] !== undefined && typeof input[field] !== "string") throw new Error(`Invalid ${field} type.`);
|
|
287
|
+
if (input[field]?.length > 40000) throw new Error("Assessment context too long.");
|
|
288
|
+
}
|
|
289
|
+
if (input.new_assessment !== undefined && typeof input.new_assessment !== "boolean") throw new Error("Invalid new_assessment type.");
|
|
290
|
+
const s = input.structured_input;
|
|
291
|
+
if (s !== undefined) {
|
|
292
|
+
if (!s || typeof s !== "object" || Array.isArray(s)) throw new Error("Invalid structured_input.");
|
|
293
|
+
for (const k of ["original_input", "jd_text", "student_profile", "hr_reply"]) if (s[k] !== undefined && typeof s[k] !== "string") throw new Error(`Invalid ${k} type.`);
|
|
294
|
+
if (s.original_input?.length > 40000) throw new Error("Original input too long.");
|
|
295
|
+
if (s.offers !== undefined) {
|
|
296
|
+
if (!Array.isArray(s.offers) || s.offers.length !== 2) throw new Error("Exactly two offers are required.");
|
|
297
|
+
for (const o of s.offers) {
|
|
298
|
+
if (!o || typeof o.id !== "string" || !o.id.trim() || o.id.length > 40 || typeof o.jd_text !== "string" || !o.jd_text.trim() || (o.hr_reply !== undefined && typeof o.hr_reply !== "string")) throw new Error("Invalid offer fields.");
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
if (s.original_input !== undefined) {
|
|
302
|
+
const excerpts = [s.jd_text, s.student_profile, s.hr_reply, ...(s.offers ?? []).flatMap(o => [o.jd_text, o.hr_reply])].filter(Boolean);
|
|
303
|
+
for (const excerpt of excerpts) if (!s.original_input.includes(excerpt)) throw new Error("Extracted source is not a literal excerpt of original_input.");
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
function compareOffers(input) {
|
|
308
|
+
const s = input.structured_input, offers = s.offers;
|
|
309
|
+
if (offers.length !== 2 || new Set(offers.map(o => o.id)).size !== 2) throw new Error("Provide exactly two offers with distinct IDs.");
|
|
310
|
+
const results = offers.map(o => ({ id: o.id, ...auditOfferGuardInput({ structured_input: { jd_text: o.jd_text, hr_reply: o.hr_reply, student_profile: s.student_profile } }) }));
|
|
311
|
+
const eligible = results.filter(r => r.route !== "reject"), ranked = eligible.filter(r => r.overall_score !== null).sort((a, b) => b.overall_score - a.overall_score);
|
|
312
|
+
const winner = ranked.length === eligible.length && ranked.length && ranked[0].route === "advance" && (ranked.length === 1 || ranked[0].overall_score > ranked[1].overall_score) ? ranked[0].id : null;
|
|
313
|
+
const report_date = dateNow();
|
|
314
|
+
return { tool: "offerguard_audit", version: TOOL_VERSION, rule_version: RULE_VERSION, mcp_ok: true, mode: "compare_offers", route: "compare", route_marker: "ROUTE: compare", route_real_marker: "ROUTE_REAL: compare", report_date,
|
|
315
|
+
offers: results, recommended_offer: winner, overall_score: null, backup_conditions: results.map(r => ({ id: r.id, conditions: r.accept_conditions, reason: r.one_sentence_reason })),
|
|
316
|
+
report_text: ["# OfferGuard Offer 对比", `报告日期:${report_date}`, winner ? `优先考虑:${safeQuote(winner)},依据相同规则下的独立审计。` : "暂不能确定优先选择;请先核实缺口、冲突或相同评分下的个人取舍。", ...results.map(r => `- ${safeQuote(r.id)}:${r.one_sentence_reason} 综合分:${r.overall_score ?? "未知"}。需确认:${r.required_followup_questions.join(" ") || "接受条件"}`)].join("\n\n") };
|
|
317
|
+
}
|
|
318
|
+
export function matchRiskTerms(input = {}) {
|
|
319
|
+
const facts = factsFor(collect(typeof input === "string" ? { jdText: input } : input)), risk_terms = risks(facts);
|
|
320
|
+
return { tool: "risk_term_matcher", version: TOOL_VERSION, facts, risk_terms, summary: { total_hits: risk_terms.length, highest_severity: risk_terms.some(r => r.severity === "critical") ? "critical" : risk_terms.length ? "medium" : "none" } };
|
|
321
|
+
}
|
|
322
|
+
export function checkInternshipCompliance(input = {}) {
|
|
323
|
+
const facts = factsFor(collect(input)), flags = risks(facts), red_lines = flags.filter(r => r.severity === "critical");
|
|
324
|
+
const missing_items = ["salary", "agreement", "hours"].filter(k => facts[k].status === "unknown").map(id => ({ id, label: QUESTIONS[id] }));
|
|
325
|
+
return { tool: "internship_compliance_checker", version: TOOL_VERSION, facts, compliance_flags: flags, red_lines, missing_items, summary: { red_line_count: red_lines.length, missing_item_count: missing_items.length, can_generate_report: true, can_generate_score: !missing_items.length && !Object.values(facts).some(f => f.status === "conflict") } };
|
|
326
|
+
}
|
|
327
|
+
export function calculateOfferScore(input = {}) {
|
|
328
|
+
// Recompute from source documents rather than trusting caller-supplied scores.
|
|
329
|
+
return { ...auditOfferGuardInput(input), tool: "offer_score_calculator" };
|
|
330
|
+
}
|