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:
eezd committed 2026-10-30 18:35:49 +08:00
1 parent 549b818e95
commit 4f43ca8b57
31 files changed
+1521 -18

No files matched your search

+2 -1
View File
@@ -5,7 +5,7 @@ from fastapi.exceptions import RequestValidationError
from fastapi.responses import RedirectResponse
from starlette.exceptions import HTTPException
from alphapulse.api import auth, calendar, chat, crawler, market, news, settings, skills, trading
from alphapulse.api import auth, calendar, chat, crawler, market, news, settings, skills, trading, risk
from alphapulse.api.response import (
ApiResponse, ValidationErrors, http_error, internal_error, success, validation_error,
)
@@ -29,6 +29,7 @@ app.include_router(skills.router)
app.include_router(chat.router)
app.include_router(trading.router)
app.include_router(calendar.router)
app.include_router(risk.router)
@app.get("/api/health", include_in_schema=False)
+14 -2
View File
@@ -15,6 +15,7 @@ from alphapulse.core.db import get_session
from alphapulse.core.redis import redis
from alphapulse.crawler.events import NEWS_CHANNEL
from alphapulse.models import NewsItem, NewsRevision
from alphapulse.models.risk import RiskAnalysis
router = APIRouter(prefix="/api/news", tags=["news"], dependencies=[Depends(require_admin)])
@@ -33,6 +34,13 @@ NEWS_KEYWORDS = (
)
class NewsRiskBrief(BaseModel):
state: str
stars: int | None = None
summary: str | None = None
verification: str | None = None
class NewsBrief(BaseModel):
id: int
source_code: str
@@ -46,6 +54,7 @@ class NewsBrief(BaseModel):
published_at: datetime
first_seen_at: datetime
revision_no: int
risk: NewsRiskBrief | None = None
class NewsPage(BaseModel):
@@ -108,14 +117,17 @@ async def list_news(
conditions = news_filters(source, q, start, end)
total = await session.scalar(select(func.count()).select_from(NewsItem).where(*conditions))
rows = (await session.execute(
select(NewsItem, func.left(NewsItem.content_text, EXCERPT_LENGTH).label("excerpt"))
select(NewsItem, func.left(NewsItem.content_text, EXCERPT_LENGTH).label("excerpt"), RiskAnalysis)
.outerjoin(RiskAnalysis, (RiskAnalysis.news_id == NewsItem.id) & (RiskAnalysis.content_hash == NewsItem.content_hash))
.where(*conditions)
.order_by(NewsItem.published_at.desc(), NewsItem.id.desc())
.offset((page - 1) * limit)
.limit(limit)
)).all()
items = [NewsBrief.model_validate({**news.__dict__, "excerpt": excerpt}) for news, excerpt in rows]
items = [NewsBrief.model_validate({**news.__dict__, "excerpt": excerpt,
"risk": {**(analysis.result or {}), "state": analysis.state} if analysis else None})
for news, excerpt, analysis in rows]
return success(NewsPage(items=items, total=total))
+146
View File
@@ -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)
+6
View File
@@ -18,6 +18,7 @@ from alphapulse.core.crypto import encrypt
from alphapulse.core.db import get_session
from alphapulse.core.redis import redis
from alphapulse.models import AiProvider, TradingAccount, TradingAnalysis, TradingCredential, TradingSymbol
from alphapulse.models.risk import RiskSettings
from alphapulse.trading.client import ExchangeError, create_client
from alphapulse.trading.snapshot import STATUS_KEY
@@ -144,6 +145,11 @@ async def patch_provider(provider_id: int, body: ProviderPatch, session: Annotat
@router.delete("/providers/{provider_id}")
async def delete_provider(provider_id: int, session: Annotated[AsyncSession, Depends(get_session)]) -> ApiResponse[None]:
provider = await get_provider(session, provider_id)
risk = await session.scalar(select(RiskSettings).where(RiskSettings.provider_id == provider_id).with_for_update())
if risk:
if risk.enabled:
raise HTTPException(status.HTTP_409_CONFLICT, "请先暂停新闻 AI 分析或切换其接入配置")
risk.provider_id = None
await session.delete(provider)
await session.flush()
if provider.is_default: