重写为 AI 情报 Agent 并接入 Jenkins 流水线

This commit is contained in:
Codex
2026-07-08 21:41:46 +08:00
commit f471c2a08a
45 changed files with 2163 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""SignalScout application package."""
+1
View File
@@ -0,0 +1 @@
"""Agent orchestration package."""
+16
View File
@@ -0,0 +1,16 @@
from datetime import date
from pathlib import Path
class ReportWriter:
"""把人可读日报写入本地文件。"""
def __init__(self, base_dir: Path) -> None:
self.base_dir = base_dir
def write_daily_report(self, run_date: date, content_md: str) -> Path:
# 日报按日期落盘,便于人工浏览和备份。
self.base_dir.mkdir(parents=True, exist_ok=True)
report_path = self.base_dir / f"{run_date.isoformat()}.md"
report_path.write_text(content_md, encoding="utf-8")
return report_path
+82
View File
@@ -0,0 +1,82 @@
from __future__ import annotations
import logging
from datetime import date
from typing import Optional
from sqlalchemy.orm import Session
from app.agent.report_writer import ReportWriter
from app.core.config import Settings
from app.db.repository import AgentRepository
from app.integrations.github_client import GitHubHotProjectClient
from app.integrations.news_search_client import NewsSearchClient
from app.llm.grok_client import GrokIntelligenceClient
from app.schemas.agent import RunResponse
logger = logging.getLogger(__name__)
class AgentRunner:
"""Coordinates source collection, persistence, and report writing."""
def __init__(self, db: Session, settings: Settings) -> None:
self.repo = AgentRepository(db)
self.db = db
self.github = GitHubHotProjectClient(settings)
self.news = NewsSearchClient(settings)
self.grok = GrokIntelligenceClient(settings)
self.writer = ReportWriter(settings.report_dir)
def run_daily(self, run_date: Optional[date] = None) -> RunResponse:
# Runner统一编排候选采集、模型研判和持久化,保证一次运行只有一种业务路径。
current_date = run_date or date.today()
request_payload = {
"run_date": current_date.isoformat(),
"mode": "grok_agent",
}
run = self.repo.create_run(request_payload=request_payload)
logger.info("agent_run_started run_id=%s run_date=%s mode=grok_agent", run.id, current_date)
try:
topics = self.repo.list_enabled_topics()
news_candidates = self.news.collect_ai_news(run_date=current_date)
github_projects = self.github.collect_hot_projects(run_date=current_date)
result = self.grok.collect_daily_intelligence(
topics=topics,
news_candidates=news_candidates,
github_projects=github_projects,
run_date=current_date,
)
signals = self.repo.save_signals(run=run, items=result.signals)
report_path = self.writer.write_daily_report(current_date, result.report_markdown)
self.repo.save_report(
run=run,
title=result.title,
content_md=result.report_markdown,
file_path=str(report_path),
)
self.repo.mark_run_complete(
run=run,
raw_response={
"mode": "grok_agent",
"topic_count": len(topics),
"news_candidate_count": len(news_candidates),
"github_project_count": len(github_projects),
"model": self.grok.settings.llm_model,
"model_response": result.raw_response,
},
)
self.db.commit()
logger.info(
"agent_run_completed run_id=%s signals=%s report_path=%s",
run.id,
len(signals),
report_path,
)
return RunResponse(run_id=run.id, status=run.status, report_path=str(report_path))
except Exception as exc:
self.repo.mark_run_failed(run=run, error=str(exc))
self.db.commit()
logger.exception("agent_run_failed run_id=%s error=%s", run.id, exc)
raise
+30
View File
@@ -0,0 +1,30 @@
from apscheduler.schedulers.background import BackgroundScheduler
from sqlalchemy.orm import Session
from app.agent.runner import AgentRunner
from app.core.config import Settings
from app.db.session import SessionLocal
def run_scheduled_job(settings: Settings) -> None:
# 定时任务不在请求生命周期内,需要自己创建和关闭数据库会话。
db: Session = SessionLocal()
try:
AgentRunner(db=db, settings=settings).run_daily()
finally:
db.close()
def build_scheduler(settings: Settings) -> BackgroundScheduler:
# APScheduler负责每日后台运行,服务启动后自动挂载任务。
scheduler = BackgroundScheduler(timezone=settings.agent_timezone)
scheduler.add_job(
run_scheduled_job,
trigger="cron",
hour=settings.agent_cron_hour,
minute=settings.agent_cron_minute,
args=[settings],
id="daily-intelligence",
replace_existing=True,
)
return scheduler
+136
View File
@@ -0,0 +1,136 @@
from __future__ import annotations
import json
import logging
from datetime import date
from typing import Iterator, Optional
from app.agent.report_writer import ReportWriter
from app.core.config import Settings
from app.db.models import AgentRun, Signal
from app.db.repository import AgentRepository
from app.db.session import SessionLocal
from app.integrations.github_client import GitHubHotProjectClient
from app.integrations.news_search_client import NewsSearchClient
from app.llm.grok_client import GrokIntelligenceClient
logger = logging.getLogger(__name__)
def stream_daily_agent_events(settings: Settings, run_date: Optional[date] = None) -> Iterator[str]:
"""以SSE方式运行每日Agent,并在模型完成后逐条发送结构化情报。"""
db = SessionLocal()
repo = AgentRepository(db)
current_date = run_date or date.today()
run: Optional[AgentRun] = None
try:
request_payload = {
"run_date": current_date.isoformat(),
"mode": "grok_agent_stream",
}
run = repo.create_run(request_payload=request_payload)
db.commit()
logger.info("agent_stream_started run_id=%s run_date=%s mode=grok_agent", run.id, current_date)
yield _sse("started", {"run_id": run.id, "status": run.status})
news = NewsSearchClient(settings)
github = GitHubHotProjectClient(settings)
grok = GrokIntelligenceClient(settings)
topics = repo.list_enabled_topics()
yield _sse("stage", {"run_id": run.id, "stage": "news"})
news_candidates = news.collect_ai_news(run_date=current_date)
yield _sse("stage", {"run_id": run.id, "stage": "github"})
github_projects = github.collect_hot_projects(run_date=current_date)
yield _sse("stage", {"run_id": run.id, "stage": "grok"})
result = grok.collect_daily_intelligence(
topics=topics,
news_candidates=news_candidates,
github_projects=github_projects,
run_date=current_date,
)
saved_count = 0
final_signals = []
for item in result.signals:
# 模型一次性完成筛选和摘要,SSE只负责把最终情报逐条落库并推给前端。
signal = repo.save_or_update_signal(run=run, item=item)
if signal is None:
continue
db.commit()
db.refresh(signal)
final_signals.append(signal)
saved_count += 1
yield _sse("candidate", {"signal": _signal_payload(signal), "saved_count": saved_count})
writer = ReportWriter(settings.report_dir)
report_path = writer.write_daily_report(current_date, result.report_markdown)
repo.save_report(
run=run,
title=result.title,
content_md=result.report_markdown,
file_path=str(report_path),
)
repo.mark_run_complete(
run=run,
raw_response={
"mode": "grok_agent_stream",
"topic_count": len(topics),
"news_candidate_count": len(news_candidates),
"github_project_count": len(github_projects),
"model": settings.llm_model,
"model_response": result.raw_response,
},
)
db.commit()
logger.info(
"agent_stream_completed run_id=%s candidates=%s final_signals=%s report_path=%s",
run.id,
saved_count,
len(final_signals),
report_path,
)
yield _sse(
"completed",
{
"run_id": run.id,
"status": "completed",
"candidate_count": saved_count,
"final_signal_count": len(final_signals),
"report_path": str(report_path),
},
)
except Exception as exc:
if run is not None:
repo.mark_run_failed(run=run, error=str(exc))
db.commit()
logger.exception("agent_stream_failed run_id=%s error=%s", run.id, exc)
yield _sse("failed", {"run_id": run.id, "status": "failed", "error": str(exc)})
else:
logger.exception("agent_stream_failed_before_run error=%s", exc)
yield _sse("failed", {"run_id": None, "status": "failed", "error": str(exc)})
finally:
db.close()
def _signal_payload(signal: Signal) -> dict:
# SSE载荷只使用JSON安全字段,方便前端直接追加到列表。
return {
"id": signal.id,
"run_id": signal.run_id,
"topic": signal.topic,
"title": signal.title,
"summary": signal.summary,
"source_url": signal.source_url,
"source_name": signal.source_name,
"published_at": signal.published_at.isoformat() if signal.published_at else None,
"importance": signal.importance,
"entities": signal.entities,
"created_at": signal.created_at.isoformat() if signal.created_at else None,
}
def _sse(event: str, data: dict) -> str:
# 标准SSE帧让前端可以通过流式请求消费事件。
payload = json.dumps(data, ensure_ascii=False)
return f"event: {event}\ndata: {payload}\n\n"
+1
View File
@@ -0,0 +1 @@
"""HTTP API routes."""
+120
View File
@@ -0,0 +1,120 @@
from __future__ import annotations
from datetime import date
from typing import Optional
from fastapi import APIRouter, Depends
from fastapi.responses import StreamingResponse
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.agent.runner import AgentRunner
from app.agent.streaming_runner import stream_daily_agent_events
from app.core.config import Settings, get_settings
from app.db.models import AgentRun, Report, Signal, Topic
from app.db.session import get_db
from app.schemas.agent import (
DashboardRead,
ReportRead,
RunRead,
RunResponse,
SignalRead,
TopicCreate,
TopicRead,
)
router = APIRouter()
@router.get("/health")
def health(settings: Settings = Depends(get_settings)) -> dict:
# 健康检查只返回服务身份,避免暴露密钥和内部地址。
return {"status": "ok", "app": settings.app_name, "env": settings.env}
@router.post("/agent/runs/daily", response_model=RunResponse)
def run_daily_agent(
run_date: Optional[date] = None,
db: Session = Depends(get_db),
settings: Settings = Depends(get_settings),
) -> RunResponse:
# 手动触发和定时任务共用同一个Runner,保证行为一致。
return AgentRunner(db=db, settings=settings).run_daily(run_date=run_date)
@router.post("/agent/runs/daily/stream")
def stream_daily_agent(
run_date: Optional[date] = None,
settings: Settings = Depends(get_settings),
) -> StreamingResponse:
# 流式接口立即启动Agent,并把最终结构化情报逐条推送给前端。
return StreamingResponse(
stream_daily_agent_events(settings=settings, run_date=run_date),
media_type="text/event-stream",
)
@router.get("/agent/runs", response_model=list[RunRead])
def list_runs(db: Session = Depends(get_db)) -> list[AgentRun]:
# 运行列表保持紧凑,方便仪表盘和命令行查看最近状态。
return list(db.scalars(select(AgentRun).order_by(AgentRun.id.desc()).limit(20)).all())
@router.get("/signals", response_model=list[SignalRead])
def list_signals(limit: int = 50, db: Session = Depends(get_db)) -> list[Signal]:
# 情报列表按采集时间倒序返回,是仪表盘的主要阅读入口。
safe_limit = max(1, min(limit, 200))
return list(
db.scalars(_visible_signals_query().order_by(Signal.id.desc()).limit(safe_limit)).all()
)
@router.get("/reports/latest", response_model=Optional[ReportRead])
def latest_report(db: Session = Depends(get_db)) -> Optional[Report]:
# 最新日报提供一份可直接阅读的每日简报。
return db.scalar(select(Report).order_by(Report.id.desc()).limit(1))
@router.get("/dashboard", response_model=DashboardRead)
def dashboard(db: Session = Depends(get_db)) -> DashboardRead:
# 单个仪表盘接口返回运行、信号、日报和主题,前端不需要理解内部调度细节。
runs = list(db.scalars(select(AgentRun).order_by(AgentRun.id.desc()).limit(10)).all())
signals = list(db.scalars(_visible_signals_query().order_by(Signal.id.desc()).limit(80)).all())
report = db.scalar(select(Report).order_by(Report.id.desc()).limit(1))
topics = list(db.scalars(select(Topic).order_by(Topic.id.asc())).all())
return DashboardRead(
runs=runs,
signals=signals,
latest_report=report,
topics=topics,
)
@router.get("/logs/recent", response_model=list[str])
def recent_logs(settings: Settings = Depends(get_settings)) -> list[str]:
# 最近日志用于定位Agent和模型供应商调用问题。
if not settings.log_file.exists():
return []
lines = settings.log_file.read_text(encoding="utf-8", errors="replace").splitlines()
return lines[-settings.log_tail_lines :]
@router.post("/topics", response_model=TopicRead)
def create_topic(payload: TopicCreate, db: Session = Depends(get_db)) -> Topic:
# 主题让Agent可以在不改代码的情况下扩展关注范围。
topic = Topic(name=payload.name, description=payload.description, enabled=payload.enabled)
db.add(topic)
db.commit()
db.refresh(topic)
return topic
@router.get("/topics", response_model=list[TopicRead])
def list_topics(db: Session = Depends(get_db)) -> list[Topic]:
# 主题列表让操作者确认当前情报目标。
return list(db.scalars(select(Topic).order_by(Topic.id.asc())).all())
def _visible_signals_query():
# 运行中的信号和完成后的信号走同一张表,查询函数保留统一排序入口。
return select(Signal).join(AgentRun, Signal.run_id == AgentRun.id)
+1
View File
@@ -0,0 +1 @@
"""Core application settings and wiring."""
+43
View File
@@ -0,0 +1,43 @@
from functools import lru_cache
from pathlib import Path
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
# 配置统一来自环境变量,保证本地和服务器使用同一套启动路径。
app_name: str = "SignalScout"
env: str = "dev"
database_url: str = Field(
default="mysql+pymysql://signalscout:signalscout@127.0.0.1:3306/signalscout?charset=utf8mb4"
)
llm_base_url: str = "https://api.zayuapi.com/v1"
llm_api_key: str = ""
llm_model: str = "grok-4.3"
llm_timeout_seconds: int = 90
llm_max_tokens: int = 4000
news_recent_days: int = 3
news_max_items: int = 12
news_search_max_records: int = 20
github_recent_days: int = 30
github_min_stars: int = 20
github_max_projects: int = 5
agent_timezone: str = "Asia/Shanghai"
agent_cron_hour: int = 8
agent_cron_minute: int = 30
report_dir: Path = Path("reports")
log_file: Path = Path("logs/app.log")
log_tail_lines: int = 200
model_config = SettingsConfigDict(
env_file=".env",
env_prefix="SIGNALSCOUT_",
extra="ignore",
)
@lru_cache
def get_settings() -> Settings:
# Caching prevents repeated env parsing while tests can still clear it if needed.
return Settings()
+39
View File
@@ -0,0 +1,39 @@
import logging
from logging.handlers import RotatingFileHandler
from pathlib import Path
def configure_logging(log_file: Path) -> None:
"""Configure console and file logging once for local service runs."""
log_file.parent.mkdir(parents=True, exist_ok=True)
root_logger = logging.getLogger()
root_logger.setLevel(logging.INFO)
if not _has_handler("signalscout-console"):
# Console logs are for the terminal window that runs `make run`.
console_handler = logging.StreamHandler()
console_handler.name = "signalscout-console"
console_handler.setFormatter(_formatter())
root_logger.addHandler(console_handler)
if not _has_handler("signalscout-file"):
# File logs persist agent failures after the terminal scrollback is gone.
file_handler = RotatingFileHandler(
log_file,
maxBytes=2_000_000,
backupCount=3,
encoding="utf-8",
)
file_handler.name = "signalscout-file"
file_handler.setFormatter(_formatter())
root_logger.addHandler(file_handler)
def _formatter() -> logging.Formatter:
# Include module name so source, API, and runner failures are easy to separate.
return logging.Formatter("%(asctime)s %(levelname)s [%(name)s] %(message)s")
def _has_handler(name: str) -> bool:
# Uvicorn imports the app during startup, so handlers must be idempotent.
return any(handler.name == name for handler in logging.getLogger().handlers)
+1
View File
@@ -0,0 +1 @@
"""Database models, sessions, and repositories."""
+71
View File
@@ -0,0 +1,71 @@
from __future__ import annotations
from datetime import datetime
from typing import Optional
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, JSON, String, Text, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
class Base(DeclarativeBase):
"""Shared SQLAlchemy declarative base for app models and Alembic metadata."""
class Topic(Base):
__tablename__ = "topics"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(120), nullable=False, unique=True)
description: Mapped[str] = mapped_column(Text, nullable=False)
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), onupdate=func.now(), nullable=False
)
class AgentRun(Base):
__tablename__ = "agent_runs"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
status: Mapped[str] = mapped_column(String(32), nullable=False)
started_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
finished_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
error: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
request_payload: Mapped[dict] = mapped_column(JSON, nullable=False)
raw_response: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
signals: Mapped[list["Signal"]] = relationship(back_populates="run")
report: Mapped[Optional[Report]] = relationship(back_populates="run", uselist=False)
class Signal(Base):
__tablename__ = "signals"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
run_id: Mapped[int] = mapped_column(ForeignKey("agent_runs.id"), nullable=False, index=True)
topic: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
title: Mapped[str] = mapped_column(String(300), nullable=False)
summary: Mapped[str] = mapped_column(Text, nullable=False)
source_url: Mapped[str] = mapped_column(Text, nullable=False)
source_url_hash: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
source_name: Mapped[str] = mapped_column(String(200), nullable=False)
published_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
importance: Mapped[int] = mapped_column(Integer, nullable=False)
entities: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
run: Mapped[AgentRun] = relationship(back_populates="signals")
class Report(Base):
__tablename__ = "reports"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
run_id: Mapped[int] = mapped_column(ForeignKey("agent_runs.id"), nullable=False)
title: Mapped[str] = mapped_column(String(200), nullable=False)
content_md: Mapped[str] = mapped_column(Text, nullable=False)
file_path: Mapped[Optional[str]] = mapped_column(String(1024), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
run: Mapped[AgentRun] = relationship(back_populates="report")
+94
View File
@@ -0,0 +1,94 @@
from __future__ import annotations
from datetime import datetime
from hashlib import sha256
from typing import Optional
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.db.models import AgentRun, Report, Signal, Topic
from app.schemas.agent import SignalItem
class AgentRepository:
"""Database boundary for the agent runner."""
def __init__(self, db: Session) -> None:
self.db = db
def list_enabled_topics(self) -> list[Topic]:
# 启用主题是Agent每次运行时使用的长期目标。
return list(self.db.scalars(select(Topic).where(Topic.enabled.is_(True))).all())
def create_run(self, request_payload: dict) -> AgentRun:
# 运行先进入running状态,情报和日报保存完成后再标记完成。
run = AgentRun(status="running", request_payload=request_payload)
self.db.add(run)
self.db.flush()
return run
def mark_run_complete(self, run: AgentRun, raw_response: dict) -> None:
run.status = "completed"
run.finished_at = datetime.utcnow()
run.raw_response = raw_response
self.db.flush()
def mark_run_failed(self, run: AgentRun, error: str) -> None:
run.status = "failed"
run.finished_at = datetime.utcnow()
run.error = error
self.db.flush()
def save_signals(self, run: AgentRun, items: list[SignalItem]) -> list[Signal]:
# 相同来源链接只保存一次,避免日报和仪表盘重复出现同一条情报。
saved: list[Signal] = []
for item in items:
signal = self.save_or_update_signal(run=run, item=item)
if signal is None:
continue
saved.append(signal)
return saved
def save_or_update_signal(self, run: AgentRun, item: SignalItem) -> Optional[Signal]:
# 同一来源链接只保留一条情报,流式接口和普通接口共用这条去重规则。
source_url_hash = self._source_url_hash(item.source_url)
existing = self.db.scalar(select(Signal).where(Signal.source_url_hash == source_url_hash))
if existing and existing.run_id != run.id:
return None
if existing:
existing.topic = item.topic
existing.title = item.title
existing.summary = item.summary
existing.source_name = item.source_name
existing.published_at = item.published_at
existing.importance = item.importance
existing.entities = item.entities
self.db.flush()
return existing
signal = Signal(
run_id=run.id,
topic=item.topic,
title=item.title,
summary=item.summary,
source_url=item.source_url,
source_url_hash=source_url_hash,
source_name=item.source_name,
published_at=item.published_at,
importance=item.importance,
entities=item.entities,
)
self.db.add(signal)
self.db.flush()
return signal
def _source_url_hash(self, source_url: str) -> str:
# MySQL不适合直接唯一索引长URL,使用SHA-256作为去重键。
return sha256(source_url.encode("utf-8")).hexdigest()
def save_report(self, run: AgentRun, title: str, content_md: str, file_path: str) -> Report:
# 文件路径便于人工打开Markdown产物,MySQL保存可查询正文。
report = Report(run_id=run.id, title=title, content_md=content_md, file_path=file_path)
self.db.add(report)
self.db.flush()
return report
+21
View File
@@ -0,0 +1,21 @@
from collections.abc import Generator
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from app.core.config import get_settings
settings = get_settings()
# pool_pre_ping避免调度器长时间空闲后拿到失效的MySQL连接。
engine = create_engine(settings.database_url, pool_pre_ping=True, future=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
def get_db() -> Generator[Session, None, None]:
# FastAPI依赖负责在请求结束后关闭数据库会话。
db = SessionLocal()
try:
yield db
finally:
db.close()
+1
View File
@@ -0,0 +1 @@
"""External data-source clients used by the agent pipeline."""
+116
View File
@@ -0,0 +1,116 @@
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.parse import urlencode
from urllib.request import Request, urlopen
from app.core.config import Settings
from app.schemas.agent import SignalItem
logger = logging.getLogger(__name__)
class GitHubHotProjectClient:
"""Collects recently active, high-signal GitHub AI repositories."""
endpoint = "https://api.github.com/search/repositories"
def __init__(self, settings: Settings) -> None:
self.settings = settings
def collect_hot_projects(self, run_date: date) -> list[SignalItem]:
# GitHub官方搜索提供开源项目热度候选,最终是否入选交给模型判断。
since = run_date - timedelta(days=self.settings.github_recent_days)
repositories: dict[str, dict[str, Any]] = {}
for query in self._queries(since=since):
payload = self._search(query)
for repository in payload.get("items", []):
html_url = repository.get("html_url")
if isinstance(html_url, str):
repositories[html_url] = repository
ranked = sorted(repositories.values(), key=lambda item: self._heat_score(item, run_date), reverse=True)
projects = [self._to_signal_item(repository) for repository in ranked[: self.settings.github_max_projects]]
logger.info(
"github_hot_projects_collected days=%s min_stars=%s projects=%s",
self.settings.github_recent_days,
self.settings.github_min_stars,
len(projects),
)
return projects
def _queries(self, since: date) -> list[str]:
# 查询集聚焦新近创建的AI项目,避免长期头部仓库挤占每日发现。
since_text = since.isoformat()
min_stars = self.settings.github_min_stars
return [
f"topic:llm created:>={since_text} stars:>={min_stars}",
f"topic:ai-agent created:>={since_text} stars:>={min_stars}",
f"topic:rag created:>={since_text} stars:>={min_stars}",
f"llm in:name,description created:>={since_text} stars:>={min_stars}",
f"llm agent in:name,description created:>={since_text} stars:>={min_stars}",
]
def _search(self, query: str) -> dict[str, Any]:
# GitHub REST搜索直接返回排序后的仓库元数据,便于后续模型研判。
url = f"{self.endpoint}?{urlencode({'q': query, 'sort': 'stars', 'order': 'desc', 'per_page': '10'})}"
request = Request(
url,
headers={
"Accept": "application/vnd.github+json",
"User-Agent": "SignalScout",
"X-GitHub-Api-Version": "2022-11-28",
},
)
try:
with urlopen(request, timeout=20) as response:
body = response.read().decode("utf-8")
except HTTPError as exc:
message = exc.read().decode("utf-8", errors="replace")
raise RuntimeError(f"GitHub project search failed: HTTP {exc.code} {message}") from exc
except URLError as exc:
raise RuntimeError(f"GitHub project search failed: {exc.reason}") from exc
return json.loads(body)
def _to_signal_item(self, repository: dict[str, Any]) -> SignalItem:
# 仓库元数据先标准化为信号候选,后续由模型统一筛选和改写摘要。
full_name = str(repository.get("full_name") or repository.get("name") or "Unknown repository")
description = str(repository.get("description") or "暂无项目描述")
stars = int(repository.get("stargazers_count") or 0)
forks = int(repository.get("forks_count") or 0)
language = repository.get("language") or "未标注语言"
created_at = self._parse_datetime(repository.get("created_at"))
pushed_at = self._parse_datetime(repository.get("pushed_at"))
freshness = created_at.date().isoformat() if created_at else "最近"
summary = (
f"{description} 该项目创建于 {freshness},当前约 {stars} stars、{forks} forks"
f"主要语言为 {language}"
)
return SignalItem(
topic="GitHub 热门项目",
title=full_name,
summary=summary,
source_url=str(repository["html_url"]),
source_name="GitHub",
published_at=created_at or pushed_at,
importance=3,
entities=[full_name, "GitHub", str(language)],
)
def _heat_score(self, repository: dict[str, Any], run_date: date) -> int:
# Stars表示关注度,forks表示采用迹象,新项目获得额外新鲜度权重。
stars = int(repository.get("stargazers_count") or 0)
forks = int(repository.get("forks_count") or 0)
created_at = self._parse_datetime(repository.get("created_at"))
recency_bonus = 100 if created_at and created_at.date() >= run_date - timedelta(days=7) else 0
return stars * 3 + forks + recency_bonus
def _parse_datetime(self, value: Any) -> Optional[datetime]:
# GitHub时间戳使用UTC ISO格式和Z后缀。
if not isinstance(value, str):
return None
return datetime.fromisoformat(value.replace("Z", "+00:00")).replace(tzinfo=None)
+99
View File
@@ -0,0 +1,99 @@
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.parse import urlencode
from urllib.request import Request, urlopen
from app.core.config import Settings
from app.schemas.agent import SignalItem
logger = logging.getLogger(__name__)
class NewsSearchClient:
"""通过公开新闻搜索接口采集动态AI新闻候选。"""
endpoint = "https://api.gdeltproject.org/api/v2/doc/doc"
def __init__(self, settings: Settings) -> None:
self.settings = settings
def collect_ai_news(self, run_date: date) -> list[SignalItem]:
# GDELT按日期窗口返回全球新闻候选,模型后续只从这些证据里挑选日报条目。
since = run_date - timedelta(days=self.settings.news_recent_days)
articles: dict[str, dict[str, Any]] = {}
payload = self._search(query=self._query(), since=since, until=run_date)
for article in payload.get("articles", []):
url = article.get("url")
if isinstance(url, str) and url.startswith("http"):
articles[url] = article
candidates = [self._article_to_signal(article) for article in articles.values()]
candidates = [candidate for candidate in candidates if candidate is not None]
candidates.sort(key=lambda item: item.published_at or datetime.min, reverse=True)
logger.info("news_candidates_collected days=%s items=%s", self.settings.news_recent_days, len(candidates))
return candidates[: self.settings.news_search_max_records]
def _query(self) -> str:
# GDELT限制请求频率,单次组合查询覆盖模型、Agent、开源和融资四类情报。
return (
'("AI agent" OR "agent framework" OR "LLM agent" OR '
'"large language model" OR LLM OR "AI model" OR '
'"open source AI" OR "open-source AI" OR "AI startup funding")'
)
def _search(self, query: str, since: date, until: date) -> dict[str, Any]:
# ArtList模式返回标题、URL、域名和发现时间,正好满足候选证据采集。
params = {
"query": query,
"mode": "ArtList",
"format": "json",
"maxrecords": str(self.settings.news_search_max_records),
"sort": "HybridRel",
"startdatetime": since.strftime("%Y%m%d000000"),
"enddatetime": until.strftime("%Y%m%d235959"),
}
url = f"{self.endpoint}?{urlencode(params)}"
request = Request(url, headers={"User-Agent": "SignalScout"})
try:
with urlopen(request, timeout=30) as response:
body = response.read().decode("utf-8")
except HTTPError as exc:
message = exc.read().decode("utf-8", errors="replace")
raise RuntimeError(f"News search failed: HTTP {exc.code} {message}") from exc
except URLError as exc:
raise RuntimeError(f"News search failed: {exc.reason}") from exc
return json.loads(body)
def _article_to_signal(self, article: dict[str, Any]) -> Optional[SignalItem]:
# 新闻候选先保留标题、链接和来源,重要性与摘要由模型最终确定。
title = article.get("title")
url = article.get("url")
if not isinstance(title, str) or not isinstance(url, str):
return None
domain = str(article.get("domain") or "News")
source_country = str(article.get("sourcecountry") or "")
published_at = self._parse_gdelt_date(article.get("seendate"))
return SignalItem(
topic="AI 新闻候选",
title=title[:300],
summary=f"来自 {domain} 的新闻候选,GDELT发现时间为 {article.get('seendate') or '未知'}",
source_url=url,
source_name=domain,
published_at=published_at,
importance=3,
entities=[value for value in [domain, source_country] if value],
)
def _parse_gdelt_date(self, value: Any) -> Optional[datetime]:
# GDELT使用类似20260708T120000Z的时间格式。
if not isinstance(value, str):
return None
try:
return datetime.strptime(value, "%Y%m%dT%H%M%SZ")
except ValueError:
return None
+1
View File
@@ -0,0 +1 @@
"""模型调用与结构化情报生成模块。"""
+198
View File
@@ -0,0 +1,198 @@
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,
)
+53
View File
@@ -0,0 +1,53 @@
import sys
from contextlib import asynccontextmanager
from collections.abc import AsyncIterator
import uvicorn
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
# Direct IDE runs should not leave Python bytecode caches in the project tree.
sys.dont_write_bytecode = True
from app.agent.scheduler import build_scheduler # noqa: E402
from app.api.routes import router # noqa: E402
from app.core.config import get_settings # noqa: E402
from app.core.log_setup import configure_logging # noqa: E402
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
# The scheduler lives with the web process for the demo deployment profile.
settings = get_settings()
scheduler = build_scheduler(settings)
scheduler.start()
app.state.scheduler = scheduler
try:
yield
finally:
scheduler.shutdown(wait=False)
def create_app() -> FastAPI:
# App factory keeps tests and production startup on the same initialization path.
settings = get_settings()
configure_logging(settings.log_file)
app = FastAPI(title=settings.app_name, lifespan=lifespan)
# The separate Vite frontend runs on 5173 during local development.
app.add_middleware(
CORSMiddleware,
allow_origins=["http://127.0.0.1:5173", "http://localhost:5173"],
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(router)
return app
app = create_app()
if __name__ == "__main__":
# Direct execution is for IDE/local development; deployment can still call uvicorn explicitly.
uvicorn.run("app.main:app", host="127.0.0.1", port=8010, reload=False)
+1
View File
@@ -0,0 +1 @@
"""Pydantic contracts used across the app."""
+98
View File
@@ -0,0 +1,98 @@
from __future__ import annotations
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field
class SearchTopic(BaseModel):
# 主题是模型每次运行时使用的长期情报目标。
name: str
description: str
class SignalItem(BaseModel):
# 模型产出的结构化情报会通过这个契约进入数据库和日报。
topic: str
title: str
summary: str
source_url: str
source_name: str
published_at: Optional[datetime] = None
importance: int = Field(ge=1, le=5)
entities: list[str] = Field(default_factory=list)
class AgentResult(BaseModel):
# Agent结果同时承载机器可读信号和人可读日报。
title: str
signals: list[SignalItem]
report_markdown: str
raw_response: dict = Field(default_factory=dict)
class RunResponse(BaseModel):
run_id: int
status: str
report_path: Optional[str] = None
class RunRead(BaseModel):
id: int
status: str
started_at: datetime
finished_at: Optional[datetime]
error: Optional[str]
model_config = {"from_attributes": True}
class SignalRead(BaseModel):
id: int
run_id: int
topic: str
title: str
summary: str
source_url: str
source_name: str
published_at: Optional[datetime]
importance: int
entities: list[str]
created_at: datetime
model_config = {"from_attributes": True}
class ReportRead(BaseModel):
id: int
run_id: int
title: str
content_md: str
file_path: Optional[str]
created_at: datetime
model_config = {"from_attributes": True}
class DashboardRead(BaseModel):
runs: list[RunRead]
signals: list[SignalRead]
latest_report: Optional[ReportRead]
topics: list["TopicRead"]
class TopicCreate(BaseModel):
name: str = Field(min_length=2, max_length=120)
description: str = Field(min_length=5)
enabled: bool = True
class TopicRead(BaseModel):
id: int
name: str
description: str
enabled: bool
model_config = {"from_attributes": True}