Skip to content

Commit 69ea0ae

Browse files
committed
feat: implement gemma vision pipeline for form population and scene descriptions
1 parent 47e901c commit 69ea0ae

5 files changed

Lines changed: 283 additions & 43 deletions

File tree

api/db/models.py

Lines changed: 53 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,53 @@
1-
from sqlmodel import SQLModel, Field
2-
from sqlalchemy import Column, JSON
3-
from datetime import datetime
4-
5-
class Template(SQLModel, table=True):
6-
id: int | None = Field(default=None, primary_key=True)
7-
name: str
8-
fields: dict = Field(sa_column=Column(JSON))
9-
pdf_path: str
10-
created_at: datetime = Field(default_factory=datetime.utcnow)
11-
12-
13-
class FormSubmission(SQLModel, table=True):
14-
id: int | None = Field(default=None, primary_key=True)
15-
template_id: int
16-
input_text: str
17-
output_pdf_path: str
18-
created_at: datetime = Field(default_factory=datetime.utcnow)
19-
20-
# ADD THIS TO api/db/models.py
21-
# (append to existing file — don't replace)
22-
23-
from sqlmodel import SQLModel, Field
24-
from typing import Optional
25-
from datetime import datetime
26-
27-
28-
class IncidentMasterData(SQLModel, table=True):
29-
"""
30-
The Incident Data Lake.
31-
Stores all extracted data from one incident as a master JSON blob.
32-
Any agency can generate their PDF from this single record — zero new LLM calls.
33-
"""
34-
id: Optional[int] = Field(default=None, primary_key=True)
35-
incident_id: str = Field(index=True) # INC-2026-0321-4821
36-
master_json: str # JSON string — all extracted fields
37-
transcript_text: str # original transcript
38-
location_lat: Optional[float] = None # from PWA GPS
39-
location_lng: Optional[float] = None # from PWA GPS
40-
officer_notes: Optional[str] = None # additional context
41-
created_at: datetime = Field(default_factory=datetime.utcnow)
42-
updated_at: datetime = Field(default_factory=datetime.utcnow)
1+
from sqlmodel import SQLModel, Field
2+
from sqlalchemy import Column, JSON
3+
from typing import Optional
4+
from datetime import datetime
5+
6+
class Template(SQLModel, table=True):
7+
id: int | None = Field(default=None, primary_key=True)
8+
name: str
9+
fields: dict = Field(sa_column=Column(JSON))
10+
pdf_path: str
11+
created_at: datetime = Field(default_factory=datetime.utcnow)
12+
13+
14+
class FormSubmission(SQLModel, table=True):
15+
id: int | None = Field(default=None, primary_key=True)
16+
template_id: int
17+
input_text: str
18+
output_pdf_path: str
19+
created_at: datetime = Field(default_factory=datetime.utcnow)
20+
21+
22+
class IncidentMasterData(SQLModel, table=True):
23+
"""
24+
The Incident Data Lake.
25+
Stores all extracted data from one incident as a master JSON blob.
26+
Any agency can generate their PDF from this single record — zero new LLM calls.
27+
"""
28+
id: Optional[int] = Field(default=None, primary_key=True)
29+
incident_id: str = Field(index=True) # INC-2026-0321-4821
30+
master_json: str # JSON string — all extracted fields
31+
transcript_text: str # original transcript
32+
location_lat: Optional[float] = None # from PWA GPS
33+
location_lng: Optional[float] = None # from PWA GPS
34+
officer_notes: Optional[str] = None # additional context
35+
created_at: datetime = Field(default_factory=datetime.utcnow)
36+
updated_at: datetime = Field(default_factory=datetime.utcnow)
37+
38+
39+
class FormFieldCoordinates(SQLModel, table=True):
40+
"""
41+
Stores visual field positions detected by the vision model for a given template.
42+
Coordinates are stored as percentages (0-100) of the page dimensions.
43+
"""
44+
id: int | None = Field(default=None, primary_key=True)
45+
template_id: int = Field(foreign_key="template.id", index=True)
46+
field_label: str # e.g. "patient_name", "incident_date"
47+
page_number: int = Field(default=0) # 0-indexed PDF page
48+
x: float # % from left edge (0–100)
49+
y: float # % from top edge (0–100)
50+
width: float # % of page width
51+
height: float # % of page height
52+
field_type: str = Field(default="text") # "text", "checkbox", "image"
53+
scanned_at: datetime = Field(default_factory=datetime.utcnow)

