Explorer
/opt/struktur/memory/expire_reviews.py
← Zurück ↓ Download
#!/usr/bin/env python3
"""
Review-Queue Expire-Job
Zweck: pending Reviews mit abgelaufener expires_at auf expired setzen.
Keine Löschung. Keine Obsidian-/Graphiti-Aktion. Keine automatischen Approvals.
"""
import json
import sys
from datetime import datetime, timezone
from pathlib import Path

REVIEW_DIR = Path("/opt/struktur/memory/review-queue")
AUDIT_LOG  = Path("/opt/struktur/memory/expire_audit.jsonl")

ACTOR = "system-expire-job"


def now_utc() -> datetime:
    return datetime.now(timezone.utc)


def parse_dt(s: str) -> datetime:
    """ISO-8601 String → datetime (timezone-aware)."""
    # Python 3.11+ unterstützt Z direkt; für ältere Versionen ersetzen
    return datetime.fromisoformat(s.replace("Z", "+00:00"))


def write_audit(entry: dict):
    AUDIT_LOG.parent.mkdir(parents=True, exist_ok=True)
    with open(AUDIT_LOG, "a", encoding="utf-8") as f:
        f.write(json.dumps(entry, ensure_ascii=False) + "\n")


def expire_reviews(dry_run: bool = False) -> dict:
    now = now_utc()
    stats = {"checked": 0, "expired": 0, "skipped": 0, "errors": 0}

    if not REVIEW_DIR.exists():
        print(f"[WARN] Review-Verzeichnis nicht gefunden: {REVIEW_DIR}")
        return stats

    for fp in sorted(REVIEW_DIR.glob("*.json")):
        stats["checked"] += 1
        try:
            with open(fp, "r", encoding="utf-8") as f:
                review = json.load(f)

            review_id = review.get("review_id", fp.stem)

            # Nur pending Reviews prüfen
            if review.get("status") != "pending":
                stats["skipped"] += 1
                continue

            # expires_at prüfen
            expires_at_raw = review.get("expires_at")
            if not expires_at_raw:
                print(f"[WARN] {review_id}: kein expires_at — übersprungen")
                stats["skipped"] += 1
                continue

            expires_at = parse_dt(expires_at_raw)

            if expires_at >= now:
                # Noch nicht abgelaufen
                remaining = expires_at - now
                days = remaining.days
                print(f"[OK]   {review_id}: noch {days}d übrig")
                stats["skipped"] += 1
                continue

            # Abgelaufen → Status auf expired setzen
            old_status = review["status"]
            expired_at = now.isoformat()

            audit_entry = {
                "timestamp": expired_at,
                "review_id": review_id,
                "old_status": old_status,
                "new_status": "expired",
                "actor": ACTOR,
                "source_file": str(fp),
                "expires_at": expires_at_raw,
            }

            if dry_run:
                print(f"[DRY]  {review_id}: würde expired gesetzt (abgelaufen seit {(now - expires_at).days}d)")
                stats["expired"] += 1
                continue

            review["status"] = "expired"
            review["expired_at"] = expired_at
            review["expiration_reason"] = "auto_expired_after_14_days"
            review["auto_expired"] = True
            # Sicherheitsfelder explizit erhalten
            review["obsidian_write_allowed"] = False
            review["graphiti_write_allowed"] = False

            with open(fp, "w", encoding="utf-8") as f:
                json.dump(review, f, ensure_ascii=False, indent=2)

            write_audit(audit_entry)

            print(f"[EXP]  {review_id}: pending → expired (abgelaufen seit {(now - expires_at).days}d)")
            stats["expired"] += 1

        except Exception as e:
            print(f"[ERR]  {fp.name}: {e}")
            stats["errors"] += 1

    return stats


def main():
    dry_run = "--dry-run" in sys.argv
    mode = "DRY-RUN" if dry_run else "LIVE"

    print(f"=== Review Expire-Job [{mode}] — {now_utc().isoformat()} ===")
    print(f"    Review-Dir: {REVIEW_DIR}")
    print(f"    Audit-Log:  {AUDIT_LOG}")
    print()

    stats = expire_reviews(dry_run=dry_run)

    print()
    print(f"=== Ergebnis ===")
    print(f"    Geprüft:  {stats['checked']}")
    print(f"    Expired:  {stats['expired']}")
    print(f"    Skipped:  {stats['skipped']}")
    print(f"    Fehler:   {stats['errors']}")

    if stats["errors"] > 0:
        sys.exit(1)


if __name__ == "__main__":
    main()