11import os
2+ import re
23import shutil
34import uuid
45from fastapi import APIRouter , Depends , UploadFile , File , Form
1112
1213router = APIRouter (prefix = "/templates" , tags = ["templates" ])
1314
14- # Save directly into src/inputs/ — stable location, won't get wiped
1515TEMPLATES_DIR = os .path .join ("src" , "inputs" )
1616os .makedirs (TEMPLATES_DIR , exist_ok = True )
1717
@@ -22,24 +22,19 @@ async def create(
2222 file : UploadFile = File (...),
2323 db : Session = Depends (get_db )
2424):
25- # Validate PDF
2625 if not file .filename .endswith (".pdf" ):
2726 raise AppError ("Only PDF files are allowed" , status_code = 400 )
2827
29- # Save uploaded file with unique name into src/inputs/
3028 unique_name = f"{ uuid .uuid4 ().hex } _{ file .filename } "
3129 save_path = os .path .join (TEMPLATES_DIR , unique_name )
3230
3331 with open (save_path , "wb" ) as f :
3432 shutil .copyfileobj (file .file , f )
3533
36- # Extract fields using commonforms + pypdf
37- # Store as simple list of field name strings — what Filler expects
3834 try :
3935 from commonforms import prepare_form
4036 from pypdf import PdfReader
4137
42- # Read real field names directly from original PDF
4338 reader = PdfReader (save_path )
4439 raw_fields = reader .get_fields () or {}
4540
@@ -49,7 +44,6 @@ async def create(
4944 if isinstance (field_data , dict ):
5045 label = field_data .get ("/TU" )
5146 if not label :
52- import re
5347 label = re .sub (r'([a-z])([A-Z])' , r'\1 \2' , internal_name )
5448 label = re .sub (r'_af_.*$' , '' , label )
5549 label = label .replace ('_' , ' ' ).strip ().title ()
@@ -59,101 +53,285 @@ async def create(
5953 print (f"Field extraction failed: { e } " )
6054 fields = []
6155
62- # Save to DB
6356 tpl = Template (name = name , pdf_path = save_path , fields = fields )
6457 return create_template (db , tpl )
6558
6659
6760@router .get ("" , response_model = list [TemplateResponse ])
68- def list_templates (
69- limit : int = 100 ,
70- offset : int = 0 ,
71- db : Session = Depends (get_db )
72- ):
61+ def list_templates (limit : int = 100 , offset : int = 0 , db : Session = Depends (get_db )):
7362 return get_all_templates (db , limit = limit , offset = offset )
7463
7564
7665@router .get ("/{template_id}" , response_model = TemplateResponse )
77- def get_template_by_id (
78- template_id : int ,
79- db : Session = Depends (get_db )
80- ):
66+ def get_template_by_id (template_id : int , db : Session = Depends (get_db )):
8167 from api .db .repositories import get_template
8268 tpl = get_template (db , template_id )
8369 if not tpl :
8470 raise AppError ("Template not found" , status_code = 404 )
8571 return tpl
8672
8773
74+ # ── PyMuPDF-based deterministic field finder ───────────────────────────
75+ #
76+ # Approach (for static non-fillable PDFs with a text layer):
77+ #
78+ # 1. PyMuPDF page.get_text("words") → word-level bboxes (x0,y0,x1,y1,text)
79+ # 2. Group words into visual lines by Y coordinate (3pt tolerance)
80+ # 3. Lines containing ":" or "____" are field lines
81+ # 4. Blank zone = underscore position OR right-edge of colon word + small gap
82+ # 5. Gemma (text-only) cleans raw label into snake_case — no vision needed
83+ # 6. Coordinates stored as percentages of page size in the DB
84+ # → filler converts back to absolute pts at render time
85+ #
86+ # PyMuPDF is used instead of pdfplumber because it is already installed
87+ # as part of the core stack (fitz) and avoids an optional dependency.
88+ #
89+ # Gemma Vision is ONLY used as a fallback when the PDF has NO text layer
90+ # (i.e. a flat image scan with no searchable text).
91+
92+ def _scan_fields_with_pymupdf (pdf_path : str ) -> list [dict ]:
93+ """
94+ Deterministic field detection for static PDFs using PyMuPDF word-level bboxes.
95+
96+ Uses page.get_text("words") which returns tuples:
97+ (x0, y0, x1, y1, text, block_no, line_no, word_no)
98+ where the coordinate origin is the TOP-LEFT of the page (y increases downward).
99+
100+ Returns a list of dicts, each with:
101+ raw_label: cleaned label text (e.g. "Name")
102+ answer_x: absolute x-point where the answer starts (pt, top-left origin)
103+ answer_y: absolute y-point of the line top (pt, top-left origin)
104+ answer_bottom: absolute y-point of the line bottom (pt, top-left origin)
105+ page_num: 0-indexed page number
106+ page_w: page width in points
107+ page_h: page height in points
108+ line_text: full original line text (for debugging)
109+ """
110+ import fitz # PyMuPDF — already installed
111+
112+ found_fields = []
113+
114+
115+ return found_fields
116+
117+
118+ async def _clean_labels_with_gemma (raw_labels : list [str ], image_bytes : bytes ) -> list [str ]:
119+ """
120+ Optional: Ask Gemma to convert raw label strings to clean snake_case names.
121+ Falls back to simple regex cleaning if Gemma fails.
122+ """
123+ from src .llm import LLM
124+ import json
125+
126+ llm = LLM ()
127+
128+ # Build a prompt asking Gemma to clean these specific labels
129+ label_list = "\n " .join (f" { i } : \" { l } \" " for i , l in enumerate (raw_labels ))
130+ prompt = f'''I have these raw field labels from a form. Convert each to a clear, short snake_case identifier.
131+
132+ Raw labels:
133+ { label_list }
134+
135+ Return a JSON array of strings in the SAME ORDER, one snake_case name per label.
136+ Example: ["full_name", "email_address", "phone_number"]
137+
138+ Rules:
139+ - Keep the semantic meaning (e.g. "Name" → "full_name", "Tel" → "phone")
140+ - Use only lowercase letters and underscores
141+ - Output ONLY the JSON array'''
142+
143+ try :
144+ import base64
145+ ollama_host = os .getenv ("OLLAMA_HOST" , "http://localhost:11434" ).rstrip ("/" )
146+ vision_model = os .getenv ("FIREFORM_VISION_MODEL" , "gemma3:4b" )
147+ b64_image = base64 .b64encode (image_bytes ).decode ("utf-8" )
148+
149+ payload = {
150+ "model" : vision_model ,
151+ "prompt" : prompt ,
152+ "images" : [b64_image ],
153+ "stream" : False ,
154+ }
155+
156+ import requests
157+ response = requests .post (f"{ ollama_host } /api/generate" , json = payload , timeout = 300 )
158+ response .raise_for_status ()
159+ res_text = response .json ().get ("response" , "" ).strip ()
160+
161+ # Clean markdown fences
162+ for fence in ["```json" , "```" ]:
163+ if res_text .startswith (fence ):
164+ res_text = res_text [len (fence ):]
165+ if res_text .endswith ("```" ):
166+ res_text = res_text [:- 3 ]
167+ res_text = res_text .strip ()
168+
169+ data = json .loads (res_text )
170+ if isinstance (data , list ) and len (data ) == len (raw_labels ):
171+ print (f"[GEMMA] Cleaned labels: { data } " )
172+ return [str (d ) for d in data ]
173+ elif isinstance (data , dict ):
174+ for v in data .values ():
175+ if isinstance (v , list ) and len (v ) == len (raw_labels ):
176+ return [str (d ) for d in v ]
177+ except Exception as e :
178+ print (f"[GEMMA] Label cleaning failed ({ e } ), using regex fallback" )
179+
180+ # Regex fallback
181+ clean = []
182+ for label in raw_labels :
183+ snake = re .sub (r'[^a-zA-Z0-9]' , '_' , label .strip ().lower ())
184+ snake = re .sub (r'_+' , '_' , snake ).strip ('_' )
185+ clean .append (snake or "unknown_field" )
186+ return clean
187+
188+
189+ # ── Scan endpoint ─────────────────────────────────────────────────────────────
190+
88191@router .post ("/{template_id}/scan" )
89- async def scan_static_template (
90- template_id : int ,
91- db : Session = Depends (get_db )
92- ):
192+ async def scan_static_template (template_id : int , db : Session = Depends (get_db )):
93193 """
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.
194+ Deterministic scan for static (non-fillable) PDFs.
195+ Scan Once → Fill Forever.
196+
197+ For PDFs WITH a text layer:
198+ 1. pdfplumber extracts word-level coordinates (deterministic)
199+ 2. We find field lines (colon/underscore patterns)
200+ 3. Gemma cleans up the label names (semantic)
201+ 4. Absolute point coordinates are saved to FormFieldCoordinates
202+
203+ For PDFs WITHOUT a text layer (flat scans):
204+ 1. Gemma Vision scans the image for field bounding boxes
205+ 2. Results are saved to FormFieldCoordinates
206+
207+ Either way: the template is scanned ONCE and coordinates are
208+ stored in the Data Lake forever. No re-scanning, no guessing.
97209 """
98- print (f"\n \n 🚨 [SCAN] Vision scan requested for template { template_id } \n " )
210+ print (f"\n 🔍 [SCAN] Template { template_id } \n " )
99211 from api .db .repositories import get_template , get_template_coordinates , create_field_coordinates
100212 from api .db .models import FormFieldCoordinates
213+ import fitz
214+ from src .llm import LLM
101215
102216 tpl = get_template (db , template_id )
103217 if not tpl :
104218 raise AppError ("Template not found" , status_code = 404 )
105-
106219 if not os .path .exists (tpl .pdf_path ):
107220 raise AppError ("Template PDF not found on disk" , status_code = 404 )
108221
109222 existing_coords = get_template_coordinates (db , template_id )
110223 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
224+ return {"status" : "already_scanned" , "message" : "Already scanned." , "fields_found" : len (existing_coords )}
119225
226+ # Get page dimensions and check for text layer
120227 doc = fitz .open (tpl .pdf_path )
121228 if len (doc ) == 0 :
122229 raise AppError ("PDF has no pages" , status_code = 400 )
123230
124- # Render page 1 as a high-res image for the vision model
125231 page = doc [0 ]
232+ page_w , page_h = page .rect .width , page .rect .height
126233 pix = page .get_pixmap (dpi = 150 )
127234 img_bytes = pix .tobytes ("png" )
235+ word_count = len (page .get_text ("words" ))
128236 doc .close ()
129237
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 )
238+ has_text_layer = word_count > 5
239+ print (f"[SCAN] { page_w :.0f} ×{ page_h :.0f} pt | words={ word_count } | text_layer={ has_text_layer } " )
240+
241+ found_fields : list [dict ] = []
242+ scan_mode = "unknown"
243+
244+ # ── PRIMARY PATH: PyMuPDF deterministic scan ─────────────────────────
245+ if has_text_layer :
246+ scan_mode = "pymupdf"
247+ print ("[SCAN] Using PyMuPDF deterministic word-level scan..." )
248+
249+ try :
250+ raw_fields = _scan_fields_with_pymupdf (tpl .pdf_path )
251+ except Exception as e :
252+ print (f"[SCAN] ⚠️ PyMuPDF scan crashed: { e } " )
253+ import traceback
254+ traceback .print_exc ()
255+ raw_fields = []
136256
137- if not vision_fields :
138- return {"status" : "no_fields_found" , "message" : "Vision model found no fields." }
257+ if raw_fields :
258+ print (f"[SCAN] Found { len (raw_fields )} field lines:" )
259+ for f in raw_fields :
260+ print (f" '{ f ['raw_label' ]:30s} ' → answer_x={ f ['answer_x' ]:.1f} pt y={ f ['answer_y' ]:.1f} pt" )
139261
140- # Save coordinates into DB
141- stored_coords = []
142- semantic_fields = {}
262+ # Ask Gemma to clean up label names (semantic intelligence)
263+ raw_labels = [f ["raw_label" ] for f in raw_fields ]
264+ clean_labels = await _clean_labels_with_gemma (raw_labels , img_bytes )
265+
266+ for i , rf in enumerate (raw_fields ):
267+ snake_label = clean_labels [i ] if i < len (clean_labels ) else "unknown_field"
268+ found_fields .append ({
269+ "label" : snake_label ,
270+ "display" : rf ["raw_label" ],
271+ "x" : rf ["answer_x" ], # absolute PDF points
272+ "y" : rf ["answer_y" ], # absolute PDF points
273+ "w" : rf ["page_w" ] * 0.9 - rf ["answer_x" ], # remaining width
274+ "h" : rf ["answer_bottom" ] - rf ["answer_y" ], # line height
275+ "page_num" : rf ["page_num" ],
276+ "page_w" : rf ["page_w" ],
277+ "page_h" : rf ["page_h" ],
278+ "type" : "text" ,
279+ "coord_unit" : "pt" , # MARKER: absolute points, not %
280+ })
281+
282+ print (f"\n [SCAN] ✅ pdfplumber matched { len (found_fields )} fields" )
283+
284+ # ── FALLBACK PATH: Gemma Vision for image-only PDFs ──────────────────
285+ if not found_fields :
286+ reason = "no text layer" if not has_text_layer else "pdfplumber found 0 fields"
287+ print (f"[SCAN] Fallback → Gemma Vision ({ reason } )..." )
288+ scan_mode = "vision"
289+ llm = LLM ()
290+ try :
291+ found_fields = await llm .async_vision_scan_fields (img_bytes )
292+ # Vision fields come as percentages — mark them accordingly
293+ for vf in found_fields :
294+ vf ["coord_unit" ] = "pct"
295+ print (f"[SCAN] Gemma vision: { len (found_fields )} fields" )
296+ except Exception as e :
297+ raise AppError (f"Vision scan failed: { e } " , status_code = 500 )
298+
299+ if not found_fields :
300+ return {"status" : "no_fields_found" , "message" : "No fields detected." }
301+
302+ # ── Save to Data Lake (FormFieldCoordinates) ─────────────────────────
303+ stored_coords , semantic_fields = [], {}
304+ for vf in found_fields :
305+ coord_unit = vf .get ("coord_unit" , "pct" )
306+
307+ if coord_unit == "pt" :
308+ # Absolute points: convert to percentages for DB storage
309+ # (DB schema uses 0-100 percentages)
310+ x_pct = (vf ["x" ] / vf ["page_w" ]) * 100
311+ y_pct = (vf ["y" ] / vf ["page_h" ]) * 100
312+ w_pct = (vf ["w" ] / vf ["page_w" ]) * 100
313+ h_pct = (vf ["h" ] / vf ["page_h" ]) * 100
314+ else :
315+ # Already percentages (from vision fallback)
316+ x_pct = float (vf .get ("x" , 0 ))
317+ y_pct = float (vf .get ("y" , 0 ))
318+ w_pct = float (vf .get ("w" , vf .get ("width" , 20 )))
319+ h_pct = float (vf .get ("h" , vf .get ("height" , 5 )))
143320
144- for vf in vision_fields :
145321 c = FormFieldCoordinates (
146322 template_id = template_id ,
147323 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 )) ),
324+ page_number = vf . get ( "page_num" , 0 ) ,
325+ x = round ( x_pct , 3 ),
326+ y = round ( y_pct , 3 ),
327+ width = round ( w_pct , 3 ),
328+ height = round ( h_pct , 3 ),
153329 field_type = vf .get ("type" , "text" )
154330 )
155331 stored_coords .append (c )
156- semantic_fields [c .field_label ] = c .field_label .replace ("_" , " " ).title ()
332+ semantic_fields [c .field_label ] = vf .get ("display" , c .field_label ).replace ("_" , " " ).title ()
333+
334+ print (f" 💾 { c .field_label :25s} → x={ c .x :.1f} % y={ c .y :.1f} % w={ c .width :.1f} % [{ scan_mode } ]" )
157335
158336 create_field_coordinates (db , stored_coords )
159337
@@ -164,6 +342,7 @@ async def scan_static_template(
164342
165343 return {
166344 "status" : "success" ,
167- "message" : f"Vision scan complete — { len (stored_coords )} fields mapped." ,
168- "fields" : semantic_fields
345+ "scan_mode" : scan_mode ,
346+ "message" : f"Scan complete ({ scan_mode } ) — { len (stored_coords )} fields." ,
347+ "fields" : semantic_fields ,
169348 }
0 commit comments