Spaces:
Running
Running
Download yui_code_graph.py from BachDaThan/Ural-AI: direct link, hf CLI and curl.
- Browser
- Download file 26.8 kB
-
https://huggingface.co/spaces/BachDaThan/Ural-AI/resolve/main/yui_code_graph.py
- Command line
-
hf download hf://spaces/BachDaThan/Ural-AI/yui_code_graph.py
-
curl -L -o yui_code_graph.py https://huggingface.co/spaces/BachDaThan/Ural-AI/resolve/main/yui_code_graph.py
26.8 kB
| # ================================================================ | |
| # yui_code_graph.py — CODE KNOWLEDGE GRAPH v1.0 | |
| # Inspired by CodeGraph (Colby McInny) — adapted for Yui/free-tier | |
| # | |
| # Quét toàn bộ Python project → build Knowledge Graph → SQLite | |
| # Khi AI cần hiểu code, query graph thay vì đọc lại file từ đầu | |
| # | |
| # HIỆU QUẢ (theo CodeGraph research): | |
| # • Tiết kiệm ~59% token (không đọc lại file) | |
| # • Giảm ~70% tool calls (1 query thay vì scan nhiều file) | |
| # • Nhanh hơn ~49% overall | |
| # | |
| # HOÀN TOÀN FREE: | |
| # • SQLite local (không cần DB server) | |
| # • Pure Python stdlib (ast, sqlite3, pathlib) | |
| # • Không cần API, không cần key | |
| # | |
| # SCHEMA: | |
| # nodes(id, type, name, file, line, code_snippet, docstring) | |
| # edges(from_id, to_id, relation) -- calls, imports, inherits, uses | |
| # file_summaries(file, summary, last_modified, token_count) | |
| # search_index(term, node_id, weight) | |
| # ================================================================ | |
| import os, ast, re, sqlite3, time, hashlib, json | |
| from pathlib import Path | |
| from typing import Optional, List, Dict, Tuple, Any | |
| from dataclasses import dataclass, field | |
| # ─── SCHEMA ────────────────────────────────────────────────────── | |
| _SCHEMA = """ | |
| CREATE TABLE IF NOT EXISTS nodes ( | |
| id TEXT PRIMARY KEY, | |
| type TEXT NOT NULL, -- function|class|method|import|variable|constant | |
| name TEXT NOT NULL, | |
| file TEXT NOT NULL, | |
| line INTEGER, | |
| code_snippet TEXT, -- Chỉ signature, không phải toàn bộ body | |
| docstring TEXT, | |
| decorators TEXT, -- JSON list | |
| parent TEXT, -- class chứa method | |
| created_at REAL | |
| ); | |
| CREATE TABLE IF NOT EXISTS edges ( | |
| from_id TEXT NOT NULL, | |
| to_id TEXT NOT NULL, | |
| relation TEXT NOT NULL, -- calls|imports|inherits|uses|defines | |
| PRIMARY KEY (from_id, to_id, relation) | |
| ); | |
| CREATE TABLE IF NOT EXISTS file_summaries ( | |
| file TEXT PRIMARY KEY, | |
| summary TEXT, -- AI-generated hoặc auto-extract | |
| lines INTEGER, | |
| functions INTEGER, | |
| classes INTEGER, | |
| imports TEXT, -- JSON list | |
| last_modified REAL, | |
| hash TEXT | |
| ); | |
| CREATE TABLE IF NOT EXISTS search_index ( | |
| term TEXT NOT NULL, | |
| node_id TEXT NOT NULL, | |
| weight REAL DEFAULT 1.0, | |
| PRIMARY KEY (term, node_id) | |
| ); | |
| CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name); | |
| CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file); | |
| CREATE INDEX IF NOT EXISTS idx_nodes_type ON nodes(type); | |
| CREATE INDEX IF NOT EXISTS idx_edges_from ON edges(from_id); | |
| CREATE INDEX IF NOT EXISTS idx_edges_to ON edges(to_id); | |
| CREATE INDEX IF NOT EXISTS idx_search_term ON search_index(term); | |
| """ | |
| # ─── NODE ID GENERATOR ─────────────────────────────────────────── | |
| def _node_id(file: str, name: str, line: int = 0) -> str: | |
| key = f"{file}::{name}::{line}" | |
| return hashlib.md5(key.encode()).hexdigest()[:12] | |
| # ─── AST VISITOR ───────────────────────────────────────────────── | |
| class ParsedNode: | |
| nid: str | |
| ntype: str | |
| name: str | |
| file: str | |
| line: int | |
| snippet: str | |
| docstring: str | |
| decs: List[str] = field(default_factory=list) | |
| parent: str = "" | |
| calls: List[str] = field(default_factory=list) | |
| imports: List[str] = field(default_factory=list) | |
| class CodeVisitor(ast.NodeVisitor): | |
| """AST visitor tách nodes + relationships.""" | |
| def __init__(self, filepath: str, source: str): | |
| self.filepath = filepath | |
| self.source = source | |
| self.lines = source.splitlines() | |
| self.nodes: List[ParsedNode] = [] | |
| self._class_stack: List[str] = [] # stack khi vào class | |
| self._current_fn: Optional[str] = None | |
| # ── IMPORTS ────────────────────────────────────────────────── | |
| def visit_Import(self, node: ast.Import): | |
| for alias in node.names: | |
| name = alias.asname or alias.name | |
| nid = _node_id(self.filepath, f"import:{alias.name}", node.lineno) | |
| self.nodes.append(ParsedNode( | |
| nid=nid, ntype="import", name=name, | |
| file=self.filepath, line=node.lineno, | |
| snippet=f"import {alias.name}", | |
| docstring="", | |
| imports=[alias.name], | |
| )) | |
| self.generic_visit(node) | |
| def visit_ImportFrom(self, node: ast.ImportFrom): | |
| module = node.module or "" | |
| for alias in node.names: | |
| full = f"{module}.{alias.name}" if module else alias.name | |
| nid = _node_id(self.filepath, f"from:{full}", node.lineno) | |
| self.nodes.append(ParsedNode( | |
| nid=nid, ntype="import", name=alias.asname or alias.name, | |
| file=self.filepath, line=node.lineno, | |
| snippet=f"from {module} import {alias.name}", | |
| docstring="", | |
| imports=[full], | |
| )) | |
| self.generic_visit(node) | |
| # ── CLASSES ────────────────────────────────────────────────── | |
| def visit_ClassDef(self, node: ast.ClassDef): | |
| nid = _node_id(self.filepath, node.name, node.lineno) | |
| bases = [self._name(b) for b in node.bases] | |
| decs = [self._name(d) for d in node.decorator_list] | |
| doc = ast.get_docstring(node) or "" | |
| snippet = f"class {node.name}({', '.join(bases)}):" | |
| pnode = ParsedNode( | |
| nid=nid, ntype="class", name=node.name, | |
| file=self.filepath, line=node.lineno, | |
| snippet=snippet, docstring=doc[:200], | |
| decs=decs, | |
| imports=bases, # bases treated as "uses" relationship | |
| parent=self._class_stack[-1] if self._class_stack else "", | |
| ) | |
| self.nodes.append(pnode) | |
| self._class_stack.append(node.name) | |
| self.generic_visit(node) | |
| self._class_stack.pop() | |
| # ── FUNCTIONS ───────────────────────────────────────────────── | |
| def visit_FunctionDef(self, node: ast.FunctionDef): | |
| self._handle_fn(node) | |
| def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef): | |
| self._handle_fn(node, is_async=True) | |
| def _handle_fn(self, node, is_async=False): | |
| parent_class = self._class_stack[-1] if self._class_stack else "" | |
| ntype = "method" if parent_class else "function" | |
| # Build signature | |
| args = self._get_args(node.args) | |
| ret = self._name(node.returns) if node.returns else "" | |
| ret_str = f" -> {ret}" if ret else "" | |
| prefix = "async " if is_async else "" | |
| snippet = f"{prefix}def {node.name}({', '.join(args)}){ret_str}:" | |
| doc = ast.get_docstring(node) or "" | |
| decs = [self._name(d) for d in node.decorator_list] | |
| nid = _node_id(self.filepath, f"{parent_class}.{node.name}" if parent_class else node.name, node.lineno) | |
| # Tìm các hàm được gọi trong body | |
| calls = self._find_calls(node) | |
| pnode = ParsedNode( | |
| nid=nid, ntype=ntype, name=node.name, | |
| file=self.filepath, line=node.lineno, | |
| snippet=snippet, docstring=doc[:200], | |
| decs=decs, parent=parent_class, | |
| calls=calls, | |
| ) | |
| self.nodes.append(pnode) | |
| prev = self._current_fn | |
| self._current_fn = f"{parent_class}.{node.name}" if parent_class else node.name | |
| self.generic_visit(node) | |
| self._current_fn = prev | |
| # ── CONSTANTS ───────────────────────────────────────────────── | |
| def visit_Assign(self, node: ast.Assign): | |
| # Chỉ bắt module-level constants (ALL_CAPS) | |
| if not self._class_stack and not self._current_fn: | |
| for target in node.targets: | |
| if isinstance(target, ast.Name) and target.id.isupper(): | |
| try: | |
| val = ast.literal_eval(node.value) | |
| snippet = f"{target.id} = {repr(val)[:60]}" | |
| except: | |
| snippet = f"{target.id} = ..." | |
| nid = _node_id(self.filepath, target.id, node.lineno) | |
| self.nodes.append(ParsedNode( | |
| nid=nid, ntype="constant", name=target.id, | |
| file=self.filepath, line=node.lineno, | |
| snippet=snippet, docstring="", | |
| )) | |
| self.generic_visit(node) | |
| # ── HELPERS ─────────────────────────────────────────────────── | |
| def _name(self, node) -> str: | |
| if node is None: return "" | |
| if isinstance(node, ast.Name): return node.id | |
| if isinstance(node, ast.Attribute): return f"{self._name(node.value)}.{node.attr}" | |
| if isinstance(node, ast.Constant): return repr(node.value) | |
| if isinstance(node, ast.Subscript): return f"{self._name(node.value)}[...]" | |
| return ast.dump(node)[:30] | |
| def _get_args(self, args: ast.arguments) -> List[str]: | |
| result = [] | |
| for arg in args.args: | |
| ann = f": {self._name(arg.annotation)}" if arg.annotation else "" | |
| result.append(f"{arg.arg}{ann}") | |
| if args.vararg: | |
| result.append(f"*{args.vararg.arg}") | |
| if args.kwarg: | |
| result.append(f"**{args.kwarg.arg}") | |
| return result[:6] # Max 6 args in snippet | |
| def _find_calls(self, fn_node) -> List[str]: | |
| calls = [] | |
| for node in ast.walk(fn_node): | |
| if isinstance(node, ast.Call): | |
| name = self._name(node.func) | |
| if name and '.' not in name[:1] and name not in calls: | |
| calls.append(name) | |
| return calls[:15] | |
| # ─── FILE PARSER ───────────────────────────────────────────────── | |
| def parse_file(filepath: str) -> Tuple[List[ParsedNode], Dict]: | |
| """Parse một Python file → nodes + file summary.""" | |
| try: | |
| with open(filepath, 'r', encoding='utf-8', errors='ignore') as f: | |
| source = f.read() | |
| except Exception as e: | |
| return [], {} | |
| try: | |
| tree = ast.parse(source, filename=filepath) | |
| except SyntaxError: | |
| return [], {} | |
| visitor = CodeVisitor(filepath, source) | |
| visitor.visit(tree) | |
| # File summary | |
| all_imports = [] | |
| fn_count = 0 | |
| cls_count = 0 | |
| for n in visitor.nodes: | |
| if n.ntype == "import": | |
| all_imports.extend(n.imports) | |
| elif n.ntype in ("function", "method"): | |
| fn_count += 1 | |
| elif n.ntype == "class": | |
| cls_count += 1 | |
| summary = { | |
| "file": filepath, | |
| "lines": len(source.splitlines()), | |
| "functions":fn_count, | |
| "classes": cls_count, | |
| "imports": list(set(all_imports))[:20], | |
| "last_modified": os.path.getmtime(filepath), | |
| "hash": hashlib.md5(source[:2000].encode()).hexdigest()[:8], | |
| } | |
| return visitor.nodes, summary | |
| # ─── GRAPH BUILDER ─────────────────────────────────────────────── | |
| class CodeGraph: | |
| """ | |
| Knowledge Graph cho Python codebase. | |
| Lưu vào SQLite, query nhanh không cần đọc file. | |
| """ | |
| def __init__(self, db_path: str = "/tmp/yui_code_graph.db"): | |
| self.db_path = db_path | |
| self._conn = None | |
| self._init_db() | |
| def _conn_(self) -> sqlite3.Connection: | |
| if not self._conn: | |
| self._conn = sqlite3.connect(self.db_path, check_same_thread=False) | |
| self._conn.row_factory = sqlite3.Row | |
| self._conn.execute("PRAGMA journal_mode=WAL") | |
| self._conn.execute("PRAGMA synchronous=NORMAL") | |
| return self._conn | |
| def _init_db(self): | |
| conn = self._conn_() | |
| conn.executescript(_SCHEMA) | |
| conn.commit() | |
| print(f"[CODEGRAPH] DB: {self.db_path}") | |
| # ── BUILD / UPDATE ──────────────────────────────────────────── | |
| def scan_directory( | |
| self, | |
| root: str, | |
| extensions: List[str] = [".py"], | |
| exclude_dirs: List[str] = ["__pycache__", ".git", "venv", "node_modules", ".pyc"], | |
| max_file_kb: int = 500, | |
| ) -> Dict[str, int]: | |
| """ | |
| Quét toàn bộ directory → build graph. | |
| Returns: stats dict | |
| """ | |
| stats = {"files": 0, "nodes": 0, "edges": 0, "skipped": 0} | |
| root_path = Path(root) | |
| py_files = [] | |
| for ext in extensions: | |
| for f in root_path.rglob(f"*{ext}"): | |
| skip = False | |
| for ex in exclude_dirs: | |
| if ex in str(f): | |
| skip = True; break | |
| if not skip and f.stat().st_size < max_file_kb * 1024: | |
| py_files.append(str(f)) | |
| print(f"[CODEGRAPH] Tìm thấy {len(py_files)} files, đang quét...") | |
| for fp in py_files: | |
| try: | |
| n_new, n_updated = self._index_file(fp) | |
| stats["files"] += 1 | |
| stats["nodes"] += n_new | |
| except Exception as e: | |
| stats["skipped"] += 1 | |
| print(f"[CODEGRAPH] Skip {fp}: {e}") | |
| stats["edges"] = self._conn_().execute("SELECT COUNT(*) FROM edges").fetchone()[0] | |
| print(f"[CODEGRAPH] ✅ Scan xong: {stats}") | |
| return stats | |
| def _index_file(self, filepath: str) -> Tuple[int, int]: | |
| """Index một file. Trả về (nodes_added, nodes_updated).""" | |
| conn = self._conn_() | |
| # Check if file changed | |
| try: | |
| mtime = os.path.getmtime(filepath) | |
| row = conn.execute( | |
| "SELECT last_modified, hash FROM file_summaries WHERE file=?", | |
| (filepath,) | |
| ).fetchone() | |
| if row: | |
| with open(filepath,'r',encoding='utf-8',errors='ignore') as f: | |
| src = f.read() | |
| h = hashlib.md5(src[:2000].encode()).hexdigest()[:8] | |
| if abs(row["last_modified"] - mtime) < 1 and row["hash"] == h: | |
| return 0, 0 # File chưa thay đổi | |
| except: | |
| pass | |
| nodes, summary = parse_file(filepath) | |
| # Clear old data for this file | |
| old_ids = [r[0] for r in conn.execute( | |
| "SELECT id FROM nodes WHERE file=?", (filepath,) | |
| ).fetchall()] | |
| if old_ids: | |
| conn.execute( | |
| f"DELETE FROM edges WHERE from_id IN ({','.join('?'*len(old_ids))})", | |
| old_ids | |
| ) | |
| conn.execute("DELETE FROM nodes WHERE file=?", (filepath,)) | |
| conn.execute("DELETE FROM search_index WHERE node_id IN " | |
| f"({','.join('?'*len(old_ids))})", old_ids) | |
| now = time.time() | |
| # Insert nodes | |
| for n in nodes: | |
| conn.execute(""" | |
| INSERT OR REPLACE INTO nodes | |
| (id, type, name, file, line, code_snippet, docstring, decorators, parent, created_at) | |
| VALUES (?,?,?,?,?,?,?,?,?,?) | |
| """, ( | |
| n.nid, n.ntype, n.name, n.file, n.line, | |
| n.snippet, n.docstring, | |
| json.dumps(n.decs), n.parent, now | |
| )) | |
| # Build call edges | |
| for called in n.calls: | |
| edge_to = _node_id(filepath, called, 0) | |
| conn.execute( | |
| "INSERT OR IGNORE INTO edges VALUES (?,?,?)", | |
| (n.nid, edge_to, "calls") | |
| ) | |
| # Build import edges | |
| for imp in n.imports: | |
| edge_to = _node_id(filepath, f"import:{imp}", 0) | |
| conn.execute( | |
| "INSERT OR IGNORE INTO edges VALUES (?,?,?)", | |
| (n.nid, edge_to, "imports") | |
| ) | |
| # Search index — tokenize name + docstring | |
| terms = set() | |
| for word in re.split(r'[_\s\./]+', n.name.lower()): | |
| if len(word) >= 2: terms.add(word) | |
| for word in re.split(r'\W+', (n.docstring or "").lower()): | |
| if len(word) >= 3: terms.add(word) | |
| for term in terms: | |
| conn.execute( | |
| "INSERT OR IGNORE INTO search_index VALUES (?,?,?)", | |
| (term, n.nid, 1.0) | |
| ) | |
| # File summary | |
| conn.execute(""" | |
| INSERT OR REPLACE INTO file_summaries | |
| (file, summary, lines, functions, classes, imports, last_modified, hash) | |
| VALUES (?,?,?,?,?,?,?,?) | |
| """, ( | |
| summary.get("file",""), "", | |
| summary.get("lines",0), summary.get("functions",0), | |
| summary.get("classes",0), json.dumps(summary.get("imports",[])), | |
| summary.get("last_modified", now), summary.get("hash",""), | |
| )) | |
| conn.commit() | |
| return len(nodes), 0 | |
| # ── QUERY API ──────────────────────────────────────────────── | |
| def find(self, name: str, ntype: str = "") -> List[Dict]: | |
| """Tìm node theo tên. Hỗ trợ partial match.""" | |
| conn = self._conn_() | |
| where = "WHERE LOWER(name) LIKE ?" | |
| args = [f"%{name.lower()}%"] | |
| if ntype: | |
| where += " AND type=?" | |
| args.append(ntype) | |
| rows = conn.execute( | |
| f"SELECT * FROM nodes {where} ORDER BY LENGTH(name) LIMIT 15", args | |
| ).fetchall() | |
| return [dict(r) for r in rows] | |
| def who_calls(self, func_name: str) -> List[Dict]: | |
| """Ai gọi hàm này?""" | |
| conn = self._conn_() | |
| rows = conn.execute(""" | |
| SELECT n.* FROM nodes n | |
| JOIN edges e ON e.from_id = n.id | |
| WHERE e.relation='calls' | |
| AND e.to_id IN ( | |
| SELECT id FROM nodes WHERE LOWER(name)=LOWER(?) | |
| ) | |
| LIMIT 20 | |
| """, (func_name,)).fetchall() | |
| return [dict(r) for r in rows] | |
| def what_calls(self, func_name: str) -> List[Dict]: | |
| """Hàm này gọi những gì?""" | |
| conn = self._conn_() | |
| row = conn.execute( | |
| "SELECT id FROM nodes WHERE LOWER(name)=LOWER(?) LIMIT 1", | |
| (func_name,) | |
| ).fetchone() | |
| if not row: return [] | |
| rows = conn.execute(""" | |
| SELECT n.* FROM nodes n | |
| JOIN edges e ON e.to_id = n.id | |
| WHERE e.from_id=? AND e.relation='calls' | |
| LIMIT 20 | |
| """, (row["id"],)).fetchall() | |
| return [dict(r) for r in rows] | |
| def file_overview(self, filepath: str) -> Dict: | |
| """Tổng quan 1 file: functions, classes, imports.""" | |
| conn = self._conn_() | |
| summary = conn.execute( | |
| "SELECT * FROM file_summaries WHERE file LIKE ?", | |
| (f"%{filepath}%",) | |
| ).fetchone() | |
| nodes = conn.execute( | |
| "SELECT type, name, line, code_snippet FROM nodes WHERE file LIKE ? ORDER BY line", | |
| (f"%{filepath}%",) | |
| ).fetchall() | |
| return { | |
| "summary": dict(summary) if summary else {}, | |
| "nodes": [dict(n) for n in nodes], | |
| } | |
| def search(self, query: str, limit: int = 10) -> List[Dict]: | |
| """Full-text search trên name + docstring.""" | |
| conn = self._conn_() | |
| terms = [t for t in re.split(r'\W+', query.lower()) if len(t) >= 2] | |
| if not terms: return [] | |
| # Score nodes by how many terms match | |
| placeholders = ",".join("?" * len(terms)) | |
| rows = conn.execute(f""" | |
| SELECT n.*, SUM(si.weight) as score | |
| FROM search_index si | |
| JOIN nodes n ON n.id = si.node_id | |
| WHERE si.term IN ({placeholders}) | |
| GROUP BY n.id | |
| ORDER BY score DESC, LENGTH(n.name) | |
| LIMIT ? | |
| """, terms + [limit]).fetchall() | |
| return [dict(r) for r in rows] | |
| def get_context_for_query(self, user_query: str, max_tokens: int = 2000) -> str: | |
| """ | |
| Main API cho AI: Nhận query → trả về context từ graph. | |
| Thay thế việc đọc lại file từ đầu. | |
| """ | |
| results = self.search(user_query, limit=12) | |
| if not results: | |
| # Fallback: exact name match | |
| for word in re.split(r'\W+', user_query): | |
| if len(word) >= 4: | |
| results = self.find(word) | |
| if results: break | |
| if not results: | |
| return "" | |
| lines = ["[CODEGRAPH CONTEXT]"] | |
| token_estimate = 0 | |
| # Group by file | |
| by_file: Dict[str, List] = {} | |
| for r in results: | |
| f = r.get("file", "?") | |
| by_file.setdefault(f, []).append(r) | |
| for filepath, nodes in by_file.items(): | |
| fname = os.path.basename(filepath) | |
| lines.append(f"\n📄 {fname}") | |
| for n in nodes[:5]: | |
| snippet = n.get("code_snippet", "") | |
| doc = n.get("docstring", "") | |
| line = n.get("line", 0) | |
| lines.append(f" L{line} [{n['type']}] {snippet}") | |
| if doc: | |
| lines.append(f" → {doc[:80]}") | |
| token_estimate += len(snippet) // 4 + 5 | |
| if token_estimate > max_tokens: | |
| break | |
| if token_estimate > max_tokens: | |
| break | |
| lines.append(f"\n[{len(results)} nodes found | ~{token_estimate} tokens]") | |
| return "\n".join(lines) | |
| def stats(self) -> Dict: | |
| """Stats của graph.""" | |
| conn = self._conn_() | |
| return { | |
| "nodes": conn.execute("SELECT COUNT(*) FROM nodes").fetchone()[0], | |
| "edges": conn.execute("SELECT COUNT(*) FROM edges").fetchone()[0], | |
| "files": conn.execute("SELECT COUNT(*) FROM file_summaries").fetchone()[0], | |
| "functions": conn.execute("SELECT COUNT(*) FROM nodes WHERE type='function'").fetchone()[0], | |
| "methods": conn.execute("SELECT COUNT(*) FROM nodes WHERE type='method'").fetchone()[0], | |
| "classes": conn.execute("SELECT COUNT(*) FROM nodes WHERE type='class'").fetchone()[0], | |
| "db_size_kb": os.path.getsize(self.db_path) // 1024 if os.path.exists(self.db_path) else 0, | |
| } | |
| def export_summary(self) -> str: | |
| """Tạo text summary cho AI về toàn bộ codebase.""" | |
| conn = self._conn_() | |
| files = conn.execute( | |
| "SELECT * FROM file_summaries ORDER BY functions DESC LIMIT 20" | |
| ).fetchall() | |
| lines = ["=== CODEBASE OVERVIEW ==="] | |
| for f in files: | |
| fname = os.path.basename(f["file"]) | |
| lines.append( | |
| f" {fname}: {f['lines']}L, {f['functions']} funcs, {f['classes']} classes" | |
| ) | |
| top_fns = conn.execute(""" | |
| SELECT name, file, line FROM nodes | |
| WHERE type IN ('function','method') | |
| ORDER BY rowid DESC LIMIT 20 | |
| """).fetchall() | |
| lines.append("\n=== KEY FUNCTIONS ===") | |
| for fn in top_fns: | |
| lines.append(f" {fn['name']}() @ {os.path.basename(fn['file'])}:{fn['line']}") | |
| return "\n".join(lines) | |
| # ─── SINGLETON + AUTO-SCAN ──────────────────────────────────────── | |
| _graph: Optional[CodeGraph] = None | |
| _last_scan: float = 0 | |
| _SCAN_INTERVAL = 300 # Re-scan mỗi 5 phút | |
| def get_graph() -> CodeGraph: | |
| """Lấy singleton graph, tự scan nếu cần.""" | |
| global _graph, _last_scan | |
| if _graph is None: | |
| _graph = CodeGraph(db_path=os.environ.get( | |
| "CODEGRAPH_DB", "/tmp/yui_code_graph.db" | |
| )) | |
| return _graph | |
| def ensure_indexed(root: str = "/app", force: bool = False): | |
| """Đảm bảo codebase đã được index. Gọi lúc startup.""" | |
| global _last_scan | |
| g = get_graph() | |
| now = time.time() | |
| if force or (now - _last_scan) > _SCAN_INTERVAL: | |
| stats = g.scan_directory(root) | |
| _last_scan = now | |
| return stats | |
| return g.stats() | |
| # ─── PUBLIC QUERY API ───────────────────────────────────────────── | |
| def query_graph(user_query: str, max_tokens: int = 1500) -> str: | |
| """ | |
| Entry point cho ural_code_engine.py. | |
| Trả về context string để inject vào code prompt. | |
| """ | |
| g = get_graph() | |
| if g.stats()["nodes"] == 0: | |
| return "" | |
| return g.get_context_for_query(user_query, max_tokens) | |
| def graph_stats_string() -> str: | |
| """Stats string ngắn gọn cho /status command.""" | |
| g = get_graph() | |
| s = g.stats() | |
| return ( | |
| f"📊 CodeGraph: {s['nodes']} nodes | {s['edges']} edges | " | |
| f"{s['files']} files | {s['db_size_kb']}KB SQLite" | |
| ) | |
| # ─── STARTUP INDEX ─────────────────────────────────────────────── | |
| import threading | |
| def _background_index(): | |
| """Index codebase trong background khi startup.""" | |
| time.sleep(8) # Đợi app khởi động xong | |
| try: | |
| root = os.environ.get("CODEGRAPH_ROOT", "/app") | |
| if not os.path.exists(root): | |
| root = os.path.dirname(os.path.abspath(__file__)) | |
| stats = ensure_indexed(root) | |
| print(f"[CODEGRAPH] Background index xong: {stats}") | |
| except Exception as e: | |
| print(f"[CODEGRAPH] Background index lỗi: {e}") | |
| _bg_thread = threading.Thread(target=_background_index, daemon=True) | |
| _bg_thread.start() | |
| print("✅ [CODEGRAPH] Knowledge Graph v1.0 loaded!") | |
| print(" Storage: SQLite (local, free)") | |
| print(" API: query_graph(query) → context string") | |
| print(" Background indexing started...") | |