Files
nanobot-runtime/skills/llm-wiki/scripts/wiki_compile.py
2026-06-24 08:11:12 +02:00

170 lines
6.5 KiB
Python
Executable File

#!/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(<drain goal>) — 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())