#!/usr/bin/env python3 """kb_graph.py — đồ thị tri thức company_kb (kiểu Obsidian) cho dashboard. CHỈ-ĐỌC. nodes = mỗi file .md trong company_kb; edges từ 3 nguồn: 1) [[wikilink]] giữa note, 2) cấu trúc: business// → node framework (gom cụm canvas), 3) #tag chung (nối các note cùng tag, bỏ tag quá phổ biến). CLI: python3 lib/kb_graph.py graph -> {ok, nodes, edges, stats} python3 lib/kb_graph.py note -> {ok, id, title, body} """ import os import re import sys import json def _bundle_root(): # Cập nhật đường dẫn gốc về workspace của CEO để tìm thấy company_kb # File hiện tại: /opt/ai-os/core/lib/kb_graph.py # Gốc cần trỏ về: /opt/ai-os/products/ceo # Thay vì lùi 2 cấp về /opt/ai-os/core, ta lùi 3 cấp rồi vào products/ceo core_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) ai_os_dir = os.path.dirname(core_dir) return os.path.join(ai_os_dir, "products", "ceo") # Global variables and configuration ROOT = _bundle_root() KB = os.path.join(ROOT, "company_kb") SKIP = {"readme", "_index", "_profile"} # file meta/sinh tự động — bỏ khỏi graph WIKILINK = re.compile(r"\[\[([^\]\|#]+)(?:[#\|][^\]]*)?\]\]") MDLINK = re.compile(r"\[[^\]]*\]\(([^)\s]+\.md)\)") # OKF: [text](path.md) TAG = re.compile(r"(?:^|\s)#([A-Za-z][A-Za-z0-9_\-/]+)") def _rel_id(path): rel = os.path.relpath(path, KB).replace(os.sep, "/") return rel[:-3] if rel.endswith(".md") else rel def _title(path, nid): try: for ln in open(path, encoding="utf-8"): s = ln.strip() if s.startswith("#"): return s.lstrip("# ").strip() or nid.split("/")[-1] except Exception: pass return nid.split("/")[-1] def _body(path): try: t = open(path, encoding="utf-8").read() except Exception: return "" if t.startswith("---"): p = t.split("---", 2) if len(p) == 3: t = p[2] return t.lstrip("\n") def _walk(): if not os.path.isdir(KB): return for dp, _, fns in os.walk(KB): for fn in fns: if fn.endswith(".md"): yield os.path.join(dp, fn) def build(): nodes, by_base, raw = {}, {}, {} for f in _walk(): nid = _rel_id(f) base = nid.split("/")[-1].lower() if base in SKIP: continue # Exclude raw notebooklm_ingest files to keep graph clean if nid.startswith("notebooklm_ingest/"): continue # Group by sub-categories under knowledge/ parts = nid.split("/") if len(parts) >= 2 and parts[0] == "knowledge": grp = parts[1] else: grp = parts[0] if "/" in nid else "note" nodes[nid] = {"id": nid, "label": _title(f, nid), "group": grp, "kind": "note", "deg": 0} by_base.setdefault(base, nid) raw[nid] = _body(f) edges, seen = [], set() def add_edge(s, t, kind): if s == t or s not in nodes or t not in nodes: return k = tuple(sorted((s, t))) + (kind,) if k in seen: return seen.add(k) edges.append({"s": s, "t": t, "kind": kind}) nodes[s]["deg"] += 1 nodes[t]["deg"] += 1 # 1) liên kết — Obsidian [[wikilink]] + OKF [text](path.md). Path = danh tính. def _resolve(tgt): tgt = tgt.strip().split("#")[0].split("|")[0].strip().strip("/") tgt = re.sub(r"\.md$", "", tgt) if not tgt: return None if tgt in nodes: # khớp path đầy đủ (vd business/bmc/customers) return tgt return by_base.get(tgt.split("/")[-1].lower()) # hoặc theo basename for nid, body in raw.items(): for m in list(WIKILINK.findall(body)) + list(MDLINK.findall(body)): t = _resolve(m) if t: add_edge(nid, t, "link") # 2) cấu trúc business// → node framework fw_new = {} for nid in list(nodes): p = nid.split("/") if len(p) >= 3 and p[0] == "business": fwid = "business/%s" % p[1] if fwid not in nodes: fw_new[fwid] = {"id": fwid, "label": p[1], "group": "framework", "kind": "framework", "deg": 0} nodes.update(fw_new) for nid in list(nodes): p = nid.split("/") if len(p) >= 3 and p[0] == "business": add_edge(nid, "business/%s" % p[1], "structure") # 3) tag chung tag_map = {} for nid, body in raw.items(): for tg in set(TAG.findall(body)): tag_map.setdefault(tg.lower(), []).append(nid) for ids in tag_map.values(): if 2 <= len(ids) <= 12: for i in range(len(ids)): for j in range(i + 1, len(ids)): add_edge(ids[i], ids[j], "tag") ns = list(nodes.values()) return {"ok": True, "nodes": ns, "edges": edges, "stats": {"notes": sum(1 for n in ns if n["kind"] == "note"), "frameworks": sum(1 for n in ns if n["kind"] == "framework"), "edges": len(edges)}} def note(nid): rel = re.sub(r"\.\.+", "", (nid or "").strip().strip("/")) target = os.path.realpath(os.path.join(KB, rel + ".md")) base = os.path.realpath(KB) if not (target == base or target.startswith(base + os.sep)) or not os.path.isfile(target): return {"ok": False, "error": "not_found"} rid = _rel_id(target) return {"ok": True, "id": rid, "title": _title(target, rid), "body": _body(target).strip()} def main(): a = sys.argv[1:] cmd = a[0] if a else "graph" if cmd == "graph": out = build() elif cmd == "note": out = note(a[1] if len(a) > 1 else "") else: out = {"ok": False, "error": "unknown cmd"} print(json.dumps(out, ensure_ascii=False)) return 0 if __name__ == "__main__": sys.exit(main())