From 169039c4b49539940f4fb7760d5c3f62fd64856a Mon Sep 17 00:00:00 2001 From: sabo Date: Wed, 26 Aug 2026 13:20:23 +0300 Subject: [PATCH] Fix search: stem matching and skip query words absent from corpus. Co-authored-by: Cursor --- webapp/templates/viewer.html | 97 +++++++++++++++++++++++++++--------- 1 file changed, 74 insertions(+), 23 deletions(-) diff --git a/webapp/templates/viewer.html b/webapp/templates/viewer.html index ed679a2..9a8b8fc 100644 --- a/webapp/templates/viewer.html +++ b/webapp/templates/viewer.html @@ -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 => `