classify.mjs 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520
  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. // 合规关键词
  56. // ---------------------------------------------------------------------------
  57. // 关键词只描述「我方服务出的负面问题」,不包含学生自身的前置困难。
  58. // 「挂科 / 补考 / 被拒 / 退款」本身是痛点与需求(甚至对应「挂科挽救」「退费保障」等亮点),
  59. // 命中它们会把最好的案例误判成负面事件——因此一律不在此列。
  60. const RISK_KEYWORDS = {
  61. COMPLAINT: [
  62. '投诉', '客诉', '差评', '维权', '举报', '曝光',
  63. '退费纠纷', '退款纠纷', '要求退款', '要求赔偿', '协商赔偿', '黑猫',
  64. ],
  65. NEGATIVE_EVENT: [
  66. '投诉老师', '要求换老师', '老师失联', '服务事故', '课时不符',
  67. '没效果', '没有效果', '效果不好', '毫无效果',
  68. '成绩造假', '伪造材料', '数据造假',
  69. ],
  70. };
  71. const TOPIC_KEYWORDS = ['留学', '申请', '课程', '辅导', '补考', '提分', '录取', '申诉', '学校', '专业', '成绩', '成绩单', '老师', '答疑', 'GPA', '雅思', '托福', 'A-Level', 'AP', 'IB', 'case', 'offer', '期末', '考试', '论文'];
  72. // ---------------------------------------------------------------------------
  73. // 打标关键词(中文线索 → 字典取值)
  74. // ---------------------------------------------------------------------------
  75. const TAG_CLUES = {
  76. highlightTypes: {
  77. 提分: ['提分', '提高了', '涨了', '分数上升', '从\d+分?到\d+分?'],
  78. 录取: ['录取', 'offer', '拿到了', '上岸', 'offer letter'],
  79. 申诉成功: ['申诉成功', '申诉通过', '撤销处分', '撤销学术不端', 'appeal 成功'],
  80. 补考通过: ['补考通过', '补考 pass', '补考过了', 'pass 了', '补考稳 pass'],
  81. 稳分: ['稳分', '稳住', '保分', '不再挂'],
  82. 高分冲刺: ['冲刺高分', '冲高分', 'distinction', '一等学位'],
  83. 挂科挽救: ['挂科挽救', '挽救', '差点挂', '补救'],
  84. 退费保障: ['退费', '保障', '不过退'],
  85. 家长认可: ['家长', '妈妈', '爸爸', '家长很满意', '家长认可'],
  86. 出分反馈: ['出分', '成绩出来了', '反馈成绩', '查分'],
  87. 首课体验: ['首课', '试听', '第一节课'],
  88. 导师匹配: ['匹配导师', '导师匹配', '换到合适的老师', '老师很对口'],
  89. 群内答疑: ['群内答疑', '答疑群', '随时问', '响应很快'],
  90. 课时反馈: ['课时反馈', '每节课反馈', '课后反馈', '课堂反馈'],
  91. 大课时囤课: ['大课时', '囤课', '续课', '加课'],
  92. },
  93. scenarioTags: {
  94. 考前冲刺: ['考前', '冲刺', '临近考试', '来不及'],
  95. 开学季: ['开学', '新学期'],
  96. 期中考试: ['期中'],
  97. 期末考试: ['期末', 'final'],
  98. 补考季: ['补考', 'resit', '补考季'],
  99. 论文季: ['论文', 'dissertation', '毕业论文'],
  100. 选课指导: ['选课', '选课指导'],
  101. 转专业: ['转专业', '换专业', '转系'],
  102. 申诉期: ['申诉', 'appeal'],
  103. 退费咨询: ['退费', '退款'],
  104. 家长陪同: ['家长', '陪读', '妈妈', '爸爸'],
  105. 多地时差: ['时差', '国外时间', '半夜'],
  106. 时间紧张: ['时间不够', '时间紧', '来不及', '任务重'],
  107. 基础薄弱: ['基础差', '基础薄弱', '零基础', '底子差'],
  108. 目标高分: ['高分', '冲分', 'distinction', '一等'],
  109. 临近毕业: ['毕业', '大四', '最后一年'],
  110. },
  111. objectionTags: {
  112. 贵: ['太贵', '贵了', '价格高', '预算不够', '便宜点'],
  113. 不放心老师: ['不放心老师', '老师行不行', '老师靠不靠谱', '换老师'],
  114. 犹豫: ['再想想', '犹豫', '考虑一下', '还没想好'],
  115. 怕没效果: ['没效果', '有用吗', '能提分吗', '真的能'],
  116. 怕时间不够: ['来不及', '时间不够', '太晚了'],
  117. 要和家里商量: ['和家里商量', '问下父母', '跟爸妈'],
  118. 对比其他机构: ['其他机构', '别家', '对比一下', '也在看'],
  119. 担心退费难: ['退费难', '能退吗', '退款麻烦'],
  120. },
  121. };
  122. function matchesAny(text, needles) {
  123. return needles.some((needle) => {
  124. if (needle.startsWith('从') || needle.includes('\\d')) {
  125. try {
  126. return new RegExp(needle).test(text);
  127. } catch {
  128. return text.includes(needle);
  129. }
  130. }
  131. return text.toLowerCase().includes(needle.toLowerCase());
  132. });
  133. }
  134. /** 按字典 + 关键词线索打标,返回命中值与被用到但字典里没有的新值。 */
  135. export function classifyTags(text, dict) {
  136. const corpus = String(text || '');
  137. const dims = (dict && dict.dimensions) || {};
  138. const out = {};
  139. const learned = [];
  140. const pickFromDictionary = (dimension) => {
  141. const values = Array.isArray(dims[dimension] && dims[dimension].values) ? dims[dimension].values : [];
  142. return values.filter((value) => corpus.includes(value));
  143. };
  144. // 硬性静态属性:字典值直接命中
  145. out.country = pickFromDictionary('country').slice(0, 1);
  146. out.schoolCanonical = pickFromDictionary('schoolCanonical');
  147. out.major = pickFromDictionary('major');
  148. out.subject = pickFromDictionary('subject');
  149. out.stage = pickFromDictionary('stage').slice(0, 1);
  150. // 产品线(硬规则:三选一,字典里没有则不猜)
  151. out.productLine = pickFromDictionary('productLine').slice(0, 1);
  152. // 关键词线索打标
  153. for (const dimension of ['highlightTypes', 'scenarioTags', 'objectionTags']) {
  154. const clues = TAG_CLUES[dimension] || {};
  155. const hits = [];
  156. for (const [value, needles] of Object.entries(clues)) {
  157. if (matchesAny(corpus, needles)) hits.push(value);
  158. else if (corpus.includes(value)) hits.push(value);
  159. }
  160. out[dimension] = uniq(hits);
  161. }
  162. // 字典里已有但关键词没覆盖到的补充命中(避免漏标字典取值)
  163. for (const dimension of ['highlightTypes', 'scenarioTags', 'objectionTags']) {
  164. const known = Array.isArray(dims[dimension] && dims[dimension].values) ? dims[dimension].values : [];
  165. out[dimension] = uniq([...(out[dimension] || []), ...known.filter((v) => corpus.includes(v))]);
  166. }
  167. // 字典自进化候选:文本里出现的、形如标签的短语(保守:只在强线索命中后才收集)
  168. return { tags: out, learned };
  169. }
  170. // ---------------------------------------------------------------------------
  171. // 学校别名归一
  172. // ---------------------------------------------------------------------------
  173. /**
  174. * 把文本里的别名写法归一为标准校名。
  175. * @returns {{schoolCanonical:string, schoolAliases:string[], country:string, hits:object[]}}
  176. */
  177. export function normalizeSchool(text, dict) {
  178. const corpus = String(text || '');
  179. const entries = (dict && dict.dimensions && dict.dimensions.schoolAlias && dict.dimensions.schoolAlias.entries) || [];
  180. const canonicalNames = (dict && dict.dimensions && dict.dimensions.schoolCanonical && dict.dimensions.schoolCanonical.values) || [];
  181. const hits = [];
  182. for (const entry of entries) {
  183. const alias = String(entry.aliasText || '');
  184. if (!alias) continue;
  185. // 英文别名按词边界匹配,避免 "IC" 命中 "MAGIC";中文直接包含
  186. const isAscii = /^[\x20-\x7e]+$/.test(alias);
  187. const hit = isAscii
  188. ? new RegExp(`(^|[^A-Za-z0-9])${alias.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^A-Za-z0-9]|$)`, 'i').test(corpus)
  189. : corpus.includes(alias);
  190. if (hit) hits.push({ aliasText: alias, canonicalName: entry.canonicalName, country: entry.country || '' });
  191. }
  192. // 标准名直接出现(此时国家从别名表里同名校名的条目推断)
  193. const directHits = canonicalNames.filter((name) => corpus.includes(name));
  194. const countryOf = (canonical) => {
  195. const entry = entries.find((e) => String(e.canonicalName) === canonical);
  196. return entry ? String(entry.country || '') : '';
  197. };
  198. const canonical = uniq([...hits.map((h) => h.canonicalName), ...directHits])[0] || '';
  199. const country = (hits.find((h) => h.canonicalName === canonical) || {}).country || countryOf(canonical);
  200. return {
  201. schoolCanonical: canonical,
  202. // 命中的别名写法(不含标准名本身),供写入 CaseAsset.schoolAliases
  203. schoolAliases: uniq(hits.map((h) => h.aliasText)),
  204. country,
  205. hits,
  206. };
  207. }
  208. // ---------------------------------------------------------------------------
  209. // 推荐适用跟进状态(docs/prd/dashboard/10-案例推荐策略.md)
  210. // ---------------------------------------------------------------------------
  211. const STRATEGY_MAP = [
  212. { status: '异议处理中', when: (t) => t.objectionTags.length > 0 || ['出分反馈', '家长认可', '退费保障', '大课时囤课'].some((v) => t.highlightTypes.includes(v)) },
  213. { status: '待决策', when: (t) => ['录取', '提分', '高分冲刺'].some((v) => t.highlightTypes.includes(v)) && t.scenarioTags.includes('考前冲刺') === false },
  214. { status: '挖需中', when: (t) => ['补考通过', '挂科挽救', '稳分'].some((v) => t.highlightTypes.includes(v)) || t.scenarioTags.some((s) => ['时间紧张', '基础薄弱', '补考季'].includes(s)) },
  215. { status: '方案推荐中', when: (t) => ['导师匹配', '群内答疑', '课时反馈'].some((v) => t.highlightTypes.includes(v)) },
  216. { status: '沉默待跟进', when: (t) => t.scenarioTags.includes('考前冲刺') || t.highlightTypes.includes('出分反馈') },
  217. { status: '已成交', when: (t) => ['首课体验', '群内答疑', '课时反馈'].some((v) => t.highlightTypes.includes(v)) },
  218. { status: '新进线', when: (t) => t.subject.length > 0 },
  219. ];
  220. export function inferFitStatus(tags) {
  221. const t = {
  222. highlightTypes: tags.highlightTypes || [],
  223. scenarioTags: tags.scenarioTags || [],
  224. objectionTags: tags.objectionTags || [],
  225. subject: tags.subject || [],
  226. };
  227. const matched = STRATEGY_MAP.filter((rule) => rule.when(t)).map((rule) => rule.status);
  228. const ordered = ['新进线', '挖需中', '方案推荐中', '异议处理中', '待决策', '沉默待跟进', '已成交'];
  229. const fitStatus = ordered.filter((s) => matched.includes(s));
  230. return {
  231. fitStatus,
  232. reason: fitStatus.length
  233. ? `按「跟进状态 → 该发什么案例」策略命中:${fitStatus.join('、')}`
  234. : '未命中策略规则,需人工指定适用跟进状态',
  235. };
  236. }
  237. // ---------------------------------------------------------------------------
  238. // 案例 vs 素材 分离
  239. // ---------------------------------------------------------------------------
  240. /**
  241. * 从各种来源抽出「案例说明」文本,与素材数组分开。
  242. * @param {object} input
  243. * - textBundle: { paragraphs:string[], notes:string[], titles:string[], transcript:string }
  244. * - visionResults: [{ocrText, description, usageSuggestion, role, label, ok}]
  245. */
  246. export function splitCaseAndMaterials(input = {}) {
  247. const bundle = input.textBundle || {};
  248. const descriptionTexts = [];
  249. const materialTexts = [];
  250. const push = (target, value) => {
  251. const text = String(value || '').trim();
  252. if (text) target.push(text);
  253. };
  254. for (const paragraph of bundle.paragraphs || []) push(descriptionTexts, paragraph);
  255. for (const note of bundle.notes || []) push(descriptionTexts, typeof note === 'string' ? note : note.text);
  256. push(descriptionTexts, bundle.transcript);
  257. for (const result of input.visionResults || []) {
  258. if (result.role === 'description') {
  259. push(descriptionTexts, result.ocrText);
  260. push(descriptionTexts, result.description);
  261. } else {
  262. push(materialTexts, result.ocrText);
  263. push(materialTexts, result.description);
  264. }
  265. }
  266. return {
  267. // 案例说明语料(用于抽 title/summary/usageSuggestion/outcome)
  268. descriptionCorpus: descriptionTexts.join('\n'),
  269. descriptionTexts,
  270. // 素材语料(用于打标、也用于素材 label)
  271. materialCorpus: materialTexts.join('\n'),
  272. materialTexts,
  273. };
  274. }
  275. /** 从说明语料里抽一句话摘要、使用建议、目标客户、结果证据。 */
  276. export function deriveCaseFields(corpus, groups, tags, fit) {
  277. const lines = String(corpus || '').split('\n').map((l) => l.trim()).filter(Boolean);
  278. const title = truncate(
  279. lines.find((l) => l.length >= 6 && l.length <= 40) || lines[0] || '未命名案例',
  280. 40,
  281. );
  282. const summaryLine = lines.find((l) => l.length >= 12 && /[,。!?]|提分|录取|通过|结果/.test(l)) || lines[1] || lines[0] || '';
  283. const summary = truncate(summaryLine || title, 120);
  284. const productLine = (tags.productLine || [])[0] || '';
  285. const school = (tags.schoolCanonical || [])[0] || '';
  286. const subject = (tags.subject || [])[0] || '';
  287. const stage = (tags.stage || [])[0] || '';
  288. const audience = [stage, school, subject, productLine].filter(Boolean).join(' · ');
  289. const usageSuggestion = (() => {
  290. const fits = fit.fitStatus || [];
  291. if (fits.includes('异议处理中')) return `客户在顾虑(${(tags.objectionTags || []).join('/') || '犹豫、怕没效果'})时发,用结果打消顾虑`;
  292. if (fits.includes('挖需中')) return `客户痛点对上(${(tags.scenarioTags || []).join('/') || '怕挂、时间紧'})时发,把痛点和方案对上`;
  293. if (fits.includes('沉默待跟进')) return '客户不回复时做低成本唤醒,考前节点发';
  294. if (fits.includes('待决策')) return '已报价、客户说考虑时发,给一条完整可参考的成单路径';
  295. if (fits.includes('已成交')) return '成交后发,让客户安心(服务过程类)';
  296. if (fits.includes('方案推荐中')) return '推产品阶段发,证明机制有效(导师匹配/群内答疑/课时反馈)';
  297. if (fits.includes('新进线')) return `刚加上还没破冰时发,用${subject || '所学科目'}的案例建立专业感`;
  298. return '按客户当前跟进状态选用';
  299. })();
  300. const resultEvidence = truncate(
  301. lines.filter((l) => /提分|录取|通过|出分|反馈|成绩|offer|pass/i.test(l)).slice(0, 2).join(';'),
  302. 160,
  303. );
  304. const outcome = extractOutcome(corpus);
  305. return {
  306. title,
  307. summary,
  308. targetCustomer: audience || '有同类需求的在读学生',
  309. usageSuggestion,
  310. resultEvidence,
  311. outcome,
  312. };
  313. }
  314. /** 抽结果数据:提分幅度、周期、录取/出分结果、学校专业。 */
  315. export function extractOutcome(corpus) {
  316. const text = String(corpus || '');
  317. const outcome = {};
  318. const scoreDelta = text.match(/(?:提分|提高|涨了|提升了?)\s*([\d.]+)\s*分/);
  319. if (scoreDelta) outcome.scoreGain = `${scoreDelta[1]}分`;
  320. const fromTo = text.match(/(\d{1,3})\s*分?\s*(?:到|→|->|至)\s*(\d{1,3})\s*分/);
  321. if (fromTo) outcome.scoreRange = `${fromTo[1]} → ${fromTo[2]}`;
  322. const duration = text.match(/(\d+)\s*(?:周|个月|月|天|课时)/);
  323. if (duration) outcome.period = duration[0];
  324. // 「被 / 收到 / 拿到 / 获得」后可选的「了 / 到」,再到校名 + 录取/offer
  325. const admitted = text.match(/(?:被|收到|拿到|获得)\s*(?:了|到)?\s*([一-龥A-Za-z][一-龥A-Za-z\s]{1,19}?)(?:的)?(?:录取|offer)/i);
  326. if (admitted) outcome.admittedTo = admitted[1].trim();
  327. const grade = text.match(/(?:GPA|均分|成绩)\s*(?:从)?\s*([\d.]+)/i);
  328. if (grade) outcome.grade = grade[1];
  329. if (/pass|通过|及格/i.test(text)) outcome.result = 'pass';
  330. else if (/distinction|一等/i.test(text)) outcome.result = 'distinction';
  331. return outcome;
  332. }
  333. // ---------------------------------------------------------------------------
  334. // 合规判断
  335. // ---------------------------------------------------------------------------
  336. /**
  337. * 能否进案例库?这是「客诉/负面事件」还是「可对外展示的好案例」?
  338. *
  339. * @param {object} input
  340. * - corpus: string 全部文本
  341. * - authorizationStatus: 'authorized' | 'pending' | 'denied' | 其它
  342. * - hasMaterials: boolean 是否有可用素材
  343. * - fields: 抽取出来的案例字段(用于判断信息完整度)
  344. * @returns {{authorizationOk:boolean, riskFlags:string[], privacyFindings:object[], complianceBlockers:string[], reviewHint:string}}
  345. */
  346. export function assessCompliance(input = {}) {
  347. const corpus = String(input.corpus || '');
  348. const riskFlags = [];
  349. const complianceBlockers = [];
  350. for (const [flag, needles] of Object.entries(RISK_KEYWORDS)) {
  351. if (matchesAny(corpus, needles)) riskFlags.push(flag);
  352. }
  353. const privacyFindings = findPrivacy(corpus);
  354. if (privacyFindings.length) riskFlags.push('PII_RISK');
  355. // 主题相关性
  356. const topicHit = matchesAny(corpus, TOPIC_KEYWORDS) || (input.hasMaterials && corpus.trim().length > 0);
  357. if (!topicHit) riskFlags.push('OFF_TOPIC');
  358. // 授权判定(硬门)
  359. const authorizationStatus = String(input.authorizationStatus || '').toLowerCase();
  360. const authorizationOk = authorizationStatus === 'authorized';
  361. if (!authorizationOk) {
  362. riskFlags.push('UNAUTHORIZED');
  363. complianceBlockers.push(`无授权(authorizationStatus=${authorizationStatus || '缺失'})——一律不入库、不产生案例对象`);
  364. }
  365. if (!input.hasMaterials) {
  366. complianceBlockers.push('没有任何可用素材(materialAssets 为空),无法构成案例');
  367. }
  368. if (['COMPLAINT', 'NEGATIVE_EVENT'].includes(riskFlags.find((f) => f === 'COMPLAINT' || f === 'NEGATIVE_EVENT'))) {
  369. complianceBlockers.push('出现客诉/负面事件线索:这是内部复盘材料,不能作为对外展示的好案例进入公共素材库');
  370. }
  371. if (riskFlags.includes('OFF_TOPIC')) {
  372. complianceBlockers.push('与留学/课程/辅导主题无关,不符合案例规范');
  373. }
  374. let reviewHint = '可入库待审(reviewStatus=pending,不进公共素材库,需人工审核后放行)';
  375. if (riskFlags.includes('COMPLAINT') || riskFlags.includes('NEGATIVE_EVENT')) {
  376. reviewHint = '建议驳回或转内部复盘,不得对外展示';
  377. } else if (privacyFindings.length) {
  378. reviewHint = '存在隐私片段,打码前必须人工抽检(未通过不得进入检索结果)';
  379. }
  380. return {
  381. authorizationOk,
  382. riskFlags: uniq(riskFlags),
  383. privacyFindings,
  384. complianceBlockers,
  385. reviewHint,
  386. };
  387. }
  388. // ---------------------------------------------------------------------------
  389. // 汇总:把以上拼成一个「案例包」
  390. // ---------------------------------------------------------------------------
  391. /**
  392. * @param {object} input
  393. * - sourceType, sourceRef, authorizationStatus
  394. * - textBundle {paragraphs, notes, titles, transcript}
  395. * - visionResults [], groups []
  396. * - dict 已加载的标签字典
  397. * - hints { schoolCanonical, productLine, stage, tags:{...} } 人工/上游显式指定(优先级最高)
  398. * @returns {{caseFields:object, tags:object, compliance:object, learned:{tags:[],aliases:[]}}}
  399. */
  400. export function classifyCase(input = {}) {
  401. const dict = input.dict;
  402. const groups = input.groups || [];
  403. const visionResults = input.visionResults || [];
  404. const split = splitCaseAndMaterials({
  405. textBundle: input.textBundle,
  406. visionResults,
  407. });
  408. const groupsCorpus = groups.map((g) => g.preview || '').join('\n');
  409. const fullCorpus = [split.descriptionCorpus, split.materialCorpus, groupsCorpus].filter(Boolean).join('\n');
  410. // 打标:先关键词/字典,再用 hints 覆盖
  411. const { tags: rawTags } = classifyTags(fullCorpus, dict);
  412. const school = normalizeSchool(fullCorpus, dict);
  413. const hints = input.hints || {};
  414. const tags = {
  415. country: uniq([hints.country, school.country, ...(rawTags.country || [])]).slice(0, 1),
  416. productLine: uniq(hints.productLine ? [hints.productLine] : [], rawTags.productLine || []).slice(0, 1),
  417. schoolCanonical: uniq(hints.schoolCanonical ? [hints.schoolCanonical] : [], school.schoolCanonical ? [school.schoolCanonical] : [], rawTags.schoolCanonical || []),
  418. schoolAliases: uniq(school.schoolAliases),
  419. major: uniq(hints.major ? [hints.major] : [], rawTags.major || []),
  420. stage: uniq(hints.stage ? [hints.stage] : [], rawTags.stage || []).slice(0, 1),
  421. subject: uniq(rawTags.subject || []),
  422. highlightTypes: uniq([...(hints.highlightTypes || []), ...(rawTags.highlightTypes || [])]),
  423. scenarioTags: uniq([...(hints.scenarioTags || []), ...(rawTags.scenarioTags || [])]),
  424. objectionTags: uniq([...(hints.objectionTags || []), ...(rawTags.objectionTags || [])]),
  425. };
  426. const fit = inferFitStatus(tags);
  427. const caseFields = deriveCaseFields(split.descriptionCorpus || split.materialCorpus, groups, tags, fit);
  428. const compliance = assessCompliance({
  429. corpus: fullCorpus,
  430. authorizationStatus: input.authorizationStatus,
  431. hasMaterials: groups.some((g) => g.count > 0),
  432. fields: caseFields,
  433. });
  434. // 字典自进化候选:文本里出现、字典里没有、但强线索命中的高价值短语
  435. const learnedTags = [];
  436. for (const dimension of ['highlightTypes', 'scenarioTags', 'objectionTags']) {
  437. for (const value of tags[dimension] || []) {
  438. learnedTags.push({ dimension, value });
  439. }
  440. }
  441. return {
  442. caseFields,
  443. tags,
  444. fit,
  445. school,
  446. compliance,
  447. learned: { tags: learnedTags, aliases: school.hits.map((h) => ({ aliasText: h.aliasText, canonicalName: h.canonicalName, country: h.country })) },
  448. texts: split,
  449. };
  450. }
  451. export { TAG_CLUES, RISK_KEYWORDS, STRATEGY_MAP, PII_RULES };