Files
full_droperandClaude Opus 5 5537790591 Додано detector.js: сам детектор живе поруч із даними
Логіка й фільтри мусять оновлюватися разом: скелети транслітерації та
схема фільтрів у них спільні. Функція на Lamdas стає тонкою обгорткою,
а пороги виносяться у змінні оточення, щоб їх крутити без перевикладання.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 12:35:32 +03:00

274 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ru-detector-v2 — визначення мови для 36 кириличних мов.
//
// Вхід: POST { "text": "..." } або сире тіло, або ?text=...
// Вихід: { hasRussian, language, mixed, score, confidence, signals, stats, top, ... }
// Поля збережено сумісними з першою версією, щоб не ламати sky-ru-bridge.
//
// Як це працює:
// 1. Словник. Фільтри Блума на 8.62 млн словоформ: кожне слово приписане мові,
// де його відносна частота принаймні втричі вища, ніж у будь-якій іншій.
// Спільні для кількох мов слова (в, на, не, погода) у фільтри не потрапили взагалі.
// 2. Транслітерація. Слово латиницею зводиться до «скелета» і шукається в
// окремому шарі фільтрів. Ловить і підміну літер (пpивeт), і цифри (4to).
// 3. Літери, закінчення, триграми — підказки для коротких повідомлень,
// де словник мовчить. Ваги підібрані замірами, навмисно малі.
//
// Дані живуть в npm-пакеті cyrillic-lang-data (Gitea), а не в коді функції:
// 25 МБ фільтрів не влізли б у квоту розміру коду.
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { FILTERS_DIR, SIGNALS_PATH } from './index.js';
import { skeletons, deHomoglyph } from './translit.js';
const MAX_CHARS = 100_000;
const TOKEN = /[Ѐ-ӿa-zA-Z0-9'’-]{2,}/gu;
const hasCyr = (s) => /[Ѐ-ӿ]/.test(s);
// Усі пороги перекриваються змінними оточення — щоб підкрутити з панелі без перевикладання.
const num = (key, fallback) => (process.env[key] === undefined ? fallback : Number(process.env[key]));
// Ваги сигналів відносно словникового влучання (воно = 1). Підібрано на 1600 реченнях:
// більші значення псують результат, бо триграми шумлять на 36 мовах одразу.
const W = {
letter: num('W_LETTER', 0.2),
ending: num('W_ENDING', 0.2),
trigram: num('W_TRIGRAM', 0.01)
};
// Слово вважається російським, якщо бал ≥2 або чверть усіх балів тексту.
// На чистих українських текстах це дає 0.3% хибних спрацювань.
const RU_SCORE = num('RU_SCORE', 2);
const RU_SHARE = num('RU_SHARE', 0.25);
const RU_SHARE_MIN_SCORE = num('RU_SHARE_MIN_SCORE', 1); // частка без мінімального балу нічого не
// означає: одне випадкове влучання дало б «100% російської» на трьох словах
const NAMES = { rus: 'russian', ukr: 'ukrainian' };
// Слово, що влучило одразу в стільки мов, — шум фільтра, а не збіг: ігноруємо.
const MAX_LANGS_PER_WORD = num('MAX_LANGS_PER_WORD', 3);
// Текст самою лише латиницею судимо суворіше: англійські слова випадково
// збігаються зі скелетами (hello, today, think), і це давало хибні звинувачення.
const RU_SCORE_LATIN = num('RU_SCORE_LATIN', 3);
const RU_SHARE_LATIN = num('RU_SHARE_LATIN', 0.4);
// Найчастіші англійські слова — їх не транслітеруємо взагалі.
const EN = new Set(
('the be to of and a in that have i it for not on with he as you do at this but his by from they we say her she or an will my one all would there their what so up out if about who get which go me when make can like time no just him know take people into year your good some could them see other than then now look only come its over think also back after use two how our work first well way even new want because any these give day most us is are was were been has had did does got said made am hello hi hey thanks thank please yes yeah ok okay lol nice cool great sorry today tomorrow yesterday week month right left here where why very much many more less every each own same such last next new old big small long short high low'
).split(' ')
);
let state = null;
function hash2(s) {
let h1 = 0x811c9dc5, h2 = 0x01000193;
for (let i = 0; i < s.length; i++) {
const c = s.charCodeAt(i);
h1 = Math.imul(h1 ^ c, 0x01000193);
h2 = Math.imul(h2 + c, 0x85ebca6b) ^ (h2 >>> 13);
}
return [h1 >>> 0, (h2 >>> 0) || 1];
}
// Завантаження при холодному старті: 72 файли, ~25 МБ у памʼяті, близько 60 мс.
function load() {
if (state) return state;
const manifest = JSON.parse(readFileSync(join(FILTERS_DIR, 'manifest.json'), 'utf8'));
const filters = [];
for (const [code, kinds] of Object.entries(manifest.langs)) {
const entry = { code };
for (const kind of ['cyr', 'lat']) {
if (!kinds[kind]) continue;
try {
entry[kind] = { ...kinds[kind], bits: new Uint8Array(readFileSync(join(FILTERS_DIR, `${code}.${kind}.bin`))) };
} catch { /* файл не доїхав — мова просто не перевіряється */ }
}
if (entry.cyr || entry.lat) filters.push(entry);
}
const raw = JSON.parse(readFileSync(SIGNALS_PATH, 'utf8'));
const index = (section) => {
const map = new Map();
for (const [code, items] of Object.entries(raw[section] || {}))
for (const [key, w] of Object.entries(items)) {
let list = map.get(key);
if (!list) map.set(key, (list = []));
list.push([code, w]);
}
return map;
};
state = {
filters,
signals: { letters: index('letters'), endings: index('endings'), trigrams: index('trigrams') },
langs: filters.length
};
return state;
}
function inFilter(f, w) {
if (!f) return false;
const [a, b] = hash2(w);
for (let i = 0; i < f.k; i++) {
const idx = ((a + Math.imul(i, b)) >>> 0) % f.m;
if (!(f.bits[idx >> 3] & (1 << (idx & 7)))) return false;
}
return true;
}
function analyze(text) {
const { filters, signals } = load();
const acc = new Map(); // код мови → { score, dict, translit, letters, endings, trigrams }
const bucket = (code) => {
let b = acc.get(code);
if (!b) acc.set(code, (b = { score: 0, dict: 0, translit: 0, letters: 0, endings: 0, trigrams: 0 }));
return b;
};
const matched = [];
let words = 0, latinWords = 0;
const cyrillicChars = (text.match(/[Ѐ-ӿ]/g) || []).length;
for (const raw of text.toLowerCase().match(TOKEN) || []) {
const word = deHomoglyph(raw);
const cyr = hasCyr(word);
if (!cyr && !/[a-z]/.test(word)) continue;
words++;
if (!cyr) latinWords++;
if (!cyr && EN.has(word)) continue;
const keys = cyr ? [word.replace(/[^Ѐ-ӿ']/g, '')] : skeletons(word);
const kind = cyr ? 'cyr' : 'lat';
const minLen = cyr ? 2 : 3;
const found = [];
for (const f of filters) {
if (!f[kind]) continue;
for (const key of keys) {
if (key.length >= minLen && inFilter(f[kind], key)) { found.push(f.code); break; }
}
}
// Слово, що спрацювало одразу в кількох мовах, — майже завжди хибне
// спрацювання фільтра, тож бал ділимо між претендентами.
if (found.length && found.length < MAX_LANGS_PER_WORD) {
const w = 1 / found.length;
for (const code of found) {
const b = bucket(code);
b.score += w;
if (cyr) b.dict += w; else b.translit += w;
}
if (matched.length < 40) matched.push({ word: raw, langs: found, translit: !cyr });
}
if (!cyr) continue; // літери, закінчення й триграми транслітерація стирає
const key = keys[0];
for (const ch of key) {
const list = signals.letters.get(ch);
if (!list) continue;
for (const [code, w] of list) { const b = bucket(code); b.score += w * W.letter; b.letters++; }
}
for (let k = 4; k >= 2; k--) {
if (key.length <= k) continue;
const list = signals.endings.get(key.slice(-k));
if (!list) continue;
for (const [code, w] of list) { const b = bucket(code); b.score += w * W.ending; b.endings++; }
break; // лише найдовше збіжне закінчення
}
const padded = `_${key}_`;
for (let i = 0; i < padded.length - 2; i++) {
const list = signals.trigrams.get(padded.slice(i, i + 3));
if (!list) continue;
for (const [code, w] of list) { const b = bucket(code); b.score += w * W.trigram; b.trigrams++; }
}
}
const ranked = [...acc.entries()].sort((a, b) => b[1].score - a[1].score);
const total = ranked.reduce((s, [, b]) => s + b.score, 0);
const round = (n) => Math.round(n * 1000) / 1000;
const top = ranked.slice(0, 5).map(([code, b]) => ({
code,
score: round(b.score),
share: total ? round(b.score / total) : 0
}));
const ru = acc.get('rus');
const uk = acc.get('ukr');
const ruScore = ru?.score || 0;
const ukScore = uk?.score || 0;
const ruShare = total ? ruScore / total : 0;
const latinOnly = cyrillicChars === 0;
const minScore = latinOnly ? RU_SCORE_LATIN : RU_SCORE;
const minShare = latinOnly ? RU_SHARE_LATIN : RU_SHARE;
const minShareScore = latinOnly ? RU_SCORE_LATIN / 1.5 : RU_SHARE_MIN_SCORE;
const hasRussian = ruScore >= minScore || (ruShare >= minShare && ruScore >= minShareScore);
let language;
if (cyrillicChars === 0 && !latinWords) language = 'not-cyrillic';
else if (!ranked.length) language = cyrillicChars ? 'undetermined' : 'not-cyrillic';
else language = NAMES[ranked[0][0]] || ranked[0][0];
const pair = ruScore + ukScore;
const detail = (b) => ({
score: round(b?.score || 0),
words: round(b?.dict || 0),
translit: round(b?.translit || 0),
letters: b?.letters || 0,
endings: b?.endings || 0,
trigrams: b?.trigrams || 0,
// назви з першої версії, щоб ембед у sky-ru-bridge не показував нулі
stopWords: round(b?.dict || 0),
morphology: b?.endings || 0,
strongMarkers: round(b?.translit || 0)
});
return {
hasRussian,
language,
mixed: hasRussian && language !== 'russian',
score: pair ? round((ruScore - ukScore) / pair) : 0,
confidence: top.length ? round(top[0].share) : 0,
top,
transliterated: latinWords > 0 && (ru?.translit || uk?.translit || 0) > 0,
signals: { russian: detail(ru), ukrainian: detail(uk) },
stats: {
words,
latinWords,
cyrillicChars,
chars: text.length,
languages: load().langs
},
matched,
version: 2
};
}
export default async function handler(event, context) {
const body = event.body;
let text = '';
if (typeof body === 'string') text = body;
else if (body && typeof body === 'object' && typeof body.text === 'string') text = body.text;
if (!text && typeof event.query?.text === 'string') text = event.query.text;
if (!text.trim()) {
return {
statusCode: 400,
headers: { 'content-type': 'application/json' },
body: { error: 'Provide text via JSON {"text": "..."} or ?text=...' }
};
}
if (text.length > MAX_CHARS) text = text.slice(0, MAX_CHARS);
const started = Date.now();
const result = analyze(text);
context.log(
`ru-detector-v2: ${result.language} hasRussian=${result.hasRussian} ` +
`score=${result.score} conf=${result.confidence} ${Date.now() - started}ms`
);
return { statusCode: 200, headers: { 'content-type': 'application/json' }, body: result };
}