Add semantic flow and convergence web views
This commit is contained in:
parent
f9f7105983
commit
6609edc804
14 changed files with 616 additions and 128 deletions
|
|
@ -34,10 +34,39 @@ 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@13"
|
||||
VISUALIZATION_TEMPLATE = "graph-browser@14"
|
||||
DEFAULT_EDGE_LIMIT = 100
|
||||
MAX_EDGE_LIMIT = 400
|
||||
MAX_LINEAGE_EDGE_LIMIT = 1_000
|
||||
FLOW_REVERSED_RELATIONS = frozenset(
|
||||
{
|
||||
"defined_in",
|
||||
"inherits",
|
||||
"imports",
|
||||
"depends_on",
|
||||
"reads",
|
||||
"tested_by",
|
||||
}
|
||||
)
|
||||
FLOW_CONTEXT_RELATIONS = frozenset(
|
||||
{
|
||||
"documents",
|
||||
"governs",
|
||||
"relates_to",
|
||||
}
|
||||
)
|
||||
WEB_ROOT_ADJACENT_RELATIONS = frozenset(
|
||||
{
|
||||
"activates",
|
||||
"calls",
|
||||
"contains",
|
||||
"defines",
|
||||
"dispatches_to",
|
||||
"implemented_by",
|
||||
"launches",
|
||||
"writes",
|
||||
}
|
||||
)
|
||||
DEFAULT_INITIAL_GRACE_SECONDS = 120.0
|
||||
DEFAULT_LEASE_SECONDS = 180.0
|
||||
LEASE_MONITOR_INTERVAL_SECONDS = 1.0
|
||||
|
|
@ -138,6 +167,7 @@ class VisualizationIndexSnapshot:
|
|||
statuses=_facet_rows(connection, "nodes", "status"),
|
||||
relations=_facet_rows(connection, "edges", "relation"),
|
||||
max_results=self.max_results,
|
||||
max_depth=self.max_depth,
|
||||
snapshot=True,
|
||||
)
|
||||
|
||||
|
|
@ -367,11 +397,12 @@ class VisualizationIndexSnapshot:
|
|||
)
|
||||
|
||||
def lineage(self, node_id: str, *, limit: int) -> dict[str, object]:
|
||||
"""Return every bounded, directed ancestry path terminating at ``node_id``.
|
||||
"""Return bounded semantic flow paths 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.
|
||||
Structural and execution edges retain their stored direction. Dependency,
|
||||
import, data-read, inheritance, and ``tested_by`` edges are reversed so their
|
||||
prerequisites flow into the consumer. Context-only documentation edges remain
|
||||
available in Web but do not clutter Flow.
|
||||
"""
|
||||
if type(limit) is not int or limit < 1 or limit > MAX_LINEAGE_EDGE_LIMIT:
|
||||
raise DocForgeError(
|
||||
|
|
@ -391,31 +422,37 @@ class VisualizationIndexSnapshot:
|
|||
)
|
||||
visited = {node_id}
|
||||
frontier = {node_id}
|
||||
selected: list[dict[str, str]] = []
|
||||
hops = {node_id: 0}
|
||||
selected: list[dict[str, object]] = []
|
||||
selected_keys: set[tuple[str, str, str]] = set()
|
||||
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),
|
||||
f"WHERE source_id IN ({placeholders}) OR target_id IN ({placeholders}) "
|
||||
"ORDER BY source_id, relation, target_id",
|
||||
(*sorted(frontier), *sorted(frontier)),
|
||||
).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"],
|
||||
}
|
||||
key = (row["source_id"], row["relation"], row["target_id"])
|
||||
if key in selected_keys or row["relation"] in FLOW_CONTEXT_RELATIONS:
|
||||
continue
|
||||
reversed_edge = row["relation"] in FLOW_REVERSED_RELATIONS
|
||||
source_id = row["target_id"] if reversed_edge else row["source_id"]
|
||||
target_id = row["source_id"] if reversed_edge else row["target_id"]
|
||||
if target_id not in frontier:
|
||||
continue
|
||||
if len(selected) >= limit:
|
||||
truncated = True
|
||||
break
|
||||
selected_keys.add(key)
|
||||
edge = _visualization_edge(row, reversed_edge=reversed_edge)
|
||||
selected.append(edge)
|
||||
source_id = edge["source_id"]
|
||||
if source_id not in visited:
|
||||
visited.add(source_id)
|
||||
hops[source_id] = hops[target_id] + 1
|
||||
next_frontier.add(source_id)
|
||||
frontier = next_frontier
|
||||
if frontier and len(selected) >= limit:
|
||||
|
|
@ -430,6 +467,113 @@ class VisualizationIndexSnapshot:
|
|||
lineage=True,
|
||||
edge_limit=limit,
|
||||
truncated=truncated,
|
||||
hops=hops,
|
||||
node=_node_dict(root_row),
|
||||
nodes=[_node_dict(row, include_content=False) for row in node_rows],
|
||||
edges=selected,
|
||||
snapshot=True,
|
||||
)
|
||||
|
||||
def web(self, node_id: str, *, depth: int, limit: int) -> dict[str, object]:
|
||||
"""Return a bounded convergence web centered on ``node_id``.
|
||||
|
||||
Web follows every semantic contributor path toward the focus, including the
|
||||
context relationships omitted from Flow. It also reverses direct focus-owned
|
||||
members and execution dependencies into adjacent contributor branches. Later
|
||||
traversal continues only toward those branches, so entering a package or class
|
||||
cannot fan out through unrelated siblings.
|
||||
"""
|
||||
if type(depth) is not int or depth < 1 or depth > self.max_depth:
|
||||
raise DocForgeError("invalid_depth", "Traversal depth is outside the configured limit")
|
||||
if type(limit) is not int or limit < 1 or limit > MAX_LINEAGE_EDGE_LIMIT:
|
||||
raise DocForgeError(
|
||||
"invalid_limit",
|
||||
"Visualization web 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}
|
||||
hops = {node_id: 0}
|
||||
selected: list[dict[str, object]] = []
|
||||
selected_keys: set[tuple[str, str, str, bool]] = set()
|
||||
truncated = False
|
||||
for hop in range(1, depth + 1):
|
||||
if not frontier or len(selected) >= limit:
|
||||
break
|
||||
placeholders = ",".join("?" for _ in frontier)
|
||||
values = tuple(sorted(frontier))
|
||||
rows = connection.execute(
|
||||
"SELECT source_id, relation, target_id FROM edges "
|
||||
f"WHERE source_id IN ({placeholders}) OR target_id IN ({placeholders}) "
|
||||
"ORDER BY source_id, relation, target_id",
|
||||
(*values, *values),
|
||||
).fetchall()
|
||||
next_frontier: set[str] = set()
|
||||
for row in rows:
|
||||
semantic_reversed = row["relation"] in FLOW_REVERSED_RELATIONS
|
||||
semantic_source = row["target_id"] if semantic_reversed else row["source_id"]
|
||||
semantic_target = row["source_id"] if semantic_reversed else row["target_id"]
|
||||
candidates: list[tuple[str, bool]] = []
|
||||
if semantic_target in frontier:
|
||||
candidates.append((semantic_source, semantic_reversed))
|
||||
if (
|
||||
semantic_source == node_id
|
||||
and row["relation"] in WEB_ROOT_ADJACENT_RELATIONS
|
||||
):
|
||||
candidates.append((semantic_target, not semantic_reversed))
|
||||
for source_id, reversed_edge in candidates:
|
||||
key = (
|
||||
row["source_id"],
|
||||
row["relation"],
|
||||
row["target_id"],
|
||||
reversed_edge,
|
||||
)
|
||||
if key in selected_keys:
|
||||
continue
|
||||
existing_hop = hops.get(source_id)
|
||||
if existing_hop is not None and existing_hop < hop:
|
||||
continue
|
||||
if len(selected) >= limit:
|
||||
truncated = True
|
||||
break
|
||||
selected_keys.add(key)
|
||||
selected.append(_visualization_edge(row, reversed_edge=reversed_edge))
|
||||
if source_id not in visited:
|
||||
visited.add(source_id)
|
||||
hops[source_id] = hop
|
||||
next_frontier.add(source_id)
|
||||
if truncated:
|
||||
break
|
||||
frontier = next_frontier
|
||||
if frontier and len(selected) >= limit:
|
||||
truncated = True
|
||||
|
||||
convergent_ids = {node_id}
|
||||
for edge in selected:
|
||||
convergent_ids.add(cast(str, edge["source_id"]))
|
||||
convergent_ids.add(cast(str, edge["target_id"]))
|
||||
placeholders = ",".join("?" for _ in convergent_ids)
|
||||
node_rows = connection.execute(
|
||||
f"SELECT * FROM nodes WHERE node_id IN ({placeholders}) ORDER BY node_id",
|
||||
tuple(sorted(convergent_ids)),
|
||||
).fetchall()
|
||||
return self._result(
|
||||
root=node_id,
|
||||
web=True,
|
||||
depth=depth,
|
||||
edge_limit=limit,
|
||||
truncated=truncated,
|
||||
hops={node: hops[node] for node in sorted(convergent_ids)},
|
||||
node=_node_dict(root_row),
|
||||
nodes=[_node_dict(row, include_content=False) for row in node_rows],
|
||||
edges=selected,
|
||||
|
|
@ -794,6 +938,9 @@ class VisualizationRunner:
|
|||
elif parsed.path == f"{prefix}/api/lineage":
|
||||
self._touch_lease()
|
||||
payload = self._lineage(reader, params)
|
||||
elif parsed.path == f"{prefix}/api/web":
|
||||
self._touch_lease()
|
||||
payload = self._web(reader, params)
|
||||
else:
|
||||
self._respond_error(
|
||||
handler,
|
||||
|
|
@ -891,6 +1038,24 @@ class VisualizationRunner:
|
|||
)
|
||||
return reader.lineage(node_id, limit=limit)
|
||||
|
||||
def _web(
|
||||
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")
|
||||
depth = _integer(_one(params, "depth") or str(reader.max_depth))
|
||||
limit = _integer(_one(params, "limit") or str(MAX_LINEAGE_EDGE_LIMIT))
|
||||
if limit > MAX_LINEAGE_EDGE_LIMIT:
|
||||
raise DocForgeError(
|
||||
"invalid_limit",
|
||||
"Visualization web limit exceeds the fixed safety boundary",
|
||||
maximum=MAX_LINEAGE_EDGE_LIMIT,
|
||||
)
|
||||
return reader.web(node_id, depth=depth, limit=limit)
|
||||
|
||||
def _filter(
|
||||
self,
|
||||
reader: VisualizationIndexSnapshot,
|
||||
|
|
@ -1346,6 +1511,23 @@ def _integer(value: str) -> int:
|
|||
return int(value)
|
||||
|
||||
|
||||
def _visualization_edge(
|
||||
row: sqlite3.Row,
|
||||
*,
|
||||
reversed_edge: bool,
|
||||
) -> dict[str, object]:
|
||||
stored_source_id = cast(str, row["source_id"])
|
||||
stored_target_id = cast(str, row["target_id"])
|
||||
return {
|
||||
"source_id": stored_target_id if reversed_edge else stored_source_id,
|
||||
"relation": row["relation"],
|
||||
"target_id": stored_source_id if reversed_edge else stored_target_id,
|
||||
"stored_source_id": stored_source_id,
|
||||
"stored_target_id": stored_target_id,
|
||||
"reversed": reversed_edge,
|
||||
}
|
||||
|
||||
|
||||
def _node_dict(row: sqlite3.Row, *, include_content: bool = True) -> dict[str, object]:
|
||||
result = {
|
||||
"node_id": row["node_id"],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue