← Files LuciaARCHIVED FILE

modules/apertura-pratica/scripts/review_server.py

5.56 KB · Oct 3, 2026 · 06:31 UTC

↓ Download file

from __future__ import annotations

import argparse
import json
import logging
import secrets
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
from urllib.parse import urlparse

from apertura_pratica_core import (
    ValidationError,
    load_json,
    prepare_review,
    review_payload_hash,
    utc_now,
    write_json,
)

ASSET = Path(__file__).resolve().parents[1] / "assets" / "apertura-pratica-review.html"
MAX_REQUEST_BYTES = 512 * 1024


class ReviewHandler(BaseHTTPRequestHandler):
    run_dir: Path
    token: str
    page: bytes

    def _path(self, suffix: str) -> str:
        return f"/{self.token}{suffix}"

    def _send(self, status: HTTPStatus, body: bytes, content_type: str) -> None:
        self.send_response(status)
        self.send_header("Content-Type", content_type)
        self.send_header("Content-Length", str(len(body)))
        self.send_header("Cache-Control", "no-store")
        self.send_header(
            "Content-Security-Policy",
            "default-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'",
        )
        self.send_header("X-Content-Type-Options", "nosniff")
        self.send_header("Referrer-Policy", "no-referrer")
        self.end_headers()
        self.wfile.write(body)

    def _json(self, status: HTTPStatus, payload: Any) -> None:
        self._send(
            status,
            json.dumps(payload, ensure_ascii=False).encode("utf-8"),
            "application/json; charset=utf-8",
        )

    def do_GET(self) -> None:  # noqa: N802
        path = urlparse(self.path).path
        if path in {self._path(""), self._path("/")}:
            self._send(HTTPStatus.OK, self.page, "text/html; charset=utf-8")
            return
        if path == self._path("/api/review"):
            try:
                payload = load_json(self.run_dir / "review_payload.json")
            except ValidationError as exc:
                self._json(HTTPStatus.CONFLICT, {"error": str(exc)})
                return
            self._json(
                HTTPStatus.OK,
                {
                    "review": payload,
                    "review_payload_sha256": review_payload_hash(payload),
                },
            )
            return
        self._json(HTTPStatus.NOT_FOUND, {"error": "not found"})

    def do_POST(self) -> None:  # noqa: N802
        if urlparse(self.path).path != self._path("/api/decisions"):
            self._json(HTTPStatus.NOT_FOUND, {"error": "not found"})
            return
        try:
            length = int(self.headers.get("Content-Length", "0"))
        except ValueError:
            self._json(HTTPStatus.BAD_REQUEST, {"error": "invalid content length"})
            return
        if length <= 0 or length > MAX_REQUEST_BYTES:
            self._json(
                HTTPStatus.REQUEST_ENTITY_TOO_LARGE, {"error": "invalid request size"}
            )
            return
        try:
            submitted = json.loads(self.rfile.read(length))
            review = load_json(self.run_dir / "review_payload.json")
            if not isinstance(submitted, dict) or not isinstance(
                submitted.get("decisions"), list
            ):
                raise ValidationError("Decisions payload is incomplete.")
            reviewer = str(submitted.get("reviewer", "")).strip()
            if not reviewer:
                raise ValidationError("Reviewer is required.")
            pending = {
                "schema_version": "1.0",
                "workflow": "apertura-pratica",
                "run_id": review["run_id"],
                "intake_sha256": review["intake_sha256"],
                "review_payload_sha256": review_payload_hash(review),
                "reviewer": reviewer,
                "decision_source": "local_workbench",
                "confirmed_by_user": True,
                "saved_at": utc_now(),
                "decisions": submitted["decisions"],
            }
            write_json(self.run_dir / "pending_review_decisions.json", pending)
        except (json.JSONDecodeError, ValidationError, OSError) as exc:
            self._json(HTTPStatus.BAD_REQUEST, {"error": str(exc)})
            return
        self._json(
            HTTPStatus.OK,
            {"saved": True, "path": "pending_review_decisions.json", "applied": False},
        )

    def log_message(self, message: str, *args: object) -> None:
        logging.info("review-ui %s", message % args)


def main() -> int:
    parser = argparse.ArgumentParser(
        description="Serve the local Apertura pratica review workbench."
    )
    parser.add_argument("run_dir", type=Path)
    parser.add_argument("--port", type=int, default=0)
    args = parser.parse_args()
    try:
        prepare_review(args.run_dir)
        page = ASSET.read_bytes()
    except (ValidationError, OSError) as exc:
        logging.error("%s", exc)
        return 2
    token = secrets.token_urlsafe(24)
    handler = type(
        "BoundReviewHandler",
        (ReviewHandler,),
        {"run_dir": args.run_dir.resolve(), "token": token, "page": page},
    )
    server = ThreadingHTTPServer(("127.0.0.1", args.port), handler)
    host, port = server.server_address
    logging.info("Review workbench: http://%s:%s/%s/", host, port, token)
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        return 0
    finally:
        server.server_close()
    return 0


if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
    raise SystemExit(main())

SHA-256: d52faa8763957f8612430fe4ba9fdce7187f6a8c6e214d2748cb06ee97b0b5c7