fix: классификатор не ловил кириллицу и склонения

- regex `\b` в JS не работает с кириллицей (русские буквы не word-char),
  поэтому ни одно русское ключевое слово не давало хитов;
- exact-match не учитывал склонения: «приложение» vs «приложении».

Заменил на ручной матч с допуском в 2 символа суффикса
(shared prefix ≥ max(keyword.length - 2, 4)). Сохранён exact-match
для коротких ключей (sms/ios/crm) чтобы избежать ложных срабатываний.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
zhanakova 2026-05-22 13:43:14 +05:00
parent d8f3cfea24
commit 0455123d8e
2 changed files with 56 additions and 20 deletions

View File

@ -448,23 +448,41 @@ function applyLang(){
function normalize(s){ function normalize(s){
return s.toLowerCase().replace(/ё/g,"е").replace(/[^\p{L}\p{N}\s]/gu," ").split(/\s+/).filter(Boolean); return s.toLowerCase().replace(/ё/g,"е").replace(/[^\p{L}\p{N}\s]/gu," ").split(/\s+/).filter(Boolean);
} }
function escapeReg(s){ return s.replace(/[.*+?^${}()|[\]\\]/g,"\\$&"); } function matchTerm(tokens, term){
function scoreZones(text){ const norm = normalize(term);
const tokenStr = normalize(text).join(" "); if (norm.length === 0) return 0;
return state.directory.zones.map(z => { if (norm.length > 1) {
let score=0; const hits=[]; const joined = " " + tokens.join(" ") + " ";
for (const k of z.keywords){ const needle = " " + norm.join(" ") + " ";
const n = normalize(k).join(" "); let cnt = 0, idx = 0;
const c = (tokenStr.match(new RegExp("\\b"+escapeReg(n)+"\\b","g"))||[]).length; while ((idx = joined.indexOf(needle, idx)) !== -1) { cnt++; idx += needle.length - 1; }
if (c>0){ score += c*1.0; hits.push(k); } return cnt;
} }
for (const sys of z.systems){ const n = norm[0];
const n = normalize(sys).join(" "); let cnt = 0;
const c = (tokenStr.match(new RegExp("\\b"+escapeReg(n)+"\\b","g"))||[]).length; for (const tok of tokens) {
if (c>0){ score += c*1.5; hits.push(sys); } if (tok === n) { cnt++; continue; }
if (n.length < 4) continue;
const k = Math.max(n.length - 2, 4);
if (tok.length < k || n.length < k) continue;
if (tok.slice(0, k) === n.slice(0, k)) cnt++;
}
return cnt;
}
function scoreZones(text){
const tokens = normalize(text);
return state.directory.zones.map(z => {
let score = 0; const hits = [];
for (const k of z.keywords) {
const c = matchTerm(tokens, k);
if (c > 0) { score += c * 1.0; hits.push(k); }
}
for (const sys of z.systems) {
const c = matchTerm(tokens, sys);
if (c > 0) { score += c * 1.5; hits.push(sys); }
} }
return { zoneId:z.id, score, hits }; return { zoneId:z.id, score, hits };
}).filter(x=>x.score>0).sort((a,b)=>b.score-a.score); }).filter(x => x.score > 0).sort((a,b) => b.score - a.score);
} }
function assignPerson(zoneId){ function assignPerson(zoneId){
const cs = state.directory.people.filter(p => p.zoneId===zoneId); const cs = state.directory.people.filter(p => p.zoneId===zoneId);

View File

@ -252,20 +252,38 @@ function applyLang() {
function normalize(s){ function normalize(s){
return s.toLowerCase().replace(/ё/g,"е").replace(/[^\p{L}\p{N}\s]/gu," ").split(/\s+/).filter(Boolean); return s.toLowerCase().replace(/ё/g,"е").replace(/[^\p{L}\p{N}\s]/gu," ").split(/\s+/).filter(Boolean);
} }
function escapeReg(s){ return s.replace(/[.*+?^${}()|[\]\\]/g,"\\$&"); } function matchTerm(tokens, term){
const norm = normalize(term);
if (norm.length === 0) return 0;
if (norm.length > 1) {
const joined = " " + tokens.join(" ") + " ";
const needle = " " + norm.join(" ") + " ";
let cnt = 0, idx = 0;
while ((idx = joined.indexOf(needle, idx)) !== -1) { cnt++; idx += needle.length - 1; }
return cnt;
}
const n = norm[0];
let cnt = 0;
for (const tok of tokens) {
if (tok === n) { cnt++; continue; }
if (n.length < 4) continue;
const k = Math.max(n.length - 2, 4);
if (tok.length < k || n.length < k) continue;
if (tok.slice(0, k) === n.slice(0, k)) cnt++;
}
return cnt;
}
function scoreZones(text){ function scoreZones(text){
const tokenStr = normalize(text).join(" "); const tokens = normalize(text);
return state.directory.zones.map(z => { return state.directory.zones.map(z => {
let score = 0; const hits = []; let score = 0; const hits = [];
for (const k of z.keywords) { for (const k of z.keywords) {
const n = normalize(k).join(" "); const c = matchTerm(tokens, k);
const c = (tokenStr.match(new RegExp("\\b"+escapeReg(n)+"\\b","g"))||[]).length; if (c > 0) { score += c * 1.0; hits.push(k); }
if (c>0){ score += c*1.0; hits.push(k); }
} }
for (const sys of z.systems) { for (const sys of z.systems) {
const n = normalize(sys).join(" "); const c = matchTerm(tokens, sys);
const c = (tokenStr.match(new RegExp("\\b"+escapeReg(n)+"\\b","g"))||[]).length; if (c > 0) { score += c * 1.5; hits.push(sys); }
if (c>0){ score += c*1.5; hits.push(sys); }
} }
return { zoneId:z.id, score, hits }; return { zoneId:z.id, score, hits };
}).filter(x => x.score>0).sort((a,b)=>b.score-a.score); }).filter(x => x.score>0).sort((a,b)=>b.score-a.score);