#!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.11" # dependencies = ["nanobot-ai"] # /// """wiki_compile.py — dávkový compile nasbíraných zdrojů z cml/raw/ do cml/wiki/. Spouštěn systémovým cronem každou minutu. Capture (interaktivní) hází zdroje do cml/raw/ a hned potvrdí; těžký raw→wiki compile (čtení zdrojů, psaní stránek, rozhodování) běží mimo interaktivní tah přes LLM agenta — tady, na pozadí. Tok: 1. Levná pre-kontrola (BEZ LLM): jsou v cml/raw/ nezpracované zdroje (regulérní soubory mimo _done/, _hard/, assets/)? Žádné → exit 0, agenta vůbec neinstancuj. 2. Lockfile (cml/.compile.lock, PID + start-timestamp): běží jiný compile? → exit 0 (neduplikovat). Stale lock (mrtvý proces / > STALE_SECONDS) se přebere, ať se to nezasekne po pádu. 3. Jinak Nanobot.from_config() + bot.run() — vyprázdní VŠECHNO nasbírané v jednom dávkovém běhu (jeden index/graph update pro víc zdrojů). 4. Tiše: jen append do log/wiki_compile_cron.log; žádný Telegram. Vzor = skills/detach/scripts/tasks-daemon.py (shebang uv run, deps nanobot-ai, Nanobot.from_config + asyncio.wait_for(bot.run(...), timeout)). """ import asyncio import json import os import sys import traceback from datetime import datetime from pathlib import Path # Skript žije v workspace/skills/llm-wiki/scripts/ → parents[3] = workspace. WORKSPACE = Path(__file__).resolve().parents[3] CML = WORKSPACE / "cml" RAW = CML / "raw" LOCK = CML / ".compile.lock" LOG = WORKSPACE / "log" / "wiki_compile_cron.log" # Podadresáře v raw/, které NEjsou pending zdroje. RESERVED_DIRS = {"_done", "_hard", "assets"} TIMEOUT_SECONDS = 25 * 60 STALE_SECONDS = 30 * 60 DRAIN_GOAL = ( "Pomocí skillu llm-wiki (operace Compile/drain) zkompiluj VŠECHNY nezpracované zdroje " "v `cml/raw/` (regulérní soubory přímo v `cml/raw/`, mimo `_done/`, `_hard/`, `assets/`) " "do wiki v `cml/wiki/`. Pro každý zdroj proveď plný ingest podle " "references/ingest-workflow.md: source/entity/concept stránky s frontmatterem a `[[odkazy]]`, " "aktualizuj `cml/wiki/index.md` a `cml/wiki/log.md`. Po zpracování všech zdrojů regeneruj graph " "(`wiki_graph_lint.py` + `wiki_graph_extract.py` na `cml/wiki/`). Každý úspěšně zpracovaný " "zdroj přesuň do `cml/raw/_done/`. Ambiguózní/konfliktní zdroj NEcompiluj natvrdo — nech ho " "v `cml/raw/` (nebo přesuň do `cml/raw/_hard/`) a důvod zaznamenej do `cml/wiki/log.md`. " "Lint je report-only: žádné destruktivní úpravy existujících stránek bez potvrzení. " "Idempotence: pokud pro zdroj už stránky existují (byl zkompilován dřív, jen nepřesunut), " "NEcykluj reconciliací — ber ho jako hotový, přesuň raw soubor do `cml/raw/_done/` a pokračuj. " "Každý vyřízený zdroj VŽDY přesuň z `cml/raw/` pryč, ať ho příští cron tik nezpracovává znovu. " "Běžíš v izolované session na pozadí, bez interakce s uživatelem." ) def log(message: str) -> None: LOG.parent.mkdir(parents=True, exist_ok=True) stamp = datetime.now().astimezone().isoformat(timespec="seconds") with LOG.open("a", encoding="utf-8") as handle: handle.write(f"{stamp} {message}\n") def pending_sources() -> list[Path]: """Regulérní soubory přímo v cml/raw/ (mimo skryté a rezervované podadresáře).""" if not RAW.exists(): return [] return [p for p in sorted(RAW.iterdir()) if p.is_file() and not p.name.startswith(".")] def _pid_alive(pid: int) -> bool: try: os.kill(pid, 0) except ProcessLookupError: return False except PermissionError: return True return True def _lock_is_stale() -> bool: """Lock je mrtvý, když ho nelze přečíst, proces neběží, nebo je starší než STALE_SECONDS.""" try: data = json.loads(LOCK.read_text()) pid = int(data["pid"]) started = datetime.fromisoformat(data["started"]) except (OSError, ValueError, KeyError): return True if not _pid_alive(pid): return True age = (datetime.now().astimezone() - started).total_seconds() return age > STALE_SECONDS def acquire_lock() -> bool: """Atomicky vytvoř lock. Vrať False, když už běží živý compile.""" for _ in range(2): try: fd = os.open(LOCK, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644) except FileExistsError: if not _lock_is_stale(): return False log("stale lock, reclaiming") LOCK.unlink(missing_ok=True) continue payload = {"pid": os.getpid(), "started": datetime.now().astimezone().isoformat()} with os.fdopen(fd, "w", encoding="utf-8") as handle: json.dump(payload, handle) return True return False async def run_compile(goal: str) -> str: # Heavy import deferred: the per-minute pre-check (no pending work) must not # pay the nanobot import cost — only an actual compile run needs it. from nanobot import Nanobot bot = Nanobot.from_config() result = await bot.run(goal, session_key="wiki-compile") return result.content or "" def main() -> int: dry_run = "--dry-run" in sys.argv[1:] pending = pending_sources() if not pending: return 0 if not acquire_lock(): log(f"SKIP compile already running ({len(pending)} pending)") return 0 if dry_run: names = ", ".join(p.name for p in pending) log(f"DRY-RUN would compile {len(pending)} pending: {names}") LOCK.unlink(missing_ok=True) return 0 started = datetime.now().astimezone() log(f"START compile {len(pending)} pending: {', '.join(p.name for p in pending)}") try: result_text = asyncio.run( asyncio.wait_for(run_compile(DRAIN_GOAL), timeout=TIMEOUT_SECONDS) ) summary = result_text.strip().splitlines()[0][:200] if result_text.strip() else "(prázdný výstup)" duration = int((datetime.now().astimezone() - started).total_seconds()) log(f"END compile duration={duration}s remaining={len(pending_sources())} :: {summary}") return 0 except asyncio.TimeoutError: log(f"TIMEOUT compile po {TIMEOUT_SECONDS // 60} min") return 1 except Exception as error: log(f"EXCEPTION compile: {error}\n{traceback.format_exc()}") return 1 finally: LOCK.unlink(missing_ok=True) if __name__ == "__main__": sys.exit(main())