From cf01d847232b193ab6333b743d08ca3cd7087a6b Mon Sep 17 00:00:00 2001 From: triplec <202340740213@stu.hunau.edu.cn> Date: Fri, 17 Jul 2026 04:46:34 -0700 Subject: [PATCH] feat(dashboard): complete interactive evidence link graph --- dashboard/src/lib/ctxClient.ts | 3 + dashboard/src/lib/i18n.tsx | 18 ++ dashboard/src/lib/types.ts | 33 +++ dashboard/src/panels/ProvenancePanel.tsx | 26 ++- .../src/panels/components/EvidenceGraph.tsx | 197 +++++++++++++----- dashboard/vite.config.ts | 2 +- src/contextseek/client/contextseek.py | 38 ++++ src/contextseek/domain/__init__.py | 10 + src/contextseek/domain/link_graph.py | 180 ++++++++++++++++ src/contextseek/http/server.py | 22 ++ tests/unit_tests/domain/test_link_graph.py | 92 ++++++++ tests/unit_tests/test_http_server.py | 49 +++++ 12 files changed, 608 insertions(+), 62 deletions(-) create mode 100644 src/contextseek/domain/link_graph.py create mode 100644 tests/unit_tests/domain/test_link_graph.py diff --git a/dashboard/src/lib/ctxClient.ts b/dashboard/src/lib/ctxClient.ts index e1c9295..c973b56 100644 --- a/dashboard/src/lib/ctxClient.ts +++ b/dashboard/src/lib/ctxClient.ts @@ -23,6 +23,8 @@ import type { EnvVaultUpsertRequest, EvidenceChain, EvidenceChainRequest, + LinkGraph, + LinkGraphRequest, ExpandRequest, ExpandResponse, FeedbackRequest, @@ -152,6 +154,7 @@ export const ctx = { dream: (req: DreamRequest) => post("/dream", req), upstream: (req: UpstreamRequest) => post("/upstream", req), evidenceChain: (req: EvidenceChainRequest) => post("/evidence_chain", req), + linkGraph: (req: LinkGraphRequest) => post("/link_graph", req), items: (req: ItemsRequest) => post("/items", req), overview: (scope: string) => get("/overview", { scope }), globalOverview: (scope?: string) => diff --git a/dashboard/src/lib/i18n.tsx b/dashboard/src/lib/i18n.tsx index 55b9ca5..d36d384 100644 --- a/dashboard/src/lib/i18n.tsx +++ b/dashboard/src/lib/i18n.tsx @@ -157,6 +157,12 @@ const zh: Dict = { "evidence.criticalPath": "关键路径", "evidence.conflicts": "冲突", "evidence.noNodes": "该条目没有证据链节点", + "evidence.graphTruncated": "图谱较大,仅显示前 {count} 个节点。可降低深度以查看更聚焦的关系。", + "evidence.graphSummary": "{nodes} 个节点 · {edges} 条关系", + "evidence.selectNode": "点击节点查看内容摘要", + "evidence.nodeDetails": "节点详情", + "evidence.confidence": "置信度", + "evidence.missingNode": "目标条目缺失", // overview "overview.kpi.total": "总条目", @@ -520,6 +526,12 @@ const en: Dict = { "evidence.criticalPath": "Critical path", "evidence.conflicts": "Conflicts", "evidence.noNodes": "This item has no evidence-chain nodes", + "evidence.graphTruncated": "Large graph: showing the first {count} nodes. Reduce depth for a more focused view.", + "evidence.graphSummary": "{nodes} nodes · {edges} relations", + "evidence.selectNode": "Select a node to inspect its content preview", + "evidence.nodeDetails": "Node details", + "evidence.confidence": "Confidence", + "evidence.missingNode": "Target item is missing", "overview.kpi.total": "Total items", "overview.kpi.health": "Health score", @@ -878,6 +890,12 @@ const ja: Dict = { "evidence.criticalPath": "クリティカルパス", "evidence.conflicts": "競合", "evidence.noNodes": "この項目には証拠チェーンノードがありません", + "evidence.graphTruncated": "大きなグラフのため、先頭 {count} ノードのみ表示しています。深さを下げると関係を絞り込めます。", + "evidence.graphSummary": "{nodes} ノード · {edges} 関係", + "evidence.selectNode": "ノードを選択して内容の概要を確認", + "evidence.nodeDetails": "ノード詳細", + "evidence.confidence": "信頼度", + "evidence.missingNode": "対象項目が見つかりません", "overview.kpi.total": "総項目数", "overview.kpi.health": "ヘルススコア", diff --git a/dashboard/src/lib/types.ts b/dashboard/src/lib/types.ts index 0aee9da..b5376a9 100644 --- a/dashboard/src/lib/types.ts +++ b/dashboard/src/lib/types.ts @@ -291,6 +291,32 @@ export interface EvidenceChain { needs_reverification: boolean; } +export interface LinkGraphNode { + item_id: string; + stage: Stage; + confidence: number; + depth: number; + is_root: boolean; + is_missing: boolean; + content_preview: string; +} + +export interface LinkGraphEdge { + source_id: string; + target_id: string; + relation: LinkType; + strength: number; +} + +export interface LinkGraph { + root_item_id: string; + nodes: LinkGraphNode[]; + edges: LinkGraphEdge[]; + max_depth: number; + max_nodes: number; + truncated: boolean; +} + export interface GlobalOverview { total_items: number; health_score: number; @@ -450,6 +476,13 @@ export interface EvidenceChainRequest { max_depth?: number; } +export interface LinkGraphRequest { + scope: string; + item_id: string; + max_depth?: number; + max_nodes?: number; +} + export interface ItemsRequest { scope: string; stage?: Stage; diff --git a/dashboard/src/panels/ProvenancePanel.tsx b/dashboard/src/panels/ProvenancePanel.tsx index 8a171ed..624ce22 100644 --- a/dashboard/src/panels/ProvenancePanel.tsx +++ b/dashboard/src/panels/ProvenancePanel.tsx @@ -13,7 +13,7 @@ import { useI18n } from "@/lib/i18n"; import { useScope } from "@/context/ScopeContext"; import { useNav } from "@/context/NavContext"; import { useAsyncFn } from "@/lib/utils"; -import type { EvidenceChain, UpstreamResponse } from "@/lib/types"; +import type { EvidenceChain, LinkGraph, UpstreamResponse } from "@/lib/types"; import { EvidenceDetails } from "./components/EvidenceDetails"; import { EvidenceGraph } from "./components/EvidenceGraph"; import { ItemCard } from "./components/ItemCard"; @@ -26,12 +26,14 @@ export function ProvenancePanel({ initialItemId = "" }: { initialItemId?: string const [maxDepth, setMaxDepth] = useState(10); const chain = useAsyncFn(ctx.evidenceChain); + const graph = useAsyncFn(ctx.linkGraph); const upstream = useAsyncFn(ctx.upstream); const inspect = () => { const id = itemId.trim(); if (!id) return; chain.run({ scope, item_id: id, max_depth: maxDepth }); + graph.run({ scope, item_id: id, max_depth: maxDepth, max_nodes: 100 }); upstream.run({ scope, item_id: id }); }; @@ -40,6 +42,7 @@ export function ProvenancePanel({ initialItemId = "" }: { initialItemId?: string if (initialItemId) { setItemId(initialItemId); chain.run({ scope, item_id: initialItemId, max_depth: maxDepth }); + graph.run({ scope, item_id: initialItemId, max_depth: maxDepth, max_nodes: 100 }); upstream.run({ scope, item_id: initialItemId }); } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -74,19 +77,30 @@ export function ProvenancePanel({ initialItemId = "" }: { initialItemId?: string id="pv-depth" type="number" min={1} + max={10} value={maxDepth} - onChange={(e) => setMaxDepth(Number(e.target.value) || 1)} + onChange={(e) => + setMaxDepth(Math.min(10, Math.max(1, Number(e.target.value) || 1))) + } className="w-24" /> - + {t("provenance.analyze")} - - {chain.data && ( + + {chain.data && graph.data && ( {t("provenance.tabGraph")} @@ -97,7 +111,7 @@ export function ProvenancePanel({ initialItemId = "" }: { initialItemId?: string - + diff --git a/dashboard/src/panels/components/EvidenceGraph.tsx b/dashboard/src/panels/components/EvidenceGraph.tsx index fab1054..0deb9c7 100644 --- a/dashboard/src/panels/components/EvidenceGraph.tsx +++ b/dashboard/src/panels/components/EvidenceGraph.tsx @@ -2,15 +2,17 @@ import dagre from "@dagrejs/dagre"; import { Background, Controls, + MarkerType, + MiniMap, type Edge, type Node, ReactFlow, } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { useI18n } from "@/lib/i18n"; -import type { EvidenceChain, Stage } from "@/lib/types"; +import type { LinkGraph, LinkGraphNode, LinkType, Stage } from "@/lib/types"; const STAGE_COLOR: Record = { raw: "#cbd5e1", @@ -19,41 +21,52 @@ const STAGE_COLOR: Record = { skill: "#c4b5fd", }; -const NODE_W = 180; -const NODE_H = 56; +const RELATION_COLOR: Record = { + derived_from: "#2563eb", + supported_by: "#16a34a", + refuted_by: "#e11d48", + supersedes: "#d97706", + merged_from: "#7c3aed", + distilled_into: "#4f46e5", + related_to: "#64748b", + requires: "#0891b2", + synthesized_from: "#db2777", +}; + +const NODE_W = 190; +const NODE_H = 66; -function layout(chain: EvidenceChain): { nodes: Node[]; edges: Edge[] } { - const g = new dagre.graphlib.Graph(); - g.setGraph({ rankdir: "LR", nodesep: 30, ranksep: 70 }); - g.setDefaultEdgeLabel(() => ({})); +function layout(graph: LinkGraph): { nodes: Node[]; edges: Edge[] } { + const dag = new dagre.graphlib.Graph(); + dag.setGraph({ rankdir: "LR", nodesep: 36, ranksep: 100 }); + dag.setDefaultEdgeLabel(() => ({})); - for (const n of chain.nodes) { - g.setNode(n.item_id, { width: NODE_W, height: NODE_H }); + for (const node of graph.nodes) { + dag.setNode(node.item_id, { width: NODE_W, height: NODE_H }); } - for (const e of chain.edges) { - if (g.hasNode(e.source_id) && g.hasNode(e.target_id)) { - g.setEdge(e.source_id, e.target_id); + for (const edge of graph.edges) { + if (dag.hasNode(edge.source_id) && dag.hasNode(edge.target_id)) { + dag.setEdge(edge.source_id, edge.target_id); } } - dagre.layout(g); + dagre.layout(dag); - const criticalSet = new Set(chain.critical_path); - const conflictTargets = new Set(chain.conflicts.map((c) => c.item_id)); - - const nodes: Node[] = chain.nodes.map((n) => { - const pos = g.node(n.item_id); - const onCritical = criticalSet.has(n.item_id); + const nodes: Node[] = graph.nodes.map((node) => { + const position = dag.node(node.item_id); return { - id: n.item_id, - position: { x: (pos?.x ?? 0) - NODE_W / 2, y: (pos?.y ?? 0) - NODE_H / 2 }, + id: node.item_id, + position: { + x: (position?.x ?? 0) - NODE_W / 2, + y: (position?.y ?? 0) - NODE_H / 2, + }, data: { label: ( -
-
{n.item_id}
-
{n.stage}
+
+
{node.item_id}
+
{node.stage}
- conf {n.effective_confidence.toFixed(2)} - {n.is_missing && " · missing"} + confidence {node.confidence.toFixed(2)} + {node.is_missing && " · missing"}
), @@ -62,51 +75,125 @@ function layout(chain: EvidenceChain): { nodes: Node[]; edges: Edge[] } { width: NODE_W, height: NODE_H, borderRadius: 8, - padding: 6, - fontSize: 12, - background: n.is_missing ? "#fff" : STAGE_COLOR[n.stage], - border: n.is_root - ? "2px solid #1e293b" - : onCritical - ? "2px solid #f59e0b" - : n.is_missing - ? "2px dashed #94a3b8" - : "1px solid #94a3b8", - opacity: n.is_missing ? 0.6 : 1, + padding: 8, + background: node.is_missing ? "#fff" : STAGE_COLOR[node.stage], + border: node.is_root + ? "3px solid #0f172a" + : node.is_missing + ? "2px dashed #94a3b8" + : "1px solid #94a3b8", + opacity: node.is_missing ? 0.65 : 1, }, }; }); - const edges: Edge[] = chain.edges.map((e, i) => { - const isConflict = e.relation === "refuted_by" || conflictTargets.has(e.target_id); + const edges: Edge[] = graph.edges.map((edge, index) => { + const color = RELATION_COLOR[edge.relation]; return { - id: `${e.source_id}->${e.target_id}-${i}`, - source: e.source_id, - target: e.target_id, - label: e.relation, - animated: isConflict, - style: { stroke: isConflict ? "#e11d48" : "#64748b" }, - labelStyle: { fontSize: 9, fill: "#475569" }, + id: `${edge.source_id}->${edge.target_id}-${edge.relation}-${index}`, + source: edge.source_id, + target: edge.target_id, + label: `${edge.relation} · ${edge.strength.toFixed(2)}`, + animated: edge.relation === "refuted_by" || edge.relation === "supersedes", + markerEnd: { type: MarkerType.ArrowClosed, color }, + style: { stroke: color, strokeWidth: 2 }, + labelStyle: { fontSize: 10, fontWeight: 600, fill: color }, + labelBgStyle: { fill: "var(--background)", fillOpacity: 0.9 }, + labelBgPadding: [5, 3], + labelBgBorderRadius: 4, }; }); return { nodes, edges }; } -export function EvidenceGraph({ chain }: { chain: EvidenceChain }) { +export function EvidenceGraph({ graph }: { graph: LinkGraph }) { const { t } = useI18n(); - const { nodes, edges } = useMemo(() => layout(chain), [chain]); + const [selectedId, setSelectedId] = useState(null); + const { nodes, edges } = useMemo(() => layout(graph), [graph]); + const selected = graph.nodes.find((node) => node.item_id === selectedId); - if (chain.nodes.length === 0) { + if (graph.nodes.length === 0) { return
{t("evidence.noNodes")}
; } return ( -
- - - - +
+
+ + {t("evidence.graphSummary", { + nodes: graph.nodes.length, + edges: graph.edges.length, + })} + + {t("evidence.selectNode")} +
+ + {graph.truncated && ( +
+ {t("evidence.graphTruncated", { count: graph.max_nodes })} +
+ )} + +
+ {(Object.entries(RELATION_COLOR) as [LinkType, string][]).map(([relation, color]) => ( + + + {relation} + + ))} +
+ +
+ setSelectedId(node.id)} + > + + + {graph.nodes.length > 12 && ( + { + const source = graph.nodes.find((item) => item.item_id === node.id); + return source?.is_missing ? "#fff" : STAGE_COLOR[source?.stage ?? "raw"]; + }} + /> + )} + +
+ + {selected && } +
+ ); +} + +function NodeDetails({ node }: { node: LinkGraphNode }) { + const { t } = useI18n(); + return ( +
+
+ {t("evidence.nodeDetails")} + {node.item_id} +
+
+ {node.stage} + + {t("evidence.confidence")} {node.confidence.toFixed(2)} + + depth {node.depth} +
+

+ {node.is_missing ? t("evidence.missingNode") : node.content_preview || "—"} +

); } diff --git a/dashboard/vite.config.ts b/dashboard/vite.config.ts index 7180029..8c9bc60 100644 --- a/dashboard/vite.config.ts +++ b/dashboard/vite.config.ts @@ -8,7 +8,7 @@ import { defineConfig } from "vite"; // that both `vite dev` and `vite preview` work without setting VITE_CTX_BASE. const API_SEGMENTS = "add|retrieve|expand|forget|delete|compact|dream|feedback|upstream|" + - "evidence_chain|chain_confidence|skill_tools|skill_context|skill_md|items|" + + "evidence_chain|link_graph|chain_confidence|skill_tools|skill_context|skill_md|items|" + "overview|global_overview|scopes|config|metrics|plugs|seed|health|install|restart|__desktop"; const API_PROXY_PATTERN = `^/(${API_SEGMENTS})(/|$|\\?)`; const API_PROXY_TARGET = { target: "http://127.0.0.1:8000", changeOrigin: true }; diff --git a/src/contextseek/client/contextseek.py b/src/contextseek/client/contextseek.py index 774a988..6ccf546 100644 --- a/src/contextseek/client/contextseek.py +++ b/src/contextseek/client/contextseek.py @@ -1059,6 +1059,44 @@ def _resolver(item_id: str) -> ContextItem | None: return result + def link_graph( + self, + ref: str, + *, + scope: str, + max_depth: int = 3, + max_nodes: int = 100, + ): + """Return a bounded graph of every link type around an item. + + Incoming and outgoing relations are traversed so the selected item can + reveal both its sources and items derived from it. Edge direction is + preserved in the result. + """ + from contextseek.domain.link_graph import build_link_graph + + payload = self.adapter.read(ref) + if payload is None: + raise ValueError(f"item not found: {ref}") + root_item = deserialize_context_item(payload) + result = build_link_graph( + root_item, + self._load_scope_items(scope=scope), + max_depth=max_depth, + max_nodes=max_nodes, + ) + self._emit_audit( + action="link_graph", + scope=scope, + detail={ + "ref": ref, + "nodes": len(result.nodes), + "edges": len(result.edges), + "truncated": result.truncated, + }, + ) + return result + def chain_confidence(self, ref: str, *, scope: str) -> float: """Quick propagated confidence lookup for an item. diff --git a/src/contextseek/domain/__init__.py b/src/contextseek/domain/__init__.py index b7dc5cc..aa7fd67 100644 --- a/src/contextseek/domain/__init__.py +++ b/src/contextseek/domain/__init__.py @@ -28,6 +28,12 @@ propagate_invalidation, ) from contextseek.domain.links import Link, LinkType +from contextseek.domain.link_graph import ( + LinkGraph, + LinkGraphEdge, + LinkGraphNode, + build_link_graph, +) from contextseek.domain.provenance import Provenance, SourceType from contextseek.domain.results import ( CompactReport, @@ -62,6 +68,9 @@ "EvidenceChain", "InvalidationResult", "Link", + "LinkGraph", + "LinkGraphEdge", + "LinkGraphNode", "LinkType", "Provenance", "ResponseMeta", @@ -76,6 +85,7 @@ "WriteConflict", "default_tool_specs", "build_provenance", + "build_link_graph", "compute_chain_confidence", "compute_evidence_chain", "deserialize_context_item", diff --git a/src/contextseek/domain/link_graph.py b/src/contextseek/domain/link_graph.py new file mode 100644 index 0000000..594d0d6 --- /dev/null +++ b/src/contextseek/domain/link_graph.py @@ -0,0 +1,180 @@ +"""Bounded link-graph projection for dashboard and API consumers.""" + +from __future__ import annotations + +from collections import defaultdict, deque +from dataclasses import dataclass +from typing import Any + +from contextseek.domain.context_item import ContextItem +from contextseek.domain.links import LinkType +from contextseek.domain.stages import Stage + + +@dataclass(frozen=True) +class LinkGraphNode: + """One visible item (or unresolved target) in a link graph.""" + + item_id: str + stage: Stage + confidence: float + depth: int + is_root: bool + is_missing: bool + content_preview: str + + +@dataclass(frozen=True) +class LinkGraphEdge: + """One directed ContextItem link in a link graph.""" + + source_id: str + target_id: str + relation: LinkType + strength: float + + +@dataclass(frozen=True) +class LinkGraph: + """A bounded, connected projection centered on one item.""" + + root_item_id: str + nodes: list[LinkGraphNode] + edges: list[LinkGraphEdge] + max_depth: int + max_nodes: int + truncated: bool + + def to_dict(self) -> dict[str, Any]: + return { + "root_item_id": self.root_item_id, + "nodes": [ + { + "item_id": node.item_id, + "stage": node.stage.value, + "confidence": node.confidence, + "depth": node.depth, + "is_root": node.is_root, + "is_missing": node.is_missing, + "content_preview": node.content_preview, + } + for node in self.nodes + ], + "edges": [ + { + "source_id": edge.source_id, + "target_id": edge.target_id, + "relation": edge.relation.value, + "strength": edge.strength, + } + for edge in self.edges + ], + "max_depth": self.max_depth, + "max_nodes": self.max_nodes, + "truncated": self.truncated, + } + + +def build_link_graph( + root: ContextItem, + items: list[ContextItem], + *, + max_depth: int = 3, + max_nodes: int = 100, +) -> LinkGraph: + """Build a bounded graph that follows both incoming and outgoing links. + + Traversal is undirected so selecting either end of a relation reveals its + local neighborhood. Returned edges retain their original direction. + """ + if max_depth < 0: + raise ValueError("max_depth must be non-negative") + if max_nodes < 1: + raise ValueError("max_nodes must be at least 1") + + item_by_id = {item.id: item for item in items} + item_by_id[root.id] = root + all_edges: list[LinkGraphEdge] = [] + neighbors: dict[str, set[str]] = defaultdict(set) + for item in item_by_id.values(): + for link in item.links: + edge = LinkGraphEdge( + source_id=item.id, + target_id=link.target_id, + relation=link.relation, + strength=max(0.0, min(1.0, float(link.strength))), + ) + all_edges.append(edge) + neighbors[item.id].add(link.target_id) + neighbors[link.target_id].add(item.id) + + depth_by_id = {root.id: 0} + queue: deque[str] = deque([root.id]) + truncated = False + while queue: + item_id = queue.popleft() + depth = depth_by_id[item_id] + if depth >= max_depth: + continue + for neighbor_id in sorted(neighbors[item_id]): + if neighbor_id in depth_by_id: + continue + if len(depth_by_id) >= max_nodes: + truncated = True + continue + depth_by_id[neighbor_id] = depth + 1 + queue.append(neighbor_id) + + visible_ids = set(depth_by_id) + graph_nodes: list[LinkGraphNode] = [] + for item_id, depth in sorted( + depth_by_id.items(), key=lambda entry: (entry[1], entry[0]) + ): + item = item_by_id.get(item_id) + if item is None: + graph_nodes.append( + LinkGraphNode( + item_id=item_id, + stage=Stage.raw, + confidence=0.0, + depth=depth, + is_root=False, + is_missing=True, + content_preview="", + ) + ) + continue + confidence = ( + item.effective_confidence + if item.effective_confidence is not None + else item.provenance.confidence + ) + preview = " ".join(item.content_text.split())[:160] + graph_nodes.append( + LinkGraphNode( + item_id=item.id, + stage=item.stage, + confidence=max(0.0, min(1.0, float(confidence))), + depth=depth, + is_root=item.id == root.id, + is_missing=False, + content_preview=preview, + ) + ) + + graph_edges = [ + edge + for edge in all_edges + if edge.source_id in visible_ids and edge.target_id in visible_ids + ] + graph_edges.sort( + key=lambda edge: (edge.source_id, edge.target_id, edge.relation.value) + ) + return LinkGraph( + root_item_id=root.id, + nodes=graph_nodes, + edges=graph_edges, + max_depth=max_depth, + max_nodes=max_nodes, + truncated=truncated, + ) diff --git a/src/contextseek/http/server.py b/src/contextseek/http/server.py index 25e1a9c..ecd4acc 100644 --- a/src/contextseek/http/server.py +++ b/src/contextseek/http/server.py @@ -99,6 +99,13 @@ class EvidenceChainRequest(BaseModel): max_depth: int = 10 +class LinkGraphRequest(BaseModel): + scope: str + item_id: str + max_depth: int = Field(default=3, ge=0, le=10) + max_nodes: int = Field(default=100, ge=1, le=500) + + class ChainConfidenceRequest(BaseModel): scope: str item_id: str @@ -1199,6 +1206,21 @@ async def evidence_chain_item(req: EvidenceChainRequest) -> dict[str, Any]: chain = ctx.evidence_chain(ref, scope=req.scope, max_depth=req.max_depth) return chain.to_dict() + @app.post("/link_graph") + async def link_graph_item(req: LinkGraphRequest) -> dict[str, Any]: + ref = ( + req.item_id + if req.item_id.startswith(ctx.resolver.scheme) + else ctx.resolver.ref_for(req.scope, req.item_id) + ) + graph = ctx.link_graph( + ref, + scope=req.scope, + max_depth=req.max_depth, + max_nodes=req.max_nodes, + ) + return graph.to_dict() + @app.post("/chain_confidence") async def chain_confidence_item(req: ChainConfidenceRequest) -> dict[str, Any]: ref = ( diff --git a/tests/unit_tests/domain/test_link_graph.py b/tests/unit_tests/domain/test_link_graph.py new file mode 100644 index 0000000..813e34d --- /dev/null +++ b/tests/unit_tests/domain/test_link_graph.py @@ -0,0 +1,92 @@ +"""Tests for the bounded, all-relation link graph projection.""" + +from __future__ import annotations + +from contextseek.domain.context_item import ContextItem +from contextseek.domain.link_graph import build_link_graph +from contextseek.domain.links import Link, LinkType +from contextseek.domain.provenance import Provenance, SourceType +from contextseek.domain.stages import Stage + + +def _item( + item_id: str, + *, + links: list[Link] | None = None, + content: str | None = None, +) -> ContextItem: + return ContextItem( + id=item_id, + scope="tenant/project", + content=content or f"content for {item_id}", + links=links or [], + stage=Stage.knowledge, + provenance=Provenance( + source_type=SourceType.document, + source_id=f"doc://{item_id}", + confidence=0.8, + ), + ) + + +def test_link_graph_includes_every_relation_type() -> None: + links = [ + Link(target_id=f"target-{relation.value}", relation=relation, strength=0.75) + for relation in LinkType + ] + root = _item("root", links=links) + targets = [_item(link.target_id) for link in links] + + graph = build_link_graph(root, [root, *targets], max_depth=1) + + assert {edge.relation for edge in graph.edges} == set(LinkType) + assert all(edge.strength == 0.75 for edge in graph.edges) + assert len(graph.nodes) == len(LinkType) + 1 + + +def test_link_graph_traverses_incoming_links() -> None: + root = _item("root") + child = _item( + "child", + links=[Link(target_id="root", relation=LinkType.derived_from)], + ) + + graph = build_link_graph(root, [root, child], max_depth=1) + + assert {node.item_id for node in graph.nodes} == {"root", "child"} + assert graph.edges[0].source_id == "child" + assert graph.edges[0].target_id == "root" + + +def test_link_graph_represents_missing_targets() -> None: + root = _item( + "root", + links=[Link(target_id="missing", relation=LinkType.refuted_by)], + ) + + graph = build_link_graph(root, [root], max_depth=1) + + missing = next(node for node in graph.nodes if node.item_id == "missing") + assert missing.is_missing is True + assert missing.confidence == 0.0 + + +def test_link_graph_caps_large_neighborhoods() -> None: + root = _item( + "root", + links=[ + Link(target_id=f"target-{index}", relation=LinkType.related_to) + for index in range(10) + ], + ) + targets = [_item(f"target-{index}") for index in range(10)] + + graph = build_link_graph(root, [root, *targets], max_depth=1, max_nodes=4) + + assert len(graph.nodes) == 4 + assert graph.truncated is True + assert all( + edge.source_id in {node.item_id for node in graph.nodes} + and edge.target_id in {node.item_id for node in graph.nodes} + for edge in graph.edges + ) diff --git a/tests/unit_tests/test_http_server.py b/tests/unit_tests/test_http_server.py index c95ecce..033aa6a 100644 --- a/tests/unit_tests/test_http_server.py +++ b/tests/unit_tests/test_http_server.py @@ -151,6 +151,55 @@ def test_http_retrieve_forwards_include_expired_and_returns_meta() -> None: assert body["items"][0]["content"] is None +def test_http_link_graph_forwards_bounds_and_returns_graph() -> None: + ctx = MagicMock(name="ContextSeek") + ctx.resolver.scheme = "contextseek://" + ctx.resolver.ref_for.return_value = "contextseek://tenant/project/root" + graph = MagicMock() + graph.to_dict.return_value = { + "root_item_id": "root", + "nodes": [{"item_id": "root"}], + "edges": [], + "max_depth": 2, + "max_nodes": 25, + "truncated": False, + } + ctx.link_graph.return_value = graph + app = create_app(client=ctx) + + res = _asgi_post( + app, + "/link_graph", + json={ + "scope": "tenant/project", + "item_id": "root", + "max_depth": 2, + "max_nodes": 25, + }, + ) + + assert res.status_code == 200 + ctx.link_graph.assert_called_once_with( + "contextseek://tenant/project/root", + scope="tenant/project", + max_depth=2, + max_nodes=25, + ) + assert res.json()["root_item_id"] == "root" + + +def test_http_link_graph_rejects_invalid_node_limit() -> None: + app = create_app(client=MagicMock(name="ContextSeek")) + + res = _asgi_post( + app, + "/link_graph", + json={"scope": "tenant/project", "item_id": "root", "max_nodes": 0}, + ) + + assert res.status_code == 422 + + def test_http_scopes_lists_sqlite_scopes(tmp_path) -> None: from seekvfs import VFS