Add three-stage cross-domain news research and reading briefings

Group original news into tagged fact packages, review each package with evidence and read-only research, and build complete 72-hour reading lists with independent model selections.

Add paused RSS/Atom subscriptions, news-scoped Skills, versioned analysis records, and locked deployment support for SearXNG. Preserve original news and keep live migration and legacy AI cleanup separately authorized.

Validate with 604 backend tests, 49 frontend tests, 19 CI control tests, lint, type checks, production build, and disposable PostgreSQL migration and three-stage smoke checks.
This commit is contained in:
eezd committed 2026-10-31 22:08:59 +08:00
1 parent ae35d3d608
commit 80d266dba7
44 files changed
+2682 -574

No files matched your search

+19
View File
@@ -81,6 +81,7 @@ class SourcesOut(BaseModel):
class SourcePatch(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=64)
enabled: bool | None = None
interval_seconds: Annotated[int | None, Field(ge=5, le=86400)] = None
# 设为时间表示开始或继续回补到该时间;显式传 null 表示取消
@@ -275,3 +276,21 @@ async def restart(body: RestartIn, session: Annotated[AsyncSession, Depends(get_
if receivers == 0:
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, "采集管理进程未运行")
return success(None)
class RssIn(BaseModel):
name: str = Field(min_length=1, max_length=64)
url: str = Field(min_length=1, max_length=255)
interval_seconds: int = Field(default=300, ge=60, le=86400)
@router.post('/sources/rss', status_code=201)
async def create_rss(body: RssIn, session: Annotated[AsyncSession, Depends(get_session)]):
from alphapulse.news_risk.research import public_address
try: await public_address(body.url)
except ValueError as error: raise HTTPException(422, str(error)) from error
except OSError as error: raise HTTPException(422, '无法解析订阅来源地址') from error
source = Source(code='rss_'+uuid.uuid4().hex[:24], name=body.name, list_url=body.url,
adapter='rss', collector='news', enabled=False, interval_seconds=body.interval_seconds, backfill_hours=72)
session.add(source); await session.commit()
return success({'code': source.code})
+13 -6
View File
@@ -15,7 +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
from alphapulse.models.risk import ArticleAnalysis, NewsSummary, SummaryArticle
router = APIRouter(prefix="/api/news", tags=["news"], dependencies=[Depends(require_admin)])
@@ -120,17 +120,24 @@ async def list_news(
conditions.append(NewsItem.id.in_(ids))
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"), RiskAnalysis)
.outerjoin(RiskAnalysis, (RiskAnalysis.news_id == NewsItem.id) & (RiskAnalysis.content_hash == NewsItem.content_hash))
select(NewsItem, func.left(NewsItem.content_text, EXCERPT_LENGTH).label("excerpt"), ArticleAnalysis)
.outerjoin(ArticleAnalysis, (ArticleAnalysis.news_id == NewsItem.id) & (ArticleAnalysis.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,
"risk": {**(analysis.result or {}), "state": analysis.state} if analysis else None})
for news, excerpt, analysis in rows]
items = []
for news, excerpt, analysis in rows:
summaries = (await session.scalars(select(NewsSummary).join(SummaryArticle).where(
SummaryArticle.analysis_id == analysis.id, NewsSummary.result.is_not(None)).order_by(NewsSummary.stars.desc()))).all() if analysis else []
risk = None
if analysis:
risk = {'state': 'pending' if analysis.state == 'done' else analysis.state}
if summaries:
risk = {**summaries[0].result, 'stars': summaries[0].stars, 'state': 'done'}
items.append(NewsBrief.model_validate({**news.__dict__, 'excerpt': excerpt, 'risk': risk}))
return success(NewsPage(items=items, total=total))
+178 -69
View File
@@ -11,8 +11,8 @@ 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
from alphapulse.models.risk import RiskSettings, RiskSnapshot, ArticleAnalysis, NewsSummary, SummaryArticle, SummaryVersion
from alphapulse.news_risk.service import default_settings, complete
router = APIRouter(prefix='/api/risk', tags=['risk'], dependencies=[Depends(require_admin)])
DB = Annotated[AsyncSession, Depends(get_session)]
@@ -21,116 +21,187 @@ DB = Annotated[AsyncSession, Depends(get_session)]
class RiskWindow(IntEnum):
DAY = 24
TWO_DAYS = 48
THREE_DAYS = 72
WEEK = 168
class SettingsIn(BaseModel):
enabled: bool = False
provider_id: int = Field(gt=0)
model: str = Field(min_length=1, max_length=128, pattern=r'.*\S.*')
provider_id: int | None = Field(default=None, gt=0)
model: str = Field(default='', max_length=128)
second_provider_id: int | None = Field(default=None, gt=0)
second_model: str = Field(default='', max_length=128)
third_provider_id: int | None = Field(default=None, gt=0)
third_model: str = Field(default='', max_length=128)
grouping_skill_id: int | None = Field(default=None, gt=0)
analysis_skill_id: int | None = Field(default=None, gt=0)
outlook_skill_id: int | None = Field(default=None, gt=0)
def config_out(value):
return {**{key: getattr(value, key) for key in SettingsIn.model_fields}, 'start_at': value.start_at}
@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})
return success(config_out(await default_settings(session)))
@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
previous = await session.get(RiskSettings, 1)
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()
raise HTTPException(409, '请先使用测试并启用按钮验证三阶段模型和Skills')
value = RiskSettings(id=1, **body.model_dump(), start_at=previous.start_at if previous else None)
await session.merge(value); await session.commit()
return success(None)
@router.post('/skill-templates')
async def templates(session: DB):
from alphapulse.ai.skills import create_node, add_version
from alphapulse.news_risk.pipeline import TEMPLATES
from alphapulse.models import SkillNode
result = {}
for role, (name, content) in TEMPLATES.items():
node = await session.scalar(select(SkillNode).where(SkillNode.parent_id.is_(None), SkillNode.name == name))
if node is not None and node.scope != 'news':
raise HTTPException(409, f'{name}已被其他用途使用,请先重命名')
if node is None:
node = await create_node(session, None, 'skill', name, scope='news')
await add_version(session, node, content, 'user', '新闻AI默认草稿')
result[role+'_skill_id' if role != 'analysis' else 'analysis_skill_id'] = node.id
await session.commit()
return success(result)
@router.post('/enable')
async def enable(body: SettingsIn, session: DB):
settings = RiskSettings(id=1, **{**body.model_dump(), 'enabled': True})
from alphapulse.news_risk.pipeline import configuration, Grouping, summarize_articles, Evidence, validate_evidence
from alphapulse.news_risk import briefing
before = await session.get(RiskSettings, 1)
before_config = config_out(before) if before else None
value = RiskSettings(id=1, **{**body.model_dump(), 'enabled': True})
fingerprints = {}
fixture = {'id': 1, 'title': '虚构示例:示例公司发布维护通知', 'summary': '示例公司计划例行维护。',
'content': '本条为虚构测试,不是真实市场事件。示例公司计划例行维护,恢复时间尚未公布。'}
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())
provider, system, _, fingerprints['grouping'] = await configuration(session, value, 'grouping')
grouped, _ = await complete(provider, system, {'article': fixture, 'candidates': []}, Grouping)
for event in grouped.events:
if event.event_id is not None: raise ValueError('测试没有已有候选事件')
validate_evidence([Evidence(article_id=1, quote=event.evidence)], {1: fixture})
provider, system, _, fingerprints['analysis'] = await configuration(session, value, 'analysis')
await summarize_articles(provider, system, [fixture], {'id': 1, 'title': fixture['title'],
'fact': '示例公司计划例行维护', 'category': 'company', 'tags': ['维护'], 'content_kind': 'statement'})
provider, system, _, fingerprints['outlook'] = await configuration(session, value, 'outlook')
await briefing.generate(provider, system, [{'id': 1, 'revision': 1, 'importance_stars': 3, 'result': {'summary': '虚构测试'}}])
except Exception as error:
raise HTTPException(502, f'模型验证失败:{error}') from error
await session.merge(settings); await session.commit()
raise HTTPException(502, f'三阶段验证失败:{error}') from error
previous = await session.get(RiskSettings, 1, populate_existing=True, with_for_update=True)
if (config_out(previous) if previous else None) != before_config:
raise HTTPException(409, '验证期间设置已变化,请重新操作')
for stage, fingerprint in fingerprints.items():
if (await configuration(session, value, stage))[3] != fingerprint:
raise HTTPException(409, '验证期间模型或Skill已变化,请重新验证')
value.start_at = previous.start_at if previous and previous.start_at else datetime.now(UTC)
await session.merge(value); 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)}
from alphapulse.news_risk.worker import eligible
settings = await session.get(RiskSettings, 1)
if not settings or not settings.start_at:
return dict(total=0, done=0, failed=0, processing=0, pending=0)
total = await session.scalar(select(func.count()).select_from(NewsItem).where(eligible(settings.start_at)))
counts = dict((await session.execute(select(ArticleAnalysis.state, func.count()).join(NewsItem).where(
ArticleAnalysis.content_hash == NewsItem.content_hash, eligible(settings.start_at)).group_by(ArticleAnalysis.state))).all())
return {'total': total, **{k: counts.get(k, 0) for k in ('done', 'failed', 'processing')},
'pending': max(0, total-sum(counts.values()))+counts.get('pending', 0)}
@router.get('/overview')
async def overview(session: DB, hours: RiskWindow = RiskWindow.DAY):
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))
counts = dict((await session.execute(select(NewsSummary.importance_stars, func.count()).where(
NewsSummary.updated_at >= cutoff, NewsSummary.result.is_not(None)).group_by(NewsSummary.importance_stars))).all())
snapshot = await session.scalar(select(RiskSnapshot).where(RiskSnapshot.state == 'done', RiskSnapshot.result['inputs'].is_not(None)).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,
scenario = None
stage2 = dict((await session.execute(select(NewsSummary.state, func.count()).group_by(NewsSummary.state))).all())
current = (await session.scalars(select(NewsSummary).where(NewsSummary.result.is_not(None), NewsSummary.updated_at >= now-timedelta(hours=72)))).all()
from alphapulse.news_risk.briefing import semantic_packages
previous_inputs = semantic_packages(snapshot.result['inputs']) if snapshot else []
changed = previous_inputs != semantic_packages([{'id': p.id, 'importance_stars': p.importance_stars, 'result': p.result} for p in current])
stage3 = {'state': last.state if last else 'pending', 'pending': changed,
'heartbeat': await redis.get('ap:risk:outlook:heartbeat')}
return success({'stage2': stage2, 'stage3': stage3, 'as_of': now, 'counts': counts, 'coverage': await coverage(session), 'scenario': scenario, 'briefing': snapshot.result if snapshot else None,
'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),
'stale': not snapshot or changed or bool(last and 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: RiskWindow | None = None, stars: int = Query(1, ge=0, le=5),
q: str = '', start: datetime | None = None, end: datetime | None = None,
@router.get('/summaries')
async def events(session: DB, hours: RiskWindow | None = None, stars: int = Query(0, ge=0, le=5),
tag: str = '', importance: int = Query(0, ge=0, le=5), category: str = '', asset: str = '', 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))})
conditions = [NewsSummary.stars >= stars, NewsSummary.result.is_not(None)]
if importance: conditions.append(NewsSummary.importance_stars >= importance)
if tag: conditions.append(NewsSummary.tags.contains([tag.strip().casefold()]))
if hours: conditions.append(NewsSummary.updated_at >= datetime.now(UTC)-timedelta(hours=hours))
if start: conditions.append(NewsSummary.updated_at >= start)
if end: conditions.append(NewsSummary.updated_at < end)
if category: conditions.append(NewsSummary.category == category)
if asset: conditions.append(NewsSummary.result['asset_impacts'].astext.contains(asset, autoescape=True))
if q: conditions.append(NewsSummary.title.contains(q, autoescape=True))
rows = (await session.scalars(select(NewsSummary).where(*conditions).order_by(
NewsSummary.stars.desc(), NewsSummary.updated_at.desc(), NewsSummary.id.desc()).offset((page-1)*limit).limit(limit))).all()
return success({'items': [await event_out(e, session) for e in rows], 'total': await session.scalar(select(func.count()).select_from(NewsSummary).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')}
async def event_out(event, session):
count = await session.scalar(select(func.count(func.distinct(ArticleAnalysis.news_id))).join(SummaryArticle).join(NewsItem).where(
SummaryArticle.summary_id == event.id, ArticleAnalysis.content_hash == NewsItem.content_hash))
return {'risk_stars': event.stars, 'article_count': count, **{key: getattr(event, key) for key in ('id', 'title', 'category', 'entities', 'tags', 'content_kind', 'importance_stars', 'peak_stars', 'result', 'published_at', 'updated_at', 'state', 'analyzed_at', 'revision', 'published_revision', 'error')}}
@router.get('/events/{event_id}')
@router.get('/summaries/{event_id}')
async def event_detail(event_id: int, session: DB):
event = await session.get(RiskEvent, event_id)
event = await session.get(NewsSummary, 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]})
rows = (await session.scalars(select(ArticleAnalysis).join(SummaryArticle).where(
SummaryArticle.summary_id == event_id).order_by(ArticleAnalysis.id))).all()
versions = (await session.scalars(select(SummaryVersion).where(SummaryVersion.summary_id == event_id)
.order_by(SummaryVersion.id.desc()))).all()
from alphapulse.models.risk import PackageEvidence
evidence = (await session.scalars(select(PackageEvidence).where(PackageEvidence.summary_id == event_id).order_by(PackageEvidence.id))).all()
return success({'evidence': [{'id': e.id, 'version_id': e.version_id, 'kind': e.kind, 'data': e.data, 'fetched_at': e.fetched_at} for e in evidence], 'event': await event_out(event, session), 'history': [analysis_out(r) for r in rows],
'versions': [{'id': v.id, 'revision': v.revision, 'state': v.state, 'model': v.model,
'rules': v.rules, 'result': v.result, 'error': v.error, 'analyzed_at': v.finished_at} for v in versions]})
def analysis_out(row):
return {'id': row.id, 'news_id': row.news_id, 'state': row.state, 'result': row.result, 'input': row.input,
return {'id': row.id, 'news_id': row.news_id, 'state': row.state, 'result': None, 'input': {k: v for k, v in row.input.items() if k != 'content'},
'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)
@router.get('/tasks')
async def analyses(session: DB, stage: str = Query('grouping', pattern='^(grouping|analysis|outlook)$'), news_id: int | None = None, revision_no: int | None = Query(None, ge=1), failed: bool = False):
if stage == 'outlook':
query = select(RiskSnapshot)
if failed: query = query.where(RiskSnapshot.state == 'failed')
rows = (await session.scalars(query.order_by(RiskSnapshot.id.desc()).limit(100))).all()
return success([{'id': r.id, 'state': r.state, 'error': r.error, 'model': r.model, 'input': {'title': '市场综合判断'}, 'result': None} for r in rows])
if stage == 'analysis':
query = select(SummaryVersion)
if failed: query = query.where(SummaryVersion.state == 'failed')
rows = (await session.scalars(query.order_by(SummaryVersion.id.desc()).limit(100))).all()
return success([{'id': r.id, 'summary_id': r.summary_id, 'state': r.state, 'error': r.error, 'model': r.model,
'input': {'title': f'事件总结 #{r.summary_id}'}, 'result': r.result} for r in rows])
query = select(ArticleAnalysis)
if news_id: query = query.where(ArticleAnalysis.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)
@@ -138,14 +209,31 @@ async def analyses(session: DB, news_id: int | None = None, revision_no: int | N
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()])
query = query.where(ArticleAnalysis.content_hash == content_hash)
if failed: query = query.where(ArticleAnalysis.state == 'failed')
return success([analysis_out(r) for r in (await session.scalars(query.order_by(ArticleAnalysis.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)
@router.post('/tasks/{analysis_id}/retry', status_code=202)
async def retry(analysis_id: int, session: DB, stage: str = Query('grouping', pattern='^(grouping|analysis|outlook)$')):
if stage == 'outlook':
row = await session.get(RiskSnapshot, analysis_id, with_for_update=True)
if row is None or row.state != 'failed': raise HTTPException(409, '仅可重试失败任务')
row.quotes = {**row.quotes, 'retry_requested': True}
await session.commit(); return success(None)
if stage == 'analysis':
row = await session.get(SummaryVersion, analysis_id, with_for_update=True)
if row is None or row.state != 'failed': raise HTTPException(409, '仅可重试失败任务')
event = await session.get(NewsSummary, row.summary_id, with_for_update=True)
if event.revision != row.revision:
raise HTTPException(409, '已有更新的事件输入,请重试最新任务')
if event.published_revision >= row.revision:
raise HTTPException(409, '该修订已有成功结果,无需重试旧失败任务')
if event.state in ('pending', 'processing'):
raise HTTPException(409, '该包已排队或正在处理')
event.state = 'pending'; event.due_at = datetime.now(UTC); event.error = None
await session.commit(); return success(None)
row = await session.get(ArticleAnalysis, 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)
@@ -154,3 +242,24 @@ async def retry(analysis_id: int, session: DB):
row.state = 'pending'; row.error = None
await session.commit()
return success(None)
@router.get('/news/{news_id}/summaries')
async def news_summaries(news_id: int, session: DB, revision_no: int | None = Query(None, ge=1)):
query = select(NewsSummary).join(SummaryArticle).join(ArticleAnalysis).where(ArticleAnalysis.news_id == news_id)
if revision_no is not None:
query = query.where(ArticleAnalysis.input['revision_no'].as_integer() == revision_no)
return success([await event_out(e, session) for e in (await session.scalars(query.distinct())).all()])
@router.get('/tags')
async def tags(session: DB, q: str = ''):
rows = (await session.scalars(select(NewsSummary.tags))).all()
return success(sorted({tag for row in rows for tag in row if q.casefold() in tag}))
@router.get('/briefings')
async def briefings(session: DB, page: int = Query(1, ge=1)):
rows = (await session.scalars(select(RiskSnapshot).where(RiskSnapshot.result['inputs'].is_not(None))
.order_by(RiskSnapshot.id.desc()).offset((page-1)*20).limit(20))).all()
return success([{'id': r.id, 'state': r.state, 'result': r.result, 'error': r.error, 'created_at': r.created_at} for r in rows])
+7 -2
View File
@@ -154,11 +154,16 @@ 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())
risk = await session.scalar(select(RiskSettings).where((RiskSettings.provider_id == provider_id) | (RiskSettings.second_provider_id == provider_id) | (RiskSettings.third_provider_id == provider_id)).with_for_update())
if risk:
if risk.enabled:
raise HTTPException(status.HTTP_409_CONFLICT, "请先暂停新闻 AI 分析或切换其接入配置")
risk.provider_id = None
if risk.provider_id == provider_id:
risk.provider_id = None
if risk.third_provider_id == provider_id:
risk.third_provider_id = None
if risk.second_provider_id == provider_id:
risk.second_provider_id = None
await session.delete(provider)
await session.flush()
if provider.is_default:
+10 -1
View File
@@ -21,6 +21,7 @@ class NodeOut(BaseModel):
id: int
parent_id: int | None
kind: Literal["folder", "skill"]
scope: Literal["shared", "news"] = "shared"
name: str
path: str
sort_order: int
@@ -35,6 +36,7 @@ class NodeOut(BaseModel):
class NodeIn(BaseModel):
parent_id: int | None = None
kind: Literal["folder", "skill"]
scope: Literal["shared", "news"] = "shared"
name: Annotated[str, Field(min_length=1, max_length=128)]
priority: int = 0
# 新建 skill 时的初始内容,保存为 v1 草稿(未发布);为空则不建版本
@@ -102,7 +104,7 @@ async def tree(session: Annotated[AsyncSession, Depends(get_session)]) -> ApiRes
@router.post("/nodes", status_code=status.HTTP_201_CREATED)
async def create(body: NodeIn, session: Annotated[AsyncSession, Depends(get_session)]) -> ApiResponse[NodeOut]:
try:
node = await create_node(session, body.parent_id, body.kind, body.name, body.priority)
node = await create_node(session, body.parent_id, body.kind, body.name, body.priority, body.scope)
# 没有初始内容时不建空版本,第一次保存即 v1
if body.kind == "skill" and body.content:
await add_version(session, node, body.content, "user", "新建")
@@ -138,6 +140,8 @@ async def move(node_id: int, body: MoveIn, session: Annotated[AsyncSession, Depe
node = await get_node(session, node_id)
if body.parent_id is not None:
parent = await get_node(session, body.parent_id)
if parent.scope != node.scope:
raise HTTPException(400, "不能跨用途移动 Skill")
if parent.kind != "folder":
raise HTTPException(status.HTTP_400_BAD_REQUEST, "只能移动到文件夹中")
# 不能移到自己或自己的子孙下面
@@ -169,6 +173,11 @@ async def delete(node_id: int, session: Annotated[AsyncSession, Depends(get_sess
node = await get_node(session, node_id)
if node.kind == "folder" and await session.scalar(select(func.count()).where(SkillNode.parent_id == node_id)):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "文件夹不为空,请先移走或删除其中的节点")
from alphapulse.models.risk import RiskSettings
referenced = await session.scalar(select(RiskSettings.id).where(
(RiskSettings.grouping_skill_id == node_id) | (RiskSettings.analysis_skill_id == node_id) | (RiskSettings.outlook_skill_id == node_id)))
if referenced:
raise HTTPException(409, "请先解除新闻模型的Skill绑定")
# 先解除对版本的引用,版本随节点级联删除
node.published_version_id = None
await session.flush()