PMFlow/backend/app/services/external_query_service/gateway.py

272 lines
10 KiB
Python

from __future__ import annotations
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,
) -> None:
self.planner = planner
self.registry = registry
async def execute(
self,
*,
question: str,
query_semantics: dict[str, Any] | None = None,
) -> ExternalQueryExecution:
planned = await self.planner.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}"
result = await adapter.execute(plan)
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
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 _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())
@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,
}
return {
"ok": True,
"reason_code": None,
"missing_fields": [],
"bundle_score": bundle_score,
"anchor_scores": anchor_scores,
}