#!/usr/bin/env python3 """kb.py — deterministic guard for the COMPANY knowledge base (shared vault). company_kb/ is a SHARED, company-level Obsidian-style vault (plain markdown), distinct from per-user state in users//. The runtime model READS it directly (tiered: open _index.md first, then the page it points to). The only WRITE the runtime is allowed is a lightweight CAPTURE: append a note to the LIVE page company_kb/notes.md — readable IMMEDIATELY, no ingest engine needed (works on Starter). It NEVER merges/cross-links; the heavy ingest (notes.md + bulk sources → structured topic pages) is done by a capable AI at admin/install time via obsidian-wiki (Pro+). Writes are admin-only (CEO) because the vault is shared across the whole team. python3 lib/kb.py status -> {ok, pages, notes, empty, message} python3 lib/kb.py search "" -> {ok, hits:[{path,title}]} (tiered-retrieval helper) python3 lib/kb.py capture "" -> append note to notes.md (admin only) (Bộ khung khởi nghiệp Starter Plus có engine RIÊNG: lib/startup_kit.py → company_kb/business/.) Stdlib only. user_id + org_role resolved the same way identity.py does (registry). """ import os import re import sys import json import glob import datetime PLATFORM_PREFIX = {"telegram": "tg", "slack": "sl", "discord": "dc", "whatsapp": "wa"} ADMIN_ROLES = {"ceo", "admin", "owner"} def _bundle_root(): # file at /lib/kb.py → root is parent of lib/ return os.path.dirname(os.path.dirname(os.path.abspath(__file__))) ROOT = _bundle_root() KB = os.path.join(ROOT, "company_kb") NOTES = os.path.join(KB, "notes.md") # live page for manual captures (readable immediately) INDEX = os.path.join(KB, "_index.md") REGISTRY = os.path.join(ROOT, "users", "_index.json") def _sess(name, default=""): return os.environ.get(name, default) or default def resolve_user(): platform = _sess("HERMES_SESSION_PLATFORM", "telegram").lower() uid = _sess("HERMES_SESSION_USER_ID") or _sess("HERMES_SESSION_CHAT_ID") if not uid: return None prefix = PLATFORM_PREFIX.get(platform) or (re.sub(r"[^a-z0-9]", "", platform)[:2] or "xx") safe = re.sub(r"[^A-Za-z0-9_-]", "", str(uid)) return ("%s_%s" % (prefix, safe)) if safe else None def _user_record(): uid = resolve_user() if not uid: return uid, {} try: with open(REGISTRY, encoding="utf-8") as f: return uid, json.load(f).get("users", {}).get(uid, {}) except Exception: return uid, {} def is_admin(): _, rec = _user_record() return str(rec.get("org_role", "")).strip().lower() in ADMIN_ROLES def _pages(): if not os.path.isdir(KB): return [] out = [] for p in glob.glob(os.path.join(KB, "**", "*.md"), recursive=True): b = os.path.basename(p) if b.startswith("_"): continue # skip _index.md + other underscore meta (notes.md IS included) out.append(p) return sorted(out) def _title(path): try: for line in open(path, encoding="utf-8"): line = line.strip() if line.startswith("#"): return line.lstrip("# ").strip() except Exception: pass return os.path.splitext(os.path.basename(path))[0] def _note_count(): if not os.path.isfile(NOTES): return 0 try: return sum(1 for ln in open(NOTES, encoding="utf-8") if ln.startswith("## ")) except Exception: return 0 def status(): structured = [p for p in _pages() if os.path.basename(p) != "notes.md"] notes = _note_count() empty = not structured and not notes if empty: msg = ("Kho tri thức công ty đang **trống**. Đây là nơi lưu tài liệu dùng chung cả team " "(quy trình, chính sách, hồ sơ sản phẩm…). Anh/chị (admin) có thể nhắn em " "\"ghi vào kho tri thức: …\" để em lưu lại — em đọc được ngay.") else: bits = [] if structured: bits.append("%d trang" % len(structured)) if notes: bits.append("%d ghi chú tay" % notes) msg = "Kho tri thức công ty có " + " + ".join(bits) + "." return {"ok": True, "pages": len(structured), "notes": notes, "empty": empty, "message": msg} def search(kw): kw = (kw or "").strip().lower() hits = [] if kw: for p in _pages(): hay = (_title(p) + "\n" + _safe_read(p)).lower() if kw in hay: hits.append({"path": os.path.relpath(p, ROOT), "title": _title(p)}) return {"ok": True, "hits": hits[:20], "message": ("Tìm thấy %d trang." % len(hits)) if hits else "Chưa có trang nào khớp trong kho tri thức công ty."} def _safe_read(path): try: return open(path, encoding="utf-8").read() except Exception: return "" def capture(text): text = (text or "").strip() if not text: return {"ok": False, "reason": "empty", "message": "Anh/chị muốn em ghi nội dung gì vào kho tri thức công ty?"} if not is_admin(): return {"ok": False, "reason": "admin_only", "message": "Kho tri thức công ty dùng chung cả team nên chỉ **admin/CEO** mới thêm được. " "Anh/chị nhờ admin ghi giúp nhé."} os.makedirs(KB, exist_ok=True) uid, rec = _user_record() now = datetime.datetime.now() author = rec.get("display_name") or uid or "?" new = not os.path.isfile(NOTES) with open(NOTES, "a", encoding="utf-8") as f: if new: f.write("# Ghi chú tri thức công ty (nạp tay)\n" "> Ghi nhanh qua chat (admin). Trợ lý đọc được ngay. " "Bản Pro+ sẽ gộp các ghi chú này vào trang có cấu trúc.\n") f.write("\n## %s — %s\n%s\n" % (now.strftime("%Y-%m-%d %H:%M"), author, text)) return {"ok": True, "file": os.path.relpath(NOTES, ROOT), "message": "Đã lưu vào kho tri thức công ty ✅ — em đọc lại được ngay. " "Anh/chị cứ nhắn thêm bất cứ điều gì muốn cả team dùng chung."} def main(): a = sys.argv[1:] cmd = a[0] if a else "status" if cmd == "status": out = status() elif cmd == "search": out = search(a[1] if len(a) > 1 else "") elif cmd == "capture": out = capture(a[1] if len(a) > 1 else "") else: out = {"ok": False, "message": "unknown cmd: %s" % cmd} print(json.dumps(out, ensure_ascii=False, indent=2)) return 0 if __name__ == "__main__": sys.exit(main())