diff --git a/src/components/admin/merge-context-panel.tsx b/src/components/admin/merge-context-panel.tsx new file mode 100644 index 0000000..d4dd7a6 --- /dev/null +++ b/src/components/admin/merge-context-panel.tsx @@ -0,0 +1,164 @@ +"use client" + +import { useEffect, useState } 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" + +// Wide enough that the neighbor-overlap intersection is meaningful even for +// well-connected entities; the payload is one hop only. +const SUBGRAPH_LIMIT = 100 +// 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 { + 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) +} + +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 +} + +/** + * 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. + */ +export function MergeContextPanel({ review }: { review: Review }) { + const [context, setContext] = useState(null) + const [failed, setFailed] = useState(false) + + const subjectsKey = review.subject_ids.slice(0, MAX_SUBJECTS).join(",") + + useEffect(() => { + const subjectIds = subjectsKey ? subjectsKey.split(",") : [] + if (subjectIds.length === 0) return + let cancelled = false + const ctrl = new AbortController() + ;(async () => { + try { + const graphs = await Promise.all( + subjectIds.map((id) => + getSubgraph( + { start_node: id, depth: 1, limit: SUBGRAPH_LIMIT }, + ctrl.signal + ) + ) + ) + if (cancelled) return + setContext(deriveMergeContext(subjectIds, graphs)) + } catch { + if (!cancelled) setFailed(true) + } + })() + return () => { + cancelled = true + ctrl.abort() + } + }, [subjectsKey]) + + if (failed) { + return ( +

+ Graph context unavailable +

