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
View File
@@ -179,6 +179,8 @@ def smoke(build, label):
run('docker', 'run', '-d', '--name', names['web'], '--network-alias', 'web', *common, build['images']['web']['local'])
wait_command('docker', 'exec', names['api'], 'python', '-c',
'import urllib.request; urllib.request.urlopen("http://localhost:8000/api/health", timeout=3)')
print(run('docker', 'exec', '-i', names['api'], 'python', '-',
input=(ROOT / 'scripts/smoke_risk.py').read_text()), flush=True)
output = run('docker', 'exec', '-i', '-e', 'SMOKE_WEB=' + names['web'], names['api'], 'python', '-',
input=(ROOT / 'scripts/smoke_app.py').read_text())
print(output, flush=True)
+12 -10
View File
@@ -11,7 +11,7 @@ import time
from ci import IMAGES, ROOT, branch_head, locked, require, run, save
INSTANCE = Path('/docker/gitea/dev/AlphaPulse')
SERVICES = ('api', 'web', 'market', 'crawler', 'calendar', 'trader')
SERVICES = ('api', 'web', 'market', 'crawler', 'calendar', 'trader', 'news-risk')
SCHEMA = '''
import asyncio, json
@@ -85,9 +85,9 @@ def inspect_services(command):
return json.loads(run('docker', 'inspect', *identifiers)) if identifiers else []
def health(command, build):
def health(command, build, expected_services=SERVICES):
containers = inspect_services(command)
require({c['Config']['Labels']['com.docker.compose.service'] for c in containers} == set(SERVICES), 'Missing services')
require({c['Config']['Labels']['com.docker.compose.service'] for c in containers} == set(expected_services), 'Missing services')
for container in containers:
require(container['State']['Running'] and not container['State']['Paused'] and container['RestartCount'] == 0,
'Service exited, paused or restarted')
@@ -148,12 +148,12 @@ def deploy(build, directory=INSTANCE):
if previous:
# Docker unpauses a paused container before delivering the stop signal.
run(*old_command, 'stop', 'trader')
run(*old_command, 'stop', 'crawler', 'calendar', 'market', 'web', 'api')
run(*old_command, 'stop', *[service for service in previous_running if service != 'trader'])
(directory / 'compose.yml').write_text((ROOT / 'deploy/docker-compose.yml').read_text())
command = compose(directory, build)
run(*command, 'config', '--quiet')
run(*command, 'up', '-d', '--no-build', '--pull', 'never', '--wait', '--wait-timeout', '120', 'api', 'web')
run(*command, 'up', '-d', '--no-build', '--pull', 'never', 'market', 'crawler', 'calendar', 'trader')
run(*command, 'up', '-d', '--no-build', '--pull', 'never', 'market', 'crawler', 'calendar', 'trader', 'news-risk')
time.sleep(5)
health(command, build)
if previous:
@@ -171,7 +171,7 @@ def deploy(build, directory=INSTANCE):
if previous:
restore(directory, pending)
raise
print('All six services now run the accepted development digests', flush=True)
print('All application services now run the accepted development digests', flush=True)
def restore(directory, pending):
@@ -190,9 +190,10 @@ def restore(directory, pending):
validate_images(previous)
(directory / 'compose.yml').write_text(pending['previous_compose'])
command = compose(directory, previous)
run(*command, 'up', '-d', '--no-build', '--pull', 'never', '--wait', '--wait-timeout', '120', *pending['previous_running'])
if set(pending['previous_running']) == set(SERVICES):
health(command, previous)
run(*command, 'up', '-d', '--no-build', '--pull', 'never', '--remove-orphans', '--wait', '--wait-timeout', '120', *pending['previous_running'])
previous_services = re.findall(r'^ ([a-z][a-z0-9-]*):$', pending['previous_compose'], re.M)
if set(pending['previous_running']) == set(previous_services):
health(command, previous, previous_services)
save(directory / 'deployment.json', previous)
(directory / 'deployment.pending.json').unlink()
print('Restored previous images and configuration; business data was retained', flush=True)
@@ -213,7 +214,8 @@ def main():
require(not (INSTANCE / 'deployment.pending.json').exists(), 'Recover the pending deployment first')
previous = json.loads((INSTANCE / 'deployment.previous.json').read_text())
pending = {'previous': previous, 'previous_compose': (INSTANCE / 'compose.previous.yml').read_text(),
'previous_running': list(SERVICES)}
'previous_running': [name for name in re.findall(r'^ ([a-z][a-z0-9-]*):$',
(INSTANCE / 'compose.previous.yml').read_text(), re.M) if name in SERVICES]}
save(INSTANCE / 'deployment.pending.json', pending)
run(*compose(INSTANCE, json.loads((INSTANCE / 'deployment.json').read_text())), 'stop')
restore(INSTANCE, pending)
+2 -1
View File
@@ -40,13 +40,14 @@ async def main():
try:
for path in ['/api/auth/me', '/api/news?q=CI', '/api/crawler/sources', '/api/market/symbols',
'/api/skills/tree', '/api/trading/status', '/api/chat/session',
'/api/risk/settings', '/api/risk/overview', '/api/risk/events',
'/api/calendar?start=' + now.strftime('%Y-%m-%d')]:
response = await client.get(path)
response.raise_for_status()
assert response.json()['code'] == 0, path
assert 'CI synthetic news' in (await client.get('/api/news?q=CI')).text
assert 'CI CPI' in (await client.get('/api/calendar?start=' + now.strftime('%Y-%m-%d'))).text
for path in ['/', '/news', '/calendar', '/crawler', '/market', '/trading', '/chat', '/skills', '/settings']:
for path in ['/', '/home', '/news', '/calendar', '/crawler', '/market', '/trading', '/chat', '/skills', '/settings']:
response = await client.get(path)
assert response.status_code == 200 and '<html' in response.text.lower(), path
assert (await client.get('/version.json')).json()['commit'] == os.environ['APP_COMMIT']
+152
View File
@@ -0,0 +1,152 @@
"""Exercise news risk regressions against the synthetic CI database only."""
import asyncio
from datetime import UTC, datetime, timedelta
import json
import os
from types import SimpleNamespace
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.api.settings import delete_provider
from alphapulse.core.db import SessionLocal, engine
from alphapulse.models import AiProvider, NewsItem
from alphapulse.models.risk import RiskAnalysis, RiskEvent, RiskSettings, RiskSnapshot
from alphapulse.news_risk import service, worker
def label(identifier, title, stars=0, event_id=None):
return service.Label(id=identifier, title=title, summary=title, category='security', entities=['Exchange'],
stars=stars, direction='unknown', verification='reported', confidence='low', reasoning='Synthetic fixture',
uncertainty='Synthetic fixture', evidence=title, event_id=event_id)
def event(at, stars):
return RiskEvent(title='Exchange security incident', category='security', entities=['Exchange'],
stars=stars, peak_stars=stars, result=label(1, 'Exchange security incident', stars).model_dump(),
published_at=at, updated_at=at)
def news(external_id, at, content='Synthetic fixture'):
return NewsItem(source_code='wscn_news', external_id='ci-risk-'+external_id, url='https://example.invalid',
title='Exchange security incident', summary='', content_text=content, published_at=at, content_hash=external_id)
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-risk', protocol='openai', base_url='https://example.invalid',
api_key_encrypted=b'fake', api_key_hint='fake', model='shared', max_tokens=256, context_window=200000)
session.add(provider); await session.flush()
provider_id = provider.id
session.add(RiskSettings(id=1, enabled=True, provider_id=provider.id, model='synthetic-news'))
original = event(now-timedelta(hours=3), 5)
article = news('correction', now-timedelta(minutes=1))
session.add_all([original, article]); await session.flush()
original_id, article_id = original.id, article.id
session.add(RiskAnalysis(news_id=article.id, content_hash='old-content', state='done', event_id=original.id,
input={'title': article.title, 'published_at': article.published_at.isoformat(), 'progress_at': article.published_at.isoformat()}))
for i in range(80):
unrelated = event(now-timedelta(minutes=i+2), 0)
unrelated.title = f'Company {i} ordinary earnings announcement'
unrelated.entities = [f'Company {i}']
unrelated.result = label(1, unrelated.title).model_dump()
session.add(unrelated)
await session.commit()
payloads = []
async def stream(provider, system, messages, tools, effort):
payload = json.loads(messages[0]['text']); payloads.append(payload)
if 'news' in payload:
items = [label(n['id'], n['title'], 1 if n.get('revision_no') == 2 else 0,
original_id if n.get('revision_no') == 2 else None) for n in payload['news']]
result = service.Labels(items=items)
else:
result = service.Outlook(windows=[service.Scenario(hours=h, direction='unknown', summary='Synthetic',
conditions='Synthetic', counter_factors='Synthetic', uncertainty='Synthetic',
event_ids=[e['id'] for e in payload['windows'][str(h)]]) for h in service.WINDOWS])
yield llm.Completion(tool_calls=[{'name': 'submit', 'arguments': result.model_dump()}])
class Binance:
def __init__(self, **kwargs): self.http = type('Http', (), {'aclose': AsyncMock()})()
async def ticker(self, *args): return {'lastPrice': '100', 'priceChangePercent': '-6', 'closeTime': 1}
try:
async with SessionLocal() as session:
candidates, _ = await worker.find_candidates(session, [SimpleNamespace(
news_id=0, input={'title': 'Exchange security update', 'summary': ''})], now)
assert original_id in {e.id for e in candidates}
article = await session.get(NewsItem, article_id); article.revision_no = 2
await session.commit()
with patch.object(llm, 'stream', stream): await worker.analyze()
async with SessionLocal() as session:
original = await session.get(RiskEvent, original_id)
assert original.stars == 1 and original.peak_stars == 5
assert original_id in {e['id'] for e in payloads[-1]['candidates']}
assert await session.scalar(select(func.count()).select_from(RiskEvent)) == 81
assert (await session.get(AiProvider, provider_id)).model == 'shared'
await session.execute(delete(RiskAnalysis)); await session.execute(delete(RiskEvent))
session.add_all([event(now-timedelta(days=3), 5) for _ in range(100)])
fresh = event(now-timedelta(minutes=1), 4); session.add(fresh)
await session.flush(); fresh_id = fresh.id
await session.commit()
with patch.object(llm, 'stream', stream), patch.object(worker, 'BinanceClient', Binance):
await worker.summarize()
for hours in ('24', '48'):
assert [e['id'] for e in payloads[-1]['windows'][hours]] == [fresh_id]
assert len(payloads[-1]['windows']['168']) == 100
async with SessionLocal() as session:
snapshot = await session.scalar(select(RiskSnapshot))
snapshot.created_at = now-timedelta(minutes=11)
provider = await session.get(AiProvider, provider_id); provider.context_window = 8192
await session.commit()
with patch.object(llm, 'stream', stream), patch.object(worker, 'BinanceClient', Binance):
await worker.summarize()
for hours in ('24', '48'):
assert [e['id'] for e in payloads[-1]['windows'][hours]] == [fresh_id]
assert 0 < len(payloads[-1]['windows']['168']) < 100
async with SessionLocal() as session:
await session.execute(delete(RiskEvent))
provider = await session.get(AiProvider, provider_id); provider.context_window = 8192
article = await session.get(NewsItem, article_id); article.title = 'Batch article'
article.content_hash = 'current'; article.content_text = 'x'*1000; article.revision_no = 3
session.add_all([news(str(i), now-timedelta(minutes=i+2), 'x'*1000) for i in range(9)])
session.add(news('oversized', now-timedelta(seconds=1), 'x'*30000))
await session.commit()
with patch.object(llm, 'stream', stream):
for _ in range(15): await worker.analyze()
async with SessionLocal() as session:
counts = dict((await session.execute(select(RiskAnalysis.state, func.count()).group_by(RiskAnalysis.state))).all())
assert counts == {'done': 10, 'failed': 1}, counts
failed = await session.scalar(select(RiskAnalysis).where(RiskAnalysis.state == 'failed'))
assert len(failed.input['content']) == 30000 and '上下文' in failed.error
assert all(len(p['news']) < 10 for p in payloads if 'news' in p)
try:
await delete_provider(provider_id, session)
except HTTPException as error:
assert error.status_code == 409
else:
raise AssertionError('Enabled risk provider deletion was accepted')
settings = await session.get(RiskSettings, 1); settings.enabled = False
await session.commit()
await delete_provider(provider_id, session)
assert (await session.get(RiskSettings, 1)).provider_id is None
print('News risk: correction recall, independent windows, context packing, isolated oversized article and provider deletion passed')
finally:
async with SessionLocal() as session:
for model in (RiskAnalysis, RiskEvent, RiskSnapshot, RiskSettings):
await session.execute(delete(model))
await session.execute(delete(NewsItem).where(NewsItem.external_id.startswith('ci-risk-')))
await session.execute(delete(AiProvider).where(AiProvider.id == provider_id))
await session.commit()
await engine.dispose()
asyncio.run(main())
+16
View File
@@ -82,6 +82,22 @@ class BuildTests(unittest.TestCase):
class RecoveryTests(unittest.TestCase):
def test_six_service_rollback_removes_new_worker_and_checks_previous_services(self):
with tempfile.TemporaryDirectory() as temporary:
directory = Path(temporary)
ci.save(directory / 'deployment.pending.json', {})
services = [name for name in deploy.SERVICES if name != 'news-risk']
previous = {'images': {part: {'digest': 'synthetic'} for part in ci.IMAGES}}
compose = 'services:\n' + ''.join(f' {name}:\n image: synthetic\n' for name in services)
with patch.object(deploy, 'run', return_value='') as run, \
patch.object(deploy, 'validate_images'), patch.object(deploy, 'health') as health:
deploy.restore(directory, {'previous': previous, 'previous_compose': compose, 'previous_running': services})
update = next(call.args for call in run.call_args_list if 'up' in call.args)
self.assertIn('--remove-orphans', update)
self.assertNotIn('news-risk', update)
self.assertEqual(health.call_args.args[2], services)
self.assertFalse((directory / 'deployment.pending.json').exists())
def test_pending_write_failure_never_pauses_trader(self):
with tempfile.TemporaryDirectory() as temporary:
directory = Path(temporary)