api/db/repositories.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,4 +114,26 @@ def update_incident_json(db, incident_id: str, new_data: dict, new_transcript: s
114114
db.add(incident)
115115
db.commit()
116116
db.refresh(incident)
117-
return incident
117+
return incident
118+
119+
120+
# ── Vision Coordinates ────────────────────────────────────────
121+
122+
from api.db.models import FormFieldCoordinates
123+
124+
def create_field_coordinates(db: Session, coords: list[FormFieldCoordinates]) -> list[FormFieldCoordinates]:
125+
for coord in coords:
126+
db.add(coord)
127+
db.commit()
128+
for coord in coords:
129+
db.refresh(coord)
130+
return coords
131+
132+
133+
def get_template_coordinates(db: Session, template_id: int) -> list[FormFieldCoordinates]:
134+
from sqlmodel import select
135+
return db.exec(
136+
select(FormFieldCoordinates).where(
137+
FormFieldCoordinates.template_id == template_id
138+
)
139+
).all()

api/routes/incidents.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,4 +275,64 @@ def generate_narrative(incident_id: str, db: Session = Depends(get_db)):
275275
"narrative": narrative,
276276
"format": "markdown",
277277
"generated_at": datetime.utcnow().isoformat()
278+
}
279+
280+
281+
# ── Vision Model Endpoints ──────────────────────────────
282+
283+
from fastapi import UploadFile, File
284+
285+
@router.post("/{incident_id}/scene-photo")
286+
async def add_scene_photo(
287+
incident_id: str,
288+
file: UploadFile = File(...),
289+
db: Session = Depends(get_db)
290+
):
291+
"""
292+
Multimodal Vision Integration.
293+
Upload a scene photo to the incident. Uses Gemma 3 vision model to generate
294+
a professional narrative of the scene and merges it into the Data Lake Master JSON.
295+
"""
296+
if not incident_id:
297+
raise AppError("Missing incident_id", status_code=400)
298+
299+
incident = get_incident(db, incident_id)
300+
if not incident:
301+
# Auto-create if it doesn't exist
302+
incident = IncidentMasterData(
303+
incident_id=incident_id,
304+
master_json="{}",
305+
transcript_text="[VISION] Image submitted."
306+
)
307+
create_incident(db, incident)
308+
309+
import base64
310+
image_bytes = await file.read()
311+
312+
# Run the vision model (async)
313+
from src.llm import LLM
314+
llm = LLM()
315+
try:
316+
scene_narrative = await llm.async_vision_describe_scene(image_bytes)
317+
print(f"[VISION] Scene Narrative generated: {scene_narrative[:50]}...")
318+
except Exception as e:
319+
raise AppError(f"Vision model failed: {e}", status_code=500)
320+
321+
# Update the data lake using the smart consensus merge
322+
new_data = {
323+
"scene_visual_analysis": scene_narrative
324+
}
325+
326+
update_incident_json(
327+
db,
328+
incident_id,
329+
new_data,
330+
new_transcript=f"[VISUAL OBSERVATION]: {scene_narrative}"
331+
)
332+
333+
return {
334+
"incident_id": incident_id,
335+
"status": "photo_analyzed_and_merged",
336+
"narrative_preview": scene_narrative[:100] + "...",
337+
"message": "Scene analysis merged into master data lake."
278338
}

src/filler.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,4 +274,64 @@ def fill_form_with_data(self, pdf_form: str, data: dict) -> str:
274274
PdfWriter().write(output_pdf, pdf)
275275
print("\nlog extracted successfully")
276276
print(f"along with what it extracted accordingly, pdf file : {output_pdf}")
277+
return output_pdf
278+
279+
def fill_static_pdf(self, pdf_form: str, coordinates: list, data: dict) -> str:
280+
"""
281+
Uses PyMuPDF to draw text directly onto absolute pixel coordinates
282+
for static (non-fillable) forms.
283+
coordinates: list of FormFieldCoordinates (from DB)
284+
data: dictionary of extracted values (from Data Lake)
285+
"""
286+
import fitz # PyMuPDF
287+
import os
288+
289+
output_pdf = (
290+
pdf_form[:-4]
291+
+ "_"
292+
+ datetime.now().strftime("%Y%m%d_%H%M%S")
293+
+ "_static_filled.pdf"
294+
)
295+
296+
doc = fitz.open(pdf_form)
297+
298+
# Draw each field
299+
for coord in coordinates:
300+
# Match data (case-insensitive fallback)
301+
raw_val = data.get(coord.field_label)
302+
if raw_val is None:
303+
for k, v in data.items():
304+
if k.lower() == coord.field_label.lower():
305+
raw_val = v
306+
break
307+
308+
if raw_val is None or str(raw_val).strip() == "":
309+
continue
310+
311+
val_str = str(raw_val).strip()
312+
313+
if coord.page_number < len(doc):
314+
page = doc[coord.page_number]
315+
page_rect = page.rect
316+
317+
# Convert percentages (0-100) to actual PDF points
318+
x_pts = (coord.x / 100.0) * page_rect.width
319+
y_pts = (coord.y / 100.0) * page_rect.height
320+
w_pts = (coord.width / 100.0) * page_rect.width
321+
h_pts = (coord.height / 100.0) * page_rect.height
322+
323+
rect = fitz.Rect(x_pts, y_pts, x_pts + w_pts, y_pts + h_pts)
324+
325+
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)
328+
elif coord.field_type == "checkbox":
329+
page.draw_rect(rect, color=(0,0,0), width=1)
330+
if val_str.lower() in TRUTHY_VALUES:
331+
# Draw X
332+
page.draw_line(rect.top_left, rect.bottom_right, color=(0,0,0), width=1.5)
333+
page.draw_line(rect.bottom_left, rect.top_right, color=(0,0,0), width=1.5)
334+
335+
doc.save(output_pdf)
336+
doc.close()
277337
return output_pdf

