"""Canonical merger: combine rule parser output with LLM output.""" from __future__ import annotations from copy import deepcopy from typing import Any _HIGH_CONFIDENCE = 0.70 _UNKNOWN_VALUES = {"unknown", "unclear", "not_provided"} def _is_confident(output: dict[str, Any], field: str | None = None) -> bool: """Return True if output has overall confidence >= threshold.""" conf = output.get("confidence", {}) if field and field in conf: return float(conf[field]) >= _HIGH_CONFIDENCE return float(conf.get("overall", 0.0)) >= _HIGH_CONFIDENCE def _field_is_unknown(value: Any) -> bool: return str(value).lower() in _UNKNOWN_VALUES if value is not None else True def _merge_scalar( rule_val: Any, llm_val: Any | None, rule_conf: float, llm_conf: float, field_name: str, warnings: list[str], provenance: dict[str, str], ) -> Any: """Merge a single scalar field with provenance tracking. Strategy: - Both confident + disagree → conflict flag, rule wins (conservative). - Only rule confident → rule wins. - Only LLM confident → LLM wins (fills in unknown/low-confidence rule output). - Neither confident → prefer non-unknown value, rule takes priority. """ rule_unknown = _field_is_unknown(rule_val) llm_unknown = llm_val is None or _field_is_unknown(llm_val) rule_confident = not rule_unknown and rule_conf >= _HIGH_CONFIDENCE llm_confident = not llm_unknown and llm_conf >= _HIGH_CONFIDENCE # Both confident and disagree → record conflict; rule wins but conflict is flagged if rule_confident and llm_confident and str(rule_val) != str(llm_val): warnings.append(f"rule_llm_conflict:{field_name} rule={rule_val} llm={llm_val}") provenance[field_name] = "rule" return rule_val # Rule wins when confident (and no conflict detected above) if rule_confident: provenance[field_name] = "rule" return rule_val # LLM wins when confident (rule is not confident) if llm_confident: provenance[field_name] = "llm" return llm_val # Neither confident — prefer non-unknown, rule first if not rule_unknown: provenance[field_name] = "rule" return rule_val if not llm_unknown: provenance[field_name] = "llm" return llm_val provenance[field_name] = "rule" return rule_val # both unknown → keep rule default def merge( rule_output: dict[str, Any], llm_output: dict[str, Any] | None, ) -> dict[str, Any]: """Merge rule parser output with optional LLM output into a canonical record. Strategy: - Rule parser wins when confident (overall >= 0.70). - LLM fills in unknown fields when it is confident. - Conflicts → conservative merge ("unknown"/"mixed") + warning. - Adds ``provenance`` dict and ``rule_llm_conflict`` flag. """ result = deepcopy(rule_output) result["parser_kind"] = "merged" provenance: dict[str, str] = {} warnings: list[str] = list(result.get("warnings", [])) has_conflict = False if llm_output is None: # No LLM — everything from rule result["provenance"] = dict.fromkeys(_CANONICAL_FIELDS, "rule") result["rule_llm_conflict"] = False return result rule_conf = float(rule_output.get("confidence", {}).get("overall", 0.0)) llm_conf = float(llm_output.get("confidence", {}).get("overall", 0.0)) # Merge top-level scalar fields for field in ("event_type", "event_direction"): merged_val = _merge_scalar( rule_val=rule_output.get(field), llm_val=llm_output.get(field), rule_conf=float(rule_output.get("confidence", {}).get(field, rule_conf)), llm_conf=float(llm_output.get("confidence", {}).get(field, llm_conf)), field_name=field, warnings=warnings, provenance=provenance, ) result[field] = merged_val if any(f"rule_llm_conflict:{field}" in w for w in warnings): has_conflict = True # Headline / summary: prefer LLM (usually more informative) if llm_output.get("headline") and not _field_is_unknown(llm_output.get("headline")): result["headline"] = llm_output["headline"] provenance["headline"] = "llm" else: provenance["headline"] = "rule" if llm_output.get("summary") and not _field_is_unknown(llm_output.get("summary")): result["summary"] = llm_output["summary"] provenance["summary"] = "llm" else: provenance["summary"] = "rule" # Guidance rule_guidance = rule_output.get("guidance", {}) llm_guidance = llm_output.get("guidance", {}) merged_guidance = deepcopy(rule_guidance) guidance_rule_conf = float(rule_output.get("confidence", {}).get("guidance", rule_conf)) guidance_llm_conf = float(llm_output.get("confidence", {}).get("guidance", llm_conf)) g_status = _merge_scalar( rule_val=rule_guidance.get("status"), llm_val=llm_guidance.get("status"), rule_conf=guidance_rule_conf, llm_conf=guidance_llm_conf, field_name="guidance.status", warnings=warnings, provenance=provenance, ) merged_guidance["status"] = g_status if any("rule_llm_conflict:guidance.status" in w for w in warnings): has_conflict = True if llm_guidance.get("scope") and not _field_is_unknown(llm_guidance.get("scope")): if _field_is_unknown(rule_guidance.get("scope")): merged_guidance["scope"] = llm_guidance["scope"] provenance["guidance.scope"] = "llm" else: provenance["guidance.scope"] = "rule" else: provenance["guidance.scope"] = "rule" result["guidance"] = merged_guidance # Signals: LLM fills in unknown rule signals rule_signals = rule_output.get("signals", {}) llm_signals = llm_output.get("signals", {}) merged_signals = deepcopy(rule_signals) for sig_field in ( "demand_strength", "pricing_power", "backlog_or_bookings", "customer_expansion", "margin_quality", ): if _field_is_unknown(rule_signals.get(sig_field)) and not _field_is_unknown( llm_signals.get(sig_field) ): merged_signals[sig_field] = llm_signals[sig_field] provenance[f"signals.{sig_field}"] = "llm" else: provenance[f"signals.{sig_field}"] = "rule" result["signals"] = merged_signals # Risk flags: OR of rule + LLM (either flagging = flagged) rule_risks = rule_output.get("risk_flags", {}) llm_risks = llm_output.get("risk_flags", {}) merged_risks: dict[str, bool] = {} for risk_field in ( "oneoff_item", "tax_benefit", "valuation_gain", "non_gaap_heavy", "financing_related", "legal_or_regulatory_overhang", ): rule_flag = bool(rule_risks.get(risk_field, False)) llm_flag = bool(llm_risks.get(risk_field, False)) merged_risks[risk_field] = rule_flag or llm_flag if rule_flag and llm_flag: provenance[f"risk_flags.{risk_field}"] = "merged" elif rule_flag: provenance[f"risk_flags.{risk_field}"] = "rule" elif llm_flag: provenance[f"risk_flags.{risk_field}"] = "llm" else: provenance[f"risk_flags.{risk_field}"] = "rule" result["risk_flags"] = merged_risks # Confidence: use higher overall if llm_conf > rule_conf: result["confidence"] = llm_output.get("confidence", result.get("confidence", {})) provenance["confidence"] = "llm" else: provenance["confidence"] = "rule" result["provenance"] = provenance result["rule_llm_conflict"] = has_conflict result["warnings"] = warnings return result # Canonical fields used for provenance initialization _CANONICAL_FIELDS = [ "event_type", "event_direction", "headline", "summary", "guidance.status", "guidance.scope", "confidence", *[f"signals.{f}" for f in ("demand_strength", "pricing_power", "backlog_or_bookings", "customer_expansion", "margin_quality")], *[f"risk_flags.{f}" for f in ("oneoff_item", "tax_benefit", "valuation_gain", "non_gaap_heavy", "financing_related", "legal_or_regulatory_overhang")], ] def should_queue_for_review(merged: dict[str, Any]) -> tuple[bool, list[str]]: """Determine if a merged record needs human review. Returns: (should_queue, reason_codes) where reason_codes is a list of strings. """ reason_codes: list[str] = [] conf = float(merged.get("confidence", {}).get("overall", 0.0)) if conf < _HIGH_CONFIDENCE: reason_codes.append("low_confidence") if merged.get("rule_llm_conflict"): reason_codes.append("rule_llm_conflict") guidance = merged.get("guidance", {}) if guidance.get("status") in ("unclear",): reason_codes.append("guidance_ambiguous") risk_flags = merged.get("risk_flags", {}) if risk_flags.get("oneoff_item") or risk_flags.get("non_gaap_heavy"): reason_codes.append("oneoff_likely") event_direction = merged.get("event_direction", "unknown") if event_direction in ("unknown", "mixed"): reason_codes.append("direction_ambiguous") return bool(reason_codes), reason_codes