170 lines
6.5 KiB
Python
Executable File
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())
|