Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 164 additions & 0 deletions src/components/admin/merge-context-panel.tsx
Original file line number Diff line number Diff line change
@@ -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<MergeGraphContext | null>(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 (
<p className="mt-3 border-t border-border/30 pt-2 text-[10px] text-muted-foreground">
Graph context unavailable
</p>
)
}

return (
<div className="mt-3 border-t border-border/30 pt-2" data-testid="merge-context">
<div className="mb-1.5 font-mono text-[9px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Graph Context
</div>

{context === null ? (
<div className="grid gap-2 sm:grid-cols-2">
{Array.from({ length: 2 }).map((_, i) => (
<div key={i} className="h-14 animate-pulse rounded-md bg-muted/20" />
))}
</div>
) : (
<>
<div className="grid gap-2 sm:grid-cols-2">
{context.subjects.map((subject) => (
<div
key={subject.refId}
className="rounded-md border border-border/40 bg-background/40 p-2"
data-testid={`merge-context-subject-${subject.refId}`}
>
<div className="mb-1 flex items-baseline justify-between gap-2">
<span className="truncate text-[11px] font-medium">
{subjectName(review, subject.refId)}
</span>
<span className="shrink-0 text-[10px] text-muted-foreground">
{edgeCountsLine(subject)}
</span>
</div>
{subject.mentions.length > 0 ? (
<ul className="flex flex-col gap-1">
{subject.mentions.map((mention) => (
<li
key={mention.refId}
className="text-[10px] leading-relaxed text-foreground/70"
>
<span className="mr-1 rounded border border-border/50 bg-muted/30 px-1 py-px font-mono text-[8px] uppercase text-muted-foreground">
{mention.nodeType ?? "?"}
</span>
“{mention.excerpt}”
</li>
))}
</ul>
) : (
<p className="text-[10px] italic text-muted-foreground">
No source text found in the immediate neighborhood
</p>
)}
</div>
))}
</div>

{context.subjects.length > 1 && (
<p
className={
context.sharedCount > 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 && (
<span className="text-muted-foreground">
{" "}
— {context.sharedExamples.map((n) => n.name).join(", ")}
</span>
)}
</>
) : (
"No shared connections — the neighborhoods are disjoint"
)}
</p>
)}
</>
)}
</div>
)
}
6 changes: 6 additions & 0 deletions src/components/admin/review-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -1487,6 +1488,11 @@ export function ReviewRow({
</div>
)}

{/* Graph evidence for merge decisions — fetched lazily on expand */}
{review.action_name === "merge_nodes" && (
<MergeContextPanel review={review} />
)}

{(() => {
const conf = extractConfidence(review.rationale)
const text = conf?.cleaned ?? review.rationale
Expand Down
124 changes: 124 additions & 0 deletions src/lib/__tests__/merge-context.test.ts
Original file line number Diff line number Diff line change
@@ -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>): 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)
})
})
Loading
Loading