feat(ai): catalogue d'outils lecture & recherche (#79 phase C)
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
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
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user