Files
AlphaPulse/scripts/smoke_risk.py
T
eezd 80d266dba7 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.
2026-10-31 22:08:59 +08:00

175 lines
12 KiB
Python

"""Exercise both news stages against the disposable CI database and fake models."""
import asyncio
from datetime import UTC, datetime, timedelta
import json
import os
from unittest.mock import AsyncMock, patch
from urllib.parse import urlparse
from fastapi import HTTPException
from sqlalchemy import delete, func, select
from alphapulse.ai import llm
from alphapulse.ai.skills import create_node, add_version, published_skills
from alphapulse.api.settings import delete_provider
from alphapulse.core.db import SessionLocal, engine
from alphapulse.models import AiProvider, NewsItem, SkillNode, SkillVersion
from alphapulse.models.risk import RiskSettings, RiskSnapshot, ArticleAnalysis, NewsSummary, SummaryArticle, SummaryVersion, PackageEvidence
from alphapulse.news_risk import service, pipeline, worker, briefing
async def main():
url = urlparse(os.environ['DATABASE_URL'])
assert url.username == 'ci' and url.password == 'ci-only' and url.path == '/ci'
assert url.hostname.startswith('ap-ci-') or url.hostname in ('127.0.0.1', 'localhost')
now = datetime.now(UTC)
async with SessionLocal() as session:
assert await session.scalar(select(func.count()).select_from(RiskSettings)) == 0
provider = AiProvider(name='ci-news', protocol='openai', base_url='https://example.invalid', api_key_encrypted=b'fake',
api_key_hint='fake', model='shared', max_tokens=2048, context_window=200000)
session.add(provider); await session.flush(); provider_id = provider.id
skill_ids = {}
for role, (name, content) in pipeline.TEMPLATES.items():
node = await create_node(session, None, 'skill', name, scope='news')
version = await add_version(session, node, content, 'user', 'CI')
node.published_version_id = version.id
skill_ids[role+'_skill_id'] = node.id
await session.flush()
assert not await published_skills(session), 'News skills leaked into Chat or trading'
session.add(RiskSettings(id=1, enabled=True, provider_id=provider_id, model='luna', second_provider_id=provider_id,
second_model='reviewer', third_provider_id=provider_id, third_model='senior', start_at=now-timedelta(minutes=2), **skill_ids))
for key, at, content in [('old', now-timedelta(days=1), 'old'), ('first', now-timedelta(minutes=1), 'original'),
('duplicate', now-timedelta(seconds=30), 'repost')]:
session.add(NewsItem(source_code='wscn_news', external_id='ci-news-'+key, url='https://example.invalid/'+key,
title='Exchange security incident', summary='', content_text=content, content_hash=key, published_at=at))
await session.commit()
calls = []
broken = False
inject_update = False
finish_during_grouping = False
async def stream(provider, system, messages, tools, effort):
nonlocal inject_update, finish_during_grouping
data = json.loads(messages[0]['text']); calls.append((provider.model, data))
if 'article' in data:
assert provider.model == 'luna'
if finish_during_grouping:
finish_during_grouping = False
await worker.summarize()
candidates = data['candidates']
result = pipeline.Grouping( reason='Financial event', events=[pipeline.Match(
event_id=candidates[0]['id'] if candidates else None, group_key=None if candidates else 'security',
title='Exchange security incident', category='security', entities=['Exchange'], fact='incident',
evidence=data['article']['title'], urgent=True, tags=['Exchange'], content_kind='report')])
elif data.get('task') == 'event':
assert provider.model == 'reviewer'
if broken: raise ValueError('Synthetic validation failure')
if inject_update:
inject_update = False
async with SessionLocal() as other:
changed = await other.get(NewsSummary, data['id'], with_for_update=True)
worker.schedule(changed, datetime.now(UTC), True)
await other.commit()
correction = any(a['content'] == 'correction' for a in data['articles'])
result = pipeline.EventResult(id=data['id'], title='Exchange security incident', summary='Correction' if correction else 'Incident',
category='security', entities=['Exchange'], risk_stars=1 if correction else 5, importance_stars=5, direction='unknown',
verification='refuted' if correction else 'reported', confidence='low', reasoning='Synthetic', uncertainty='Synthetic',
facts='Synthetic', timeline='Synthetic',
asset_impacts='Exchange', opinions='Uncertain', counter_evidence='Unknown', watchpoints='Updates', claims=[pipeline.Claim(statement='Synthetic', verdict='insufficient', evidence_refs=[])], related_packages=[], excluded_article_ids=[],
evidence_refs=[pipeline.Evidence(article_id=a['id'], quote=a['title']) for a in data['articles']])
elif data.get('task') == 'ranking':
assert provider.model == 'senior'
result = briefing.Ranked(reading=[briefing.Reading(id=p['id'], revision=p['revision'], priority=90, reason='Synthetic') for p in data['packages']], notes='Synthetic notes')
else:
trend = briefing.Trend(summary='Unknown', conditions='Unknown', counter_evidence='Unknown', watchpoints='Unknown')
result = briefing.Synthesis(summary='Synthetic', market='Unknown', short_term=trend, medium_term=trend, references=[])
yield llm.Completion(tool_calls=[{'name': 'submit', 'arguments': result.model_dump()}], usage={'input_tokens': 10})
try:
with patch.object(llm, 'stream', stream):
await worker.analyze()
finish_during_grouping = True
await worker.analyze()
async with SessionLocal() as session:
event = await session.scalar(select(NewsSummary))
assert event.state == 'pending' and event.revision == 2 and event.published_revision == 1, 'Grouping lost an update after concurrent summary completion'
await worker.summarize()
async with SessionLocal() as session:
assert await session.scalar(select(func.count()).select_from(ArticleAnalysis)) == 2
event = await session.scalar(select(NewsSummary)); event_id = event.id
assert event.stars == 5 and event.revision == 2
assert await session.scalar(select(func.count()).select_from(SummaryArticle)) == 2
article = await session.scalar(select(NewsItem).where(NewsItem.external_id == 'ci-news-first'))
article.content_hash = 'corrected'; article.content_text = 'correction'; article.revision_no = 2
article.source_updated_at = datetime.now(UTC)
await session.commit()
await worker.analyze(); await worker.summarize()
async with SessionLocal() as session:
event = await session.get(NewsSummary, event_id)
assert event.stars == 1 and event.peak_stars == 5 and event.result['verification'] == 'refuted'
version = await session.scalar(select(SummaryVersion).order_by(SummaryVersion.id.desc()).limit(1))
assert len(version.input['articles']) == 2, 'Superseded article revision entered the current summary'
event.revision += 1; event.state = 'pending'; event.due_at = datetime.now(UTC)
await session.commit()
inject_update = True
await worker.summarize()
async with SessionLocal() as session:
event = await session.get(NewsSummary, event_id)
assert event.state == 'pending' and event.revision > event.published_revision, 'Concurrent update was lost'
broken = True
await worker.summarize()
async with SessionLocal() as session:
event = await session.get(NewsSummary, event_id)
assert event.state == 'failed' and event.stars == 1 and event.result['summary'] == 'Correction'
broken = False
async with SessionLocal() as session:
failed = RiskSnapshot(state='failed', model='reviewer', quotes={}, error='Synthetic old failure',
created_at=datetime.now(UTC)-timedelta(minutes=20))
session.add(failed); await session.commit(); failed_id = failed.id
await worker.outlook()
async with SessionLocal() as session:
snapshot = await session.scalar(select(RiskSnapshot).order_by(RiskSnapshot.id.desc()).limit(1))
assert snapshot.state == 'done' and snapshot.result['inputs'][0]['id'] == event_id
assert (await session.get(AiProvider, provider_id)).model == 'shared'
from fastapi import FastAPI
import httpx
from alphapulse.api import risk as risk_api, news as news_api
app = FastAPI()
app.include_router(risk_api.router); app.include_router(news_api.router)
app.dependency_overrides[risk_api.require_admin] = lambda: None
with patch.object(risk_api.redis, 'get', AsyncMock(return_value=None)):
async with httpx.AsyncClient(transport=httpx.ASGITransport(app), base_url='http://test') as client:
for path in ('/api/risk/settings', '/api/risk/overview', '/api/risk/summaries',
f'/api/risk/summaries/{event_id}', '/api/news'):
response = await client.get(path)
assert response.status_code == 200, (path, response.text)
assert response.json()['code'] == 0
response = await client.get(f'/api/risk/summaries/{event_id}')
assert response.json()['data']['event']['article_count'] == 2
assert response.json()['data']['versions']
response = await client.get('/api/risk/summaries', params={'hours': 25})
assert response.status_code == 422
response = await client.post(f'/api/risk/tasks/{failed_id}/retry', params={'stage': 'outlook'})
assert response.status_code == 202, response.text
await worker.outlook()
async with SessionLocal() as session:
assert await session.scalar(select(func.count()).select_from(RiskSnapshot)) == 3, 'Old failed outlook retry was ignored'
assert not (await session.get(RiskSnapshot, failed_id)).quotes.get('retry_requested')
async with SessionLocal() as session:
try: await delete_provider(provider_id, session)
except HTTPException as error: assert error.status_code == 409
else: raise AssertionError('Enabled model provider deletion accepted')
settings = await session.get(RiskSettings, 1); settings.enabled = False
await session.commit(); await delete_provider(provider_id, session)
assert settings.provider_id is None and settings.second_provider_id is None and settings.third_provider_id is None
print('Three-stage packages: scope, cutoff, grouping, reposts, revisions, corrections, peak risk, retained success, outlook and provider isolation passed')
finally:
async with SessionLocal() as session:
for model in (PackageEvidence, SummaryVersion, SummaryArticle, NewsSummary, ArticleAnalysis, RiskSnapshot, RiskSettings):
await session.execute(delete(model))
await session.execute(delete(NewsItem).where(NewsItem.external_id.startswith('ci-news-')))
await session.execute(delete(AiProvider).where(AiProvider.id == provider_id))
await session.execute(SkillNode.__table__.update().where(SkillNode.id.in_(skill_ids.values())).values(published_version_id=None))
await session.execute(delete(SkillVersion).where(SkillVersion.skill_id.in_(skill_ids.values())))
await session.execute(delete(SkillNode).where(SkillNode.id.in_(skill_ids.values())))
await session.commit()
await engine.dispose()
asyncio.run(main())