diff --git a/src/components/admin/merge-context-panel.tsx b/src/components/admin/merge-context-panel.tsx index d4dd7a6..929c1fd 100644 --- a/src/components/admin/merge-context-panel.tsx +++ b/src/components/admin/merge-context-panel.tsx @@ -1,49 +1,95 @@ "use client" -import { useEffect, useState } from "react" +import { useEffect, useState, type ReactNode } from "react" import { getSubgraph } from "@/lib/graph-api" import type { Review } from "@/lib/graph-api" -import { deriveMergeContext } from "@/lib/merge-context" -import type { MergeGraphContext, SubjectGraphContext } from "@/lib/merge-context" +import { deriveMentions, nameStems } from "@/lib/merge-context" +import type { MentionExcerpt } from "@/lib/merge-context" -// Wide enough that the neighbor-overlap intersection is meaningful even for -// well-connected entities; the payload is one hop only. -const SUBGRAPH_LIMIT = 100 +const SUBGRAPH_LIMIT = 50 // Merge reviews carry candidate + canonical; legacy multi-candidate reviews // are capped so a pathological row can't fan out requests. const MAX_SUBJECTS = 4 -const EDGE_TYPES_SHOWN = 3 -function subjectName(review: Review, refId: string): string { +function subjectName(review: Review, refId: string): string | null { const subject = review.subject_nodes.find((sn) => sn.ref_id === refId) const props = subject?.properties for (const key of ["name", "title", "episode_title"]) { const value = props?.[key] if (typeof value === "string" && value.trim()) return value.trim() } - return refId.slice(0, 8) + return null } -function edgeCountsLine(subject: SubjectGraphContext): string { - const parts = Object.entries(subject.edgeCounts) - .sort((a, b) => b[1] - a[1]) - .slice(0, EDGE_TYPES_SHOWN) - .map(([type, count]) => `${type} ${count}`) - const connections = `${subject.degree} connection${subject.degree === 1 ? "" : "s"}` - return parts.length > 0 ? `${connections} · ${parts.join(" · ")}` : connections +/** + * Card order mirrors the Sources → Canonical layout above the panel: the + * merge sources come first, the canonical (to) node last. subject_ids order + * is not reliable for this — it's fingerprint-sorted. + */ +function orderedSubjectIds(review: Review): string[] { + const payload = review.action_payload as { from?: unknown; to?: unknown } | null + const from = Array.isArray(payload?.from) + ? payload.from.filter((id): id is string => typeof id === "string" && id !== "") + : [] + const to = typeof payload?.to === "string" ? payload.to : null + if (to) { + const sources = Array.from(new Set(from)).filter((id) => id !== to) + return [...sources, to].slice(0, MAX_SUBJECTS) + } + return review.subject_ids.slice(0, MAX_SUBJECTS) +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") } /** - * Graph evidence under an expanded merge review: per subject its degree and - * the sentences it was extracted from (via /v2/graph/subgraph), plus the - * neighbor overlap between the subjects — shared neighbors argue for the - * merge, disjoint neighborhoods against it. + * The excerpt with the words matching the subject's name marked. Matching is + * stem-based (same stems the excerpt was windowed around), so inflected + * forms light up too — "extract" for a subject named "…extraction". + */ +function HighlightedExcerpt({ text, term }: { text: string; term: string | null }) { + const stems = nameStems(term) + if (stems.length === 0) return <>{text} + const pattern = new RegExp( + `\\b(${stems.map(escapeRegExp).join("|")})[\\w]*`, + "gi" + ) + const out: ReactNode[] = [] + let last = 0 + let key = 0 + for (const match of text.matchAll(pattern)) { + const idx = match.index ?? 0 + if (idx > last) out.push(text.slice(last, idx)) + out.push( + + {match[0]} + + ) + last = idx + match[0].length + } + if (last < text.length) out.push(text.slice(last)) + return <>{out} +} + +interface SubjectMentions { + refId: string + name: string | null + mentions: MentionExcerpt[] +} + +/** + * The sentences each merge subject was extracted from, shown under the + * expanded row. Extracted entities carry no provenance properties — their + * incoming MENTIONS edges are the only trail back to the source text. + * Fetched lazily on expand; sources first, canonical last, matching the + * columns above. */ export function MergeContextPanel({ review }: { review: Review }) { - const [context, setContext] = useState(null) + const [subjects, setSubjects] = useState(null) const [failed, setFailed] = useState(false) - const subjectsKey = review.subject_ids.slice(0, MAX_SUBJECTS).join(",") + const subjectsKey = orderedSubjectIds(review).join(",") useEffect(() => { const subjectIds = subjectsKey ? subjectsKey.split(",") : [] @@ -55,13 +101,27 @@ export function MergeContextPanel({ review }: { review: Review }) { const graphs = await Promise.all( subjectIds.map((id) => getSubgraph( - { start_node: id, depth: 1, limit: SUBGRAPH_LIMIT }, + { + start_node: id, + depth: 1, + limit: SUBGRAPH_LIMIT, + edge_type: ["MENTIONS"], + }, ctrl.signal ) ) ) if (cancelled) return - setContext(deriveMergeContext(subjectIds, graphs)) + setSubjects( + subjectIds.map((id, i) => { + const name = subjectName(review, id) + return { + refId: id, + name, + mentions: deriveMentions(id, graphs[i] ?? { nodes: [], edges: [] }, name), + } + }) + ) } catch { if (!cancelled) setFailed(true) } @@ -70,12 +130,14 @@ export function MergeContextPanel({ review }: { review: Review }) { cancelled = true ctrl.abort() } + // review identity is stable for a row; subjectsKey captures what we fetch on + // eslint-disable-next-line react-hooks/exhaustive-deps }, [subjectsKey]) if (failed) { return (

