From c05c727afb8401b1bfe446036753a85fb052485a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 04:40:11 +0000 Subject: [PATCH] search: pick snippet window that covers the most distinct terms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous snippet() always centred the context window on the earliest occurrence of any matched term. For a multi-term query where the terms cluster together only later in the text (e.g. one isolated early hit and both terms appearing together further on), this produced a snippet showing only one of the matched terms — a poor result for the reader. The new implementation collects every occurrence of every matched term, then picks the candidate position whose window (±half of contextChars) contains the highest count of distinct terms. Single-term behaviour is unchanged; for multi-term queries the displayed snippet is now far more likely to show the words the reader actually searched for, advancing the search-result-quality goal tracked in issue #3. A new smoke test captures the exact failing case: 'silence' appears early (isolated) and again near 'integral' much later; the snippet must now include both. --- scripts/smoke.mjs | 14 ++++++++++++++ src/search.js | 47 ++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs index 02f90be..ff24f33 100644 --- a/scripts/smoke.mjs +++ b/scripts/smoke.mjs @@ -154,4 +154,18 @@ check('search matches an author name', () => { assert.ok(hits[0].matched.includes('nirodbaran')); }); +check('snippet centres on the window covering the most distinct terms', () => { + // 'silence' appears early (isolated) and again near 'integral' much later. + // A window centred on the early occurrence captures only 'silence'; a window + // centred near 'integral' captures both. The improved snippet picks the latter. + const filler = 'The way unfolds gradually. '.repeat(4); + const s = snippet( + { body: `Silence rests here. ${filler}In integral yoga, silence is paramount.` }, + ['integral', 'silence'], + { contextChars: 80 }, + ); + assert.ok(s.toLowerCase().includes('integral'), `expected 'integral' in: ${s}`); + assert.ok(s.toLowerCase().includes('silence'), `expected 'silence' in: ${s}`); +}); + console.log(`\n${passed} checks passed.`); diff --git a/src/search.js b/src/search.js index 73ab647..b226692 100644 --- a/src/search.js +++ b/src/search.js @@ -107,9 +107,15 @@ export function tokenize(text) { } /** - * Build a short contextual snippet around the first matched term, for display - * beneath a search result. Looks in the summary first, then the body; if no - * term is found, falls back to the start of the text. + * Build a short contextual snippet around the best window for the matched + * terms, for display beneath a search result. Looks in the summary first, then + * the body; if no term is found, falls back to the start of the text. + * + * "Best window" means the position (among all occurrences of all terms) that + * maximises the number of distinct terms visible within the context window. + * For single-term queries this is identical to the previous behaviour; for + * multi-term queries it avoids returning a snippet that shows only one term + * when the terms cluster together elsewhere in the text. * * @param {{summary?: string, body?: string}} article * @param {string[]} terms lowercased query terms (as returned in `matched`) @@ -121,14 +127,37 @@ export function snippet(article, terms = [], { contextChars = 120 } = {}) { for (const text of [article.summary, article.body]) { if (!text) continue; const lower = text.toLowerCase(); - let idx = -1; + + // Collect every occurrence position of every term. + const occurrences = []; for (const t of terms) { - const at = lower.indexOf(t); - if (at !== -1 && (idx === -1 || at < idx)) idx = at; + let at = lower.indexOf(t); + while (at !== -1) { + occurrences.push({ pos: at, term: t }); + at = lower.indexOf(t, at + 1); + } + } + if (!occurrences.length) continue; + + // Pick the position whose window covers the most distinct terms. + // Ties go to the earlier position (natural iteration order). + let bestPos = occurrences[0].pos; + let bestCoverage = 0; + for (const { pos } of occurrences) { + const lo = pos - half; + const hi = pos + half; + const seen = new Set(); + for (const o of occurrences) { + if (o.pos >= lo && o.pos <= hi) seen.add(o.term); + } + if (seen.size > bestCoverage) { + bestCoverage = seen.size; + bestPos = pos; + } } - if (idx === -1) continue; - const start = Math.max(0, idx - half); - const end = Math.min(text.length, idx + half); + + const start = Math.max(0, bestPos - half); + const end = Math.min(text.length, bestPos + half); let s = text.slice(start, end).trim(); if (start > 0) s = `… ${s}`; if (end < text.length) s = `${s} …`;