Files
signalscout-backend/app/llm/grok_client.py
T
2026-07-08 23:39:41 +08:00

204 lines
8.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import json
import logging
from datetime import date, timedelta
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
from app.core.config import Settings
from app.db.models import Topic
from app.schemas.agent import AgentResult, SignalItem
logger = logging.getLogger(__name__)
class GrokIntelligenceClient:
"""通过 OpenAI 兼容接口调用 Grok,生成每日 AI 情报结构化结果。"""
def __init__(self, settings: Settings) -> None:
self.settings = settings
def collect_daily_intelligence(
self,
topics: list[Topic],
news_candidates: list[SignalItem],
github_projects: list[SignalItem],
run_date: date,
) -> AgentResult:
# 先用确定性 GitHub 候选缩小开源项目范围,再由模型统一判断新闻价值和日报结构。
if not self.settings.llm_api_key:
raise RuntimeError("SIGNALSCOUT_LLM_API_KEY is required")
messages = [
{"role": "system", "content": self._system_prompt()},
{
"role": "user",
"content": self._user_prompt(
topics=topics,
news_candidates=news_candidates,
github_projects=github_projects,
run_date=run_date,
),
},
]
logger.info(
"grok_intelligence_started model=%s run_date=%s github_candidates=%s",
self.settings.llm_model,
run_date.isoformat(),
len(github_projects),
)
payload = self._chat_json(messages)
result = AgentResult.model_validate(payload)
logger.info(
"grok_intelligence_completed model=%s signals=%s",
self.settings.llm_model,
len(result.signals),
)
return result
def _chat_json(self, messages: list[dict[str, str]]) -> dict[str, Any]:
# JSON mode把结构化契约交给模型侧执行,解析失败时直接暴露供应商原文便于排障。
url = f"{self.settings.llm_base_url.rstrip('/')}/chat/completions"
body = json.dumps(
{
"model": self.settings.llm_model,
"messages": messages,
"temperature": 0.2,
"max_tokens": self.settings.llm_max_tokens,
"response_format": {"type": "json_object"},
},
ensure_ascii=False,
).encode("utf-8")
request = Request(
url,
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 request failed: HTTP {exc.code} {message}") from exc
except URLError as exc:
raise RuntimeError(f"Grok request failed: {exc.reason}") from exc
completion = json.loads(response_body)
content = completion["choices"][0]["message"]["content"]
if not isinstance(content, str):
raise RuntimeError("Grok response content is not text")
try:
payload = json.loads(content)
except json.JSONDecodeError as exc:
raise RuntimeError(f"Grok returned invalid JSON: {content[:1200]}") from exc
payload.setdefault("raw_response", {})
payload["raw_response"].update(
{
"provider": self.settings.llm_base_url,
"model": self.settings.llm_model,
"usage": completion.get("usage", {}),
}
)
return payload
def _system_prompt(self) -> str:
# 这段提示词定义唯一产出格式,数据库与报告都依赖同一个模型结果。
return (
"你是 SignalScout,一个专门追踪 AI 新闻、模型发布、Agent 工具、开源项目和融资动态的情报 Agent。"
"你必须优先给出最近发生、可点击验证、对工程和产品判断有价值的事件。"
"所有结论必须带 source_url,不能编造链接。"
"只输出一个 JSON object,不输出 Markdown 代码块或额外解释。"
)
def _user_prompt(
self,
topics: list[Topic],
news_candidates: list[SignalItem],
github_projects: list[SignalItem],
run_date: date,
) -> str:
# 日期窗口让模型把“最新”落到明确范围,GitHub候选则避免热门项目只靠语言模型记忆。
since = run_date - timedelta(days=self.settings.news_recent_days)
topic_payload = [
{"name": topic.name, "description": topic.description}
for topic in topics
if topic.enabled
]
github_payload = [
{
"source_type": item.source_type,
"title": item.title,
"summary": item.summary,
"source_url": item.source_url,
"published_at": item.published_at.isoformat() if item.published_at else None,
"entities": item.entities,
}
for item in github_projects
]
news_payload = [
{
"source_type": item.source_type,
"title": item.title,
"summary": item.summary,
"source_url": item.source_url,
"source_name": item.source_name,
"published_at": item.published_at.isoformat() if item.published_at else None,
"entities": item.entities,
}
for item in news_candidates
]
return json.dumps(
{
"task": "生成每日 AI 情报日报,并返回结构化信号。",
"run_date": run_date.isoformat(),
"date_window": {
"from": since.isoformat(),
"to": run_date.isoformat(),
},
"limits": {
"max_news_signals": self.settings.news_max_items,
"max_github_signals": self.settings.github_max_projects,
},
"topics": topic_payload,
"news_candidates": news_payload,
"github_candidates": github_payload,
"output_schema": {
"title": "字符串,日报标题",
"signals": [
{
"source_type": "news 或 github;新闻候选必须为 newsGitHub 候选必须为 github",
"topic": "AI 新闻 / GitHub 热门项目 / 模型发布 / Agent 工具 / 融资动态等",
"title": "字符串",
"summary": "中文摘要,说明为什么重要",
"source_url": "可点击来源链接",
"source_name": "来源名称",
"published_at": "ISO 时间;不知道具体时间可用日期T00:00:00",
"importance": "1到5的整数",
"entities": ["公司、项目、模型、人名等实体"],
}
],
"report_markdown": "中文 Markdown 日报,包含今日重点、AI 新闻、GitHub 热门项目和观察",
"raw_response": {
"notes": "简短说明检索和筛选依据",
},
},
"rules": [
"新闻必须来自日期窗口内或接近日内发生的事件。",
"AI新闻只能从 news_candidates 中选择,不要新增候选外新闻。",
"GitHub 热门项目只能从 github_candidates 中选择,不要新增候选外仓库。",
"news 和 github 是两个独立栏目,分别尽量输出到对应上限,不要把 GitHub 项目写成新闻。",
"只有候选重复、链接不可验证或明显不相关时,才少于对应上限。",
"每条 signal 必须有真实 source_url。",
"report_markdown 只能基于 signals 写,不要加入 signals 外的新事实。",
"输出必须是可被 json.loads 解析的 JSON object。",
],
},
ensure_ascii=False,
)