provozni zaloha
This commit is contained in:
169
skills/llm-wiki/scripts/wiki_compile.py
Executable file
169
skills/llm-wiki/scripts/wiki_compile.py
Executable file
@@ -0,0 +1,169 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user