Files
AlphaPulse/scripts/smoke_events.py
T
eezd f1d060d475
Development CI / check-and-build (push) Successful in 2m33s
GitHub Git mirror / mirror (push) Successful in 5s
Development CI / deploy-development (push) Failing after 7s
feat: organize news packages into verified event timelines
Add optional events, evidence-backed timelines, versioned package corrections and bounded historical reprocessing. Keep independent packages readable and isolate retries by failed stage. Configure reasoning effort per model stage and refresh default news Skill drafts without publishing.
2026-11-01 11:46:11 +08:00

218 lines
16 KiB
Python

"""Exercise event aggregation and correction transactions in the disposable CI DB."""
import asyncio
from datetime import UTC, datetime, timedelta
import os
from types import SimpleNamespace
from unittest.mock import patch
from urllib.parse import urlparse
from sqlalchemy import select, func, delete
from alphapulse.core.db import SessionLocal, engine
from alphapulse.models import AiProvider, NewsItem, SkillNode, SkillVersion
from alphapulse.models.risk import NewsSummary, ArticleAnalysis, SummaryArticle, RiskSettings, NewsEvent, EventMember, EventVersion, ReprocessJob, PackageCorrection, GroupingVersion, SummaryVersion, PackageEvidence, RiskSnapshot, EventLink
from alphapulse.ai.skills import create_node, add_version
from alphapulse.news_risk import pipeline, events, reprocess, worker, briefing
from alphapulse.news_risk.contracts import Correction
from alphapulse.news_risk.corrections import apply_correction
async def main():
url=urlparse(os.environ['DATABASE_URL'])
assert url.username=='ci' and url.password=='ci-only' and url.path=='/ci'
now=datetime.now(UTC)
async with SessionLocal() as session:
assert await session.scalar(select(func.count()).select_from(RiskSettings))==0
provider=AiProvider(name='event-ci',protocol='openai',base_url='https://example.invalid',api_key_encrypted=b'fake',api_key_hint='fake',model='shared',max_tokens=8192,context_window=200000)
session.add(provider); await session.flush()
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
ids[role+'_skill_id']=node.id
settings=RiskSettings(id=1,enabled=True,start_at=now-timedelta(minutes=5),provider_id=provider.id,second_provider_id=provider.id,third_provider_id=provider.id,model='luna',second_model='reviewer',third_model='senior',**ids)
session.add(settings)
packages=[]; analyses=[]
for i in (1,2):
news=NewsItem(source_code='wscn_news',external_id='ci-event-'+str(i),url='https://example.invalid/'+str(i),title='Incident' if i==1 else 'Response',summary='',content_text='Original evidence '+str(i),content_hash=str(i),published_at=now)
session.add(news); await session.flush()
analysis=ArticleAnalysis(news_id=news.id,content_hash=news.content_hash,state='done',result={'previous':True},input={'title':news.title,'summary':'','content':news.content_text,'url':news.url,'source':'wscn_news','revision_no':1,'published_at':now.isoformat(),'progress_at':now.isoformat()})
session.add(analysis); await session.flush(); analyses.append(analysis)
result={'title':news.title,'summary':news.title,'importance_stars':4,'risk_stars':3,'category':'security','evidence_refs':[{'article_id':analysis.id,'quote':news.content_text}],'claims':[]}
package=NewsSummary(title=news.title,fact=news.title,category='security',tags=['exchange'],entities=['Exchange'],content_kind='report',published_at=now,updated_at=now,changed_at=now,due_at=now,state='done',revision=1,published_revision=1,result=result,importance_stars=4)
session.add(package); await session.flush(); packages.append(package)
session.add(SummaryArticle(summary_id=package.id,analysis_id=analysis.id,active=True,unit={'fact':news.title,'evidence':news.content_text}))
await session.flush()
result=SimpleNamespace(related_events=[],event_proposal=pipeline.EventProposal(title='Exchange incident',package_ids=[packages[0].id],reason='Official response to the same incident'))
await events.associate(session,packages[1],result,{packages[0].id})
event=await session.scalar(select(NewsEvent)); event.due_at=now
package_ids=[p.id for p in packages]; event_id=event.id
withdrawn = NewsSummary(title='Withdrawn',fact='Withdrawn',category='security',tags=['exchange'],entities=[],
content_kind='report',published_at=now,updated_at=now,changed_at=now,due_at=now,state='withdrawn',revision=1,published_revision=0)
session.add(withdrawn); await session.flush()
session.add(EventMember(package_id=withdrawn.id,event_id=event_id,reason='Previous member'))
await session.commit()
async def complete(provider,system,payload,schema):
points=[]
for p in payload['packages']:
points.append(events.TimelinePoint(occurred_at=None,time_precision='unknown',time_text='Time unknown',reported_at=now,description=p['result']['summary'],verification='partial',package_id=p['id'],package_revision=p['revision'],evidence_refs=p['result']['evidence_refs']))
return schema(title='Exchange incident',summary='Disclosure followed by response',category='security',importance_stars=4,risk_stars=3,uncertainty='Time unknown',opinions='Wait',counter_evidence='Unknown',watchpoints='Investigation',timeline_points=points),{}
with patch.object(events,'complete',complete):
await events.organize()
await events.organize()
async with SessionLocal() as session:
event=await session.get(NewsEvent,event_id)
assert event.state=='done' and len(event.result['timeline_points'])==2
inputs=await events.reading_inputs(session,now-timedelta(hours=72))
assert len(inputs)==1 and inputs[0]['kind']=='event'
assert await session.scalar(select(func.count()).select_from(EventVersion))==1
counts,_=await reprocess.preview(session,now-timedelta(seconds=1),now+timedelta(seconds=1))
assert counts=={'articles':2,'packages':2,'events':1}
started=await reprocess.start_job(session,now-timedelta(seconds=1),now+timedelta(seconds=1))
job=await session.get(ReprocessJob,started['id'])
assert len(job.items)==2
provider=await session.get(AiProvider,provider.id); provider.model='changed'; await session.commit()
frozen,_,_=await reprocess.frozen_configuration(session,job.settings,'grouping')
assert frozen.model=='luna'
job_id = job.id
await session.commit()
async with SessionLocal() as session:
event = await session.get(NewsEvent, event_id)
event.state = 'pending'; event.revision += 1; event.due_at = now
published = event.published_revision
await session.commit()
async def change_during_timeline(*args):
async with SessionLocal() as session:
package = await session.get(NewsSummary, package_ids[0])
worker.schedule(package, now, True)
await session.commit()
return await complete(*args)
with patch.object(events, 'complete', change_during_timeline): await events.organize()
async with SessionLocal() as session:
event = await session.get(NewsEvent, event_id)
assert event.state == 'pending' and event.published_revision == published
latest = await session.scalar(select(EventVersion).order_by(EventVersion.id.desc()).limit(1))
assert latest.state == 'superseded'
package = await session.get(NewsSummary, package_ids[0])
package.state = 'done'; package.published_revision = package.revision
await session.commit()
with patch.object(events, 'complete', complete): await events.organize()
analyze = worker.analyze
async def pause_before_grouping(*args):
async with SessionLocal() as session:
settings = await session.get(RiskSettings, 1); settings.enabled = False
await session.commit()
return await analyze(*args)
with patch.object(worker, 'analyze', pause_before_grouping): await reprocess.process_job()
async with SessionLocal() as session:
job = await session.get(ReprocessJob, job_id)
assert job.position == 0 and not job.failures
assert (await session.get(ArticleAnalysis, analyses[0].id)).result == {'previous':True}
settings = await session.get(RiskSettings, 1); settings.enabled = True
await session.commit()
async def fail_grouping(*args):
from alphapulse.api.risk import reprocess_cancel
async with SessionLocal() as other:
job = await other.get(ReprocessJob, job_id)
if job.state != 'cancelled': await reprocess_cancel(job_id, other)
raise ValueError('Synthetic grouping failure during cancellation')
with patch.object(worker, 'complete', fail_grouping): await reprocess.process_job()
async with SessionLocal() as session:
job = await session.get(ReprocessJob, job_id)
assert job.state == 'cancelled' and job.position == 1 and len(job.failures) == 1
old = await session.get(ArticleAnalysis, analyses[0].id)
assert old.result == {'previous':True} and old.state == 'done' and old.error
assert await session.scalar(select(func.count()).select_from(SummaryArticle).where(SummaryArticle.active.is_(True))) == 2
assert await session.scalar(select(func.count()).select_from(GroupingVersion).where(GroupingVersion.state == 'failed')) == 1
second = await reprocess.start_job(session, now-timedelta(seconds=1), now+timedelta(seconds=1))
job = await session.get(ReprocessJob, second['id'])
job.state = 'running'; job.position = len(job.items)
package = await session.get(NewsSummary, package_ids[0])
package.review_config = job.settings; package.state = 'failed'; package.error = 'Synthetic review failure'
await session.commit()
await reprocess.process_job()
async with SessionLocal() as session:
job = await session.get(ReprocessJob, second['id'])
assert job.state == 'failed' and job.failures == [{'stage':'analysis','id':package_ids[0],'error':'Synthetic review failure'}]
from alphapulse.api.risk import reprocess_retry
await reprocess_retry(job.id, session)
assert job.position == len(job.items) and job.state == 'running'
assert (await session.get(NewsSummary, package_ids[1])).state == 'done'
assert (await session.get(NewsSummary, package_ids[0])).state == 'pending'
assert await session.scalar(select(func.count()).select_from(GroupingVersion)) == 1
job.state = 'cancelled'
package = await session.get(NewsSummary, package_ids[0])
package.state = 'done'; package.error = None; package.review_config = None
news = NewsItem(source_code='wscn_news', external_id='ci-event-repost', url='https://example.invalid/repost',
title='Incident', summary='', content_text='Original evidence 1', content_hash='repost', published_at=now)
session.add(news); await session.flush()
repost = ArticleAnalysis(news_id=news.id, content_hash=news.content_hash, state='pending', input={
'title':news.title,'summary':'','content':news.content_text,'url':news.url,'source':'wscn_news',
'revision_no':1,'published_at':now.isoformat(),'progress_at':now.isoformat()})
session.add(repost); await session.commit(); analyses.append(repost)
old_revision, old_progress = package.revision, package.updated_at
async def group_repost(provider, system, payload, schema):
return schema(reason='Same concrete fact', packages=[dict(package_id=package_ids[0], group_key=None,
title='Incident', category='security', entities=['Exchange'], fact='Incident', evidence='Original evidence 1',
urgent=False, tags=['exchange'], content_kind='report', change_kind='repost')]), {}
with patch.object(worker, 'complete', group_repost): await worker.analyze(repost.id)
async with SessionLocal() as session:
package = await session.get(NewsSummary, package_ids[0])
assert package.revision == old_revision and package.updated_at == old_progress and package.state == 'done'
third = await reprocess.start_job(session, now-timedelta(seconds=1), now+timedelta(seconds=1))
job = await session.get(ReprocessJob, third['id'])
job.state = 'running'; job.position = len(job.items)
await session.commit()
await reprocess.process_job()
async with SessionLocal() as session:
assert (await session.get(ReprocessJob, third['id'])).state == 'running'
async def senior(provider, system, payload, schema):
assert provider.model == 'senior'
if schema is briefing.Ranked:
return schema(reading=[dict(kind=p['kind'], id=p['id'], revision=p['revision'], priority=80, reason='Synthetic')
for p in payload['packages']], notes='Synthetic'), {}
trend = briefing.Trend(summary='Unknown', conditions='Unknown', counter_evidence='Unknown', watchpoints='Updates')
return schema(summary='Synthetic', market='Unknown', short_term=trend, medium_term=trend, references=[]), {}
async def failed_senior(*args): raise ValueError('Synthetic outlook failure')
with patch.object(briefing, 'complete', failed_senior): await worker.outlook()
await reprocess.process_job()
async with SessionLocal() as session:
job = await session.get(ReprocessJob, third['id'])
assert job.state == 'failed' and len(job.failures) == 1 and job.failures[0]['stage'] == 'outlook'
groups_before = await session.scalar(select(func.count()).select_from(GroupingVersion))
await reprocess_retry(job.id, session)
assert job.position == len(job.items) == 3
with patch.object(briefing, 'complete', senior): await worker.outlook()
await reprocess.process_job()
async with SessionLocal() as session:
assert (await session.get(ReprocessJob, third['id'])).state == 'done'
assert await session.scalar(select(func.count()).select_from(GroupingVersion)) == groups_before
packages=[await session.get(NewsSummary,i) for i in package_ids]
proposal=Correction(packages=[{'id':p.id,'revision':p.revision} for p in packages],replacements=[{'keep_id':min(package_ids),'title':'Corrected shared fact','fact':'Same fact','category':'security','tags':['exchange'],'content_kind':'report','assignments':[{'analysis_id':a.id,'evidence':a.input['content']} for a in analyses]}],reason='Synthetic duplicate correction')
output=await apply_correction(session,proposal,package_ids[0],{package_ids[1]},{'test':True})
await session.commit()
assert output==[min(package_ids)]
assert (await session.get(NewsSummary,max(package_ids))).replacement_ids==output
assert await session.scalar(select(func.count()).select_from(PackageCorrection))==1
assert await session.scalar(select(func.count()).select_from(SummaryArticle).where(SummaryArticle.active.is_(True)))==3
assert await session.scalar(select(func.count()).select_from(NewsItem).where(NewsItem.external_id.startswith('ci-event-')))==3
await events.organize()
async with SessionLocal() as session:
event=await session.get(NewsEvent,event_id); event.due_at=now; await session.commit()
await events.organize()
async with SessionLocal() as session:
assert (await session.get(NewsEvent,event_id)).state=='retired'
assert len(await events.reading_inputs(session,now-timedelta(hours=72)))==1
async with SessionLocal() as session:
for model in (EventLink, EventVersion, EventMember, NewsEvent, ReprocessJob, PackageCorrection,
GroupingVersion, PackageEvidence, SummaryVersion, SummaryArticle, NewsSummary, ArticleAnalysis, RiskSnapshot, RiskSettings):
await session.execute(delete(model))
await session.execute(delete(NewsItem).where(NewsItem.external_id.startswith('ci-event-')))
await session.execute(delete(AiProvider).where(AiProvider.id == provider.id))
await session.execute(SkillNode.__table__.update().where(SkillNode.id.in_(ids.values())).values(published_version_id=None))
await session.execute(delete(SkillVersion).where(SkillVersion.skill_id.in_(ids.values())))
await session.execute(delete(SkillNode).where(SkillNode.id.in_(ids.values())))
await session.commit()
await engine.dispose()
print('Events: original evidence, independent packages, aggregation, timeline, preview, frozen config and atomic merge history passed')
asyncio.run(main())