201 lines
7.2 KiB
Python
201 lines
7.2 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
VerdictTank PDF Generator - fills template with verdict data, renders to PDF via WeasyPrint.
|
|
Usage: python3 generate-pdf.py [--data data.json] [--output verdict-report.pdf]
|
|
"""
|
|
import json, os, sys, re
|
|
from datetime import datetime
|
|
from weasyprint import HTML
|
|
|
|
TEMPLATE_PATH = os.path.join(os.path.dirname(__file__), "pdf-template.html")
|
|
OUTPUT_DIR = "/var/www/verdicttank/reports"
|
|
|
|
EM_DASH_PATTERN = re.compile(r'\u2014|\u2013') # em dash and en dash
|
|
|
|
|
|
def clean(text):
|
|
"""Strip all em/en dashes - replace with hyphen."""
|
|
return EM_DASH_PATTERN.sub('-', str(text))
|
|
|
|
|
|
def score_bar(score):
|
|
if score is None:
|
|
return '<span class="score-bar score-na" style="width:80px"></span> N/A'
|
|
width = score * 8
|
|
if score <= 3:
|
|
cls, label = "score-critical", "Critical"
|
|
elif score <= 5:
|
|
cls, label = "score-warning", "Weak"
|
|
elif score <= 7:
|
|
cls, label = "score-ok", "Adequate"
|
|
else:
|
|
cls, label = "score-good", "Strong"
|
|
return clean(f'<span class="score-bar {cls}" style="width:{width}px"></span> {score}/10 - {label}')
|
|
|
|
|
|
def flaw_label(severity):
|
|
mapping = {
|
|
"Existential": "flaw-existential",
|
|
"Structural": "flaw-structural",
|
|
"Financial": "flaw-financial",
|
|
"Operational": "flaw-operational",
|
|
"Brand-fatal": "flaw-existential",
|
|
}
|
|
cls = mapping.get(severity, "flaw-structural")
|
|
return clean(f'<span class="flaw-label {cls}">{severity}</span>')
|
|
|
|
|
|
def build_score_rows(scores):
|
|
rows = ""
|
|
for dim in scores:
|
|
rows += clean(f'<tr><td class="dim">{dim["name"]}</td><td>{score_bar(dim["score"])}</td><td style="font-size:8.5pt;color:#555">{dim["note"]}</td></tr>\n')
|
|
return rows
|
|
|
|
|
|
def build_flaws(flaws):
|
|
html = ""
|
|
for i, f in enumerate(flaws, 1):
|
|
html += clean(f'''<div class="flaw-box">
|
|
<span class="flaw-num">#{i}</span> <strong>{f["title"]}</strong> {flaw_label(f.get("severity", "Structural"))}
|
|
<p style="margin-top:4px;font-size:9pt;color:#555;">{f["detail"]}</p>
|
|
</div>\n''')
|
|
return html
|
|
|
|
|
|
def build_judge_cards(judges):
|
|
"""Build judge cards - uses data-provided names (expected: Judge 1, Judge 2, Judge 3)."""
|
|
html = ""
|
|
for i, j in enumerate(judges):
|
|
verdict_html = ""
|
|
if j.get("verdict") == "upheld":
|
|
verdict_html = '<span class="judge-verdict upheld">UPHOLDS</span>'
|
|
else:
|
|
verdict_html = clean(f'<span class="judge-verdict overruled">{j.get("verdict", "DISAGREES")}</span>')
|
|
|
|
confidence = j.get("confidence", "")
|
|
conf_html = f'<span class="judge-confidence">{confidence} confidence</span>' if confidence else ""
|
|
|
|
html += clean(f'''<div class="judge-card">
|
|
<div class="judge-name">{j["name"]}</div>
|
|
{verdict_html}
|
|
{conf_html}
|
|
<p class="judge-note">{j.get("note", "")}</p>
|
|
</div>\n''')
|
|
return html
|
|
|
|
|
|
def build_strengths(items):
|
|
return "\n".join(clean(f"<li>{s}</li>") for s in items)
|
|
|
|
|
|
def build_action_rows(actions):
|
|
html = ""
|
|
for a in actions:
|
|
p = a["priority"]
|
|
cls = f"priority-p{p}" if p in ("0", "1", "2") else ""
|
|
html += clean(f'<tr><td class="priority {cls}">P{p}</td><td><strong>{a["action"]}</strong></td><td style="font-size:8.5pt;color:#555">{a["why"]}</td><td style="font-size:8.5pt;color:#999">{a["effort"]}</td></tr>\n')
|
|
return html
|
|
|
|
|
|
def build_research(data):
|
|
html = ""
|
|
for r in data:
|
|
html += clean(f'''<div class="finding">
|
|
<div class="finding-label">{r["label"]}</div>
|
|
<div class="finding-text">{r["text"]}</div>
|
|
<div class="finding-source">Source: {r.get("source", "Research sweep")}</div>
|
|
</div>\n''')
|
|
return html
|
|
|
|
|
|
def build_competitor_rows(competitors):
|
|
html = ""
|
|
for c in competitors:
|
|
html += clean(f'<tr><td><strong>{c["name"]}</strong></td><td style="font-size:8.5pt">{c["strengths"]}</td><td style="font-size:8.5pt;color:#cc0000">{c["gaps"]}</td><td style="font-size:8pt;color:#999">{c["threat"]}</td></tr>\n')
|
|
return html
|
|
|
|
|
|
def build_judge_notes(judges):
|
|
"""Judge notes - uses data-provided judge names, no model identifiers."""
|
|
html = ""
|
|
for i, j in enumerate(judges):
|
|
if j.get("dissent"):
|
|
html += clean(f'''<div style="margin-bottom:12px">
|
|
<strong>{j["name"]}:</strong> {j["dissent"]}
|
|
</div>\n''')
|
|
else:
|
|
html += clean(f'''<div style="margin-bottom:12px">
|
|
<strong>{j["name"]}:</strong> No dissent. Agreed with findings and scoring without material deviation.
|
|
</div>\n''')
|
|
return html
|
|
|
|
|
|
def build_citations(citations):
|
|
html = ""
|
|
for i, c in enumerate(citations, 1):
|
|
url_html = f' <a href="{c["url"]}">{c["url"]}</a>' if c.get("url") else ""
|
|
html += clean(f'<div class="citation">[{i}] {c["text"]}{url_html}</div>\n')
|
|
return html
|
|
|
|
|
|
def fill_template(data):
|
|
with open(TEMPLATE_PATH, "r") as f:
|
|
template = f.read()
|
|
|
|
judges = data.get("judges", [])
|
|
replacements = {
|
|
"{{PROPOSAL_NAME}}": clean(data.get("proposal_name", "Untitled Proposal")),
|
|
"{{SUBMITTER_NAME}}": clean(data.get("submitter_name", "Unknown")),
|
|
"{{REVIEW_DATE}}": clean(data.get("review_date", datetime.now().strftime("%B %d, %Y"))),
|
|
"{{REVIEW_ID}}": clean(data.get("review_id", "VT-" + datetime.now().strftime("%Y%m%d-%H%M"))),
|
|
"{{EXECUTIVE_SUMMARY}}": clean(data.get("executive_summary", "")),
|
|
"{{SCORE_ROWS}}": build_score_rows(data.get("scores", [])),
|
|
"{{FATAL_FLAWS}}": build_flaws(data.get("fatal_flaws", [])),
|
|
"{{JUDGE_CARDS}}": build_judge_cards(judges),
|
|
"{{JUDGE_MAJORITY_VERDICT}}": clean(data.get("judge_majority_verdict", "NO CONSENSUS")),
|
|
"{{JUDGE_AGREEMENT}}": str(data.get("judge_agreement", 0)),
|
|
"{{JUDGE_TOTAL}}": str(len(judges)),
|
|
"{{STRENGTHS}}": build_strengths(data.get("strengths", [])),
|
|
"{{RESEARCH_FINDINGS}}": build_research(data.get("research", [])),
|
|
"{{COMPETITOR_ROWS}}": build_competitor_rows(data.get("competitors", [])),
|
|
"{{ACTION_ROWS}}": build_action_rows(data.get("action_plan", [])),
|
|
"{{JUDGE_NOTES}}": build_judge_notes(judges),
|
|
"{{CITATIONS}}": build_citations(data.get("citations", [])),
|
|
}
|
|
|
|
for placeholder, value in replacements.items():
|
|
template = template.replace(placeholder, value)
|
|
|
|
# Kill any remaining unreplaced placeholders
|
|
template = re.sub(r'\{\{[A-Z_]+\}\}', 'N/A', template)
|
|
|
|
return template
|
|
|
|
|
|
def generate(data_path, output_path=None):
|
|
with open(data_path, "r") as f:
|
|
data = json.load(f)
|
|
|
|
html = fill_template(data)
|
|
|
|
if not output_path:
|
|
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
|
client = data.get("submitter_name", "Client")
|
|
safe_client = re.sub(r'[^a-zA-Z0-9]', '', client)
|
|
if not safe_client:
|
|
safe_client = "Client"
|
|
output_path = os.path.join(OUTPUT_DIR, f"{safe_client}-VerdictTank-Report.pdf")
|
|
|
|
HTML(string=html).write_pdf(output_path)
|
|
return output_path
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import argparse
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--data", required=True, help="JSON data file")
|
|
ap.add_argument("--output", help="Output PDF path")
|
|
args = ap.parse_args()
|
|
path = generate(args.data, args.output)
|
|
print(f"PDF generated: {path}")
|