Add AI news risk analysis and dashboard
Analyze news revisions with independent model settings, evidence-backed risk ratings, event grouping, and rolling market outlooks. Add a default homepage, archival history, pending-result refresh, and isolated regression coverage. Keep analysis disabled until explicitly enabled. Require a separately authorized schema migration before deployment and preserve compatibility with the previous six-service instance.
This commit is contained in:
1 parent
549b818e95
commit
4f43ca8b57
31 files changed
+1521
-18
No files matched your search
@@ -0,0 +1,146 @@
|
||||
"""Authenticated news risk research endpoints."""
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Annotated, Literal
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from alphapulse.api.response import success
|
||||
from alphapulse.core.auth import require_admin
|
||||
from alphapulse.core.db import get_session
|
||||
from alphapulse.core.redis import redis
|
||||
from alphapulse.models import NewsItem, NewsRevision
|
||||
from alphapulse.models.risk import RiskSettings, RiskAnalysis, RiskEvent, RiskSnapshot
|
||||
from alphapulse.news_risk.service import START, default_settings, provider_for, complete, Labels, SYSTEM
|
||||
|
||||
router = APIRouter(prefix='/api/risk', tags=['risk'], dependencies=[Depends(require_admin)])
|
||||
DB = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
||||
|
||||
class SettingsIn(BaseModel):
|
||||
enabled: bool = False
|
||||
provider_id: int = Field(gt=0)
|
||||
model: str = Field(min_length=1, max_length=128, pattern=r'.*\S.*')
|
||||
|
||||
|
||||
@router.get('/settings')
|
||||
async def settings(session: DB):
|
||||
value = await default_settings(session)
|
||||
return success({'enabled': value.enabled, 'provider_id': value.provider_id, 'model': value.model, 'start_at': START})
|
||||
|
||||
|
||||
@router.put('/settings')
|
||||
async def save_settings(body: SettingsIn, session: DB):
|
||||
settings = RiskSettings(id=1, **body.model_dump())
|
||||
try:
|
||||
await provider_for(session, settings)
|
||||
except ValueError as error:
|
||||
raise HTTPException(400, str(error)) from error
|
||||
if body.enabled:
|
||||
previous = await session.get(RiskSettings, 1)
|
||||
if not previous or not previous.enabled or previous.provider_id != body.provider_id or previous.model != body.model:
|
||||
raise HTTPException(409, '请先使用测试并启用按钮验证模型')
|
||||
await session.merge(settings); await session.commit()
|
||||
return success(None)
|
||||
|
||||
|
||||
@router.post('/enable')
|
||||
async def enable(body: SettingsIn, session: DB):
|
||||
settings = RiskSettings(id=1, **{**body.model_dump(), 'enabled': True})
|
||||
try:
|
||||
provider = await provider_for(session, settings)
|
||||
except ValueError as error:
|
||||
raise HTTPException(400, str(error)) from error
|
||||
fixture = {'news': [{'id': 1, 'title': '测试消息:暂无新事件', 'summary': '', 'content': ''}], 'candidates': []}
|
||||
from alphapulse.news_risk.service import validate_labels
|
||||
try:
|
||||
result, _ = await complete(provider, SYSTEM, fixture, Labels)
|
||||
validate_labels(result, {1: fixture['news'][0]}, set())
|
||||
except Exception as error:
|
||||
raise HTTPException(502, f'模型验证失败:{error}') from error
|
||||
await session.merge(settings); await session.commit()
|
||||
return success(None)
|
||||
|
||||
|
||||
async def coverage(session):
|
||||
total = await session.scalar(select(func.count()).select_from(NewsItem).where(NewsItem.published_at.between(START, datetime.now(UTC))))
|
||||
counts = dict((await session.execute(select(RiskAnalysis.state, func.count()).join(NewsItem).where(
|
||||
RiskAnalysis.content_hash == NewsItem.content_hash,
|
||||
NewsItem.published_at.between(START, datetime.now(UTC))).group_by(RiskAnalysis.state))).all())
|
||||
return {'total': total, 'done': counts.get('done', 0), 'failed': counts.get('failed', 0),
|
||||
'processing': counts.get('processing', 0), 'pending': max(0, total - sum(counts.values())) + counts.get('pending', 0)}
|
||||
|
||||
|
||||
@router.get('/overview')
|
||||
async def overview(session: DB, hours: Literal[24, 48, 168] = 24):
|
||||
now = datetime.now(UTC); cutoff = now - timedelta(hours=hours)
|
||||
counts = dict((await session.execute(select(RiskEvent.stars, func.count()).where(
|
||||
RiskEvent.updated_at >= cutoff, RiskEvent.result["verification"].astext != "insufficient").group_by(RiskEvent.stars))).all())
|
||||
snapshot = await session.scalar(select(RiskSnapshot).where(RiskSnapshot.state == 'done').order_by(RiskSnapshot.id.desc()).limit(1))
|
||||
last = await session.scalar(select(RiskSnapshot).order_by(RiskSnapshot.id.desc()).limit(1))
|
||||
scenario = next((w for w in snapshot.result['windows'] if w['hours'] == hours), None) if snapshot else None
|
||||
return success({'as_of': now, 'counts': counts, 'coverage': await coverage(session), 'scenario': scenario,
|
||||
'snapshot_at': snapshot.created_at if snapshot else None, 'quotes': snapshot.quotes if snapshot else None,
|
||||
'stale': not snapshot or (now-snapshot.created_at).total_seconds() > 1200 or bool(last.error),
|
||||
'error': last.error if last else None, 'heartbeat': await redis.get('ap:risk:heartbeat')})
|
||||
|
||||
|
||||
@router.get('/events')
|
||||
async def events(session: DB, hours: Literal[24, 48, 168] | None = None, stars: int = Query(1, ge=0, le=5),
|
||||
q: str = '', start: datetime | None = None, end: datetime | None = None,
|
||||
page: int = Query(1, ge=1), limit: int = Query(20, ge=1, le=100)):
|
||||
conditions = [RiskEvent.stars >= stars]
|
||||
if hours: conditions.append(RiskEvent.updated_at >= datetime.now(UTC)-timedelta(hours=hours))
|
||||
if start: conditions.append(RiskEvent.updated_at >= start)
|
||||
if end: conditions.append(RiskEvent.updated_at < end)
|
||||
if q: conditions.append(RiskEvent.title.contains(q, autoescape=True))
|
||||
rows = (await session.scalars(select(RiskEvent).where(*conditions).order_by(
|
||||
RiskEvent.stars.desc(), RiskEvent.updated_at.desc(), RiskEvent.id.desc()).offset((page-1)*limit).limit(limit))).all()
|
||||
return success({'items': [event_out(e) for e in rows], 'total': await session.scalar(select(func.count()).select_from(RiskEvent).where(*conditions))})
|
||||
|
||||
|
||||
def event_out(event):
|
||||
return {key: getattr(event, key) for key in ('id', 'title', 'category', 'entities', 'stars', 'peak_stars', 'result', 'published_at', 'updated_at')}
|
||||
|
||||
|
||||
@router.get('/events/{event_id}')
|
||||
async def event_detail(event_id: int, session: DB):
|
||||
event = await session.get(RiskEvent, event_id)
|
||||
if event is None: raise HTTPException(404, '风险事件不存在')
|
||||
rows = (await session.scalars(select(RiskAnalysis).where(RiskAnalysis.event_id == event_id).order_by(RiskAnalysis.id))).all()
|
||||
return success({'event': event_out(event), 'history': [analysis_out(r) for r in rows]})
|
||||
|
||||
|
||||
def analysis_out(row):
|
||||
return {'id': row.id, 'news_id': row.news_id, 'state': row.state, 'result': row.result, 'input': row.input,
|
||||
'model': row.model, 'error': row.error, 'analyzed_at': row.finished_at,
|
||||
'historical': row.started_at is not None and (row.started_at-datetime.fromisoformat(row.input['progress_at'])).total_seconds() > 300}
|
||||
|
||||
|
||||
@router.get('/analyses')
|
||||
async def analyses(session: DB, news_id: int | None = None, revision_no: int | None = Query(None, ge=1), failed: bool = False):
|
||||
query = select(RiskAnalysis)
|
||||
if news_id: query = query.where(RiskAnalysis.news_id == news_id)
|
||||
if revision_no is not None:
|
||||
if not news_id: raise HTTPException(400, '指定修订版本时必须提供新闻 ID')
|
||||
current = await session.get(NewsItem, news_id)
|
||||
if current is None: raise HTTPException(404, '新闻不存在')
|
||||
content_hash = current.content_hash if current.revision_no == revision_no else await session.scalar(
|
||||
select(NewsRevision.content_hash).where(NewsRevision.news_id == news_id, NewsRevision.revision_no == revision_no))
|
||||
if content_hash is None: raise HTTPException(404, '新闻修订版本不存在')
|
||||
query = query.where(RiskAnalysis.content_hash == content_hash)
|
||||
if failed: query = query.where(RiskAnalysis.state == 'failed')
|
||||
return success([analysis_out(r) for r in (await session.scalars(query.order_by(RiskAnalysis.id.desc()).limit(100))).all()])
|
||||
|
||||
|
||||
@router.post('/analyses/{analysis_id}/retry', status_code=202)
|
||||
async def retry(analysis_id: int, session: DB):
|
||||
row = await session.get(RiskAnalysis, analysis_id, with_for_update=True)
|
||||
if row is None: raise HTTPException(404, '分析记录不存在')
|
||||
if row.state != 'failed': raise HTTPException(409, '仅可重试失败任务')
|
||||
current = await session.get(NewsItem, row.news_id)
|
||||
if current.content_hash != row.content_hash:
|
||||
raise HTTPException(409, '新闻已修订,请等待最新版本分析;旧记录保留')
|
||||
row.state = 'pending'; row.error = None
|
||||
await session.commit()
|
||||
return success(None)
|
||||
Reference in new issue
Block a user