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

467 lines
18 KiB
Python

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
query_kind = str(plan.query_kind or "").strip().lower()
if query_kind == "market":
return True
combined = " ".join(
str(item or "")
for item in [
plan.topic,
plan.answer_contract,
plan.time_range_hint,
*list(plan.search_queries or [])[:3],
]
)
return any(
token in combined
for token in (
"行业",
"趋势",
"市场",
"规模",
"增长",
"报告",
"白皮书",
"监管",
"政策",
"竞品",
"格局",
"动态",
)
)
@classmethod
def _extract_grounding_years(cls, item: ExternalEvidenceItem) -> 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"(?<!\d)(20\d{2})(?!\d)", value):
try:
year = int(match.group(1))
except Exception:
continue
if 2000 <= year <= cls._current_year() + 1 and year not in years:
years.append(year)
return years
@classmethod
def _freshness_gate_result(cls, *, plan: ExternalQueryPlan, evidence: list[ExternalEvidenceItem]) -> 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] = []
for item in evidence:
for year in cls._extract_grounding_years(item):
if year not in evidence_years:
evidence_years.append(year)
fresh_years = [year for year in evidence_years if year >= year_floor]
return {
"ok": bool(fresh_years),
"freshness_year_floor": year_floor,
"evidence_years": sorted(evidence_years),
"fresh_years": sorted(fresh_years),
}
@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,
}