204 lines
8.7 KiB
Python
204 lines
8.7 KiB
Python
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;新闻候选必须为 news,GitHub 候选必须为 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,
|
||
)
|