Trace complete directed graph lineage in Flow view
This commit is contained in:
parent
984a4fd993
commit
e405326334
3 changed files with 139 additions and 61 deletions
|
|
@ -33,9 +33,10 @@ from .errors import DocForgeError
|
|||
from .index import APPLICATION_ID, INDEX_SCHEMA_VERSION, ProjectIndex, re_tokenize
|
||||
from .project import project_root_fingerprint
|
||||
|
||||
VISUALIZATION_TEMPLATE = "graph-browser@10"
|
||||
VISUALIZATION_TEMPLATE = "graph-browser@11"
|
||||
DEFAULT_EDGE_LIMIT = 100
|
||||
MAX_EDGE_LIMIT = 400
|
||||
MAX_LINEAGE_EDGE_LIMIT = 1_000
|
||||
DEFAULT_INITIAL_GRACE_SECONDS = 120.0
|
||||
DEFAULT_LEASE_SECONDS = 180.0
|
||||
LEASE_MONITOR_INTERVAL_SECONDS = 1.0
|
||||
|
|
@ -287,6 +288,76 @@ class VisualizationIndexSnapshot:
|
|||
snapshot=True,
|
||||
)
|
||||
|
||||
def lineage(self, node_id: str, *, limit: int) -> dict[str, object]:
|
||||
"""Return every bounded, directed ancestry path terminating at ``node_id``.
|
||||
|
||||
A lineage follows stored edge direction only: ``source -> target``. This keeps
|
||||
Flow literal and auditable. It does not reinterpret relationship meanings or
|
||||
reverse dependency/data edges as the old client-side Flow view did.
|
||||
"""
|
||||
if type(limit) is not int or limit < 1 or limit > MAX_LINEAGE_EDGE_LIMIT:
|
||||
raise DocForgeError(
|
||||
"invalid_limit",
|
||||
"Visualization lineage limit exceeds the fixed safety boundary",
|
||||
maximum=MAX_LINEAGE_EDGE_LIMIT,
|
||||
)
|
||||
with self._connection() as connection:
|
||||
root_row = connection.execute(
|
||||
"SELECT * FROM nodes WHERE node_id = ?", (node_id,)
|
||||
).fetchone()
|
||||
if root_row is None:
|
||||
raise DocForgeError(
|
||||
"missing_node",
|
||||
"No node has the requested stable ID",
|
||||
node_id=node_id,
|
||||
)
|
||||
visited = {node_id}
|
||||
frontier = {node_id}
|
||||
selected: list[dict[str, str]] = []
|
||||
truncated = False
|
||||
while frontier and len(selected) < limit:
|
||||
placeholders = ",".join("?" for _ in frontier)
|
||||
remaining = limit - len(selected)
|
||||
rows = connection.execute(
|
||||
"SELECT source_id, relation, target_id FROM edges "
|
||||
f"WHERE target_id IN ({placeholders}) "
|
||||
"ORDER BY source_id, relation, target_id LIMIT ?",
|
||||
(*sorted(frontier), remaining + 1),
|
||||
).fetchall()
|
||||
if len(rows) > remaining:
|
||||
rows = rows[:remaining]
|
||||
truncated = True
|
||||
next_frontier: set[str] = set()
|
||||
for row in rows:
|
||||
edge = {
|
||||
"source_id": row["source_id"],
|
||||
"relation": row["relation"],
|
||||
"target_id": row["target_id"],
|
||||
}
|
||||
selected.append(edge)
|
||||
source_id = edge["source_id"]
|
||||
if source_id not in visited:
|
||||
visited.add(source_id)
|
||||
next_frontier.add(source_id)
|
||||
frontier = next_frontier
|
||||
if frontier and len(selected) >= limit:
|
||||
truncated = True
|
||||
placeholders = ",".join("?" for _ in visited)
|
||||
node_rows = connection.execute(
|
||||
f"SELECT * FROM nodes WHERE node_id IN ({placeholders}) ORDER BY node_id",
|
||||
tuple(sorted(visited)),
|
||||
).fetchall()
|
||||
return self._result(
|
||||
root=node_id,
|
||||
lineage=True,
|
||||
edge_limit=limit,
|
||||
truncated=truncated,
|
||||
node=_node_dict(root_row),
|
||||
nodes=[_node_dict(row, include_content=False) for row in node_rows],
|
||||
edges=selected,
|
||||
snapshot=True,
|
||||
)
|
||||
|
||||
def require_node(self, node_id: str) -> None:
|
||||
with self._connection() as connection:
|
||||
row = connection.execute("SELECT 1 FROM nodes WHERE node_id = ?", (node_id,)).fetchone()
|
||||
|
|
@ -615,6 +686,9 @@ class VisualizationRunner:
|
|||
elif parsed.path == f"{prefix}/api/node":
|
||||
self._touch_lease()
|
||||
payload = self._node(reader, params)
|
||||
elif parsed.path == f"{prefix}/api/lineage":
|
||||
self._touch_lease()
|
||||
payload = self._lineage(reader, params)
|
||||
else:
|
||||
self._respond_error(
|
||||
handler,
|
||||
|
|
@ -682,6 +756,23 @@ class VisualizationRunner:
|
|||
)
|
||||
return reader.node(node_id, depth=depth, limit=limit)
|
||||
|
||||
def _lineage(
|
||||
self,
|
||||
reader: VisualizationIndexSnapshot,
|
||||
params: dict[str, list[str]],
|
||||
) -> dict[str, object]:
|
||||
node_id = _one(params, "id").strip()
|
||||
if not node_id:
|
||||
raise DocForgeError("missing_node", "One exact node ID is required")
|
||||
limit = _integer(_one(params, "limit") or str(MAX_LINEAGE_EDGE_LIMIT))
|
||||
if limit > MAX_LINEAGE_EDGE_LIMIT:
|
||||
raise DocForgeError(
|
||||
"invalid_limit",
|
||||
"Visualization lineage limit exceeds the fixed safety boundary",
|
||||
maximum=MAX_LINEAGE_EDGE_LIMIT,
|
||||
)
|
||||
return reader.lineage(node_id, limit=limit)
|
||||
|
||||
def _filter(
|
||||
self,
|
||||
reader: VisualizationIndexSnapshot,
|
||||
|
|
@ -2046,32 +2137,18 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
|
|||
},
|
||||
]));
|
||||
}
|
||||
function semanticFlowEdge(edge) {
|
||||
const direction = relationStyle(edge.relation).flow;
|
||||
if (direction === null) return null;
|
||||
if (direction === "reverse") {
|
||||
return {
|
||||
...edge,
|
||||
source_id: edge.target_id,
|
||||
target_id: edge.source_id,
|
||||
stored_source_id: edge.source_id,
|
||||
stored_target_id: edge.target_id,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...edge,
|
||||
stored_source_id: edge.source_id,
|
||||
stored_target_id: edge.target_id,
|
||||
};
|
||||
}
|
||||
function buildFlowGraph(data) {
|
||||
const semanticEdges = data.edges.map(semanticFlowEdge).filter(Boolean);
|
||||
// The server has already supplied the complete directed ancestry for this
|
||||
// focus. Keep every stored edge as-is: source -> target. Flow must show
|
||||
// what literally leads to the selected terminal, not infer an alternate
|
||||
// direction from a relationship label.
|
||||
const lineageEdges = data.edges;
|
||||
const upstreamHops = new Map([[data.root, 0]]);
|
||||
let frontier = [data.root];
|
||||
while (frontier.length) {
|
||||
const next = [];
|
||||
for (const targetId of frontier) {
|
||||
for (const edge of semanticEdges) {
|
||||
for (const edge of lineageEdges) {
|
||||
if (edge.target_id !== targetId || upstreamHops.has(edge.source_id)) continue;
|
||||
upstreamHops.set(edge.source_id, upstreamHops.get(targetId) + 1);
|
||||
next.push(edge.source_id);
|
||||
|
|
@ -2081,7 +2158,7 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
|
|||
}
|
||||
const nodes = data.nodes.filter((node) => upstreamHops.has(node.node_id));
|
||||
const nodeIds = new Set(nodes.map((node) => node.node_id));
|
||||
const edges = semanticEdges.filter(
|
||||
const edges = lineageEdges.filter(
|
||||
(edge) => nodeIds.has(edge.source_id) && nodeIds.has(edge.target_id),
|
||||
);
|
||||
const topology = new Map(nodes.map((node) => [
|
||||
|
|
@ -2532,11 +2609,18 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
|
|||
}
|
||||
async function loadNode(nodeId) {
|
||||
try {
|
||||
setStatus(`Loading ${nodeId}…`);
|
||||
const params = new URLSearchParams({id: nodeId, depth: String(state.depth), limit: "100"});
|
||||
const data = await api(`node?${params}`);
|
||||
const showingFlow = state.mode === "flow";
|
||||
setStatus(`${showingFlow ? "Tracing lineage for" : "Loading"} ${nodeId}…`);
|
||||
const params = new URLSearchParams(
|
||||
showingFlow
|
||||
? {id: nodeId, limit: "1000"}
|
||||
: {id: nodeId, depth: String(state.depth), limit: "100"},
|
||||
);
|
||||
const data = await api(`${showingFlow ? "lineage" : "node"}?${params}`);
|
||||
renderGraph(data);
|
||||
setStatus(`${data.nodes.length} nodes · ${data.edges.length} edges in neighborhood`);
|
||||
const scope = showingFlow ? "directed ancestry" : "neighborhood";
|
||||
const suffix = data.truncated ? " · truncated at the safety limit" : "";
|
||||
setStatus(`${data.nodes.length} nodes · ${data.edges.length} edges in ${scope}${suffix}`);
|
||||
history.replaceState(
|
||||
null,
|
||||
"",
|
||||
|
|
@ -2546,22 +2630,13 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
|
|||
setStatus(error.message, true);
|
||||
}
|
||||
}
|
||||
function setViewMode(mode) {
|
||||
async function setViewMode(mode) {
|
||||
if (mode !== "nodes" && mode !== "flow") return;
|
||||
state.mode = mode;
|
||||
$("view-switch").dataset.mode = mode;
|
||||
$("view-nodes").setAttribute("aria-pressed", String(mode === "nodes"));
|
||||
$("view-flow").setAttribute("aria-pressed", String(mode === "flow"));
|
||||
if (state.graph) {
|
||||
renderGraph(state.graph, true);
|
||||
const suffix = mode === "flow" ? "upstream flow" : "node neighborhood";
|
||||
setStatus(`Showing ${suffix} for ${state.root}`);
|
||||
history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`?node=${encodeURIComponent(state.root)}&depth=${state.depth}&view=${state.mode}`,
|
||||
);
|
||||
}
|
||||
if (state.root) await loadNode(state.root);
|
||||
}
|
||||
function clamp(value, minimum, maximum) {
|
||||
return Math.min(maximum, Math.max(minimum, value));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue