Spaces:
Running
Running
| """ | |
| Module 3 — Rule Finalization | |
| POST /finalize-rules — store reviewed ruleset in shared state | |
| GET /active-rules — retrieve current active ruleset | |
| State lives in backend/state.py — do NOT declare it here. | |
| """ | |
| import hashlib | |
| import json | |
| import logging | |
| from typing import Any, Dict, List | |
| from fastapi import APIRouter, HTTPException | |
| from pydantic import BaseModel | |
| import state # shared mutable store | |
| from models.schemas import FinalizeRulesResponse | |
| logger = logging.getLogger(__name__) | |
| router = APIRouter() | |
| # --------------------------------------------------------------------------- | |
| # Request model (we accept raw dicts so rules that were edited in the frontend | |
| # JSON editor don't get rejected by strict Pydantic validation here) | |
| # --------------------------------------------------------------------------- | |
| class FinalizeRulesRequest(BaseModel): | |
| rules: List[Dict[str, Any]] | |
| # --------------------------------------------------------------------------- | |
| # Endpoints | |
| # --------------------------------------------------------------------------- | |
| async def finalize_rules(body: FinalizeRulesRequest) -> FinalizeRulesResponse: | |
| """ | |
| Replace the active ruleset with the caller's reviewed rules array. | |
| Each rule must already have review_status set to one of: | |
| accepted | modified | kept_original | |
| Rules still in 'pending' status are rejected to enforce the review workflow. | |
| """ | |
| if not body.rules: | |
| raise HTTPException(status_code=400, detail="Rules array must not be empty.") | |
| pending = [r for r in body.rules if r.get("review_status") == "pending"] | |
| if pending: | |
| pending_ids = [r.get("rule_id", "unknown") for r in pending] | |
| raise HTTPException( | |
| status_code=422, | |
| detail=( | |
| f"{len(pending)} rule(s) still have status 'pending' and have not been reviewed: " | |
| f"{pending_ids}. Action all flagged rules before finalizing." | |
| ), | |
| ) | |
| # Atomically replace the shared list contents | |
| state.active_ruleset.clear() | |
| state.active_ruleset.extend(body.rules) | |
| # Generate a deterministic SHA-256 policy ID from the finalized rules. | |
| # Used to link document execution history logs back to this exact ruleset. | |
| ruleset_json = json.dumps(body.rules, sort_keys=True, ensure_ascii=False) | |
| state.active_ruleset_hash = hashlib.sha256(ruleset_json.encode("utf-8")).hexdigest()[:16] | |
| # ---- Persist to disk so the ruleset survives server restarts ---------- | |
| try: | |
| from datetime import datetime, timezone | |
| state.DB_PATH.parent.mkdir(parents=True, exist_ok=True) | |
| persist_payload = { | |
| "rules": body.rules, | |
| "policy_id": state.active_ruleset_hash, | |
| "saved_at": datetime.now(timezone.utc).isoformat(), | |
| "total": len(body.rules), | |
| } | |
| state.DB_PATH.write_text( | |
| json.dumps(persist_payload, indent=2, ensure_ascii=False), | |
| encoding="utf-8", | |
| ) | |
| logger.info(f"[PERSIST] Ruleset saved to disk at {state.DB_PATH}") | |
| except Exception as e: | |
| logger.warning(f"[PERSIST] Could not save ruleset to disk: {e}") | |
| accepted = sum(1 for r in body.rules if r.get("review_status") == "accepted") | |
| modified = sum(1 for r in body.rules if r.get("review_status") == "modified") | |
| kept = sum(1 for r in body.rules if r.get("review_status") == "kept_original") | |
| logger.info( | |
| f"Ruleset finalized: {len(body.rules)} rules — policy_id={state.active_ruleset_hash} " | |
| f"(accepted={accepted}, modified={modified}, kept_original={kept})" | |
| ) | |
| return FinalizeRulesResponse( | |
| accepted=accepted, | |
| modified=modified, | |
| kept_original=kept, | |
| total=len(body.rules), | |
| message=f"Ruleset finalized with {len(body.rules)} rules. Policy ID: {state.active_ruleset_hash}", | |
| ) | |
| async def get_active_rules() -> Dict[str, Any]: | |
| """Return the currently active finalized ruleset.""" | |
| if not state.active_ruleset: | |
| return { | |
| "rules": [], | |
| "total": 0, | |
| "message": "No ruleset has been finalized yet.", | |
| } | |
| return { | |
| "rules": state.active_ruleset, | |
| "total": len(state.active_ruleset), | |
| } | |