classify.mjs 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573
  1. // Copyright (c) 未来飞马
  2. //
  3. // This Source Code Form is subject to the terms of the Mozilla Public
  4. // License, v. 2.0. If a copy of the MPL was not distributed with this
  5. // file, You can obtain one at https://mozilla.org/MPL/2.0/.
  6. //
  7. // Trademark Notice:
  8. // The MPL-2.0 license grants copyright permissions for source code only.
  9. // It does NOT grant any rights to use trademarks including "未来飞马",
  10. // "Harness Loop", "RSI", and associated slogan "让AI进化提前发生,让AI落地快人一步".
  11. // Any use of these trademarks requires separate written permission.
  12. /**
  13. * 分类层:案例信息 vs 素材 分离、打标、合规判断
  14. *
  15. * 三类知识结构严格分开:
  16. * ① 案例说明(什么时候用 / 大概情况 / 背景)→ 案例字段
  17. * title / summary / targetCustomer / usageSuggestion / resultEvidence / outcome
  18. * ② 图片视频 → materialAssets[](本文件不动顺序,只做角色与标签)
  19. * ③ 合规判定 → riskFlags / privacyFindings
  20. *
  21. * 打标依据:docs/prd/dashboard/05-标签与别名管理.md + 10-案例推荐策略.md,
  22. * 取值来源 references/tag-dictionary.json(技能会在采集过程中增量更新)。
  23. */
  24. import { uniq, truncate } from './lib.mjs';
  25. // ---------------------------------------------------------------------------
  26. // 隐私发现(本期只记录,不打码;片段做掩码,绝不落完整 PII)
  27. // ---------------------------------------------------------------------------
  28. const PII_RULES = [
  29. { field: '手机号', rule: 'phone', re: /(?:\+?86[-\s]?)?1[3-9]\d{9}\b/g, mask: (m) => `${m.slice(0, 3)}****${m.slice(-4)}` },
  30. { field: '邮箱', rule: 'email', re: /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, mask: (m) => `${m[0]}***@${m.split('@')[1]}` },
  31. { field: '微信号', rule: 'wechat', re: /(?:微信|WeChat|wechat|VX|vx)\s*[::]?\s*[A-Za-z][-_A-Za-z0-9]{5,19}/g, mask: () => '微信:[已掩码]' },
  32. { field: 'QQ', rule: 'qq', re: /(?:QQ|qq)\s*[::]?\s*\d{5,12}/g, mask: () => 'QQ:[已掩码]' },
  33. { field: '身份证号', rule: 'id-card', re: /\b\d{17}[\dXx]\b/g, mask: (m) => `${m.slice(0, 3)}************${m.slice(-3)}` },
  34. { field: '银行卡号', rule: 'bank-card', re: /\b\d{16,19}\b/g, mask: (m) => `${m.slice(0, 4)}********${m.slice(-4)}` },
  35. { field: '称呼', rule: 'student-name', re: /(?:姓名|学员|学生|同学)\s*[::]\s*[一-龥]{2,4}/g, mask: (m) => `${m.split(/[::]/)[0]}:[已掩码]` },
  36. ];
  37. /**
  38. * 在文本中找隐私片段。**片段一律掩码后再返回**(真实 PII 不持久化,红线)。
  39. * @returns {Array<{field:string, rule:string, snippet:string}>}
  40. */
  41. export function findPrivacy(text) {
  42. const findings = [];
  43. const source = String(text || '');
  44. if (!source) return findings;
  45. for (const pii of PII_RULES) {
  46. const matches = source.match(pii.re);
  47. if (!matches) continue;
  48. for (const match of uniq(matches).slice(0, 5)) {
  49. findings.push({ field: pii.field, rule: pii.rule, snippet: truncate(pii.mask(match), 40) });
  50. }
  51. }
  52. return findings;
  53. }
  54. /**
  55. * 把图片理解(vision)给出的 PII 线索转成与规则同一形状的隐私发现。
  56. * 为什么需要:截图里的姓名/头像只存在于像素中,文本正则永远扫不到;
  57. * 不并进来就会「图里有 4 个姓名,privacyFindings 却是 0 条」——审核员会被误导。
  58. * 片段同样一律掩码(只留首字 + 圆点),绝不落完整 PII。
  59. * @param {Array<{field?:string, snippet?:string}>} hints
  60. * @returns {Array<{field:string, rule:string, snippet:string}>}
  61. */
  62. export function normalizePiiHints(hints) {
  63. const out = [];
  64. for (const hint of Array.isArray(hints) ? hints : []) {
  65. if (!hint || !hint.field) continue;
  66. const field = String(hint.field);
  67. const raw = String(hint.snippet || '').trim();
  68. let snippet;
  69. if (field === '头像') {
  70. snippet = '头像:[图片内容已标记]';
  71. } else if (!raw) {
  72. snippet = `${field}:[已掩码]`;
  73. } else {
  74. snippet = `${raw.slice(0, 1)}${'*'.repeat(Math.max(1, Math.min(raw.length, 8) - 1))}`;
  75. }
  76. out.push({ field, rule: 'vision', snippet: truncate(snippet, 40) });
  77. }
  78. return out;
  79. }
  80. /** 按 字段+片段 去重(同一姓名被规则和 vision 各命中一次时只留一条)。 */
  81. function dedupeFindings(findings) {
  82. const seen = new Set();
  83. const out = [];
  84. for (const f of findings) {
  85. const key = `${f.field}|${f.snippet}`;
  86. if (seen.has(key)) continue;
  87. seen.add(key);
  88. out.push(f);
  89. }
  90. return out;
  91. }
  92. // ---------------------------------------------------------------------------
  93. // 合规关键词
  94. // ---------------------------------------------------------------------------
  95. // 关键词只描述「我方服务出的负面问题」,不包含学生自身的前置困难。
  96. // 「挂科 / 补考 / 被拒 / 退款」本身是痛点与需求(甚至对应「挂科挽救」「退费保障」等亮点),
  97. // 命中它们会把最好的案例误判成负面事件——因此一律不在此列。
  98. const RISK_KEYWORDS = {
  99. COMPLAINT: [
  100. '投诉', '客诉', '差评', '维权', '举报', '曝光',
  101. '退费纠纷', '退款纠纷', '要求退款', '要求赔偿', '协商赔偿', '黑猫',
  102. ],
  103. NEGATIVE_EVENT: [
  104. '投诉老师', '要求换老师', '老师失联', '服务事故', '课时不符',
  105. '没效果', '没有效果', '效果不好', '毫无效果',
  106. '成绩造假', '伪造材料', '数据造假',
  107. ],
  108. };
  109. const TOPIC_KEYWORDS = ['留学', '申请', '课程', '辅导', '补考', '提分', '录取', '申诉', '学校', '专业', '成绩', '成绩单', '老师', '答疑', 'GPA', '雅思', '托福', 'A-Level', 'AP', 'IB', 'case', 'offer', '期末', '考试', '论文'];
  110. // ---------------------------------------------------------------------------
  111. // 打标关键词(中文线索 → 字典取值)
  112. // ---------------------------------------------------------------------------
  113. const TAG_CLUES = {
  114. highlightTypes: {
  115. 提分: ['提分', '提高了', '涨了', '分数上升', '从\d+分?到\d+分?'],
  116. 录取: ['录取', 'offer', '拿到了', '上岸', 'offer letter'],
  117. 申诉成功: ['申诉成功', '申诉通过', '撤销处分', '撤销学术不端', 'appeal 成功'],
  118. 补考通过: ['补考通过', '补考 pass', '补考过了', 'pass 了', '补考稳 pass'],
  119. 稳分: ['稳分', '稳住', '保分', '不再挂'],
  120. 高分冲刺: ['冲刺高分', '冲高分', 'distinction', '一等学位'],
  121. 挂科挽救: ['挂科挽救', '挽救', '差点挂', '补救'],
  122. 退费保障: ['退费', '保障', '不过退'],
  123. 家长认可: ['家长', '妈妈', '爸爸', '家长很满意', '家长认可'],
  124. 出分反馈: ['出分', '成绩出来了', '反馈成绩', '查分'],
  125. 首课体验: ['首课', '试听', '第一节课'],
  126. 导师匹配: ['匹配导师', '导师匹配', '换到合适的老师', '老师很对口'],
  127. 群内答疑: ['群内答疑', '答疑群', '随时问', '响应很快'],
  128. 课时反馈: ['课时反馈', '每节课反馈', '课后反馈', '课堂反馈'],
  129. 大课时囤课: ['大课时', '囤课', '续课', '加课'],
  130. },
  131. scenarioTags: {
  132. 考前冲刺: ['考前', '冲刺', '临近考试', '来不及'],
  133. 开学季: ['开学', '新学期'],
  134. 期中考试: ['期中'],
  135. 期末考试: ['期末', 'final'],
  136. 补考季: ['补考', 'resit', '补考季'],
  137. 论文季: ['论文', 'dissertation', '毕业论文'],
  138. 选课指导: ['选课', '选课指导'],
  139. 转专业: ['转专业', '换专业', '转系'],
  140. 申诉期: ['申诉', 'appeal'],
  141. 退费咨询: ['退费', '退款'],
  142. 家长陪同: ['家长', '陪读', '妈妈', '爸爸'],
  143. 多地时差: ['时差', '国外时间', '半夜'],
  144. 时间紧张: ['时间不够', '时间紧', '来不及', '任务重'],
  145. 基础薄弱: ['基础差', '基础薄弱', '零基础', '底子差'],
  146. 目标高分: ['高分', '冲分', 'distinction', '一等'],
  147. 临近毕业: ['毕业', '大四', '最后一年'],
  148. },
  149. objectionTags: {
  150. 贵: ['太贵', '贵了', '价格高', '预算不够', '便宜点'],
  151. 不放心老师: ['不放心老师', '老师行不行', '老师靠不靠谱', '换老师'],
  152. 犹豫: ['再想想', '犹豫', '考虑一下', '还没想好'],
  153. 怕没效果: ['没效果', '有用吗', '能提分吗', '真的能'],
  154. 怕时间不够: ['来不及', '时间不够', '太晚了'],
  155. 要和家里商量: ['和家里商量', '问下父母', '跟爸妈'],
  156. 对比其他机构: ['其他机构', '别家', '对比一下', '也在看'],
  157. 担心退费难: ['退费难', '能退吗', '退款麻烦'],
  158. },
  159. };
  160. function matchesAny(text, needles) {
  161. return needles.some((needle) => {
  162. if (needle.startsWith('从') || needle.includes('\\d')) {
  163. try {
  164. return new RegExp(needle).test(text);
  165. } catch {
  166. return text.includes(needle);
  167. }
  168. }
  169. return text.toLowerCase().includes(needle.toLowerCase());
  170. });
  171. }
  172. /** 按字典 + 关键词线索打标,返回命中值与被用到但字典里没有的新值。 */
  173. export function classifyTags(text, dict) {
  174. const corpus = String(text || '');
  175. const dims = (dict && dict.dimensions) || {};
  176. const out = {};
  177. const learned = [];
  178. const pickFromDictionary = (dimension) => {
  179. const values = Array.isArray(dims[dimension] && dims[dimension].values) ? dims[dimension].values : [];
  180. return values.filter((value) => corpus.includes(value));
  181. };
  182. // 硬性静态属性:字典值直接命中
  183. out.country = pickFromDictionary('country').slice(0, 1);
  184. out.schoolCanonical = pickFromDictionary('schoolCanonical');
  185. out.major = pickFromDictionary('major');
  186. out.subject = pickFromDictionary('subject');
  187. out.stage = pickFromDictionary('stage').slice(0, 1);
  188. // 产品线(硬规则:三选一,字典里没有则不猜)
  189. out.productLine = pickFromDictionary('productLine').slice(0, 1);
  190. // 关键词线索打标
  191. for (const dimension of ['highlightTypes', 'scenarioTags', 'objectionTags']) {
  192. const clues = TAG_CLUES[dimension] || {};
  193. const hits = [];
  194. for (const [value, needles] of Object.entries(clues)) {
  195. if (matchesAny(corpus, needles)) hits.push(value);
  196. else if (corpus.includes(value)) hits.push(value);
  197. }
  198. out[dimension] = uniq(hits);
  199. }
  200. // 字典里已有但关键词没覆盖到的补充命中(避免漏标字典取值)
  201. for (const dimension of ['highlightTypes', 'scenarioTags', 'objectionTags']) {
  202. const known = Array.isArray(dims[dimension] && dims[dimension].values) ? dims[dimension].values : [];
  203. out[dimension] = uniq([...(out[dimension] || []), ...known.filter((v) => corpus.includes(v))]);
  204. }
  205. // 字典自进化候选:文本里出现的、形如标签的短语(保守:只在强线索命中后才收集)
  206. return { tags: out, learned };
  207. }
  208. // ---------------------------------------------------------------------------
  209. // 学校别名归一
  210. // ---------------------------------------------------------------------------
  211. /**
  212. * 把文本里的别名写法归一为标准校名。
  213. * @returns {{schoolCanonical:string, schoolAliases:string[], country:string, hits:object[]}}
  214. */
  215. export function normalizeSchool(text, dict) {
  216. const corpus = String(text || '');
  217. const entries = (dict && dict.dimensions && dict.dimensions.schoolAlias && dict.dimensions.schoolAlias.entries) || [];
  218. const canonicalNames = (dict && dict.dimensions && dict.dimensions.schoolCanonical && dict.dimensions.schoolCanonical.values) || [];
  219. const hits = [];
  220. for (const entry of entries) {
  221. const alias = String(entry.aliasText || '');
  222. if (!alias) continue;
  223. // 英文别名按词边界匹配,避免 "IC" 命中 "MAGIC";中文直接包含
  224. const isAscii = /^[\x20-\x7e]+$/.test(alias);
  225. const hit = isAscii
  226. ? new RegExp(`(^|[^A-Za-z0-9])${alias.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^A-Za-z0-9]|$)`, 'i').test(corpus)
  227. : corpus.includes(alias);
  228. if (hit) hits.push({ aliasText: alias, canonicalName: entry.canonicalName, country: entry.country || '' });
  229. }
  230. // 标准名直接出现(此时国家从别名表里同名校名的条目推断)
  231. const directHits = canonicalNames.filter((name) => corpus.includes(name));
  232. const countryOf = (canonical) => {
  233. const entry = entries.find((e) => String(e.canonicalName) === canonical);
  234. return entry ? String(entry.country || '') : '';
  235. };
  236. const canonical = uniq([...hits.map((h) => h.canonicalName), ...directHits])[0] || '';
  237. const country = (hits.find((h) => h.canonicalName === canonical) || {}).country || countryOf(canonical);
  238. return {
  239. schoolCanonical: canonical,
  240. // 命中的别名写法(不含标准名本身),供写入 CaseAsset.schoolAliases
  241. schoolAliases: uniq(hits.map((h) => h.aliasText)),
  242. country,
  243. hits,
  244. };
  245. }
  246. // ---------------------------------------------------------------------------
  247. // 推荐适用跟进状态(docs/prd/dashboard/10-案例推荐策略.md)
  248. // ---------------------------------------------------------------------------
  249. const STRATEGY_MAP = [
  250. { status: '异议处理中', when: (t) => t.objectionTags.length > 0 || ['出分反馈', '家长认可', '退费保障', '大课时囤课'].some((v) => t.highlightTypes.includes(v)) },
  251. { status: '待决策', when: (t) => ['录取', '提分', '高分冲刺'].some((v) => t.highlightTypes.includes(v)) && t.scenarioTags.includes('考前冲刺') === false },
  252. { status: '挖需中', when: (t) => ['补考通过', '挂科挽救', '稳分'].some((v) => t.highlightTypes.includes(v)) || t.scenarioTags.some((s) => ['时间紧张', '基础薄弱', '补考季'].includes(s)) },
  253. { status: '方案推荐中', when: (t) => ['导师匹配', '群内答疑', '课时反馈'].some((v) => t.highlightTypes.includes(v)) },
  254. { status: '沉默待跟进', when: (t) => t.scenarioTags.includes('考前冲刺') || t.highlightTypes.includes('出分反馈') },
  255. { status: '已成交', when: (t) => ['首课体验', '群内答疑', '课时反馈'].some((v) => t.highlightTypes.includes(v)) },
  256. { status: '新进线', when: (t) => t.subject.length > 0 },
  257. ];
  258. export function inferFitStatus(tags) {
  259. const t = {
  260. highlightTypes: tags.highlightTypes || [],
  261. scenarioTags: tags.scenarioTags || [],
  262. objectionTags: tags.objectionTags || [],
  263. subject: tags.subject || [],
  264. };
  265. const matched = STRATEGY_MAP.filter((rule) => rule.when(t)).map((rule) => rule.status);
  266. const ordered = ['新进线', '挖需中', '方案推荐中', '异议处理中', '待决策', '沉默待跟进', '已成交'];
  267. const fitStatus = ordered.filter((s) => matched.includes(s));
  268. return {
  269. fitStatus,
  270. reason: fitStatus.length
  271. ? `按「跟进状态 → 该发什么案例」策略命中:${fitStatus.join('、')}`
  272. : '未命中策略规则,需人工指定适用跟进状态',
  273. };
  274. }
  275. // ---------------------------------------------------------------------------
  276. // 案例 vs 素材 分离
  277. // ---------------------------------------------------------------------------
  278. /**
  279. * 从各种来源抽出「案例说明」文本,与素材数组分开。
  280. * @param {object} input
  281. * - textBundle: { paragraphs:string[], notes:string[], titles:string[], transcript:string }
  282. * - visionResults: [{ocrText, description, usageSuggestion, role, label, ok}]
  283. */
  284. export function splitCaseAndMaterials(input = {}) {
  285. const bundle = input.textBundle || {};
  286. const descriptionTexts = [];
  287. const materialTexts = [];
  288. const push = (target, value) => {
  289. const text = String(value || '').trim();
  290. if (text) target.push(text);
  291. };
  292. for (const paragraph of bundle.paragraphs || []) push(descriptionTexts, paragraph);
  293. for (const note of bundle.notes || []) push(descriptionTexts, typeof note === 'string' ? note : note.text);
  294. push(descriptionTexts, bundle.transcript);
  295. for (const result of input.visionResults || []) {
  296. if (result.role === 'description') {
  297. push(descriptionTexts, result.ocrText);
  298. push(descriptionTexts, result.description);
  299. } else {
  300. push(materialTexts, result.ocrText);
  301. push(materialTexts, result.description);
  302. }
  303. }
  304. return {
  305. // 案例说明语料(用于抽 title/summary/usageSuggestion/outcome)
  306. descriptionCorpus: descriptionTexts.join('\n'),
  307. descriptionTexts,
  308. // 素材语料(用于打标、也用于素材 label)
  309. materialCorpus: materialTexts.join('\n'),
  310. materialTexts,
  311. };
  312. }
  313. /** 从说明语料里抽一句话摘要、使用建议、目标客户、结果证据。 */
  314. export function deriveCaseFields(corpus, groups, tags, fit) {
  315. const lines = String(corpus || '').split('\n').map((l) => l.trim()).filter(Boolean);
  316. const title = truncate(
  317. lines.find((l) => l.length >= 6 && l.length <= 40) || lines[0] || '未命名案例',
  318. 40,
  319. );
  320. const summaryLine = lines.find((l) => l.length >= 12 && /[,。!?]|提分|录取|通过|结果/.test(l)) || lines[1] || lines[0] || '';
  321. const summary = truncate(summaryLine || title, 120);
  322. const productLine = (tags.productLine || [])[0] || '';
  323. const school = (tags.schoolCanonical || [])[0] || '';
  324. const subject = (tags.subject || [])[0] || '';
  325. const stage = (tags.stage || [])[0] || '';
  326. const audience = [stage, school, subject, productLine].filter(Boolean).join(' · ');
  327. const usageSuggestion = (() => {
  328. const fits = fit.fitStatus || [];
  329. if (fits.includes('异议处理中')) return `客户在顾虑(${(tags.objectionTags || []).join('/') || '犹豫、怕没效果'})时发,用结果打消顾虑`;
  330. if (fits.includes('挖需中')) return `客户痛点对上(${(tags.scenarioTags || []).join('/') || '怕挂、时间紧'})时发,把痛点和方案对上`;
  331. if (fits.includes('沉默待跟进')) return '客户不回复时做低成本唤醒,考前节点发';
  332. if (fits.includes('待决策')) return '已报价、客户说考虑时发,给一条完整可参考的成单路径';
  333. if (fits.includes('已成交')) return '成交后发,让客户安心(服务过程类)';
  334. if (fits.includes('方案推荐中')) return '推产品阶段发,证明机制有效(导师匹配/群内答疑/课时反馈)';
  335. if (fits.includes('新进线')) return `刚加上还没破冰时发,用${subject || '所学科目'}的案例建立专业感`;
  336. return '按客户当前跟进状态选用';
  337. })();
  338. const resultEvidence = truncate(
  339. lines.filter((l) => /提分|录取|通过|出分|反馈|成绩|offer|pass/i.test(l)).slice(0, 2).join(';'),
  340. 160,
  341. );
  342. const outcome = extractOutcome(corpus);
  343. return {
  344. title,
  345. summary,
  346. targetCustomer: audience || '有同类需求的在读学生',
  347. usageSuggestion,
  348. resultEvidence,
  349. outcome,
  350. };
  351. }
  352. /** 抽结果数据:提分幅度、周期、录取/出分结果、学校专业。 */
  353. export function extractOutcome(corpus) {
  354. const text = String(corpus || '');
  355. const outcome = {};
  356. const scoreDelta = text.match(/(?:提分|提高|涨了|提升了?)\s*([\d.]+)\s*分/);
  357. if (scoreDelta) outcome.scoreGain = `${scoreDelta[1]}分`;
  358. const fromTo = text.match(/(\d{1,3})\s*分?\s*(?:到|→|->|至)\s*(\d{1,3})\s*分/);
  359. if (fromTo) outcome.scoreRange = `${fromTo[1]} → ${fromTo[2]}`;
  360. const duration = text.match(/(\d+)\s*(?:周|个月|月|天|课时)/);
  361. if (duration) outcome.period = duration[0];
  362. // 「被 / 收到 / 拿到 / 获得」后可选的「了 / 到」,再到校名 + 录取/offer
  363. const admitted = text.match(/(?:被|收到|拿到|获得)\s*(?:了|到)?\s*([一-龥A-Za-z][一-龥A-Za-z\s]{1,19}?)(?:的)?(?:录取|offer)/i);
  364. if (admitted) outcome.admittedTo = admitted[1].trim();
  365. const grade = text.match(/(?:GPA|均分|成绩)\s*(?:从)?\s*([\d.]+)/i);
  366. if (grade) outcome.grade = grade[1];
  367. if (/pass|通过|及格/i.test(text)) outcome.result = 'pass';
  368. else if (/distinction|一等/i.test(text)) outcome.result = 'distinction';
  369. return outcome;
  370. }
  371. // ---------------------------------------------------------------------------
  372. // 合规判断
  373. // ---------------------------------------------------------------------------
  374. /**
  375. * 能否进案例库?这是「客诉/负面事件」还是「可对外展示的好案例」?
  376. *
  377. * @param {object} input
  378. * - corpus: string 全部文本
  379. * - authorizationStatus: 'authorized' | 'pending' | 'denied' | 其它
  380. * - hasMaterials: boolean 是否有可用素材
  381. * - fields: 抽取出来的案例字段(用于判断信息完整度)
  382. * - piiHints: 图片理解给出的隐私线索 [{field, snippet}]
  383. * @returns {{authorizationOk:boolean, riskFlags:string[], privacyFindings:object[], complianceBlockers:string[], reviewHint:string}}
  384. */
  385. export function assessCompliance(input = {}) {
  386. const corpus = String(input.corpus || '');
  387. const riskFlags = [];
  388. const complianceBlockers = [];
  389. for (const [flag, needles] of Object.entries(RISK_KEYWORDS)) {
  390. if (matchesAny(corpus, needles)) riskFlags.push(flag);
  391. }
  392. // 文本正则 + 图片理解线索合并:截图类素材的姓名/头像只在像素里,必须两条腿走路
  393. const privacyFindings = dedupeFindings([
  394. ...findPrivacy(corpus),
  395. ...normalizePiiHints(input.piiHints),
  396. ]);
  397. if (privacyFindings.length) riskFlags.push('PII_RISK');
  398. // 主题相关性
  399. const topicHit = matchesAny(corpus, TOPIC_KEYWORDS) || (input.hasMaterials && corpus.trim().length > 0);
  400. if (!topicHit) riskFlags.push('OFF_TOPIC');
  401. // 授权判定(硬门)
  402. const authorizationStatus = String(input.authorizationStatus || '').toLowerCase();
  403. const authorizationOk = authorizationStatus === 'authorized';
  404. if (!authorizationOk) {
  405. riskFlags.push('UNAUTHORIZED');
  406. complianceBlockers.push(`无授权(authorizationStatus=${authorizationStatus || '缺失'})——一律不入库、不产生案例对象`);
  407. }
  408. if (!input.hasMaterials) {
  409. complianceBlockers.push('没有任何可用素材(materialAssets 为空),无法构成案例');
  410. }
  411. if (['COMPLAINT', 'NEGATIVE_EVENT'].includes(riskFlags.find((f) => f === 'COMPLAINT' || f === 'NEGATIVE_EVENT'))) {
  412. complianceBlockers.push('出现客诉/负面事件线索:这是内部复盘材料,不能作为对外展示的好案例进入公共素材库');
  413. }
  414. if (riskFlags.includes('OFF_TOPIC')) {
  415. complianceBlockers.push('与留学/课程/辅导主题无关,不符合案例规范');
  416. }
  417. let reviewHint = '可入库待审(reviewStatus=pending,不进公共素材库,需人工审核后放行)';
  418. if (riskFlags.includes('COMPLAINT') || riskFlags.includes('NEGATIVE_EVENT')) {
  419. reviewHint = '建议驳回或转内部复盘,不得对外展示';
  420. } else if (privacyFindings.length) {
  421. reviewHint = '存在隐私片段,打码前必须人工抽检(未通过不得进入检索结果)';
  422. }
  423. return {
  424. authorizationOk,
  425. riskFlags: uniq(riskFlags),
  426. privacyFindings,
  427. complianceBlockers,
  428. reviewHint,
  429. };
  430. }
  431. // ---------------------------------------------------------------------------
  432. // 汇总:把以上拼成一个「案例包」
  433. // ---------------------------------------------------------------------------
  434. /**
  435. * @param {object} input
  436. * - sourceType, sourceRef, authorizationStatus
  437. * - textBundle {paragraphs, notes, titles, transcript}
  438. * - visionResults [], groups []
  439. * - dict 已加载的标签字典
  440. * - hints { schoolCanonical, productLine, stage, tags:{...} } 人工/上游显式指定(优先级最高)
  441. * @returns {{caseFields:object, tags:object, compliance:object, learned:{tags:[],aliases:[]}}}
  442. */
  443. export function classifyCase(input = {}) {
  444. const dict = input.dict;
  445. const groups = input.groups || [];
  446. const visionResults = input.visionResults || [];
  447. const split = splitCaseAndMaterials({
  448. textBundle: input.textBundle,
  449. visionResults,
  450. });
  451. const groupsCorpus = groups.map((g) => g.preview || '').join('\n');
  452. const fullCorpus = [split.descriptionCorpus, split.materialCorpus, groupsCorpus].filter(Boolean).join('\n');
  453. // 打标:先关键词/字典,再用 hints 覆盖
  454. const { tags: rawTags } = classifyTags(fullCorpus, dict);
  455. const school = normalizeSchool(fullCorpus, dict);
  456. const hints = input.hints || {};
  457. const tags = {
  458. country: uniq([hints.country, school.country, ...(rawTags.country || [])]).slice(0, 1),
  459. productLine: uniq(hints.productLine ? [hints.productLine] : [], rawTags.productLine || []).slice(0, 1),
  460. schoolCanonical: uniq(hints.schoolCanonical ? [hints.schoolCanonical] : [], school.schoolCanonical ? [school.schoolCanonical] : [], rawTags.schoolCanonical || []),
  461. schoolAliases: uniq(school.schoolAliases),
  462. major: uniq(hints.major ? [hints.major] : [], rawTags.major || []),
  463. stage: uniq(hints.stage ? [hints.stage] : [], rawTags.stage || []).slice(0, 1),
  464. subject: uniq(rawTags.subject || []),
  465. highlightTypes: uniq([...(hints.highlightTypes || []), ...(rawTags.highlightTypes || [])]),
  466. scenarioTags: uniq([...(hints.scenarioTags || []), ...(rawTags.scenarioTags || [])]),
  467. objectionTags: uniq([...(hints.objectionTags || []), ...(rawTags.objectionTags || [])]),
  468. };
  469. const fit = inferFitStatus(tags);
  470. const caseFields = deriveCaseFields(split.descriptionCorpus || split.materialCorpus, groups, tags, fit);
  471. // 图片理解给出的隐私线索(姓名/头像/手机号等)汇总后与文本正则一起判断
  472. const piiHints = [
  473. ...(Array.isArray(input.piiHints) ? input.piiHints : []),
  474. ...visionResults.flatMap((r) => (Array.isArray(r.piiHints) ? r.piiHints : [])),
  475. ...groups.flatMap((g) => (g.items || []).flatMap((it) => (Array.isArray(it.piiHints) ? it.piiHints : []))),
  476. ];
  477. const compliance = assessCompliance({
  478. corpus: fullCorpus,
  479. authorizationStatus: input.authorizationStatus,
  480. hasMaterials: groups.some((g) => g.count > 0),
  481. fields: caseFields,
  482. piiHints,
  483. });
  484. // 字典自进化候选:文本里出现、字典里没有、但强线索命中的高价值短语
  485. const learnedTags = [];
  486. for (const dimension of ['highlightTypes', 'scenarioTags', 'objectionTags']) {
  487. for (const value of tags[dimension] || []) {
  488. learnedTags.push({ dimension, value });
  489. }
  490. }
  491. return {
  492. caseFields,
  493. tags,
  494. fit,
  495. school,
  496. compliance,
  497. learned: { tags: learnedTags, aliases: school.hits.map((h) => ({ aliasText: h.aliasText, canonicalName: h.canonicalName, country: h.country })) },
  498. texts: split,
  499. };
  500. }
  501. export { TAG_CLUES, RISK_KEYWORDS, STRATEGY_MAP, PII_RULES };