- Graph context unavailable + Source sentences unavailable

) } @@ -83,81 +145,57 @@ export function MergeContextPanel({ review }: { review: Review }) { return (
- Graph Context + Source Sentences
- {context === null ? ( + {subjects === null ? (
{Array.from({ length: 2 }).map((_, i) => ( -
+
))}
) : ( - <> -
- {context.subjects.map((subject) => ( -
-
- - {subjectName(review, subject.refId)} - - - {edgeCountsLine(subject)} - -
- {subject.mentions.length > 0 ? ( -
    - {subject.mentions.map((mention) => ( -
  • - - {mention.nodeType ?? "?"} - - “{mention.excerpt}” -
  • - ))} -
- ) : ( -

- No source text found in the immediate neighborhood -

- )} -
- ))} -
- - {context.subjects.length > 1 && ( -

0 - ? "mt-1.5 text-[10px] text-emerald-400" - : "mt-1.5 text-[10px] text-amber-400" - } - data-testid="merge-context-overlap" +

+ {subjects.map((subject) => ( +
- {context.sharedCount > 0 ? ( - <> - {context.sharedCount} shared connection - {context.sharedCount === 1 ? "" : "s"} - {context.sharedExamples.length > 0 && ( - - {" "} - — {context.sharedExamples.map((n) => n.name).join(", ")} - - )} - +
+ {subject.name ?? subject.refId.slice(0, 8)} +
+ {subject.mentions.length > 0 ? ( +
    + {subject.mentions.map((mention) => ( +
  • + + {mention.nodeType ?? "?"} + + “” +
  • + ))} +
) : ( - "No shared connections — the neighborhoods are disjoint" +

+ No source text found +

)} -

- )} - +
+ ))} +
)}
) diff --git a/src/components/admin/review-row.tsx b/src/components/admin/review-row.tsx index d9d6a0d..eba5ac2 100644 --- a/src/components/admin/review-row.tsx +++ b/src/components/admin/review-row.tsx @@ -3,7 +3,7 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react" import { createPortal } from "react-dom" import { useRouter } from "next/navigation" -import { ArrowRight, ArrowRightLeft, CheckCircle2, ChevronRight, GitMerge, Layers, Loader2, Network, Pencil, PlusCircle, PlusSquare, Share2, Trash2, Users, type LucideIcon } from "lucide-react" +import { ArrowRight, ArrowRightLeft, Check, CheckCircle2, ChevronRight, Copy, GitMerge, Layers, Loader2, Network, Pencil, PlusCircle, PlusSquare, Share2, Trash2, Users, type LucideIcon } from "lucide-react" import { formatDateRelative } from "@/lib/date-format" import type { PromotionSummary, @@ -500,6 +500,31 @@ function ConfirmActionPopover({ // ── Subject list item (for expanded panel) ─────────────────────────────────── +function CopyRefButton({ refId }: { refId: string }) { + const [copied, setCopied] = useState(false) + return ( + + ) +} + function SubjectListItem({ refId, resolved, @@ -513,22 +538,28 @@ function SubjectListItem({ }) { if (!resolved || resolved.node_type === null) { return ( - + Deleted: {refId} + ) } return ( - +
+
+ +
+ +
) } diff --git a/src/lib/__tests__/merge-context.test.ts b/src/lib/__tests__/merge-context.test.ts index 3d637a8..4738b27 100644 --- a/src/lib/__tests__/merge-context.test.ts +++ b/src/lib/__tests__/merge-context.test.ts @@ -1,124 +1,193 @@ import { describe, it, expect } from "vitest" -import { - cleanExcerpt, - deriveMergeContext, - deriveSubjectContext, -} from "@/lib/merge-context" +import { deriveMentions, excerptFor, nameStems } from "@/lib/merge-context" import type { SubgraphResponse } from "@/lib/graph-api" -function graph(partial: Partial): SubgraphResponse { - return { nodes: [], edges: [], ...partial } -} +describe("nameStems", () => { + it("keeps significant words, stemmed, without stopwords", () => { + expect(nameStems("Document search and extraction")).toEqual([ + "docu", + "sear", + "extrac", + ]) + }) + + it("is empty for null/short names", () => { + expect(nameStems(null)).toEqual([]) + expect(nameStems("of")).toEqual([]) + }) +}) -describe("cleanExcerpt", () => { - it("strips stored wrapper quotes and collapses whitespace", () => { - expect(cleanExcerpt('"A US ban was\n\nsupposed"')).toBe( - "A US ban was supposed" +describe("excerptFor", () => { + it("keeps short texts whole when the window covers them", () => { + const text = + '"RT @OpenAI: ChatGPT Voice is now in the desktop app today for everyone"' + expect(excerptFor(text, "ChatGPT Voice")).toBe( + "RT @OpenAI: ChatGPT Voice is now in the desktop app today for everyone" ) }) - it("returns null for non-strings and empty strings", () => { - expect(cleanExcerpt(undefined)).toBeNull() - expect(cleanExcerpt(42)).toBeNull() - expect(cleanExcerpt('""')).toBeNull() - expect(cleanExcerpt(" ")).toBeNull() + it("windows around a mid-text occurrence with ellipses marking the cuts", () => { + const words = Array.from({ length: 30 }, (_, i) => `w${i}`) + words.splice(15, 0, "Docker", "Compose") + const result = excerptFor(words.join(" "), "Docker Compose") + expect(result).toBe( + "… " + + // 6 words before the match, the match, 10 words after + [...words.slice(9, 15), "Docker", "Compose", ...words.slice(17, 27)].join(" ") + + " …" + ) + }) + + it("matches inflected forms via stems — the name rarely appears verbatim", () => { + const text = + "With the official skill your agent acts here to manage files and versions in bulk, search and extract document data, and query your cloud file system for anything else you need today." + const result = excerptFor(text, "Document search and extraction") + expect(result).toContain("extract document") + expect(result!.startsWith("… ")).toBe(true) + expect(result!.endsWith(" …")).toBe(true) + }) + + it("decodes literal escape sequences from stored tweet text", () => { + const text = '"two passes:\\n1\\ufe0f\\u20e3 A fast pass"' + const result = excerptFor(text, null) + expect(result).not.toContain("\\n") + expect(result).not.toContain("\\u") + expect(result).toContain("two passes:") + expect(result).toContain("A fast pass") + }) + + it("falls back to the head of the text when the name never occurs", () => { + const words = Array.from({ length: 30 }, (_, i) => `w${i}`).join(" ") + expect(excerptFor(words, "Zebra")).toBe( + Array.from({ length: 20 }, (_, i) => `w${i}`).join(" ") + " …" + ) }) - it("truncates long text on a word boundary with an ellipsis", () => { - const result = cleanExcerpt("word ".repeat(100)) - expect(result!.length).toBeLessThanOrEqual(221) - expect(result!.endsWith("…")).toBe(true) - expect(result).not.toContain("wor…") // no mid-word cut + it("returns null for non-strings and empty strings", () => { + expect(excerptFor(undefined, "x")).toBeNull() + expect(excerptFor(42, "x")).toBeNull() + expect(excerptFor('""', "x")).toBeNull() + expect(excerptFor(" ", "x")).toBeNull() }) }) -describe("deriveSubjectContext", () => { +describe("deriveMentions", () => { const subject = "org-1" - const subgraph = graph({ + const subgraph: SubgraphResponse = { nodes: [ { ref_id: subject, node_type: "Organization", properties: { name: "United States" } }, { ref_id: "tweet-1", node_type: "Tweet", - properties: { text: '"a sentence mentioning it"' }, + properties: { text: '"a sentence mentioning United States"' }, }, - { ref_id: "chapter-1", node_type: "Chapter", properties: { name: "AI race overview" } }, - { ref_id: "alias-1", node_type: "Organization", properties: { name: "USA" } }, + { + ref_id: "chapter-1", + node_type: "Chapter", + properties: { + name: "AI race overview", + description: "The panel frames United States policy against China's.", + }, + }, + { ref_id: "chapter-2", node_type: "Chapter", properties: { name: "Closing thoughts" } }, + { ref_id: "no-text", node_type: "Image", properties: {} }, { ref_id: "bystander", node_type: "Episode", properties: { episode_title: "Ep" } }, ], edges: [ { ref_id: 1, edge_type: "MENTIONS", source: "tweet-1", target: subject }, { ref_id: 2, edge_type: "MENTIONS", source: "chapter-1", target: subject }, - { ref_id: 3, edge_type: "IS_ALIAS", source: "alias-1", target: subject }, - // outgoing MENTIONS: counts as a connection but not a mention source + { ref_id: 3, edge_type: "MENTIONS", source: "no-text", target: subject }, + { ref_id: 7, edge_type: "MENTIONS", source: "chapter-2", target: subject }, + // outgoing MENTIONS: the subject mentioning something is not a source { ref_id: 4, edge_type: "MENTIONS", source: subject, target: "bystander" }, - // edge between neighbors — apoc returns these; must not affect the subject - { ref_id: 5, edge_type: "HAS", source: "bystander", target: "chapter-1" }, + // non-MENTIONS incident edge: not a source either + { ref_id: 5, edge_type: "IS_ALIAS", source: "bystander", target: subject }, + // edge between neighbors — apoc returns these; must be ignored + { ref_id: 6, edge_type: "HAS", source: "bystander", target: "chapter-1" }, ], - }) + } - it("counts only incident edges and dedupes neighbors", () => { - const ctx = deriveSubjectContext(subject, subgraph) - expect(ctx.degree).toBe(4) - expect(ctx.edgeCounts).toEqual({ MENTIONS: 3, IS_ALIAS: 1 }) + it("collects prose properties only — labels like a chapter's name are not sentences", () => { + expect(deriveMentions(subject, subgraph, "United States")).toEqual([ + { + refId: "tweet-1", + nodeType: "Tweet", + excerpt: "a sentence mentioning United States", + matched: true, + }, + // chapter descriptions are prose and count as source sentences + { + refId: "chapter-1", + nodeType: "Chapter", + excerpt: "The panel frames United States policy against China's.", + matched: true, + }, + // chapter-2 has only a name (topic label) → excluded + ]) }) - it("collects cleaned excerpts only from incoming MENTIONS sources", () => { - const ctx = deriveSubjectContext(subject, subgraph) - expect(ctx.mentions).toEqual([ - { refId: "tweet-1", nodeType: "Tweet", excerpt: "a sentence mentioning it" }, - { refId: "chapter-1", nodeType: "Chapter", excerpt: "AI race overview" }, + it("ranks sources containing the name above head-of-text fallbacks", () => { + const graph: SubgraphResponse = { + nodes: [ + // text property but the name never occurs → fallback excerpt + { ref_id: "tweet-nomatch", node_type: "Tweet", properties: { text: "something entirely unrelated" } }, + // only a summary, but it contains the name → matched excerpt + { ref_id: "ep-match", node_type: "Episode", properties: { summary: "a summary about United States policy" } }, + ], + edges: [ + { ref_id: 1, edge_type: "MENTIONS", source: "tweet-nomatch", target: subject }, + { ref_id: 2, edge_type: "MENTIONS", source: "ep-match", target: subject }, + ], + } + const mentions = deriveMentions(subject, graph, "United States") + expect(mentions.map((m) => [m.refId, m.matched])).toEqual([ + ["ep-match", true], + ["tweet-nomatch", false], ]) }) -}) - -describe("deriveMergeContext", () => { - const a = "node-a" - const b = "node-b" - - function graphFor(subject: string, neighbors: string[]): SubgraphResponse { - return graph({ - nodes: neighbors.map((id) => ({ - ref_id: id, - node_type: "Episode", - properties: { name: `Name ${id}` }, - })), - edges: neighbors.map((id, i) => ({ - ref_id: i, - edge_type: "MENTIONS", - source: id, - target: subject, - })), - }) - } - it("intersects neighborhoods, excluding the subjects themselves", () => { - // a and b are each other's neighbors (IS_ALIAS-style) plus one real shared - const ctx = deriveMergeContext( - [a, b], - [graphFor(a, ["shared-1", "only-a", b]), graphFor(b, ["shared-1", "only-b", a])] - ) - expect(ctx.sharedCount).toBe(1) - expect(ctx.sharedExamples).toEqual([ - { refId: "shared-1", name: "Name shared-1", nodeType: "Episode" }, - ]) + it("ranks text sources above summary sources regardless of edge order", () => { + const graph: SubgraphResponse = { + nodes: [ + { ref_id: "ep-1", node_type: "Episode", properties: { summary: "An episode summary about it" } }, + { ref_id: "tweet-9", node_type: "Tweet", properties: { text: "the actual sentence" } }, + ], + edges: [ + // summary-bearing source comes FIRST in edge order… + { ref_id: 1, edge_type: "MENTIONS", source: "ep-1", target: subject }, + { ref_id: 2, edge_type: "MENTIONS", source: "tweet-9", target: subject }, + ], + } + const mentions = deriveMentions(subject, graph, null) + // …but the text-bearing tweet still ranks first + expect(mentions.map((m) => m.refId)).toEqual(["tweet-9", "ep-1"]) }) - it("reports zero overlap for disjoint neighborhoods", () => { - const ctx = deriveMergeContext( - [a, b], - [graphFor(a, ["x1", "x2"]), graphFor(b, ["y1"])] - ) - expect(ctx.sharedCount).toBe(0) - expect(ctx.sharedExamples).toEqual([]) - expect(ctx.subjects[0].degree).toBe(2) - expect(ctx.subjects[1].degree).toBe(1) + it("windows into an episode transcript when one is stored", () => { + const transcript = + '"Rene Haas: There\'s no computing problem left. ' + + "filler ".repeat(50) + + 'Later on we discuss how United States policy shapes chip supply chains going forward for everyone involved."' + const graph: SubgraphResponse = { + nodes: [ + { + ref_id: "ep-t", + node_type: "Episode", + properties: { transcript, summary: "unrelated summary" }, + }, + ], + edges: [ + { ref_id: 1, edge_type: "MENTIONS", source: "ep-t", target: subject }, + ], + } + const mentions = deriveMentions(subject, graph, "United States") + expect(mentions).toHaveLength(1) + expect(mentions[0].matched).toBe(true) + expect(mentions[0].excerpt).toContain("United States policy shapes") + expect(mentions[0].excerpt.startsWith("… ")).toBe(true) }) - it("tolerates a missing subgraph for a subject", () => { - const ctx = deriveMergeContext([a, b], [graphFor(a, ["x1"])]) - expect(ctx.subjects).toHaveLength(2) - expect(ctx.subjects[1].degree).toBe(0) - expect(ctx.sharedCount).toBe(0) + it("returns empty for an empty subgraph", () => { + expect(deriveMentions(subject, { nodes: [], edges: [] }, null)).toEqual([]) }) }) diff --git a/src/lib/__tests__/reviews.test.tsx b/src/lib/__tests__/reviews.test.tsx index a81bd88..6ee8e1c 100644 --- a/src/lib/__tests__/reviews.test.tsx +++ b/src/lib/__tests__/reviews.test.tsx @@ -2470,88 +2470,114 @@ describe("ReviewsPage decided-view controls", () => { // ── MergeContextPanel ──────────────────────────────────────────────────────── describe("MergeContextPanel", () => { - const subjectA = "n1" - const subjectB = "n2" - - function graphFor(subject: string, opts: { shared?: boolean; tweetText?: string }) { - const nodes = [ - { ref_id: "shared-ep", node_type: "Episode", properties: { name: "Shared Episode" } }, - ] - const edges = [] - if (opts.shared) { - edges.push({ ref_id: 1, edge_type: "MENTIONS", source: "shared-ep", target: subject }) - } - if (opts.tweetText) { - nodes.push({ - ref_id: `tweet-${subject}`, - node_type: "Tweet", - properties: { text: opts.tweetText }, - }) - edges.push({ - ref_id: 2, - edge_type: "MENTIONS", - source: `tweet-${subject}`, - target: subject, - }) + // Fixture direction: action_payload { from: ["n2"], to: "n1" } — so the + // panel must show n2 (source) first and n1 (canonical) last, mirroring the + // Sources → Canonical columns above it. + const canonical = "n1" + const source = "n2" + + function mentionsGraph(subject: string, text?: string) { + if (!text) return { nodes: [], edges: [] } + return { + nodes: [ + { ref_id: `tweet-${subject}`, node_type: "Tweet", properties: { text } }, + ], + edges: [ + { + ref_id: 1, + edge_type: "MENTIONS", + source: `tweet-${subject}`, + target: subject, + }, + ], } - return { nodes, edges } } beforeEach(() => { mockGetSubgraph.mockReset() }) - it("fetches both subjects' subgraphs and renders excerpts plus overlap", async () => { + it("orders cards sources-first and renders highlighted source sentences", async () => { mockGetSubgraph.mockImplementation((params: { start_node: string }) => Promise.resolve( - graphFor(params.start_node, { - shared: true, - tweetText: params.start_node === subjectA ? '"extracted sentence"' : undefined, - }) + mentionsGraph( + params.start_node, + params.start_node === source + ? '"RT @OpenAI: Node Two is now in the desktop app"' + : undefined + ) ) ) const { MergeContextPanel } = await import( "@/components/admin/merge-context-panel" ) - render() + const { container } = render() await waitFor(() => { - expect(screen.getByTestId("merge-context-overlap")).toBeTruthy() + expect(screen.getByTestId(`merge-context-subject-${source}`)).toBeTruthy() }) + + // MENTIONS-only fetch, one call per subject expect(mockGetSubgraph).toHaveBeenCalledTimes(2) expect(mockGetSubgraph).toHaveBeenCalledWith( - expect.objectContaining({ start_node: subjectA, depth: 1 }), + expect.objectContaining({ + start_node: source, + depth: 1, + edge_type: ["MENTIONS"], + }), expect.anything() ) - // Excerpt is cleaned (quotes stripped, whitespace collapsed) - expect(screen.getByText(/“extracted sentence”/)).toBeTruthy() - // Shared neighbor line names the example - expect(screen.getByTestId("merge-context-overlap").textContent).toContain( - "1 shared connection" + + // Source card comes before the canonical card in DOM order + const cards = Array.from( + container.querySelectorAll('[data-testid^="merge-context-subject-"]') + ) + expect(cards.map((c) => c.getAttribute("data-testid"))).toEqual([ + `merge-context-subject-${source}`, + `merge-context-subject-${canonical}`, + ]) + + // The short tweet fits inside the word window, so it shows whole, with + // the name's words highlighted. The s split the text, so assert + // on combined textContent. + const sourceCard = screen.getByTestId(`merge-context-subject-${source}`) + expect(sourceCard.textContent).toContain( + "RT @OpenAI: Node Two is now in the desktop app" ) - expect(screen.getByTestId("merge-context-overlap").textContent).toContain( - "Shared Episode" + const marks = Array.from(container.querySelectorAll("mark")).map( + (m) => m.textContent ) + expect(marks).toEqual(["Node", "Two"]) + + // The canonical side has no source text + expect(screen.getByText("No source text found")).toBeTruthy() }) - it("flags disjoint neighborhoods", async () => { - mockGetSubgraph.mockImplementation((params: { start_node: string }) => - Promise.resolve( - params.start_node === subjectA - ? { nodes: [{ ref_id: "only-a", node_type: "Episode", properties: {} }], edges: [{ ref_id: 1, edge_type: "MENTIONS", source: "only-a", target: subjectA }] } - : { nodes: [], edges: [] } - ) - ) + it("falls back to subject_ids order when the payload has no direction", async () => { + mockGetSubgraph.mockResolvedValue({ nodes: [], edges: [] }) const { MergeContextPanel } = await import( "@/components/admin/merge-context-panel" ) - render() + const review = makeReview({ + action_payload: {}, + subject_ids: ["s1", "s2"], + subject_nodes: [ + { ref_id: "s1", node_type: "Topic", properties: { name: "S One" } }, + { ref_id: "s2", node_type: "Topic", properties: { name: "S Two" } }, + ], + }) + const { container } = render() await waitFor(() => { - expect( - screen.getByText("No shared connections — the neighborhoods are disjoint") - ).toBeTruthy() + expect(screen.getByTestId("merge-context-subject-s1")).toBeTruthy() }) + const cards = Array.from( + container.querySelectorAll('[data-testid^="merge-context-subject-"]') + ) + expect(cards.map((c) => c.getAttribute("data-testid"))).toEqual([ + "merge-context-subject-s1", + "merge-context-subject-s2", + ]) }) it("degrades quietly when the subgraph fetch fails", async () => { @@ -2559,10 +2585,66 @@ describe("MergeContextPanel", () => { const { MergeContextPanel } = await import( "@/components/admin/merge-context-panel" ) - render() + render() await waitFor(() => { - expect(screen.getByText("Graph context unavailable")).toBeTruthy() + expect(screen.getByText("Source sentences unavailable")).toBeTruthy() }) }) }) + +// ── Copy node id button ────────────────────────────────────────────────────── + +describe("SubjectListItem copy button", () => { + const user = userEvent.setup() + const noop = () => {} + + it("copies the subject's ref_id to the clipboard on click", async () => { + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + }) + + const review = makeReview({ + action_name: "merge_nodes", + action_payload: { from: ["n2"], to: "n1" }, + }) + const { getByText, getByTestId } = render( + + ) + // Expand the row + await user.click(getByText("Node Two")) + + await user.click(getByTestId("copy-ref-n2")) + expect(writeText).toHaveBeenCalledWith("n2") + // Copied feedback swaps the icon state + expect(getByTestId("copy-ref-n2").getAttribute("title")).toBe("Copied") + + // The canonical side has its own button + await user.click(getByTestId("copy-ref-n1")) + expect(writeText).toHaveBeenCalledWith("n1") + }) + + it("does not expand or navigate when the copy button is clicked", async () => { + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + }) + + const review = makeReview({ + action_name: "merge_nodes", + action_payload: { from: ["n2"], to: "n1" }, + }) + const { getByText, getByTestId, queryByText } = render( + + ) + await user.click(getByText("Node Two")) + expect(queryByText(/Canonical \(survives\)/i)).toBeTruthy() + + // Clicking copy must not collapse the panel (stopPropagation) + await user.click(getByTestId("copy-ref-n1")) + expect(queryByText(/Canonical \(survives\)/i)).toBeTruthy() + }) +}) diff --git a/src/lib/graph-api.ts b/src/lib/graph-api.ts index 858129b..6320a6e 100644 --- a/src/lib/graph-api.ts +++ b/src/lib/graph-api.ts @@ -1277,8 +1277,11 @@ export async function getSubgraph( if (params.depth !== undefined) qs.set("depth", String(params.depth)) if (params.limit !== undefined) qs.set("limit", String(params.limit)) if (params.q) qs.set("q", params.q) - for (const et of params.edge_type ?? []) qs.append("edge_type", et) - for (const nt of params.node_type ?? []) qs.append("node_type", nt) + // The backend ast.literal_evals list params, so they go over the wire as a + // stringified list ('["MENTIONS"]'), not repeated keys — a JSON array of + // strings is a valid Python literal. + if (params.edge_type?.length) qs.set("edge_type", JSON.stringify(params.edge_type)) + if (params.node_type?.length) qs.set("node_type", JSON.stringify(params.node_type)) return api.get(`/v2/graph/subgraph?${qs}`, undefined, signal) } diff --git a/src/lib/merge-context.ts b/src/lib/merge-context.ts index 4bbf88e..35b7bab 100644 --- a/src/lib/merge-context.ts +++ b/src/lib/merge-context.ts @@ -1,12 +1,14 @@ import type { SubgraphNode, SubgraphResponse } from "@/lib/graph-api" /** - * Derives comparison context for merge-review subjects from their 1-hop - * subgraphs (GET /v2/graph/subgraph): how connected each node is, the - * sentences it was extracted from (incoming MENTIONS from text-bearing - * nodes), and how much the subjects' neighborhoods overlap — shared - * neighbors are the strongest same-entity signal; disjoint neighborhoods - * are a red flag for a proposed merge. + * Derives the source sentences a merge-review subject was extracted from, + * out of its 1-hop MENTIONS subgraph (GET /v2/graph/subgraph). Only the + * fragment around the subject's name is shown — a few words either side — + * not the whole property value. + * + * The name is matched word-by-word with crude prefix stemming, because the + * entity name is normalised by extraction and rarely appears verbatim + * ("Document search and extraction" ↔ "…search and extract document data…"). * * Pure functions — the fetching lives in MergeContextPanel. */ @@ -15,157 +17,191 @@ export interface MentionExcerpt { refId: string nodeType: string | null excerpt: string + // False when the subject's name never occurs in the source text — the + // excerpt is then just the head of the text, shown as context-only. + matched: boolean } -export interface SubjectGraphContext { - refId: string - degree: number - edgeCounts: Record - neighborIds: string[] - mentions: MentionExcerpt[] -} - -export interface SharedNeighbor { - refId: string - name: string - nodeType: string | null -} - -export interface MergeGraphContext { - subjects: SubjectGraphContext[] - sharedCount: number - sharedExamples: SharedNeighbor[] -} - -const EXCERPT_MAX = 220 -const MENTIONS_MAX = 3 -const SHARED_EXAMPLES_MAX = 4 - -/** Keys tried, in order, for a mention source's displayable text. */ -const TEXT_KEYS = ["text", "name", "episode_title", "title", "summary"] as const +// Window around the matched name: enough context either side to read the +// claim being made, not just the phrase. +const WORDS_BEFORE = 6 +const WORDS_AFTER = 10 +// Fallback head length (words) when the name doesn't occur in the text. +const FALLBACK_WORDS = 20 +const MENTIONS_MAX = 4 /** - * Normalise source text for display: strip the literal wrapper quotes tweet - * texts are stored with, collapse whitespace, truncate on a word boundary. + * Only properties that hold actual prose count as a source sentence — a + * Chapter/Episode *name* is a topic label, not the sentence the entity was + * extracted from. Verbatim sources first: a tweet's `text` and an episode's + * `transcript` (newer episodes store the full speaker-attributed transcript; + * older ones don't), then a chapter/clip's `description` (a real sentence + * naming the entities of that segment), then an episode's `summary`. + * Order doubles as ranking. */ -export function cleanExcerpt(raw: unknown): string | null { +const TEXT_KEYS = ["text", "transcript", "description", "summary"] as const + +const STOPWORDS = new Set([ + "the", "and", "for", "with", "that", "this", "from", "into", "over", + "a", "an", "of", "to", "in", "on", "is", "are", "was", "were", "its", +]) + +function normaliseText(raw: unknown): string | null { if (typeof raw !== "string") return null let text = raw.trim() + // Tweet texts are stored with literal wrapper quotes and literal escape + // sequences (backslash-n, backslash-uXXXX) — decode them for display. if (text.length >= 2 && text.startsWith('"') && text.endsWith('"')) { text = text.slice(1, -1) } + text = text + .replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => + String.fromCharCode(parseInt(hex, 16)) + ) + .replace(/\\[nrt]/g, " ") text = text.split(/\s+/).join(" ").trim() - if (!text) return null - if (text.length > EXCERPT_MAX) { - text = text.slice(0, EXCERPT_MAX).replace(/\s+\S*$/, "") + "…" - } - return text + return text || null } -function nodeExcerpt(node: SubgraphNode | undefined): string | null { - const props = node?.properties - if (!props) return null - for (const key of TEXT_KEYS) { - const cleaned = cleanExcerpt(props[key]) - if (cleaned) return cleaned - } - return null +function normaliseWord(word: string): string { + return word.toLowerCase().replace(/[^a-z0-9@#]/g, "") } -function nodeDisplayName(node: SubgraphNode | undefined, refId: string): string { - const props = node?.properties - for (const key of ["name", "title", "episode_title"]) { - const value = props?.[key] - if (typeof value === "string" && value.trim()) return value.trim() +/** + * Crude prefix stem: enough of the token that inflections still match + * ("extraction" → "extrac" matches "extract"/"extracting"), never shorter + * than 4 chars so tiny words stay exact. + */ +function stem(token: string): string { + if (token.length <= 4) return token + return token.slice(0, Math.max(4, token.length - 4)) +} + +/** Significant, stemmed words of the subject's name — also drives highlighting. */ +export function nameStems(name: string | null | undefined): string[] { + if (!name) return [] + const stems: string[] = [] + for (const word of name.split(/\s+/)) { + const cleaned = normaliseWord(word) + if (cleaned.length >= 3 && !STOPWORDS.has(cleaned)) { + const s = stem(cleaned) + if (!stems.includes(s)) stems.push(s) + } } - return refId.slice(0, 8) + return stems } -export function deriveSubjectContext( - refId: string, - subgraph: SubgraphResponse -): SubjectGraphContext { - const nodeById = new Map(subgraph.nodes.map((n) => [n.ref_id, n])) - const neighborIds = new Set() - const edgeCounts: Record = {} - const mentionSources: SubgraphNode[] = [] - const seenMentionSources = new Set() +function wordMatchesAnyStem(word: string, stems: string[]): boolean { + const cleaned = normaliseWord(word) + if (!cleaned) return false + return stems.some((s) => cleaned.startsWith(s)) +} - for (const edge of subgraph.edges) { - const isSource = edge.source === refId - const isTarget = edge.target === refId - // apoc.subgraphAll also returns edges among the neighbors themselves — - // only edges incident to the subject describe the subject. - if (!isSource && !isTarget) continue - const otherId = isSource ? edge.target : edge.source - if (!otherId || otherId === refId) continue - neighborIds.add(otherId) - edgeCounts[edge.edge_type] = (edgeCounts[edge.edge_type] ?? 0) + 1 - if (edge.edge_type === "MENTIONS" && isTarget && !seenMentionSources.has(otherId)) { - seenMentionSources.add(otherId) - const source = nodeById.get(otherId) - if (source) mentionSources.push(source) +/** + * The displayable fragment: the words around the first stretch of the text + * that matches the name (longest run of consecutive name-words wins, so a + * full-phrase occurrence beats a stray single word). Falls back to the head + * of the text when the name never occurs — flagged via `matched: false`. + */ +export function buildExcerpt( + raw: unknown, + name: string | null +): { excerpt: string; matched: boolean } | null { + const text = normaliseText(raw) + if (!text) return null + const words = text.split(" ") + const stems = nameStems(name) + + let matchStart = -1 + let matchLen = 0 + if (stems.length > 0) { + for (let i = 0; i < words.length; i++) { + if (!wordMatchesAnyStem(words[i], stems)) continue + let len = 1 + while ( + i + len < words.length && + wordMatchesAnyStem(words[i + len], stems) + ) { + len++ + } + if (len > matchLen) { + matchStart = i + matchLen = len + } + i += len } } - const mentions: MentionExcerpt[] = [] - for (const source of mentionSources) { - if (mentions.length >= MENTIONS_MAX) break - const excerpt = nodeExcerpt(source) - if (excerpt) { - mentions.push({ refId: source.ref_id, nodeType: source.node_type, excerpt }) - } + let start: number + let end: number + if (matchStart >= 0) { + start = Math.max(0, matchStart - WORDS_BEFORE) + end = Math.min(words.length, matchStart + matchLen + WORDS_AFTER) + } else { + start = 0 + end = Math.min(words.length, FALLBACK_WORDS) } + const prefix = start > 0 ? "… " : "" + const suffix = end < words.length ? " …" : "" return { - refId, - degree: neighborIds.size, - edgeCounts, - neighborIds: Array.from(neighborIds), - mentions, + excerpt: prefix + words.slice(start, end).join(" ") + suffix, + matched: matchStart >= 0, } } -export function deriveMergeContext( - subjectIds: string[], - subgraphs: SubgraphResponse[] -): MergeGraphContext { - const subjects = subjectIds.map((id, i) => - deriveSubjectContext(id, subgraphs[i] ?? { nodes: [], edges: [] }) - ) - - // Shared = neighbors present on EVERY subject; the subjects themselves are - // excluded (candidate and canonical are often each other's IS_ALIAS - // neighbor, which says nothing about a third common connection). - let shared: Set | null = null - for (const subject of subjects) { - const ids = new Set(subject.neighborIds) - if (shared === null) { - shared = ids - } else { - const previous: Set = shared - shared = new Set(Array.from(previous).filter((id) => ids.has(id))) - } - } - const sharedIds = Array.from(shared ?? new Set()).filter( - (id) => !subjectIds.includes(id) - ) +export function excerptFor(raw: unknown, name: string | null): string | null { + return buildExcerpt(raw, name)?.excerpt ?? null +} + +export function deriveMentions( + refId: string, + subgraph: SubgraphResponse, + name?: string | null +): MentionExcerpt[] { + const nodeById = new Map(subgraph.nodes.map((n) => [n.ref_id, n])) + const sources: SubgraphNode[] = [] + const seen = new Set() - const nodeById = new Map() - for (const graph of subgraphs) { - for (const node of graph?.nodes ?? []) nodeById.set(node.ref_id, node) + for (const edge of subgraph.edges) { + if (edge.edge_type !== "MENTIONS" || edge.target !== refId) continue + const sourceId = edge.source + if (!sourceId || sourceId === refId || seen.has(sourceId)) continue + seen.add(sourceId) + const source = nodeById.get(sourceId) + if (source) sources.push(source) } - const sharedExamples = sharedIds - .map((id) => { - const node = nodeById.get(id) - return { - refId: id, - name: nodeDisplayName(node, id), - nodeType: node?.node_type ?? null, - } - }) - .sort((a, b) => a.name.localeCompare(b.name)) - .slice(0, SHARED_EXAMPLES_MAX) - return { subjects, sharedCount: sharedIds.length, sharedExamples } + // Ranking: sources whose text actually contains the name beat ones where + // it never occurs (head-of-text fallbacks), then a `text` property beats a + // `summary` one — so the sentence showing the name is never crowded out. + const candidates: Array = [] + for (const source of sources) { + const props = source.properties + if (!props) continue + for (let rank = 0; rank < TEXT_KEYS.length; rank++) { + const built = buildExcerpt(props[TEXT_KEYS[rank]], name ?? null) + if (built) { + candidates.push({ + refId: source.ref_id, + nodeType: source.node_type, + excerpt: built.excerpt, + matched: built.matched, + rank, + }) + break + } + } + } + return candidates + .sort((a, b) => + a.matched !== b.matched ? (a.matched ? -1 : 1) : a.rank - b.rank + ) + .slice(0, MENTIONS_MAX) + .map(({ refId: rid, nodeType, excerpt, matched }) => ({ + refId: rid, + nodeType, + excerpt, + matched, + })) }