Keep the best-matching snippet per thread
This commit is contained in:
parent
2c432b57e7
commit
81fde4c932
1 changed files with 64 additions and 46 deletions
|
|
@ -12,16 +12,19 @@ export type SearchResult = {
|
|||
createdAt: number;
|
||||
};
|
||||
|
||||
// Window the snippet around the best term cluster: the anchor occurrence
|
||||
// whose window covers the most distinct query terms, so a multi-word query
|
||||
// excerpts the passage matching the whole phrase, not the first lone word.
|
||||
// Ties go to the earliest position.
|
||||
function snippetOf(content: string, query: string): string {
|
||||
// Window the snippet around the best match: a full-phrase occurrence when
|
||||
// present, otherwise the term cluster covering the most distinct query
|
||||
// terms. The score ranks how well this content matched (phrase beats any
|
||||
// scattered cluster), so dedupe can keep the best snippet per thread.
|
||||
function snippetOf(
|
||||
content: string,
|
||||
query: string,
|
||||
): { text: string; score: number } {
|
||||
const flat = content.replace(/\s+/g, " ").trim();
|
||||
const MAX = 140;
|
||||
if (flat.length <= MAX) return flat;
|
||||
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 }[] = [];
|
||||
for (const t of terms) {
|
||||
|
|
@ -31,27 +34,36 @@ function snippetOf(content: string, query: string): string {
|
|||
i += t.length;
|
||||
}
|
||||
}
|
||||
if (occurrences.length === 0) return flat.slice(0, MAX) + "…";
|
||||
occurrences.sort((a, b) => a.i - b.i);
|
||||
|
||||
let anchor = occurrences[0]?.i ?? -1;
|
||||
let score = 0;
|
||||
const phraseIdx = terms.length > 1 ? lower.indexOf(phrase) : -1;
|
||||
if (phraseIdx !== -1) {
|
||||
anchor = phraseIdx;
|
||||
score = terms.length + 100; // Full phrase beats any scattered cluster
|
||||
} else {
|
||||
const span = MAX - 40; // Visible chars from the anchor to the window's end
|
||||
let best = occurrences[0];
|
||||
let bestScore = 0;
|
||||
for (const o of occurrences) {
|
||||
const seen = new Set<string>();
|
||||
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 > bestScore) {
|
||||
bestScore = seen.size;
|
||||
best = o;
|
||||
if (seen.size > score) {
|
||||
score = seen.size;
|
||||
anchor = o.i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (best.i <= 40) return flat.slice(0, MAX) + "…";
|
||||
const start = best.i - 40;
|
||||
if (flat.length <= MAX) return { text: flat, score };
|
||||
if (anchor <= 40) return { text: flat.slice(0, MAX) + "…", score };
|
||||
const start = anchor - 40;
|
||||
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
|
||||
|
|
@ -72,39 +84,45 @@ export async function searchThreads(query: string): Promise<SearchResult[]> {
|
|||
return h !== undefined && (groups.size === 0 || groups.has(h));
|
||||
});
|
||||
|
||||
const byThread = new Map<string, SearchResult>();
|
||||
const opless: SearchResult[] = [];
|
||||
type Candidate = SearchResult & { score: number };
|
||||
const byThread = new Map<string, Candidate>();
|
||||
const needTitle: Candidate[] = [];
|
||||
for (const e of events) {
|
||||
if (e.kind === 11) {
|
||||
const existing = byThread.get(e.id);
|
||||
// An OP match wins over a reply match on the same thread
|
||||
if (existing && existing.matchKind === "thread") continue;
|
||||
byThread.set(e.id, {
|
||||
threadId: e.id,
|
||||
title: e.tags.find((t) => t[0] === "title")?.[1] ?? "(untitled)",
|
||||
snippet: snippetOf(e.content, query),
|
||||
matchKind: "thread",
|
||||
createdAt: e.created_at,
|
||||
});
|
||||
} else {
|
||||
const root = e.tags.find((t) => t[0] === "E")?.[1];
|
||||
if (!root || byThread.has(root)) continue;
|
||||
const r: SearchResult = {
|
||||
threadId: root,
|
||||
title: "",
|
||||
snippet: snippetOf(e.content, query),
|
||||
matchKind: "reply",
|
||||
const isOp = e.kind === 11;
|
||||
const threadId = isOp ? e.id : e.tags.find((t) => t[0] === "E")?.[1];
|
||||
if (!threadId) continue;
|
||||
const title = isOp
|
||||
? (e.tags.find((t) => t[0] === "title")?.[1] ?? "(untitled)")
|
||||
: "";
|
||||
const { text, score } = snippetOf(e.content, query);
|
||||
const existing = byThread.get(threadId);
|
||||
if (!existing) {
|
||||
const r: Candidate = {
|
||||
threadId,
|
||||
title,
|
||||
snippet: text,
|
||||
matchKind: isOp ? "thread" : "reply",
|
||||
createdAt: e.created_at,
|
||||
score,
|
||||
};
|
||||
byThread.set(root, r);
|
||||
opless.push(r);
|
||||
byThread.set(threadId, 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
|
||||
// multi-id queries, so fetch each OP on its own
|
||||
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 op: Event | undefined = ops[0];
|
||||
r.title = op?.tags.find((t) => t[0] === "title")?.[1] ?? "(untitled)";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue