| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520 |
- // Copyright (c) 未来飞马
- //
- // This Source Code Form is subject to the terms of the Mozilla Public
- // License, v. 2.0. If a copy of the MPL was not distributed with this
- // file, You can obtain one at https://mozilla.org/MPL/2.0/.
- //
- // Trademark Notice:
- // The MPL-2.0 license grants copyright permissions for source code only.
- // It does NOT grant any rights to use trademarks including "未来飞马",
- // "Harness Loop", "RSI", and associated slogan "让AI进化提前发生,让AI落地快人一步".
- // Any use of these trademarks requires separate written permission.
- /**
- * 分类层:案例信息 vs 素材 分离、打标、合规判断
- *
- * 三类知识结构严格分开:
- * ① 案例说明(什么时候用 / 大概情况 / 背景)→ 案例字段
- * title / summary / targetCustomer / usageSuggestion / resultEvidence / outcome
- * ② 图片视频 → materialAssets[](本文件不动顺序,只做角色与标签)
- * ③ 合规判定 → riskFlags / privacyFindings
- *
- * 打标依据:docs/prd/dashboard/05-标签与别名管理.md + 10-案例推荐策略.md,
- * 取值来源 references/tag-dictionary.json(技能会在采集过程中增量更新)。
- */
- import { uniq, truncate } from './lib.mjs';
- // ---------------------------------------------------------------------------
- // 隐私发现(本期只记录,不打码;片段做掩码,绝不落完整 PII)
- // ---------------------------------------------------------------------------
- const PII_RULES = [
- { field: '手机号', rule: 'phone', re: /(?:\+?86[-\s]?)?1[3-9]\d{9}\b/g, mask: (m) => `${m.slice(0, 3)}****${m.slice(-4)}` },
- { field: '邮箱', rule: 'email', re: /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, mask: (m) => `${m[0]}***@${m.split('@')[1]}` },
- { field: '微信号', rule: 'wechat', re: /(?:微信|WeChat|wechat|VX|vx)\s*[::]?\s*[A-Za-z][-_A-Za-z0-9]{5,19}/g, mask: () => '微信:[已掩码]' },
- { field: 'QQ', rule: 'qq', re: /(?:QQ|qq)\s*[::]?\s*\d{5,12}/g, mask: () => 'QQ:[已掩码]' },
- { field: '身份证号', rule: 'id-card', re: /\b\d{17}[\dXx]\b/g, mask: (m) => `${m.slice(0, 3)}************${m.slice(-3)}` },
- { field: '银行卡号', rule: 'bank-card', re: /\b\d{16,19}\b/g, mask: (m) => `${m.slice(0, 4)}********${m.slice(-4)}` },
- { field: '称呼', rule: 'student-name', re: /(?:姓名|学员|学生|同学)\s*[::]\s*[一-龥]{2,4}/g, mask: (m) => `${m.split(/[::]/)[0]}:[已掩码]` },
- ];
- /**
- * 在文本中找隐私片段。**片段一律掩码后再返回**(真实 PII 不持久化,红线)。
- * @returns {Array<{field:string, rule:string, snippet:string}>}
- */
- export function findPrivacy(text) {
- const findings = [];
- const source = String(text || '');
- if (!source) return findings;
- for (const pii of PII_RULES) {
- const matches = source.match(pii.re);
- if (!matches) continue;
- for (const match of uniq(matches).slice(0, 5)) {
- findings.push({ field: pii.field, rule: pii.rule, snippet: truncate(pii.mask(match), 40) });
- }
- }
- return findings;
- }
- // ---------------------------------------------------------------------------
- // 合规关键词
- // ---------------------------------------------------------------------------
- // 关键词只描述「我方服务出的负面问题」,不包含学生自身的前置困难。
- // 「挂科 / 补考 / 被拒 / 退款」本身是痛点与需求(甚至对应「挂科挽救」「退费保障」等亮点),
- // 命中它们会把最好的案例误判成负面事件——因此一律不在此列。
- const RISK_KEYWORDS = {
- COMPLAINT: [
- '投诉', '客诉', '差评', '维权', '举报', '曝光',
- '退费纠纷', '退款纠纷', '要求退款', '要求赔偿', '协商赔偿', '黑猫',
- ],
- NEGATIVE_EVENT: [
- '投诉老师', '要求换老师', '老师失联', '服务事故', '课时不符',
- '没效果', '没有效果', '效果不好', '毫无效果',
- '成绩造假', '伪造材料', '数据造假',
- ],
- };
- const TOPIC_KEYWORDS = ['留学', '申请', '课程', '辅导', '补考', '提分', '录取', '申诉', '学校', '专业', '成绩', '成绩单', '老师', '答疑', 'GPA', '雅思', '托福', 'A-Level', 'AP', 'IB', 'case', 'offer', '期末', '考试', '论文'];
- // ---------------------------------------------------------------------------
- // 打标关键词(中文线索 → 字典取值)
- // ---------------------------------------------------------------------------
- const TAG_CLUES = {
- highlightTypes: {
- 提分: ['提分', '提高了', '涨了', '分数上升', '从\d+分?到\d+分?'],
- 录取: ['录取', 'offer', '拿到了', '上岸', 'offer letter'],
- 申诉成功: ['申诉成功', '申诉通过', '撤销处分', '撤销学术不端', 'appeal 成功'],
- 补考通过: ['补考通过', '补考 pass', '补考过了', 'pass 了', '补考稳 pass'],
- 稳分: ['稳分', '稳住', '保分', '不再挂'],
- 高分冲刺: ['冲刺高分', '冲高分', 'distinction', '一等学位'],
- 挂科挽救: ['挂科挽救', '挽救', '差点挂', '补救'],
- 退费保障: ['退费', '保障', '不过退'],
- 家长认可: ['家长', '妈妈', '爸爸', '家长很满意', '家长认可'],
- 出分反馈: ['出分', '成绩出来了', '反馈成绩', '查分'],
- 首课体验: ['首课', '试听', '第一节课'],
- 导师匹配: ['匹配导师', '导师匹配', '换到合适的老师', '老师很对口'],
- 群内答疑: ['群内答疑', '答疑群', '随时问', '响应很快'],
- 课时反馈: ['课时反馈', '每节课反馈', '课后反馈', '课堂反馈'],
- 大课时囤课: ['大课时', '囤课', '续课', '加课'],
- },
- scenarioTags: {
- 考前冲刺: ['考前', '冲刺', '临近考试', '来不及'],
- 开学季: ['开学', '新学期'],
- 期中考试: ['期中'],
- 期末考试: ['期末', 'final'],
- 补考季: ['补考', 'resit', '补考季'],
- 论文季: ['论文', 'dissertation', '毕业论文'],
- 选课指导: ['选课', '选课指导'],
- 转专业: ['转专业', '换专业', '转系'],
- 申诉期: ['申诉', 'appeal'],
- 退费咨询: ['退费', '退款'],
- 家长陪同: ['家长', '陪读', '妈妈', '爸爸'],
- 多地时差: ['时差', '国外时间', '半夜'],
- 时间紧张: ['时间不够', '时间紧', '来不及', '任务重'],
- 基础薄弱: ['基础差', '基础薄弱', '零基础', '底子差'],
- 目标高分: ['高分', '冲分', 'distinction', '一等'],
- 临近毕业: ['毕业', '大四', '最后一年'],
- },
- objectionTags: {
- 贵: ['太贵', '贵了', '价格高', '预算不够', '便宜点'],
- 不放心老师: ['不放心老师', '老师行不行', '老师靠不靠谱', '换老师'],
- 犹豫: ['再想想', '犹豫', '考虑一下', '还没想好'],
- 怕没效果: ['没效果', '有用吗', '能提分吗', '真的能'],
- 怕时间不够: ['来不及', '时间不够', '太晚了'],
- 要和家里商量: ['和家里商量', '问下父母', '跟爸妈'],
- 对比其他机构: ['其他机构', '别家', '对比一下', '也在看'],
- 担心退费难: ['退费难', '能退吗', '退款麻烦'],
- },
- };
- function matchesAny(text, needles) {
- return needles.some((needle) => {
- if (needle.startsWith('从') || needle.includes('\\d')) {
- try {
- return new RegExp(needle).test(text);
- } catch {
- return text.includes(needle);
- }
- }
- return text.toLowerCase().includes(needle.toLowerCase());
- });
- }
- /** 按字典 + 关键词线索打标,返回命中值与被用到但字典里没有的新值。 */
- export function classifyTags(text, dict) {
- const corpus = String(text || '');
- const dims = (dict && dict.dimensions) || {};
- const out = {};
- const learned = [];
- const pickFromDictionary = (dimension) => {
- const values = Array.isArray(dims[dimension] && dims[dimension].values) ? dims[dimension].values : [];
- return values.filter((value) => corpus.includes(value));
- };
- // 硬性静态属性:字典值直接命中
- out.country = pickFromDictionary('country').slice(0, 1);
- out.schoolCanonical = pickFromDictionary('schoolCanonical');
- out.major = pickFromDictionary('major');
- out.subject = pickFromDictionary('subject');
- out.stage = pickFromDictionary('stage').slice(0, 1);
- // 产品线(硬规则:三选一,字典里没有则不猜)
- out.productLine = pickFromDictionary('productLine').slice(0, 1);
- // 关键词线索打标
- for (const dimension of ['highlightTypes', 'scenarioTags', 'objectionTags']) {
- const clues = TAG_CLUES[dimension] || {};
- const hits = [];
- for (const [value, needles] of Object.entries(clues)) {
- if (matchesAny(corpus, needles)) hits.push(value);
- else if (corpus.includes(value)) hits.push(value);
- }
- out[dimension] = uniq(hits);
- }
- // 字典里已有但关键词没覆盖到的补充命中(避免漏标字典取值)
- for (const dimension of ['highlightTypes', 'scenarioTags', 'objectionTags']) {
- const known = Array.isArray(dims[dimension] && dims[dimension].values) ? dims[dimension].values : [];
- out[dimension] = uniq([...(out[dimension] || []), ...known.filter((v) => corpus.includes(v))]);
- }
- // 字典自进化候选:文本里出现的、形如标签的短语(保守:只在强线索命中后才收集)
- return { tags: out, learned };
- }
- // ---------------------------------------------------------------------------
- // 学校别名归一
- // ---------------------------------------------------------------------------
- /**
- * 把文本里的别名写法归一为标准校名。
- * @returns {{schoolCanonical:string, schoolAliases:string[], country:string, hits:object[]}}
- */
- export function normalizeSchool(text, dict) {
- const corpus = String(text || '');
- const entries = (dict && dict.dimensions && dict.dimensions.schoolAlias && dict.dimensions.schoolAlias.entries) || [];
- const canonicalNames = (dict && dict.dimensions && dict.dimensions.schoolCanonical && dict.dimensions.schoolCanonical.values) || [];
- const hits = [];
- for (const entry of entries) {
- const alias = String(entry.aliasText || '');
- if (!alias) continue;
- // 英文别名按词边界匹配,避免 "IC" 命中 "MAGIC";中文直接包含
- const isAscii = /^[\x20-\x7e]+$/.test(alias);
- const hit = isAscii
- ? new RegExp(`(^|[^A-Za-z0-9])${alias.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^A-Za-z0-9]|$)`, 'i').test(corpus)
- : corpus.includes(alias);
- if (hit) hits.push({ aliasText: alias, canonicalName: entry.canonicalName, country: entry.country || '' });
- }
- // 标准名直接出现(此时国家从别名表里同名校名的条目推断)
- const directHits = canonicalNames.filter((name) => corpus.includes(name));
- const countryOf = (canonical) => {
- const entry = entries.find((e) => String(e.canonicalName) === canonical);
- return entry ? String(entry.country || '') : '';
- };
- const canonical = uniq([...hits.map((h) => h.canonicalName), ...directHits])[0] || '';
- const country = (hits.find((h) => h.canonicalName === canonical) || {}).country || countryOf(canonical);
- return {
- schoolCanonical: canonical,
- // 命中的别名写法(不含标准名本身),供写入 CaseAsset.schoolAliases
- schoolAliases: uniq(hits.map((h) => h.aliasText)),
- country,
- hits,
- };
- }
- // ---------------------------------------------------------------------------
- // 推荐适用跟进状态(docs/prd/dashboard/10-案例推荐策略.md)
- // ---------------------------------------------------------------------------
- const STRATEGY_MAP = [
- { status: '异议处理中', when: (t) => t.objectionTags.length > 0 || ['出分反馈', '家长认可', '退费保障', '大课时囤课'].some((v) => t.highlightTypes.includes(v)) },
- { status: '待决策', when: (t) => ['录取', '提分', '高分冲刺'].some((v) => t.highlightTypes.includes(v)) && t.scenarioTags.includes('考前冲刺') === false },
- { status: '挖需中', when: (t) => ['补考通过', '挂科挽救', '稳分'].some((v) => t.highlightTypes.includes(v)) || t.scenarioTags.some((s) => ['时间紧张', '基础薄弱', '补考季'].includes(s)) },
- { status: '方案推荐中', when: (t) => ['导师匹配', '群内答疑', '课时反馈'].some((v) => t.highlightTypes.includes(v)) },
- { status: '沉默待跟进', when: (t) => t.scenarioTags.includes('考前冲刺') || t.highlightTypes.includes('出分反馈') },
- { status: '已成交', when: (t) => ['首课体验', '群内答疑', '课时反馈'].some((v) => t.highlightTypes.includes(v)) },
- { status: '新进线', when: (t) => t.subject.length > 0 },
- ];
- export function inferFitStatus(tags) {
- const t = {
- highlightTypes: tags.highlightTypes || [],
- scenarioTags: tags.scenarioTags || [],
- objectionTags: tags.objectionTags || [],
- subject: tags.subject || [],
- };
- const matched = STRATEGY_MAP.filter((rule) => rule.when(t)).map((rule) => rule.status);
- const ordered = ['新进线', '挖需中', '方案推荐中', '异议处理中', '待决策', '沉默待跟进', '已成交'];
- const fitStatus = ordered.filter((s) => matched.includes(s));
- return {
- fitStatus,
- reason: fitStatus.length
- ? `按「跟进状态 → 该发什么案例」策略命中:${fitStatus.join('、')}`
- : '未命中策略规则,需人工指定适用跟进状态',
- };
- }
- // ---------------------------------------------------------------------------
- // 案例 vs 素材 分离
- // ---------------------------------------------------------------------------
- /**
- * 从各种来源抽出「案例说明」文本,与素材数组分开。
- * @param {object} input
- * - textBundle: { paragraphs:string[], notes:string[], titles:string[], transcript:string }
- * - visionResults: [{ocrText, description, usageSuggestion, role, label, ok}]
- */
- export function splitCaseAndMaterials(input = {}) {
- const bundle = input.textBundle || {};
- const descriptionTexts = [];
- const materialTexts = [];
- const push = (target, value) => {
- const text = String(value || '').trim();
- if (text) target.push(text);
- };
- for (const paragraph of bundle.paragraphs || []) push(descriptionTexts, paragraph);
- for (const note of bundle.notes || []) push(descriptionTexts, typeof note === 'string' ? note : note.text);
- push(descriptionTexts, bundle.transcript);
- for (const result of input.visionResults || []) {
- if (result.role === 'description') {
- push(descriptionTexts, result.ocrText);
- push(descriptionTexts, result.description);
- } else {
- push(materialTexts, result.ocrText);
- push(materialTexts, result.description);
- }
- }
- return {
- // 案例说明语料(用于抽 title/summary/usageSuggestion/outcome)
- descriptionCorpus: descriptionTexts.join('\n'),
- descriptionTexts,
- // 素材语料(用于打标、也用于素材 label)
- materialCorpus: materialTexts.join('\n'),
- materialTexts,
- };
- }
- /** 从说明语料里抽一句话摘要、使用建议、目标客户、结果证据。 */
- export function deriveCaseFields(corpus, groups, tags, fit) {
- const lines = String(corpus || '').split('\n').map((l) => l.trim()).filter(Boolean);
- const title = truncate(
- lines.find((l) => l.length >= 6 && l.length <= 40) || lines[0] || '未命名案例',
- 40,
- );
- const summaryLine = lines.find((l) => l.length >= 12 && /[,。!?]|提分|录取|通过|结果/.test(l)) || lines[1] || lines[0] || '';
- const summary = truncate(summaryLine || title, 120);
- const productLine = (tags.productLine || [])[0] || '';
- const school = (tags.schoolCanonical || [])[0] || '';
- const subject = (tags.subject || [])[0] || '';
- const stage = (tags.stage || [])[0] || '';
- const audience = [stage, school, subject, productLine].filter(Boolean).join(' · ');
- const usageSuggestion = (() => {
- const fits = fit.fitStatus || [];
- if (fits.includes('异议处理中')) return `客户在顾虑(${(tags.objectionTags || []).join('/') || '犹豫、怕没效果'})时发,用结果打消顾虑`;
- if (fits.includes('挖需中')) return `客户痛点对上(${(tags.scenarioTags || []).join('/') || '怕挂、时间紧'})时发,把痛点和方案对上`;
- if (fits.includes('沉默待跟进')) return '客户不回复时做低成本唤醒,考前节点发';
- if (fits.includes('待决策')) return '已报价、客户说考虑时发,给一条完整可参考的成单路径';
- if (fits.includes('已成交')) return '成交后发,让客户安心(服务过程类)';
- if (fits.includes('方案推荐中')) return '推产品阶段发,证明机制有效(导师匹配/群内答疑/课时反馈)';
- if (fits.includes('新进线')) return `刚加上还没破冰时发,用${subject || '所学科目'}的案例建立专业感`;
- return '按客户当前跟进状态选用';
- })();
- const resultEvidence = truncate(
- lines.filter((l) => /提分|录取|通过|出分|反馈|成绩|offer|pass/i.test(l)).slice(0, 2).join(';'),
- 160,
- );
- const outcome = extractOutcome(corpus);
- return {
- title,
- summary,
- targetCustomer: audience || '有同类需求的在读学生',
- usageSuggestion,
- resultEvidence,
- outcome,
- };
- }
- /** 抽结果数据:提分幅度、周期、录取/出分结果、学校专业。 */
- export function extractOutcome(corpus) {
- const text = String(corpus || '');
- const outcome = {};
- const scoreDelta = text.match(/(?:提分|提高|涨了|提升了?)\s*([\d.]+)\s*分/);
- if (scoreDelta) outcome.scoreGain = `${scoreDelta[1]}分`;
- const fromTo = text.match(/(\d{1,3})\s*分?\s*(?:到|→|->|至)\s*(\d{1,3})\s*分/);
- if (fromTo) outcome.scoreRange = `${fromTo[1]} → ${fromTo[2]}`;
- const duration = text.match(/(\d+)\s*(?:周|个月|月|天|课时)/);
- if (duration) outcome.period = duration[0];
- // 「被 / 收到 / 拿到 / 获得」后可选的「了 / 到」,再到校名 + 录取/offer
- const admitted = text.match(/(?:被|收到|拿到|获得)\s*(?:了|到)?\s*([一-龥A-Za-z][一-龥A-Za-z\s]{1,19}?)(?:的)?(?:录取|offer)/i);
- if (admitted) outcome.admittedTo = admitted[1].trim();
- const grade = text.match(/(?:GPA|均分|成绩)\s*(?:从)?\s*([\d.]+)/i);
- if (grade) outcome.grade = grade[1];
- if (/pass|通过|及格/i.test(text)) outcome.result = 'pass';
- else if (/distinction|一等/i.test(text)) outcome.result = 'distinction';
- return outcome;
- }
- // ---------------------------------------------------------------------------
- // 合规判断
- // ---------------------------------------------------------------------------
- /**
- * 能否进案例库?这是「客诉/负面事件」还是「可对外展示的好案例」?
- *
- * @param {object} input
- * - corpus: string 全部文本
- * - authorizationStatus: 'authorized' | 'pending' | 'denied' | 其它
- * - hasMaterials: boolean 是否有可用素材
- * - fields: 抽取出来的案例字段(用于判断信息完整度)
- * @returns {{authorizationOk:boolean, riskFlags:string[], privacyFindings:object[], complianceBlockers:string[], reviewHint:string}}
- */
- export function assessCompliance(input = {}) {
- const corpus = String(input.corpus || '');
- const riskFlags = [];
- const complianceBlockers = [];
- for (const [flag, needles] of Object.entries(RISK_KEYWORDS)) {
- if (matchesAny(corpus, needles)) riskFlags.push(flag);
- }
- const privacyFindings = findPrivacy(corpus);
- if (privacyFindings.length) riskFlags.push('PII_RISK');
- // 主题相关性
- const topicHit = matchesAny(corpus, TOPIC_KEYWORDS) || (input.hasMaterials && corpus.trim().length > 0);
- if (!topicHit) riskFlags.push('OFF_TOPIC');
- // 授权判定(硬门)
- const authorizationStatus = String(input.authorizationStatus || '').toLowerCase();
- const authorizationOk = authorizationStatus === 'authorized';
- if (!authorizationOk) {
- riskFlags.push('UNAUTHORIZED');
- complianceBlockers.push(`无授权(authorizationStatus=${authorizationStatus || '缺失'})——一律不入库、不产生案例对象`);
- }
- if (!input.hasMaterials) {
- complianceBlockers.push('没有任何可用素材(materialAssets 为空),无法构成案例');
- }
- if (['COMPLAINT', 'NEGATIVE_EVENT'].includes(riskFlags.find((f) => f === 'COMPLAINT' || f === 'NEGATIVE_EVENT'))) {
- complianceBlockers.push('出现客诉/负面事件线索:这是内部复盘材料,不能作为对外展示的好案例进入公共素材库');
- }
- if (riskFlags.includes('OFF_TOPIC')) {
- complianceBlockers.push('与留学/课程/辅导主题无关,不符合案例规范');
- }
- let reviewHint = '可入库待审(reviewStatus=pending,不进公共素材库,需人工审核后放行)';
- if (riskFlags.includes('COMPLAINT') || riskFlags.includes('NEGATIVE_EVENT')) {
- reviewHint = '建议驳回或转内部复盘,不得对外展示';
- } else if (privacyFindings.length) {
- reviewHint = '存在隐私片段,打码前必须人工抽检(未通过不得进入检索结果)';
- }
- return {
- authorizationOk,
- riskFlags: uniq(riskFlags),
- privacyFindings,
- complianceBlockers,
- reviewHint,
- };
- }
- // ---------------------------------------------------------------------------
- // 汇总:把以上拼成一个「案例包」
- // ---------------------------------------------------------------------------
- /**
- * @param {object} input
- * - sourceType, sourceRef, authorizationStatus
- * - textBundle {paragraphs, notes, titles, transcript}
- * - visionResults [], groups []
- * - dict 已加载的标签字典
- * - hints { schoolCanonical, productLine, stage, tags:{...} } 人工/上游显式指定(优先级最高)
- * @returns {{caseFields:object, tags:object, compliance:object, learned:{tags:[],aliases:[]}}}
- */
- export function classifyCase(input = {}) {
- const dict = input.dict;
- const groups = input.groups || [];
- const visionResults = input.visionResults || [];
- const split = splitCaseAndMaterials({
- textBundle: input.textBundle,
- visionResults,
- });
- const groupsCorpus = groups.map((g) => g.preview || '').join('\n');
- const fullCorpus = [split.descriptionCorpus, split.materialCorpus, groupsCorpus].filter(Boolean).join('\n');
- // 打标:先关键词/字典,再用 hints 覆盖
- const { tags: rawTags } = classifyTags(fullCorpus, dict);
- const school = normalizeSchool(fullCorpus, dict);
- const hints = input.hints || {};
- const tags = {
- country: uniq([hints.country, school.country, ...(rawTags.country || [])]).slice(0, 1),
- productLine: uniq(hints.productLine ? [hints.productLine] : [], rawTags.productLine || []).slice(0, 1),
- schoolCanonical: uniq(hints.schoolCanonical ? [hints.schoolCanonical] : [], school.schoolCanonical ? [school.schoolCanonical] : [], rawTags.schoolCanonical || []),
- schoolAliases: uniq(school.schoolAliases),
- major: uniq(hints.major ? [hints.major] : [], rawTags.major || []),
- stage: uniq(hints.stage ? [hints.stage] : [], rawTags.stage || []).slice(0, 1),
- subject: uniq(rawTags.subject || []),
- highlightTypes: uniq([...(hints.highlightTypes || []), ...(rawTags.highlightTypes || [])]),
- scenarioTags: uniq([...(hints.scenarioTags || []), ...(rawTags.scenarioTags || [])]),
- objectionTags: uniq([...(hints.objectionTags || []), ...(rawTags.objectionTags || [])]),
- };
- const fit = inferFitStatus(tags);
- const caseFields = deriveCaseFields(split.descriptionCorpus || split.materialCorpus, groups, tags, fit);
- const compliance = assessCompliance({
- corpus: fullCorpus,
- authorizationStatus: input.authorizationStatus,
- hasMaterials: groups.some((g) => g.count > 0),
- fields: caseFields,
- });
- // 字典自进化候选:文本里出现、字典里没有、但强线索命中的高价值短语
- const learnedTags = [];
- for (const dimension of ['highlightTypes', 'scenarioTags', 'objectionTags']) {
- for (const value of tags[dimension] || []) {
- learnedTags.push({ dimension, value });
- }
- }
- return {
- caseFields,
- tags,
- fit,
- school,
- compliance,
- learned: { tags: learnedTags, aliases: school.hits.map((h) => ({ aliasText: h.aliasText, canonicalName: h.canonicalName, country: h.country })) },
- texts: split,
- };
- }
- export { TAG_CLUES, RISK_KEYWORDS, STRATEGY_MAP, PII_RULES };
|