199 lines
8.2 KiB
Python
199 lines
8.2 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 = [
|
|
{
|
|
"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 = [
|
|
{
|
|
"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": [
|
|
{
|
|
"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 中选择,不要新增候选外仓库。",
|
|
"每条 signal 必须有真实 source_url。",
|
|
"report_markdown 只能基于 signals 写,不要加入 signals 外的新事实。",
|
|
"输出必须是可被 json.loads 解析的 JSON object。",
|
|
],
|
|
},
|
|
ensure_ascii=False,
|
|
)
|