Fix search: stem matching and skip query words absent from corpus.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-26 13:20:23 +03:00
parent 877a3f55db
commit 169039c4b4

View File

@@ -582,18 +582,18 @@ function toggleKw(ev, kw) {
}
function tokenizeQuery(q) {
// Паразитни / служебни думи — прескачат се при търсене
// Служебни думи (винаги се прескачат)
const STOP = new Set([
'аз','ти','той','тя','то','ние','вие','те','ме','ми','му','го','я','ги','ни','ви','се','си',
'съм','си','е','сме','сте','са','бях','беше','бяха','ще','да','не','ли','дали',
'съм','е','сме','сте','са','бях','беше','бяха','ще','да','не','ли','дали',
'и','или','но','а','нито','че','като','ако','защото','понеже',
'за','от','на','в','във','с','със','по','към','до','при','след','преди','без','над','под','през','около','между',
'този','тази','това','тези','онзи','онази','онова','онези','един','една','едно','едни',
'как','кой','коя','кое','кои','къде','кога','защо','колко','какъв','каква','какво','какви',
'инфо','информация','търся','търсим','търсите','искам','искаме','искате','моля','моляте',
'инфо','информация','търся','търсим','търсите','искам','искаме','искате','моля',
'покажи','покажете','кажи','кажете','дай','дайте','намери','намерете','имам','имаме','има','няма',
'може','могат','трябва','също','още','вече','само','много','малко','добре','така','тук','там',
'сега','тогава','пак','отново','примерно','например','относно','във','връзка','относно',
'сега','тогава','пак','отново','примерно','например','относно','връзка',
'please','info','information','about','for','the','a','an','i','am','looking','find','show','get',
]);
const tokens = [];
@@ -601,17 +601,63 @@ function tokenizeQuery(q) {
let m;
while ((m = re.exec(q)) !== null) {
let t = (m[1] || m[2] || '').toLowerCase();
// махаме пунктуация в края/началото: "карти:," -> "карти"
t = t.replace(/^[\s.,;:!?()\[\]{}"'«»]+|[\s.,;:!?()\[\]{}"'«»]+$/g, '');
if (!t) continue;
// quoted phrases keep even if short; unquoted stop-words skip
const quoted = !!m[1];
if (!quoted && (STOP.has(t) || t.length < 2)) continue;
tokens.push(t);
tokens.push({ raw: t, quoted });
}
return tokens;
}
/** Грубо стемване за BG: лимитни/лимитна → лимит ; карти/карта → карт */
function stemBg(w) {
w = String(w || '').toLowerCase();
if (w.length < 4) return w;
const ends = [
'ите','ата','ята','ият','ия','ът','ят','та','те','то',
'ове','еве','ам','ят','ях','иш','ит',
'ни','на','но','ен','ни','и','а','о','я','у','ю','е',
];
for (const e of ends) {
if (w.length > e.length + 3 && w.endsWith(e)) return w.slice(0, -e.length);
}
return w;
}
function wordsStemmed(s) {
return (String(s || '').toLowerCase().match(/[а-яa-z0-9]+/gi) || []).map(stemBg);
}
/** Дали терминът се среща някъде в корпуса (ключови + заглавие + текст) */
function tokenInCorpus(token) {
const st = stemBg(token);
if (st.length < 2) return false;
return ALL.some(r => {
const words = wordsStemmed(
(r.keywords || '') + ' ' + (r.title || '') + ' ' + (r.text || '')
);
return words.some(w => w === st || (st.length >= 4 && (w.startsWith(st) || st.startsWith(w))));
});
}
function fieldHay(r, mode) {
const kw = r.keywords || '';
const title = r.title || '';
const text = r.text || '';
if (mode === 'keywords') return kw;
if (mode === 'text') return text + ' ' + title;
return kw + ' ' + title + ' ' + text;
}
function rowMatchesToken(r, token, mode) {
const hay = fieldHay(r, mode);
if (token.quoted) return hay.toLowerCase().includes(token.raw);
const st = stemBg(token.raw);
const words = wordsStemmed(hay);
return words.some(w => w === st || (st.length >= 4 && w.startsWith(st)));
}
function searchMode() {
const el = document.querySelector('input[name="search-mode"]:checked');
return el ? el.value : 'all';
@@ -619,24 +665,29 @@ function searchMode() {
function doSearch() {
const raw = document.getElementById('search-input').value.trim();
const q = raw.toLowerCase();
const tokens = q ? tokenizeQuery(q) : [];
const mode = searchMode();
const results = tokens.length === 0 ? ALL : ALL.filter(r => {
const kw = (r.keywords || '').toLowerCase();
const title = (r.title || '').toLowerCase();
const text = (r.text || '').toLowerCase();
let hay = '';
if (mode === 'keywords') hay = kw;
else if (mode === 'text') hay = text + ' ' + title;
else hay = kw + ' ' + title + ' ' + text;
return tokens.every(t => hay.includes(t));
});
const skippedNote = (raw && tokens.length === 0)
? ' · само паразитни думи — показани всички'
: (tokens.length ? ' · филтър: ' + tokens.join(', ') : '');
let tokens = raw ? tokenizeQuery(raw.toLowerCase()) : [];
// Паразити = думи, които ги няма в ключовите думи / зоната за търсене → прескачат се
const kept = [];
const skipped = [];
for (const t of tokens) {
if (t.quoted || tokenInCorpus(t.raw)) kept.push(t);
else skipped.push(t.raw);
}
tokens = kept;
const results = tokens.length === 0 ? ALL : ALL.filter(r =>
tokens.every(t => rowMatchesToken(r, t, mode))
);
let note = '';
if (tokens.length) note += ' · филтър: ' + tokens.map(t => t.raw).join(', ');
if (skipped.length) note += ' · пропуснати: ' + skipped.join(', ');
if (raw && !tokens.length) note = ' · само паразитни думи — показани всички';
document.getElementById('search-stats').textContent =
results.length + ' извлечения' + skippedNote;
results.length + ' извлечения' + note;
document.getElementById('search-results').innerHTML = results.map(r => `
<div class="card ${selected.has(r.code)?'selected':''}" onclick="toggleSelect('${r.code}', this)">
<div class="card-header">