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

+132 -138
View File
@@ -1,176 +1,170 @@
"""Exercise both news stages against the disposable CI database and fake models."""
"""Exercise event-scoped news research using only the disposable CI database."""
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
import httpx
from fastapi import FastAPI, 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, GroupingVersion
from alphapulse.news_risk import service, pipeline, worker, briefing
from alphapulse.models.risk import (RiskSettings, RiskSnapshot, ArticleAnalysis, NewsSummary,
SummaryArticle, SummaryVersion, GroupingVersion, NewsEvent, EventMember, EventVersion, EventEvidence)
from alphapulse.news_risk import pipeline, worker, briefing, event_research
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)
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='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',
reasoning_effort='low', second_reasoning_effort='high', third_reasoning_effort='xhigh', 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))
assert not await session.scalar(select(func.count()).select_from(RiskSettings))
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=8192,context_window=200000)
session.add(provider); await session.flush(); provider_id=provider.id
skills={}
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
await session.flush(); assert not await published_skills(session)
session.add(RiskSettings(id=1,enabled=True,start_at=now-timedelta(minutes=2),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',third_reasoning_effort='xhigh',**skills))
for key,at in [('old',now-timedelta(days=1)),('first',now-timedelta(minutes=1)),('repost',now-timedelta(seconds=30))]:
session.add(NewsItem(source_code='wscn_news',external_id='ci-news-'+key,url='https://example.invalid/'+key,
title='' if key=='first' else 'Exchange reports incident',summary='',content_text='original',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
assert effort == {'luna': 'low', 'reviewer': 'high', 'senior': 'xhigh'}[provider.model]
data = json.loads(messages[0]['text']); calls.append((provider.model, data))
calls=[]; broken=False; change=False
async def stream(provider,system,messages,tools,effort):
nonlocal change
assert effort=={'luna':'low','reviewer':'high','senior':'xhigh'}[provider.model]
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', packages=[pipeline.Match(
package_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',
timeline_points=[dict(occurred_at=None, time_precision='unknown', time_text='Unknown', reported_at=None, description='Synthetic', verification='insufficient', evidence_refs=[{'article_id':a['id'],'quote':a['title']} for a in data['articles']])], 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')
candidate=next(iter(data['candidates']),None)
target={'event_id':candidate['main_event_id'] if candidate else None,'group_key':None if candidate else 'incident',
'title':'Exchange incident','fact':'Concrete incident','reason':'Same concrete occurrence'}
result=pipeline.Grouping(reason='Same fact',packages=[dict(event=target,
package_id=candidate['id'] if candidate else None,group_key=None if candidate else 'fact',
title='Exchange reports incident',category='安全',entities=['Exchange'],fact='Concrete incident',
evidence=data['article']['title'] or data['article']['content'],urgent=True,tags=['exchange'],content_kind='report',
change_kind='repost' if candidate and data['article']['revision_no']==1 else 'correction' if candidate else 'new_fact')])
elif data.get('task')=='event_research':
assert provider.model=='reviewer'
if broken: raise ValueError('Synthetic research failure')
if change:
change=False
async with SessionLocal() as session:
event=await session.get(NewsEvent,data['id'])
event_research.schedule(event,datetime.now(UTC),True); await session.commit()
refs=[dict(article_id=a['id'],quote=a['title'] or a['content']) for a in data['articles']]
result=event_research.EventResult(id=data['id'],title='Exchange reports incident',summary='Event summary',category='安全',entities=['Exchange'],
risk_stars=4,importance_stars=4,direction='unknown',verification='reported',confidence='low',reasoning='Synthetic',uncertainty='Unknown',
facts='Original facts',timeline='Incident',asset_impacts='Unknown',opinions='Wait',counter_evidence='Unknown',watchpoints='Response',
claims=[dict(statement='Incident',verdict='supported',evidence_refs=refs)],evidence_refs=refs,
timeline_points=[dict(package_id=p['id'],package_revision=p['revision'],occurred_at=None,time_precision='unknown',
time_text='',reported_at=None,description=p['fact'],verification='partial',
evidence_refs=[r for r in refs if r['article_id'] in p['article_ids']]) for p in data['packages']])
elif data.get('task')=='ranking':
result=briefing.Ranked(reading=[dict(kind=p['kind'],id=p['id'],revision=p['revision'],priority=90,reason='Synthetic') for p in data['packages']],notes='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})
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(mode='json')}],usage={'input_tokens':10})
try:
with patch.object(llm, 'stream', stream):
await worker.analyze()
finish_during_grouping = True
await worker.analyze()
with patch.object(llm,'stream',stream):
assert await worker.analyze()=='done'
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()
event=await session.scalar(select(NewsEvent)); event_id=event.id; revision=event.revision
assert await worker.analyze()=='done'
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)
event=await session.get(NewsEvent,event_id)
assert event.revision==revision, 'Pure repost triggered event research'
package=await session.scalar(select(NewsSummary)); package_id=package.id
assert package.state=='done' and package.result is None
assert await session.scalar(select(func.count()).select_from(SummaryVersion))==0
assert await session.scalar(select(func.count()).select_from(SummaryArticle))==2
await event_research.review(event_id)
async with SessionLocal() as session:
event=await session.get(NewsEvent,event_id)
assert event.state=='done' and event.result['risk_stars']==4
version=await session.scalar(select(EventVersion))
assert len(version.input['articles'])==2
news=await session.scalar(select(NewsItem).where(NewsItem.external_id=='ci-news-first'))
news.content_hash='revised'; news.content_text='correction'; news.revision_no=2; news.source_updated_at=datetime.now(UTC)
event_research.schedule(event,datetime.now(UTC),True)
await session.commit()
await worker.analyze(); await worker.summarize()
before=len(calls)
await event_research.review(event_id)
assert len(calls)==before, 'Outdated originals reached a model'
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()
blocked=await session.get(NewsEvent,event_id)
assert blocked.state=='failed' and '原文已修订' in blocked.error
assert await worker.analyze()=='done'
await event_research.review(event_id)
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()
event=await session.get(NewsEvent,event_id)
version=await session.scalar(select(EventVersion).order_by(EventVersion.id.desc()).limit(1))
assert len(version.input['articles'])==2 and any(a['content']=='correction' for a in version.input['articles'])
event_research.schedule(event,datetime.now(UTC),True); await session.commit()
published=event.published_revision
change=True
await event_research.review(event_id)
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
event=await session.get(NewsEvent,event_id)
assert event.state=='pending' and event.published_revision==published
assert (await session.scalar(select(EventVersion).order_by(EventVersion.id.desc()).limit(1))).state=='superseded'
broken=True
await event_research.review(event_id)
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
event=await session.get(NewsEvent,event_id)
assert event.state=='failed' and event.result['summary']=='Event summary'
event.state='pending'; await session.commit()
broken=False
await event_research.review(event_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')
snapshot=await session.scalar(select(RiskSnapshot).order_by(RiskSnapshot.id.desc()).limit(1))
assert snapshot.state=='done' and [p['kind'] for p in snapshot.result['inputs']]==['event']
assert (await session.get(AiProvider,provider_id)).model=='shared'
from alphapulse.api import risk as api
app=FastAPI(); app.include_router(api.router); app.dependency_overrides[api.require_admin]=lambda:None
with patch.object(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/events',f'/api/risk/events/{event_id}',
'/api/risk/packages',f'/api/risk/packages/{package_id}'):
response=await client.get(path); assert response.status_code==200,(path,response.text)
detail=(await client.get(f'/api/risk/events/{event_id}')).json()['data']
assert len(detail['packages'])==1 and detail['versions']
facts=(await client.get(f'/api/risk/packages/{package_id}')).json()['data']['event']
assert facts['article_count']==2 and 'risk_stars' not in facts and 'result' not in facts
assert (await client.get('/api/risk/events',params={'hours':25})).status_code==422
assert (await client.get('/api/risk/summaries')).status_code==404
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')
try: await delete_provider(provider_id,session)
except HTTPException as error: assert error.status_code==409
else: raise AssertionError('Enabled provider deleted')
settings=await session.get(RiskSettings,1); settings.enabled=False; await session.commit()
await delete_provider(provider_id,session)
assert len([c for c in calls if c[0]=='luna'])==3
assert any(not data['article']['title'] and data['article']['content'] for model,data in calls if model=='luna')
print('Event research: grouping with untitled originals, repost suppression, originals, revision guards, retained success, retries, event-only outlook and APIs passed')
finally:
async with SessionLocal() as session:
for model in (GroupingVersion, PackageEvidence, SummaryVersion, SummaryArticle, NewsSummary, ArticleAnalysis, RiskSnapshot, RiskSettings):
for model in (EventEvidence,EventVersion,EventMember,NewsEvent,GroupingVersion,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.execute(delete(AiProvider).where(AiProvider.id==provider_id))
await session.execute(SkillNode.__table__.update().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.commit()
await engine.dispose()
asyncio.run(main())