← Files CarSleuthARCHIVED FILE

skills/used-car-investigator/scripts/render_report.py

15.1 KB · Oct 5, 2026 · 18:31 UTC

↓ Download file

#!/usr/bin/env python3
"""Render a self-contained CarSleuth HTML report from compact JSON.

The renderer owns layout and styling so the investigating model only writes
structured findings. All user-supplied strings are HTML-escaped.
"""

from __future__ import annotations

import argparse
import html
import json
import sys
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urlparse


def esc(value: object) -> str:
    return html.escape("" if value is None else str(value), quote=True)


def values(value: object) -> list:
    return value if isinstance(value, list) else []


def safe_url(value: object) -> str:
    raw = str(value or "").strip()
    parsed = urlparse(raw)
    return raw if parsed.scheme == "https" and parsed.netloc else ""


def validate_report(data: dict, mode: str) -> tuple[list[str], list[str]]:
    """Return fatal schema errors and non-fatal quality warnings."""
    errors: list[str] = []
    warnings: list[str] = []
    vehicle = data.get("vehicle") if isinstance(data.get("vehicle"), dict) else {}
    verdict = data.get("verdict") if isinstance(data.get("verdict"), dict) else {}
    dealer = data.get("dealer") if isinstance(data.get("dealer"), dict) else {}
    sections = [item for item in values(data.get("sections")) if isinstance(item, dict)]

    if not vehicle.get("title"):
        errors.append("vehicle.title is required")
    for field in ("label", "confidence", "summary"):
        if not verdict.get(field):
            errors.append(f"verdict.{field} is required")
    try:
        score = float(verdict.get("score"))
        if not 0 <= score <= 100:
            raise ValueError
    except (TypeError, ValueError):
        errors.append("verdict.score must be a number from 0 to 100")
    if len(values(verdict.get("metrics"))) != 4:
        errors.append("verdict.metrics must contain exactly four items")
    for field in ("name", "risk", "note"):
        if not dealer.get(field):
            errors.append(f"dealer.{field} is required")

    lower, upper = (8, 12) if mode == "standard" else (8, 16)
    if not lower <= len(sections) <= upper:
        errors.append(f"{mode} mode requires {lower}–{upper} sections; found {len(sections)}")

    section_types = {str(section.get("type", "prose")).lower() for section in sections}
    for required in ("scorebars", "numbered", "questions", "sources"):
        if required not in section_types:
            errors.append(f"a {required} section is required")

    source_sections = [section for section in sections if str(section.get("type", "")).lower() == "sources"]
    source_items = values(source_sections[-1].get("items")) if source_sections else []
    source_lower, source_upper = (5, 10) if mode == "standard" else (10, 18)
    if not source_lower <= len(source_items) <= source_upper:
        errors.append(
            f"{mode} mode requires {source_lower}–{source_upper} sources; found {len(source_items)}"
        )
    for index, item in enumerate(source_items, start=1):
        url = item.get("url") if isinstance(item, dict) else ""
        if not safe_url(url):
            errors.append(f"source {index} must use a valid HTTPS URL")
            continue
        parsed = urlparse(str(url))
        if parsed.path in {"", "/"} and not parsed.query:
            warnings.append(f"source {index} is a homepage; prefer a claim-specific page")

    return errors, warnings


def status_class(value: object) -> str:
    key = str(value or "").lower()
    if "verif" in key or key in {"a", "confirmed"}:
        return "verified"
    if "claim" in key or "seller" in key:
        return "claim"
    if "risk" in key or "warning" in key:
        return "risk"
    return "unknown"


def heading(section: dict) -> str:
    title = esc(section.get("title", ""))
    lede = section.get("lede")
    out = [f'<section class="section"><h2>{title}</h2>']
    if lede:
        out.append(f'<p class="lede">{esc(lede)}</p>')
    return "".join(out)


def note(section: dict) -> str:
    value = section.get("note")
    return f'<p class="note">{esc(value)}</p>' if value else ""


def render_bullets(section: dict, numbered: bool = False) -> str:
    tag = "ol" if numbered else "ul"
    klass = "number-list" if numbered else "bullet-list"
    items = "".join(f"<li>{esc(item)}</li>" for item in values(section.get("items")))
    return f'{heading(section)}<{tag} class="{klass}">{items}</{tag}>{note(section)}</section>'


def render_specs(section: dict) -> str:
    items = []
    for item in values(section.get("items")):
        if not isinstance(item, dict):
            continue
        status = item.get("status")
        badge = ""
        if status:
            badge = f'<span class="tag tag-{status_class(status)}">{esc(status)}</span>'
        items.append(
            '<div class="spec">'
            f'<div class="spec-label">{esc(item.get("label"))}</div>'
            f'<div class="spec-value">{esc(item.get("value"))}{badge}</div>'
            "</div>"
        )
    return f'{heading(section)}<div class="spec-grid">{"".join(items)}</div>{note(section)}</section>'