+ ) + } + + return ( +
+
+ Graph Context +
+ + {context === 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" + > + {context.sharedCount > 0 ? ( + <> + {context.sharedCount} shared connection + {context.sharedCount === 1 ? "" : "s"} + {context.sharedExamples.length > 0 && ( + + {" "} + — {context.sharedExamples.map((n) => n.name).join(", ")} + + )} + + ) : ( + "No shared connections — the neighborhoods are disjoint" + )} +

+ )} + + )} +
+ ) +} diff --git a/src/components/admin/review-row.tsx b/src/components/admin/review-row.tsx index 155e131..d9d6a0d 100644 --- a/src/components/admin/review-row.tsx +++ b/src/components/admin/review-row.tsx @@ -13,6 +13,7 @@ import type { } from "@/lib/graph-api" import { approveReview, dismissReview, triggerMergeWorkflow } from "@/lib/graph-api" import { SchemaPromotionDialog } from "@/components/admin/schema-promotion-dialog" +import { MergeContextPanel } from "@/components/admin/merge-context-panel" import { useStakworkRunStatus } from "@/lib/hooks/use-stakwork-run-status" import { cn, displayNodeType } from "@/lib/utils" import { @@ -1487,6 +1488,11 @@ export function ReviewRow({
)} + {/* Graph evidence for merge decisions — fetched lazily on expand */} + {review.action_name === "merge_nodes" && ( + + )} + {(() => { const conf = extractConfidence(review.rationale) const text = conf?.cleaned ?? review.rationale diff --git a/src/lib/__tests__/merge-context.test.ts b/src/lib/__tests__/merge-context.test.ts new file mode 100644 index 0000000..3d637a8 --- /dev/null +++ b/src/lib/__tests__/merge-context.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect } from "vitest" +import { + cleanExcerpt, + deriveMergeContext, + deriveSubjectContext, +} from "@/lib/merge-context" +import type { SubgraphResponse } from "@/lib/graph-api" + +function graph(partial: Partial): SubgraphResponse { + return { nodes: [], edges: [], ...partial } +} + +describe("cleanExcerpt", () => { + it("strips stored wrapper quotes and collapses whitespace", () => { + expect(cleanExcerpt('"A US ban was\n\nsupposed"')).toBe( + "A US ban was supposed" + ) + }) + + it("returns null for non-strings and empty strings", () => { + expect(cleanExcerpt(undefined)).toBeNull() + expect(cleanExcerpt(42)).toBeNull() + expect(cleanExcerpt('""')).toBeNull() + expect(cleanExcerpt(" ")).toBeNull() + }) + + 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 + }) +}) + +describe("deriveSubjectContext", () => { + const subject = "org-1" + const subgraph = graph({ + nodes: [ + { ref_id: subject, node_type: "Organization", properties: { name: "United States" } }, + { + ref_id: "tweet-1", + node_type: "Tweet", + properties: { text: '"a sentence mentioning it"' }, + }, + { ref_id: "chapter-1", node_type: "Chapter", properties: { name: "AI race overview" } }, + { ref_id: "alias-1", node_type: "Organization", properties: { name: "USA" } }, + { 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: 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" }, + ], + }) + + 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 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" }, + ]) + }) +}) + +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("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("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) + }) +}) diff --git a/src/lib/__tests__/reviews.test.tsx b/src/lib/__tests__/reviews.test.tsx index 89afef1..a81bd88 100644 --- a/src/lib/__tests__/reviews.test.tsx +++ b/src/lib/__tests__/reviews.test.tsx @@ -13,6 +13,7 @@ const { mockTriggerMergeWorkflow, mockGetLatestStakworkRun, mockGetReviewStats, + mockGetSubgraph, } = vi.hoisted(() => ({ mockApproveReview: vi.fn(), mockDismissReview: vi.fn(), @@ -21,6 +22,9 @@ const { mockTriggerMergeWorkflow: vi.fn(), mockGetLatestStakworkRun: vi.fn(), mockGetReviewStats: vi.fn(), + // Default: empty neighborhood so ReviewRow tests that expand merge rows + // render the context panel quietly without configuring it. + mockGetSubgraph: vi.fn().mockResolvedValue({ nodes: [], edges: [] }), })) vi.mock("@/lib/graph-api", async (importOriginal) => { @@ -34,6 +38,7 @@ vi.mock("@/lib/graph-api", async (importOriginal) => { triggerMergeWorkflow: (...args: unknown[]) => mockTriggerMergeWorkflow(...args), getLatestStakworkRun: (...args: unknown[]) => mockGetLatestStakworkRun(...args), getReviewStats: (...args: unknown[]) => mockGetReviewStats(...args), + getSubgraph: (...args: unknown[]) => mockGetSubgraph(...args), } }) @@ -2461,3 +2466,103 @@ describe("ReviewsPage decided-view controls", () => { expect(decidedSortCalls).toHaveLength(0) }) }) + +// ── 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, + }) + } + return { nodes, edges } + } + + beforeEach(() => { + mockGetSubgraph.mockReset() + }) + + it("fetches both subjects' subgraphs and renders excerpts plus overlap", async () => { + mockGetSubgraph.mockImplementation((params: { start_node: string }) => + Promise.resolve( + graphFor(params.start_node, { + shared: true, + tweetText: params.start_node === subjectA ? '"extracted sentence"' : undefined, + }) + ) + ) + const { MergeContextPanel } = await import( + "@/components/admin/merge-context-panel" + ) + render() + + await waitFor(() => { + expect(screen.getByTestId("merge-context-overlap")).toBeTruthy() + }) + expect(mockGetSubgraph).toHaveBeenCalledTimes(2) + expect(mockGetSubgraph).toHaveBeenCalledWith( + expect.objectContaining({ start_node: subjectA, depth: 1 }), + 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" + ) + expect(screen.getByTestId("merge-context-overlap").textContent).toContain( + "Shared Episode" + ) + }) + + 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: [] } + ) + ) + const { MergeContextPanel } = await import( + "@/components/admin/merge-context-panel" + ) + render() + + await waitFor(() => { + expect( + screen.getByText("No shared connections — the neighborhoods are disjoint") + ).toBeTruthy() + }) + }) + + it("degrades quietly when the subgraph fetch fails", async () => { + mockGetSubgraph.mockRejectedValue(new Error("boom")) + const { MergeContextPanel } = await import( + "@/components/admin/merge-context-panel" + ) + render() + + await waitFor(() => { + expect(screen.getByText("Graph context unavailable")).toBeTruthy() + }) + }) +}) diff --git a/src/lib/graph-api.ts b/src/lib/graph-api.ts index 842a307..858129b 100644 --- a/src/lib/graph-api.ts +++ b/src/lib/graph-api.ts @@ -1231,6 +1231,57 @@ export async function getReviewStats( return api.get(`/v2/reviews/stats?${qs}`, undefined, signal) } +// ── Subgraph (GET /v2/graph/subgraph) ──────────────────────────────────────── + +export interface SubgraphNode { + ref_id: string + node_type: string | null + properties: Record | null +} + +export interface SubgraphEdge { + ref_id: string | number + edge_type: string + source: string + target: string + properties?: Record | null +} + +export interface SubgraphResponse { + nodes: SubgraphNode[] + edges: SubgraphEdge[] +} + +/** + * Neighborhood of a node — namespace-aware v2 of the graph traversal. + * depth defaults to 1 server-side; `q` searches within the neighborhood. + * Used by the merge-review context panel to find the sentences an entity + * was extracted from (incoming MENTIONS edges to text-bearing nodes). + */ +export async function getSubgraph( + params: { + start_node: string + depth?: number + limit?: number + q?: string + edge_type?: string[] + node_type?: string[] + }, + signal?: AbortSignal +): Promise { + if (isMocksEnabled()) { + return { nodes: [], edges: [] } + } + const qs = new URLSearchParams() + qs.set("start_node", params.start_node) + 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) + return api.get(`/v2/graph/subgraph?${qs}`, undefined, signal) +} + /** * Fetch the property table proposed for a scratchpad_entry review's new type. * diff --git a/src/lib/merge-context.ts b/src/lib/merge-context.ts new file mode 100644 index 0000000..4bbf88e --- /dev/null +++ b/src/lib/merge-context.ts @@ -0,0 +1,171 @@ +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. + * + * Pure functions — the fetching lives in MergeContextPanel. + */ + +export interface MentionExcerpt { + refId: string + nodeType: string | null + excerpt: string +} + +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 + +/** + * Normalise source text for display: strip the literal wrapper quotes tweet + * texts are stored with, collapse whitespace, truncate on a word boundary. + */ +export function cleanExcerpt(raw: unknown): string | null { + if (typeof raw !== "string") return null + let text = raw.trim() + if (text.length >= 2 && text.startsWith('"') && text.endsWith('"')) { + text = text.slice(1, -1) + } + 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 +} + +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 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() + } + return refId.slice(0, 8) +} + +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() + + 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) + } + } + + 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 }) + } + } + + return { + refId, + degree: neighborIds.size, + edgeCounts, + neighborIds: Array.from(neighborIds), + mentions, + } +} + +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) + ) + + const nodeById = new Map() + for (const graph of subgraphs) { + for (const node of graph?.nodes ?? []) nodeById.set(node.ref_id, node) + } + 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 } +}