Files
cowork-local/core/structure_graph.py
f9f6bc01fd
CI / test (push) Canceled after 0s
Feature/delta team/epic r04 (#7)
## Summary

epic r04 - begin refactor

## Change Type

- [x] Cowork feature
- [ ] Bug fix
- [ ] Core AI contribution
- [ ] Test / hardening
- [ ] Performance
- [ ] Documentation

## Related Work

Cowork Task:

Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets

Core AI Issue:

Core Task:

Related PR:

## Scope

What is intentionally included?

What is intentionally NOT included?

## Validation

- [ ] Unit tests
- [ ] Integration tests
- [ ] Manual verification
- [ ] Regression check

Commands / evidence:

## Security Impact

Permission / credential / network / customer data impact:

## Compatibility

- [ ] No breaking change
- [ ] Breaking change documented

## Reviewer Notes

Anything Cowork reviewers should pay attention to.

---------

Co-authored-by: Anh Tran Nguyen Minh <anhtnm1@fpt.com>
Co-authored-by: Huong Le Thi Thien <huongltt35@fpt.com>
Co-authored-by: Nam Pham Dinh Thanh <nampdt@fpt.com>
Co-authored-by: Vu Dam Tuan <vudt15@fpt.com>
Co-authored-by: Hiep Ha Van <hiephv3@fpt.com>
Co-authored-by: Lam Hoang Van <lamhv7@fpt.com>
Reviewed-on: #7
Co-authored-by: Duy Le Huu <duylh19@fpt.com>
2026-08-31 05:15:13 +00:00

514 lines
21 KiB
Python

"""Build a knowledge graph (Graph-RAG style) of code / document structure.
Two builders:
- ``build_from_directory`` — stdlib only. Parses Python files with ``ast``
(files, classes, functions, methods, imports), Markdown/text files by
heading hierarchy, and JSON files by their own key/array structure. No
external dependency.
- ``build_from_codebase_memory`` — best-effort enrichment using the
codebase-memory-mcp knowledge graph when available; falls back to the local
builder on any problem.
Both return a :class:`StructureGraph` of nodes + edges that the UI lays out and
renders. A simple layered layout is provided.
"""
from __future__ import annotations
import ast
import json
import os
from collections import defaultdict, deque
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List, Tuple
EXCLUDE_DIRS = {".venv", "__pycache__", ".git", "node_modules", ".pytest_cache",
".mypy_cache", "dist", "build", ".idea", ".vscode"}
CODE_SUFFIXES = {".py"}
DOC_SUFFIXES = {".md", ".markdown", ".txt", ".rst"}
JSON_SUFFIXES = {".json"}
DEFAULT_MAX_NODES = 400
MAX_IMPORTS_PER_FILE = 8
MAX_JSON_DEPTH = 20 # how many nested levels of keys/arrays to expand
MAX_JSON_KEYS_PER_LEVEL = 200 # cap per object, so one huge JSON can't flood the graph
@dataclass
class GNode:
"""Một node trong đồ thị cấu trúc: thư mục, tệp, lớp, hàm, phương thức hay mục tài liệu."""
id: str
label: str
kind: str # dir | file | class | function | method | module | section
detail: str = ""
path: str = "" # absolute file/folder this node maps to (for "open folder")
@dataclass
class GEdge:
"""Một cạnh trong đồ thị cấu trúc, kèm LOẠI quan hệ (chứa / định nghĩa / import…)."""
source: str
target: str
type: str = "" # contains | defines | method | imports | subsection
@dataclass
class StructureGraph:
"""Đồ thị cấu trúc mã nguồn, có trần số node/cạnh.
Chạm trần thì bật cờ ``truncated`` và ngừng thêm — đồ thị quá lớn làm treo
khung vẽ, thà hiện một phần kèm cảnh báo còn hơn đứng hình.
"""
nodes: List[GNode] = field(default_factory=list)
edges: List[GEdge] = field(default_factory=list)
truncated: bool = False
max_nodes: int = 0 # 0 = unlimited
max_edges: int = 0 # 0 = unlimited
def __post_init__(self):
"""Dựng sẵn tập id node để kiểm tra một cạnh có hợp lệ không trong thời gian
hằng số, thay vì quét cả danh sách node cho từng cạnh.
"""
self._ids = {n.id for n in self.nodes}
def add_node(self, node: GNode) -> bool:
"""Thêm một node; trả về ``False`` nếu trùng id hoặc đã chạm trần."""
if node.id in self._ids:
return False
if self.max_nodes and len(self.nodes) >= self.max_nodes:
self.truncated = True
return False
self.nodes.append(node)
self._ids.add(node.id)
return True
def add_edge(self, source: str, target: str, type_: str = "") -> None:
"""Thêm một cạnh; bỏ qua nếu một trong hai đầu chưa có node, hoặc đã chạm trần."""
if source in self._ids and target in self._ids:
if self.max_edges and len(self.edges) >= self.max_edges:
self.truncated = True
return
self.edges.append(GEdge(source, target, type_))
def has(self, node_id: str) -> bool:
"""Đồ thị đã có node với id này chưa."""
return node_id in self._ids
# --------------------------------------------------------------------------
# Local (stdlib) builder
# --------------------------------------------------------------------------
def build_from_directory(root, mode: str = "all", max_nodes: int = 0,
max_edges: int = 0) -> StructureGraph:
"""Build the structure graph for a folder.
mode:
- 'files' — the WHOLE folder/file tree: Python and docs parsed deeply, and
every other file type shown as a plain file node (structure only).
- 'all' — Code & Docs: Python + Markdown/text parsed (no other file types).
- 'code' — Python files only.
- 'doc' — Markdown/text docs only.
``max_nodes=0`` (default) means no cap — every node/edge is kept.
"""
root_path = Path(root).expanduser().resolve()
graph = StructureGraph(max_nodes=max_nodes, max_edges=max_edges)
root_id = f"dir:{root_path}"
graph.add_node(GNode(root_id, root_path.name or str(root_path), "dir", str(root_path), str(root_path)))
want_code = mode in ("files", "all", "code")
want_doc = mode in ("files", "all", "doc")
want_json = mode == "files" # deep-parse JSON only in the "All" (files) view
want_other = mode == "files" # show every remaining file type as a file node
for dirpath, dirnames, filenames in os.walk(root_path):
dirnames[:] = [d for d in dirnames if d not in EXCLUDE_DIRS and not d.startswith(".")]
if graph.truncated:
break
dpath = Path(dirpath)
dir_id = f"dir:{dpath}"
if dpath != root_path:
graph.add_node(GNode(dir_id, dpath.name, "dir", str(dpath), str(dpath)))
parent_id = f"dir:{dpath.parent}"
graph.add_edge(parent_id, dir_id, "contains")
for fname in sorted(filenames):
suffix = Path(fname).suffix.lower()
fpath = dpath / fname
if want_code and suffix in CODE_SUFFIXES:
_add_python_file(graph, dir_id, fpath, root_path)
elif want_json and suffix in JSON_SUFFIXES:
_add_json_file(graph, dir_id, fpath, root_path)
elif want_doc and suffix in DOC_SUFFIXES:
_add_doc_file(graph, dir_id, fpath, root_path)
elif want_other:
_add_generic_file(graph, dir_id, fpath, root_path)
if graph.truncated:
break
return graph
def _add_generic_file(graph: StructureGraph, dir_id: str, fpath: Path, root: Path) -> None:
"""Add a plain file node (no inner parsing) so the full folder/file tree is
shown — used for file types beyond Python/docs in 'files' mode."""
file_id = f"file:{fpath}"
if graph.add_node(GNode(file_id, fpath.name, "file", _rel(fpath, root), str(fpath))):
graph.add_edge(dir_id, file_id, "contains")
def _rel(path: Path, root: Path) -> str:
"""Đường dẫn tương đối so với thư mục gốc; nằm ngoài gốc thì trả nguyên đường dẫn."""
try:
return str(path.relative_to(root))
except ValueError:
return str(path)
def _add_python_file(graph: StructureGraph, dir_id: str, fpath: Path, root: Path) -> None:
"""Thêm một tệp Python vào đồ thị: node tệp, các lớp, hàm, phương thức và import."""
file_id = f"file:{fpath}"
if not graph.add_node(GNode(file_id, fpath.name, "file", _rel(fpath, root), str(fpath))):
return
graph.add_edge(dir_id, file_id, "contains")
fp = str(fpath)
try:
tree = ast.parse(fpath.read_text(encoding="utf-8", errors="replace"))
except (OSError, SyntaxError, ValueError):
return
for node in tree.body:
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
fn_id = f"{file_id}::{node.name}"
graph.add_node(GNode(fn_id, node.name + "()", "function", _rel(fpath, root), fp))
graph.add_edge(file_id, fn_id, "defines")
elif isinstance(node, ast.ClassDef):
cls_id = f"{file_id}::{node.name}"
graph.add_node(GNode(cls_id, node.name, "class", _rel(fpath, root), fp))
graph.add_edge(file_id, cls_id, "defines")
for item in node.body:
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
m_id = f"{cls_id}.{item.name}"
graph.add_node(GNode(m_id, item.name + "()", "method", f"{node.name}.{item.name}", fp))
graph.add_edge(cls_id, m_id, "method")
imports = _module_imports(tree)[:MAX_IMPORTS_PER_FILE]
for mod in imports:
mod_id = f"mod:{mod}"
graph.add_node(GNode(mod_id, mod, "module", "import"))
graph.add_edge(file_id, mod_id, "imports")
def _module_imports(tree: ast.AST) -> List[str]:
"""Tên các module mà một cây AST import vào."""
mods: List[str] = []
for node in ast.walk(tree):
if isinstance(node, ast.Import):
mods += [a.name.split(".")[0] for a in node.names]
elif isinstance(node, ast.ImportFrom) and node.module:
mods.append(node.module.split(".")[0])
seen, out = set(), []
for m in mods:
if m and m not in seen:
seen.add(m)
out.append(m)
return out
def _add_doc_file(graph: StructureGraph, dir_id: str, fpath: Path, root: Path) -> None:
"""Thêm một tệp tài liệu (Markdown…) vào đồ thị, tách theo cấp tiêu đề."""
file_id = f"file:{fpath}"
fp = str(fpath)
if not graph.add_node(GNode(file_id, fpath.name, "file", _rel(fpath, root), fp)):
return
graph.add_edge(dir_id, file_id, "contains")
try:
lines = fpath.read_text(encoding="utf-8", errors="replace").splitlines()
except OSError:
return
# Track the most recent heading id at each level to build hierarchy.
last_at_level: Dict[int, str] = {0: file_id}
counter = 0
for line in lines:
stripped = line.lstrip()
if not stripped.startswith("#"):
continue
level = len(stripped) - len(stripped.lstrip("#"))
title = stripped[level:].strip()
if not title or level > 6:
continue
counter += 1
sec_id = f"{file_id}#sec{counter}"
if not graph.add_node(GNode(sec_id, title[:48], "section", title, fp)):
break
parent = next((last_at_level[lv] for lv in range(level - 1, -1, -1) if lv in last_at_level), file_id)
graph.add_edge(parent, sec_id, "subsection")
last_at_level[level] = sec_id
# invalidate deeper levels
for lv in list(last_at_level):
if lv > level:
del last_at_level[lv]
def _add_json_file(graph: StructureGraph, dir_id: str, fpath: Path, root: Path) -> None:
"""Parse a JSON file's OWN structure (object keys / array shape) into
nodes — the same way Python files become classes/functions and Markdown
becomes heading sections — instead of showing up as just a flat file
node with nothing inside it."""
file_id = f"file:{fpath}"
fp = str(fpath)
if not graph.add_node(GNode(file_id, fpath.name, "file", _rel(fpath, root), fp)):
return
graph.add_edge(dir_id, file_id, "contains")
try:
data = json.loads(fpath.read_text(encoding="utf-8", errors="replace"))
except (OSError, ValueError, RecursionError):
# RecursionError: a syntactically valid but very deeply nested document
# (e.g. 1000+ levels of arrays) blows Python's json parser's recursion
# budget — skip just this file rather than aborting the whole scan.
return
_add_json_value(graph, file_id, fp, data, depth=0)
def _json_scalar_preview(value) -> str:
"""Chuỗi xem trước ngắn cho một giá trị JSON, để nhãn node không quá dài."""
if isinstance(value, dict):
return f"{{…}} ({len(value)} keys)"
if isinstance(value, list):
return f"[…] ({len(value)} items)"
return str(value)[:40]
def _add_json_value(graph: StructureGraph, parent_id: str, fp: str, value, depth: int) -> None:
"""Thêm cấu trúc một giá trị JSON vào đồ thị, chặn ở ``MAX_JSON_DEPTH``.
Có trần độ sâu vì JSON lồng sâu sẽ sinh ra hàng nghìn node mà chẳng nói lên
điều gì về cấu trúc dự án.
"""
if depth >= MAX_JSON_DEPTH:
return
if isinstance(value, dict):
# Child ids are the parent's id plus this key's POSITION, never the key
# text itself — a key that happens to contain the "#" join character
# (or matches another key's text at a different depth) can otherwise
# collide with a deeper node's id and silently delete it from the graph.
for i, (key, val) in enumerate(list(value.items())[:MAX_JSON_KEYS_PER_LEVEL]):
key_id = f"{parent_id}#{i}"
# Only dicts and arrays-of-objects get expanded further; a scalar
# array (e.g. ["a","b","c"]) has nothing more to show, so it's
# labelled with its preview and left as a leaf like any scalar.
needs_recursion = isinstance(val, dict) or (
isinstance(val, list) and val and isinstance(val[0], dict))
# Always show key: value (even for nested objects/arrays show a preview)
label = f"{key}: {_json_scalar_preview(val)}"
if not graph.add_node(GNode(key_id, label[:120], "json_key", str(key), fp)):
continue
graph.add_edge(parent_id, key_id, "contains")
if needs_recursion:
_add_json_value(graph, key_id, fp, val, depth + 1)
elif isinstance(value, list) and value and isinstance(value[0], dict):
# Arrays of objects: show the FIRST element's shape as a representative
# sample rather than exploding every item (a 500-row JSON array would
# otherwise flood the graph with near-identical nodes).
sample_id = f"{parent_id}#0"
if graph.add_node(GNode(sample_id, f"[0] of {len(value)} (sample)", "json_key",
"array item sample", fp)):
graph.add_edge(parent_id, sample_id, "contains")
_add_json_value(graph, sample_id, fp, value[0], depth + 1)
# --------------------------------------------------------------------------
# Codebase-memory builder (best effort, falls back to local)
# --------------------------------------------------------------------------
def build_from_codebase_memory(mem, repo_path, mode: str = "all",
max_nodes: int = 0, max_edges: int = 0) -> StructureGraph:
"""Try to build from the codebase-memory knowledge graph; fall back local."""
try:
graph = StructureGraph(max_nodes=max_nodes, max_edges=max_edges)
produced = 0
for label, kind in (("Class", "class"), ("Function", "function")):
res = mem.call("search_graph", {"label": label, "limit": 150})
for item in _iter_results(res):
name = item.get("name") or item.get("label")
if not name:
continue
file = item.get("file") or item.get("path") or ""
nid = f"{label}:{file}:{name}"
if graph.add_node(GNode(nid, name, kind, file)):
produced += 1
if file:
fid = f"file:{file}"
graph.add_node(GNode(fid, Path(file).name, "file", file))
graph.add_edge(fid, nid, "defines")
if produced >= 3:
return graph
except Exception:
pass
return build_from_directory(repo_path, mode=mode)
def _iter_results(res):
"""Duyệt kết quả trả về từ bộ nhớ mã nguồn, chấp nhận nhiều khuôn khoá khác nhau
(``results`` / ``nodes`` / ``items`` / ``data``).
"""
if isinstance(res, dict):
for key in ("results", "nodes", "items", "data"):
val = res.get(key)
if isinstance(val, list):
return [x for x in val if isinstance(x, dict)]
if isinstance(res, list):
return [x for x in res if isinstance(x, dict)]
return []
# --------------------------------------------------------------------------
# Layout (layered by distance from roots)
# --------------------------------------------------------------------------
def layered_layout(graph: StructureGraph, col_w: int = 280, row_h: int = 64) -> Tuple[Dict[str, Tuple[int, int]], Dict[str, int]]:
"""Xếp đồ thị thành các cột theo bậc phụ thuộc, trong cột xếp dọc.
Trả về (toạ độ từng node, lớp của từng node).
"""
indeg = {n.id: 0 for n in graph.nodes}
adj = defaultdict(list)
for e in graph.edges:
if e.target in indeg:
indeg[e.target] += 1
adj[e.source].append(e.target)
roots = [n.id for n in graph.nodes if indeg.get(n.id, 0) == 0]
if not roots and graph.nodes:
roots = [graph.nodes[0].id]
level: Dict[str, int] = {}
dq = deque()
for r in roots:
level[r] = 0
dq.append(r)
while dq:
cur = dq.popleft()
for nxt in adj[cur]:
if nxt not in level:
level[nxt] = level[cur] + 1
dq.append(nxt)
for n in graph.nodes:
level.setdefault(n.id, 0)
by_level: Dict[int, List[str]] = defaultdict(list)
for n in graph.nodes:
by_level[level[n.id]].append(n.id)
pos: Dict[str, Tuple[int, int]] = {}
for lv in sorted(by_level):
for i, nid in enumerate(by_level[lv]):
pos[nid] = (lv * col_w, i * row_h)
return pos, level
def _networkx_layout(graph: StructureGraph, width: int, height: int):
"""Use networkx spring layout when available (better for heavy graphs)."""
try:
import networkx as nx
except ImportError:
return None
try:
g = nx.Graph()
g.add_nodes_from(n.id for n in graph.nodes)
g.add_edges_from((e.source, e.target) for e in graph.edges)
if g.number_of_nodes() == 0:
return None
pos = nx.spring_layout(g, seed=42) # deterministic
scale = min(width, height) * 0.45
out = {}
for nid, (x, y) in pos.items():
out[nid] = (int(width / 2 + x * scale), int(height / 2 + y * scale))
return out
except Exception:
return None
def force_layout(graph: StructureGraph, width: int = 1600, height: int = 1000) -> Dict[str, Tuple[int, int]]:
"""Layout positions. Prefers networkx spring layout when installed
(faster/nicer for heavy graphs); otherwise a deterministic pure-Python
Fruchterman-Reingold fallback (no extra dependency).
"""
import math
nx_pos = _networkx_layout(graph, width, height)
if nx_pos is not None:
return nx_pos
ids = [n.id for n in graph.nodes]
n = len(ids) or 1
k = math.sqrt((width * height) / n)
# deterministic initial placement on a circle
pos = {}
for i, nid in enumerate(ids):
ang = 2 * math.pi * i / n
pos[nid] = [width / 2 + (width / 3) * math.cos(ang),
height / 2 + (height / 3) * math.sin(ang)]
edges = [(e.source, e.target) for e in graph.edges if e.source in pos and e.target in pos]
iterations = min(220, max(20, 7000 // n))
t = width / 10.0
for _ in range(iterations):
disp = {nid: [0.0, 0.0] for nid in ids}
for i in range(n):
a = ids[i]
ax, ay = pos[a]
for j in range(i + 1, n):
b = ids[j]
dx = ax - pos[b][0]
dy = ay - pos[b][1]
dist = math.hypot(dx, dy) or 0.01
force = k * k / dist
ux, uy = dx / dist, dy / dist
disp[a][0] += ux * force
disp[a][1] += uy * force
disp[b][0] -= ux * force
disp[b][1] -= uy * force
for a, b in edges:
dx = pos[a][0] - pos[b][0]
dy = pos[a][1] - pos[b][1]
dist = math.hypot(dx, dy) or 0.01
force = dist * dist / k
ux, uy = dx / dist, dy / dist
disp[a][0] -= ux * force
disp[a][1] -= uy * force
disp[b][0] += ux * force
disp[b][1] += uy * force
for nid in ids:
dx, dy = disp[nid]
d = math.hypot(dx, dy) or 0.01
pos[nid][0] += dx / d * min(d, t)
pos[nid][1] += dy / d * min(d, t)
t *= 0.95
return {nid: (int(p[0]), int(p[1])) for nid, p in pos.items()}
NODE_KIND_COLORS = {
"dir": "#64748b",
"file": "#3b82f6",
"class": "#f37021",
"function": "#22a06b",
"method": "#8b5cf6",
"module": "#eab308",
"section": "#ec4899",
"json_key": "#06b6d4",
}
# The relationship (edge) types the builders emit, each with a distinct colour
# so the graph doesn't just show anonymous lines — every edge now carries a
# defined, colour-coded meaning (shown as a label on the edge + in the legend).
# contains — a folder/file holds another folder/file (tree structure)
# defines — a file/module defines a class or top-level function
# method — a class owns a method
# imports — a module imports another module
# subsection — a doc/JSON section nests a subsection/key
EDGE_KIND_COLORS = {
"contains": "#7c8aa0",
"defines": "#22a06b",
"method": "#8b5cf6",
"imports": "#eab308",
"subsection": "#ec4899",
}