Skip to content

Commit 926cc34

Browse files
committed
feat: Implement Data Lake and AI Semantic Mapper for seamless Multi-Form Generation (ICS-209 & ICS-214)
1 parent 343dbe4 commit 926cc34

4 files changed

Lines changed: 192 additions & 161 deletions

File tree

api/routes/incidents.py

Lines changed: 45 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,37 @@ async def extract_to_data_lake(
5757
merged_fields = {}
5858

5959
if all_templates:
60-
# Build superset from all known templates
61-
for tpl in all_templates:
62-
if isinstance(tpl.fields, dict):
63-
merged_fields.update(tpl.fields)
64-
print(f"[DATA LAKE] Base schema: {len(merged_fields)} template fields across {len(all_templates)} templates")
60+
# Instead of pulling 1000+ unreadable AcroForm fields from all templates,
61+
# we provide a core Universal ICS Schema to guide the LLM, and let its dynamic
62+
# schema-less extraction catch everything else. This prevents 503 timeouts!
63+
merged_fields = {
64+
"incident_name": "Incident Name",
65+
"incident_number": "Incident Number",
66+
"date_time_of_report": "Date and Time of Report",
67+
"report_version": "Report Version",
68+
"incident_commander": "Incident Commander",
69+
"incident_management_organization": "Incident Management Organization",
70+
"incident_description": "Incident Description / Sub-type",
71+
"location": "Location",
72+
"current_incident_size_acres": "Current Incident Size (Acres)",
73+
"percent_contained": "Percent Contained",
74+
"estimated_containment_date": "Estimated Containment Date",
75+
"cause_of_incident": "Cause of Incident",
76+
"weather_conditions": "Weather Conditions (Current)",
77+
"significant_events": "Significant Events / Critical Activity",
78+
"planned_actions": "Planned Actions for Next 24 Hours",
79+
"operational_period_from": "Operational Period From",
80+
"operational_period_to": "Operational Period To",
81+
"from": "From Time",
82+
"to": "To Time",
83+
"unit_name": "Unit Name / Designators",
84+
"unit_leader": "Unit Leader (Name and ICS Position)",
85+
"personnel_assigned": "Personnel Assigned",
86+
"activity_log": "Activity Log",
87+
"prepared_by": "Prepared By",
88+
"preparation_date_time": "Date and Time of Preparation"
89+
}
90+
print(f"[DATA LAKE] Using Universal ICS Schema ({len(merged_fields)} fields) to prevent LLM timeout.")
6591

6692
try:
6793
llm = LLM(transcript_text=input_text, target_fields=merged_fields)
@@ -134,9 +160,7 @@ async def generate_pdf_from_lake(
134160

135161
master_data = json.loads(incident.master_json)
136162

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.
163+
# Determine target fields based on whether this is a static or AcroForm PDF.
140164
from api.db.repositories import get_template_coordinates
141165
coords = get_template_coordinates(db, template_id)
142166

@@ -146,7 +170,19 @@ async def generate_pdf_from_lake(
146170
print(f"[DATA LAKE] Static PDF detected — using {len(tpl_fields)} scanned coordinate labels as targets")
147171
else:
148172
# AcroForm path: use stored field names from template
149-
tpl_fields = list(template.fields.keys()) if isinstance(template.fields, dict) else template.fields
173+
raw_fields = list(template.fields.keys()) if isinstance(template.fields, dict) else template.fields
174+
175+
# --- JUNK FILTER ---
176+
import re
177+
tpl_fields = []
178+
for f in raw_fields:
179+
if not f: continue
180+
basename = str(f).split('.')[-1].split('[')[0].lower()
181+
if re.match(r'^(textfield|text|checkbox|check|button|btn|radio|listbox|combo|rectangle|line)\d*$', basename):
182+
continue
183+
tpl_fields.append(f)
184+
185+
print(f"[DATA LAKE] Junk Filter: compressed {len(raw_fields)} raw structural fields down to {len(tpl_fields)} meaningful targets.")
150186

151187
# --- THE MAGIC BRIDGE: AI Semantic Mapper ---
152188
from src.llm import LLM
@@ -297,64 +333,4 @@ def generate_narrative(incident_id: str, db: Session = Depends(get_db)):
297333
"narrative": narrative,
298334
"format": "markdown",
299335
"generated_at": datetime.utcnow().isoformat()
300-
}
301-
302-
303-
# ── Vision Model Endpoints ──────────────────────────────
304-
305-
from fastapi import UploadFile, File
306-
307-
@router.post("/{incident_id}/scene-photo")
308-
async def add_scene_photo(
309-
incident_id: str,
310-
file: UploadFile = File(...),
311-
db: Session = Depends(get_db)
312-
):
313-
"""
314-
Multimodal Vision Integration.
315-
Upload a scene photo to the incident. Uses Gemma 3 vision model to generate
316-
a professional narrative of the scene and merges it into the Data Lake Master JSON.
317-
"""
318-
if not incident_id:
319-
raise AppError("Missing incident_id", status_code=400)
320-
321-
incident = get_incident(db, incident_id)
322-
if not incident:
323-
# Auto-create if it doesn't exist
324-
incident = IncidentMasterData(
325-
incident_id=incident_id,
326-
master_json="{}",
327-
transcript_text="[VISION] Image submitted."
328-
)
329-
create_incident(db, incident)
330-
331-
import base64
332-
image_bytes = await file.read()
333-
334-
# Run the vision model (async)
335-
from src.llm import LLM
336-
llm = LLM()
337-
try:
338-
scene_narrative = await llm.async_vision_describe_scene(image_bytes)
339-
print(f"[VISION] Scene Narrative generated: {scene_narrative[:50]}...")
340-
except Exception as e:
341-
raise AppError(f"Vision model failed: {e}", status_code=500)
342-
343-
# Update the data lake using the smart consensus merge
344-
new_data = {
345-
"scene_visual_analysis": scene_narrative
346-
}
347-
348-
update_incident_json(
349-
db,
350-
incident_id,
351-
new_data,
352-
new_transcript=f"[VISUAL OBSERVATION]: {scene_narrative}"
353-
)
354-
355-
return {
356-
"incident_id": incident_id,
357-
"status": "photo_analyzed_and_merged",
358-
"narrative_preview": scene_narrative[:100] + "...",
359-
"message": "Scene analysis merged into master data lake."
360336
}

api/routes/templates.py

Lines changed: 93 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -220,74 +220,105 @@ def _scan_fields_with_tesseract(pdf_path: str, dpi: int = 200) -> list[dict]:
220220
lt = " ".join(w["text"] for w in lw)
221221
print(f" line {idx:02d}: '{lt[:90]}'")
222222

223-
# ── Identical field detection logic as PyMuPDF path ─────────────
223+
# ── Enhanced field detection: multi-colon + numbered patterns ───────
224+
# Handles forms like ICS-214 where:
225+
# - Multiple "Label: [blank] Label: [blank]" appear on one line
226+
# - Numbered fields "1. Incident Name" have no colon but ARE fillable
224227
for line_words in lines:
225228
line_words = sorted(line_words, key=lambda w: w["x0"])
226-
full_text = " ".join(w["text"] for w in line_words)
229+
full_text = " ".join(w["text"] for w in line_words)
227230

228231
has_colon = ":" in full_text
229232
has_underscores = "_" * 4 in full_text or full_text.count("_") >= 4
230233

231-
if not (has_colon or has_underscores):
232-
continue
233-
234-
# Skip header lines with content after colon
235-
if has_colon and not has_underscores:
236-
colon_pos = full_text.find(":")
237-
after_colon = full_text[colon_pos + 1:].strip()
238-
if after_colon and not after_colon.startswith("_"):
239-
label_part = full_text[:colon_pos].strip().lower()
240-
HEADER_WORDS = {"title", "form", "section", "page", "date", "version", "revision"}
241-
if any(hw in label_part for hw in HEADER_WORDS):
242-
continue
243-
244-
label_text = full_text.strip()
245-
answer_x: float | None = None
246234
answer_y = min(w["y0"] for w in line_words)
247235
answer_bottom = max(w["y1"] for w in line_words)
248236

237+
# ── Strategy A: UNDERSCORE blanks ───────────────────────────
249238
if has_underscores:
250239
for w in line_words:
251240
if "_" * 4 in w["text"]:
252-
answer_x = w["x0"]
253-
answer_bottom = w["y1"]
241+
answer_x = w["x0"]
254242
label_parts = [lw["text"] for lw in line_words if lw["x0"] < w["x0"]]
255-
if label_parts:
256-
label_text = " ".join(label_parts)
257-
break
258-
259-
if answer_x is None and has_colon:
260-
for w in line_words:
261-
if ":" in w["text"]:
262-
answer_x = w["x1"] + 2
263-
label_parts = []
264-
for lw in line_words:
265-
label_parts.append(lw["text"])
266-
if ":" in lw["text"]:
267-
break
268-
label_text = " ".join(label_parts)
269-
break
270-
271-
if answer_x is None:
272-
answer_x = line_words[-1]["x1"] + 2
273-
274-
clean_label = re.sub(r'^[\d\.\)\s]+', '', label_text)
275-
clean_label = clean_label.rstrip(': _\n').strip()
276-
277-
if not clean_label:
278-
continue
243+
label_text = " ".join(label_parts) if label_parts else full_text
244+
clean_label = re.sub(r'^[\d\.\)\s]+', '', label_text).rstrip(': _\n').strip()
245+
if clean_label:
246+
found_fields.append({
247+
"raw_label": clean_label, "answer_x": round(answer_x, 2),
248+
"answer_y": round(answer_y, 2), "answer_bottom": round(w["y1"], 2),
249+
"page_num": page_num, "page_w": round(pw_pt, 2), "page_h": round(ph_pt, 2),
250+
"line_text": full_text.strip(),
251+
})
252+
print(f" [FIELD] '{clean_label:25s}' answer_x={answer_x:.1f}pt y0={answer_y:.1f}pt")
253+
continue # underscore lines done
254+
255+
# ── Strategy B: ALL colons in one line → one field each ──────
256+
if has_colon:
257+
HEADER_WORDS = {"title", "form", "section", "page", "version", "revision", "ics", "noaa"}
258+
# Find every word that contains ":" and treat each as a field label endpoint
259+
colon_words = [i for i, w in enumerate(line_words) if ":" in w["text"]]
260+
for ci, colon_idx in enumerate(colon_words):
261+
# Label = words from previous colon_word+1 up to this colon_word (inclusive)
262+
prev_end = colon_words[ci - 1] + 1 if ci > 0 else 0
263+
label_parts = [line_words[j]["text"] for j in range(prev_end, colon_idx + 1)]
264+
label_text = " ".join(label_parts)
265+
clean_label = re.sub(r'^[\d\.\)\s/]+', '', label_text).rstrip(': _\n').strip()
266+
267+
if not clean_label or len(clean_label) < 2:
268+
continue
269+
if any(hw in clean_label.lower() for hw in HEADER_WORDS):
270+
continue
279271

280-
found_fields.append({
281-
"raw_label": clean_label,
282-
"answer_x": round(answer_x, 2),
283-
"answer_y": round(answer_y, 2),
284-
"answer_bottom": round(answer_bottom, 2),
285-
"page_num": page_num,
286-
"page_w": round(pw_pt, 2),
287-
"page_h": round(ph_pt, 2),
288-
"line_text": full_text.strip(),
289-
})
290-
print(f" [FIELD] '{clean_label:25s}' answer_x={answer_x:.1f}pt y0={answer_y:.1f}pt")
272+
# answer_x = right edge of the colon word + small padding
273+
answer_x = line_words[colon_idx]["x1"] + 2
274+
275+
# answer width = distance to next colon word (or page edge)
276+
if ci + 1 < len(colon_words):
277+
next_label_start = line_words[colon_words[ci + 1] - 1]["x0"] if colon_words[ci + 1] > 0 else pw_pt
278+
field_w = max(next_label_start - answer_x - 4, 20)
279+
else:
280+
field_w = pw_pt * 0.9 - answer_x
281+
282+
found_fields.append({
283+
"raw_label": clean_label, "answer_x": round(answer_x, 2),
284+
"answer_y": round(answer_y, 2), "answer_bottom": round(answer_bottom, 2),
285+
"page_num": page_num, "page_w": round(pw_pt, 2), "page_h": round(ph_pt, 2),
286+
"line_text": full_text.strip(),
287+
})
288+
print(f" [FIELD] '{clean_label:25s}' answer_x={answer_x:.1f}pt y0={answer_y:.1f}pt")
289+
continue # colon lines done — all colons processed
290+
291+
# ── Strategy C: Numbered fields "1. Incident Name" (no colon) ──
292+
# ICS-214 has headers like "1. Incident Name 2. Operational Period"
293+
# Find all numbered tokens, split the line at each, create one field per segment
294+
numbered_positions = [(m.start(), m.group()) for m in re.finditer(r'(?<!\d)\d+\.(?!\d)', full_text)]
295+
if len(numbered_positions) >= 1:
296+
# Split full_text into segments between numbered markers
297+
segments = []
298+
for si, (start, _) in enumerate(numbered_positions):
299+
end = numbered_positions[si + 1][0] if si + 1 < len(numbered_positions) else len(full_text)
300+
segments.append(full_text[start:end].strip())
301+
302+
for seg in segments:
303+
# The answer area is to the RIGHT of this label segment's last word in the line
304+
seg_clean = re.sub(r'^\d+\.\s*', '', seg).strip().rstrip(': _').strip()
305+
if not seg_clean or len(seg_clean) < 2:
306+
continue
307+
# Find x position of the last word in this segment
308+
seg_words = [w for w in line_words if w["text"] in seg.split()]
309+
if seg_words:
310+
last_seg_word = max(seg_words, key=lambda w: w["x1"])
311+
answer_x = last_seg_word["x1"] + 4
312+
else:
313+
answer_x = line_words[-1]["x1"] + 4
314+
315+
found_fields.append({
316+
"raw_label": seg_clean, "answer_x": round(answer_x, 2),
317+
"answer_y": round(answer_y, 2), "answer_bottom": round(answer_bottom, 2),
318+
"page_num": page_num, "page_w": round(pw_pt, 2), "page_h": round(ph_pt, 2),
319+
"line_text": full_text.strip(),
320+
})
321+
print(f" [FIELD] '{seg_clean:25s}' answer_x={answer_x:.1f}pt y0={answer_y:.1f}pt")
291322

292323
doc.close()
293324
print(f"[TESSERACT] Done — {len(found_fields)} field(s) found")
@@ -467,9 +498,14 @@ async def scan_static_template(template_id: int, db: Session = Depends(get_db)):
467498
})
468499
print(f"[SCAN] ✅ {scan_mode}{len(found_fields)} fields with exact coords")
469500

