PMFlow/backend/app/services/external_query_service/adapters/dashscope_responses.py

439 lines
18 KiB
Python

from __future__ import annotations
import os
import time
from datetime import datetime, timezone
from typing import Any, Callable
import httpx
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from ....models import Integration
from ..schemas import ExternalAdapterResult, ExternalEvidenceItem, ExternalQueryPlan
PLATFORM_RUNTIME_TENANT_ID = int(os.getenv("PMFLOW_PLATFORM_RUNTIME_TENANT_ID", "1") or "1")
class DashScopeResponsesSearchAdapter:
adapter_name = "dashscope_responses"
supported_query_kinds = {"weather", "news", "market", "company", "model", "generic_web", "unknown"}
def __init__(
self,
*,
api_key: str,
base_url: str = "https://dashscope.aliyuncs.com/compatible-mode/v1",
model: str = "qwen3.6-plus",
client_factory: Callable[[], httpx.AsyncClient] | None = None,
tenant_id: int | None = None,
user_id: int | None = None,
timeout_seconds: float | None = None,
routing_meta: dict[str, Any] | None = None,
) -> None:
self.api_key = str(api_key or "").strip()
self.base_url = str(base_url or "https://dashscope.aliyuncs.com/compatible-mode/v1").strip()
self.model = str(model or "qwen3.6-plus").strip()
self.timeout_seconds = self._normalize_timeout(timeout_seconds)
self.client_factory = client_factory or (
lambda: httpx.AsyncClient(timeout=self.timeout_seconds, trust_env=False, follow_redirects=True)
)
self.tenant_id = int(tenant_id or PLATFORM_RUNTIME_TENANT_ID)
self.user_id = int(user_id) if user_id is not None else None
self.routing_meta = dict(routing_meta or {}) if isinstance(routing_meta, dict) else {}
@property
def available(self) -> bool:
return bool(self.api_key and self.model)
async def execute(self, plan: ExternalQueryPlan) -> ExternalAdapterResult:
if not self.available:
return ExternalAdapterResult(
adapter_name=self.adapter_name,
status="unavailable",
evidence=[],
meta={"reason": "dashscope_responses_runtime_missing"},
)
query = self._build_input(plan)
if not query:
return ExternalAdapterResult(
adapter_name=self.adapter_name,
status="empty",
evidence=[],
meta={"reason": "empty_query"},
)
endpoint = f"{self.base_url.rstrip('/')}/responses"
payload = {
"model": self.model,
"input": query,
"tools": [{"type": "web_search"}],
"search_options": {"forced_search": True},
"enable_thinking": True,
}
if plan.freshness_required:
freshness_days = self._freshness_days(plan)
if freshness_days:
payload["search_options"]["freshness"] = freshness_days
response_payload: dict[str, Any] | None = None
status = "error"
error_summary = ""
started_at = time.perf_counter()
try:
async with self.client_factory() as client:
response = await client.post(
endpoint,
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
},
json=payload,
)
try:
response_payload = response.json()
except Exception:
response_payload = {"raw_text": str(response.text or "")}
response.raise_for_status()
evidence = self._evidence_from_response(plan=plan, payload=response_payload, query=query)
status = "success" if evidence else "empty"
return ExternalAdapterResult(
adapter_name=self.adapter_name,
status=status,
evidence=evidence[: max(1, int(plan.result_limit or 3))],
meta={
"query": query,
"provider": self.adapter_name,
"model": self.model,
"search_result_count": len(self._extract_search_results(response_payload)),
"tool_usage": self._extract_tool_usage(response_payload),
"latency_ms": int((time.perf_counter() - started_at) * 1000),
"timeout_seconds": self.timeout_seconds,
"fetched_pages": 0,
},
)
except Exception as exc:
error_summary = f"{type(exc).__name__}: {exc!s}".strip()[:500]
if error_summary.endswith(":"):
error_summary = repr(exc)[:500]
return ExternalAdapterResult(
adapter_name=self.adapter_name,
status="error",
evidence=[],
meta={
"query": query,
"provider": self.adapter_name,
"model": self.model,
"reason": "dashscope_responses_request_failed",
"error": error_summary,
"latency_ms": int((time.perf_counter() - started_at) * 1000),
"timeout_seconds": self.timeout_seconds,
},
)
finally:
await self._log_ai_call(
endpoint=endpoint,
payload=payload,
response_payload=response_payload,
status=status,
error_summary=error_summary,
latency_ms=int((time.perf_counter() - started_at) * 1000),
)
@staticmethod
def _build_input(plan: ExternalQueryPlan) -> str:
queries = [str(item or "").strip() for item in list(plan.search_queries or []) if str(item or "").strip()]
topic = str(plan.topic or "").strip()
primary = queries[0] if queries else topic
if not primary:
return ""
context_bits = []
if plan.freshness_required:
context_bits.append("优先检索最近公开来源。")
if plan.requires_multi_source:
context_bits.append("尽量覆盖多个独立公开来源。")
if plan.subject_entities:
context_bits.append("关注对象:" + "、".join(str(item) for item in plan.subject_entities[:5]))
suffix = "\n".join(context_bits)
return f"{primary}\n{suffix}".strip()
@staticmethod
def _freshness_days(plan: ExternalQueryPlan) -> int | None:
hint = str(plan.time_range_hint or "").lower()
if "24" in hint or "today" in hint or "今天" in hint:
return 1
if "week" in hint or "7" in hint or "一周" in hint or "最近" in hint:
return 7
if "month" in hint or "30" in hint or "一个月" in hint:
return 30
return 30
@staticmethod
def _normalize_timeout(value: float | None) -> float:
raw = value
if raw is None:
env_value = str(os.getenv("DASHSCOPE_RESPONSES_TIMEOUT_SECONDS") or "").strip()
try:
raw = float(env_value) if env_value else 120.0
except Exception:
raw = 120.0
try:
normalized = float(raw)
except Exception:
normalized = 120.0
return max(30.0, min(normalized, 240.0))
@classmethod
def _evidence_from_response(
cls,
*,
plan: ExternalQueryPlan,
payload: dict[str, Any] | None,
query: str,
) -> list[ExternalEvidenceItem]:
data = payload if isinstance(payload, dict) else {}
search_results = cls._extract_search_results(data)
output_text = cls._extract_output_text(data)
allow_output_text_as_single_source_snippet = len(search_results) == 1
retrieved_at = datetime.now(timezone.utc).isoformat()
evidence: list[ExternalEvidenceItem] = []
for raw in search_results:
target_url = cls._first_text(raw, "url", "link", "href", "contentUrl", "site_url")
if not target_url.startswith("http://") and not target_url.startswith("https://"):
continue
title = cls._first_text(raw, "title", "name", "source_name") or target_url
snippet = cls._first_text(raw, "snippet", "summary", "content", "text", "description")
if not snippet and output_text and allow_output_text_as_single_source_snippet:
snippet = output_text
metadata = {
"provider": "dashscope_responses",
"query": query,
"result_query": query,
"model": str(data.get("model") or ""),
"published_at": cls._first_text(raw, "published_at", "date", "time"),
}
evidence.append(
ExternalEvidenceItem(
source_type="url",
source_label=title[:255],
title=title[:255],
target_url=target_url,
snippet=str(snippet or "")[:1800],
retrieved_at=retrieved_at,
freshness_score=0.86 if plan.freshness_required else 0.74,
authority_score=0.68,
grounded_fields=["dashscope_responses_web_search"],
raw_metadata={key: value for key, value in metadata.items() if value},
)
)
return evidence
@classmethod
def _extract_search_results(cls, payload: dict[str, Any] | None) -> list[dict[str, Any]]:
data = payload if isinstance(payload, dict) else {}
candidates: list[Any] = []
search_info = data.get("search_info") if isinstance(data.get("search_info"), dict) else {}
candidates.append(search_info.get("search_results"))
output = data.get("output")
if isinstance(output, dict):
nested_search_info = output.get("search_info") if isinstance(output.get("search_info"), dict) else {}
candidates.append(nested_search_info.get("search_results"))
candidates.append(output.get("search_results"))
if isinstance(output, list):
for item in output:
if not isinstance(item, dict):
continue
item_search_info = item.get("search_info") if isinstance(item.get("search_info"), dict) else {}
candidates.append(item_search_info.get("search_results"))
candidates.append(item.get("search_results"))
if str(item.get("type") or "") == "web_search_call":
action = item.get("action") if isinstance(item.get("action"), dict) else {}
sources = action.get("sources")
if isinstance(sources, list):
candidates.append(
[
{
"url": source.get("url"),
"title": source.get("title") or source.get("url"),
"snippet": source.get("snippet") or source.get("summary") or "",
}
for source in sources
if isinstance(source, dict)
]
)
for candidate in candidates:
if isinstance(candidate, list):
rows = [dict(item) for item in candidate if isinstance(item, dict)]
if rows:
return rows
return []
@staticmethod
def _extract_tool_usage(payload: dict[str, Any] | None) -> dict[str, Any]:
data = payload if isinstance(payload, dict) else {}
usage = data.get("usage") if isinstance(data.get("usage"), dict) else {}
tools = usage.get("x_tools") if isinstance(usage.get("x_tools"), dict) else {}
if tools:
return dict(tools)
details = usage.get("x_details") if isinstance(usage.get("x_details"), list) else []
for item in details:
if not isinstance(item, dict):
continue
plugins = item.get("plugins") if isinstance(item.get("plugins"), dict) else {}
if plugins:
return dict(plugins)
return {}
@classmethod
def _extract_output_text(cls, payload: dict[str, Any]) -> str:
direct = str(payload.get("output_text") or "").strip()
if direct:
return direct
output = payload.get("output")
texts: list[str] = []
if isinstance(output, list):
for item in output:
if not isinstance(item, dict):
continue
content = item.get("content")
if isinstance(content, list):
for content_item in content:
if isinstance(content_item, dict):
text = str(content_item.get("text") or "").strip()
if text:
texts.append(text)
text = str(item.get("text") or "").strip()
if text:
texts.append(text)
elif isinstance(output, dict):
text = str(output.get("text") or output.get("output_text") or "").strip()
if text:
texts.append(text)
return "\n".join(texts).strip()
@staticmethod
def _first_text(raw: dict[str, Any], *keys: str) -> str:
for key in keys:
value = str(raw.get(key) or "").strip()
if value:
return value
return ""
async def _log_ai_call(
self,
*,
endpoint: str,
payload: dict[str, Any],
response_payload: dict[str, Any] | None,
status: str,
error_summary: str,
latency_ms: int | None,
) -> None:
try:
from ...llm_service import llm_service
routing_meta = {
**self.routing_meta,
"scene_key": "external_query",
"slot_key": "external_query",
"slot_provider_id": self.adapter_name,
"routing_decision": "external_query_provider_registry",
"route_source": "active_slot",
"response_api": "dashscope_responses",
"latency_ms": latency_ms,
"provider_timeout_seconds": self.timeout_seconds,
"tools": [str(item.get("type") or "") for item in list(payload.get("tools") or []) if isinstance(item, dict)],
}
await llm_service._log_ai_call(
tenant_id=self.tenant_id,
user_id=self.user_id,
function_name="external_query_dashscope_responses",
prompt=str(payload.get("input") or "")[:4000],
response=str(response_payload or error_summary or "")[:4000],
status=f"external_query_{status}",
provider="dashscope",
model=self.model,
provider_request_payload={**payload, "api_key": None},
provider_response_payload=response_payload,
provider_attempts=[
{
"provider": "dashscope",
"model": self.model,
"endpoint": endpoint,
"status": status,
"latency_ms": latency_ms,
"request_payload": {**payload, "api_key": None},
"response_payload": response_payload,
"error": error_summary or None,
}
],
routing_meta=routing_meta,
)
except Exception:
return
async def build_dashscope_responses_adapter_from_runtime(
*,
db: AsyncSession | None,
tenant_id: int | None = None,
user_id: int | None = None,
timeout_seconds: float | None = None,
routing_meta: dict[str, Any] | None = None,
) -> DashScopeResponsesSearchAdapter:
runtime = await _resolve_dashscope_runtime(db=db, tenant_id=tenant_id)
return DashScopeResponsesSearchAdapter(
api_key=str(runtime.get("api_key") or ""),
base_url=str(runtime.get("base_url") or "https://dashscope.aliyuncs.com/compatible-mode/v1"),
model=str(runtime.get("model") or "qwen3.6-plus"),
tenant_id=tenant_id,
user_id=user_id,
timeout_seconds=timeout_seconds if timeout_seconds is not None else runtime.get("timeout_seconds"),
routing_meta=routing_meta,
)
async def _resolve_dashscope_runtime(*, db: AsyncSession | None, tenant_id: int | None) -> dict[str, Any]:
env_key = str(os.getenv("DASHSCOPE_API_KEY") or "").strip()
fallback = {
"api_key": env_key,
"base_url": str(os.getenv("DASHSCOPE_BASE_URL") or "https://dashscope.aliyuncs.com/compatible-mode/v1"),
"model": str(os.getenv("DASHSCOPE_RESPONSES_MODEL") or "qwen3.6-plus"),
"timeout_seconds": str(os.getenv("DASHSCOPE_RESPONSES_TIMEOUT_SECONDS") or "120"),
}
if db is None:
return fallback
try:
row = (
await db.execute(
select(Integration).where(
and_(
Integration.tenant_id == int(tenant_id or PLATFORM_RUNTIME_TENANT_ID),
Integration.type == "llm_engine",
)
)
)
).scalar_one_or_none()
except Exception:
return fallback
config = row.config if row is not None and isinstance(row.config, dict) else {}
engines = [item for item in list(config.get("engines") or []) if isinstance(item, dict)]
candidates = [
item
for item in engines
if str(item.get("provider") or "").strip().lower() == "dashscope"
and bool(item.get("enabled", True))
and str(item.get("model") or "").strip().lower().startswith("qwen3.6")
and str(item.get("api_key") or "").strip()
]
if not candidates:
return fallback
selected = candidates[0]
return {
"api_key": str(selected.get("api_key") or env_key),
"base_url": str(selected.get("base_url") or fallback["base_url"]),
"model": str(selected.get("model") or fallback["model"]),
"timeout_seconds": selected.get("responses_timeout_seconds") or fallback["timeout_seconds"],
}