#!/usr/bin/env python3 """ notebooklm_sync.py — Hybrid Sync Script Chạy đồng bộ NotebookLM → Master file với Kanban integration. Mỗi bước comment lên task Kanban để CEO/Nhi theo dõi tiến độ real-time. """ import subprocess, json, re, time, os, sys from datetime import datetime # ── Constants ── NLM_BIN = "/opt/ai-os/products/ceo/integrations/notebooklm-mcp-cli/.venv/bin/nlm" NOTEBOOK_ID = "a0f8646b-90b1-4a65-bddc-07280aa06a99" MASTER_PATH = "/opt/ai-os/products/ceo/content/research/domains/tourism/master_tourism_vn.md" WORKDIR = "/opt/ai-os/products/ceo" BOARD = "research-hub" # ── Helpers ── def log(msg): print(msg, flush=True) def shell(cmd, timeout=120): """Run a shell command, return stdout or None.""" try: res = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout, cwd=WORKDIR) if res.returncode == 0: return res.stdout.strip() return None except Exception as e: log(f" ⚠️ shell error: {e}") return None def refresh_auth(): log("🔄 Refreshing NotebookLM auth...") r = subprocess.run(["python3", "scripts/notebooklm_refresh.py"], capture_output=True, text=True, cwd=WORKDIR, timeout=60) return r.returncode == 0 # ── Kanban Operations ── def kanban_call(action, *args): """Generic kanban CLI wrapper.""" cmd = f"hermes kanban {action} " + " ".join(args) return shell(cmd, timeout=30) def create_kanban_task(title, body, assignee="r-and-d"): out = shell( f'hermes kanban create --json --assignee {assignee} ' f'--body {shlex_quote(body)} ' f'--initial-status running ' f'--board {BOARD} ' f'{shlex_quote(title)}', timeout=30 ) if out: data = json.loads(out) return data.get("id") return None def comment(task_id, text): return kanban_call("comment", task_id, shlex_quote(text)) def block_task(task_id, reason): return kanban_call("block", task_id, shlex_quote(reason)) def assign_task(task_id, profile): return kanban_call("assign", task_id, profile) def shlex_quote(s): """Simple shell quoting.""" escaped = s.replace("'", "'\\''") return f"'{escaped}'" def normalize_title(title): t = title.lower() t = re.sub(r'\.md$|\.pdf$|\.docx$|\.txt$', '', t) t = re.sub(r'[^a-z0-9]', '', t) return t def query_rag(source_id): q = ( "Hãy tóm tắt chi tiết văn bản này theo từng chương/phần. " "Liệt kê các mục tiêu cụ thể, số liệu benchmark và giải pháp nếu có. " "Chỉ dựa trên nội dung tài liệu, KHÔNG bịa thêm thông tin. " "TUYỆT ĐỐI KHÔNG dùng dấu gạch nối trong văn xuôi tiếng Việt." ) cmd = [NLM_BIN, "notebook", "query", "--source-ids", source_id, "--timeout", "180", NOTEBOOK_ID, q] try: res = subprocess.run(cmd, capture_output=True, text=True, timeout=200, cwd=WORKDIR) if res.returncode == 0: data = json.loads(res.stdout) return data.get("answer", "").split("***")[0].strip() except: pass return None def get_describe(source_id): cmd = [NLM_BIN, "source", "describe", "--json", source_id] try: res = subprocess.run(cmd, capture_output=True, text=True, timeout=60, cwd=WORKDIR) if res.returncode == 0: data = json.loads(res.stdout) return data.get("summary", ""), data.get("keywords", []) except: pass return "", [] # ═══════════════════════════════════════════ # MAIN SYNC FLOW # ═══════════════════════════════════════════ def main(): date_str = datetime.now().strftime("%Y-%m-%d_%H:%M") task_id = None has_errors = False try: # ─── Step 0: Auth Check ─── log("=== NOTEBOOKLM SYNC === (Kanban integration)") # Create Kanban task title = f"[Sync] NotebookLM ➔ Master ({date_str})" body = ( "Tự động đồng bộ nguồn mới từ NotebookLM vào master_tourism_vn.md.\n\n" "**Các bước:**\n" "* [ ] Bước 1: Kiểm tra Auth & Doctor\n" "* [ ] Bước 2: Quét Source List & Fuzzy Match\n" "* [ ] Bước 3: RAG Summarize (VN + EN)\n" "* [ ] Bước 4: Ghi File Master & QA Dash Free\n" ) task_id = create_kanban_task(title, body) if not task_id: log("❌ Không thể tạo Kanban task. Tiếp tục không có Kanban track.") else: log(f"📋 Task Kanban: {task_id}") comment(task_id, "🚀 **Bắt đầu sync...**") # ─── Step 1: Auth Check ─── log("\n[Step 1/4] Kiểm tra Auth & Doctor...") doctor = shell(f"cd {WORKDIR} && {NLM_BIN} doctor 2>&1", timeout=30) if doctor and "Authentication" in doctor and "expired" in doctor: if not refresh_auth(): msg = "❌ Lỗi Bước 1: Auth expired và refresh thất bại." log(msg) if task_id: comment(task_id, msg) has_errors = True block_task(task_id, msg) assign_task(task_id, "ceo") return log("✅ Auth refreshed.") elif not doctor: msg = "⚠️ Lỗi Bước 1: `nlm doctor` không ra kết quả. Thử refresh..." log(msg) if task_id: comment(task_id, msg) refresh_auth() else: log("✅ Auth OK.") if task_id: comment(task_id, "[Step 1/4] ✅ Đã xác thực cookie thành công. Session active.") # ─── Step 2: Scan & Fuzzy Match ─── log("\n[Step 2/4] Quét Source List & Fuzzy Match...") # 2a: Fetch sources from NotebookLM sources_raw = shell( f"cd {WORKDIR} && {NLM_BIN} source list {NOTEBOOK_ID} --json 2>&1", timeout=60) if not sources_raw: msg = "❌ Lỗi Bước 2: Không lấy được danh sách nguồn từ NotebookLM." log(msg) if task_id: comment(task_id, msg) block_task(task_id, msg) assign_task(task_id, "ceo") return sources = json.loads(sources_raw) log(f"📡 NotebookLM: {len(sources)} sources") # 2b: Read master file if not os.path.exists(MASTER_PATH): msg = f"❌ Lỗi: Master file không tồn tại tại {MASTER_PATH}" log(msg) if task_id: comment(task_id, msg) block_task(task_id, msg) assign_task(task_id, "ceo") return with open(MASTER_PATH, 'r') as f: master_content = f.read() # 2c: Parse master entries entries = list(re.finditer( r'(### (\d+)\.\s*(.*?)\n- \*\*ID:\*\* `([^`]+)`)(.*?)(?=\n### \d+\.|\Z)', master_content, re.DOTALL)) log(f"📄 Master file: {len(entries)} entries") # Build master map master_map = {} for entry in entries: num = entry.group(2) title_m = entry.group(3).strip() eid = entry.group(4).strip() body = entry.group(5).strip() full = entry.group(0) norm = normalize_title(title_m) master_map[norm] = {"num": num, "title": title_m, "id": eid, "body": body, "full": full, "start": entry.start(), "end": entry.end()} # 2d: Compare ids_updated = 0 new_sources = [] stale_sources = [] for src in sources: src_id = src["id"] src_title = src["title"] src_norm = normalize_title(src_title) matched = None if src_norm in master_map: matched = master_map[src_norm] else: for norm, entry in master_map.items(): if len(src_norm) > 10 and (src_norm in norm or norm in src_norm): matched = entry break if matched: if matched["id"] != src_id: ids_updated += 1 old_id_line = f"- **ID:** `{matched['id']}`" new_id_line = f"- **ID:** `{src_id}`" master_content = master_content.replace(old_id_line, new_id_line) matched["id"] = src_id log(f"✏️ ID updated: {matched['title'][:40]}") # Check need update has_vn = "Tóm tắt (VN)" in matched["body"] is_short = len(matched["body"]) < 1200 if not has_vn or is_short: stale_sources.append((src_id, src_title, matched)) else: new_sources.append((src_id, src_title)) step2_msg = ( f"[Step 2/4] ✅ Quét hoàn tất: {len(sources)} nguồn trên cloud, " f"{len(entries)} trong master. " f"Sửa ID: {ids_updated}. " f"Nguồn mới: {len(new_sources)}. " f"Nguồn thiếu tóm tắt: {len(stale_sources)}." ) log(step2_msg) if task_id: comment(task_id, step2_msg) # ─── Step 3: RAG Summarize ─── log("\n[Step 3/4] RAG Summarize cho nguồn mới & thiếu...") all_to_process = [(sid, stitle, None) for sid, stitle in new_sources] all_to_process += [(sid, stitle, entry) for sid, stitle, entry in stale_sources] if not all_to_process: log("✅ Không có nguồn nào cần tóm tắt. Bỏ qua bước 3.") if task_id: comment(task_id, "[Step 3/4] ✅ Không có nguồn mới/thiếu. Bỏ qua.") comment(task_id, "[Step 4/4] ✅ Không có thay đổi. Master file đã up-to-date.") else: log(f"📝 Cần xử lý: {len(all_to_process)} nguồn") summarized = 0 errors = 0 for i, (sid, stitle, entry) in enumerate(all_to_process, 1): log(f"\n [{i}/{len(all_to_process)}] {stitle[:60]}...") time.sleep(2) # Query RAG vn = query_rag(sid) if not vn: log(f" ⚠️ Query VN thất bại cho {sid[:8]}") errors += 1 continue en, kws = get_describe(sid) kw_str = ", ".join([f"`{k}`" for k in kws]) if kws else "" new_body_parts = [] if kw_str: new_body_parts.append(f"- **Từ khóa:** ** {kw_str}") if en: new_body_parts.append(f"- **Tóm tắt:** ** {en.strip()}") new_body_parts.append(f"\n- **Tóm tắt (VN):** ** {vn.strip()}") new_body_str = "\n".join(new_body_parts) if entry: # Update existing entry header_match = re.match( r'(### \d+\.\s*.*?\n- \*\*ID:\*\* `[^`]+`\n- \*\*Nhóm:\*\* [^\n]*)', entry["full"]) if header_match: new_block = header_match.group(1) + new_body_str master_content = master_content.replace(entry["full"], new_block) else: # New entry - append to end group_prefix = "NEW_" if stitle.startswith("GOV_"): group_prefix = "GOV_" elif stitle.startswith("PP_"): group_prefix = "PP_" elif stitle.startswith("RP_"): group_prefix = "RP_" new_entry = ( f"\n### 999. {stitle}\n" f"- **ID:** `{sid}`\n" f"- **Nhóm:** Tự động đồng bộ (NotebookLM Auto Sync)\n{new_body_str}\n" ) master_content += new_entry summarized += 1 time.sleep(3) step3_msg = ( f"[Step 3/4] ✅ Xử lý xong: {summarized}/{len(all_to_process)} nguồn " f"được tóm tắt thành công. Lỗi: {errors}." ) log(step3_msg) if task_id: comment(task_id, step3_msg) # ─── Step 4: Write & QA ─── log("\n[Step 4/4] Ghi file Master & QA Dash Free...") # Check no dashes in VN prose vn_sections = re.findall(r'- \*\*Tóm tắt \(VN\):\*\* \*\*(.*?)(?=\n###|\n##|\Z)', master_content, re.DOTALL) dash_count = 0 for vs in vn_sections: lines = vs.strip().split('\n') for line in lines: stripped = line.strip() if stripped.startswith('- ') and not stripped.startswith('- **'): dash_count += 1 if dash_count > 0: msg = f"⚠️ [Step 4/4] CẢNH BÁO: Phát hiện {dash_count} dấu gạch nối trong văn xuôi VN!" log(msg) if task_id: comment(task_id, msg) else: msg = "[Step 4/4] ✅ QA: Không phát hiện dấu gạch nối trong văn xuôi VN. Đạt chuẩn." log(msg) # Write file with open(MASTER_PATH, 'w') as f: f.write(master_content) log("✅ Đã ghi master file.") # Final summary changes_summary = ( f"**Đồng bộ hoàn tất ({date_str})**\n" f"* ID được cập nhật: {ids_updated}\n" f"* Nguồn mới: {summarized}\n" f"* Nguồn bỏ qua (đã có tóm tắt dài): {len(sources) - ids_updated - summarized}\n" f"* Dash check: {'✅ Pass' if dash_count == 0 else f'⚠️ {dash_count} lỗi'}\n" ) if task_id: comment(task_id, changes_summary) # Block for CEO review block_task(task_id, "Đồng bộ hoàn tất. Chờ CEO duyệt và kiểm tra kết quả.") assign_task(task_id, "ceo") log("\n📋 Task đã block, gán cho CEO duyệt.") log(changes_summary) except Exception as e: log(f"❌ Lỗi nghiêm trọng: {e}") if task_id: comment(task_id, f"❌ Lỗi nghiêm trọng: {e}") block_task(task_id, f"Lỗi nghiêm trọng: {e}") assign_task(task_id, "ceo") log("\n=== SYNC DONE ===") if __name__ == "__main__": main()