"""
Crime Scene Analyzer Module
===========================
Provides a CrimeSceneAnalyzer class for forensic image analysis.
"""
import os
import json
import base64
import io
from datetime import datetime
from pathlib import Path
from dotenv import load_dotenv
import torch
from ultralytics import YOLO
from groq import Groq
from PIL import Image, ImageDraw, ImageFont
from reportlab.lib.pagesizes import A4
from reportlab.lib import colors
from reportlab.lib.units import mm
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_RIGHT, TA_JUSTIFY
from reportlab.platypus import (
SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle,
HRFlowable, Image as RLImage, KeepTogether
)
from reportlab.pdfgen import canvas
from reportlab.lib.utils import ImageReader
# ── palette ────────────────────────────────────────────────────────────────
C_BLACK = colors.HexColor("#0D0D0D")
C_DARK_GRAY = colors.HexColor("#1C1C1E")
C_MID_GRAY = colors.HexColor("#3A3A3C")
C_LIGHT_GRAY = colors.HexColor("#AEAEB2")
C_WHITE = colors.white
C_ACCENT = colors.HexColor("#BF0000")
C_ACCENT2 = colors.HexColor("#E8A000")
C_GRID = colors.HexColor("#2C2C2E")
C_ROW_ALT = colors.HexColor("#1A1A1C")
C_HIGH = colors.HexColor("#3A0000")
C_MED = colors.HexColor("#3A2A00")
C_LOW = colors.HexColor("#1A2A1A")
BOX_COLORS = [
"#BF0000", "#E8A000", "#2060C0", "#20A060",
"#8020C0", "#C06000", "#206080", "#C02060",
"#60A000", "#804020",
]
# ── helpers ────────────────────────────────────────────────────────────────
def confidence_tier(conf: float) -> str:
if conf >= 0.80:
return "HIGH"
if conf >= 0.50:
return "MEDIUM"
return "LOW"
# ── step 1 : detect objects via YOLOVIT ──────────────────────────────
def detect_objects_pil(img: Image.Image, model: YOLO) -> list[dict]:
"""Detect objects in a PIL Image using YOLO model."""
print("[1/4] Detecting objects with YOLOVIT …")
# Save image to temp file for YOLO
temp_path = "temp_image.jpg"
img.save(temp_path)
# Run inference
results = model(temp_path, conf=0.25, iou=0.45)
# Get image dimensions
W, H = img.size
objects = []
category_mapping = {
'person': 'person',
'car': 'vehicle', 'truck': 'vehicle', 'bus': 'vehicle', 'motorcycle': 'vehicle', 'bicycle': 'vehicle',
'knife': 'weapon', 'gun': 'weapon', 'rifle': 'weapon', 'pistol': 'weapon',
'cell phone': 'evidence', 'laptop': 'evidence', 'camera': 'evidence',
'bottle': 'evidence', 'cup': 'evidence', 'wine glass': 'evidence',
'chair': 'environmental', 'table': 'environmental', 'couch': 'environmental',
'bed': 'environmental', 'tv': 'environmental',
'book': 'document', 'paper': 'document', 'notebook': 'document',
}
for result in results:
boxes = result.boxes
for i, box in enumerate(boxes):
# Get box coordinates in pixels
x1, y1, x2, y2 = box.xyxy[0].tolist()
# Convert to normalized coordinates (0.0-1.0)
x = x1 / W
y = y1 / H
w = (x2 - x1) / W
h = (y2 - y1) / H
# Get class label and confidence
class_id = int(box.cls[0])
confidence = float(box.conf[0])
label = result.names[class_id]
# Map to forensic category
category = category_mapping.get(label.lower(), 'other')
# Generate forensic notes based on label
notes = f"Detected {label} with {confidence*100:.0f}% confidence"
objects.append({
"id": i + 1,
"label": label.capitalize(),
"category": category,
"confidence": confidence,
"bbox": {"x": x, "y": y, "w": w, "h": h},
"notes": notes
})
# Clean up temp file
if os.path.exists(temp_path):
os.remove(temp_path)
print(f" → {len(objects)} objects detected")
return objects
# ── step 2 : draw bounding boxes ───────────────────────────────────────────
def draw_bounding_boxes_pil(img: Image.Image, objects: list[dict]) -> Image.Image:
"""Draw bounding boxes on a PIL Image."""
print("[2/4] Drawing bounding boxes …")
img = img.convert("RGB")
W, H = img.size
draw = ImageDraw.Draw(img, "RGBA")
# Font search: Linux → macOS → Windows, fall back to PIL default
_font_candidates = [
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", # Linux
"/Library/Fonts/Arial Bold.ttf", # macOS
"C:/Windows/Fonts/arialbd.ttf", # Windows
"C:/Windows/Fonts/arial.ttf", # Windows fallback
]
def _load_font(size):
for fp in _font_candidates:
try:
return ImageFont.truetype(fp, size)
except Exception:
continue
return ImageFont.load_default()
try:
font_label = _load_font(max(12, W // 60))
font_id = _load_font(max(10, W // 70))
except Exception:
font_label = ImageFont.load_default()
font_id = font_label
for i, obj in enumerate(objects):
color_hex = BOX_COLORS[i % len(BOX_COLORS)]
color_rgb = tuple(int(color_hex.lstrip("#")[j:j+2], 16) for j in (0, 2, 4))
bbox = obj.get("bbox", {})
x = int(bbox.get("x", 0) * W)
y = int(bbox.get("y", 0) * H)
w = int(bbox.get("w", 0.1) * W)
h = int(bbox.get("h", 0.1) * H)
x2, y2 = x + w, y + h
# semi-transparent fill
draw.rectangle([x, y, x2, y2], fill=(*color_rgb, 40), outline=(*color_rgb, 220), width=max(2, W // 300))
# corner ticks
tick = max(8, W // 80)
for cx, cy, dx, dy in [(x, y, 1, 1), (x2, y, -1, 1), (x, y2, 1, -1), (x2, y2, -1, -1)]:
draw.line([cx, cy, cx + dx * tick, cy], fill=color_rgb, width=3)
draw.line([cx, cy, cx, cy + dy * tick], fill=color_rgb, width=3)
# label badge
label_text = f"[{obj['id']}] {obj['label']}"
conf_text = f"{obj['confidence']*100:.0f}%"
bbox_l = draw.textbbox((0, 0), label_text, font=font_label)
bbox_c = draw.textbbox((0, 0), conf_text, font=font_id)
lw = max(bbox_l[2] - bbox_l[0], bbox_c[2] - bbox_c[0]) + 12
lh = (bbox_l[3] - bbox_l[1]) + (bbox_c[3] - bbox_c[1]) + 10
tag_y = max(0, y - lh - 4)
draw.rectangle([x, tag_y, x + lw, tag_y + lh], fill=(*color_rgb, 220))
draw.text((x + 6, tag_y + 2), label_text, font=font_label, fill=(255, 255, 255))
draw.text((x + 6, tag_y + (bbox_l[3] - bbox_l[1]) + 4), conf_text, font=font_id, fill=(220, 220, 220))
return img
# ── step 3 : scene summary via Groq ─────────────────────────────────────
SUMMARY_PROMPT = """You are a senior forensic analyst writing an official crime-scene report.
Based on the list of detected objects below, write a structured
forensic narrative summary. Cover:
1. SCENE OVERVIEW — general description of the environment
2. KEY FINDINGS — most significant pieces of evidence
3. PROBABLE SEQUENCE OF EVENTS — what may have happened
4. AREAS REQUIRING FURTHER INVESTIGATION
5. INITIAL RISK ASSESSMENT — any ongoing hazards
Detected objects:
{objects_json}
Write in formal, third-person forensic language. Be concise but thorough.
Total length: 250-400 words.
"""
def generate_summary_text(objects: list[dict], client: Groq) -> str:
"""Generate forensic summary using Groq API."""
print("[3/4] Generating forensic summary with Groq …")
obj_json = json.dumps([{"id": o["id"], "label": o["label"],
"category": o["category"], "confidence": o["confidence"],
"notes": o.get("notes", "")} for o in objects], indent=2)
prompt = SUMMARY_PROMPT.format(objects_json=obj_json)
response = client.chat.completions.create(
model="llama-3.3-70b-versatile",
messages=[
{
"role": "user",
"content": prompt
}
],
max_tokens=1024
)
return response.choices[0].message.content.strip()
# ── step 4 : build PDF report ──────────────────────────────────────────────
class ForensicReportCanvas:
"""Adds header/footer watermark to every page."""
def __init__(self, case_id: str, analyst: str):
self.case_id = case_id
self.analyst = analyst
def __call__(self, canv: canvas.Canvas, doc):
W, H = A4
canv.saveState()
# ── top bar ──
canv.setFillColor(C_BLACK)
canv.rect(0, H - 18*mm, W, 18*mm, fill=1, stroke=0)
canv.setFillColor(C_ACCENT)
canv.rect(0, H - 19*mm, W, 1*mm, fill=1, stroke=0)
canv.setFont("Helvetica-Bold", 7)
canv.setFillColor(C_LIGHT_GRAY)
canv.drawString(12*mm, H - 10*mm, "RESTRICTED — FORENSIC INTELLIGENCE UNIT")
canv.drawRightString(W - 12*mm, H - 10*mm, f"CASE {self.case_id}")
canv.setFont("Helvetica", 6)
canv.drawRightString(W - 12*mm, H - 14*mm,
f"Analyst: {self.analyst} | {datetime.now().strftime('%Y-%m-%d %H:%M')}")
# ── bottom bar ──
canv.setFillColor(C_BLACK)
canv.rect(0, 0, W, 12*mm, fill=1, stroke=0)
canv.setFillColor(C_ACCENT)
canv.rect(0, 12*mm, W, 0.5*mm, fill=1, stroke=0)
canv.setFont("Helvetica", 6)
canv.setFillColor(C_LIGHT_GRAY)
canv.drawString(12*mm, 4*mm, "CONFIDENTIAL — NOT FOR PUBLIC RELEASE")
canv.drawCentredString(W/2, 4*mm, f"Page {doc.page}")
canv.drawRightString(W - 12*mm, 4*mm, "AI-ASSISTED ANALYSIS")
canv.restoreState()
def pil_to_rl_image(pil_img: Image.Image, max_w_mm: float, max_h_mm: float) -> RLImage:
buf = io.BytesIO()
pil_img.save(buf, format="PNG")
buf.seek(0)
W_px, H_px = pil_img.size
aspect = H_px / W_px
w = min(max_w_mm * mm, (max_h_mm / aspect) * mm)
h = w * aspect
if h > max_h_mm * mm:
h = max_h_mm * mm
w = h / aspect
return RLImage(buf, width=w, height=h)
def build_pdf_bytes(
img: Image.Image,
annotated_img: Image.Image,
objects: list[dict],
summary: str,
case_id: str,
analyst: str = "AI System v1.0"
) -> bytes:
"""Build PDF report and return as bytes."""
print("[4/4] Building forensic PDF report …")
buf = io.BytesIO()
doc = SimpleDocTemplate(
buf,
pagesize=A4,
topMargin=22*mm,
bottomMargin=16*mm,
leftMargin=15*mm,
rightMargin=15*mm,
)
W, H = A4
usable_w = W - 30*mm
# ── styles ──
ss = getSampleStyleSheet()
def style(name, parent="Normal", **kw) -> ParagraphStyle:
return ParagraphStyle(name, parent=ss[parent], **kw)
S = {
"title": style("title", fontSize=22, fontName="Helvetica-Bold",
textColor=C_WHITE, spaceAfter=2, leading=26),
"subtitle": style("subtitle", fontSize=9, fontName="Helvetica",
textColor=C_ACCENT, spaceAfter=6, leading=12),
"section": style("section", fontSize=11, fontName="Helvetica-Bold",
textColor=C_ACCENT, spaceBefore=10, spaceAfter=4),
"meta_label": style("meta_label", fontSize=7, fontName="Helvetica-Bold",
textColor=C_LIGHT_GRAY),
"meta_value": style("meta_value", fontSize=8, fontName="Helvetica",
textColor=C_WHITE),
"body": style("body", fontSize=8.5, fontName="Helvetica",
textColor=colors.HexColor("#D0D0D0"), leading=13,
spaceAfter=6, alignment=TA_JUSTIFY),
"caption": style("caption", fontSize=7, fontName="Helvetica",
textColor=C_LIGHT_GRAY, alignment=TA_CENTER, spaceAfter=4),
"tbl_hdr": style("tbl_hdr", fontSize=7.5, fontName="Helvetica-Bold",
textColor=C_WHITE),
"tbl_cell": style("tbl_cell", fontSize=7.5, fontName="Helvetica",
textColor=colors.HexColor("#CCCCCC"), leading=10),
"tbl_notes": style("tbl_notes", fontSize=7, fontName="Helvetica-Oblique",
textColor=C_LIGHT_GRAY, leading=9),
"badge_high": style("badge_high", fontSize=7, fontName="Helvetica-Bold",
textColor=colors.HexColor("#FF6060")),
"badge_med": style("badge_med", fontSize=7, fontName="Helvetica-Bold",
textColor=colors.HexColor("#FFC060")),
"badge_low": style("badge_low", fontSize=7, fontName="Helvetica-Bold",
textColor=colors.HexColor("#60FF60")),
}
story = []
# ════════════════════════════════════════════════════════════════
# COVER / TITLE BLOCK
# ════════════════════════════════════════════════════════════════
title_data = [[
Paragraph("FORENSIC SCENE
ANALYSIS REPORT", S["title"]),
Paragraph(f"CASE NO.
{case_id}",
S["subtitle"])
]]
title_tbl = Table(title_data, colWidths=[usable_w * 0.65, usable_w * 0.35])
title_tbl.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, -1), C_BLACK),
("TOPPADDING", (0, 0), (-1, -1), 10),
("BOTTOMPADDING", (0, 0), (-1, -1), 10),
("LEFTPADDING", (0, 0), (-1, -1), 10),
("RIGHTPADDING", (0, 0), (-1, -1), 10),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("ALIGN", (1, 0), (1, 0), "RIGHT"),
("LINEBELOW", (0, 0), (-1, -1), 1, C_ACCENT),
]))
story.append(title_tbl)
story.append(Spacer(1, 4*mm))
# ── meta block ──
ts = datetime.now()
meta_rows = [
["SOURCE IMAGE", "uploaded_image.jpg",
"ANALYSIS DATE", ts.strftime("%d %B %Y")],
["ANALYST", analyst,
"TIME", ts.strftime("%H:%M:%S UTC")],
["TOTAL OBJECTS", str(len(objects)),
"CLASSIFICATION", "RESTRICTED"],
]
meta_col_w = [usable_w * 0.17, usable_w * 0.33, usable_w * 0.17, usable_w * 0.33]
meta_tbl_data = []
for row in meta_rows:
meta_tbl_data.append([
Paragraph(row[0], S["meta_label"]),
Paragraph(row[1], S["meta_value"]),
Paragraph(row[2], S["meta_label"]),
Paragraph(row[3], S["meta_value"]),
])
meta_tbl = Table(meta_tbl_data, colWidths=meta_col_w)
meta_tbl.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, -1), C_DARK_GRAY),
("ROWBACKGROUNDS",(0, 0), (-1, -1), [C_DARK_GRAY, C_MID_GRAY]),
("TOPPADDING", (0, 0), (-1, -1), 4),
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
("LEFTPADDING", (0, 0), (-1, -1), 8),
("RIGHTPADDING", (0, 0), (-1, -1), 8),
("LINEBELOW", (0, -1), (-1, -1), 0.5, C_ACCENT),
]))
story.append(meta_tbl)
story.append(Spacer(1, 6*mm))
# ════════════════════════════════════════════════════════════════
# ANNOTATED IMAGE
# ════════════════════════════════════════════════════════════════
story.append(Paragraph("SECTION 1 — ANNOTATED SCENE IMAGE", S["section"]))
story.append(HRFlowable(width="100%", thickness=0.5, color=C_ACCENT))
story.append(Spacer(1, 3*mm))
rl_img = pil_to_rl_image(annotated_img, max_w_mm=180, max_h_mm=120)
img_tbl = Table([[rl_img]], colWidths=[usable_w])
img_tbl.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, -1), C_BLACK),
("ALIGN", (0, 0), (-1, -1), "CENTER"),
("TOPPADDING", (0, 0), (-1, -1), 6),
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
("LINEBELOW", (0, 0), (-1, -1), 1, C_ACCENT),
]))
story.append(img_tbl)
story.append(Paragraph(
f"Figure 1.1 — Annotated crime scene image. "
f"{len(objects)} objects identified and marked with coloured bounding boxes.",
S["caption"]))
story.append(Spacer(1, 5*mm))
# ════════════════════════════════════════════════════════════════
# EVIDENCE TABLE
# ════════════════════════════════════════════════════════════════
story.append(Paragraph("SECTION 2 — DETECTED OBJECTS & EVIDENCE INVENTORY", S["section"]))
story.append(HRFlowable(width="100%", thickness=0.5, color=C_ACCENT))
story.append(Spacer(1, 3*mm))
col_w = [
usable_w * 0.05, # ID
usable_w * 0.22, # Label
usable_w * 0.14, # Category
usable_w * 0.11, # Confidence
usable_w * 0.11, # Tier
usable_w * 0.37, # Notes
]
tbl_data = [[
Paragraph("ID", S["tbl_hdr"]),
Paragraph("LABEL", S["tbl_hdr"]),
Paragraph("CATEGORY", S["tbl_hdr"]),
Paragraph("CONFIDENCE", S["tbl_hdr"]),
Paragraph("TIER", S["tbl_hdr"]),
Paragraph("FORENSIC NOTES", S["tbl_hdr"]),
]]
tier_style_map = {"HIGH": S["badge_high"], "MEDIUM": S["badge_med"], "LOW": S["badge_low"]}
tier_bg_map = {"HIGH": C_HIGH, "MEDIUM": C_MED, "LOW": C_LOW}
tbl_row_bgs = []
for i, obj in enumerate(objects):
tier = confidence_tier(obj["confidence"])
row_bg = C_ROW_ALT if i % 2 == 0 else C_DARK_GRAY
tbl_row_bgs.append(row_bg)
tbl_data.append([
Paragraph(str(obj["id"]), S["tbl_cell"]),
Paragraph(obj["label"], S["tbl_cell"]),
Paragraph(obj.get("category", "other").upper(), S["tbl_cell"]),
Paragraph(f"{obj['confidence']*100:.1f}%", S["tbl_cell"]),
Paragraph(tier, tier_style_map[tier]),
Paragraph(obj.get("notes", "—"), S["tbl_notes"]),
])
ev_tbl = Table(tbl_data, colWidths=col_w, repeatRows=1)
ts_cmds = [
("BACKGROUND", (0, 0), (-1, 0), C_ACCENT),
("TEXTCOLOR", (0, 0), (-1, 0), C_WHITE),
("TOPPADDING", (0, 0), (-1, -1), 4),
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
("LEFTPADDING", (0, 0), (-1, -1), 5),
("RIGHTPADDING", (0, 0), (-1, -1), 5),
("ROWBACKGROUNDS",(0, 1), (-1, -1), [C_ROW_ALT, C_DARK_GRAY]),
("GRID", (0, 0), (-1, -1), 0.3, C_GRID),
("VALIGN", (0, 0), (-1, -1), "TOP"),
]
ev_tbl.setStyle(TableStyle(ts_cmds))
story.append(ev_tbl)
story.append(Spacer(1, 5*mm))
# ── category summary mini-table ──
from collections import Counter
cat_counts = Counter(o.get("category", "other") for o in objects)
cat_rows = [[Paragraph("CATEGORY", S["tbl_hdr"]),
Paragraph("COUNT", S["tbl_hdr"]),
Paragraph("% OF TOTAL", S["tbl_hdr"])]]
for cat, cnt in sorted(cat_counts.items(), key=lambda x: -x[1]):
cat_rows.append([
Paragraph(cat.upper(), S["tbl_cell"]),
Paragraph(str(cnt), S["tbl_cell"]),
Paragraph(f"{cnt/len(objects)*100:.1f}%", S["tbl_cell"]),
])
cat_tbl = Table(cat_rows, colWidths=[usable_w*0.5, usable_w*0.25, usable_w*0.25])
cat_tbl.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), C_MID_GRAY),
("BACKGROUND", (0, 1), (-1, -1), C_DARK_GRAY),
("GRID", (0, 0), (-1, -1), 0.3, C_GRID),
("TOPPADDING", (0, 0), (-1, -1), 3),
("BOTTOMPADDING", (0, 0), (-1, -1), 3),
("LEFTPADDING", (0, 0), (-1, -1), 6),
("RIGHTPADDING", (0, 0), (-1, -1), 6),
]))
story.append(Paragraph("Table 2.2 — Category Distribution Summary", S["caption"]))
story.append(cat_tbl)
story.append(Spacer(1, 5*mm))
# ════════════════════════════════════════════════════════════════
# FORENSIC NARRATIVE SUMMARY
# ════════════════════════════════════════════════════════════════
story.append(Paragraph("SECTION 3 — FORENSIC NARRATIVE SUMMARY", S["section"]))
story.append(HRFlowable(width="100%", thickness=0.5, color=C_ACCENT))
story.append(Spacer(1, 3*mm))
# parse the summary into sections
for line in summary.split("\n"):
line = line.strip()
if not line:
story.append(Spacer(1, 2*mm))
continue
# bold numbered headings
if line and line[0].isdigit() and "." in line[:3]:
story.append(Paragraph(
f'{line}', S["body"]))
else:
story.append(Paragraph(line, S["body"]))
story.append(Spacer(1, 5*mm))
# ════════════════════════════════════════════════════════════════
# DISCLAIMER
# ════════════════════════════════════════════════════════════════
story.append(HRFlowable(width="100%", thickness=0.5, color=C_MID_GRAY))
story.append(Spacer(1, 2*mm))
disclaimer = (
"DISCLAIMER: This report was generated by an AI-assisted forensic analysis system. "
"All findings are preliminary and must be reviewed and validated by a certified forensic "
"investigator before being used in any legal or official capacity. Confidence scores are "
"model estimates and do not constitute legal certainty."
)
story.append(Paragraph(disclaimer, style(
"disclaimer", fontSize=6.5, textColor=C_LIGHT_GRAY,
fontName="Helvetica-Oblique", alignment=TA_JUSTIFY)))
# ── build ──
page_fn = ForensicReportCanvas(case_id, analyst)
doc.build(story, onFirstPage=page_fn, onLaterPages=page_fn)
buf.seek(0)
return buf.read()
# ── CrimeSceneAnalyzer Class ───────────────────────────────────────────
class CrimeSceneAnalyzer:
"""Main class for crime scene analysis."""
def __init__(self):
"""Initialize the analyzer with models and API clients."""
# Load environment variables
load_dotenv()
# Get Groq API key
groq_api_key = os.environ.get("GROQ_API_KEY")
if not groq_api_key:
raise ValueError("GROQ_API_KEY not found in .env file or environment variables.")
# Initialize Groq client
self.groq_client = Groq(api_key=groq_api_key)
# Load YOLO model
print("Loading YOLOVIT model...")
self.yolo_model = YOLO('yolov8x.pt')
print("YOLOVIT model loaded successfully.")
def analyze_scene(self, image: Image.Image) -> dict:
"""
Analyze a crime scene image and return PDF bytes.
Args:
image: PIL Image object
Returns:
dict with keys:
- pdf_bytes: bytes of the generated PDF
- error: error message if analysis failed
"""
try:
# Generate case ID
case_id = f"CS-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
print(f"\n{'='*55}")
print(f" AI CRIME SCENE ANALYZER — Case {case_id}")
print(f"{'='*55}\n")
# Step 1: Detect objects
objects = detect_objects_pil(image, self.yolo_model)
# Step 2: Draw bounding boxes
annotated_img = draw_bounding_boxes_pil(image, objects)
# Step 3: Generate summary
summary = generate_summary_text(objects, self.groq_client)
# Step 4: Build PDF
pdf_bytes = build_pdf_bytes(image, annotated_img, objects, summary, case_id)
print(f"\n{'='*55}")
print(f" COMPLETE — PDF generated")
print(f"{'='*55}\n")
return {"pdf_bytes": pdf_bytes}
except Exception as e:
print(f"Error during analysis: {str(e)}")
return {"error": str(e)}