from __future__ import annotations from datetime import date from typing import Optional from fastapi import APIRouter, Depends from fastapi.responses import HTMLResponse, 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, ) from app.ui.home import HOME_PAGE_HTML router = APIRouter() @router.get("/", response_class=HTMLResponse) def home() -> HTMLResponse: # 根路径返回业务工作台,避免用户直接打开域名时看到接口404。 return HTMLResponse(HOME_PAGE_HTML) @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)