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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user