189 lines
6.2 KiB
Python
189 lines
6.2 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
wiki-continuous-ingest.py
|
|
Detects new/modified .md files in the vault and enqueues them to the ARQ worker.
|
|
Runs on the host, accesses local Redis (127.0.0.1:6379) and Qdrant (localhost:6333).
|
|
"""
|
|
import os
|
|
import sys
|
|
import json
|
|
import hashlib
|
|
import asyncio
|
|
from pathlib import Path
|
|
from datetime import datetime, timezone
|
|
|
|
from dotenv import load_dotenv
|
|
from arq import create_pool
|
|
from arq.connections import RedisSettings
|
|
import redis.asyncio as aioredis
|
|
|
|
# ─── Config ────────────────────────────────────────────────────────────────
|
|
ENV_PATH = os.environ.get("ENV_PATH", "")
|
|
if ENV_PATH:
|
|
env_p = Path(ENV_PATH)
|
|
if env_p.exists():
|
|
load_dotenv(env_p)
|
|
|
|
WIKI_ROOT = Path(os.environ.get("WIKI_ROOT", "."))
|
|
STATE_DIR = Path(os.environ.get("HERMES_STATE_DIR", str(Path.home() / ".hermes")))
|
|
STATE_FILE = STATE_DIR / "wiki_ingest_state.json"
|
|
FAILURES_FILE = STATE_DIR / "wiki_ingest_failures.json"
|
|
REDIS_PASSWORD = os.environ.get("REDIS_PASSWORD", "")
|
|
|
|
redis_settings = RedisSettings(
|
|
host="127.0.0.1",
|
|
port=6379,
|
|
password=REDIS_PASSWORD or None,
|
|
)
|
|
|
|
|
|
def load_state() -> dict:
|
|
if STATE_FILE.exists():
|
|
with open(STATE_FILE) as f:
|
|
return json.load(f)
|
|
return {}
|
|
|
|
|
|
def save_state(state: dict):
|
|
"""Atomic write via tempfile + rename to avoid corruption."""
|
|
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp = STATE_FILE.with_suffix(".tmp")
|
|
with open(tmp, "w") as f:
|
|
json.dump(state, f, indent=2)
|
|
f.flush()
|
|
os.fsync(f.fileno())
|
|
os.replace(tmp, STATE_FILE)
|
|
|
|
|
|
def file_hash(path: Path) -> str:
|
|
return hashlib.sha256(path.read_bytes()).hexdigest()[:16]
|
|
|
|
|
|
async def redis_ready() -> bool:
|
|
"""Check whether Redis is accessible before enqueuing."""
|
|
try:
|
|
r = aioredis.Redis(
|
|
host="127.0.0.1", port=6379,
|
|
password=REDIS_PASSWORD or None,
|
|
socket_connect_timeout=3,
|
|
socket_timeout=3,
|
|
)
|
|
ok = await r.ping()
|
|
await r.aclose()
|
|
return bool(ok)
|
|
except Exception as e:
|
|
print(f" ⚠️ Redis unavailable: {e}")
|
|
return False
|
|
|
|
|
|
async def main():
|
|
if not await redis_ready():
|
|
print("❌ Redis not ready. Docker stack may still be starting up. Aborting.")
|
|
return
|
|
|
|
state = load_state()
|
|
new_files = []
|
|
modified_files = []
|
|
skipped = 0
|
|
total = 0
|
|
|
|
# Scan all .md files
|
|
for path in sorted(WIKI_ROOT.rglob("*.md")):
|
|
total += 1
|
|
rel = str(path.relative_to(WIKI_ROOT))
|
|
mtime = path.stat().st_mtime
|
|
current_hash = file_hash(path)
|
|
|
|
if rel not in state:
|
|
new_files.append(rel)
|
|
state[rel] = {"mtime": mtime, "hash": current_hash, "queued_at": None, "ingested_at": None}
|
|
elif state[rel]["hash"] != current_hash:
|
|
modified_files.append(rel)
|
|
state[rel]["mtime"] = mtime
|
|
state[rel]["hash"] = current_hash
|
|
state[rel]["queued_at"] = None
|
|
state[rel]["ingested_at"] = None
|
|
else:
|
|
skipped += 1
|
|
|
|
files_to_ingest = new_files + modified_files
|
|
|
|
if not files_to_ingest:
|
|
print(f"⏭️ Nothing new. {total} files tracked, {skipped} unchanged.")
|
|
return
|
|
|
|
# Enqueue in ARQ
|
|
redis = await create_pool(redis_settings)
|
|
enqueued = 0
|
|
failed = 0
|
|
failures = []
|
|
|
|
for rel_path in files_to_ingest:
|
|
abs_path = str(WIKI_ROOT / rel_path)
|
|
try:
|
|
job = await redis.enqueue_job(
|
|
"process_wiki_file",
|
|
file_path=f"/wiki/{rel_path}", # path inside container
|
|
)
|
|
state[rel_path]["queued_at"] = datetime.now(timezone.utc).isoformat()
|
|
enqueued += 1
|
|
print(f" ✅ Enqueued: {rel_path} (job: {job.job_id[:8]})")
|
|
except Exception as e:
|
|
failed += 1
|
|
error_msg = str(e)
|
|
# Classify the error for the DLQ
|
|
error_lower = error_msg.lower()
|
|
transient_patterns = ["timeout", "connection", "rate limit", "503", "502", "504",
|
|
"unavailable", "too many requests", "refused", "reset"]
|
|
permanent_patterns = ["400", "404", "not found", "invalid", "parse error",
|
|
"deleted", "permission denied"]
|
|
failure_class = "unknown"
|
|
for p in transient_patterns:
|
|
if p in error_lower:
|
|
failure_class = "transient"
|
|
break
|
|
if failure_class == "unknown":
|
|
for p in permanent_patterns:
|
|
if p in error_lower:
|
|
failure_class = "permanent"
|
|
break
|
|
|
|
failures.append({
|
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
"file": rel_path,
|
|
"error": error_msg,
|
|
"failure_class": failure_class, # NEW: classification
|
|
"reported": False, # NEW: not yet reported
|
|
"retry_count": 0, # NEW: zero retries
|
|
})
|
|
print(f" ⚠️ Failure: {rel_path} — {e} [{failure_class}]")
|
|
|
|
await redis.aclose()
|
|
save_state(state)
|
|
|
|
# Persist failures to simple DLQ (atomic, last 500)
|
|
if failures:
|
|
FAILURES_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
existing = []
|
|
if FAILURES_FILE.exists():
|
|
with open(FAILURES_FILE) as f:
|
|
existing = json.load(f)
|
|
existing.extend(failures)
|
|
existing = existing[-500:]
|
|
tmp = FAILURES_FILE.with_suffix(".tmp")
|
|
with open(tmp, "w") as f:
|
|
json.dump(existing, f, indent=2)
|
|
f.flush()
|
|
os.fsync(f.fileno())
|
|
os.replace(tmp, FAILURES_FILE)
|
|
|
|
print(f"\n📊 {total} files tracked")
|
|
print(f" New: {len(new_files)} | Modified: {len(modified_files)} | Unchanged: {skipped}")
|
|
print(f" Enqueued: {enqueued} | Failures: {failed}")
|
|
if failures:
|
|
print(f" 📋 Failures persisted to: {FAILURES_FILE}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
asyncio.run(main())
|