Spaces:
Sleeping
Sleeping
| """ | |
| Deterministic numeric validation for SWOT analysis outputs. | |
| Layer 4: Validates that cited metric values match the reference table. | |
| Extracts [M##] citations from SWOT text and verifies against metric_reference dict. | |
| """ | |
| import re | |
| from typing import Optional | |
| # Pattern to match citations in NEW format: [M01] Revenue: $394.3B - insight | |
| # Matches: [M##] followed by metric name, colon, and value | |
| CITATION_PATTERN_NEW = re.compile( | |
| r'\[M(\d{2})\]\s*[^:]+:\s*(\$?[\d,]+\.?\d*[BMKTx%]?)', | |
| re.IGNORECASE | |
| ) | |
| # Pattern to match citations in OLD format: $394.3B [M01] (kept for backwards compatibility) | |
| CITATION_PATTERN_OLD = re.compile( | |
| r'([\d,$\.]+[BMK%]?)\s*\[M(\d{2})\]', | |
| re.IGNORECASE | |
| ) | |
| # Combined pattern to find any [M##] reference (for citation counting) | |
| CITATION_REF_PATTERN = re.compile(r'\[M(\d{2})\]', re.IGNORECASE) | |
| def normalize_value(text: str) -> Optional[float]: | |
| """ | |
| Normalize a value string to a float for comparison. | |
| Handles: | |
| - Currency: $394.3B -> 394300000000, $56.6M -> 56600000 | |
| - Percentages: 25.3% -> 25.3 | |
| - Plain numbers: 32.5 -> 32.5, 1,234 -> 1234 | |
| Returns None if parsing fails. | |
| """ | |
| if not text: | |
| return None | |
| # Remove whitespace and common formatting | |
| text = text.strip().replace(',', '').replace(' ', '') | |
| # Handle currency with B/M/K suffix | |
| if text.startswith('$'): | |
| text = text[1:] # Remove $ | |
| multiplier = 1 | |
| if text.upper().endswith('B'): | |
| multiplier = 1e9 | |
| text = text[:-1] | |
| elif text.upper().endswith('M'): | |
| multiplier = 1e6 | |
| text = text[:-1] | |
| elif text.upper().endswith('K'): | |
| multiplier = 1e3 | |
| text = text[:-1] | |
| try: | |
| return float(text) * multiplier | |
| except ValueError: | |
| return None | |
| # Handle percentages | |
| if text.endswith('%'): | |
| try: | |
| return float(text[:-1]) | |
| except ValueError: | |
| return None | |
| # Plain number | |
| try: | |
| return float(text) | |
| except ValueError: | |
| return None | |
| def values_match(found_value: float, expected_value: float, value_type: str = "unknown") -> bool: | |
| """ | |
| Check if two values match within acceptable tolerance. | |
| Tolerances: | |
| - Currency (large numbers): ±1% relative | |
| - Percentages: ±0.1 absolute | |
| - Small decimals (ratios, etc.): ±0.05 absolute | |
| """ | |
| if found_value is None or expected_value is None: | |
| return False | |
| # Large numbers (currency) - use relative tolerance | |
| if abs(expected_value) >= 1e6: | |
| tolerance = abs(expected_value) * 0.01 # 1% | |
| return abs(found_value - expected_value) <= tolerance | |
| # Small numbers - use absolute tolerance | |
| # Percentages and ratios | |
| if abs(expected_value) < 100: | |
| tolerance = 0.15 # Allow slight rounding differences | |
| return abs(found_value - expected_value) <= tolerance | |
| # Medium numbers | |
| tolerance = abs(expected_value) * 0.01 | |
| return abs(found_value - expected_value) <= tolerance | |
| def extract_citations(text: str) -> list[dict]: | |
| """ | |
| Extract all [M##] citations from text. | |
| Supports both formats: | |
| - NEW: [M01] Revenue: $394.3B - insight | |
| - OLD: $394.3B [M01] | |
| Returns list of dicts: | |
| [ | |
| {"ref_id": "M01", "cited_value": "$394.3B", "normalized": 394300000000.0}, | |
| {"ref_id": "M02", "cited_value": "25.3%", "normalized": 25.3}, | |
| ] | |
| """ | |
| citations = [] | |
| seen_refs = set() | |
| # Try NEW format first: [M##] Metric: Value | |
| for match in CITATION_PATTERN_NEW.finditer(text): | |
| ref_num = match.group(1) | |
| cited_value = match.group(2) | |
| ref_id = f"M{ref_num}" | |
| if ref_id not in seen_refs: | |
| normalized = normalize_value(cited_value) | |
| citations.append({ | |
| "ref_id": ref_id, | |
| "cited_value": cited_value, | |
| "normalized": normalized | |
| }) | |
| seen_refs.add(ref_id) | |
| # Also try OLD format: Value [M##] | |
| for match in CITATION_PATTERN_OLD.finditer(text): | |
| cited_value = match.group(1) | |
| ref_num = match.group(2) | |
| ref_id = f"M{ref_num}" | |
| if ref_id not in seen_refs: | |
| normalized = normalize_value(cited_value) | |
| citations.append({ | |
| "ref_id": ref_id, | |
| "cited_value": cited_value, | |
| "normalized": normalized | |
| }) | |
| seen_refs.add(ref_id) | |
| return citations | |
| def validate_citations(swot_text: str, metric_reference: dict) -> dict: | |
| """ | |
| Validate all citations in SWOT text against metric_reference. | |
| Args: | |
| swot_text: The SWOT analysis output | |
| metric_reference: Dict from Layer 1 with format: | |
| {"M01": {"key": "revenue", "raw_value": 394328000000, "formatted": "..."}, ...} | |
| Returns: | |
| { | |
| "valid": bool, | |
| "citations_found": int, | |
| "mismatches": [ | |
| "revenue [M01]: cited $56.6B, expected $394.3B", | |
| ... | |
| ], | |
| "missing_refs": ["M99"], # Citations to non-existent refs | |
| "details": [...] # Full details for each citation | |
| } | |
| """ | |
| citations = extract_citations(swot_text) | |
| result = { | |
| "valid": True, | |
| "citations_found": len(citations), | |
| "mismatches": [], | |
| "missing_refs": [], | |
| "details": [] | |
| } | |
| for citation in citations: | |
| ref_id = citation["ref_id"] | |
| cited_value = citation["cited_value"] | |
| cited_normalized = citation["normalized"] | |
| detail = { | |
| "ref_id": ref_id, | |
| "cited_value": cited_value, | |
| "cited_normalized": cited_normalized, | |
| "status": "unknown" | |
| } | |
| # Check if reference exists | |
| if ref_id not in metric_reference: | |
| result["missing_refs"].append(ref_id) | |
| result["valid"] = False | |
| detail["status"] = "missing_ref" | |
| detail["error"] = f"Reference {ref_id} not found in metric table" | |
| result["details"].append(detail) | |
| continue | |
| ref_entry = metric_reference[ref_id] | |
| expected_value = ref_entry.get("raw_value") | |
| metric_key = ref_entry.get("key", "unknown") | |
| expected_formatted = ref_entry.get("formatted", str(expected_value)) | |
| detail["metric_key"] = metric_key | |
| detail["expected_value"] = expected_value | |
| detail["expected_formatted"] = expected_formatted | |
| # Check if values match | |
| if cited_normalized is None: | |
| result["mismatches"].append( | |
| f"{metric_key} [{ref_id}]: could not parse cited value '{cited_value}'" | |
| ) | |
| result["valid"] = False | |
| detail["status"] = "parse_error" | |
| elif not values_match(cited_normalized, expected_value): | |
| # Format expected value for display | |
| if abs(expected_value) >= 1e9: | |
| expected_display = f"${expected_value/1e9:.1f}B" | |
| elif abs(expected_value) >= 1e6: | |
| expected_display = f"${expected_value/1e6:.0f}M" | |
| else: | |
| expected_display = expected_formatted.split(" (as of")[0] if " (as of" in expected_formatted else expected_formatted | |
| result["mismatches"].append( | |
| f"{metric_key} [{ref_id}]: cited {cited_value}, expected {expected_display}" | |
| ) | |
| result["valid"] = False | |
| detail["status"] = "mismatch" | |
| else: | |
| detail["status"] = "valid" | |
| result["details"].append(detail) | |
| return result | |
| def validate_numeric_accuracy(swot_text: str, metric_reference: dict) -> list[str]: | |
| """ | |
| Main validation function for critic integration. | |
| Returns list of mismatch descriptions (empty if all valid). | |
| """ | |
| if not metric_reference: | |
| return [] | |
| result = validate_citations(swot_text, metric_reference) | |
| # Combine mismatches and missing refs | |
| errors = result["mismatches"].copy() | |
| for ref_id in result["missing_refs"]: | |
| errors.append(f"Invalid reference: {ref_id} not in metric table") | |
| return errors | |
| # ============================================================ | |
| # LAYER 3: Uncited Number Detection | |
| # ============================================================ | |
| # Pattern to match metric-like numbers (will filter out cited ones programmatically) | |
| # Matches: $56.6B, $394M, 25.3%, 12.14, 0.84x, etc. | |
| METRIC_NUMBER_PATTERN = re.compile( | |
| r'(' | |
| r'\$[\d,]+\.?\d*[BMK]?' # Currency: $56.6B, $394M, $1,234 | |
| r'|' | |
| r'[\d,]+\.?\d*%' # Percentage: 25.3%, 12% | |
| r'|' | |
| r'[\d,]+\.\d+x' # Ratio with x: 1.5x, 12.3x | |
| r')', | |
| re.IGNORECASE | |
| ) | |
| # Keywords that indicate a number is likely a metric value | |
| METRIC_CONTEXT_KEYWORDS = [ | |
| 'revenue', 'income', 'profit', 'margin', 'cap', 'market cap', 'enterprise value', | |
| 'p/e', 'pe ratio', 'p/b', 'pb ratio', 'p/s', 'ps ratio', 'ev/ebitda', | |
| 'beta', 'volatility', 'vix', 'growth', 'yield', 'dividend', | |
| 'debt', 'equity', 'assets', 'liabilities', 'cash flow', 'fcf', | |
| 'eps', 'earnings', 'roi', 'roe', 'roa', 'ebitda', | |
| 'gdp', 'inflation', 'unemployment', 'interest rate', | |
| ] | |
| def find_uncited_numbers(swot_text: str, metric_reference: dict) -> list[dict]: | |
| """ | |
| Find numbers that look like metrics but don't have [M##] citations. | |
| Returns list of suspicious uncited numbers with context. | |
| """ | |
| uncited = [] | |
| # Get all cited positions to exclude (check both NEW and OLD patterns) | |
| cited_positions = set() | |
| # NEW format: [M##] Metric: Value | |
| for match in CITATION_PATTERN_NEW.finditer(swot_text): | |
| cited_positions.update(range(match.start(), match.end())) | |
| # OLD format: Value [M##] | |
| for match in CITATION_PATTERN_OLD.finditer(swot_text): | |
| cited_positions.update(range(match.start(), match.end())) | |
| # Find all metric-like numbers | |
| for match in METRIC_NUMBER_PATTERN.finditer(swot_text): | |
| # Skip if this position overlaps with a citation | |
| if any(pos in cited_positions for pos in range(match.start(), match.end())): | |
| continue | |
| value_str = match.group(1) | |
| normalized = normalize_value(value_str) | |
| if normalized is None: | |
| continue | |
| # Get surrounding context (50 chars before and after) | |
| start = max(0, match.start() - 50) | |
| end = min(len(swot_text), match.end() + 50) | |
| context = swot_text[start:end].replace('\n', ' ') | |
| # Check if context contains metric-related keywords | |
| context_lower = context.lower() | |
| has_metric_context = any(kw in context_lower for kw in METRIC_CONTEXT_KEYWORDS) | |
| # Check if value matches any known metric (within tolerance) | |
| matches_known_metric = False | |
| matched_metric_key = None | |
| for ref_id, ref_entry in metric_reference.items(): | |
| expected = ref_entry.get("raw_value") | |
| if expected and values_match(normalized, expected): | |
| matches_known_metric = True | |
| matched_metric_key = ref_entry.get("key") | |
| break | |
| # Flag as suspicious if it looks like a metric | |
| if has_metric_context or matches_known_metric: | |
| uncited.append({ | |
| "value": value_str, | |
| "normalized": normalized, | |
| "position": match.start(), | |
| "context": context.strip(), | |
| "has_metric_context": has_metric_context, | |
| "matches_known_metric": matches_known_metric, | |
| "matched_metric_key": matched_metric_key, | |
| }) | |
| return uncited | |
| def validate_uncited_numbers(swot_text: str, metric_reference: dict) -> list[str]: | |
| """ | |
| Validate that metric-like numbers have proper citations. | |
| Returns list of warnings for uncited numbers that should have citations. | |
| """ | |
| if not metric_reference: | |
| return [] | |
| uncited = find_uncited_numbers(swot_text, metric_reference) | |
| warnings = [] | |
| for item in uncited: | |
| if item["matches_known_metric"]: | |
| # This number matches a known metric - MUST have citation | |
| warnings.append( | |
| f"Uncited metric value: {item['value']} appears to be {item['matched_metric_key']} - add [M##] citation" | |
| ) | |
| elif item["has_metric_context"]: | |
| # Number in metric context without citation - suspicious | |
| warnings.append( | |
| f"Uncited number in metric context: {item['value']} - verify source or add citation" | |
| ) | |
| return warnings | |
| def get_citation_count(swot_text: str) -> int: | |
| """Count the number of [M##] citations in the text.""" | |
| return len(CITATION_REF_PATTERN.findall(swot_text)) | |
| def validate_minimum_citations(swot_text: str, metric_reference: dict, min_ratio: float = 0.5) -> dict: | |
| """ | |
| Check if SWOT has enough citations relative to available metrics. | |
| Args: | |
| swot_text: The SWOT analysis output | |
| metric_reference: Available metrics | |
| min_ratio: Minimum ratio of citations to available metrics (default 0.5 = 50%) | |
| Returns: | |
| { | |
| "valid": bool, | |
| "citations_found": int, | |
| "metrics_available": int, | |
| "ratio": float, | |
| "message": str | |
| } | |
| """ | |
| citations_found = get_citation_count(swot_text) | |
| metrics_available = len(metric_reference) if metric_reference else 0 | |
| if metrics_available == 0: | |
| return { | |
| "valid": True, | |
| "citations_found": citations_found, | |
| "metrics_available": 0, | |
| "ratio": 0, | |
| "message": "No metrics available for citation" | |
| } | |
| ratio = citations_found / metrics_available | |
| valid = ratio >= min_ratio | |
| if valid: | |
| message = f"Citation coverage: {citations_found}/{metrics_available} ({ratio:.0%})" | |
| else: | |
| message = f"Insufficient citations: {citations_found}/{metrics_available} ({ratio:.0%}) - minimum {min_ratio:.0%} required" | |
| return { | |
| "valid": valid, | |
| "citations_found": citations_found, | |
| "metrics_available": metrics_available, | |
| "ratio": ratio, | |
| "message": message | |
| } | |