def render_risks(section: dict) -> str:
    cards = []
    for item in values(section.get("items")):
        if not isinstance(item, dict):
            continue
        severity = item.get("severity")
        evidence = item.get("evidence")
        sev = f'<span class="severity">Severity {esc(severity)}</span>' if severity not in (None, "") else ""
        ev = f'<span class="evidence">Evidence {esc(evidence)}</span>' if evidence else ""
        cards.append(
            '<article class="risk-card">'
            f'<div class="risk-head">{sev}<span class="risk-title">{esc(item.get("title"))}</span>{ev}</div>'
            f'<div class="risk-body">{esc(item.get("body"))}</div>'
            "</article>"
        )
    score = section.get("score")
    score_note = f'<div class="callout">{esc(score)}</div>' if score else ""
    return f'{heading(section)}{"".join(cards)}{score_note}{note(section)}</section>'


def render_table(section: dict) -> str:
    columns = [str(x) for x in values(section.get("columns"))]
    head = "".join(f"<th>{esc(column)}</th>" for column in columns)
    body_rows = []
    for row in values(section.get("rows")):
        if isinstance(row, dict):
            cells = [row.get(column, row.get(str(index), "")) for index, column in enumerate(columns)]
        elif isinstance(row, list):
            cells = row
        else:
            continue
        padded = list(cells) + [""] * max(0, len(columns) - len(cells))
        body_rows.append("<tr>" + "".join(f"<td>{esc(cell)}</td>" for cell in padded[: len(columns)]) + "</tr>")
    return (
        f'{heading(section)}<div class="table-wrap"><table><thead><tr>{head}</tr></thead>'
        f'<tbody>{"".join(body_rows)}</tbody></table></div>{note(section)}</section>'
    )


def render_scores(section: dict) -> str:
    rows = []
    for item in values(section.get("items")):
        if not isinstance(item, dict):
            continue
        try:
            score = float(item.get("score", 0))
            maximum = float(item.get("max", 1))
        except (TypeError, ValueError):
            score, maximum = 0.0, 1.0
        maximum = maximum if maximum > 0 else 1.0
        pct = max(0.0, min(100.0, score / maximum * 100))
        shown_score = int(score) if score.is_integer() else score
        shown_max = int(maximum) if maximum.is_integer() else maximum
        rows.append(
            '<div class="score-row">'
            f'<div class="score-label">{esc(item.get("label"))}</div>'
            f'<div class="bar"><div class="bar-fill" style="width:{pct:.1f}%"></div></div>'
            f'<div class="score-points mono">{esc(shown_score)}/{esc(shown_max)}</div>'
            "</div>"
        )
    chips = []
    if section.get("subtotal"):
        chips.append(f'<span class="chip">Subtotal {esc(section["subtotal"])}</span>')
    if section.get("penalty"):
        chips.append(f'<span class="chip chip-penalty">{esc(section["penalty"])}</span>')
    if section.get("final"):
        chips.append(f'<span class="chip chip-final">Final {esc(section["final"])}</span>')
    return (
        f'{heading(section)}<div class="score-bars">{"".join(rows)}</div>'
        f'<div class="chips">{"".join(chips)}</div>{note(section)}</section>'
    )


def render_tiles(section: dict) -> str:
    items = []
    for item in values(section.get("items")):
        if not isinstance(item, dict):
            continue
        items.append(
            '<div class="tile">'
            f'<div class="tile-label">{esc(item.get("label"))}</div>'
            f'<div class="tile-value">{esc(item.get("value"))}</div>'
            "</div>"
        )
    return f'{heading(section)}<div class="tiles">{"".join(items)}</div>{note(section)}</section>'


def render_callout(section: dict) -> str:
    style = str(section.get("style", "info")).lower()
    klass = "callout-danger" if style == "danger" else "callout-warn" if style == "warn" else ""
    return f'{heading(section)}<div class="callout {klass}">{esc(section.get("body"))}</div>{note(section)}</section>'


def render_sources(section: dict) -> str:
    items = []
    for item in values(section.get("items")):
        if not isinstance(item, dict):
            continue
        url = safe_url(item.get("url"))
        title = esc(item.get("title") or url or "Source")
        label = f'<a href="{esc(url)}" target="_blank" rel="noreferrer">{title}</a>' if url else title
        items.append(
            '<div class="source">'
            f'<div>{label}</div><div class="source-note">{esc(item.get("note"))}</div>'
            "</div>"
        )
    return f'{heading(section)}<div class="source-list">{"".join(items)}</div>{note(section)}</section>'


def render_prose(section: dict) -> str:
    paragraphs = section.get("paragraphs")
    if not isinstance(paragraphs, list):
        paragraphs = [section.get("body", "")]
    body = "".join(f'<p class="lede">{esc(paragraph)}</p>' for paragraph in paragraphs if paragraph)
    return f"{heading(section)}{body}{note(section)}</section>"