470-
# ── PATH 3: Gemma Vision — last resort only ───────────────────────────
471-
if not found_fields:
472-
print("[SCAN] PATH 3: Gemma Vision (last resort — both deterministic paths failed)...")
501+
# ── PATH 3: Gemma Vision — true last resort only ──────────────────────
502+
# Only fire if BOTH deterministic paths found absolutely nothing.
503+
# Gemma Vision is slow (~5-10 min) and coordinates can be imprecise
504+
# (off-page for complex table forms). Use only when Tesseract gets 0 fields.
505+
MIN_FIELDS_THRESHOLD = 0
506+
if len(found_fields) <= MIN_FIELDS_THRESHOLD:
507+
reason = "0 fields found" if not found_fields else f"only {len(found_fields)} fields found (table form needs Gemma vision)"
508+
print(f"[SCAN] PATH 3: Gemma Vision ({reason})...")
473509
scan_mode = "vision"
474510
llm = LLM()
475511
try:

src/filler.py

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -349,24 +349,35 @@ def fill_static_pdf(self, pdf_form: str, coordinates: list, data: dict) -> str:
349349
# Convert percentages (0-100) back to absolute PDF points
350350
x_pts = (coord.x / 100.0) * page_w
351351
y_pts_from_top = (coord.y / 100.0) * page_h
352+
w_pts = (coord.width / 100.0) * page_w
352353

