Skip to content

Commit 8d9c5d0

Browse files
committed
feat: vision pipeline — Gemma 3 scan, static PDF overlay filler, Data Lake UI, semantic mapper fix
1 parent 69ea0ae commit 8d9c5d0

6 files changed

Lines changed: 295 additions & 40 deletions

File tree

api/main.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,20 @@
22
from fastapi.middleware.cors import CORSMiddleware
33
from fastapi.responses import JSONResponse
44
from fastapi.staticfiles import StaticFiles
5+
from sqlmodel import SQLModel
6+
from api.db.database import engine
7+
from api.db import models # Ensures models are registered
58
from api.routes import templates, forms, transcribe, incidents
69
from api.errors.base import AppError
710
from typing import Union
811
import os
912

1013
app = FastAPI()
1114

15+
@app.on_event("startup")
16+
def on_startup():
17+
SQLModel.metadata.create_all(engine)
18+
1219
app.add_middleware(
1320
CORSMiddleware,
1421
allow_origins=["*"],

api/routes/incidents.py

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,20 @@ async def generate_pdf_from_lake(
133133
print(f"[DATA LAKE] Generating '{template.name}' from incident {incident_id} via Semantic Mapper")
134134

135135
master_data = json.loads(incident.master_json)
136-
tpl_fields = list(template.fields.keys()) if isinstance(template.fields, dict) else template.fields
136+
137+
# FIX #2 & #4: Determine target fields based on whether this is a static or AcroForm PDF.
138+
# For static PDFs (scanned with vision), use the coordinate labels as targets.
139+
# For regular AcroForms, use the stored template field names.
140+
from api.db.repositories import get_template_coordinates
141+
coords = get_template_coordinates(db, template_id)
142+
143+
if coords:
144+
# Static PDF path: use the field labels from the scanned coordinates
145+
tpl_fields = [c.field_label for c in coords]
146+
print(f"[DATA LAKE] Static PDF detected — using {len(tpl_fields)} scanned coordinate labels as targets")
147+
else:
148+
# AcroForm path: use stored field names from template
149+
tpl_fields = list(template.fields.keys()) if isinstance(template.fields, dict) else template.fields
137150

138151
# --- THE MAGIC BRIDGE: AI Semantic Mapper ---
139152
from src.llm import LLM
@@ -153,10 +166,19 @@ async def generate_pdf_from_lake(
153166
# Fill PDF
154167
filler = Filler()
155168
try:
156-
output_path = filler.fill_form_with_data(
157-
pdf_form=template.pdf_path,
158-
data=mapped_data
159-
)
169+
if coords:
170+
print(f"[DATA LAKE] Found {len(coords)} coordinates, using static PDF filler.")
171+
output_path = filler.fill_static_pdf(
172+
pdf_form=template.pdf_path,
173+
coordinates=coords,
174+
data=mapped_data
175+
)
176+
else:
177+
print("[DATA LAKE] No coordinates found, using dynamic AcroForm filler.")
178+
output_path = filler.fill_form_with_data(
179+
pdf_form=template.pdf_path,
180+
data=mapped_data
181+
)
160182
except Exception as e:
161183
raise AppError(f"PDF generation failed: {str(e)}", status_code=500)
162184

api/routes/templates.py

Lines changed: 86 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,22 +40,18 @@ async def create(
4040
from pypdf import PdfReader
4141

4242
# Read real field names directly from original PDF
43-
# Use /T (internal name) as both key and label
44-
# Real names like "JobTitle", "Phone Number" are already human-readable
4543
reader = PdfReader(save_path)
4644
raw_fields = reader.get_fields() or {}
4745

4846
fields = {}
4947
for internal_name, field_data in raw_fields.items():
50-
# Use /TU tooltip if available, otherwise prettify /T name
5148
label = None
5249
if isinstance(field_data, dict):
5350
label = field_data.get("/TU")
5451
if not label:
55-
# Prettify: "JobTitle" → "Job Title", "DATE7_af_date" → "Date"
5652
import re
5753
label = re.sub(r'([a-z])([A-Z])', r'\1 \2', internal_name)
58-
label = re.sub(r'_af_.*$', '', label) # strip "_af_date" suffix
54+
label = re.sub(r'_af_.*$', '', label)
5955
label = label.replace('_', ' ').strip().title()
6056
fields[internal_name] = label
6157

@@ -86,4 +82,88 @@ def get_template_by_id(
8682
tpl = get_template(db, template_id)
8783
if not tpl:
8884
raise AppError("Template not found", status_code=404)
89-
return tpl
85+
return tpl
86+
87+
88+
@router.post("/{template_id}/scan")
89+
async def scan_static_template(
90+
template_id: int,
91+
db: Session = Depends(get_db)
92+
):
93+
"""
94+
Uses Gemma 3 Vision model to scan a static/non-fillable PDF once,
95+
detect all blank fields by visual analysis, and store their coordinates
96+
in the Data Lake. Scan Once → Fill Forever.
97+
"""
98+
print(f"\n\n🚨 [SCAN] Vision scan requested for template {template_id}\n")
99+
from api.db.repositories import get_template, get_template_coordinates, create_field_coordinates
100+
from api.db.models import FormFieldCoordinates
101+
102+
tpl = get_template(db, template_id)
103+
if not tpl:
104+
raise AppError("Template not found", status_code=404)
105+
106+
if not os.path.exists(tpl.pdf_path):
107+
raise AppError("Template PDF not found on disk", status_code=404)
108+
109+
existing_coords = get_template_coordinates(db, template_id)
110+
if existing_coords:
111+
return {
112+
"status": "already_scanned",
113+
"message": "Template already has coordinate data",
114+
"fields_found": len(existing_coords)
115+
}
116+
117+
import fitz
118+
from src.llm import LLM
119+
120+
doc = fitz.open(tpl.pdf_path)
121+
if len(doc) == 0:
122+
raise AppError("PDF has no pages", status_code=400)
123+
124+
# Render page 1 as a high-res image for the vision model
125+
page = doc[0]
126+
pix = page.get_pixmap(dpi=150)
127+
img_bytes = pix.tobytes("png")
128+
doc.close()
129+
130+
llm = LLM()
131+
try:
132+
vision_fields = await llm.async_vision_scan_fields(img_bytes)
133+
print(f"[VISION] Found {len(vision_fields)} fields on static template.")
134+
except Exception as e:
135+
raise AppError(f"Vision scan failed: {e}", status_code=500)
136+
137+
if not vision_fields:
138+
return {"status": "no_fields_found", "message": "Vision model found no fields."}
139+
140+
# Save coordinates into DB
141+
stored_coords = []
142+
semantic_fields = {}
143+
144+
for vf in vision_fields:
145+
c = FormFieldCoordinates(
146+
template_id=template_id,
147+
field_label=vf.get("label", "unknown_field"),
148+
page_number=0,
149+
x=float(vf.get("x", 0)),
150+
y=float(vf.get("y", 0)),
151+
width=float(vf.get("w", vf.get("width", 20))),
152+
height=float(vf.get("h", vf.get("height", 5))),
153+
field_type=vf.get("type", "text")
154+
)
155+
stored_coords.append(c)
156+
semantic_fields[c.field_label] = c.field_label.replace("_", " ").title()
157+
158+
create_field_coordinates(db, stored_coords)
159+
160+
if not tpl.fields or len(tpl.fields) == 0:
161+
tpl.fields = semantic_fields
162+
db.add(tpl)
163+
db.commit()
164+
165+
return {
166+
"status": "success",
167+
"message": f"Vision scan complete — {len(stored_coords)} fields mapped.",
168+
"fields": semantic_fields
169+
}

frontend/index.html

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -659,6 +659,26 @@
659659
margin-bottom: 24px;
660660
}
661661

662+
.btn-scan {
663+
display: inline-flex;
664+
align-items: center;
665+
gap: 8px;
666+
padding: 6px 14px;
667+
border-radius: 6px;
668+
font-family: var(--mono);
669+
font-size: 0.68rem;
670+
margin-bottom: 24px;
671+
margin-left: 10px;
672+
background: rgba(255, 69, 0, 0.1);
673+
border: 1px solid rgba(255, 69, 0, 0.4);
674+
color: var(--fire);
675+
cursor: pointer;
676+
transition: all 0.2s;
677+
}
678+
.btn-scan:hover {
679+
background: rgba(255, 69, 0, 0.2);
680+
}
681+
662682
.selected-tpl.none {
663683
background: rgba(255, 255, 255, 0.02);
664684
border: 1px solid var(--border);
@@ -1186,7 +1206,10 @@ <h1 class="hero-h1">REPORT<br /><span class="outline">ONCE.</span></h1>
11861206
</div>
11871207

11881208
<div class="form-area">
1189-
<div class="selected-tpl none" id="tplBadge">← Select a template from the sidebar</div>
1209+
<div style="display:flex;">
1210+
<div class="selected-tpl none" id="tplBadge">← Select a template from the sidebar</div>
1211+
<button id="btnScan" class="btn-scan" style="display:none;" onclick="scanTemplate()">👁️ AI Layout Scan</button>
1212+
</div>
11901213
<div class="field-label">
11911214
<span>Incident Description <span class="req">*</span></span>
11921215
<span class="char-count" id="charCount">0 chars</span>
@@ -1204,6 +1227,14 @@ <h1 class="hero-h1">REPORT<br /><span class="outline">ONCE.</span></h1>
12041227
<input class="input-field" id="incidentIdInput" type="text"
12051228
placeholder="Incident ID (Optional - To append to an existing Data Lake record)" style="margin-bottom:14px;"
12061229
oninput="updateFillBtn()" />
1230+
1231+
<div class="upload-zone" id="photoZone" style="margin-bottom:14px; padding: 15px;">
1232+
<input type="file" id="photoInput" accept="image/*" capture="environment" onchange="onPhotoChosen(this)" />
1233+
<span class="upload-zone-icon" style="font-size: 1.5rem; margin-bottom: 5px;">📸</span>
1234+
<div class="upload-zone-text"><b>Add Scene Photo</b><br/>(AI will dynamically analyze details into lake)</div>
1235+
<div class="file-chosen" id="photoChosen"></div>
1236+
</div>
1237+
12071238
<textarea id="incidentText"
12081239
placeholder="Officer Hernandez responding to a structure fire at 742 Evergreen Terrace. Two occupants evacuated safely. Minor smoke inhalation treated on scene by EMS. Unit 7 on scene 14:32, cleared 16:45. Handed off to Deputy Martinez..."
12091240
oninput="onTextInput(this)"></textarea>
@@ -1314,6 +1345,10 @@ <h1 class="hero-h1">REPORT<br /><span class="outline">ONCE.</span></h1>
13141345
const b = document.getElementById('tplBadge');
13151346
b.className = 'selected-tpl set'; b.textContent = `\ud83d\udccb ${name}`;
13161347
document.getElementById('step2').classList.add('done');
1348+
document.getElementById('btnScan').style.display = 'inline-flex';
1349+
document.getElementById('btnScan').textContent = '👁️ AI Layout Scan';
1350+
document.getElementById('btnScan').style.color = 'var(--fire)';
1351+
document.getElementById('btnScan').style.borderColor = 'rgba(255, 69, 0, 0.4)';
13171352
updateFillBtn();
13181353
}
13191354

@@ -1325,6 +1360,7 @@ <h1 class="hero-h1">REPORT<br /><span class="outline">ONCE.</span></h1>
13251360
const b = document.getElementById('tplBadge');
13261361
if (count === 0) { b.className = 'selected-tpl none'; b.textContent = '\u2190 Check multiple templates for batch fill'; }
13271362
else { b.className = 'selected-tpl set'; b.textContent = `\ud83d\udccb ${count} template${count > 1 ? 's' : ''} selected for batch`; }
1363+
document.getElementById('btnScan').style.display = 'none';
13281364
if (count > 0) document.getElementById('step2').classList.add('done');
13291365
updateFillBtn(); loadTemplates();
13301366
}
@@ -1351,6 +1387,63 @@ <h1 class="hero-h1">REPORT<br /><span class="outline">ONCE.</span></h1>
13511387
}
13521388
});
13531389

