← Files Comic SolARCHIVED FILE
skills/comic-sol/scripts/render_report.py
22.2 KB · Sep 30, 2026 · 23:14 UTC
#!/usr/bin/env python3
"""Render Comic Sol's structured QA artifacts as transparent Markdown."""
from __future__ import annotations
import argparse
import json
import re
import sys
from dataclasses import dataclass
from pathlib import Path
from PIL import Image
from comic_sol import atomic_write_bytes, atomic_write_json, read_json, sha256_file
from project_io import contained_project_path, open_path_nofollow
ROOT = Path(__file__).resolve().parents[1]
TEMPLATE_PATH = ROOT / "templates/qa-report.md.tmpl"
CHECK_IDS = (
"character-identity", "anatomy", "action", "composition",
"continuity", "text-free", "technical",
)
TOKEN_PATTERN = re.compile(r"\{\{[A-Z0-9_]+\}\}")
PAGE_PATTERN = re.compile(r"^page-[0-9]{3}\.png$")
@dataclass(frozen=True)
class QaSummary:
pages: int
panels: int
generation_attempts: int
regenerated_panels: int
accepted_warnings: int
hard_failures: int
def _attempts(record: dict[str, object]) -> int:
bindings = record.get("bindings")
value = (
bindings.get("attempts", 0)
if record.get("schema_version") == "2.0" and isinstance(bindings, dict)
else record.get("attempts", 0)
)
return value if isinstance(value, int) and not isinstance(value, bool) and value >= 0 else 0
def _panel_id(record: dict[str, object]) -> str | None:
field = "subject_id" if record.get("schema_version") == "2.0" else "panel_id"
value = record.get(field)
return value if isinstance(value, str) and value else None
def _has_error_failure(record: dict[str, object]) -> bool:
checks = record.get("checks", [])
return isinstance(checks, list) and any(
isinstance(check, dict)
and check.get("result") == "fail"
and check.get("severity") == "error"
for check in checks
)
def summarize_qa(
manifest: dict[str, object],
panel_records: list[dict[str, object]],
) -> QaSummary:
"""Aggregate QA solely from panel records plus manifest production settings."""
settings = manifest.get("settings", {})
pages = settings.get("page_count", 0) if isinstance(settings, dict) else 0
pages = pages if isinstance(pages, int) and not isinstance(pages, bool) and pages >= 0 else 0
maximum_retries = settings.get("max_panel_retries", 2) if isinstance(settings, dict) else 2
if not isinstance(maximum_retries, int) or isinstance(maximum_retries, bool) or maximum_retries < 0:
maximum_retries = 2
hard_categories = {"corrupt", "corrupt_image", "safety", "safety_refusal"}
return QaSummary(
pages=pages,
panels=len(panel_records),
generation_attempts=sum(_attempts(record) for record in panel_records),
regenerated_panels=sum(_attempts(record) > 1 for record in panel_records),
accepted_warnings=sum(
record.get("decision") in {"accept-warning", "accept_with_warnings"}
for record in panel_records
),
hard_failures=sum(
record.get("failure_category") in hard_categories
or (
_has_error_failure(record)
and _attempts(record) >= maximum_retries + 1
)
for record in panel_records
),
)
def _escape_table(value: object) -> str:
return str(value).replace("\\", "\\\\").replace("|", "\\|").replace("\r\n", "<br>").replace("\n", "<br>").replace("\r", "<br>")
def _load_records(project_dir: Path) -> list[dict[str, object]]:
records: list[dict[str, object]] = []
panel_dir = project_dir / "qa/panels"
if not panel_dir.is_dir():
return records
for path in panel_dir.glob("*.json"):
record = read_json(path)
if _panel_id(record) is None:
raise ValueError(f"panel record has no panel identity: {path}")
records.append(record)
records.sort(key=lambda record: _panel_id(record) or "")
return records
def _load_page_records(project_dir: Path) -> list[dict[str, object]]:
records: list[dict[str, object]] = []
page_dir = project_dir / "qa/pages"
if not page_dir.is_dir():
return records
for path in page_dir.glob("page-*.json"):
record = read_json(path)
if record.get("schema_version") == "2.0" and record.get("kind") == "page-qa":
records.append(record)
records.sort(key=lambda record: str(record.get("subject_id", "")))
return records
def _final_status(manifest: dict[str, object]) -> object:
"""Project the terminal status an EXPORTED project is about to reach.
Final validation requires this report and its descriptor to exist before
the terminal transition, so the report can only ever be written while the
project is still EXPORTED. From there the outcome is determined by whether
any warning is unresolved.
"""
status = manifest.get("status")
if status != "EXPORTED":
return status if status is not None else "unknown"
warnings = manifest.get("warnings")
return "COMPLETE_WITH_WARNINGS" if isinstance(warnings, list) and warnings else "COMPLETE"
def _project_summary(manifest: dict[str, object]) -> str:
return "\n".join((
f"- Project: {_escape_table(manifest.get('title', 'Untitled'))} (`{manifest.get('project_id', 'unknown')}`)",
f"- Final status: **{_final_status(manifest)}**",
))
def _capability(manifest: dict[str, object]) -> str:
capability = manifest.get("capability", {})
if not isinstance(capability, dict):
capability = {}
supported = capability.get("supports_reference_images") is True
lines = [
f"- Status: {capability.get('status', 'unknown')}",
f"- Capability: {capability.get('name') or 'none'}",
f"- Reference images supported: {'yes' if supported else 'no'}",
f"- Explicit dimensions supported: {'yes' if capability.get('supports_dimensions') is True else 'no'}",
]
if not supported:
lines.append("- Consistency: degraded consistency mode because reference images are unsupported; canonical text anchors were used instead.")
lines.append(
"- Privacy: external provider policies govern transmitted prompts and references; Comic Sol stores no provider credentials."
)
return "\n".join(lines)
def _plural(count: int, singular: str, plural: str | None = None) -> str:
return f"{count} {singular if count == 1 else (plural or singular + 's')}"
def _counts(summary: QaSummary) -> str:
return "\n".join((
f"- {_plural(summary.pages, 'page')}",
f"- {_plural(summary.panels, 'panel')}",
f"- {_plural(summary.generation_attempts, 'generation attempt')}",
f"- {_plural(summary.regenerated_panels, 'regenerated panel')}",
f"- {_plural(summary.accepted_warnings, 'accepted warning')}",
f"- {_plural(summary.hard_failures, 'hard failure')}",
))
def _evidence_provenance(project_dir: Path) -> str:
path = project_dir / "qa/evidence.json"
if not path.is_file():
return (
"- Mode: unavailable\n"
"- Scope: no explicit evidence provenance record was supplied."
)
record = read_json(path)
mode = record.get("mode")
if mode == "deterministic":
return "\n".join((
"- Mode: deterministic",
f"- Scope: {record.get('scope', 'mechanics-only')}",
"- Claim boundary: deterministic evidence proves mechanics only and "
"does not prove live visual quality.",
))
if mode != "live-visual":
raise ValueError("qa/evidence.json has an unsupported evidence mode")
required = (
"retained_attempt", "attempt_sha256", "provider", "model",
"reviewer_method",
)
if any(
not isinstance(record.get(name), str) or not record[name]
for name in required
):
raise ValueError("qa/evidence.json live-visual provenance is incomplete")
def joined(name: str) -> str:
values = record.get(name)
if not isinstance(values, list):
return "none"
return ", ".join(_escape_table(value) for value in values) or "none"
return "\n".join((
"- Mode: live-visual",
f"- Scope: {_escape_table(record.get('scope', 'retained-attempt-visual-review'))}",
f"- Provider/model: {_escape_table(record['provider'])} / {_escape_table(record['model'])}",
f"- Retained attempt: `{_escape_table(record['retained_attempt'])}`",
f"- Attempt SHA-256: `{_escape_table(record['attempt_sha256'])}`",
f"- References: {joined('references')}",
f"- Reviewer method: {_escape_table(record['reviewer_method'])}",
f"- Known limitations: {joined('limitations')}",
))
def _panel_table(records: list[dict[str, object]]) -> str:
headings = ("Panel", "Attempts", "Decision", *CHECK_IDS, "Evidence")
lines = [
"| " + " | ".join(headings) + " |",
"| " + " | ".join("---" for _ in headings) + " |",
]
for record in records:
checks = record.get("checks", [])
check_map = {
check.get("id"): check
for check in checks
if isinstance(check, dict) and isinstance(check.get("id"), str)
} if isinstance(checks, list) else {}
decision = str(record.get("decision", "unknown"))
override = record.get("override_reason")
if isinstance(override, str) and override:
decision += f" (override: {override})"
results = []
for check_id in CHECK_IDS:
check = check_map.get(check_id, {})
result = str(check.get("result", "missing"))
if result == "fail" and check.get("severity") == "warning":
result += " (warning)"
results.append(result)
evidence = "; ".join(
f"{check_id}: {check_map[check_id].get('evidence', '')}"
for check_id in CHECK_IDS if check_id in check_map
)
cells = (
_panel_id(record) or "unknown", _attempts(record), decision,
*results, evidence,
)
lines.append("| " + " | ".join(_escape_table(cell) for cell in cells) + " |")
return "\n".join(lines)
def _normalization_table(
project_dir: Path,
records: list[dict[str, object]],
) -> str:
headings = ("Panel", "Mode", "Source", "Target")
lines = [
"| " + " | ".join(headings) + " |",
"| " + " | ".join("---" for _ in headings) + " |",
]
for record in records:
panel_id = _panel_id(record) or "unknown"
bindings = record.get("bindings")
relative = (
bindings.get("normalization_path")
if isinstance(bindings, dict)
else None
)
path = _contained_or_none(project_dir, relative)
mode = source_size = target_size = "unavailable"
if path is not None and path.is_file():
try:
normalization = read_json(path)
except (OSError, UnicodeError, ValueError, json.JSONDecodeError):
normalization = {}
source = normalization.get("source")
operation = normalization.get("operation")
target = normalization.get("target_size")
if isinstance(operation, dict) and isinstance(operation.get("mode"), str):
mode = operation["mode"]
source_value = source.get("size") if isinstance(source, dict) else None
if (
isinstance(source_value, list)
and len(source_value) == 2
and all(isinstance(value, int) for value in source_value)
):
source_size = f"{source_value[0]}×{source_value[1]}"
if (
isinstance(target, list)
and len(target) == 2
and all(isinstance(value, int) for value in target)
):
target_size = f"{target[0]}×{target[1]}"
lines.append(
"| " + " | ".join(
_escape_table(value)
for value in (panel_id, mode, source_size, target_size)
) + " |"
)
return "\n".join(lines)
def _page_qa_table(records: list[dict[str, object]]) -> str:
headings = ("Page", "Layout", "Check", "Result", "Method", "Reviewer")
lines = [
"| " + " | ".join(headings) + " |",
"| " + " | ".join("---" for _ in headings) + " |",
]
for record in records:
bindings = record.get("bindings")
layout = bindings.get("layout_name", "unknown") if isinstance(bindings, dict) else "unknown"
version = bindings.get("layout_version", "unknown") if isinstance(bindings, dict) else "unknown"
checks = record.get("checks")
if not isinstance(checks, list):
checks = []
for check in checks:
if not isinstance(check, dict):
continue
cells = (
record.get("subject_id", "unknown"),
f"{layout} v{version}",
check.get("id", "unknown"),
check.get("result", "missing"),
check.get("method", "missing"),
check.get("reviewer", "missing"),
)
lines.append(
"| " + " | ".join(_escape_table(cell) for cell in cells) + " |"
)
if len(lines) == 2:
lines.append(
"| none | unavailable | unavailable | unavailable | unavailable | unavailable |"
)
return "\n".join(lines)
def _warnings(
manifest: dict[str, object],
records: list[dict[str, object]],
) -> str:
warnings: dict[str, list[str]] = {}
def add_warning(source: str, warning: str) -> None:
sources = warnings.setdefault(warning, [])
if source not in sources:
sources.append(source)
for record in records:
panel_id = _panel_id(record) or "unknown"
values = record.get("unresolved_warnings", [])
if isinstance(values, list):
for value in values:
if isinstance(value, str) and value:
add_warning(panel_id, value)
manifest_warnings = manifest.get("warnings", [])
if isinstance(manifest_warnings, list):
for value in manifest_warnings:
if isinstance(value, str) and value:
add_warning("project", value)
if not warnings:
return "No unresolved warnings."
return "\n".join(
f"- `{', '.join(sources)}`: {warning}"
for warning, sources in warnings.items()
)
def _relative(project_dir: Path, path: Path) -> str:
try:
return path.relative_to(project_dir).as_posix()
except ValueError:
return path.as_posix()
def _contained_or_none(project_dir: Path, relative: object) -> Path | None:
"""Resolve a manifest-supplied path, or None when it escapes the project.
Manifest values are agent-authored, so an absolute path, a UNC path or a
``..`` sequence must never be read or hashed into the report.
"""
if not isinstance(relative, str):
return None
try:
return contained_project_path(project_dir, relative)
except (ValueError, OSError):
return None
def _pdf_readable(path: Path, expected_pages: int) -> bool:
try:
payload = path.read_bytes()
except OSError:
return False
if not payload.startswith(b"%PDF-") or not payload.rstrip().endswith(b"%%EOF"):
return False
pages = len(re.findall(rb"/Type\s*/Page(?!s)", payload))
return pages == expected_pages
def _integrity(
project_dir: Path,
manifest: dict[str, object],
records: list[dict[str, object]],
) -> str:
lines: list[str] = []
artifacts = manifest.get("artifacts", {})
if isinstance(artifacts, dict):
for name in sorted(artifacts):
descriptor = artifacts[name]
if not isinstance(descriptor, dict):
lines.append(f"- `{name}`: invalid descriptor")
continue
relative = descriptor.get("path")
expected = descriptor.get("sha256")
path = _contained_or_none(project_dir, relative)
if path is None:
lines.append(f"- `{relative}` — outside the project boundary")
continue
exists = path.is_file()
matches = exists and isinstance(expected, str) and sha256_file(path) == expected
lines.append(
f"- `{relative}` — exists: {'yes' if exists else 'no'}; hash: `{expected}`; hash matches: {'yes' if matches else 'no'}"
)
pages = sorted(
path for path in (project_dir / "pages").glob("page-*.png")
if PAGE_PATTERN.fullmatch(path.name)
) if (project_dir / "pages").is_dir() else []
for page in pages:
try:
with open_path_nofollow(page) as stream, Image.open(stream) as image:
image.load()
dimensions = f"{image.width}×{image.height}"
valid = image.format == "PNG" and image.mode == "RGB" and image.size == (1600, 2400)
except OSError:
dimensions, valid = "unreadable", False
lines.append(
f"- `{_relative(project_dir, page)}` — dimensions: {dimensions}; valid page: {'yes' if valid else 'no'}; sha256: `{sha256_file(page) if page.is_file() else 'missing'}`"
)
references = sorted({
reference
for record in records
for reference in (
record.get("generation", {}).get("reference_paths", [])
if isinstance(record.get("generation"), dict) else []
)
if isinstance(reference, str)
})
for reference in references:
path = _contained_or_none(project_dir, reference)
valid = path is not None and path.is_file()
lines.append(f"- Reference `{reference}` — valid: {'yes' if valid else 'no'}")
project_id = manifest.get("project_id", "")
pdf_descriptor = artifacts.get("pdf") if isinstance(artifacts, dict) else None
pdf_relative = pdf_descriptor.get("path") if isinstance(pdf_descriptor, dict) else f"exports/{project_id}.pdf"
pdf_path = _contained_or_none(project_dir, pdf_relative)
readable = pdf_path is not None and _pdf_readable(pdf_path, len(pages))
lines.append(
f"- `{pdf_relative}` — PDF readable: {'yes' if readable else 'no'}; pages: {len(pages)}"
)
return "\n".join(lines) if lines else "No artifacts were recorded."
def _resume(project_dir: Path) -> str:
"""Summarize which artifacts a resume carried over versus rebuilt.
``resume_project`` emits ``artifact.reused`` for every preserved stage
output; ``promote_attempt`` emits ``artifact.regenerated`` when it replaces
an existing panel.
"""
event_path = project_dir / "logs/events.jsonl"
reused: set[str] = set()
regenerated: set[str] = set()
if event_path.is_file():
for line in event_path.read_text("utf-8").splitlines():
try:
event = json.loads(line)
except json.JSONDecodeError:
continue
if not isinstance(event, dict) or not isinstance(event.get("details"), dict):
continue
artifact = event["details"].get("artifact_path")
if not isinstance(artifact, str):
continue
name = str(event.get("event", ""))
if name == "artifact.reused":
reused.add(artifact)
elif name == "artifact.regenerated":
regenerated.add(artifact)
return "\n".join((
"- Reused: " + (", ".join(sorted(reused)) if reused else "none"),
"- Regenerated: " + (", ".join(sorted(regenerated)) if regenerated else "none"),
))
def render_report(project_dir: Path, output_path: Path | None = None) -> Path:
"""Render structured QA sections and atomically publish UTF-8 Markdown."""
project_dir = Path(project_dir)
manifest = read_json(project_dir / "project.json")
records = _load_records(project_dir)
page_records = _load_page_records(project_dir)
summary = summarize_qa(manifest, records)
template = TEMPLATE_PATH.read_text("utf-8")
replacements = {
"{{PROJECT_SUMMARY}}": _project_summary(manifest),
"{{CAPABILITY}}": _capability(manifest),
"{{COUNTS}}": _counts(summary),
"{{EVIDENCE_PROVENANCE}}": _evidence_provenance(project_dir),
"{{PANEL_TABLE}}": _panel_table(records),
"{{NORMALIZATION_TABLE}}": _normalization_table(project_dir, records),
"{{PAGE_QA_TABLE}}": _page_qa_table(page_records),
"{{WARNINGS}}": _warnings(manifest, records),
"{{INTEGRITY}}": _integrity(project_dir, manifest, records),
"{{RESUME}}": _resume(project_dir),
}
rendered = template
for token, content in replacements.items():
rendered = rendered.replace(token, content)
if "{{" in rendered or TOKEN_PATTERN.search(rendered):
raise ValueError("QA report contains an unresolved template token")
destination = Path(output_path) if output_path is not None else project_dir / "qa/report.md"
atomic_write_bytes(destination, (rendered.rstrip() + "\n").encode("utf-8"))
if destination == project_dir / "qa/report.md":
_record_report_descriptor(project_dir, destination)
return destination
def _record_report_descriptor(project_dir: Path, report_path: Path) -> None:
"""Record the qa_report descriptor final validation requires.
Mirrors ``export_pdf.guarded_export`` recording the pdf descriptor, so the
stage-by-stage route reaches a valid terminal state without a manual edit.
"""
manifest_path = project_dir / "project.json"
manifest = read_json(manifest_path)
artifacts = manifest.get("artifacts")
if not isinstance(artifacts, dict):
artifacts = {}
artifacts["qa_report"] = {
"path": "qa/report.md",
"sha256": sha256_file(report_path),
}
manifest["artifacts"] = artifacts
atomic_write_json(manifest_path, manifest)
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="render_report.py")
parser.add_argument("project_dir", type=Path)
parser.add_argument("--output", type=Path)
return parser
def main(argv: list[str] | None = None) -> int:
arguments = _build_parser().parse_args(argv)
try:
print(render_report(arguments.project_dir, arguments.output))
return 0
except (OSError, TypeError, ValueError, json.JSONDecodeError) as error:
print(f"ERROR {type(error).__name__}: {error}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
SHA-256: a267583d8cbcefde1eec092f3536088b62042a2890ba624476ed07c09210f61f