gmal-scope-builder/backend/app/services/export_pdf.py
DJP e18976fdb2 Initial commit - GMAL Scope Builder
Dockerized web app (FastAPI + React + PostgreSQL) for scoping client ratecards
against the GMAL master asset database. Features:
- GMAL data ingestion from Excel (390 assets, 120 roles, 5 model types)
- AI-powered document parsing and asset extraction (Claude Opus 4.6)
- AI matching engine with parallel batching, confidence scoring, caveats
- Ratecard builder with hours x volume calculation
- Excel and PDF export
- GMAL browser and inline editor
- AI cost tracking per project (persisted to DB)
- Debug panel for AI call inspection
- Dark theme UI with gold (#FFC407) accent

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 17:35:14 -04:00

105 lines
3.9 KiB
Python

"""Export caveats report to PDF."""
import io
import logging
from datetime import datetime
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import mm
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.gmal import GmalAsset
from app.models.project import Project, ClientAsset, Match
logger = logging.getLogger(__name__)
CONFIDENCE_COLORS = {
"exact": colors.HexColor("#22c55e"),
"close": colors.HexColor("#f59e0b"),
"multiple": colors.HexColor("#3b82f6"),
"none": colors.HexColor("#ef4444"),
}
async def export_caveats_pdf(db: AsyncSession, project: Project) -> bytes:
"""Generate a PDF caveats report for a project.
Returns the PDF as bytes.
"""
buf = io.BytesIO()
doc = SimpleDocTemplate(buf, pagesize=A4, topMargin=20 * mm, bottomMargin=20 * mm)
styles = getSampleStyleSheet()
title_style = ParagraphStyle("CustomTitle", parent=styles["Title"], fontSize=18, spaceAfter=12)
heading_style = ParagraphStyle("CustomHeading", parent=styles["Heading2"], fontSize=13, spaceAfter=6)
body_style = styles["BodyText"]
caveat_style = ParagraphStyle("Caveat", parent=body_style, textColor=colors.HexColor("#92400e"), leftIndent=10)
elements = []
# Title
elements.append(Paragraph(f"Scope Builder - Caveats Report", title_style))
elements.append(Paragraph(f"Project: {project.name}", heading_style))
if project.client_name:
elements.append(Paragraph(f"Client: {project.client_name}", body_style))
elements.append(Paragraph(f"Generated: {datetime.utcnow().strftime('%d %B %Y')}", body_style))
elements.append(Spacer(1, 12))
# Load client assets and matches
assets_result = await db.execute(
select(ClientAsset).where(ClientAsset.project_id == project.id).order_by(ClientAsset.sort_order)
)
client_assets = assets_result.scalars().all()
for ca in client_assets:
matches_result = await db.execute(
select(Match).where(Match.client_asset_id == ca.id).order_by(Match.rank)
)
matches = matches_result.scalars().all()
elements.append(Paragraph(f"{ca.raw_name} (Volume: {ca.volume})", heading_style))
if ca.raw_description:
elements.append(Paragraph(f"<i>Client description: {ca.raw_description}</i>", body_style))
elements.append(Spacer(1, 4))
if not matches:
elements.append(Paragraph("No matches found.", caveat_style))
elements.append(Spacer(1, 8))
continue
for match in matches:
# Load GMAL asset
gmal_result = await db.execute(select(GmalAsset).where(GmalAsset.id == match.gmal_asset_id))
gmal = gmal_result.scalar_one_or_none()
if not gmal:
continue
selected_marker = " [SELECTED]" if match.is_selected else ""
conf_label = match.confidence.value.upper()
score_pct = f"{float(match.confidence_score) * 100:.0f}%" if match.confidence_score else "N/A"
elements.append(Paragraph(
f"<b>Match #{match.rank}: {gmal.gmal_id} - {gmal.unique_name or gmal.asset_name}</b>{selected_marker}",
body_style,
))
elements.append(Paragraph(f"Confidence: {conf_label} ({score_pct})", body_style))
if match.ai_reasoning:
elements.append(Paragraph(f"<b>Reasoning:</b> {match.ai_reasoning}", body_style))
if match.caveat_text:
elements.append(Paragraph(f"<b>Caveats:</b>", body_style))
elements.append(Paragraph(match.caveat_text, caveat_style))
elements.append(Spacer(1, 6))
elements.append(Spacer(1, 12))
doc.build(elements)
buf.seek(0)
return buf.read()