重写为 AI 情报 Agent 并接入 Jenkins 流水线
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Database models, sessions, and repositories."""
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user