chore: sync working tree changes
This commit is contained in:
+200
@@ -0,0 +1,200 @@
|
||||
#!/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}")
|
||||
Reference in New Issue
Block a user