puff-n-parse-backend / services /json_mapper.py
Gifted-oNe's picture
feat: Intelligent inline colon mapping and datetime inference
efc4dad
Raw
History Blame Contribute Delete
10.4 kB
"""
JSON Mapper — Structures raw extracted text into field:value JSON pairs.
Uses heuristic pattern matching to identify:
- Key: Value patterns (colon-separated)
- Key = Value patterns
- Tabular data (from PDF table extraction)
- Labelled form fields
- Multi-line grouped content
Designed to handle diverse document formats common in African government
and business contexts: invoices, certificates, forms, applications.
"""
import re
from models.schemas import ExtractedField
def map_text_to_fields(raw_text: str, tables: list | None = None, ocr_blocks: list | None = None) -> list[ExtractedField]:
"""
Convert raw extracted text (and optional table/block data) into
structured ExtractedField objects.
Args:
raw_text: The full text extracted from the document.
tables: Optional list of table field dicts from pdf_parser.
ocr_blocks: Optional list of OCR detection blocks with confidence scores.
Returns:
List of ExtractedField objects ready for the frontend.
"""
fields: list[ExtractedField] = []
seen_names: set[str] = set()
# Priority 1: Use table data if available (most structured)
if tables:
for table_field in tables:
name = table_field.get("name", "").strip()
if name and name not in seen_names:
fields.append(ExtractedField(
name=name,
value=table_field.get("value", ""),
field_type=table_field.get("field_type", "text"),
confidence=table_field.get("confidence", 0.9),
))
seen_names.add(name)
# Priority 2: Parse raw text for key-value patterns
text_fields = _extract_key_value_pairs(raw_text)
for field in text_fields:
if field.name not in seen_names:
fields.append(field)
seen_names.add(field.name)
# Priority 3: If we have OCR blocks with confidence, enhance field confidence
if ocr_blocks and fields:
_enhance_confidence(fields, ocr_blocks)
# If no structured fields found, create line-by-line fields
if not fields:
fields = _fallback_line_fields(raw_text)
# Priority 4: Semantic Refinement Pass
# Rename generic names (like "Column_1 (Row 1)" or "Line X") based on the value's content
for field in fields:
_refine_field_name_by_value(field)
# Deduplicate after refinement
final_fields = []
seen_final_names = set()
for f in fields:
# If the name is duplicated, append a counter
original_name = f.name
counter = 1
while f.name in seen_final_names:
f.name = f"{original_name} {counter}"
counter += 1
final_fields.append(f)
seen_final_names.add(f.name)
return final_fields
def _extract_key_value_pairs(text: str) -> list[ExtractedField]:
"""
Extract key-value pairs from text using multiple pattern strategies.
"""
fields = []
# Strategy 1: "Key: Value" patterns (most common in forms)
colon_pattern = re.compile(
r'^[\s]*([A-Za-z][A-Za-z0-9\s/\-_\(\)\.]{1,50})\s*[:]\s*(.+)$',
re.MULTILINE,
)
for match in colon_pattern.finditer(text):
name = match.group(1).strip()
value = match.group(2).strip()
if len(name) >= 2 and len(value) >= 1 and not _is_noise(name):
fields.append(ExtractedField(
name=name,
value=value,
field_type=_infer_type(value),
confidence=0.85,
))
# Strategy 2: "Key = Value" patterns
equals_pattern = re.compile(
r'^[\s]*([A-Za-z][A-Za-z0-9\s/\-_]{1,40})\s*[=]\s*(.+)$',
re.MULTILINE,
)
for match in equals_pattern.finditer(text):
name = match.group(1).strip()
value = match.group(2).strip()
if len(name) >= 2 and len(value) >= 1 and not _is_noise(name):
fields.append(ExtractedField(
name=name,
value=value,
field_type=_infer_type(value),
confidence=0.80,
))
# Strategy 3: Tab-separated fields (common in printed forms)
tab_pattern = re.compile(
r'^[\s]*([A-Za-z][A-Za-z0-9\s]{1,40})\t+(.+)$',
re.MULTILINE,
)
for match in tab_pattern.finditer(text):
name = match.group(1).strip()
value = match.group(2).strip()
if len(name) >= 2 and len(value) >= 1 and not _is_noise(name):
fields.append(ExtractedField(
name=name,
value=value,
field_type=_infer_type(value),
confidence=0.75,
))
return fields
def _fallback_line_fields(text: str) -> list[ExtractedField]:
"""
When no key-value patterns are found, create fields from
non-empty lines of text. Each line becomes a "Line N" field.
"""
fields = []
lines = [line.strip() for line in text.split("\n") if line.strip()]
for i, line in enumerate(lines, start=1):
# Skip very short lines (likely noise) or page separators
if len(line) < 3 or line.startswith("---"):
continue
fields.append(ExtractedField(
name=f"Line {i}",
value=line,
field_type="text",
confidence=0.60,
))
return fields
def _enhance_confidence(fields: list[ExtractedField], ocr_blocks: list[dict]) -> None:
"""
If OCR blocks contain confidence scores, use them to
update the confidence of matching fields.
"""
# Build a lookup of text → confidence from OCR blocks
block_confidences: dict[str, float] = {}
for block in ocr_blocks:
text = block.get("text", "").strip().lower()
conf = block.get("confidence", 0.0)
if text:
block_confidences[text] = conf
# Match fields to OCR blocks by checking if field value appears in blocks
for field in fields:
value_lower = field.value.strip().lower()
if value_lower in block_confidences:
field.confidence = block_confidences[value_lower]
def _infer_type(value: str) -> str:
"""Heuristic type inference for extracted values."""
if not value:
return "text"
cleaned = value.replace(",", "").replace(" ", "").replace("R", "").replace("$", "").replace("€", "")
# Number check
try:
float(cleaned)
return "number"
except ValueError:
pass
# Date check
date_patterns = [
r'\d{1,2}[/\-]\d{1,2}[/\-]\d{2,4}', # DD/MM/YYYY or similar
r'\d{4}[/\-]\d{1,2}[/\-]\d{1,2}', # YYYY-MM-DD
]
for pattern in date_patterns:
if re.match(pattern, value.strip()):
return "date"
# Email check
if re.match(r'[^@]+@[^@]+\.[^@]+', value.strip()):
return "email"
# Phone check
if re.match(r'^[\+]?[\d\s\-\(\)]{7,15}$', value.strip()):
return "phone"
return "text"
def _is_noise(text: str) -> bool:
"""Check if a detected field name is likely noise or a false positive."""
noise_words = {
"page", "date", "time", "http", "www", "copyright",
"all rights", "reserved", "confidential",
}
lower = text.lower().strip()
return lower in noise_words or len(lower) < 2
def _refine_field_name_by_value(field: ExtractedField) -> None:
"""
If the field name is generic (e.g. Column_1, Line 3, RECEIPT) and the value matches
known semantic patterns, rename the field to something intelligent.
"""
# Check if the name looks generic or noisy
generic_patterns = [
r'^column_\d+', r'^line \d+', r'row \d+', r'^receipt$', r'^invoice$', r'^unknown$'
]
name_lower = field.name.lower().strip()
is_generic = any(re.search(p, name_lower) for p in generic_patterns)
# Or if the name is unusually long (which means the parser mapped a whole text block as the name)
is_long_name = len(field.name) > 40
# We always try to extract 'Order #' if the value has it, regardless of how generic the name is.
value_lower = str(field.value).lower().strip()
# 1. Order Number
if 'order #' in value_lower or 'order no' in value_lower:
field.name = 'Order Number'
return
# 2. Invoice Number
if 'invoice #' in value_lower or 'invoice no' in value_lower:
field.name = 'Invoice Number'
return
# If the name isn't generic and isn't super long, we trust it.
if not is_generic and not is_long_name:
return
val = str(field.value).strip()
# 3. Website / URL
if re.match(r'^(https?://)?(www\.)?[a-zA-Z0-9-]+\.[a-zA-Z]{2,}(/.*)?$', val):
field.name = 'Website'
return
# 4. Email Address
if re.match(r'^[^@\s]+@[^@\s]+\.[^@\s]+$', val):
field.name = 'Email Address'
return
# 5. Date & Time combined
if re.match(r'^\d{1,2}[/\-]\d{1,2}[/\-]\d{2,4}\s+\d{1,2}:\d{2}(:\d{2})?\s*(AM|PM|am|pm)?$', val):
field.name = 'Date & Time'
return
# 6. Date only
date_patterns = [
r'^\d{1,2}[/\-]\d{1,2}[/\-]\d{2,4}$',
r'^\d{4}[/\-]\d{1,2}[/\-]\d{1,2}$',
r'^[a-zA-Z]{3,9} \d{1,2},? \d{4}$' # e.g. Jan 1, 2023
]
if any(re.match(p, val) for p in date_patterns):
field.name = 'Date'
return
# 7. Time only
if re.match(r'^\d{1,2}:\d{2}(:\d{2})?\s*(AM|PM|am|pm)?$', val):
field.name = 'Time'
return
# 7. Physical Address (heuristic: contains street/st/ave/blvd/suite/city/state/zip)
address_keywords = ['street', 'st', 'st.', 'avenue', 'ave', 'boulevard', 'blvd', 'suite', 'road', 'rd', 'drive', 'dr']
if len(val) > 15 and any(kw in value_lower for kw in address_keywords) and any(c.isdigit() for c in val):
field.name = 'Address'
return
# 8. Phone Number
if re.match(r'^[\+]?[\d\s\-\(\)]{10,15}$', val):
field.name = 'Phone Number'
return
# 9. Amount / Currency
if re.match(r'^[\$\£\€R]?\s*\d{1,3}(,\d{3})*(\.\d{2})?$', val) and any(c in val for c in '$£€R.'):
field.name = 'Amount'
return
# If it was a long name, and we couldn't classify it, we just call it "Text Block"
if is_long_name:
field.name = 'Text Block'