Files
signalscout-backend/app/integrations/news_search_client.py
T
2026-07-09 00:14:20 +08:00

216 lines
10 KiB
Python

from __future__ import annotations
import json
import logging
from datetime import date, datetime, timedelta
from typing import Any, Optional
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
from app.core.config import Settings
from app.core.timezone import to_beijing_naive
from app.schemas.agent import SignalItem
logger = logging.getLogger(__name__)
class NewsSearchClient:
"""通过Grok Web Search采集中英文AI新闻候选。"""
def __init__(self, settings: Settings) -> None:
self.settings = settings
def collect_ai_news(self, run_date: date) -> list[SignalItem]:
# Grok负责联网搜索新闻候选,入库前仍统一校验来源链接和结构字段。
items: list[dict[str, Any]] = []
for query_group in self._query_groups():
payload = self._search_with_grok(run_date=run_date, query_group=query_group)
items.extend(payload.get("items", []))
candidates = [self._article_to_signal(item) for item in items]
candidates = [candidate for candidate in candidates if candidate is not None]
unique: dict[str, SignalItem] = {}
for candidate in candidates:
unique[candidate.source_url] = candidate
ranked = sorted(
unique.values(),
key=lambda item: (item.importance, item.published_at or datetime.min),
reverse=True,
)
logger.info(
"news_candidates_collected source=grok_web_search days=%s items=%s",
self.settings.news_recent_days,
len(ranked),
)
return ranked[: self.settings.news_max_items]
def _query_groups(self) -> list[dict[str, Any]]:
# 多组搜索覆盖中文、英文、融资、模型和Agent工具,避免单次搜索只返回少量相似新闻。
return [
{
"name": "中文AI新闻",
"language": "zh",
"queries": ["人工智能 大模型 智能体 AI 新闻", "AI 大模型 创业 融资 产品 发布"],
"target_count": 15,
},
{
"name": "英文AI新闻",
"language": "en",
"queries": ["latest AI news LLM agents OpenAI Anthropic", "AI startup funding model release agent tools"],
"target_count": 15,
},
{
"name": "模型与Agent发布",
"language": "mixed",
"queries": ["大模型 发布 Agent 工具 开源", "LLM model release AI agent framework open source"],
"target_count": 15,
},
{
"name": "AI商业与融资",
"language": "mixed",
"queries": ["AI 融资 创业 公司 产品", "AI funding startup acquisition product launch"],
"target_count": 15,
},
]
def _search_with_grok(self, run_date: date, query_group: dict[str, Any]) -> dict[str, Any]:
# Responses API的web_search工具是当前新闻发现入口,确保候选来自真实网页搜索。
if not self.settings.llm_api_key:
raise RuntimeError("SIGNALSCOUT_LLM_API_KEY is required")
since = run_date - timedelta(days=self.settings.news_recent_days)
body = json.dumps(
{
"model": self.settings.llm_model,
"input": [
{
"role": "system",
"content": (
"你是 SignalScout 的新闻搜索器。必须使用 web_search 找到真实网页来源,"
"只输出 JSON object,不输出 Markdown 或额外解释。"
),
},
{
"role": "user",
"content": json.dumps(
{
"task": "联网搜索最近AI新闻,覆盖中文和英文来源。",
"date_window": {
"from": since.isoformat(),
"to": run_date.isoformat(),
},
"search_group": query_group["name"],
"preferred_language": query_group["language"],
"target_count": query_group["target_count"],
"queries": query_group["queries"],
"output_schema": {
"items": [
{
"title": "新闻标题",
"summary": "中文摘要,说明为什么值得关注",
"url": "真实可访问来源链接",
"source": "媒体或网站名称",
"published_at": "ISO时间;不知道可为null",
"language": "zh或en",
"importance": "1到5整数",
"entities": ["公司、模型、项目、人名等"],
}
]
},
"rules": [
"优先返回中文和英文来源各一部分,不要只返回英文来源。",
"只使用最近日期窗口内或明显接近日内的新闻。",
"每条必须有真实 url,不要使用搜索结果页、YouTube集合页或空链接。",
"如果中文来源足够,至少保留三分之一中文新闻。",
"返回尽可能接近 target_count 的 items。",
],
},
ensure_ascii=False,
),
},
],
"tools": [{"type": "web_search"}],
"max_output_tokens": self.settings.llm_max_tokens,
},
ensure_ascii=False,
).encode("utf-8")
request = Request(
f"{self.settings.llm_base_url.rstrip('/')}/responses",
data=body,
headers={
"Authorization": f"Bearer {self.settings.llm_api_key}",
"Content-Type": "application/json",
"Accept": "application/json",
},
method="POST",
)
try:
with urlopen(request, timeout=self.settings.llm_timeout_seconds) as response:
response_body = response.read().decode("utf-8")
except HTTPError as exc:
message = exc.read().decode("utf-8", errors="replace")
raise RuntimeError(f"Grok news search failed: HTTP {exc.code} {message}") from exc
except URLError as exc:
raise RuntimeError(f"Grok news search failed: {exc.reason}") from exc
return self._parse_response_json(json.loads(response_body))
def _parse_response_json(self, response_payload: dict[str, Any]) -> dict[str, Any]:
# Responses返回多段output,最终message里的output_text才是模型给业务层的JSON。
text_parts: list[str] = []
for output in response_payload.get("output", []):
if not isinstance(output, dict):
continue
for content in output.get("content", []):
if isinstance(content, dict) and content.get("type") == "output_text":
text = content.get("text")
if isinstance(text, str):
text_parts.append(text)
content_text = "\n".join(text_parts).strip()
if not content_text:
raise RuntimeError("Grok news search returned no output_text")
try:
payload = json.loads(content_text)
except json.JSONDecodeError as exc:
raise RuntimeError(f"Grok news search returned invalid JSON: {content_text[:1200]}") from exc
if not isinstance(payload, dict):
raise RuntimeError("Grok news search JSON root is not an object")
return payload
def _article_to_signal(self, item: dict[str, Any]) -> Optional[SignalItem]:
# 搜索结果只接受带真实网页链接的条目,避免把模型说明文本写入情报库。
title = item.get("title")
url = item.get("url")
if not isinstance(title, str) or not isinstance(url, str) or not url.startswith("http"):
return None
source = item.get("source")
language = item.get("language")
entities = item.get("entities")
return SignalItem(
source_type="news",
topic="中文 AI 新闻" if language == "zh" else "AI 新闻",
title=title[:300],
summary=str(item.get("summary") or "Grok Web Search 搜索到的AI新闻候选。"),
source_url=url,
source_name=str(source or "Grok Web Search"),
published_at=self._parse_datetime(item.get("published_at")),
importance=self._parse_importance(item.get("importance")),
entities=[str(entity) for entity in entities] if isinstance(entities, list) else [],
)
def _parse_datetime(self, value: Any) -> Optional[datetime]:
# 搜索结果可能只有日期或ISO时间,能解析则统一换算为北京时间。
if not isinstance(value, str) or not value:
return None
try:
if len(value) == 10:
value = f"{value}T00:00:00"
return to_beijing_naive(datetime.fromisoformat(value.replace("Z", "+00:00")))
except ValueError:
return None
def _parse_importance(self, value: Any) -> int:
# 重要度来自搜索模型,但入库前仍限制在业务契约允许范围内。
try:
importance = int(value)
except (TypeError, ValueError):
return 3
return max(1, min(importance, 5))