1390+
async function scanTemplate() {
1391+
if (!selectedTplId) return;
1392+
const btn = document.getElementById('btnScan');
1393+
btn.disabled = true;
1394+
btn.textContent = 'Scanning...';
1395+
try {
1396+
const r = await fetch(`${API}/templates/${selectedTplId}/scan`, { method: 'POST' });
1397+
const data = await r.json();
1398+
if (r.ok || data.status === 'already_scanned') {
1399+
btn.textContent = '✓ Scanned';
1400+
btn.style.color = 'var(--green)';
1401+
btn.style.borderColor = 'var(--green)';
1402+
} else {
1403+
btn.textContent = '✗ Scan Failed';
1404+
}
1405+
} catch {
1406+
btn.textContent = '✗ Scan Failed';
1407+
}
1408+
btn.disabled = false;
1409+
}
1410+
1411+
async function onPhotoChosen(input) {
1412+
if (input.files.length > 0) {
1413+
const file = input.files[0];
1414+
const ch = document.getElementById('photoChosen');
1415+
ch.textContent = '⏳ Analyzing scene details...';
1416+
ch.style.display = 'block';
1417+
ch.style.color = 'var(--ember)';
1418+
1419+
let incidentId = document.getElementById('incidentIdInput').value.trim();
1420+
if (!incidentId) {
1421+
const d = new Date();
1422+
incidentId = `INC-${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}${String(d.getDate()).padStart(2,'0')}-${String(d.getHours()).padStart(2,'0')}${String(d.getMinutes()).padStart(2,'0')}`;
1423+
document.getElementById('incidentIdInput').value = incidentId;
1424+
}
1425+
1426+
const fd = new FormData();
1427+
fd.append('file', file);
1428+
1429+
try {
1430+
const r = await fetch(`${API}/incidents/${incidentId}/scene-photo`, { method: 'POST', body: fd});
1431+
const data = await r.json();
1432+
if (r.ok || r.status === 200) {
1433+
ch.textContent = '✓ Scene visually analyzed & logged to Data Lake!';
1434+
ch.style.color = 'var(--green)';
1435+
updateFillBtn();
1436+
} else {
1437+
ch.textContent = '✗ Analysis failed';
1438+
ch.style.color = 'var(--red)';
1439+
}
1440+
} catch {
1441+
ch.textContent = '✗ Analysis failed';
1442+
ch.style.color = 'var(--red)';
1443+
}
1444+
}
1445+
}
1446+
13541447
async function saveTemplate() {
13551448
const file = document.getElementById('pdfInput').files[0];
13561449
const name = document.getElementById('tplName').value.trim() || file?.name.replace('.pdf', '') || 'Untitled';

src/filler.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -323,8 +323,15 @@ def fill_static_pdf(self, pdf_form: str, coordinates: list, data: dict) -> str:
323323
rect = fitz.Rect(x_pts, y_pts, x_pts + w_pts, y_pts + h_pts)
324324

325325
if coord.field_type == "text":
326-
# fitz.TEXT_ALIGN_LEFT = 0
327-
page.insert_textbox(rect, val_str, fontsize=10, fontname="helv", color=(0, 0, 0), align=0)
326+
font_size = 9
327+
# For very flat rects (drawn lines have h < 3pt),
328+
# expand the rect upward so text is visually on top of the line.
329+
if h_pts < font_size:
330+
fill_rect = fitz.Rect(x_pts, y_pts - font_size - 2, x_pts + w_pts, y_pts + 1)
331+
else:
332+
fill_rect = rect
333+
page.insert_textbox(fill_rect, val_str, fontsize=font_size,
334+
fontname="helv", color=(0, 0, 0), align=0)
328335
elif coord.field_type == "checkbox":
329336
page.draw_rect(rect, color=(0,0,0), width=1)
330337
if val_str.lower() in TRUTHY_VALUES:

0 commit comments

Comments
 (0)