Keep the best-matching snippet per thread

This commit is contained in:
dtonon 2026-08-13 14:26:34 +01:00
parent 2c432b57e7
commit 81fde4c932

View file

@ -12,16 +12,19 @@ export type SearchResult = {
createdAt: number; createdAt: number;
}; };
// Window the snippet around the best term cluster: the anchor occurrence // Window the snippet around the best match: a full-phrase occurrence when
// whose window covers the most distinct query terms, so a multi-word query // present, otherwise the term cluster covering the most distinct query
// excerpts the passage matching the whole phrase, not the first lone word. // terms. The score ranks how well this content matched (phrase beats any
// Ties go to the earliest position. // scattered cluster), so dedupe can keep the best snippet per thread.
function snippetOf(content: string, query: string): string { function snippetOf(
content: string,
query: string,
): { text: string; score: number } {
const flat = content.replace(/\s+/g, " ").trim(); const flat = content.replace(/\s+/g, " ").trim();
const MAX = 140; const MAX = 140;
if (flat.length <= MAX) return flat;
const lower = flat.toLowerCase(); const lower = flat.toLowerCase();
const terms = [...new Set(query.toLowerCase().split(/\s+/).filter(Boolean))]; const phrase = query.toLowerCase().trim().replace(/\s+/g, " ");
const terms = [...new Set(phrase.split(" "))].filter(Boolean);
const occurrences: { i: number; term: string }[] = []; const occurrences: { i: number; term: string }[] = [];
for (const t of terms) { for (const t of terms) {
@ -31,27 +34,36 @@ function snippetOf(content: string, query: string): string {
i += t.length; i += t.length;
} }
} }
if (occurrences.length === 0) return flat.slice(0, MAX) + "…";
occurrences.sort((a, b) => a.i - b.i); occurrences.sort((a, b) => a.i - b.i);
const span = MAX - 40; // Visible chars from the anchor to the window's end let anchor = occurrences[0]?.i ?? -1;
let best = occurrences[0]; let score = 0;
let bestScore = 0; const phraseIdx = terms.length > 1 ? lower.indexOf(phrase) : -1;
for (const o of occurrences) { if (phraseIdx !== -1) {
const seen = new Set<string>(); anchor = phraseIdx;
for (const p of occurrences) { score = terms.length + 100; // Full phrase beats any scattered cluster
if (p.i >= o.i && p.i + p.term.length <= o.i + span) seen.add(p.term); } else {
} const span = MAX - 40; // Visible chars from the anchor to the window's end
if (seen.size > bestScore) { for (const o of occurrences) {
bestScore = seen.size; const seen = new Set<string>();
best = o; for (const p of occurrences) {
if (p.i >= o.i && p.i + p.term.length <= o.i + span) seen.add(p.term);
}
if (seen.size > score) {
score = seen.size;
anchor = o.i;
}
} }
} }
if (best.i <= 40) return flat.slice(0, MAX) + "…"; if (flat.length <= MAX) return { text: flat, score };
const start = best.i - 40; if (anchor <= 40) return { text: flat.slice(0, MAX) + "…", score };
const start = anchor - 40;
const end = Math.min(flat.length, start + MAX); const end = Math.min(flat.length, start + MAX);
return "…" + flat.slice(start, end) + (end < flat.length ? "…" : ""); return {
text: "…" + flat.slice(start, end) + (end < flat.length ? "…" : ""),
score,
};
} }
// NIP-50 search over the forum relay: thread OPs (kind 11) and replies // NIP-50 search over the forum relay: thread OPs (kind 11) and replies
@ -72,39 +84,45 @@ export async function searchThreads(query: string): Promise<SearchResult[]> {
return h !== undefined && (groups.size === 0 || groups.has(h)); return h !== undefined && (groups.size === 0 || groups.has(h));
}); });
const byThread = new Map<string, SearchResult>(); type Candidate = SearchResult & { score: number };
const opless: SearchResult[] = []; const byThread = new Map<string, Candidate>();
const needTitle: Candidate[] = [];
for (const e of events) { for (const e of events) {
if (e.kind === 11) { const isOp = e.kind === 11;
const existing = byThread.get(e.id); const threadId = isOp ? e.id : e.tags.find((t) => t[0] === "E")?.[1];
// An OP match wins over a reply match on the same thread if (!threadId) continue;
if (existing && existing.matchKind === "thread") continue; const title = isOp
byThread.set(e.id, { ? (e.tags.find((t) => t[0] === "title")?.[1] ?? "(untitled)")
threadId: e.id, : "";
title: e.tags.find((t) => t[0] === "title")?.[1] ?? "(untitled)", const { text, score } = snippetOf(e.content, query);
snippet: snippetOf(e.content, query), const existing = byThread.get(threadId);
matchKind: "thread", if (!existing) {
createdAt: e.created_at, const r: Candidate = {
}); threadId,
} else { title,
const root = e.tags.find((t) => t[0] === "E")?.[1]; snippet: text,
if (!root || byThread.has(root)) continue; matchKind: isOp ? "thread" : "reply",
const r: SearchResult = {
threadId: root,
title: "",
snippet: snippetOf(e.content, query),
matchKind: "reply",
createdAt: e.created_at, createdAt: e.created_at,
score,
}; };
byThread.set(root, r); byThread.set(threadId, r);
opless.push(r); if (!isOp) needTitle.push(r);
} else {
// Keep the snippet of whichever event matched the query best
if (score > existing.score) {
existing.snippet = text;
existing.matchKind = isOp ? "thread" : "reply";
existing.score = score;
}
if (isOp && !existing.title) existing.title = title;
} }
} }
// Backfill titles for reply-only matches; the group relay truncates // Backfill titles for reply-only matches; the group relay truncates
// multi-id queries, so fetch each OP on its own // multi-id queries, so fetch each OP on its own
await Promise.all( await Promise.all(
opless.map(async (r) => { needTitle.map(async (r) => {
if (r.title) return; // The OP appeared in the same result set
const ops = await queryForum({ kinds: [11], ids: [r.threadId] }); const ops = await queryForum({ kinds: [11], ids: [r.threadId] });
const op: Event | undefined = ops[0]; const op: Event | undefined = ops[0];
r.title = op?.tags.find((t) => t[0] === "title")?.[1] ?? "(untitled)"; r.title = op?.tags.find((t) => t[0] === "title")?.[1] ?? "(untitled)";