467 lines
18 KiB
Python
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,
|
|
}
|