""" Policy Ingestion Tools — Custom tools for the Policy Agent. Tools: 1. pdf_text_extractor — Extract text from PDF pages 2. pdf_table_extractor — Extract tables from PDF (sub-limits, exclusion lists) 3. irdai_regulation_lookup — Cross-reference against IRDAI mandated rules 4. rule_validator — Validate extracted rules for completeness and accuracy """ import json import logging import os import fitz # PyMuPDF from pathlib import Path logger = logging.getLogger(__name__) # Load knowledge base _KNOWLEDGE_DIR = Path(__file__).parent.parent / "knowledge" with open(_KNOWLEDGE_DIR / "irdai_rules.json", "r") as f: IRDAI_KB = json.load(f) # --- Tool 1: PDF Text Extractor --- def pdf_text_extractor(pdf_bytes: bytes) -> dict: """ Extract text from a PDF document, page-by-page. Returns: { "total_pages": int, "total_chars": int, "pages": [{"page_num": 1, "text": "...", "char_count": int}] } """ doc = fitz.open(stream=pdf_bytes, filetype="pdf") pages = [] total_chars = 0 for page_num in range(len(doc)): page = doc.load_page(page_num) text = page.get_text("text") char_count = len(text.strip()) total_chars += char_count pages.append({ "page_num": page_num + 1, "text": text.strip(), "char_count": char_count, }) doc.close() logger.info(f"[Tool:pdf_text_extractor] Extracted {total_chars} chars from {len(pages)} pages") return { "total_pages": len(pages), "total_chars": total_chars, "pages": pages, } # --- Tool 2: PDF Table Extractor --- def pdf_table_extractor(pdf_bytes: bytes) -> dict: """ Extract structured tables from a PDF, targeting common insurance policy table formats: - Sub-limit tables (procedure name → max amount) - Exclusion lists (numbered or bulleted) - Waiting period tables - Room rent schedule Returns: { "tables_found": int, "tables": [{"page": int, "type": str, "rows": list[list[str]]}] } """ doc = fitz.open(stream=pdf_bytes, filetype="pdf") tables = [] for page_num in range(len(doc)): page = doc.load_page(page_num) # PyMuPDF table extraction (v1.23+) try: page_tables = page.find_tables() for tab in page_tables: table_data = tab.extract() if table_data and len(table_data) > 1: # At least header + 1 row # Classify table type header_text = " ".join(str(cell) for cell in table_data[0] if cell).lower() table_type = _classify_table(header_text) tables.append({ "page": page_num + 1, "type": table_type, "header": table_data[0], "rows": table_data[1:], "row_count": len(table_data) - 1, }) except AttributeError: # Older PyMuPDF without find_tables — fallback to text-based detection text = page.get_text("text") detected = _extract_tables_from_text(text, page_num + 1) tables.extend(detected) doc.close() logger.info(f"[Tool:pdf_table_extractor] Found {len(tables)} tables") return { "tables_found": len(tables), "tables": tables, } def _classify_table(header_text: str) -> str: """Classify an insurance policy table by its header.""" header_lower = header_text.lower() if any(w in header_lower for w in ["sub-limit", "sublimit", "maximum", "cap", "limit"]): return "sublimit_schedule" elif any(w in header_lower for w in ["exclusion", "excluded", "not covered"]): return "exclusion_list" elif any(w in header_lower for w in ["waiting", "period"]): return "waiting_period_schedule" elif any(w in header_lower for w in ["room", "rent", "accommodation"]): return "room_rent_schedule" elif any(w in header_lower for w in ["co-pay", "copay", "co pay"]): return "copay_schedule" elif any(w in header_lower for w in ["benefit", "coverage", "feature"]): return "benefit_summary" return "other" def _extract_tables_from_text(text: str, page_num: int) -> list[dict]: """Fallback: extract table-like structures from plain text.""" tables = [] lines = text.strip().split("\n") # Look for lines with multiple tab/space-separated columns table_lines = [] for line in lines: parts = [p.strip() for p in line.split(" ") if p.strip()] # Double-space separated if len(parts) >= 2: table_lines.append(parts) elif table_lines and len(table_lines) >= 3: # End of a table-like section header_text = " ".join(table_lines[0]).lower() tables.append({ "page": page_num, "type": _classify_table(header_text), "header": table_lines[0], "rows": table_lines[1:], "row_count": len(table_lines) - 1, }) table_lines = [] # Handle remaining lines if len(table_lines) >= 3: header_text = " ".join(table_lines[0]).lower() tables.append({ "page": page_num, "type": _classify_table(header_text), "header": table_lines[0], "rows": table_lines[1:], "row_count": len(table_lines) - 1, }) return tables # --- Tool 5: Rule-Based Policy Extractor --- def rule_based_policy_extractor(text: str) -> dict: """ Extract basic policy info using regex/rules (FREE, No API). Targets: Insurer Name, Plan Name, Sum Insured. """ import re result = { "insurer": None, "plan_name": None, "sum_insured": None, "confidence": 0.0 } # Common Insurer patterns insurers = [ "Star Health", "HDFC ERGO", "ICICI Lombard", "Niva Bupa", "Care Health", "Aditya Birla", "TATA AIG", "Bajaj Allianz", "SBI General", "Oriental Insurance", "United India", "New India Assurance", "National Insurance" ] for insurer in insurers: if insurer.lower() in text.lower(): result["insurer"] = insurer result["confidence"] += 0.3 break # Sum Insured patterns (e.g. "Sum Insured: 5,00,000", "SI - 10 Lakhs") si_patterns = [ r"(?:Sum Insured|S\.I\.|Total SI)\s*[:\-\s]*[₹Rs\.]*\s*([\d,]+)", r"([\d,]+)\s*(?:Lakhs|Lakh|L)", ] for pattern in si_patterns: match = re.search(pattern, text, re.IGNORECASE) if match: val = match.group(1).replace(",", "") try: # Handle "10 Lakhs" vs "1000000" if "Lakh" in pattern or (match.group(0).lower().find("lakh") != -1): result["sum_insured"] = float(val) * 100000 else: result["sum_insured"] = float(val) result["confidence"] += 0.4 break except: continue # Plan Name patterns plan_patterns = [ r"(?:Plan|Product|Policy)\s*Name\s*[:\-\s]*([A-Z][a-zA-Z0-aligned\s]{3,30})", r"(?:Plan|Product)\s*[:\-\s]*([A-Z][a-zA-Z\s]{3,30})", ] for pattern in plan_patterns: match = re.search(pattern, text, re.IGNORECASE) if match: name = match.group(1).strip() if len(name) > 5: result["plan_name"] = name result["confidence"] += 0.2 break return result # --- Tool 3: IRDAI Regulation Lookup --- def irdai_regulation_lookup(query: str) -> dict: """ Look up IRDAI regulations, standard definitions, and mandated limits. Args: query: Topic to look up (e.g., "waiting period", "room rent", "co-payment", "exclusion") Returns: { "query": str, "definitions": [matching standard definitions], "regulations": [matching regulations with limits], "standard_exclusions": [if query relates to exclusions] } """ query_lower = query.lower() result = {"query": query, "definitions": [], "regulations": [], "standard_exclusions": []} # Search standard definitions for term, definition in IRDAI_KB["standard_definitions"].items(): if query_lower in term.lower() or any(w in term.lower() for w in query_lower.split()): result["definitions"].append({ "term": term.replace("_", " ").title(), "definition": definition }) # Search mandated limits for key, limit_data in IRDAI_KB["mandated_limits"].items(): if query_lower in key.lower() or query_lower in limit_data.get("description", "").lower(): result["regulations"].append({ "regulation": key.replace("_", " ").title(), **{k: v for k, v in limit_data.items()} }) # Search room rent guidelines if any(w in query_lower for w in ["room", "rent", "accommodation"]): result["regulations"].append({ "regulation": "Room Rent Guidelines", **IRDAI_KB["room_rent_guidelines"] }) # Search copay guidelines if any(w in query_lower for w in ["copay", "co-pay", "co_pay", "cost sharing"]): result["regulations"].append({ "regulation": "Co-Pay Guidelines", **IRDAI_KB["copay_guidelines"] }) # Search exclusions if any(w in query_lower for w in ["exclusion", "excluded", "not covered"]): result["standard_exclusions"] = IRDAI_KB["standard_exclusions"] logger.info(f"[Tool:irdai_regulation_lookup] Query='{query}', found {len(result['definitions'])} defs, " f"{len(result['regulations'])} regs") return result # --- Tool 4: Rule Validator --- _REQUIRED_CATEGORIES = { "room_rent", "copay", "exclusion_permanent", "waiting_period_initial", "waiting_period_pec", "pre_post_hospitalization", } _SUSPICIOUS_PATTERNS = { "room_rent": {"limit_value_range": (0.1, 10), "unit": "percentage"}, "copay": {"limit_value_range": (1, 50), "unit": "percentage"}, "deductible": {"limit_value_range": (1000, 500000), "unit": "absolute"}, } def rule_validator(rules: list[dict], sum_insured: float = 0) -> dict: """ Validate extracted policy rules for completeness, accuracy, and consistency. Checks: 1. Missing critical categories (every Indian policy should have room rent, copay, exclusions) 2. Suspicious values (negative amounts, copay > 50%, room rent > 10% of SI) 3. Duplicate rules 4. Rules that conflict with IRDAI regulations (e.g., waiting period > 48 months) Returns: { "is_valid": bool, "total_rules": int, "issues": [{"severity": "critical|warning|info", "message": str}], "categories_found": [str], "categories_missing": [str] } """ issues = [] categories_found = set() seen_conditions = set() for i, rule in enumerate(rules): cat = rule.get("category", "unknown") categories_found.add(cat) # Check for duplicates cond_key = rule.get("condition", "").lower().strip()[:80] if cond_key in seen_conditions: issues.append({ "severity": "warning", "message": f"Rule {i+1}: Possible duplicate — '{cond_key[:50]}...'" }) seen_conditions.add(cond_key) # Check for suspicious values limit_val = rule.get("limit_value") if limit_val is not None: if limit_val < 0: issues.append({ "severity": "critical", "message": f"Rule {i+1} ({cat}): Negative limit value: {limit_val}" }) if cat in _SUSPICIOUS_PATTERNS: pattern = _SUSPICIOUS_PATTERNS[cat] min_v, max_v = pattern["limit_value_range"] if limit_val < min_v or limit_val > max_v: issues.append({ "severity": "warning", "message": f"Rule {i+1} ({cat}): Value {limit_val} outside typical range ({min_v}-{max_v})" }) # IRDAI compliance checks if cat in ("waiting_period_specific", "waiting_period_pec") and limit_val: if limit_val > 48: issues.append({ "severity": "critical", "message": f"Rule {i+1}: Waiting period {limit_val} months exceeds IRDAI maximum of 48 months" }) if cat == "waiting_period_initial" and limit_val: if limit_val > 30: issues.append({ "severity": "critical", "message": f"Rule {i+1}: Initial waiting period {limit_val} days exceeds IRDAI maximum of 30 days" }) # Check clause reference if not rule.get("clause_reference") or rule.get("clause_reference") == "Not specified": issues.append({ "severity": "info", "message": f"Rule {i+1} ({cat}): No clause reference — may be hard to audit" }) # Check missing categories categories_missing = _REQUIRED_CATEGORIES - categories_found for missing in categories_missing: issues.append({ "severity": "warning", "message": f"Missing expected category: '{missing}' — most Indian policies include this" }) is_valid = not any(i["severity"] == "critical" for i in issues) logger.info(f"[Tool:rule_validator] Validated {len(rules)} rules: " f"{len(issues)} issues, valid={is_valid}") return { "is_valid": is_valid, "total_rules": len(rules), "issues": issues, "categories_found": sorted(categories_found), "categories_missing": sorted(categories_missing), }