feat(news): index news packages and research persistent events

Extract reusable literal keywords once per source revision, assign complete news packages to long-lived events, and research all event members with immutable evidence and references.

Add additive research storage, vocabulary snapshots, the two-stage workspace, isolated acceptance coverage, and a paused rollout with an initial fixed 50-news production trial.
This commit is contained in:
eezd committed 2026-11-04 12:09:58 +08:00
1 parent 07252fea0f
commit 4929a32a0a
69 files changed
+8215 -2092

No files matched your search

+160 -338
View File
@@ -1,361 +1,183 @@
"""Validate backfill modes and queue queries only in a disposable CI database."""
"""Validate event completion, queue visibility and historical scheduling in CI."""
import asyncio
import os
import json
from datetime import UTC, datetime, timedelta
from unittest.mock import AsyncMock, patch
import json
import os
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from urllib.parse import urlparse
from sqlalchemy import select, delete, update, func
from sqlalchemy import select, func, delete, update
from alphapulse.core.db import SessionLocal, engine
from alphapulse.models import AiProvider, NewsItem, SkillVersion, SkillNode
from alphapulse.models.risk import (RiskSettings, ArticleAnalysis, SummaryArticle, NewsSummary,
GroupingVersion, ReprocessJob, ReprocessItem, ReprocessTarget, NewsEvent, EventMember, PackageCorrection, RiskSnapshot)
from alphapulse.models import AiProvider, NewsItem, SkillNode, SkillVersion
from alphapulse.models.risk import (RiskSettings, ArticleAnalysis, NewsSummary, SummaryArticle, NewsEvent,
EventMember, ReprocessJob, ReprocessItem, ReprocessTarget, GroupingVersion, RiskSnapshot)
from alphapulse.ai.skills import create_node, add_version
from alphapulse.news_risk import backfill, pipeline, worker, queue, scheduler, events, briefing
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) - timedelta(hours=3)
storage = {}
async def save(key, value, **_): storage[key] = value
async def remove(key): storage.pop(key, None)
fake_redis = type('Redis', (), {'set': AsyncMock(side_effect=save), 'get': AsyncMock(side_effect=lambda k: storage.get(k)), 'delete': AsyncMock(side_effect=remove)})()
url=urlparse(os.environ['DATABASE_URL'])
assert url.username=='ci' and url.password=='ci-only' and url.path=='/ci'
now=datetime.now(UTC)-timedelta(hours=3); store={}; news_ids=[]; package_ids=[]; event_ids=[]; skills={}
async def save(key,value,**_): store[key]=value
async def remove(key): store.pop(key,None)
fake=SimpleNamespace(set=AsyncMock(side_effect=save),get=AsyncMock(side_effect=lambda k:store.get(k)),delete=AsyncMock(side_effect=remove))
async with SessionLocal() as session:
assert not await session.scalar(select(func.count()).select_from(RiskSettings))
provider = AiProvider(name='backfill-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()
skills = {}
for stage, (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; skills[stage+'_skill_id'] = node.id
session.add(RiskSettings(id=1, enabled=True, start_at=now+timedelta(days=1), provider_id=provider.id,
second_provider_id=provider.id, third_provider_id=provider.id, model='luna', second_model='reviewer',
third_model='senior', reasoning_effort='low', second_reasoning_effort='high', **skills))
news_ids, package_ids = [], []
assert not await session.get(RiskSettings,1)
provider=AiProvider(name='backfill-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(); provider_id=provider.id
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
skills[role+'_skill_id']=node.id
session.add(RiskSettings(id=1,enabled=True,start_at=now+timedelta(days=1),provider_id=provider_id,second_provider_id=provider_id,
third_provider_id=provider_id,model='luna',second_model='reviewer',third_model='senior',**skills))
for i in range(3):
news = NewsItem(source_code='wscn_news', external_id='ci-backfill-'+str(i),
url='https://example.invalid/'+str(i), title='Evidence '+str(i), summary='',
content_text='Evidence '+str(i), content_hash='ci-backfill-'+str(i), published_at=now+timedelta(minutes=i))
news=NewsItem(source_code='wscn_news',external_id='ci-backfill-'+str(i),url='https://example.invalid/'+str(i),
title='Evidence '+str(i),summary='',content_text='Evidence '+str(i),content_hash='source-'+str(i),published_at=now+timedelta(minutes=i))
session.add(news); await session.flush(); news_ids.append(news.id)
if i == 2: continue
article = ArticleAnalysis(news_id=news.id, content_hash=news.content_hash, state='done', result={'old':True},
input={'title':news.title,'summary':'','content':news.content_text,'url':news.url,'source':'wscn_news',
'revision_no':1,'published_at':news.published_at.isoformat(),'progress_at':news.published_at.isoformat()})
if i==2: continue
article=ArticleAnalysis(news_id=news.id,content_hash=news.content_hash,state='done',result={'old':True},input={
'title':news.title,'summary':'','content':news.content_text,'url':news.url,'source':'wscn_news','revision_no':1,
'published_at':news.published_at.isoformat(),'progress_at':news.published_at.isoformat()})
session.add(article); await session.flush()
package = NewsSummary(title=news.title, fact=news.title, category='science', entities=[], tags=[],
published_at=now, updated_at=now, changed_at=now, due_at=now, revision=1, published_revision=1 if i==0 else 0,
state='done' if i==0 else 'failed', result={'summary':'Original'} if i==0 else None)
package=NewsSummary(title=news.title,fact=news.title,category='科技',entities=[],tags=['science'],
published_at=now,updated_at=now,changed_at=now,due_at=now,revision=1,state='done')
session.add(package); await session.flush(); package_ids.append(package.id)
session.add(SummaryArticle(summary_id=package.id, analysis_id=article.id, active=True))
await session.commit()
grouping_calls = []
async def group(provider, system, payload, schema):
grouping_calls.append(provider.model)
article = payload['article']
return pipeline.Grouping(reason='Same concrete fact', packages=[dict(package_id=package_ids[1], group_key=None,
title='Evidence', fact='Evidence', evidence=article['title'], category='science', entities=[], tags=['science'],
urgent=False, change_kind='repost', content_kind='report')]), {}
async def prepare(start, end, modes):
async with SessionLocal() as session: return await backfill.preview(session, start, end, modes)
async def begin(value):
async with SessionLocal() as session: return (await backfill.start(session, value['preview_id']))['id']
async def process_all(job_id):
async with SessionLocal() as session:
ids = (await session.scalars(select(ReprocessItem.id).where(ReprocessItem.job_id==job_id))).all()
for identifier in ids: await backfill.process(identifier)
with patch.object(backfill, 'redis', fake_redis), patch.object(queue, 'redis', fake_redis), patch.object(worker, 'complete', group):
window = (now-timedelta(seconds=1), now+timedelta(minutes=4))
preview = await prepare(*window, ['missing'])
assert (preview['total'],preview['missing'],preview['review'],preview['articles']) == (3,2,1,2), preview
job_id = await begin(preview)
await process_all(job_id)
assert grouping_calls == ['luna'], grouping_calls
async with SessionLocal() as session:
items = (await session.scalars(select(ReprocessItem).where(ReprocessItem.job_id==job_id))).all()
assert all(i.state=='done' for i in items), [(i.state,i.error) for i in items]
targets = (await session.scalars(select(ReprocessTarget).where(ReprocessTarget.job_id==job_id))).all()
assert len(targets)==1 and targets[0].object_id==package_ids[1]
package = await session.get(NewsSummary, package_ids[1]); revision = package.revision
package.state='done'; package.result={'summary':'Verified'}; package.published_revision=revision
# An unrelated pending package must not hold this batch open.
other = NewsSummary(title='Unrelated',fact='Other',category='science',entities=[],published_at=now,
updated_at=now,changed_at=now,due_at=now,state='pending')
session.add(other); await session.commit()
job = await session.get(ReprocessJob,job_id); await backfill.reconcile(session,job)
assert job.state=='done', (job.state,job.failures)
assert (await queue.job_out(session,job))['completed']==2
for state in ('running','queued','waiting','failed','done',None):
result = await queue.listing(session,state,None,None,None,1,2)
assert result['total']>=len(result['items'])
result = await queue.listing(session,'done','backfill','history',job_id,1,1)
next_page = await queue.listing(session,'done','backfill','history',job_id,2,1)
assert result['total']==2 and result['items'][0]['key']!=next_page['items'][0]['key']
# Recheck must rerun grouping, preserve grouping history and refresh old/new targets.
preview = await prepare(now-timedelta(seconds=1),now+timedelta(seconds=1),['review'])
review_id = await begin(preview)
await process_all(review_id)
assert grouping_calls==['luna','luna']
async with SessionLocal() as session:
item = await session.scalar(select(ReprocessItem).where(ReprocessItem.job_id==review_id))
assert item.state=='done', item.error
assert await session.scalar(select(func.count()).select_from(GroupingVersion))==2
targets = (await session.scalars(select(ReprocessTarget).where(ReprocessTarget.job_id==review_id))).all()
assert {t.object_id for t in targets}==set(package_ids)
for target in targets:
package=await session.get(NewsSummary,target.object_id)
package.state='done'; package.result={'summary':'Verified'}; package.published_revision=package.revision
event=NewsEvent(title='Related event',updated_at=now,due_at=now,state='pending',revision=1)
session.add(event); await session.flush()
session.add(EventMember(package_id=package_ids[1],event_id=event.id,reason='Synthetic'))
await session.commit()
job=await session.get(ReprocessJob,review_id); await backfill.reconcile(session,job)
assert job.state=='running'
event.state='done'; event.published_revision=1; await session.commit()
await backfill.reconcile(session,job); assert job.state=='done'
# The fixed preview refuses changed settings, and a changed source fails visibly.
preview = await prepare(now+timedelta(minutes=2),now+timedelta(minutes=3),['review'])
async with SessionLocal() as session:
settings=await session.get(RiskSettings,1); settings.model='changed'; await session.commit()
try: await backfill.start(session,preview['preview_id'])
except ValueError as error: assert '配置已变化' in str(error)
else: raise AssertionError('Changed configuration accepted')
settings.model='luna'; await session.commit()
changed_id = await begin(preview)
async with SessionLocal() as session:
news=await session.get(NewsItem,news_ids[2]); news.content_hash='changed'; await session.commit()
await process_all(changed_id)
async with SessionLocal() as session:
job=await session.get(ReprocessJob,changed_id); await backfill.reconcile(session,job)
item=await session.scalar(select(ReprocessItem).where(ReprocessItem.job_id==changed_id))
assert job.state=='failed' and '版本变化' in item.error
assert grouping_calls==['luna','luna']
# Missing items completed after preview are skipped, and pause/cancel stop admissions.
async with SessionLocal() as session:
package=await session.get(NewsSummary,package_ids[1]); package.published_revision=0; package.state='pending'
await session.commit()
preview=await prepare(now-timedelta(seconds=1),now+timedelta(seconds=1),['missing'])
skip_id=await begin(preview)
async with SessionLocal() as session:
settings=await session.get(RiskSettings,1); settings.enabled=False
package=await session.get(NewsSummary,package_ids[1]); package.published_revision=package.revision; package.state='done'
await session.commit()
await process_all(skip_id)
async with SessionLocal() as session:
item=await session.scalar(select(ReprocessItem).where(ReprocessItem.job_id==skip_id)); assert item.state=='pending'
settings=await session.get(RiskSettings,1); settings.enabled=True; await session.commit()
await process_all(skip_id)
async with SessionLocal() as session:
item=await session.scalar(select(ReprocessItem).where(ReprocessItem.job_id==skip_id)); assert item.state=='skipped'
job=await session.get(ReprocessJob,skip_id); await backfill.reconcile(session,job); assert job.state=='done'
# A failed member must fail its batch without waiting forever for the blocked event.
job=await session.get(ReprocessJob,review_id); job.state='running'
package=await session.get(NewsSummary,package_ids[1]); package.state='failed'; package.error='Synthetic failure'
event=await session.scalar(select(NewsEvent)); event.state='pending'; event.revision+=1
await session.commit()
await backfill.reconcile(session,job)
assert job.state=='failed' and {f['stage'] for f in job.failures}=={'analysis','events'}, job.failures
from alphapulse.api.risk import reprocess_retry
await reprocess_retry(job.id,session)
assert (await session.scalar(select(ReprocessItem).where(ReprocessItem.job_id==job.id))).state=='done'
assert (await session.get(NewsSummary,package_ids[1])).state=='pending'
job.state='cancelled'; await session.commit()
preview=await prepare(now-timedelta(seconds=1),now+timedelta(seconds=1),['review'])
cancel_id=await begin(preview)
async with SessionLocal() as session:
item=await session.scalar(select(ReprocessItem).where(ReprocessItem.job_id==cancel_id)); item.state='processing'
article=await session.scalar(select(ArticleAnalysis).where(ArticleAnalysis.news_id==item.news_id)); article.state='pending'
await session.commit()
lock=SimpleNamespace(scalar=AsyncMock(return_value=True),execute=AsyncMock(),commit=AsyncMock())
with patch.object(scheduler,'candidate',AsyncMock(side_effect=asyncio.CancelledError)):
try: await scheduler.run(lock)
except asyncio.CancelledError: pass
else: raise AssertionError('Restart test did not reach admission')
async with SessionLocal() as session:
item=await session.scalar(select(ReprocessItem).where(ReprocessItem.job_id==cancel_id)); assert item.state=='pending'
article=await session.scalar(select(ArticleAnalysis).where(ArticleAnalysis.news_id==item.news_id)); assert article.state=='reprocess'
job=await session.get(ReprocessJob,cancel_id); job.state='cancelled'; await session.commit()
await process_all(cancel_id)
async with SessionLocal() as session:
job=await session.get(ReprocessJob,cancel_id); await backfill.reconcile(session,job)
assert job.state=='cancelled'
assert grouping_calls==['luna','luna']
# Cancelled calls drain before another batch can review the same news.
from alphapulse.api.risk import reprocess_cancel
async with SessionLocal() as session:
config=json.loads(json.dumps((await session.get(ReprocessJob,cancel_id)).settings))
config['configurations']['grouping']['provider']['model']='luna-old'
old=ReprocessJob(state='pending',items=[],position=0,failures=[],settings=config)
session.add(old); await session.flush(); old.settings={**old.settings,'job_id':old.id}
news=await session.get(NewsItem,news_ids[0])
old_item=ReprocessItem(job_id=old.id,news_id=news.id,content_hash=news.content_hash,mode='review',state='pending')
session.add(old_item); await session.commit()
old_id,old_item_id=old.id,old_item.id
entered=asyncio.Event(); release=asyncio.Event()
async def controlled(provider,system,payload,schema):
if provider.model=='luna-old':
entered.set(); await release.wait()
result,usage=await group(provider,system,payload,schema)
result.reason=provider.model
return result,usage
with patch.object(worker,'complete',controlled),patch.object(worker,'outlook',AsyncMock(return_value=False)):
old_task=asyncio.create_task(backfill.process(old_item_id))
try:
async with asyncio.timeout(5): await entered.wait()
async with SessionLocal() as session:
await reprocess_cancel(old_id,session)
config=json.loads(json.dumps((await session.get(ReprocessJob,old_id)).settings))
config['configurations']['grouping']['provider']['model']='luna-new'
new=ReprocessJob(state='pending',items=[],position=0,failures=[],settings=config)
session.add(new); await session.flush(); new.settings={**new.settings,'job_id':new.id}
retried=ReprocessItem(job_id=new.id,news_id=news_ids[0],content_hash='ci-backfill-0',mode='review',state='pending')
other_item=ReprocessItem(job_id=new.id,news_id=news_ids[1],content_hash='ci-backfill-1',mode='review',state='pending')
session.add_all([retried,other_item]); await session.commit()
new_id,retried_id,other_item_id=new.id,retried.id,other_item.id
active={('analysis',i) for i in (await session.scalars(select(NewsSummary.id))).all()}
active.update(('events',i) for i in (await session.scalars(select(NewsEvent.id))).all())
picked=await scheduler.candidate(active|{('backfill',old_item_id)},0)
assert picked and picked[0]==('backfill',other_item_id), picked
assert 'luna-new' not in grouping_calls
finally:
release.set(); await old_task
picked=await scheduler.candidate(active,0)
assert picked and picked[0]==('backfill',retried_id), picked
await picked[1]()
async with SessionLocal() as session:
article=await session.scalar(select(ArticleAnalysis).where(ArticleAnalysis.news_id==news_ids[0]))
assert article.result['reason']=='luna-new' and article.model=='luna-new'
new=await session.get(ReprocessJob,new_id); new.state='cancelled'; await session.commit()
# A pending backfill must not hide real-time grouping that won admission.
async with SessionLocal() as session:
job=ReprocessJob(state='running',items=[],position=0,failures=[],settings=(await session.get(ReprocessJob,cancel_id)).settings)
session.add(job); await session.flush()
job.settings={**job.settings,'job_id':job.id}
news=await session.get(NewsItem,news_ids[0])
item=ReprocessItem(job_id=job.id,news_id=news.id,content_hash=news.content_hash,mode='review',state='pending')
session.add(item)
article=await session.scalar(select(ArticleAnalysis).where(ArticleAnalysis.news_id==news.id)); article.state='processing'
await session.commit()
task={'stage':'grouping','object_id':article.id,'phase':'等待模型响应',
'started_at':now.isoformat(),'updated_at':now.isoformat()}
storage['ap:risk:active']=json.dumps([task])
result=await queue.listing(session,'running','grouping',None,None,1,20)
assert [r['object_id'] for r in result['items']]==[article.id] and result['total']==1
storage.pop('ap:risk:active')
result=await queue.listing(session,'running','grouping',None,None,1,20)
assert len(result['items'])==1 and result['items'][0]['state']=='unknown'
item.state='processing'; await session.commit()
storage['ap:risk:active']=json.dumps([{**task,'stage':'backfill','object_id':item.id}])
assert (await queue.listing(session,'running','grouping',None,None,1,20))['total']==0
assert (await queue.listing(session,'running','backfill',None,job.id,1,20))['total']==1
storage.pop('ap:risk:active')
# Batch membership follows durable targets; execution metadata remains real-time.
shared=await session.get(NewsSummary,package_ids[1]); shared.review_config=None; shared.state='pending'; shared.due_at=now
await backfill.target(session,job.id,'analysis',shared)
await session.commit()
result=await queue.listing(session,'queued','analysis',None,job.id,1,20)
assert result['total']==1 and result['items'][0]['object_id']==shared.id
assert result['items'][0]['origin']=='realtime' and result['items'][0]['model']=='reviewer'
assert (await queue.listing(session,'queued','analysis','realtime',job.id,1,20))['total']==1
assert (await queue.listing(session,'queued','analysis','history',job.id,1,20))['total']==0
event=await session.scalar(select(NewsEvent)); event.review_config=None; event.state='pending'; event.due_at=now
shared.state='done'; shared.published_revision=shared.revision
await backfill.target(session,job.id,'events',event)
await session.commit()
result=await queue.listing(session,'queued','events',None,job.id,1,20)
assert result['total']==1 and result['items'][0]['object_id']==event.id
await session.execute(delete(ReprocessTarget).where(ReprocessTarget.job_id==job.id))
await session.commit()
# Retaining the original package ID during a split must track both branches.
article.state='done'; item.state='done'
package=NewsSummary(title='Two facts',fact='Two facts',category='science',entities=[],tags=['science'],
published_at=now,updated_at=now,changed_at=now,due_at=now,state='done',revision=1,published_revision=1,
result={'summary':'Original'},review_config=job.settings)
session.add(package); await session.flush()
session.add(SummaryArticle(summary_id=package.id,analysis_id=article.id,active=True))
await session.flush()
await backfill.target(session,job.id,'analysis',package)
from alphapulse.news_risk.contracts import Correction
from alphapulse.news_risk.corrections import apply_correction
proposal=Correction(packages=[{'id':package.id,'revision':package.revision}],replacements=[
{'keep_id':package.id,'title':'First fact','fact':'First fact','category':'science','tags':['first'],
'content_kind':'report','assignments':[{'analysis_id':article.id,'evidence':article.input['content']}]},
{'keep_id':None,'title':'Second fact','fact':'Second fact','category':'science','tags':['second'],
'content_kind':'report','assignments':[{'analysis_id':article.id,'evidence':article.input['content']}]}],
reason='Two distinct facts in one article')
outputs=await apply_correction(session,proposal,package.id,set(),{'backfill-split':True})
package.state='done'; package.published_revision=package.revision
await session.commit()
await backfill.reconcile(session,job)
assert job.state=='running'
assert set((await session.scalars(select(ReprocessTarget.object_id).where(
ReprocessTarget.job_id==job.id,ReprocessTarget.kind=='analysis'))).all())==set(outputs)
created=await session.get(NewsSummary,outputs[1]); created.state='failed'; created.error='Synthetic split failure'
await session.commit(); await backfill.reconcile(session,job)
assert job.state=='failed' and job.failures==[{'stage':'analysis','id':created.id,'error':created.error}]
await reprocess_retry(job.id,session)
assert (await session.get(NewsSummary,created.id)).state=='pending'
created.state='done'; created.published_revision=created.revision; created.result={'summary':'Verified split'}
await session.commit(); await backfill.reconcile(session,job); assert job.state=='done'
# An empty 72h window clears old reading immediately, without models or a pool slot.
event=NewsEvent(title=news.title,fact=news.title,updated_at=now,due_at=now,revision=1,
published_revision=1 if i==0 else 0,state='done' if i==0 else 'failed',result={'summary':'Verified'} if i==0 else None)
session.add(event); await session.flush(); event_ids.append(event.id)
session.add(EventMember(package_id=package.id,event_id=event.id,reason='Same occurrence'))
await session.commit()
calls=[]
async def group(provider,system,payload,schema):
calls.append(provider.model)
index=0 if payload['article']['title']=='Evidence 0' else 1
return schema(reason='Concrete fact',packages=[dict(event=dict(event_id=event_ids[index],group_key=None,title='Event',fact='Fact',reason='Same event'),
package_id=package_ids[index],group_key=None,title='Evidence',fact='Evidence',evidence=payload['article']['title'],
category='科技',entities=[],tags=['science'],urgent=False,content_kind='report',change_kind='repost')]),{}
async def preview(modes,start=None,end=None):
async with SessionLocal() as session:
previous=RiskSnapshot(state='done',model='senior',quotes={'fingerprint':'obsolete','important':{}},
result={'reading':[{'id':package.id}],'inputs':[{'id':package.id}],'synthesis':{'summary':'Obsolete'}})
session.add(previous); await session.commit()
count=await session.scalar(select(func.count()).select_from(RiskSnapshot))
no_model=AsyncMock(side_effect=AssertionError('Empty windows must not call a model'))
with patch.object(events,'reading_inputs',AsyncMock(return_value=[])),patch.object(briefing,'generate',no_model):
assert not await worker.outlook(prepare_only=True)
assert not await worker.outlook(prepare_only=True)
return await backfill.preview(session,start or now-timedelta(seconds=1),end or now+timedelta(minutes=4),modes)
async def begin(value):
async with SessionLocal() as session: return (await backfill.start(session,value['preview_id']))['id']
async def process(job_id):
async with SessionLocal() as session: ids=list(await session.scalars(select(ReprocessItem.id).where(ReprocessItem.job_id==job_id)))
for i in ids: await backfill.process(i)
try:
with patch.object(backfill,'redis',fake),patch.object(queue,'redis',fake),patch.object(worker,'complete',group):
value=await preview(['missing'])
assert (value['total'],value['missing'],value['review'],value['articles'])==(3,2,1,2),value
job_id=await begin(value); await process(job_id)
assert calls==['luna']
async with SessionLocal() as session:
latest=await session.scalar(select(RiskSnapshot).order_by(RiskSnapshot.id.desc()).limit(1))
assert latest.state=='done' and latest.result['inputs']==[] and latest.result['reading']==[]
assert latest.result['expansion']==[] and latest.result['synthesis'] is None
assert await session.scalar(select(func.count()).select_from(RiskSnapshot))==count+1
latest.state='failed'; latest.quotes={**latest.quotes,'retry_requested':True}; await session.commit()
failed_id=latest.id
assert not await worker.outlook(prepare_only=True)
items=list(await session.scalars(select(ReprocessItem).where(ReprocessItem.job_id==job_id)))
assert all(i.state=='done' for i in items),[(i.state,i.error) for i in items]
targets=list(await session.scalars(select(ReprocessTarget).where(ReprocessTarget.job_id==job_id)))
assert len(targets)==1 and targets[0].kind=='events' and targets[0].object_id==event_ids[1]
event=await session.get(NewsEvent,event_ids[1]); event.state='done'; event.result={'summary':'Verified'}; event.published_revision=event.revision
await session.commit(); job=await session.get(ReprocessJob,job_id); await backfill.reconcile(session,job)
assert job.state=='done' and (await queue.job_out(session,job))['completed']==2
for state in ('running','queued','waiting','failed','done',None):
listing=await queue.listing(session,state,None,None,None,1,2)
assert listing['total']>=len(listing['items']) and all(r['stage']!='analysis' for r in listing['items'])
first=await queue.listing(session,'done','backfill','history',job_id,1,1)
second=await queue.listing(session,'done','backfill','history',job_id,2,1)
assert first['total']==2 and first['items'][0]['key']!=second['items'][0]['key']
value=await preview(['review'],now-timedelta(seconds=1),now+timedelta(seconds=1))
review_id=await begin(value); await process(review_id)
assert calls==['luna','luna']
async with SessionLocal() as session:
previous=await session.get(RiskSnapshot,failed_id); assert 'retry_requested' not in previous.quotes
assert await session.scalar(select(func.count()).select_from(RiskSnapshot))==count+2
assert not await worker.outlook(prepare_only=True)
no_model.assert_not_awaited()
# JSON batch references must preserve the BIGINT identity used by job rows.
assert await session.scalar(select(func.count()).select_from(GroupingVersion))==2
event=await session.get(NewsEvent,event_ids[0]); assert event.state=='pending'
job=await session.get(ReprocessJob,review_id); await backfill.reconcile(session,job); assert job.state=='running'
event.state='failed'; event.error='Synthetic failure'; await session.commit()
await backfill.reconcile(session,job); assert job.state=='failed' and job.failures[0]['stage']=='events'
from alphapulse.api.risk import reprocess_retry
await reprocess_retry(job.id,session)
assert event.state=='pending' and calls==['luna','luna']
event.state='done'; event.result={'summary':'Reviewed'}; event.published_revision=event.revision
await session.commit(); await backfill.reconcile(session,job); assert job.state=='done'
value=await preview(['review'],now+timedelta(minutes=2),now+timedelta(minutes=3))
async with SessionLocal() as session:
settings=await session.get(RiskSettings,1); settings.model='changed'; await session.commit()
try: await backfill.start(session,value['preview_id'])
except ValueError as error: assert '配置已变化' in str(error)
else: raise AssertionError('Preview configuration drift accepted')
settings.model='luna'; await session.commit()
changed_id=await begin(value)
async with SessionLocal() as session:
news=await session.get(NewsItem,news_ids[2]); news.content_hash='changed'; await session.commit()
await process(changed_id)
async with SessionLocal() as session:
job=await session.get(ReprocessJob,changed_id); await backfill.reconcile(session,job)
assert job.state=='failed' and '版本变化' in job.failures[0]['error']
event=await session.get(NewsEvent,event_ids[0]); event.state='pending'; event.published_revision=0; await session.commit()
value=await preview(['missing'],now-timedelta(seconds=1),now+timedelta(seconds=1)); skip_id=await begin(value)
async with SessionLocal() as session:
settings=await session.get(RiskSettings,1); settings.enabled=False
event=await session.get(NewsEvent,event_ids[0]); event.state='done'; event.published_revision=event.revision
await session.commit()
await process(skip_id)
async with SessionLocal() as session:
item=await session.scalar(select(ReprocessItem).where(ReprocessItem.job_id==skip_id)); assert item.state=='pending'
settings=await session.get(RiskSettings,1); settings.enabled=True; await session.commit()
await process(skip_id)
async with SessionLocal() as session:
item=await session.scalar(select(ReprocessItem).where(ReprocessItem.job_id==skip_id)); assert item.state=='skipped'
job=await session.get(ReprocessJob,skip_id); await backfill.reconcile(session,job); assert job.state=='done'
value=await preview(['review'],now-timedelta(seconds=1),now+timedelta(seconds=1)); cancel_id=await begin(value)
async with SessionLocal() as session:
item=await session.scalar(select(ReprocessItem).where(ReprocessItem.job_id==cancel_id)); item.state='processing'
article=await session.scalar(select(ArticleAnalysis).where(ArticleAnalysis.news_id==item.news_id)); article.state='pending'
await session.commit()
lock=SimpleNamespace(scalar=AsyncMock(return_value=True),execute=AsyncMock(),commit=AsyncMock())
with patch.object(scheduler,'candidate',AsyncMock(side_effect=asyncio.CancelledError)):
try: await scheduler.run(lock)
except asyncio.CancelledError: pass
async with SessionLocal() as session:
item=await session.get(ReprocessItem,item.id); assert item.state=='pending'
article=await session.get(ArticleAnalysis,article.id); assert article.state=='reprocess'
task={'stage':'grouping','object_id':article.id,'phase':'Waiting','started_at':now.isoformat(),'updated_at':now.isoformat()}
article.state='processing'; await session.commit(); store['ap:risk:active']=json.dumps([task])
listing=await queue.listing(session,'running','grouping',None,None,1,20)
assert listing['total']==1 and listing['items'][0]['object_id']==article.id
store.pop('ap:risk:active')
assert (await queue.listing(session,'running','grouping',None,None,1,20))['items'][0]['state']=='unknown'
item.state='processing'; await session.commit()
store['ap:risk:active']=json.dumps([{**task,'stage':'backfill','object_id':item.id}])
assert (await queue.listing(session,'running','grouping',None,None,1,20))['total']==0
assert (await queue.listing(session,'running','backfill',None,cancel_id,1,20))['total']==1
store.pop('ap:risk:active')
from alphapulse.api.risk import reprocess_cancel
await reprocess_cancel(cancel_id,session)
shared=await session.get(NewsEvent,event_ids[0]); shared.review_config=None; shared.state='pending'; shared.due_at=now
await backfill.target(session,cancel_id,'events',shared); await session.commit()
listing=await queue.listing(session,'queued','events',None,cancel_id,1,20)
assert listing['total']==1 and listing['items'][0]['origin']=='realtime'
large=ReprocessJob(id=2147483648,state='failed',items=[],position=0,failures=[],settings={})
session.add(large); shared.review_config={'job_id':large.id}; shared.state='failed'; await session.commit()
listing=await queue.listing(session,'failed','events','history',large.id,1,20)
assert listing['total']==1
assert (await queue.listing(session,'failed',None,None,9223372036854775807,1,20))['total']==0
no_model=AsyncMock(side_effect=AssertionError('Empty windows must not call models'))
with patch.object(events,'reading_inputs',AsyncMock(return_value=[])),patch.object(briefing,'generate',no_model):
assert not await worker.outlook(prepare_only=True)
assert not await worker.outlook(prepare_only=True)
no_model.assert_not_awaited()
assert calls==['luna','luna']
print('Backfill: event-scoped completion, preview snapshots, recheck, retry, pause, skip, restart, cancel, queue identity and BIGINT filters passed')
finally:
async with SessionLocal() as session:
original=await session.get(ReprocessJob,job_id)
large=ReprocessJob(id=2147483648,state='failed',settings={**original.settings,'job_id':2147483648},
items=[],position=0,failures=[])
package=await session.get(NewsSummary,package_ids[0])
package.review_config=large.settings; package.state='failed'
event=NewsEvent(title='Large batch',updated_at=now,due_at=now,state='failed',review_config=large.settings)
snapshot=RiskSnapshot(state='failed',model='senior',quotes={'job_id':large.id})
session.add_all([large,event,snapshot]); await session.flush()
large.failures=[{'stage':'outlook','id':snapshot.id,'error':'Synthetic large batch failure'}]
await session.commit()
result=await queue.listing(session,'failed',None,'history',large.id,1,20)
assert {(r['stage'],r['object_id']) for r in result['items']}=={
('analysis',package.id),('events',event.id),('outlook',snapshot.id)}
for state in ('running','queued','waiting','failed','done',None):
result=await queue.listing(session,state,None,None,9223372036854775807,1,20)
assert result['total']==0 and result['items']==[]
from alphapulse.api.risk import reprocess_retry
await reprocess_retry(large.id,session)
assert (await session.get(RiskSnapshot,snapshot.id)).quotes['retry_requested']
async with SessionLocal() as session:
for model in (ReprocessTarget,ReprocessItem,ReprocessJob,EventMember,NewsEvent,PackageCorrection,RiskSnapshot,GroupingVersion,SummaryArticle,NewsSummary,ArticleAnalysis):
await session.execute(delete(model))
await session.execute(delete(NewsItem).where(NewsItem.id.in_(news_ids)))
await session.execute(delete(RiskSettings))
await session.execute(update(SkillNode).where(SkillNode.id.in_(skills.values())).values(published_version_id=None))
await session.execute(delete(SkillVersion).where(SkillVersion.skill_id.in_(skills.values())))
await session.execute(delete(SkillNode).where(SkillNode.id.in_(skills.values())))
await session.execute(delete(AiProvider).where(AiProvider.id==provider.id)); await session.commit()
await engine.dispose()
print('Historical backfill: selection, frozen preview, reuse, recheck, targets, source revisions and queue passed')
for model in (ReprocessTarget,ReprocessItem,ReprocessJob,EventMember,NewsEvent,RiskSnapshot,GroupingVersion,SummaryArticle,NewsSummary,ArticleAnalysis,RiskSettings):
await session.execute(delete(model))
await session.execute(delete(NewsItem).where(NewsItem.id.in_(news_ids)))
await session.execute(update(SkillNode).where(SkillNode.id.in_(skills.values())).values(published_version_id=None))
await session.execute(delete(SkillVersion).where(SkillVersion.skill_id.in_(skills.values())))
await session.execute(delete(SkillNode).where(SkillNode.id.in_(skills.values())))
await session.execute(delete(AiProvider).where(AiProvider.id==provider_id)); await session.commit()
await engine.dispose()
asyncio.run(main())