"""personas.py — persona registry loader + /mentor, /experts commands (P4). Reads ``personas/*.yaml`` (the thin manifests that compose personality + skills + content + state slot + tiers + access) and turns the registry — until now only human/tooling-readable — into a runtime: list the roster, and let a user pick the active persona. Selection is recorded in the per-user registry (active_persona); the actual voice overlay is Hermes' own /personality (the persona's personality id). Filtering is by org_role AND tier: the instance tier comes from BUILD.txt (written by build_client) and a persona shows only if its ``tiers`` intersect the tier's extends-chain — so a `starter` instance won't list `starter_plus+` experts ("ghost" mentors). If BUILD.txt is absent (dev/source), tier filtering is skipped (show all). """ from __future__ import annotations import os import glob import unicodedata from typing import Any, Dict, List, Optional from . import identity as ident def _norm(s: str) -> str: """Hạ chữ thường + bỏ dấu tiếng Việt (để khớp 'ban hang' ~ 'bán hàng', 'kotler' ~ 'Kotler').""" s = unicodedata.normalize("NFD", (s or "").lower()) s = "".join(c for c in s if unicodedata.category(c) != "Mn") return s.replace("đ", "d") def _match_personas(query: str, personas: Dict[str, dict]) -> List[str]: """Khớp THÔNG MINH query → id(s). Ưu tiên: exact id → tên/display → chủ đề (when/summary). Trả [] (không thấy), [1] (chắc chắn), hoặc [n] (mơ hồ → để caller hỏi lại).""" if query in personas: return [query] qn = _norm(query) if not qn: return [] qtoks = [t for t in qn.split() if t] name_hits: List[str] = [] topic_hits: List[str] = [] for pid, p in personas.items(): name_hay = _norm(pid + " " + (p.get("display_name") or "") + " " + (p.get("personality") or "")) topic_hay = _norm((p.get("when") or "") + " " + (p.get("summary") or "")) if qn in name_hay or any(t in name_hay for t in qtoks): name_hits.append(pid) elif qn in topic_hay or any(len(t) > 2 and t in topic_hay for t in qtoks): topic_hits.append(pid) return name_hits or topic_hits _SKIP = {"_template", "readme"} def personas_dir() -> str: return os.path.join(ident.bundle_root(), "personas") def load_personas() -> Dict[str, dict]: out: Dict[str, dict] = {} try: import yaml # Hermes ships pyyaml except Exception: return out for path in sorted(glob.glob(os.path.join(personas_dir(), "*.yaml"))): stem = os.path.splitext(os.path.basename(path))[0] if stem.lower() in _SKIP: continue try: with open(path, encoding="utf-8") as f: data = yaml.safe_load(f) or {} except Exception: continue pid = data.get("id") or stem out[pid] = data return out def _roles_allowed(p: dict) -> List[str]: return ((p.get("access") or {}).get("org_roles")) or ["ceo", "manager", "staff"] # -- tier gating ------------------------------------------------------------ def instance_tier() -> Optional[str]: """Tier of THIS instance, from BUILD.txt (written by build_client). None in dev/source.""" br = ident.bundle_root() for cand in (br, os.path.dirname(br), os.path.dirname(os.path.dirname(br))): path = os.path.join(cand, "BUILD.txt") try: for line in open(path, encoding="utf-8"): if line.strip().startswith("tier:"): return line.split(":", 1)[1].strip() or None except Exception: continue return None def tier_chain(tier: Optional[str]) -> set: """Tiers a given tier 'covers' = itself + everything it extends (transitively). Đọc tiers/.yaml `extends`. Vd pro→starter_plus→starter.""" if not tier: return set() try: import yaml except Exception: return {tier} seen: set = set() t: Optional[str] = tier tdir = os.path.join(ident.bundle_root(), "tiers") while t and t not in seen and t not in ("null", ""): seen.add(t) try: d = yaml.safe_load(open(os.path.join(tdir, t + ".yaml"), encoding="utf-8")) or {} except Exception: break t = d.get("extends") return seen def _tier_allows(p: dict, chain: set) -> bool: pt = p.get("tiers") if not pt: # persona không khai tier → mọi tier return True return bool(set(pt) & chain) def list_for_role(role: str) -> List[dict]: it = instance_tier() chain = tier_chain(it) if it else None # None → bỏ lọc tier (dev/source) out: List[dict] = [] for p in load_personas().values(): if role not in _roles_allowed(p): continue if chain is not None and not _tier_allows(p, chain): continue out.append(p) return out # -- commands --------------------------------------------------------------- def _fmt(p: dict) -> str: tiers = ", ".join(p.get("tiers") or []) status = p.get("status", "") flag = " (đang xây)" if status == "stub" else "" return "• %s — %s%s\n id: %s · gói: %s" % ( p.get("display_name") or p.get("id"), p.get("summary", ""), flag, p.get("id"), tiers) def cmd_experts(raw_args: str = "") -> str: uid = ident.resolve_user() role = ident.org_role(uid) if uid else "staff" items = list_for_role(role) if not items: return "Chưa có chuyên gia nào khả dụng cho vai trò của bạn." head = "Ban cố vấn của bạn (%d chuyên gia):" % len(items) return head + "\n\n" + "\n\n".join(_fmt(p) for p in items) + "\n\nGõ /mentor để chọn." def cmd_mentor(raw_args: str = "") -> str: uid = ident.resolve_user() if not uid: return "Chưa xác định được user (chỉ hoạt động trên gateway)." # Chuẩn hoá NBSP / khoảng trắng unicode → space; lấy TOKEN ĐẦU làm id, # phần còn lại (nếu user gõ kèm câu hỏi: "/mentor alex_hormozi ") = rest. norm = (raw_args or "").replace("\xa0", " ").replace(" ", " ").replace("​", "").strip() if not norm: return cmd_experts() parts = norm.split() arg = parts[0].strip().strip("\"'") rest = " ".join(parts[1:]).strip().strip("\"'") personas = load_personas() # Nhận diện thông minh: theo id / tên / chủ đề (không phân biệt hoa-dấu). matches = _match_personas(arg, personas) if not matches: return "Không thấy chuyên gia khớp %r. Gõ /experts để xem danh sách." % arg if len(matches) > 1: lines = "\n".join("• %s — id: %s" % ((personas[m].get("display_name") or m), m) for m in matches[:4]) return "Có vài chuyên gia khớp '%s' — ý anh/chị là ai?\n%s\n\nGõ /mentor ." % (arg, lines) arg = matches[0] p = personas[arg] role = ident.org_role(uid) if role not in _roles_allowed(p): return "Vai trò của bạn (%s) chưa được mở chuyên gia này." % role it = instance_tier() if it and not _tier_allows(p, tier_chain(it)): return "Chuyên gia này thuộc gói cao hơn (%s). Gói hiện tại: %s." % ( ", ".join(p.get("tiers") or []), it) ident.ensure_user_dir(uid) ident.upsert_index(uid, active_persona=arg) name = p.get("display_name") or arg msg = ("Đã chuyển sang %s. Từ giờ mình sẽ đồng hành ở vai trò này.\n" "(Mẹo: dùng /personality %s nếu muốn đổi giọng tương ứng.)" % (name, p.get("personality") or arg)) if rest: # user gõ kèm câu hỏi trên cùng dòng — slash nuốt mất, mời gửi lại msg += "\n\n📩 Anh/chị gửi lại câu hỏi để em trả lời theo phong cách %s nhé:\n«%s»" % (name, rest) return msg def active_persona(user_id: str) -> Optional[str]: return (ident.load_index().get("users", {}).get(user_id, {}) or {}).get("active_persona") def register(ctx) -> None: ctx.register_command("experts", handler=cmd_experts, description="ai-os: liệt kê ban cố vấn (các chuyên gia AI khả dụng).") ctx.register_command("mentor", handler=cmd_mentor, args_hint="", description="ai-os: chọn chuyên gia AI để đồng hành (vd /mentor business_mentor).")