353354
# CRITICAL: reportlab uses BOTTOM-LEFT origin
354-
# pdfplumber/our DB uses TOP-LEFT origin
355-
# So we need to flip the Y axis
356355
y_reportlab = page_h - y_pts_from_top
357356

358-
# Draw the text slightly above the baseline
359-
# (y_reportlab points to the TOP of the line; text needs
360-
# to sit on the baseline which is near the bottom of the line)
361-
font_size = 10
362-
h_pts = (coord.height / 100.0) * page_h
363-
text_y = y_reportlab - h_pts + 2 # near bottom of field area
364-
365-
c.setFontSize(font_size)
366-
c.drawString(x_pts, text_y, val_str)
357+
# Use Paragraph to support word-wrapping and newlines natively
358+
from reportlab.platypus import Paragraph
359+
from reportlab.lib.styles import getSampleStyleSheet
360+
style = getSampleStyleSheet()["Normal"]
361+
style.fontName = "Helvetica"
362+
style.fontSize = 10
363+
style.leading = 12
364+
365+
# If width is 0 or extremely small due to edge scanning, give it a default reasonable width
366+
avail_w = w_pts if w_pts > 40 else 250
367+
368+
html_val = val_str.replace("\n", "<br/>")
369+
p = Paragraph(html_val, style)
370+
371+
# Wrap computes how much actual box height (bh) the text needs
372+
bw, bh = p.wrap(avail_w, page_h)
373+
374+
# drawOn places the very bottom of the paragraph.
375+
# Since y_reportlab is the top of our field boundary, we subtract bh to anchor it below.
376+
draw_y = y_reportlab - bh
377+
p.drawOn(c, x_pts, draw_y)
367378

368379
fields_filled += 1
369-
print(f" [OVERLAY] '{coord.field_label}' → '{val_str}' at ({x_pts:.1f}, {text_y:.1f})")
380+
print(f" [OVERLAY] '{coord.field_label}' (WRAP w={avail_w:.0f}pt) → {val_str[:30]}... at ({x_pts:.1f}, {draw_y:.1f})")
370381

371382
c.save()
372383
packet.seek(0)

0 commit comments

Comments
 (0)