← Files VeraARCHIVED FILE
modules/comunicazione-professionale/scripts/record_review.py
7.51 KB · Oct 2, 2026 · 00:29 UTC
#!/usr/bin/env python3
"""Record a professional review decision for one current contribution scope."""
from __future__ import annotations
import argparse
import logging
from pathlib import Path
from typing import Any
from workflow_core import (
atomic_write_json,
load_json,
recompute_contribution_digest,
utc_now,
validate_schema,
verify_package_manifest,
verify_visual_assessment,
verify_visual_manifest,
workflow_lock,
)
__all__ = ["record_review", "record_review_bundle", "main"]
LOGGER = logging.getLogger(__name__)
DECISIONS = {"accepted", "returned", "rejected"}
def record_review(
run_dir: Path,
*,
scope: str,
decision: str,
reviewer: str,
note: str,
confirmed_by_user: bool,
quality_checklist_confirmed: bool,
) -> Path:
"""Append one digest-bound, locally asserted review event."""
if not confirmed_by_user:
raise ValueError("Review recording requires --confirmed-by-user")
if decision not in DECISIONS:
raise ValueError(f"Unsupported review decision: {decision}")
root = run_dir.resolve()
with workflow_lock(root):
workbench = load_json(root / "content_workbench.json")
contribution_digest = recompute_contribution_digest(root)
artifact_digest = None
visual_assessment_digest = None
post_generation_scopes = workbench.get("post_generation_review_scopes", [])
if scope == "rendered_output":
if scope not in post_generation_scopes:
raise ValueError("Rendered output review is not required for this run")
if decision == "accepted" and not quality_checklist_confirmed:
raise ValueError(
"Accepted rendered output requires --quality-checklist-confirmed"
)
artifact_digest = verify_visual_manifest(root)
if decision == "accepted":
visual_assessment_digest = verify_visual_assessment(root)
elif scope == "packaged_output":
if scope not in post_generation_scopes:
raise ValueError("Packaged output review is not required for this run")
artifact_digest = verify_package_manifest(root)
elif scope not in workbench["required_review_scopes"]:
raise ValueError(f"Scope is not required for this contribution: {scope}")
log_path = root / "review_log.json"
review_log = load_json(log_path)
event = {
"event_id": f"REVIEW-{len(review_log['events']) + 1:04d}",
"scope": scope,
"decision": decision,
"reviewer": reviewer,
"reviewer_role": "commercialista_or_authorized_professional",
"reviewer_identity_asserted_not_authenticated": True,
"confirmed_by_user": True,
"note": note,
"input_digest": workbench["input_digest"],
"contribution_digest": contribution_digest,
"reviewed_at": utc_now(),
}
if artifact_digest is not None:
event["artifact_digest"] = artifact_digest
if scope == "rendered_output":
event["quality_checklist_confirmed"] = quality_checklist_confirmed
if visual_assessment_digest is not None:
event["visual_assessment_digest"] = visual_assessment_digest
review_log["events"].append(event)
atomic_write_json(log_path, review_log)
return log_path
def record_review_bundle(
run_dir: Path,
bundle_path: Path,
*,
reviewer: str,
confirmed_by_user: bool,
) -> Path:
"""Record every semantic scope from one visible, user-confirmed review matrix."""
if not confirmed_by_user:
raise ValueError("Review recording requires --confirmed-by-user")
root = run_dir.resolve()
with workflow_lock(root):
bundle = load_json(bundle_path)
validate_schema(bundle, "review_bundle.schema.json")
workbench = load_json(root / "content_workbench.json")
if bundle["run_id"] != workbench["run_id"]:
raise ValueError("Review bundle run_id mismatch")
decisions = bundle["decisions"]
scopes = [row["scope"] for row in decisions]
if len(scopes) != len(set(scopes)):
raise ValueError("Review bundle repeats a scope")
if set(scopes) != set(workbench["required_review_scopes"]):
raise ValueError(
"Review bundle must cover every current semantic scope exactly"
)
contribution_digest = recompute_contribution_digest(root)
log_path = root / "review_log.json"
review_log = load_json(log_path)
session_id = (
f"REVIEW-SESSION-{len(review_log['events']) + 1:04d}-"
f"{contribution_digest[:12]}"
)
reviewed_at = utc_now()
events: list[dict[str, Any]] = []
for offset, row in enumerate(decisions, start=1):
events.append(
{
"event_id": f"REVIEW-{len(review_log['events']) + offset:04d}",
"review_session_id": session_id,
"scope": row["scope"],
"decision": row["decision"],
"reviewer": reviewer,
"reviewer_role": "commercialista_or_authorized_professional",
"reviewer_identity_asserted_not_authenticated": True,
"confirmed_by_user": True,
"note": row["note"],
"input_digest": workbench["input_digest"],
"contribution_digest": contribution_digest,
"reviewed_at": reviewed_at,
}
)
review_log["events"].extend(events)
atomic_write_json(log_path, review_log)
return log_path
def main(argv: list[str] | None = None) -> int:
"""Record one review event."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--run-dir", type=Path, required=True)
parser.add_argument("--scope")
parser.add_argument("--decision", choices=sorted(DECISIONS))
parser.add_argument("--bundle", type=Path)
parser.add_argument("--reviewer", required=True)
parser.add_argument("--note", default="")
parser.add_argument("--confirmed-by-user", action="store_true")
parser.add_argument("--quality-checklist-confirmed", action="store_true")
args = parser.parse_args(argv)
try:
if args.bundle is not None:
if args.scope is not None or args.decision is not None:
raise ValueError("Use either --bundle or --scope/--decision")
path = record_review_bundle(
args.run_dir,
args.bundle,
reviewer=args.reviewer,
confirmed_by_user=args.confirmed_by_user,
)
else:
if args.scope is None or args.decision is None:
raise ValueError("--scope and --decision are required without --bundle")
path = record_review(
args.run_dir,
scope=args.scope,
decision=args.decision,
reviewer=args.reviewer,
note=args.note,
confirmed_by_user=args.confirmed_by_user,
quality_checklist_confirmed=args.quality_checklist_confirmed,
)
except (OSError, ValueError) as exc:
LOGGER.error("REVIEW_RECORD_FAILED: %s", exc)
return 1
LOGGER.info("Recorded review decision: %s", path)
return 0
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO, format="%(message)s")
raise SystemExit(main())
SHA-256: 6b40e370d916be6ec21a93614cdf2a498dbffaadb78b2190dcabc7a6de7d13ee