#!/usr/bin/env python3 """activity.py — append-only "AI activity" ledger cho dashboard + bot. Nguồn dữ liệu cho card "Hoạt động AI": skills ghi 1 dòng event mỗi khi làm việc. Store company_kb/_activity.json (company-shared, giữ qua update). Append-only, cap ~500 event (trim cũ nhất) để bound size + dashboard load nhanh. Ghi atomic (.tmp + os.replace). python3 lib/activity.py log [--status ok|error|skip] [--meta ''] python3 lib/activity.py recent [--limit 20] -> {events:[...]} mới nhất trước python3 lib/activity.py summary -> {total,last_ts,by_skill,by_day,recent} Stdlib only. user_id resolve như identity.py/kb.py (None -> "system" cho cron). `recent`/`summary` chỉ-đọc (an toàn cho API). `log` KHÔNG expose ra web. """ import os import re import sys import json import datetime PLATFORM_PREFIX = {"telegram": "tg", "slack": "sl", "discord": "dc", "whatsapp": "wa"} CAP = 500 STATUSES = {"ok", "error", "skip"} def _bundle_root(): return os.path.dirname(os.path.dirname(os.path.abspath(__file__))) ROOT = _bundle_root() KB = os.path.join(ROOT, "company_kb") LEDGER = os.path.join(KB, "_activity.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 "system" 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 "system" def _safe(s): return re.sub(r"[^a-z0-9_-]", "", (s or "").strip().lower()) def _load(): try: with open(LEDGER, encoding="utf-8") as f: d = json.load(f) if isinstance(d, dict) and isinstance(d.get("events"), list): return d except Exception: pass return {"version": 1, "events": []} def _save(d): os.makedirs(KB, exist_ok=True) tmp = LEDGER + ".tmp" with open(tmp, "w", encoding="utf-8") as f: json.dump(d, f, ensure_ascii=False, indent=2) os.replace(tmp, LEDGER) def log(skill, action, status="ok", meta=None): skill = _safe(skill) or "unknown" action = _safe(action) or "event" status = status if status in STATUSES else "ok" if isinstance(meta, str): try: meta = json.loads(meta) except Exception: meta = {"_raw": meta[:200]} if not isinstance(meta, dict): meta = {} ev = {"ts": datetime.datetime.now().isoformat(timespec="seconds"), "user_id": resolve_user(), "skill": skill, "action": action, "status": status, "meta": meta} d = _load() d["events"].append(ev) if len(d["events"]) > CAP: d["events"] = d["events"][-CAP:] _save(d) return {"ok": True, "logged": {"skill": skill, "action": action, "status": status}} def recent(limit=20): try: limit = max(1, min(int(limit), CAP)) except Exception: limit = 20 evs = _load()["events"] return {"ok": True, "events": list(reversed(evs))[:limit]} def summary(): evs = _load()["events"] by_skill, by_day = {}, {} for e in evs: by_skill[e.get("skill", "?")] = by_skill.get(e.get("skill", "?"), 0) + 1 day = (e.get("ts") or "")[:10] if day: by_day[day] = by_day.get(day, 0) + 1 return {"ok": True, "total": len(evs), "last_ts": evs[-1]["ts"] if evs else None, "by_skill": by_skill, "by_day": by_day, "recent": list(reversed(evs))[:5]} def main(): a = sys.argv[1:] cmd = a[0] if a else "summary" def opt(name, default=None): return a[a.index(name) + 1] if name in a and a.index(name) + 1 < len(a) else default if cmd == "log": out = log(a[1] if len(a) > 1 else "", a[2] if len(a) > 2 else "", opt("--status", "ok"), opt("--meta")) elif cmd == "recent": out = recent(opt("--limit", 20)) elif cmd == "summary": out = summary() 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())