def render_section(section: object) -> str:
    if not isinstance(section, dict):
        return ""
    section_type = str(section.get("type", "prose")).lower()
    if section_type in {"bullets", "questions"}:
        return render_bullets(section)
    if section_type == "numbered":
        return render_bullets(section, numbered=True)
    if section_type == "specs":
        return render_specs(section)
    if section_type == "risks":
        return render_risks(section)
    if section_type == "table":
        return render_table(section)
    if section_type == "scorebars":
        return render_scores(section)
    if section_type == "tiles":
        return render_tiles(section)
    if section_type == "callout":
        return render_callout(section)
    if section_type == "sources":
        return render_sources(section)
    return render_prose(section)


def render_hero(data: dict) -> str:
    vehicle = data.get("vehicle") if isinstance(data.get("vehicle"), dict) else {}
    verdict = data.get("verdict") if isinstance(data.get("verdict"), dict) else {}
    title = vehicle.get("title") or data.get("title") or "Used Car Investigation"
    meta = "".join(f"<span>{esc(item)}</span>" for item in values(vehicle.get("meta")))
    try:
        score = max(0.0, min(100.0, float(verdict.get("score", 0))))
    except (TypeError, ValueError):
        score = 0.0
    circumference = 150.8
    dash = circumference * score / 100
    score_shown = int(score) if score.is_integer() else score
    metrics = []
    for item in values(verdict.get("metrics"))[:4]:
        if not isinstance(item, dict):
            continue
        metrics.append(
            '<div class="metric">'
            f'<div class="metric-label">{esc(item.get("label"))}</div>'
            f'<div class="metric-value">{esc(item.get("value"))}</div>'
            "</div>"
        )
    return (
        f'<header><h1>{esc(title)}</h1><div class="meta">{meta}</div></header>'
        '<section class="hero">'
        '<div class="hero-banner"><div>'
        f'<div class="verdict">{esc(verdict.get("label", "REVIEW"))}</div>'
        f'<div class="confidence">Confidence: {esc(verdict.get("confidence", "Unknown"))}</div></div>'
        '<div class="score-ring"><svg viewBox="0 0 58 58">'
        '<circle class="score-track" cx="29" cy="29" r="24"></circle>'
        f'<circle class="score-fill" cx="29" cy="29" r="24" stroke-dasharray="{dash:.2f} {circumference:.2f}"></circle>'
        f'</svg><div class="score-number mono">{esc(score_shown)}</div></div></div>'
        f'<div class="metrics">{"".join(metrics)}</div>'
        f'<div class="hero-summary">{esc(verdict.get("summary"))}</div></section>'
    )


def render_dealer(data: dict) -> str:
    dealer = data.get("dealer")
    if not isinstance(dealer, dict) or not dealer:
        return ""
    return (
        '<section class="dealer">'
        f'<div class="dealer-grade mono">{esc(dealer.get("grade", "—"))}</div>'
        '<div>'
        f'<div class="dealer-name">{esc(dealer.get("name"))}</div>'
        f'<div class="dealer-risk">Dealer risk: {esc(dealer.get("risk", "Unknown"))}</div>'
        f'<div class="dealer-note">{esc(dealer.get("note"))}</div>'
        "</div></section>"
    )


def render(data: dict, template: str) -> str:
    vehicle = data.get("vehicle") if isinstance(data.get("vehicle"), dict) else {}
    page_title = str(data.get("page_title") or vehicle.get("title") or "CarSleuth Report")
    body = render_hero(data) + render_dealer(data)
    body += "".join(render_section(section) for section in values(data.get("sections")))
    generated = data.get("generated") or datetime.now(timezone.utc).strftime("%Y-%m-%d")
    return (
        template.replace("{{TITLE}}", esc(page_title))
        .replace("{{BODY}}", body)
        .replace("{{GENERATED}}", esc(generated))
    )


def load_json(path: str) -> dict:
    if path == "-":
        raw = sys.stdin.read()
    else:
        raw = Path(path).read_text(encoding="utf-8")
    data = json.loads(raw)
    if not isinstance(data, dict):
        raise ValueError("Report JSON must be an object.")
    return data


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--input", "-i", default="-", help="JSON file path, or - for stdin")
    parser.add_argument("--output", "-o", required=True, help="Destination HTML path")
    parser.add_argument("--template", help="Optional template override")
    parser.add_argument("--mode", choices=("standard", "deep"), default="standard")
    args = parser.parse_args()

    script_dir = Path(__file__).resolve().parent
    template_path = Path(args.template) if args.template else script_dir.parent / "assets" / "report-template.html"
    template = template_path.read_text(encoding="utf-8")
    data = load_json(args.input)
    errors, warnings = validate_report(data, args.mode)
    for warning in warnings:
        print(f"warning: {warning}", file=sys.stderr)
    if errors:
        parser.error("report validation failed:\n- " + "\n- ".join(errors))
    output = Path(args.output).resolve()
    output.parent.mkdir(parents=True, exist_ok=True)
    output.write_text(render(data, template), encoding="utf-8")
    print(output)


if __name__ == "__main__":
    main()

SHA-256: fe9b2d4817b3400aff9ee7d6280c51d4529e97ca861abcf1d4b930c18c73afde