Files
ObsiGate/backend/services/graph.py
T
bruno 31b65ade5b
CI / lint (push) Successful in 59s
CI / security (push) Successful in 49s
CI / test (push) Successful in 1m24s
CI / build (push) Successful in 37s
CI / e2e (push) Successful in 10m24s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
feat(ai): catalogue d'outils lecture & recherche (#79 phase C)
2026-09-11 20:33:15 -04:00

200 lines
7.5 KiB
Python

"""Graph data services shared by the REST route and the AI tool layer.
Builds the nodes/edges structure consumed by the graph view. The route
``/api/graph/{vault}`` and the tool ``get_graph`` both delegate here so the
graph semantics (parent/child edges, wikilinks, tag filter) stay identical.
Permission checks are the caller's responsibility.
"""
from __future__ import annotations
import re
from pathlib import Path
from typing import Any
from backend.indexer import SUPPORTED_EXTENSIONS, index
from backend.services.errors import ServiceError
from backend.services.paths import resolve_safe_path
from backend.services.vaults import get_vault_root
_WIKILINK_PATTERN = re.compile(r"\[\[([^\]|#]+)(?:[|#][^\]]+)?\]\]")
def get_graph(
vault_name: str,
path: str = "",
depth: int = 1,
scope: str = "directory",
tag: str = "",
) -> dict[str, Any]:
"""Return graph data (nodes and edges) for a vault or directory.
Args:
vault_name: Name of the vault.
path: Relative directory path to focus on (empty = root).
depth: Expansion depth (0 = only direct children, 1-3 = deeper).
scope: ``"directory"`` for a subtree, ``"full"`` for the whole vault.
tag: Optional tag filter (only files with this tag appear).
Returns:
Dict with ``vault``, ``path``, ``scope``, ``nodes`` and ``edges``.
Raises:
ServiceError: ``not_found`` (404) when the vault or path is missing.
"""
from backend.vault_settings import get_vault_setting
vault_root = get_vault_root(vault_name)
target = resolve_safe_path(vault_root, path) if path else vault_root.resolve()
if not target.exists():
raise ServiceError(
f"Path not found: {path}",
code="not_found",
status=404,
details={"vault": vault_name, "path": path},
)
nodes: list[dict[str, Any]] = []
edges: list[dict[str, Any]] = []
node_ids: set[str] = set()
def _add_node(name: str, ntype: str, npath: str, size: int = 0,
tags: list[str] | None = None, incoming: int = 0, outgoing: int = 0) -> str:
nid = f"{vault_name}:{npath}"
if nid not in node_ids:
node_ids.add(nid)
nodes.append({
"id": nid, "name": name, "type": ntype, "path": npath,
"size": size, "tags": tags or [],
"incoming_count": incoming, "outgoing_count": outgoing,
})
return nid
def _add_edge(source: str, target_id: str, relation: str) -> None:
edges.append({"source": source, "target": target_id, "relation": relation})
settings = get_vault_setting(vault_name) or {}
hide_hidden = settings.get("hideHiddenFiles", False)
# Build tag index from the in-memory index for fast lookups.
_tag_index: dict[str, list[str]] = {}
for doc_key, info in index.items():
vn, fp = doc_key.split("::", 1) if "::" in doc_key else ("", "")
if vn == vault_name:
for t in info.get("tags", []):
_tag_index.setdefault(t.lower(), []).append(fp)
if scope == "full":
target = vault_root.resolve()
effective_depth = depth if depth > 0 else 2
else:
target = resolve_safe_path(vault_root, path) if path else vault_root.resolve()
effective_depth = depth
if not target.exists():
raise ServiceError(
f"Path not found: {path}",
code="not_found",
status=404,
details={"vault": vault_name, "path": path},
)
focus_name = path.split("/")[-1] if path else vault_name
focus_type = "directory" if path else "vault"
focus_id = _add_node(focus_name, focus_type, path)
def _walk_dir(dir_path: Path, parent_id: str, current_depth: int) -> None:
if current_depth > effective_depth:
return
try:
for entry in sorted(dir_path.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower())):
if hide_hidden and entry.name.startswith("."):
continue
rel = str(entry.relative_to(vault_root)).replace("\\", "/")
if tag and entry.is_file():
file_tags = [t.lower() for t in _tag_index.get(rel, [])]
if tag.lower() not in file_tags:
continue
if entry.is_dir():
did = _add_node(entry.name, "directory", rel)
_add_edge(parent_id, did, "parent")
if current_depth < effective_depth:
_walk_dir(entry, did, current_depth + 1)
elif entry.suffix.lower() in SUPPORTED_EXTENSIONS or entry.name.lower() in ("dockerfile", "makefile"):
file_tags = _tag_index.get(rel, [])
fid = _add_node(entry.name, "file", rel, entry.stat().st_size, tags=file_tags)
_add_edge(parent_id, fid, "parent")
except PermissionError:
pass
if target.is_dir():
_walk_dir(target, focus_id, 0)
elif target.is_file():
_walk_dir(target.parent, focus_id, 0)
_add_wikilink_edges(nodes, edges, vault_name)
edge_counts: dict[str, dict[str, int]] = {}
for node in nodes:
edge_counts[node["id"]] = {"incoming": 0, "outgoing": 0}
for edge in edges:
if edge["relation"] in ("wikilink", "backlink"):
src = edge["source"]
tgt = edge["target"]
if src in edge_counts:
edge_counts[src]["outgoing"] += 1
if tgt in edge_counts:
edge_counts[tgt]["incoming"] += 1
for node in nodes:
counts = edge_counts.get(node["id"], {"incoming": 0, "outgoing": 0})
node["incoming_count"] = counts["incoming"]
node["outgoing_count"] = counts["outgoing"]
return {"vault": vault_name, "path": path, "scope": scope, "nodes": nodes, "edges": edges}
def _add_wikilink_edges(nodes: list[dict[str, Any]], edges: list[dict[str, Any]], vault_name: str) -> None:
"""Add edges for wikilinks between markdown files in the current graph scope."""
file_nodes = [n for n in nodes if n["type"] == "file" and n["path"].endswith(".md")]
if len(file_nodes) < 2:
return
path_to_id = {n["path"]: n["id"] for n in file_nodes}
existing = {(e["source"], e["target"]) for e in edges}
for node in file_nodes:
vault_data = index.get(vault_name)
if not vault_data:
continue
file_entry = None
for f in vault_data.get("files", []):
if f["path"] == node["path"]:
file_entry = f
break
if not file_entry:
continue
content = file_entry.get("content", "")
if not content:
continue
for match in _WIKILINK_PATTERN.finditer(content):
target = match.group(1).strip()
target_lower = target.lower()
if not target_lower.endswith(".md"):
target_lower += ".md"
for target_path, target_id in path_to_id.items():
if target_id == node["id"]:
continue
target_name = target_path.rsplit("/", 1)[-1].lower()
if target_name == target_lower or target_path.lower() == target_lower:
edge_key = tuple(sorted([node["id"], target_id]))
if edge_key not in existing and (edge_key[1], edge_key[0]) not in existing:
edges.append({"source": node["id"], "target": target_id, "relation": "wikilink"})
existing.add((node["id"], target_id))
break