rishabhsai's picture
Codex: upload Offline Form Pilot Space
57a67db verified
Raw
History Blame Contribute Delete
9.13 kB
"""Structured form analysis for Offline Form Pilot."""
from __future__ import annotations
import csv
import io
import json
import re
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from difflib import SequenceMatcher
from pathlib import Path
from typing import Any
COMMON_FIELDS = [
"full name",
"first name",
"last name",
"date of birth",
"email",
"phone",
"address",
"city",
"state",
"postal code",
"zip code",
"country",
"employer",
"school",
"student id",
"account number",
"policy number",
"emergency contact",
"relationship",
"signature",
"date",
]
SENSITIVE_TERMS = {
"ssn",
"social security",
"passport",
"bank",
"routing",
"account",
"card",
"credit",
"medical",
"diagnosis",
"tax",
"visa",
"immigration",
}
STOPWORDS = {
"the",
"a",
"an",
"your",
"you",
"of",
"for",
"and",
"or",
"to",
"in",
"on",
"with",
"please",
"enter",
"provide",
}
@dataclass(frozen=True)
class FieldMatch:
field: str
proposed_value: str
status: str
confidence: int
source: str
note: str
def normalize_label(text: str) -> str:
"""Normalize labels for fuzzy matching."""
text = text.lower()
text = re.sub(r"[^a-z0-9]+", " ", text)
text = re.sub(r"\s+", " ", text).strip()
return text
def _tokens(text: str) -> set[str]:
return {token for token in normalize_label(text).split() if token not in STOPWORDS}
def parse_user_facts(raw_facts: str) -> dict[str, str]:
"""Parse key-value facts from pasted user notes."""
facts: dict[str, str] = {}
free_lines: list[str] = []
for line in raw_facts.splitlines():
cleaned = line.strip().strip("-*")
if not cleaned:
continue
match = re.match(r"^([^:=]{2,60})\s*[:=]\s*(.+)$", cleaned)
if match:
key = normalize_label(match.group(1))
value = match.group(2).strip()
facts[key] = value
else:
free_lines.append(cleaned)
inferred = infer_facts_from_free_text("\n".join(free_lines))
for key, value in inferred.items():
facts.setdefault(key, value)
return facts
def infer_facts_from_free_text(text: str) -> dict[str, str]:
"""Extract a small set of common facts from unstructured text."""
facts: dict[str, str] = {}
email = re.search(r"[\w.+-]+@[\w.-]+\.[a-zA-Z]{2,}", text)
if email:
facts["email"] = email.group(0)
phone = re.search(r"(?:\+?\d[\d .()-]{7,}\d)", text)
if phone:
facts["phone"] = phone.group(0).strip()
zip_code = re.search(r"\b\d{5}(?:-\d{4})?\b", text)
if zip_code:
facts["zip code"] = zip_code.group(0)
facts["postal code"] = zip_code.group(0)
dob = re.search(r"\b(?:dob|date of birth)\s*[:=]?\s*([A-Za-z0-9, /.-]{6,20})", text, re.I)
if dob:
facts["date of birth"] = dob.group(1).strip()
return facts
def detect_fields(form_text: str) -> list[str]:
"""Find likely form fields from pasted form text."""
candidates: list[str] = []
for line in form_text.splitlines():
cleaned = line.strip()
if not cleaned:
continue
cleaned = re.sub(r"\s+", " ", cleaned)
label_match = re.match(r"^([A-Za-z][A-Za-z0-9 /'().,-]{1,70})\s*[:_]{1,}\s*(?:\[\s*\])?\s*$", cleaned)
if label_match:
candidates.append(label_match.group(1))
continue
bracket_match = re.match(r"^([A-Za-z][A-Za-z0-9 /'().,-]{1,70})\s*\[\s*\]\s*$", cleaned)
if bracket_match:
candidates.append(bracket_match.group(1))
continue
inline_match = re.match(r"^([A-Za-z][A-Za-z0-9 /'().,-]{1,45})\s*:\s+_{2,}", cleaned)
if inline_match:
candidates.append(inline_match.group(1))
lowered_form = normalize_label(form_text)
for common in COMMON_FIELDS:
if common in lowered_form:
candidates.append(common)
unique: list[str] = []
seen: set[str] = set()
for candidate in candidates:
label = normalize_label(candidate)
if len(label) < 2 or label in seen:
continue
seen.add(label)
unique.append(candidate.strip(" :_"))
return unique
def match_field(field: str, facts: dict[str, str]) -> FieldMatch:
"""Match one field label to available user facts."""
field_norm = normalize_label(field)
field_tokens = _tokens(field)
best_key = ""
best_score = 0.0
for key in facts:
key_tokens = _tokens(key)
overlap = len(field_tokens & key_tokens) / max(1, len(field_tokens | key_tokens))
ratio = SequenceMatcher(None, field_norm, key).ratio()
score = max(overlap, ratio * 0.85)
if score > best_score:
best_key = key
best_score = score
if not best_key:
return _missing_match(field, "No matching user fact found.")
value = facts[best_key]
sensitive = is_sensitive_field(field)
if best_score >= 0.86 or field_norm == best_key:
confidence = 95 if not sensitive else 84
status = "review" if sensitive else "ready"
note = "Strong label match."
elif best_score >= 0.58:
confidence = int(best_score * 100)
status = "review"
note = f"Possible match from '{best_key}'."
else:
return _missing_match(field, "No close enough user fact found.")
if sensitive:
note += " Sensitive field: verify manually before copying."
return FieldMatch(
field=field,
proposed_value=value,
status=status,
confidence=confidence,
source=best_key,
note=note,
)
def _missing_match(field: str, note: str) -> FieldMatch:
return FieldMatch(
field=field,
proposed_value="",
status="missing",
confidence=0,
source="",
note=note,
)
def is_sensitive_field(field: str) -> bool:
label = normalize_label(field)
return any(term in label for term in SENSITIVE_TERMS)
def questions_for_missing(matches: list[FieldMatch]) -> list[str]:
"""Generate plain-English follow-up questions for missing fields."""
questions = []
for match in matches:
if match.status == "missing":
questions.append(f"What should go in '{match.field}'?")
return questions
def _risk_summary(matches: list[FieldMatch]) -> list[str]:
risks = []
missing = sum(1 for match in matches if match.status == "missing")
review = sum(1 for match in matches if match.status == "review")
sensitive = sum(1 for match in matches if is_sensitive_field(match.field))
if missing:
risks.append(f"{missing} field(s) still need information.")
if review:
risks.append(f"{review} field(s) should be reviewed before copying.")
if sensitive:
risks.append(f"{sensitive} sensitive field(s) detected.")
if not risks:
risks.append("All detected fields have proposed values, but user review is still required.")
return risks
def analyze_form(form_text: str, user_facts: str, use_demo_fields: bool = True) -> dict[str, Any]:
"""Analyze form text and facts into reviewable outputs."""
fields = detect_fields(form_text)
if not fields and use_demo_fields:
fields = ["Full name", "Email", "Phone", "Address", "Date", "Signature"]
facts = parse_user_facts(user_facts)
matches = [match_field(field, facts) for field in fields]
rows = [asdict(match) for match in matches]
return {
"created_at": datetime.now(timezone.utc).isoformat(),
"fields": fields,
"facts": facts,
"rows": rows,
"questions": questions_for_missing(matches),
"risk_summary": _risk_summary(matches),
"copy_ready": copy_ready_text(matches),
}
def copy_ready_text(matches: list[FieldMatch]) -> str:
"""Create a conservative copy-ready field list."""
lines = []
for match in matches:
value = match.proposed_value if match.proposed_value else "[NEEDS USER INPUT]"
flag = " REVIEW" if match.status == "review" else ""
lines.append(f"{match.field}: {value}{flag}")
return "\n".join(lines)
def export_trace(payload: dict[str, Any], directory: Path | str = "traces") -> str:
"""Write one anonymizable JSON trace and return its path."""
out_dir = Path(directory)
out_dir.mkdir(parents=True, exist_ok=True)
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
path = out_dir / f"formpilot_trace_{stamp}.json"
path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
return str(path)
def rows_to_csv(rows: list[dict[str, Any]]) -> str:
"""Serialize rows for quick export."""
output = io.StringIO()
writer = csv.DictWriter(
output,
fieldnames=["field", "proposed_value", "status", "confidence", "source", "note"],
)
writer.writeheader()
writer.writerows(rows)
return output.getvalue()