#!/usr/bin/env python3 """startup_kit.py — deterministic engine cho "Bộ khung khởi nghiệp" (Starter Plus). Dẫn founder điền các khung khởi nghiệp (Ý tưởng → Khách/Nỗi đau → Đề xuất giá trị → Business Model Canvas → Kiểm chứng) THEO TỪNG Ô. Định nghĩa khung = content chung (content/startup_kit/manifest.json, read-only). Bản điền + trạng thái = per-company (company_kb/business/, giữ qua update). Model chỉ hỏi + chắt câu trả lời; MỌI việc ghi + đổi trạng thái do helper này làm (tất định, an toàn cho runtime model yếu). python3 lib/startup_kit.py status -> dashboard {frameworks[], filled_pct, validated_pct, next} python3 lib/startup_kit.py next -> ô kế tiếp cần điền {framework, block, question, ...} python3 lib/startup_kit.py show -> đọc 1 ô (để sửa) {status, version, body} python3 lib/startup_kit.py save "" -> ghi draft + bump version (admin only) python3 lib/startup_kit.py validate -> draft -> validated (admin only) python3 lib/startup_kit.py reopen -> validated -> draft (admin only) Stdlib only (JSON manifest, không cần pyyaml). admin/org_role resolve như identity.py/kb.py. """ import os import re import sys import json import datetime try: import activity as _activity # sibling lib (cùng core/lib) — ghi activity ledger except Exception: _activity = None try: import diagram as _diagram # sibling lib — render ảnh canvas LOCAL (sovereign) except Exception: _diagram = None PLATFORM_PREFIX = {"telegram": "tg", "slack": "sl", "discord": "dc", "whatsapp": "wa"} ADMIN_ROLES = {"ceo", "admin", "owner"} STATUSES = {"not_started", "draft", "validated"} def _bundle_root(): return os.path.dirname(os.path.dirname(os.path.abspath(__file__))) ROOT = _bundle_root() MANIFEST = os.path.join(ROOT, "content", "startup_kit", "manifest.json") KB = os.path.join(ROOT, "company_kb") BUSINESS = os.path.join(KB, "business") STATE = os.path.join(BUSINESS, "_state.json") REGISTRY = os.path.join(ROOT, "users", "_index.json") # ---------- identity / admin (same convention as kb.py) ---------- 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 _admin_block(): return {"ok": False, "reason": "admin_only", "message": "Bộ khung khởi nghiệp là của công ty — chỉ **admin/CEO** mới điền/sửa được."} def _log(action, fw, block): if _activity: try: # Ép path company_kb của startup_kit vào activity — tránh lệch khi __file__ của activity # bị resolve qua symlink lib (-> core/lib) cho ra bundle_root khác. _activity.KB = KB _activity.LEDGER = os.path.join(KB, "_activity.json") _activity.log("startup-kit", action, "ok", {"fw": fw, "block": block}) except Exception: pass # ---------- manifest ---------- def _manifest(): with open(MANIFEST, encoding="utf-8") as f: return json.load(f) def _frameworks(): """Framework status=ready, sorted by order.""" fws = [f for f in _manifest().get("frameworks", []) if f.get("status", "ready") == "ready"] return sorted(fws, key=lambda f: f.get("order", 99)) def _find(fw_id, block_id): """Return (framework, block) dicts from manifest, or (None, None).""" for f in _manifest().get("frameworks", []): if f.get("id") == fw_id: for b in f.get("blocks", []): if b.get("id") == block_id: return f, b return f, None return None, None def _safe(s): return re.sub(r"[^a-z0-9_-]", "", (s or "").strip().lower()) # ---------- state ---------- def _load_state(): try: with open(STATE, encoding="utf-8") as f: return json.load(f) except Exception: return {"version": 1, "kit": "startup_kit", "blocks": {}} def _save_state(st): os.makedirs(BUSINESS, exist_ok=True) st["updated_at"] = datetime.datetime.now().isoformat(timespec="seconds") tmp = STATE + ".tmp" with open(tmp, "w", encoding="utf-8") as f: json.dump(st, f, ensure_ascii=False, indent=2) os.replace(tmp, STATE) def _bkey(fw, block): return "%s/%s" % (fw, block) def _block_status(st, fw, block): return (st.get("blocks", {}).get(_bkey(fw, block), {}) or {}).get("status", "not_started") def _artifact_path(fw, block): base = os.path.realpath(BUSINESS) target = os.path.realpath(os.path.join(base, _safe(fw), _safe(block) + ".md")) if target != base and not target.startswith(base + os.sep): raise ValueError("path escape") return target # ---------- read helpers ---------- def _read_body(path): """Return body without YAML front-matter.""" try: txt = open(path, encoding="utf-8").read() except Exception: return "" if txt.startswith("---"): parts = txt.split("---", 2) if len(parts) == 3: return parts[2].lstrip("\n") return txt # ---------- commands ---------- def status(): st = _load_state() fws_out = [] total = filled = validated = 0 nxt = None for f in _frameworks(): blocks = sorted(f.get("blocks", []), key=lambda b: b.get("order", 99)) bout = [] for b in blocks: total += 1 s = _block_status(st, f["id"], b["id"]) if s == "validated": validated += 1; filled += 1 elif s == "draft": filled += 1 elif nxt is None: nxt = {"framework": f["id"], "block": b["id"]} bout.append({"id": b["id"], "title": b.get("title", b["id"]), "status": s, "version": (st.get("blocks", {}).get(_bkey(f["id"], b["id"]), {}) or {}).get("version", 0)}) done = sum(1 for x in bout if x["status"] in ("draft", "validated")) fws_out.append({"id": f["id"], "title": f.get("title", f["id"]), "order": f.get("order"), "intro": f.get("intro", ""), "blocks_total": len(bout), "blocks_done": done, "blocks": bout}) pct = (lambda n: int(round(100 * n / total)) if total else 0) if total and filled >= total: msg = "Đã điền hết %d ô 🎉. Giờ rà soát/validate hoặc xuất tóm tắt." % total elif filled == 0: msg = "Bộ khung khởi nghiệp còn trống. Mình bắt đầu nhé — chỉ vài câu mỗi lần." else: msg = "Đã điền %d/%d ô (%d ô đã chốt). Tiếp tục thôi." % (filled, total, validated) return {"ok": True, "kit": "startup_kit", "total": total, "filled": filled, "validated": validated, "filled_pct": pct(filled), "validated_pct": pct(validated), "next": nxt, "frameworks": fws_out, "message": msg} def nxt_cmd(): st = _load_state() prev_fw = None for f in _frameworks(): for b in sorted(f.get("blocks", []), key=lambda b: b.get("order", 99)): if _block_status(st, f["id"], b["id"]) == "not_started": first_of_fw = (prev_fw != f["id"]) return {"ok": True, "framework": f["id"], "block": b["id"], "framework_title": f.get("title"), "framework_intro": f.get("intro", ""), "framework_first": first_of_fw, "title": b.get("title", b["id"]), "question": b.get("question", ""), "hint": b.get("hint", ""), "examples": b.get("examples", []), "message": "Ô tiếp theo: " + b.get("title", b["id"])} prev_fw = f["id"] return {"ok": True, "framework": None, "block": None, "complete_fill": True, "message": "Đã điền hết các ô — giờ có thể rà soát/validate hoặc xem 'tiến độ'."} def show(fw, block): f, b = _find(fw, block) if not b: return {"ok": False, "reason": "unknown", "message": "Không có ô đó trong bộ khung."} st = _load_state() s = _block_status(st, fw, block) body = _read_body(_artifact_path(fw, block)) if s != "not_started" else "" return {"ok": True, "framework": fw, "block": block, "title": b.get("title"), "question": b.get("question", ""), "status": s, "version": (st.get("blocks", {}).get(_bkey(fw, block), {}) or {}).get("version", 0), "body": body, "message": ("Nội dung hiện tại của ô '%s'." % b.get("title")) if s != "not_started" else "Ô '%s' chưa điền." % b.get("title")} def save(fw, block, body): if not is_admin(): return _admin_block() f, b = _find(fw, block) if not b: return {"ok": False, "reason": "unknown", "message": "Không có ô đó trong bộ khung."} body = (body or "").strip() if not body: return {"ok": False, "reason": "empty", "message": "Chưa có nội dung cho ô này."} # Chống lặp heading: engine tự bọc "# ", nên bỏ heading markdown ở đầu body nếu model lỡ kèm. stripped = re.sub(r"^(?:#{1,6}\s.*\n+)+", "", body).strip() if stripped: body = stripped st = _load_state() rec = st.setdefault("blocks", {}).setdefault(_bkey(fw, block), {}) rec["version"] = int(rec.get("version", 0)) + 1 rec["status"] = "draft" # sửa lại = về draft (cần chốt lại) uid, urec = _user_record() now = datetime.datetime.now().isoformat(timespec="seconds") rec["updated_at"] = now rec["updated_by"] = urec.get("display_name") or uid or "?" rec["manifest_version"] = _manifest().get("version", "v1") path = _artifact_path(fw, block) os.makedirs(os.path.dirname(path), exist_ok=True) _y = lambda s: '"%s"' % str(s or "").replace('"', "'") fm = ("---\ntype: %s\ntitle: %s\ntags: [%s]\nframework: %s\nblock: %s\nstatus: draft\nversion: %d\n" "manifest_version: %s\nupdated_at: %s\nupdated_by: %s\n---\n" % # type/title/tags = OKF (Open Knowledge Format) (_y(f.get("title", fw)), _y(b.get("title", block)), fw, fw, block, rec["version"], rec["manifest_version"], now, rec["updated_by"])) with open(path, "w", encoding="utf-8") as fh: fh.write(fm + "# %s\n\n%s\n" % (b.get("title", block), body)) _save_state(st) _log("block_saved", fw, block) _refresh_profile() return {"ok": True, "framework": fw, "block": block, "status": "draft", "version": rec["version"], "file": os.path.relpath(path, ROOT), "message": "Đã lưu ô '%s' ✅ (bản nháp). Muốn chốt thì nói 'validate ô này'." % b.get("title")} def _set_status(fw, block, new): if not is_admin(): return _admin_block() f, b = _find(fw, block) if not b: return {"ok": False, "reason": "unknown", "message": "Không có ô đó trong bộ khung."} st = _load_state() rec = st.get("blocks", {}).get(_bkey(fw, block)) if not rec: return {"ok": False, "reason": "not_saved", "message": "Ô '%s' chưa điền nên chưa chốt được." % b.get("title")} rec["status"] = new rec["updated_at"] = datetime.datetime.now().isoformat(timespec="seconds") _save_state(st) # patch front-matter status in artifact (best-effort) try: p = _artifact_path(fw, block) txt = open(p, encoding="utf-8").read() txt = re.sub(r"(?m)^status: .*$", "status: " + new, txt, count=1) open(p, "w", encoding="utf-8").write(txt) except Exception: pass _log("block_validated" if new == "validated" else "block_reopened", fw, block) _refresh_profile() msg = ("Đã CHỐT ô '%s' ✅ — coi như đã kiểm chứng, đáng tin." % b.get("title")) if new == "validated" \ else ("Đã mở lại ô '%s' để sửa." % b.get("title")) return {"ok": True, "framework": fw, "block": block, "status": new, "message": msg} def _esc(s): return (s or "").replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """) _CANVAS_COLOR = {"not_started": "#e5e7eb", "draft": "#fde68a", "validated": "#86efac"} _CANVAS_ICON = {"not_started": "○", "draft": "◐", "validated": "●"} def canvas_dot(fw="bmc"): """Sinh chuỗi Graphviz dot (lưới khung, màu theo status) để render ảnh LOCAL. Model không tự viết dot.""" fw_def = next((x for x in _manifest().get("frameworks", []) if x.get("id") == fw), None) if not fw_def: return {"ok": False, "reason": "unknown", "message": "Không có khung '%s'." % fw} st = _load_state() cells = [(_esc(b.get("title", b["id"])), _block_status(st, fw, b["id"])) for b in sorted(fw_def.get("blocks", []), key=lambda b: b.get("order", 99))] filled = sum(1 for _, s in cells if s != "not_started") validated = sum(1 for _, s in cells if s == "validated") rows = "" for i in range(0, len(cells), 3): row = cells[i:i + 3] tds = "".join('<TD BGCOLOR="%s" WIDTH="150" HEIGHT="64">%s %s</TD>' % (_CANVAS_COLOR[s], _CANVAS_ICON[s], t) for t, s in row) tds += '<TD BORDER="0"></TD>' * (3 - len(row)) rows += "<TR>%s</TR>" % tds header = "%s — %d/%d ô (%d đã chốt)" % (_esc(fw_def.get("title", fw)), filled, len(cells), validated) label = ('<<TABLE BORDER="0" CELLBORDER="1" CELLSPACING="4" CELLPADDING="6">' '<TR><TD COLSPAN="3" BORDER="0"><B>%s</B></TD></TR>%s' '<TR><TD COLSPAN="3" BORDER="0"><FONT POINT-SIZE="10">' '○ chưa điền ◐ nháp ● đã chốt</FONT></TD></TR></TABLE>>') % (header, rows) dot = 'digraph G { bgcolor="white"; node [shape=plaintext fontname="DejaVu Sans"]; c [label=%s]; }' % label return {"ok": True, "framework": fw, "dot": dot, "filled": filled, "total": len(cells), "validated": validated} def plan(): """Toàn bộ kế hoạch: mỗi khung + mỗi ô kèm NỘI DUNG đã điền (cho dashboard drill-down). Chỉ-đọc; phục vụ qua dashboard có auth (chủ DN xem chính kế hoạch của mình).""" st = _load_state() fws = [] for f in _frameworks(): blocks = [] for b in sorted(f.get("blocks", []), key=lambda b: b.get("order", 99)): s = _block_status(st, f["id"], b["id"]) body = _read_body(_artifact_path(f["id"], b["id"])) if s != "not_started" else "" blocks.append({"id": b["id"], "title": b.get("title", b["id"]), "question": b.get("question", ""), "status": s, "body": body.strip()}) fws.append({"id": f["id"], "title": f.get("title", f["id"]), "intro": f.get("intro", ""), "about": f.get("about"), "blocks": blocks}) return {"ok": True, "frameworks": fws} def canvas_image(fw="bmc"): """1 lệnh cho skill: sinh dot + render PNG LOCAL → trả path (gửi qua MEDIA). Lỗi → skill gửi text.""" cd = canvas_dot(fw) if not cd.get("ok"): return cd if _diagram is None: return {"ok": False, "reason": "no_renderer", "message": "Chưa render được ảnh — em gửi tóm tắt bằng chữ nhé."} try: path = _diagram.save_png(_diagram.render_local_dot(cd["dot"])) except Exception as e: err = str(e) return {"ok": False, "reason": "no_local_renderer" if err.startswith("no_local_renderer") else "render_error", "error": err, "message": "Chưa render được ảnh (cần graphviz). Em gửi tóm tắt bằng chữ thay nhé."} return {"ok": True, "path": path, "framework": fw, "filled": cd["filled"], "total": cd["total"], "validated": cd["validated"], "message": "Canvas: %d/%d ô (%d đã chốt)." % (cd["filled"], cd["total"], cd["validated"])} # ---------- company profile (hồ sơ công ty cho AI + quản trị) ---------- STALE_DAYS = 180 # ô đã điền > 6 tháng → gắn cờ "cần review" CD_PATH = os.path.join(ROOT, "content", "customer_development.json") PROFILE_MD = os.path.join(BUSINESS, "_profile.md") def _days_since(iso): if not iso: return None try: return (datetime.datetime.now() - datetime.datetime.fromisoformat(iso)).days except Exception: return None def _excerpt(body, n=160): t = re.sub(r"^(?:#{1,6}\s.*\n+)+", "", (body or "").strip()) # bỏ heading "# <title>" engine tự bọc t = re.sub(r"\s+", " ", t.strip()) return (t[:n].rstrip() + "…") if len(t) > n else t def _fw_validated_pct(st): """{fw_id: pct_validated} — dùng để suy tiến độ Customer Development.""" out = {} for f in _frameworks(): blocks = f.get("blocks", []) tot = len(blocks) val = sum(1 for b in blocks if _block_status(st, f["id"], b["id"]) == "validated") out[f["id"]] = int(round(100 * val / tot)) if tot else 0 return out def _customer_dev_progress(st): """Suy tiến độ từng bước Customer Development từ trạng thái tool (framework) đã validated.""" try: cd = json.load(open(CD_PATH, encoding="utf-8")) except Exception: return None comp = _fw_validated_pct(st) stages = [] for s in cd.get("stages", []): if not s.get("flow"): continue steps = [] for row in s["flow"]: for stp in row.get("steps", []): tools = stp.get("tools") or ([stp["tool"]] if stp.get("tool") else []) fw_ids = [t.get("route", "").split("/")[-1] for t in tools if t.get("route", "").startswith("#/fw/")] if fw_ids: pct = int(round(sum(comp.get(i, 0) for i in fw_ids) / len(fw_ids))) prog = "done" if pct >= 100 else ("partial" if pct > 0 else "none") else: pct, prog = None, "untracked" # bước trỏ tới view (team/kb/kpi) → không theo dõi steps.append({"name": stp.get("name"), "vi": stp.get("vi"), "frameworks": fw_ids, "pct": pct, "progress": prog}) tracked = [x for x in steps if x["pct"] is not None] stages.append({"id": s["id"], "name": s.get("name"), "name_vi": s.get("name_vi"), "color": s.get("color"), "steps_total": len(steps), "steps_tracked": len(tracked), "done": sum(1 for x in tracked if x["progress"] == "done"), "pct": int(round(sum(x["pct"] for x in tracked) / len(tracked))) if tracked else 0, "steps": steps}) return {"stages": stages} def _write_profile_md(p): ov = p["overall"] L = ["# Hồ sơ công ty (tự sinh — KHÔNG sửa tay, sẽ bị ghi đè)\n", "> Tóm tắt tự động từ Bộ khung khởi nghiệp đã điền. AI đọc file này để nắm nhanh tình hình công ty.\n", "Cập nhật: %s. Tổng %d ô · đã điền %d (%d%%) · đã chốt %d (%d%%)%s.\n" % ( p["generated_at"], ov["total"], ov["filled"], ov["filled_pct"], ov["validated"], ov["validated_pct"], (" · %d ô CŨ cần review" % p["stale_count"]) if p["stale_count"] else "")] for f in p["frameworks"]: fb = [b for b in f["blocks"] if b["status"] != "not_started"] if not fb: continue L.append("\n## %s — %d/%d ô chốt%s" % (f["title"], f["validated"], f["total"], " ⚠️ có ô cũ" if any(b["stale"] for b in fb) else "")) for b in fb: tag = {"validated": "✅", "draft": "✍️"}.get(b["status"], "") L.append("- **%s** %s%s: %s" % (b["title"], tag, " ⚠️cũ" if b["stale"] else "", b["excerpt"] or "(trống)")) cd = p.get("customer_dev") if cd and cd.get("stages"): L.append("\n## Tiến độ Customer Development (suy từ tool đã chốt)") for s in cd["stages"]: L.append("- **%s** (%s): %d%% — %d/%d bước xong" % ( s["name"], s.get("name_vi", ""), s["pct"], s["done"], s["steps_tracked"])) os.makedirs(BUSINESS, exist_ok=True) tmp = PROFILE_MD + ".tmp" open(tmp, "w", encoding="utf-8").write("\n".join(L) + "\n") os.replace(tmp, PROFILE_MD) return os.path.relpath(PROFILE_MD, ROOT) def profile(write_md=True): """Hồ sơ công ty: hoàn thành từng khung + nội dung đã điền + cờ 'cũ' + tiến độ Customer Development. Sinh kèm company_kb/business/_profile.md (digest AI đọc). Chỉ-đọc với state; chỉ GHI file digest.""" st = _load_state() base = status() fws, stale_count = [], 0 for f in _frameworks(): blocks, last = [], None for b in sorted(f.get("blocks", []), key=lambda b: b.get("order", 99)): rec = (st.get("blocks", {}).get(_bkey(f["id"], b["id"]), {}) or {}) s = rec.get("status", "not_started") ua = rec.get("updated_at") ds = _days_since(ua) stale = bool(ds is not None and s != "not_started" and ds > STALE_DAYS) stale_count += 1 if stale else 0 if ua and (last is None or ua > last): last = ua body = _read_body(_artifact_path(f["id"], b["id"])) if s != "not_started" else "" blocks.append({"id": b["id"], "title": b.get("title", b["id"]), "status": s, "updated_at": ua, "updated_by": rec.get("updated_by"), "days_old": ds, "stale": stale, "excerpt": _excerpt(body)}) tot = len(blocks) val = sum(1 for x in blocks if x["status"] == "validated") fil = sum(1 for x in blocks if x["status"] != "not_started") fws.append({"id": f["id"], "title": f.get("title", f["id"]), "order": f.get("order"), "total": tot, "filled": fil, "validated": val, "pct": int(round(100 * val / tot)) if tot else 0, "last_updated": last, "blocks": blocks}) out = {"ok": True, "generated_at": datetime.datetime.now().isoformat(timespec="seconds"), "overall": {"total": base["total"], "filled": base["filled"], "validated": base["validated"], "filled_pct": base["filled_pct"], "validated_pct": base["validated_pct"]}, "stale_count": stale_count, "frameworks": fws, "customer_dev": _customer_dev_progress(st)} if write_md: try: out["profile_md"] = _write_profile_md(out) except Exception as e: out["profile_md_error"] = str(e) return out def _refresh_profile(): """Cập nhật lại digest _profile.md sau khi dữ liệu đổi (save/validate). Không chặn nếu lỗi.""" try: profile(write_md=True) except Exception: pass def main(): a = sys.argv[1:] cmd = a[0] if a else "status" g = lambda i: a[i] if len(a) > i else "" if cmd == "status": out = status() elif cmd == "next": out = nxt_cmd() elif cmd == "show": out = show(_safe(g(1)), _safe(g(2))) elif cmd == "save": out = save(_safe(g(1)), _safe(g(2)), g(3)) elif cmd == "validate": out = _set_status(_safe(g(1)), _safe(g(2)), "validated") elif cmd == "reopen": out = _set_status(_safe(g(1)), _safe(g(2)), "draft") elif cmd == "plan": out = plan() elif cmd == "profile": out = profile(write_md=("--no-md" not in a)) elif cmd == "canvas-dot": out = canvas_dot(_safe(g(1)) or "bmc") elif cmd == "canvas-image": out = canvas_image(_safe(g(1)) or "bmc") 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())