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;
|
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)";
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue