重写为 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 @@
"""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()