aibridze/document_intelligence
0
1"""2Extraction Validator — post-extraction quality checks.3 4Validates extracted data against the source document text:5 1. Party names exist in the source text6 2. Dates are valid formats and exist in source7 3. Amounts/numbers are plausible8 4. Critical fields are non-empty9 5. Computes per-field and overall semantic confidence10"""11 12import re13import logging14from typing import Dict, Any, List, Tuple15from datetime import datetime16 17from app.models.extraction_schemas import ContractType18 19logger = logging.getLogger("trident_poc")20 21 22# Fields that MUST be non-empty for a valid extraction23REQUIRED_FIELDS = {24 ContractType.NDA: {25 "parties.disclosing_party": "Disclosing party name",26 "parties.receiving_party": "Receiving party name",27 "purpose": "Purpose of the NDA",28 },29 ContractType.SERVICE_PROVIDER: {30 "parties.company": "Company name",31 "parties.service_provider": "Service provider name",32 "scope_of_work": "Scope of work",33 },34 ContractType.SUPPLY_CUM_SERVICE: {35 "parties.company": "Company name",36 "parties.service_provider": "Service provider name",37 "project_details.project_name": "Project name",38 "pricing.total_value": "Total value",39 },40 ContractType.CONSULTANCY: {41 "parties.company": "Company name",42 "parties.consultant": "Consultant name",43 "scope": "Scope of consultancy",44 "consideration.amount": "Consultancy fee",45 },46 ContractType.CUSTOMER_SUPPLY: {47 "parties.buyer": "Buyer name",48 "parties.supplier": "Supplier name",49 },50}51 52 53class ExtractionValidator:54 """Validates extraction results against source text."""55 56 def validate(57 self,58 extraction: dict,59 source_text: str,60 contract_type: ContractType,61 ) -> Dict[str, Any]:62 """63 Run all validation checks and return a comprehensive report.64 65 Returns:66 {67 "overall_confidence": float (0-1),68 "field_fill_rate": float (0-1),69 "text_match_rate": float (0-1),70 "required_fields_rate": float (0-1),71 "issues": [{"field": str, "issue": str, "severity": str}],72 "field_scores": {"field_path": float},73 }74 """75 source_lower = source_text.lower()76 issues = []77 field_scores = {}78 79 # 1. Field fill rate80 total_fields, filled_fields = self._count_fields(extraction)81 field_fill_rate = filled_fields / max(total_fields, 1)82 83 # 2. Required fields check84 required = REQUIRED_FIELDS.get(contract_type, {})85 required_filled = 086 for path, label in required.items():87 value = self._get_nested(extraction, path)88 if value and str(value).strip():89 required_filled += 190 field_scores[path] = 1.091 else:92 issues.append({93 "field": path,94 "issue": f"Required field '{label}' is empty",95 "severity": "high",96 })97 field_scores[path] = 0.098 99 required_rate = required_filled / max(len(required), 1)100 101 # 3. Text match validation — verify extracted values exist in source102 match_checks = 0103 match_hits = 0104 text_fields = self._extract_text_values(extraction)105 106 for field_path, value in text_fields:107 if not value or len(str(value)) < 3:108 continue109 110 match_checks += 1111 val_str = str(value).lower().strip()112 113 # Check if the core of the value exists in source text114 # For names: check the main words115 # For amounts: check the number part116 if self._value_exists_in_text(val_str, source_lower):117 match_hits += 1118 field_scores[field_path] = field_scores.get(field_path, 0) + 1.0119 else:120 # Partial match — check key words121 words = [w for w in val_str.split() if len(w) > 3]122 word_matches = sum(1 for w in words if w in source_lower)123 partial_score = word_matches / max(len(words), 1)124 125 if partial_score >= 0.5:126 match_hits += 0.7127 field_scores[field_path] = field_scores.get(field_path, 0) + 0.7128 else:129 issues.append({130 "field": field_path,131 "issue": f"Value '{str(value)[:50]}...' not found in source text",132 "severity": "medium",133 })134 field_scores[field_path] = field_scores.get(field_path, 0) + 0.2135 136 text_match_rate = match_hits / max(match_checks, 1)137 138 # 4. Date format validation139 date_fields = self._find_date_fields(extraction)140 for path, value in date_fields:141 if value and not self._is_valid_date(str(value)):142 issues.append({143 "field": path,144 "issue": f"Invalid date format: '{value}'",145 "severity": "low",146 })147 148 # 5. Overall confidence (weighted average)149 overall = (150 field_fill_rate * 0.35 + # 35% weight on field completeness151 required_rate * 0.35 + # 35% weight on critical fields152 text_match_rate * 0.30 # 30% weight on source text verification153 )154 155 return {156 "overall_confidence": round(overall, 3),157 "field_fill_rate": round(field_fill_rate, 3),158 "text_match_rate": round(text_match_rate, 3),159 "required_fields_rate": round(required_rate, 3),160 "total_fields": total_fields,161 "filled_fields": filled_fields,162 "issues_count": len(issues),163 "issues": issues[:20], # Cap at 20 to avoid noise164 "field_scores": field_scores,165 }166 167 # ─── Field Counting ───────────────────────────────────────────168 169 def _count_fields(170 self,171 data: dict,172 _depth: int = 0,173 _skip: set = None,174 ) -> Tuple[int, int]:175 """Count total and filled fields (first 3 levels of nesting)."""176 if _skip is None:177 _skip = {"_extraction_meta", "handwritten_data", "error"}178 if _depth > 3:179 return (0, 0)180 181 total = 0182 filled = 0183 184 for key, value in data.items():185 if key in _skip:186 continue187 188 if isinstance(value, dict):189 sub_total, sub_filled = self._count_fields(value, _depth + 1, _skip)190 total += sub_total191 filled += sub_filled192 elif isinstance(value, list):193 total += 1194 if len(value) > 0:195 filled += 1196 else:197 total += 1198 if value is not None and value != "":199 filled += 1200 201 return (total, filled)202 203 # ─── Text Matching ────────────────────────────────────────────204 205 def _extract_text_values(206 self,207 data: dict,208 prefix: str = "",209 _depth: int = 0,210 ) -> List[Tuple[str, str]]:211 """Extract all string values with their field paths."""212 if _depth > 4:213 return []214 215 results = []216 skip = {"_extraction_meta", "handwritten_data", "error", "unstructured_handwriting"}217 218 for key, value in data.items():219 if key in skip:220 continue221 path = f"{prefix}.{key}" if prefix else key222 223 if isinstance(value, dict):224 results.extend(self._extract_text_values(value, path, _depth + 1))225 elif isinstance(value, list):226 for i, item in enumerate(value):227 if isinstance(item, dict):228 results.extend(229 self._extract_text_values(item, f"{path}[{i}]", _depth + 1)230 )231 elif isinstance(item, str) and len(item) > 3:232 results.append((f"{path}[{i}]", item))233 elif isinstance(value, str) and len(value) > 3:234 results.append((path, value))235 236 return results237 238 @staticmethod239 def _value_exists_in_text(value: str, source_lower: str) -> bool:240 """Check if a value (or its essential parts) exist in the source text."""241 # Direct containment check242 if value in source_lower:243 return True244 245 # For amounts: strip currency symbols and check the number246 amount_match = re.search(r'[\d,]+\.?\d*', value)247 if amount_match:248 number = amount_match.group().replace(",", "")249 if number in source_lower.replace(",", ""):250 return True251 252 # For names: check if all significant words appear253 words = [w for w in value.split() if len(w) > 2 and w.isalpha()]254 if words and all(w in source_lower for w in words):255 return True256 257 return False258 259 # ─── Date Validation ──────────────────────────────────────────260 261 def _find_date_fields(self, data: dict, prefix: str = "") -> List[Tuple[str, str]]:262 """Find all fields likely containing dates."""263 date_fields = []264 date_keywords = {"date", "start", "end", "effective", "expiry", "execution", "termination"}265 266 for key, value in data.items():267 path = f"{prefix}.{key}" if prefix else key268 269 if isinstance(value, dict):270 date_fields.extend(self._find_date_fields(value, path))271 elif isinstance(value, str) and any(kw in key.lower() for kw in date_keywords):272 date_fields.append((path, value))273 274 return date_fields275 276 @staticmethod277 def _is_valid_date(value: str) -> bool:278 """Check if a string looks like a valid date."""279 if not value or value.lower() in ("null", "none", "n/a"):280 return True # Null is acceptable, not an error281 282 # Common date patterns283 patterns = [284 r'\d{1,2}[/\-\.]\d{1,2}[/\-\.]\d{2,4}', # DD/MM/YYYY285 r'\d{4}[/\-\.]\d{1,2}[/\-\.]\d{1,2}', # YYYY/MM/DD286 r'\d{1,2}\s+\w+\s+\d{4}', # 07 March 2026287 r'\w+\s+\d{1,2},?\s+\d{4}', # March 07, 2026288 r'\d{1,2}(?:st|nd|rd|th)\s+\w+\s+\d{4}', # 7th March 2026289 r'\d{1,2}(?:st|nd|rd|th)\s+day\s+of\s+\w+', # 7th day of March290 r'\w+\s+\d{1,2}(?:st|nd|rd|th)?,?\s+\d{4}', # November 1, 2025291 ]292 293 for pattern in patterns:294 if re.search(pattern, value, re.IGNORECASE):295 return True296 297 return False298 299 # ─── Helpers ──────────────────────────────────────────────────300 301 @staticmethod302 def _get_nested(data: dict, path: str) -> Any:303 """Get a nested dict value by dot-separated path."""304 keys = path.split(".")305 current = data306 for key in keys:307 if isinstance(current, dict):308 current = current.get(key)309 else:310 return None311 return current