2026-02-05 00:22:02 +08:00
|
|
|
|
import { xbLog } from '../../../../core/debug-core.js';
|
|
|
|
|
|
import { extensionFolderPath } from '../../../../core/constants.js';
|
2026-02-03 22:13:51 +08:00
|
|
|
|
|
|
|
|
|
|
const MODULE_ID = 'tokenizer';
|
|
|
|
|
|
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
// 词性过滤
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
|
|
// 保留的词性(名词类 + 英文)
|
|
|
|
|
|
const KEEP_POS_PREFIXES = ['n', 'eng'];
|
|
|
|
|
|
|
|
|
|
|
|
function shouldKeepByPos(pos) {
|
|
|
|
|
|
return KEEP_POS_PREFIXES.some(prefix => pos.startsWith(prefix));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
// 语言检测
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
|
|
function shouldUseJieba(text) {
|
|
|
|
|
|
const zh = (text.match(/[\u4e00-\u9fff]/g) || []).length;
|
|
|
|
|
|
return zh >= 5;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function detectMainLanguage(text) {
|
|
|
|
|
|
const zh = (text.match(/[\u4e00-\u9fff]/g) || []).length;
|
|
|
|
|
|
const jp = (text.match(/[\u3040-\u309f\u30a0-\u30ff]/g) || []).length;
|
|
|
|
|
|
const en = (text.match(/[a-zA-Z]/g) || []).length;
|
|
|
|
|
|
const total = zh + jp + en || 1;
|
|
|
|
|
|
|
|
|
|
|
|
if (jp / total > 0.2) return 'jp';
|
|
|
|
|
|
if (en / total > 0.5) return 'en';
|
|
|
|
|
|
return 'zh';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 替换原有的大停用词表
|
|
|
|
|
|
const STOP_WORDS = new Set([
|
|
|
|
|
|
// 系统词
|
|
|
|
|
|
'用户', '角色', '玩家', '旁白', 'user', 'assistant', 'system',
|
|
|
|
|
|
// 时间泛词
|
|
|
|
|
|
'时候', '现在', '今天', '明天', '昨天', '早上', '晚上',
|
|
|
|
|
|
// 方位泛词
|
|
|
|
|
|
'这里', '那里', '上面', '下面', '里面', '外面',
|
|
|
|
|
|
// 泛化名词
|
|
|
|
|
|
'东西', '事情', '事儿', '地方', '样子', '意思', '感觉',
|
|
|
|
|
|
'一下', '一些', '一点', '一会', '一次',
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
// 英文停用词(fallback 用)
|
|
|
|
|
|
const EN_STOP_WORDS = new Set([
|
|
|
|
|
|
'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been',
|
|
|
|
|
|
'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would',
|
|
|
|
|
|
'could', 'should', 'may', 'might', 'must', 'can',
|
|
|
|
|
|
'to', 'of', 'in', 'on', 'at', 'for', 'with', 'by', 'from',
|
|
|
|
|
|
'and', 'or', 'but', 'if', 'that', 'this', 'it', 'its',
|
|
|
|
|
|
'i', 'you', 'he', 'she', 'we', 'they',
|
|
|
|
|
|
'my', 'your', 'his', 'her', 'our', 'their',
|
|
|
|
|
|
'what', 'which', 'who', 'whom', 'where', 'when', 'why', 'how',
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
let jiebaModule = null;
|
|
|
|
|
|
let jiebaReady = false;
|
|
|
|
|
|
let jiebaLoading = false;
|
|
|
|
|
|
|
|
|
|
|
|
async function ensureJieba() {
|
|
|
|
|
|
if (jiebaReady) return true;
|
|
|
|
|
|
if (jiebaLoading) {
|
|
|
|
|
|
for (let i = 0; i < 50; i++) {
|
|
|
|
|
|
await new Promise(r => setTimeout(r, 100));
|
|
|
|
|
|
if (jiebaReady) return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
jiebaLoading = true;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const jiebaPath = `/${extensionFolderPath}/libs/jieba-wasm/jieba_rs_wasm.js`;
|
|
|
|
|
|
// eslint-disable-next-line no-unsanitized/method
|
|
|
|
|
|
jiebaModule = await import(jiebaPath);
|
|
|
|
|
|
|
|
|
|
|
|
if (jiebaModule.default) {
|
|
|
|
|
|
await jiebaModule.default();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
jiebaReady = true;
|
|
|
|
|
|
xbLog.info(MODULE_ID, 'jieba-wasm 加载成功');
|
|
|
|
|
|
const keys = Object.getOwnPropertyNames(jiebaModule || {});
|
|
|
|
|
|
const dkeys = Object.getOwnPropertyNames(jiebaModule?.default || {});
|
|
|
|
|
|
xbLog.info(MODULE_ID, `jieba keys: ${keys.join(',')}`);
|
|
|
|
|
|
xbLog.info(MODULE_ID, `jieba default keys: ${dkeys.join(',')}`);
|
|
|
|
|
|
xbLog.info(MODULE_ID, `jieba.tag: ${typeof jiebaModule?.tag}`);
|
|
|
|
|
|
return true;
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
xbLog.error(MODULE_ID, 'jieba-wasm 加载失败', e);
|
|
|
|
|
|
jiebaLoading = false;
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function fallbackTokenize(text) {
|
|
|
|
|
|
const tokens = [];
|
|
|
|
|
|
const lang = detectMainLanguage(text);
|
|
|
|
|
|
|
|
|
|
|
|
// 英文
|
|
|
|
|
|
const enMatches = text.match(/[a-zA-Z]{2,20}/gi) || [];
|
|
|
|
|
|
tokens.push(...enMatches.filter(w => !EN_STOP_WORDS.has(w.toLowerCase())));
|
|
|
|
|
|
|
|
|
|
|
|
// 日语假名
|
|
|
|
|
|
if (lang === 'jp') {
|
|
|
|
|
|
const kanaMatches = text.match(/[\u3040-\u309f\u30a0-\u30ff]{2,10}/g) || [];
|
|
|
|
|
|
tokens.push(...kanaMatches);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 中文/日语汉字
|
|
|
|
|
|
const zhMatches = text.match(/[\u4e00-\u9fff]{2,6}/g) || [];
|
|
|
|
|
|
tokens.push(...zhMatches);
|
|
|
|
|
|
|
|
|
|
|
|
// 数字+汉字组合
|
|
|
|
|
|
const numZhMatches = text.match(/\d+[\u4e00-\u9fff]{1,4}/g) || [];
|
|
|
|
|
|
tokens.push(...numZhMatches);
|
|
|
|
|
|
|
|
|
|
|
|
return tokens;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function extractNouns(text, options = {}) {
|
|
|
|
|
|
const { minLen = 2, maxCount = 0 } = options;
|
|
|
|
|
|
if (!text?.trim()) return [];
|
|
|
|
|
|
|
|
|
|
|
|
// 中文为主 → 用 jieba
|
|
|
|
|
|
if (shouldUseJieba(text)) {
|
|
|
|
|
|
const hasJieba = await ensureJieba();
|
|
|
|
|
|
|
|
|
|
|
|
if (hasJieba && jiebaModule?.tag) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const tagged = jiebaModule.tag(text, true);
|
|
|
|
|
|
|
|
|
|
|
|
const result = [];
|
|
|
|
|
|
const seen = new Set();
|
|
|
|
|
|
|
|
|
|
|
|
const list = Array.isArray(tagged) ? tagged : [];
|
|
|
|
|
|
for (const item of list) {
|
|
|
|
|
|
let word = '';
|
|
|
|
|
|
let pos = '';
|
|
|
|
|
|
if (Array.isArray(item)) {
|
|
|
|
|
|
[word, pos] = item;
|
|
|
|
|
|
} else if (item && typeof item === 'object') {
|
|
|
|
|
|
word = item.word || item.w || item.text || item.term || '';
|
|
|
|
|
|
pos = item.tag || item.pos || item.p || '';
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!word || !pos) continue;
|
|
|
|
|
|
if (word.length < minLen) continue;
|
|
|
|
|
|
if (!shouldKeepByPos(pos)) continue;
|
|
|
|
|
|
if (STOP_WORDS.has(word)) continue;
|
|
|
|
|
|
if (seen.has(word)) continue;
|
|
|
|
|
|
|
|
|
|
|
|
seen.add(word);
|
|
|
|
|
|
result.push(word);
|
|
|
|
|
|
|
|
|
|
|
|
if (maxCount > 0 && result.length >= maxCount) break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return result;
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
xbLog.warn(MODULE_ID, 'jieba tag 失败:' + (e && e.message ? e.message : String(e)));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 非中文 / jieba 失败 → fallback
|
|
|
|
|
|
const tokens = fallbackTokenize(text);
|
|
|
|
|
|
|
|
|
|
|
|
const result = [];
|
|
|
|
|
|
const seen = new Set();
|
|
|
|
|
|
|
|
|
|
|
|
for (const t of tokens) {
|
|
|
|
|
|
if (t.length < minLen) continue;
|
|
|
|
|
|
if (STOP_WORDS.has(t)) continue;
|
|
|
|
|
|
if (seen.has(t)) continue;
|
|
|
|
|
|
|
|
|
|
|
|
seen.add(t);
|
|
|
|
|
|
result.push(t);
|
|
|
|
|
|
|
|
|
|
|
|
if (maxCount > 0 && result.length >= maxCount) break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return result;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function extractRareTerms(text, maxCount = 15) {
|
|
|
|
|
|
if (!text?.trim()) return [];
|
|
|
|
|
|
|
|
|
|
|
|
// 中文为主 → 用 jieba
|
|
|
|
|
|
if (shouldUseJieba(text)) {
|
|
|
|
|
|
const hasJieba = await ensureJieba();
|
|
|
|
|
|
|
|
|
|
|
|
if (hasJieba && jiebaModule?.tag) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const tagged = jiebaModule.tag(text, true);
|
|
|
|
|
|
|
|
|
|
|
|
const candidates = [];
|
|
|
|
|
|
const seen = new Set();
|
|
|
|
|
|
|
|
|
|
|
|
const list = Array.isArray(tagged) ? tagged : [];
|
|
|
|
|
|
for (const item of list) {
|
|
|
|
|
|
let word = '';
|
|
|
|
|
|
let pos = '';
|
|
|
|
|
|
if (Array.isArray(item)) {
|
|
|
|
|
|
[word, pos] = item;
|
|
|
|
|
|
} else if (item && typeof item === 'object') {
|
|
|
|
|
|
word = item.word || item.w || item.text || item.term || '';
|
|
|
|
|
|
pos = item.tag || item.pos || item.p || '';
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!word || !pos) continue;
|
|
|
|
|
|
if (word.length < 2) continue;
|
|
|
|
|
|
if (!shouldKeepByPos(pos)) continue;
|
|
|
|
|
|
if (STOP_WORDS.has(word)) continue;
|
|
|
|
|
|
if (seen.has(word)) continue;
|
|
|
|
|
|
|
|
|
|
|
|
seen.add(word);
|
|
|
|
|
|
|
|
|
|
|
|
// 稀有度评分
|
|
|
|
|
|
let score = 0;
|
|
|
|
|
|
if (word.length >= 4) score += 3;
|
|
|
|
|
|
else if (word.length >= 3) score += 1;
|
|
|
|
|
|
if (/[a-zA-Z]/.test(word)) score += 2;
|
|
|
|
|
|
if (/\d/.test(word)) score += 1;
|
|
|
|
|
|
// 专名词性加分
|
|
|
|
|
|
if (['nr', 'ns', 'nt', 'nz'].some(p => pos.startsWith(p))) score += 2;
|
|
|
|
|
|
|
|
|
|
|
|
candidates.push({ term: word, score });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
candidates.sort((a, b) => b.score - a.score);
|
|
|
|
|
|
return candidates.slice(0, maxCount).map(x => x.term);
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
xbLog.warn(MODULE_ID, 'jieba tag 失败:' + (e && e.message ? e.message : String(e)));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 非中文 / jieba 失败 → fallback
|
|
|
|
|
|
const allNouns = await extractNouns(text, { minLen: 2, maxCount: 0 });
|
|
|
|
|
|
|
|
|
|
|
|
const scored = allNouns.map(t => {
|
|
|
|
|
|
let score = 0;
|
|
|
|
|
|
if (t.length >= 4) score += 3;
|
|
|
|
|
|
else if (t.length >= 3) score += 1;
|
|
|
|
|
|
if (/[a-zA-Z]/.test(t)) score += 2;
|
|
|
|
|
|
if (/\d/.test(t)) score += 1;
|
|
|
|
|
|
return { term: t, score };
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
scored.sort((a, b) => b.score - a.score);
|
|
|
|
|
|
return scored.slice(0, maxCount).map(x => x.term);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function extractNounsFromFactsO(facts, relevantSubjects, maxCount = 5) {
|
|
|
|
|
|
if (!facts?.length || !relevantSubjects?.size) return [];
|
|
|
|
|
|
|
|
|
|
|
|
const oTexts = [];
|
|
|
|
|
|
|
|
|
|
|
|
for (const f of facts) {
|
|
|
|
|
|
if (f.retracted) continue;
|
|
|
|
|
|
|
|
|
|
|
|
// 只取相关主体的 facts
|
|
|
|
|
|
const s = String(f.s || '').trim();
|
|
|
|
|
|
if (!relevantSubjects.has(s)) continue;
|
|
|
|
|
|
|
|
|
|
|
|
const o = String(f.o || '').trim();
|
|
|
|
|
|
if (!o) continue;
|
|
|
|
|
|
|
|
|
|
|
|
// 跳过太长的 O(可能是完整句子)
|
|
|
|
|
|
if (o.length > 30) continue;
|
|
|
|
|
|
|
|
|
|
|
|
oTexts.push(o);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!oTexts.length) return [];
|
|
|
|
|
|
|
|
|
|
|
|
const combined = oTexts.join(' ');
|
|
|
|
|
|
return await extractNouns(combined, { minLen: 2, maxCount });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export { ensureJieba };
|
|
|
|
|
|
|