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:
1 parent
07252fea0f
commit
4929a32a0a
69 files changed
+8215
-2092
No files matched your search
+132
-138
@@ -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())
|
||||
Reference in new issue
Block a user