src/llm.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,93 @@ def handle_plural_values(self, plural_value):
292292
def get_data(self):
293293
return self._json
294294

295+
# ── Vision Model Methods ────────────────────────────────────────
296+
297+
import base64
298+
299+
async def async_vision_scan_fields(self, image_bytes: bytes) -> list[dict]:
300+
"""
301+
Passes a page image to the Vision model to map out form fields.
302+
Returns a list of dicts: {"label": "...", "x": ..., "y": ..., "w": ..., "h": ...}
303+
where x, y, w, h are percentages (0-100) of the page width/height.
304+
"""
305+
import base64
306+
ollama_host = os.getenv("OLLAMA_HOST", "http://localhost:11434").rstrip("/")
307+
ollama_url = f"{ollama_host}/api/generate"
308+
309+
# We need the vision model here, e.g. gemma3:4b
310+
vision_model = os.getenv("FIREFORM_VISION_MODEL", "gemma3:4b")
311+
b64_image = base64.b64encode(image_bytes).decode("utf-8")
312+
313+
prompt = '''
314+
SYSTEM PROMPT:
315+
You are an expert form layout analysis AI. Your task is to extract all visual input fields (text blanks, checkboxes) from the provided image of a document.
316+
Return ONLY valid JSON. The JSON should be a list of objects with the following keys:
317+
- "label": A descriptive semantic name for the field (e.g. "patient_name", "date_of_incident"). Use snake_case.
318+
- "x": The X coordinate of the top-left corner of the blank space (as a percentage of the image width, 0.0 to 100.0).
319+
- "y": The Y coordinate of the top-left corner of the blank space (as a percentage of the image height, 0.0 to 100.0).
320+
- "w": The width of the blank space (as a percentage of the image width, 0.0 to 100.0).
321+
- "h": The height of the blank space (as a percentage of the image height).
322+
- "type": "text" or "checkbox".
323+
Do not include markdown blocks, just the JSON list.
324+
'''
325+
326+
payload = {
327+
"model": vision_model,
328+
"prompt": prompt,
329+
"images": [b64_image],
330+
"stream": False,
331+
"format": "json" # Force JSON mode if supported
332+
}
333+
334+
try:
335+
response = requests.post(ollama_url, json=payload, timeout=300)
336+
response.raise_for_status()
337+
res_text = response.json().get("response", "[]").strip()
338+
339+
# Simple cleanup in case it returns markdown
340+
if res_text.startswith("```json"):
341+
res_text = res_text[7:]
342+
if res_text.endswith("```"):
343+
res_text = res_text[:-3]
344+
345+
data = json.loads(res_text.strip())
346+
return data if isinstance(data, list) else []
347+
except Exception as e:
348+
print(f"[ERROR] Vision Scan Failed: {e}")
349+
return []
350+
351+
async def async_vision_describe_scene(self, image_bytes: bytes) -> str:
352+
"""
353+
Takes a scene photo and generates a professional incident narrative to feed into the Data Lake.
354+
"""
355+
import base64
356+
ollama_host = os.getenv("OLLAMA_HOST", "http://localhost:11434").rstrip("/")
357+
ollama_url = f"{ollama_host}/api/generate"
358+
vision_model = os.getenv("FIREFORM_VISION_MODEL", "gemma3:4b")
359+
b64_image = base64.b64encode(image_bytes).decode("utf-8")
360+
361+
prompt = '''
362+
You are a professional first responder describing an incident scene.
363+
Look at the provided image and generate a concise but highly detailed incident narrative.
364+
Describe any structures, vehicles, hazards, visible injuries, number of units involved, and overall context.
365+
Use professional, objective reporting language.
366+
'''
367+
368+
payload = {
369+
"model": vision_model,
370+
"prompt": prompt,
371+
"images": [b64_image],
372+
"stream": False,
373+
}
374+
375+
try:
376+
response = requests.post(ollama_url, json=payload, timeout=300)
377+
response.raise_for_status()
378+
return response.json().get("response", "").strip()
379+
except Exception as e:
380+
print(f"[ERROR] Scene Description Failed: {e}")
381+
return "Failed to process image scene."
295382
@staticmethod
296383
async def async_semantic_map(master_json: dict, target_pdf_fields: list) -> dict:
297384
"""

0 commit comments

Comments
 (0)