from __future__ import annotations import asyncio import inspect import re from datetime import datetime, timezone from typing import Any from urllib.parse import urlparse from .registry import ExternalProviderRegistry from .schemas import ExternalAdapterResult, ExternalEvidenceItem, ExternalQueryExecution, ExternalQueryPlan class ExternalQueryGateway: def __init__( self, *, planner: Any, registry: ExternalProviderRegistry, adapter_timeout_seconds: float | None = None, ) -> None: self.planner = planner self.registry = registry self.adapter_timeout_seconds = self._normalize_adapter_timeout(adapter_timeout_seconds) async def execute( self, *, question: str, query_semantics: dict[str, Any] | None = None, fit_brief: dict[str, Any] | None = None, ) -> ExternalQueryExecution: build_plan = getattr(self.planner, "build_plan") try: parameters = inspect.signature(build_plan).parameters except Exception: parameters = {} if "fit_brief" in parameters: planned = await build_plan( question=question, query_semantics=query_semantics, fit_brief=fit_brief, ) else: planned = await build_plan( question=question, query_semantics=query_semantics, ) planner_meta: dict[str, Any] = {} if isinstance(planned, tuple) and len(planned) == 2: plan, planner_meta = planned else: plan = planned if not isinstance(plan, ExternalQueryPlan): plan = ExternalQueryPlan.model_validate(plan) adapter_attempts: list[dict[str, Any]] = [] selected = ExternalAdapterResult(adapter_name="none", status="empty", evidence=[], meta={}) selected_verifier = self._verify_evidence_bundle(plan=plan, evidence=selected.evidence) fallback_used = False for index, adapter in enumerate(self._rank_adapters(plan=plan)): adapter_name = str(getattr(adapter, "adapter_name", "") or f"adapter_{index}").strip() or f"adapter_{index}" try: execute = adapter.execute(plan) if self.adapter_timeout_seconds is not None: result = await asyncio.wait_for(execute, timeout=self.adapter_timeout_seconds) else: result = await execute except asyncio.TimeoutError: adapter_attempts.append( { "adapter": adapter_name, "status": "timeout", "evidence_count": 0, "verifier_ok": False, "verifier_reason": "adapter_timeout", } ) continue except Exception as exc: adapter_attempts.append( { "adapter": adapter_name, "status": "error", "evidence_count": 0, "verifier_ok": False, "verifier_reason": "adapter_error", "error_type": type(exc).__name__, } ) continue if not isinstance(result, ExternalAdapterResult): result = ExternalAdapterResult.model_validate(result) verifier = self._verify_evidence_bundle(plan=plan, evidence=result.evidence) adapter_attempts.append( { "adapter": adapter_name, "status": str(result.status or "").strip() or "empty", "evidence_count": len(result.evidence), "verifier_ok": bool(verifier.get("ok")), "verifier_reason": verifier.get("reason_code"), } ) if result.evidence and bool(verifier.get("ok")): selected = result selected_verifier = verifier fallback_used = index > 0 break if selected.adapter_name == "none": selected = result selected_verifier = verifier meta = dict(selected.meta or {}) meta["fallback_used"] = bool(fallback_used) meta["adapter_attempts"] = adapter_attempts meta["selected_status"] = str(selected.status or "").strip() or "empty" return ExternalQueryExecution( plan=plan, adapter_name=selected.adapter_name, evidence=list(selected.evidence or []), verifier=selected_verifier, planner_meta=planner_meta, meta=meta, ) @staticmethod def _normalize_match_text(value: Any) -> str: raw = str(value or "").strip().lower() if not raw: return "" chars: list[str] = [] for char in raw: if char.isalnum(): chars.append(char) return "".join(chars) @classmethod def _normalize_anchor_term(cls, value: Any) -> str: return cls._normalize_match_text(value)[:48] @staticmethod def _common_prefix_size(left: str, right: str) -> int: limit = min(len(left), len(right)) idx = 0 while idx < limit and left[idx] == right[idx]: idx += 1 return idx @classmethod def _build_char_windows(cls, value: str, *, size: int = 2) -> set[str]: normalized = cls._normalize_match_text(value) if not normalized: return set() if len(normalized) <= size: return {normalized} return {normalized[idx : idx + size] for idx in range(len(normalized) - size + 1)} @classmethod def _tokenize_match_text(cls, value: Any) -> list[str]: raw = str(value or "").strip().lower() if not raw: return [] tokens: list[str] = [] current: list[str] = [] for char in raw: if char.isalnum(): current.append(char) continue if current: token = "".join(current) if token: tokens.append(token) current = [] if current: token = "".join(current) if token: tokens.append(token) normalized = cls._normalize_match_text(raw) if normalized and normalized not in tokens: tokens.append(normalized) return tokens @classmethod def _score_anchor_against_token(cls, anchor: str, token: str) -> float: if not anchor or not token: return 0.0 if anchor == token: return 1.0 if anchor in token: return max(0.82, min(0.98, len(anchor) / max(1, len(token)))) if token in anchor and len(token) >= 2: return max(0.58, len(token) / max(1, len(anchor))) prefix_size = cls._common_prefix_size(anchor, token) prefix_ratio = prefix_size / max(1, min(len(anchor), len(token))) anchor_windows = cls._build_char_windows(anchor) token_windows = cls._build_char_windows(token) window_overlap = ( len(anchor_windows & token_windows) / max(1, len(anchor_windows)) if anchor_windows and token_windows else 0.0 ) score = 0.0 if prefix_size >= 2: score = max(score, 0.42 + min(0.3, prefix_ratio * 0.4)) if window_overlap > 0: score = max(score, 0.35 + min(0.35, window_overlap * 0.5)) return round(min(score, 0.98), 4) @classmethod def _evidence_match_tokens(cls, item: ExternalEvidenceItem) -> list[str]: values = [ str(item.source_label or ""), str(item.title or ""), str(item.snippet or ""), str(item.target_url or ""), str(urlparse(str(item.target_url or "").strip()).path or ""), ] tokens: list[str] = [] seen: set[str] = set() for value in values: for token in cls._tokenize_match_text(value): if token and token not in seen: seen.add(token) tokens.append(token) return tokens @classmethod def _score_anchor_against_evidence(cls, anchor: str, evidence: list[ExternalEvidenceItem]) -> float: best = 0.0 for item in evidence: for token in cls._evidence_match_tokens(item): best = max(best, cls._score_anchor_against_token(anchor, token)) return round(best, 4) @classmethod def _score_query_relevance_against_evidence(cls, plan: ExternalQueryPlan, evidence: list[ExternalEvidenceItem]) -> float: query_text = " ".join( str(item or "").strip() for item in [ plan.topic, *list(plan.search_queries or [])[:3], ] if str(item or "").strip() ) query_windows = cls._build_char_windows(query_text) if not query_windows: return 1.0 best = 0.0 for item in evidence: evidence_text = " ".join( str(value or "").strip() for value in [ item.source_label, item.title, item.snippet, item.target_url, ] if str(value or "").strip() ) evidence_windows = cls._build_char_windows(evidence_text) if not evidence_windows: continue overlap = len(query_windows & evidence_windows) / max(1, len(query_windows)) best = max(best, overlap) return round(min(best, 1.0), 4) @classmethod def _adapter_support_level(cls, *, plan: ExternalQueryPlan, adapter: Any) -> int: supported = getattr(adapter, "supported_query_kinds", None) if not supported: return 1 query_kind = str(plan.query_kind or "").strip().lower() normalized = {str(item or "").strip().lower() for item in supported if str(item or "").strip()} return 0 if query_kind in normalized else 2 def _rank_adapters(self, *, plan: ExternalQueryPlan) -> list[Any]: ranked: list[tuple[int, int, Any]] = [] for index, adapter in enumerate(self._ensure_registry_items(plan=plan)): ranked.append((self._adapter_support_level(plan=plan, adapter=adapter), index, adapter)) ranked.sort(key=lambda item: (item[0], item[1])) return [item[2] for item in ranked] def _ensure_registry_items(self, *, plan: ExternalQueryPlan) -> list[Any]: _ = plan return list(self.registry.iter_adapters()) @staticmethod def _normalize_adapter_timeout(value: float | None) -> float | None: if value is None: return None try: timeout = float(value) except Exception: return None if timeout <= 0: return None return max(1.0, timeout) @staticmethod def _current_year() -> int: return datetime.now(timezone.utc).year @classmethod def _freshness_year_floor(cls, plan: ExternalQueryPlan) -> int: _ = plan return cls._current_year() - 1 @classmethod def _plan_requires_year_grounding(cls, plan: ExternalQueryPlan) -> bool: if not bool(plan.freshness_required): return False combined = " ".join( str(item or "") for item in [ plan.time_range_hint, *list(plan.search_queries or [])[:3], ] ) return bool(re.search(r"(? list[int]: values: list[str] = [ str(item.source_label or ""), str(item.title or ""), str(item.snippet or ""), str(item.target_url or ""), ] metadata = item.raw_metadata if isinstance(item.raw_metadata, dict) else {} for key in ("published_at", "updated_at", "date", "title", "query"): values.append(str(metadata.get(key) or "")) years: list[int] = [] for value in values: for match in re.finditer(r"(? dict[str, Any]: if not cls._plan_requires_year_grounding(plan): return {"ok": True, "freshness_year_floor": None, "evidence_years": []} year_floor = cls._freshness_year_floor(plan) evidence_years: list[int] = [] retrieved_fresh_count = 0 for item in evidence: for year in cls._extract_grounding_years(item): if year not in evidence_years: evidence_years.append(year) retrieved_at = str(item.retrieved_at or "").strip() if re.search(rf"(?= year_floor] ok = bool(fresh_years) if evidence_years else bool(retrieved_fresh_count) return { "ok": ok, "freshness_year_floor": year_floor, "evidence_years": sorted(evidence_years), "fresh_years": sorted(fresh_years), "retrieved_fresh_count": retrieved_fresh_count, } @classmethod def _build_plan_anchor_terms(cls, plan: ExternalQueryPlan) -> list[str]: terms: list[str] = [] for item in list(plan.subject_entities or []): normalized = cls._normalize_anchor_term(item) if len(normalized) >= 2 and normalized not in terms: terms.append(normalized) location_hint = cls._normalize_anchor_term(plan.location_hint) if len(location_hint) >= 2 and location_hint not in terms: terms.append(location_hint) return terms[:6] @classmethod def _verify_evidence_bundle(cls, *, plan: ExternalQueryPlan, evidence: list[ExternalEvidenceItem]) -> dict[str, Any]: if not evidence: return { "ok": False, "reason_code": "missing_external_grounding", "missing_fields": ["external_evidence"], } anchor_terms = cls._build_plan_anchor_terms(plan) anchor_hit = not anchor_terms for item in evidence: target_url = str(item.target_url or "").strip().lower() source_type = str(item.source_type or "").strip().lower() if source_type not in {"url", "mcp_search"}: return { "ok": False, "reason_code": "external_source_type_invalid", "missing_fields": ["external_source_type"], } if not target_url.startswith("http://") and not target_url.startswith("https://"): return { "ok": False, "reason_code": "external_target_url_missing", "missing_fields": ["target_url"], } anchor_scores = [cls._score_anchor_against_evidence(anchor, evidence) for anchor in anchor_terms] strong_hits = sum(1 for score in anchor_scores if score >= 0.8) medium_hits = sum(1 for score in anchor_scores if score >= 0.55) bundle_score = ( round(sum(anchor_scores) / max(1, len(anchor_scores)), 4) if anchor_scores else 1.0 ) if anchor_scores: anchor_hit = strong_hits >= 1 or medium_hits == len(anchor_scores) or bundle_score >= 0.6 if not anchor_hit: return { "ok": False, "reason_code": "external_evidence_off_topic", "missing_fields": ["plan_anchor_terms"], "bundle_score": bundle_score, "anchor_scores": anchor_scores, } if not anchor_scores: query_relevance_score = cls._score_query_relevance_against_evidence(plan=plan, evidence=evidence) if query_relevance_score < 0.28: return { "ok": False, "reason_code": "external_evidence_off_topic", "missing_fields": ["query_relevance"], "query_relevance_score": query_relevance_score, } freshness_gate = cls._freshness_gate_result(plan=plan, evidence=evidence) if not bool(freshness_gate.get("ok")): return { "ok": False, "reason_code": "external_evidence_stale", "missing_fields": ["fresh_external_evidence"], "bundle_score": bundle_score, "anchor_scores": anchor_scores, **freshness_gate, } return { "ok": True, "reason_code": None, "missing_fields": [], "bundle_score": bundle_score, "anchor_scores": anchor_scores, **freshness_gate, }