refactor(news): remove retired research workflow
This commit is contained in:
1 parent
f3894c3728
commit
d79a10c2e6
69 files changed
+460
-6713
No files matched your search
@@ -1,329 +0,0 @@
|
||||
"""Event-scoped research preserves fact coverage and amortizes repeated work."""
|
||||
import asyncio
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from types import SimpleNamespace as N
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
import pytest
|
||||
|
||||
|
||||
def test_event_debounce_has_hard_deadline_and_sticky_urgency(trading):
|
||||
from alphapulse.news_risk.event_research import schedule
|
||||
now=datetime.now(UTC)
|
||||
event=N(revision=0,state='pending',dirty_since=None,due_at=now,error=None)
|
||||
schedule(event,now)
|
||||
assert event.due_at==now+timedelta(minutes=5)
|
||||
for minute in (4,8,12,14): schedule(event,now+timedelta(minutes=minute))
|
||||
assert event.due_at==now+timedelta(minutes=15)
|
||||
event.state='done'; event.dirty_since=None
|
||||
schedule(event,now+timedelta(minutes=20),True)
|
||||
schedule(event,now+timedelta(minutes=20))
|
||||
assert event.due_at==now+timedelta(minutes=20)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('event_id,group_key',[(None,None),(1,'new')])
|
||||
def test_stage_one_event_identity_is_exclusive(trading,event_id,group_key):
|
||||
from alphapulse.news_risk.pipeline import EventTarget
|
||||
with pytest.raises(ValueError): EventTarget(event_id=event_id,group_key=group_key,title='Title',fact='Fact',reason='Reason')
|
||||
|
||||
|
||||
def test_pure_repost_updates_tags_without_research(trading):
|
||||
from alphapulse.models.risk import EventMember,NewsEvent
|
||||
from alphapulse.news_risk.event_research import attach
|
||||
now=datetime.now(UTC)
|
||||
event=N(id=2,state='done',tags=['old'],revision=3,updated_at=now,review_config=None)
|
||||
membership=N(event_id=2)
|
||||
session=N(get=AsyncMock(side_effect=lambda model,*a,**kw:membership if model is EventMember else event),add=MagicMock())
|
||||
target=N(event_id=2,group_key=None)
|
||||
asyncio.run(attach(session,N(id=1,tags=['new'],updated_at=now),target,{},False,False,None))
|
||||
assert event.revision==3 and event.state=='done' and event.tags==['new','old']
|
||||
assert session.get.call_args.args[0] is NewsEvent
|
||||
|
||||
|
||||
def test_existing_package_cannot_be_moved_by_grouping(trading):
|
||||
from alphapulse.news_risk.event_research import attach
|
||||
session=N(get=AsyncMock(return_value=N(event_id=2)))
|
||||
with pytest.raises(ValueError,match='沿用主事件'):
|
||||
asyncio.run(attach(session,N(id=1),N(event_id=3),{},True,False,None))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def event_database(trading):
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.ext.compiler import compiles, deregister
|
||||
from alphapulse.models.risk import NewsEvent, EventMember
|
||||
|
||||
@compiles(JSONB, 'sqlite')
|
||||
def jsonb_sqlite(type_, compiler, **kw):
|
||||
return 'JSON'
|
||||
|
||||
engine = create_engine('sqlite:///:memory:')
|
||||
try:
|
||||
NewsEvent.__table__.create(engine)
|
||||
EventMember.__table__.create(engine)
|
||||
yield engine
|
||||
finally:
|
||||
engine.dispose()
|
||||
deregister(JSONB)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('action', ['attach_new', 'attach_existing', 'dirty'])
|
||||
@pytest.mark.parametrize('retired', [False, True])
|
||||
def test_event_mutations_refresh_cached_versions_and_reject_retirement(event_database, action, retired):
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.orm import Session
|
||||
from alphapulse.models.risk import NewsEvent, EventMember
|
||||
from alphapulse.news_risk.event_research import attach
|
||||
from alphapulse.news_risk.events import dirty_event
|
||||
|
||||
now = datetime.now(UTC).replace(tzinfo=None)
|
||||
with Session(event_database) as seed:
|
||||
seed.add(NewsEvent(id=7, title='Incident', fact='Fact', tags=[], revision=10,
|
||||
published_revision=10, state='done', updated_at=now, due_at=now))
|
||||
if action != 'attach_new':
|
||||
seed.add(EventMember(package_id=1, event_id=7, reason='Original'))
|
||||
seed.commit()
|
||||
with Session(event_database, expire_on_commit=False) as first:
|
||||
cached = first.scalar(select(NewsEvent))
|
||||
first.commit()
|
||||
with Session(event_database) as writer:
|
||||
writer.execute(update(NewsEvent).where(NewsEvent.id == 7).values(
|
||||
revision=12, state='retired' if retired else 'done'))
|
||||
writer.commit()
|
||||
|
||||
class Adapter:
|
||||
async def get(self, *args, **kwargs):
|
||||
return first.get(*args, **kwargs)
|
||||
|
||||
def add(self, obj):
|
||||
first.add(obj)
|
||||
|
||||
async def flush(self):
|
||||
first.flush()
|
||||
|
||||
async def mutate():
|
||||
if action == 'dirty':
|
||||
await dirty_event(Adapter(), 1)
|
||||
else:
|
||||
await attach(Adapter(), N(id=1, tags=['new'], updated_at=now),
|
||||
N(event_id=7, group_key=None, reason='Followup'), {}, True, True, None)
|
||||
|
||||
if retired:
|
||||
with pytest.raises(ValueError, match='事件已失效'):
|
||||
asyncio.run(mutate())
|
||||
else:
|
||||
asyncio.run(mutate())
|
||||
assert cached.revision == 13 and cached.state == 'pending'
|
||||
first.commit()
|
||||
with Session(event_database) as check:
|
||||
event = check.get(NewsEvent, 7)
|
||||
assert (event.revision, event.state) == ((12, 'retired') if retired else (13, 'pending'))
|
||||
if retired and action == 'attach_new':
|
||||
assert check.get(EventMember, 1) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize('package_config', [None, {'job_id': 98, 'request': {}}])
|
||||
@pytest.mark.parametrize('keep_id', [1, None])
|
||||
def test_package_correction_keeps_owning_event_frozen_configuration(trading, monkeypatch, package_config, keep_id):
|
||||
from alphapulse.models import NewsItem
|
||||
from alphapulse.models.risk import ArticleAnalysis, EventMember, NewsEvent, NewsSummary
|
||||
from alphapulse.news_risk import backfill
|
||||
from alphapulse.news_risk.contracts import Correction
|
||||
from alphapulse.news_risk.corrections import apply_correction
|
||||
|
||||
now = datetime.now(UTC)
|
||||
config = {'job_id': 99, 'request': {'run_mode': 'history_only'}}
|
||||
package = N(id=1, revision=1, replacement_ids=[], fact='Original', tags=['old'], review_config=package_config)
|
||||
article = N(id=11, news_id=21, content_hash='hash', input={'content': 'Original',
|
||||
'published_at': now.isoformat(), 'progress_at': now.isoformat()})
|
||||
link = N(summary_id=1, analysis_id=11, active=True, unit={})
|
||||
membership = N(event_id=7)
|
||||
event = N(id=7, revision=1, state='processing', dirty_since=now, due_at=now, review_config=config)
|
||||
added = []
|
||||
|
||||
async def get(model, identifier, **kwargs):
|
||||
if model is EventMember:
|
||||
return membership if identifier == 1 else None
|
||||
return {ArticleAnalysis: article, NewsItem: N(content_hash='hash'), NewsEvent: event}[model]
|
||||
|
||||
async def flush():
|
||||
for obj in added:
|
||||
if isinstance(obj, NewsSummary) and obj.id is None:
|
||||
obj.id = 2
|
||||
|
||||
session = N(scalars=AsyncMock(side_effect=[N(all=lambda: [package]), N(all=lambda: [link]), [7]]),
|
||||
scalar=AsyncMock(return_value=None), get=get, add=added.append, flush=flush, delete=AsyncMock())
|
||||
enroll = AsyncMock()
|
||||
monkeypatch.setattr(backfill, 'target', enroll)
|
||||
proposal = Correction(packages=[{'id': 1, 'revision': 1}], replacements=[{'keep_id': keep_id,
|
||||
'title': 'Corrected', 'fact': 'Original', 'category': '其他', 'tags': ['corrected'], 'content_kind': 'report',
|
||||
'assignments': [{'analysis_id': 11, 'evidence': 'Original'}]}], reason='Correct boundary')
|
||||
asyncio.run(apply_correction(session, proposal, 1, {1}, {}))
|
||||
assert event.state == 'pending' and event.revision == 2 and event.review_config == config
|
||||
enroll.assert_awaited_once_with(session, 99, 'events', event)
|
||||
output = package if keep_id else next(obj for obj in added if isinstance(obj, NewsSummary))
|
||||
assert output.review_config == config
|
||||
|
||||
|
||||
@pytest.mark.parametrize('tool', ['read_event', 'read_package'])
|
||||
def test_retry_refreshes_mutable_reads_and_caches_them_only_within_the_attempt(trading, monkeypatch, tool):
|
||||
from alphapulse.models.risk import NewsEvent, NewsSummary
|
||||
from alphapulse.news_risk import research
|
||||
|
||||
reader = research.Research(event={'id': 7, 'revision': 1})
|
||||
reader.restore([{'tool': tool, 'arguments': {'id': 2}, 'result': {'id': 2, 'revision': 1}}])
|
||||
assert not reader.read_event_ids and not reader.read_package_ids
|
||||
row = N(id=2, revision=2, title='Current', fact='Current', tags=[], result=None, replacement_ids=[], state='done')
|
||||
session = MagicMock()
|
||||
session.__aenter__ = AsyncMock(return_value=session)
|
||||
session.__aexit__ = AsyncMock(return_value=False)
|
||||
session.get = AsyncMock(side_effect=lambda model, *a, **kw: row if model in (NewsEvent, NewsSummary) else None)
|
||||
empty = MagicMock()
|
||||
empty.all.return_value = []
|
||||
empty.__iter__.return_value = iter([])
|
||||
session.scalars = AsyncMock(return_value=empty)
|
||||
storage = MagicMock(return_value=session)
|
||||
monkeypatch.setattr(research, 'SessionLocal', storage)
|
||||
|
||||
async def check():
|
||||
first = await reader.tool(tool, {'id': 2})
|
||||
assert first['revision'] == 2
|
||||
assert await reader.tool(tool, {'id': 2}) == first
|
||||
|
||||
asyncio.run(check())
|
||||
assert reader.counts['read'] == 1 and len(reader.records) == 1
|
||||
storage.assert_called_once_with()
|
||||
if tool == 'read_event':
|
||||
assert reader.read_event_versions == {2: 2}
|
||||
else:
|
||||
assert reader.read_package_ids == {2}
|
||||
|
||||
|
||||
def result(trading,package_ids=(1,2)):
|
||||
from alphapulse.news_risk.event_research import EventResult
|
||||
refs=[{'article_id':i,'quote':f'Original {i}'} for i in package_ids]
|
||||
return EventResult(id=7,title='Incident',summary='Summary',category='安全',entities=[],risk_stars=2,importance_stars=3,
|
||||
direction='unknown',verification='insufficient',confidence='low',reasoning='Unknown',uncertainty='Unknown',
|
||||
facts='Facts',timeline='Timeline',asset_impacts='Unknown',opinions='Unknown',counter_evidence='Unknown',watchpoints='Updates',
|
||||
claims=[{'statement':'Claim','verdict':'insufficient','evidence_refs':[]}],evidence_refs=refs,
|
||||
timeline_points=[{'package_id':i,'package_revision':1,'occurred_at':None,'time_precision':'unknown','time_text':'',
|
||||
'reported_at':None,'description':'Fact','verification':'partial','evidence_refs':[r]} for i,r in zip(package_ids,refs,strict=True)])
|
||||
|
||||
|
||||
@pytest.mark.parametrize('fault',['omitted','revision','wrong_member','invented_quote','direction'])
|
||||
def test_event_validation_covers_each_member_with_its_own_original(trading,fault):
|
||||
from alphapulse.news_risk.event_research import validate
|
||||
output=result(trading)
|
||||
packages=[{'id':i,'revision':1,'article_ids':[i]} for i in (1,2)]
|
||||
sources={i:{'content':f'Original {i}'} for i in (1,2)}
|
||||
if fault=='omitted':output.timeline_points.pop()
|
||||
if fault=='revision':output.timeline_points[0].package_revision=2
|
||||
if fault=='wrong_member':output.timeline_points[0].evidence_refs=[{'article_id':2,'quote':'Original 2'}]
|
||||
if fault=='invented_quote':output.evidence_refs[0].quote='Invented'
|
||||
if fault=='direction':output.direction='bullish'
|
||||
with pytest.raises(ValueError):validate(output,packages,sources)
|
||||
|
||||
|
||||
def test_event_schema_has_no_per_package_research_or_event_proposal(trading):
|
||||
from alphapulse.news_risk.event_research import EventResult
|
||||
fields=EventResult.model_fields
|
||||
assert 'event_proposal' not in fields and 'related_packages' not in fields
|
||||
assert 'package_correction' in fields and 'correction' in fields
|
||||
|
||||
|
||||
def test_self_event_and_supplied_package_reads_do_not_use_storage(trading,monkeypatch):
|
||||
from alphapulse.news_risk import research
|
||||
guard=MagicMock(side_effect=AssertionError('Supplied inputs must not trigger storage'))
|
||||
monkeypatch.setattr(research,'SessionLocal',guard)
|
||||
reader=research.Research(event={'id':7,'revision':2})
|
||||
reader.provided_packages={1:{'id':1,'revision':1,'fact':'Provided'}}
|
||||
async def check():
|
||||
assert (await reader.tool('read_event',{'id':7}))['status']=='already_provided'
|
||||
assert (await reader.tool('read_package',{'id':1}))['status']=='already_provided'
|
||||
asyncio.run(check());guard.assert_not_called()
|
||||
assert reader.counts['read']==0
|
||||
|
||||
|
||||
def test_grouping_budget_keeps_both_package_and_event_candidates(trading):
|
||||
from alphapulse.news_risk.pipeline import grouping_payload,Grouping,fits_context
|
||||
provider=N(max_tokens=1000,context_window=14000)
|
||||
candidates=[{'id':i,'fact':'p'*500} for i in range(50)]
|
||||
events=[{'id':i,'fact':'e'*500} for i in range(50)]
|
||||
payload,_=asyncio.run(grouping_payload(provider,'rules',{'id':1,'title':'Original','content':'Original'},candidates,events))
|
||||
assert payload['candidates'] and payload['events']
|
||||
assert len(payload['candidates'])<50 and len(payload['events'])<50
|
||||
assert fits_context(provider,'rules',payload,Grouping)
|
||||
|
||||
|
||||
def test_long_event_rejects_extraction_that_loses_an_original(trading,monkeypatch):
|
||||
from alphapulse.news_risk import event_research as module
|
||||
reader=N(fits=lambda *a:False,run=AsyncMock())
|
||||
monkeypatch.setattr(module,'extract_articles',AsyncMock(return_value=([{'facts':'One','evidence_refs':[{'article_id':1,'quote':'One'}]}],[{'input_tokens':9}])))
|
||||
with pytest.raises(ValueError,match='遗漏事件原文') as error:
|
||||
asyncio.run(module.generate(N(),'rules',{'articles':[{'id':1},{'id':2}],'packages':[]},reader))
|
||||
assert error.value.usage['parts']==[{'input_tokens':9}]
|
||||
reader.run.assert_not_awaited()
|
||||
|
||||
|
||||
def test_long_event_extracts_every_original_and_keeps_all_member_ids(trading,monkeypatch):
|
||||
from alphapulse.news_risk import event_research as module, pipeline
|
||||
from alphapulse.news_risk.research import Research
|
||||
provider=N(max_tokens=1500,context_window=15000)
|
||||
reader=Research()
|
||||
output=result(trading)
|
||||
articles=[{'id':i,'title':f'Original {i}','content':'x'*20000,'summary':''} for i in (1,2)]
|
||||
inputs=[{'id':i,'revision':1,'article_ids':[i]} for i in (1,2)]
|
||||
async def complete(provider,system,payload,schema):
|
||||
if payload['task']=='extract':
|
||||
return pipeline.Extraction(facts='Extracted',evidence_refs=[{'article_id':payload['article_id'],'quote':payload['content'][:1]}]),{'input_tokens':1}
|
||||
raise AssertionError('Small extracts do not need another merge')
|
||||
async def research(provider,system,payload,schema):
|
||||
assert {p['id'] for p in payload['packages']}=={1,2}
|
||||
assert {r['article_id'] for e in payload['extractions'] for r in e['evidence_refs']}=={1,2}
|
||||
assert 'articles' not in payload
|
||||
return output,{'rounds':[{'output_tokens':5}]}
|
||||
monkeypatch.setattr(pipeline,'complete',complete)
|
||||
monkeypatch.setattr(reader,'run',research)
|
||||
actual,usage=asyncio.run(module.generate(provider,'rules',{'id':7,'event':{'id':7},'articles':articles,'packages':inputs},reader))
|
||||
assert actual is output and len(usage['parts'])>=2 and usage['final']['rounds'][0]['output_tokens']==5
|
||||
|
||||
|
||||
def test_derived_reset_allowlist_protects_originals_and_configuration(trading):
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
path=Path(__file__).resolve().parents[2]/'scripts/reset_news_ai.py'
|
||||
spec=importlib.util.spec_from_file_location('reset_news_ai_test',path)
|
||||
module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module)
|
||||
names={model.__tablename__ for model in module.DERIVED}
|
||||
assert not names & {'news_items','news_revisions','risk_settings','ai_providers','skill_nodes','skill_versions','chat_sessions','economic_releases'}
|
||||
assert {'news_ai_events','news_ai_summaries','news_ai_event_evidence','news_ai_reprocess_jobs','risk_analyses'}<=names
|
||||
report={'real_models':True,'quality_accepted':True,'baseline_sha256':module.hashlib.sha256(b'baseline').hexdigest(),
|
||||
'baseline_packages':20,'originals':27,'failed':0,'unassigned':0}
|
||||
assert module.accepted(report,b'baseline')
|
||||
for changes in ({'real_models':False},{'quality_accepted':False},{'failed':1},{'unassigned':1},{'originals':26}):
|
||||
assert not module.accepted({**report,**changes},b'baseline')
|
||||
assert not module.accepted(report,b'other')
|
||||
|
||||
|
||||
def test_usage_accumulates_retries_without_double_counting(trading):
|
||||
from alphapulse.news_risk.service import accumulate_usage
|
||||
first={'parts':[{'input_tokens':4}],'final':{'output_tokens':2}}
|
||||
second={'rounds':[{'input_tokens':5}]}
|
||||
usage=accumulate_usage(None,first)
|
||||
usage=accumulate_usage(usage,second)
|
||||
assert usage=={'attempts':[first,second]}
|
||||
assert accumulate_usage({'input_tokens':7},second)=={'attempts':[{'input_tokens':7},second]}
|
||||
|
||||
|
||||
def test_updating_events_do_not_erase_last_successful_briefing(trading,monkeypatch):
|
||||
from alphapulse.news_risk import worker,events
|
||||
session=MagicMock()
|
||||
session.__aenter__=AsyncMock(return_value=session);session.__aexit__=AsyncMock(return_value=False)
|
||||
session.get=AsyncMock(return_value=N(enabled=True))
|
||||
session.scalar=AsyncMock(side_effect=[N(state='done'),None,None,7])
|
||||
monkeypatch.setattr(worker,'SessionLocal',lambda:session)
|
||||
monkeypatch.setattr(events,'reading_inputs',AsyncMock(return_value=[]))
|
||||
model=AsyncMock(side_effect=AssertionError('Wait for event research'))
|
||||
monkeypatch.setattr(worker,'configuration',model)
|
||||
assert asyncio.run(worker.outlook(prepare_only=True)) is None
|
||||
session.add.assert_not_called();model.assert_not_awaited()
|
||||
@@ -1,63 +0,0 @@
|
||||
"""Backfill selection and task progress use isolated model and storage boundaries."""
|
||||
import asyncio
|
||||
from types import SimpleNamespace as N
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
import pytest
|
||||
|
||||
|
||||
def test_progress_is_scoped_and_removed_on_failure():
|
||||
from alphapulse.news_risk.progress import execute, step, active, current
|
||||
async def check():
|
||||
async def task():
|
||||
step('查询资料',tool='read_news')
|
||||
assert len(active)==1 and current.get()['tool']=='read_news'
|
||||
raise ValueError('Synthetic')
|
||||
with pytest.raises(ValueError): await execute(('analysis',7),task)
|
||||
assert active=={} and current.get() is None
|
||||
asyncio.run(check())
|
||||
|
||||
|
||||
@pytest.mark.parametrize('published,revisions,expected', [(True,(2,2),True),(True,(1,2),False),(False,(2,2),False)])
|
||||
def test_all_member_events_require_published_results(trading,published,revisions,expected):
|
||||
from alphapulse.news_risk.backfill import classification
|
||||
package=N(id=1,state='done',result={'summary':'ok'} if published else None,published_revision=revisions[0],revision=revisions[1])
|
||||
session=N(scalar=AsyncMock(return_value=N(id=3,result={'packages':[]})),scalars=AsyncMock(return_value=N(all=lambda:[package])))
|
||||
actual,_,_=asyncio.run(classification(session,N(id=1,content_hash='current')))
|
||||
assert actual is expected
|
||||
|
||||
|
||||
def test_empty_active_memberships_are_incomplete(trading):
|
||||
from alphapulse.news_risk.backfill import classification
|
||||
session=N(scalar=AsyncMock(return_value=N(id=3,result={'old':True})),scalars=AsyncMock(return_value=N(all=lambda:[])))
|
||||
assert asyncio.run(classification(session,N(id=1,content_hash='current')))[0] is False
|
||||
|
||||
|
||||
def test_expired_preview_does_not_create_work(trading,monkeypatch):
|
||||
from alphapulse.news_risk import backfill
|
||||
monkeypatch.setattr(backfill.redis,'get',AsyncMock(return_value=None))
|
||||
session=MagicMock()
|
||||
with pytest.raises(ValueError,match='预览已过期'):asyncio.run(backfill.start(session,'expired'))
|
||||
session.add.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('modes',[[],['missing','missing'],['review','other'],['other']])
|
||||
def test_range_rejects_empty_duplicate_or_unknown_modes(trading,modes):
|
||||
from pydantic import ValidationError
|
||||
from alphapulse.api.risk import ReprocessIn
|
||||
with pytest.raises(ValidationError):
|
||||
ReprocessIn(start='2026-10-01T00:00:00Z',end='2026-11-01T00:00:00Z',modes=modes)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('failure',[False,True])
|
||||
def test_empty_or_invalid_outlook_poll_does_not_admit_a_task(trading,monkeypatch,failure):
|
||||
from alphapulse.news_risk import scheduler, worker
|
||||
session=MagicMock()
|
||||
session.__aenter__=AsyncMock(return_value=session); session.__aexit__=AsyncMock()
|
||||
session.get=AsyncMock(return_value=N(enabled=True,start_at='configured'))
|
||||
session.scalar=AsyncMock(return_value=None)
|
||||
monkeypatch.setattr(scheduler,'SessionLocal',lambda:session)
|
||||
monkeypatch.setattr(worker,'enqueue',AsyncMock())
|
||||
check=AsyncMock(side_effect=ValueError('Synthetic configuration failure') if failure else None,return_value=False)
|
||||
monkeypatch.setattr(worker,'outlook',check)
|
||||
assert asyncio.run(scheduler.candidate(set(),0)) is None
|
||||
check.assert_awaited_once_with(prepare_only=True)
|
||||
@@ -1,131 +0,0 @@
|
||||
"""Concurrency behavior uses controlled tasks and isolated I/O boundaries."""
|
||||
import asyncio
|
||||
from types import SimpleNamespace as N
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
import pytest
|
||||
|
||||
|
||||
def fake_session(settings):
|
||||
session = MagicMock()
|
||||
session.__aenter__ = AsyncMock(return_value=session)
|
||||
session.__aexit__ = AsyncMock()
|
||||
session.get = AsyncMock(return_value=settings)
|
||||
session.scalars = AsyncMock(return_value=N(all=lambda: []))
|
||||
session.commit = AsyncMock()
|
||||
session.execute = AsyncMock()
|
||||
return session
|
||||
|
||||
|
||||
def test_dispatch_refills_without_waiting_for_slow_tasks_and_respects_live_limit(trading, monkeypatch):
|
||||
from alphapulse.news_risk import scheduler
|
||||
async def check():
|
||||
settings = N(enabled=True, max_concurrency=5)
|
||||
session = fake_session(settings)
|
||||
monkeypatch.setattr(scheduler, 'SessionLocal', lambda: session)
|
||||
monkeypatch.setattr(scheduler.redis, 'set', AsyncMock())
|
||||
lock = N(scalar=AsyncMock(return_value=True), execute=AsyncMock(), commit=AsyncMock())
|
||||
releases = [asyncio.Event() for _ in range(10)]
|
||||
started = []
|
||||
async def task(index):
|
||||
started.append(index)
|
||||
await releases[index].wait()
|
||||
async def pick(active, cursor, allow):
|
||||
assert len(active) < settings.max_concurrency
|
||||
index = len(started)
|
||||
# Reserve before the spawned coroutine starts.
|
||||
index = max([key[1] for key in active] + [index-1]) + 1
|
||||
if index >= len(releases): return None
|
||||
return (('grouping', 'analysis', 'events', 'outlook')[index % 4], index), lambda: task(index), cursor
|
||||
monkeypatch.setattr(scheduler, 'candidate', pick)
|
||||
run = asyncio.create_task(scheduler.run(lock))
|
||||
async def until(predicate):
|
||||
async with asyncio.timeout(3):
|
||||
while not predicate(): await asyncio.sleep(.001)
|
||||
try:
|
||||
await until(lambda: len(started) == 5)
|
||||
releases[0].set(); releases[1].set()
|
||||
await until(lambda: len(started) == 7)
|
||||
assert all(not releases[i].is_set() for i in (2,3,4))
|
||||
settings.max_concurrency = 2
|
||||
for i in (2,3,4,5): releases[i].set()
|
||||
await until(lambda: len(started) == 8)
|
||||
assert not releases[6].is_set()
|
||||
assert len(started) == 8
|
||||
settings.enabled = False
|
||||
releases[6].set(); releases[7].set()
|
||||
await asyncio.sleep(.03)
|
||||
assert len(started) == 8
|
||||
settings.enabled = True; settings.max_concurrency = 5
|
||||
await until(lambda: len(started) == 10)
|
||||
finally:
|
||||
run.cancel()
|
||||
with pytest.raises(asyncio.CancelledError): await run
|
||||
assert lock.execute.await_count >= 1
|
||||
asyncio.run(check())
|
||||
|
||||
|
||||
def test_failed_tasks_release_capacity(trading):
|
||||
from alphapulse.news_risk.scheduler import Pool
|
||||
async def check():
|
||||
pool = Pool()
|
||||
async def fail(): raise RuntimeError('Synthetic failure')
|
||||
pool.start(('analysis', 1), fail)
|
||||
await pool.reap()
|
||||
assert not pool.tasks
|
||||
await pool.close()
|
||||
asyncio.run(check())
|
||||
|
||||
|
||||
@pytest.mark.parametrize('value', [0, -1, 1.5, '5', True, None])
|
||||
def test_concurrency_requires_a_positive_integer(trading, value):
|
||||
from pydantic import ValidationError
|
||||
from alphapulse.api.risk import ConcurrencyIn
|
||||
with pytest.raises(ValidationError): ConcurrencyIn(max_concurrency=value)
|
||||
|
||||
|
||||
def test_operational_update_does_not_pause_or_change_model_settings(trading, monkeypatch):
|
||||
from alphapulse.api import risk
|
||||
settings = N(enabled=True, max_concurrency=5, model='original')
|
||||
monkeypatch.setattr(risk, 'default_settings', AsyncMock(return_value=settings))
|
||||
asyncio.run(risk.save_concurrency(risk.ConcurrencyIn(max_concurrency=3), N(commit=AsyncMock(), add=MagicMock())))
|
||||
assert settings.enabled and settings.model == 'original' and settings.max_concurrency == 3
|
||||
|
||||
|
||||
@pytest.mark.parametrize('busy,enabled', [(True,False), (False,True)])
|
||||
def test_validation_never_bypasses_background_tasks(trading, monkeypatch, busy, enabled):
|
||||
from fastapi import HTTPException
|
||||
from alphapulse.api import risk
|
||||
from alphapulse.core import db
|
||||
gate = MagicMock()
|
||||
gate.__aenter__ = AsyncMock(return_value=gate); gate.__aexit__ = AsyncMock(return_value=None)
|
||||
gate.scalar = AsyncMock(return_value=not busy)
|
||||
gate.execute = AsyncMock(); gate.commit = AsyncMock()
|
||||
monkeypatch.setattr(db, 'engine', N(connect=lambda: gate))
|
||||
validate = AsyncMock()
|
||||
monkeypatch.setattr(risk, 'validate_enable', validate)
|
||||
session = N(get=AsyncMock(return_value=N(enabled=enabled)))
|
||||
with pytest.raises(HTTPException) as error:
|
||||
asyncio.run(risk.enable(risk.SettingsIn(),session))
|
||||
assert error.value.status_code == 409
|
||||
validate.assert_not_awaited()
|
||||
|
||||
|
||||
def test_history_completion_is_counted_independently_of_item_order(trading, monkeypatch):
|
||||
from alphapulse.news_risk import reprocess, worker
|
||||
from alphapulse.models.risk import RiskSettings, ArticleAnalysis, ReprocessJob
|
||||
from alphapulse.models import NewsItem
|
||||
job = N(id=4, state='running', position=0, settings={}, failures=[],
|
||||
items=[{'id':i,'hash':'current','state':'pending'} for i in (1,2,3)])
|
||||
article = N(id=30, state='done', error=None)
|
||||
session = fake_session(None)
|
||||
session.scalar = AsyncMock(side_effect=[job, None, article])
|
||||
session.get = AsyncMock(side_effect=lambda model,*a,**kw: {
|
||||
RiskSettings:N(enabled=True), ReprocessJob:job,
|
||||
ArticleAnalysis:article, NewsItem:N(id=3, content_hash='current')}[model])
|
||||
monkeypatch.setattr(reprocess, 'SessionLocal', lambda:session)
|
||||
monkeypatch.setattr(reprocess, 'frozen_configuration', AsyncMock())
|
||||
monkeypatch.setattr(worker, 'analyze', AsyncMock(return_value='done'))
|
||||
asyncio.run(reprocess.process_job(2))
|
||||
assert job.position == 1
|
||||
assert [i['state'] for i in job.items] == ['pending','pending','done']
|
||||
assert job.failures == []
|
||||
@@ -1,88 +1,56 @@
|
||||
"""News stage overrides and template refreshes preserve shared state."""
|
||||
"""Two-stage model overrides and bound Skills preserve shared configuration."""
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
from types import SimpleNamespace as N
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.parametrize('stage,field', [('grouping', 'reasoning_effort'), ('analysis', 'second_reasoning_effort'), ('outlook', 'third_reasoning_effort')])
|
||||
@pytest.mark.parametrize('stage', ['index', 'research'])
|
||||
@pytest.mark.parametrize('selected', [None, 'low', 'medium', 'high', 'xhigh', 'max'])
|
||||
@pytest.mark.parametrize('inherited', [None, 'medium'])
|
||||
def test_stage_effort_isolated_and_fingerprinted(trading, stage, field, selected, inherited):
|
||||
def test_stage_effort_isolated(trading, stage, selected, inherited):
|
||||
from alphapulse.models import AiProvider, SkillNode
|
||||
from alphapulse.news_risk import pipeline
|
||||
provider = SimpleNamespace(id=1, model='shared', reasoning_effort=inherited, max_tokens=8192, context_window=200000)
|
||||
node = SimpleNamespace(id=1, scope='news', kind='skill', enabled=True, parent_id=None, published_version_id=1)
|
||||
version = SimpleNamespace(version_no=1, content='Synthetic rules')
|
||||
from alphapulse.news_research.service import model_config
|
||||
shared = N(id=1, model='shared', reasoning_effort=inherited, max_tokens=8192, context_window=200000)
|
||||
node = N(id=1, scope='news', kind='skill', enabled=True, parent_id=None, published_version_id=1)
|
||||
version = N(version_no=1, content='Synthetic rules')
|
||||
class Session:
|
||||
async def get(self, model, identifier):
|
||||
if model is AiProvider: return SimpleNamespace(**vars(provider))
|
||||
if model is AiProvider: return N(**vars(shared))
|
||||
return node if model is SkillNode else version
|
||||
def expunge(self, obj): pass
|
||||
settings = SimpleNamespace(provider_id=1, second_provider_id=1, third_provider_id=1, model='luna', second_model='review', third_model='advanced',
|
||||
grouping_skill_id=1, analysis_skill_id=1, outlook_skill_id=1, reasoning_effort=None, second_reasoning_effort=None, third_reasoning_effort=None)
|
||||
config = {key: {'provider_id': 1, 'model': key+'-model', 'skill_id': 1,
|
||||
'reasoning_effort': selected if key == stage else None} for key in ['index', 'research']}
|
||||
async def check():
|
||||
_, _, _, before = await pipeline.configuration(Session(), settings, stage)
|
||||
setattr(settings, field, selected)
|
||||
actual, _, rules, fingerprint = await pipeline.configuration(Session(), settings, stage)
|
||||
actual, skill, rules = await model_config(Session(), config, stage)
|
||||
assert actual.model == stage+'-model'
|
||||
assert actual.reasoning_effort == (selected if selected is not None else inherited)
|
||||
assert rules['model_config']['selected_reasoning_effort'] == selected
|
||||
assert rules['model_config']['reasoning_effort'] == actual.reasoning_effort
|
||||
assert (before == fingerprint) is (selected is None)
|
||||
for other in {'grouping', 'analysis', 'outlook'} - {stage}:
|
||||
assert (await pipeline.configuration(Session(), settings, other))[0].reasoning_effort == inherited
|
||||
assert provider.model == 'shared' and provider.reasoning_effort == inherited
|
||||
assert rules['content'] == version.content and skill.endswith(version.content)
|
||||
other = 'research' if stage == 'index' else 'index'
|
||||
assert (await model_config(Session(), config, other))[0].reasoning_effort == inherited
|
||||
assert shared.model == 'shared' and shared.reasoning_effort == inherited
|
||||
asyncio.run(check())
|
||||
|
||||
|
||||
@pytest.mark.parametrize('field', ['reasoning_effort', 'second_reasoning_effort', 'third_reasoning_effort'])
|
||||
def test_effort_validation_and_default(trading, field):
|
||||
@pytest.mark.parametrize('stage', ['index', 'research'])
|
||||
def test_effort_validation_and_default(trading, stage):
|
||||
from pydantic import ValidationError
|
||||
from alphapulse.api.risk import SettingsIn
|
||||
assert getattr(SettingsIn(), field) is None
|
||||
with pytest.raises(ValidationError): SettingsIn(**{field: 'unsupported'})
|
||||
from alphapulse.news_research.api import ModelConfig
|
||||
config = {'provider_id': 1, 'model': stage, 'skill_id': 1}
|
||||
assert ModelConfig(**config).reasoning_effort is None
|
||||
with pytest.raises(ValidationError): ModelConfig(**config, reasoning_effort='unsupported')
|
||||
|
||||
|
||||
def test_template_refresh_appends_only_changed_drafts_and_preserves_publication(trading, monkeypatch):
|
||||
from alphapulse.ai import skills
|
||||
from alphapulse.api import risk
|
||||
from alphapulse.models import SkillNode
|
||||
from alphapulse.news_risk.pipeline import TEMPLATES
|
||||
nodes = [SimpleNamespace(id=i, name=name, scope='news', kind='skill', published_version_id=100+i) for i, (name, _) in enumerate(TEMPLATES.values(), 1)]
|
||||
versions = {n.id: [SimpleNamespace(content='User draft', version_no=1)] for n in nodes}
|
||||
@pytest.mark.parametrize('scope,enabled,published', [('shared', True, 1), ('news', False, 1), ('news', True, None)])
|
||||
def test_unpublished_disabled_and_shared_skills_never_reach_model(trading, scope, enabled, published):
|
||||
from alphapulse.models import AiProvider, SkillNode
|
||||
from alphapulse.news_research.service import model_config
|
||||
class Session:
|
||||
def __init__(self): self.current = None
|
||||
async def scalar(self, statement):
|
||||
if statement.column_descriptions[0]['entity'] is SkillNode:
|
||||
name = statement.compile().params['name_1']
|
||||
self.current = next(n for n in nodes if n.name == name)
|
||||
return self.current
|
||||
return versions[self.current.id][-1]
|
||||
commit = AsyncMock()
|
||||
async def add(session, node, content, source, note):
|
||||
versions[node.id].append(SimpleNamespace(content=content, version_no=len(versions[node.id])+1))
|
||||
monkeypatch.setattr(skills, 'add_version', add)
|
||||
async def check():
|
||||
session = Session()
|
||||
original = [n.published_version_id for n in nodes]
|
||||
first = await risk.templates(session)
|
||||
assert (await risk.templates(session, risk.TemplatesIn())).data == first.data
|
||||
assert all(len(v) == 1 for v in versions.values())
|
||||
refreshed = await risk.templates(session, risk.TemplatesIn(refresh_existing=True))
|
||||
assert refreshed.data == first.data
|
||||
assert all(len(v) == 2 and v[0].content == 'User draft' for v in versions.values())
|
||||
await risk.templates(session, risk.TemplatesIn(refresh_existing=True))
|
||||
assert all(len(v) == 2 for v in versions.values())
|
||||
assert [n.published_version_id for n in nodes] == original
|
||||
asyncio.run(check())
|
||||
|
||||
|
||||
@pytest.mark.parametrize('scope,kind', [('shared', 'skill'), ('news', 'folder')])
|
||||
def test_template_refresh_rejects_name_conflict(trading, scope, kind):
|
||||
from fastapi import HTTPException
|
||||
from alphapulse.api import risk
|
||||
session = SimpleNamespace(scalar=AsyncMock(return_value=SimpleNamespace(scope=scope, kind=kind)), commit=AsyncMock())
|
||||
with pytest.raises(HTTPException) as error:
|
||||
asyncio.run(risk.templates(session, risk.TemplatesIn(refresh_existing=True)))
|
||||
assert error.value.status_code == 409
|
||||
session.commit.assert_not_awaited()
|
||||
async def get(self, model, identifier):
|
||||
if model is AiProvider: return N(id=1, model='shared', reasoning_effort=None)
|
||||
assert model is SkillNode
|
||||
return N(scope=scope, kind='skill', enabled=enabled, published_version_id=published, parent_id=None)
|
||||
def expunge(self, obj): pass
|
||||
with pytest.raises(ValueError):
|
||||
asyncio.run(model_config(Session(), {'index': {'provider_id': 1, 'model': 'index', 'skill_id': 1}}, 'index'))
|
||||
@@ -1,118 +0,0 @@
|
||||
"""Event identity, timeline provenance and heterogeneous reading contracts."""
|
||||
import asyncio
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
import pytest
|
||||
|
||||
|
||||
def point(**changes):
|
||||
return {'occurred_at':None,'time_precision':'unknown','time_text':'未说明','reported_at':None,
|
||||
'description':'Exchange response','verification':'partial','package_id':1,'package_revision':2,
|
||||
'evidence_refs':[{'article_id':10,'quote':'response'}], **changes}
|
||||
|
||||
|
||||
@pytest.mark.parametrize('changes', [{'package_revision':3}, {'package_id':2}, {'evidence_refs':[{'article_id':10,'quote':'invented'}]}])
|
||||
def test_event_timeline_rejects_unknown_versions_and_invented_quotes(trading, changes):
|
||||
from alphapulse.news_risk.events import EventResult, validate_timeline
|
||||
result=EventResult(title='Incident',summary='Summary',category='security',importance_stars=4,risk_stars=3,
|
||||
uncertainty='Unconfirmed',opinions='Wait',counter_evidence='Unknown',watchpoints='Response',timeline_points=[point(**changes)])
|
||||
with pytest.raises(ValueError):
|
||||
validate_timeline(result,[{'id':1,'revision':2,'result':{'evidence_refs':[{'article_id':10,'quote':'response'}]}}])
|
||||
|
||||
|
||||
@pytest.mark.parametrize('changes', [{'occurred_at':'2026-11-01T12:00:00Z'}, {'time_precision':'day'},
|
||||
{'occurred_at':'2026-11-01T12:00:00','time_precision':'minute'}])
|
||||
def test_time_precision_never_invents_occurrence_time(trading, changes):
|
||||
from alphapulse.news_risk.events import TimelinePoint
|
||||
with pytest.raises(ValueError): TimelinePoint(**point(**changes))
|
||||
|
||||
|
||||
def test_reading_distinguishes_event_and_package_with_same_id(trading, monkeypatch):
|
||||
from alphapulse.news_risk import briefing
|
||||
inputs=[{'kind':kind,'id':1,'revision':1,'importance_stars':3,'result':{'summary':kind}} for kind in ('event','package')]
|
||||
async def complete(provider, system, payload, schema):
|
||||
if schema is briefing.Ranked:
|
||||
return schema(reading=[{'kind':p['kind'],'id':1,'revision':1,'priority':50,'reason':'Read'} for p in payload['packages']],notes='Notes'),{}
|
||||
trend=briefing.Trend(summary='Unknown',conditions='Unknown',counter_evidence='Unknown',watchpoints='Updates')
|
||||
return schema(summary='Summary',market='Unknown',short_term=trend,medium_term=trend,references=[]),{}
|
||||
monkeypatch.setattr(briefing,'complete',complete)
|
||||
result=asyncio.run(briefing.generate(SimpleNamespace(max_tokens=100,context_window=20000),'rules',inputs))
|
||||
assert {r['kind'] for r in result['reading']}=={'event','package'}
|
||||
|
||||
|
||||
def test_reading_uses_event_rating_and_never_package_fallback(trading):
|
||||
from alphapulse.news_risk.events import reading_inputs
|
||||
now=datetime.now(UTC)
|
||||
event=SimpleNamespace(id=1,updated_at=now,published_revision=2,tags=['security'],
|
||||
result={'title':'Incident','importance_stars':2,'category':'security'})
|
||||
session=AsyncMock()
|
||||
session.scalars.return_value=SimpleNamespace(all=lambda:[event])
|
||||
inputs=asyncio.run(reading_inputs(session,now-timedelta(hours=72)))
|
||||
assert len(inputs)==1 and inputs[0]['kind']=='event' and inputs[0]['importance_stars']==2
|
||||
assert session.scalars.await_count==1
|
||||
|
||||
|
||||
def test_reprocess_range_requires_order_and_timezone(trading):
|
||||
from alphapulse.api.risk import ReprocessIn
|
||||
with pytest.raises(ValueError): ReprocessIn(start='2026-11-01',end='2026-11-02')
|
||||
with pytest.raises(ValueError): ReprocessIn(start='2026-11-02T00:00:00Z',end='2026-11-01T00:00:00Z')
|
||||
|
||||
|
||||
def test_event_timeline_cannot_omit_a_member_or_invent_known_times(trading):
|
||||
from alphapulse.news_risk.events import EventResult, validate_timeline
|
||||
result=EventResult(title='Incident',summary='Summary',category='security',importance_stars=4,risk_stars=3,
|
||||
uncertainty='Unconfirmed',opinions='Wait',counter_evidence='Unknown',watchpoints='Response',timeline_points=[point()])
|
||||
packages=[{'id':i,'revision':2,'result':{'evidence_refs':[{'article_id':10,'quote':'response'}]}} for i in (1,2)]
|
||||
with pytest.raises(ValueError, match='覆盖每个组成包'): validate_timeline(result,packages)
|
||||
result.timeline_points[0].occurred_at=datetime.now(UTC)
|
||||
result.timeline_points[0].time_precision='minute'
|
||||
with pytest.raises(ValueError, match='已核验时间点'): validate_timeline(result,packages[:1])
|
||||
|
||||
|
||||
def test_unpublished_events_do_not_emit_independent_packages(trading):
|
||||
from alphapulse.news_risk.events import reading_inputs
|
||||
session=AsyncMock()
|
||||
session.scalars.return_value=SimpleNamespace(all=lambda:[])
|
||||
assert asyncio.run(reading_inputs(session,datetime.now(UTC)-timedelta(hours=72)))==[]
|
||||
sql=str(session.scalars.call_args.args[0])
|
||||
assert 'published_revision = news_ai_events.revision' in sql
|
||||
|
||||
|
||||
@pytest.mark.parametrize('withdraw_during_call', [False, True])
|
||||
def test_withdrawn_member_allows_publish_without_repeated_calls(trading, monkeypatch, withdraw_during_call):
|
||||
from unittest.mock import MagicMock
|
||||
from alphapulse.news_risk import events
|
||||
from alphapulse.models.risk import RiskSettings
|
||||
now = datetime.now(UTC)
|
||||
members = [SimpleNamespace(id=i, published_revision=2, published_at=now, replacement_ids=[],
|
||||
state='done', result={'evidence_refs':[{'article_id':10,'quote':'response'}]}) for i in (1,2,3)]
|
||||
if not withdraw_during_call: members[2].state = 'withdrawn'
|
||||
event = SimpleNamespace(id=1,revision=2,published_revision=1,result=None,review_config=None,state='pending')
|
||||
session = MagicMock()
|
||||
session.__aenter__ = AsyncMock(return_value=session)
|
||||
session.__aexit__ = AsyncMock(return_value=None)
|
||||
session.get = AsyncMock(side_effect=lambda model,*a,**kw: SimpleNamespace(enabled=True) if model is RiskSettings else event)
|
||||
session.scalar = AsyncMock(side_effect=lambda *a: event if event.state == 'pending' else None)
|
||||
session.scalars = AsyncMock(return_value=SimpleNamespace(all=lambda:members))
|
||||
session.commit = AsyncMock()
|
||||
session.rollback = AsyncMock(side_effect=AssertionError('Unexpected rollback'))
|
||||
async def complete(provider, system, payload, schema):
|
||||
members[2].state = 'withdrawn'
|
||||
return schema(title='Incident',summary='Summary',category='security',importance_stars=4,risk_stars=3,
|
||||
uncertainty='Unconfirmed',opinions='Wait',counter_evidence='Unknown',watchpoints='Response',
|
||||
timeline_points=[point(package_id=p['id']) for p in payload['packages']]), {}
|
||||
model = AsyncMock(side_effect=complete)
|
||||
monkeypatch.setattr(events,'SessionLocal',lambda:session)
|
||||
monkeypatch.setattr(events,'configuration',AsyncMock(return_value=(SimpleNamespace(),'rules',{},'fp')))
|
||||
monkeypatch.setattr(events,'complete',model)
|
||||
async def check():
|
||||
await events.organize()
|
||||
if withdraw_during_call:
|
||||
assert event.state == 'pending' and event.published_revision == 1
|
||||
await events.organize()
|
||||
await events.organize()
|
||||
asyncio.run(check())
|
||||
assert event.state == 'done' and event.published_revision == 2
|
||||
assert {p['package_id'] for p in event.result['timeline_points']} == {1,2}
|
||||
assert model.await_count == (2 if withdraw_during_call else 1)
|
||||
@@ -1,45 +1,15 @@
|
||||
"""Two-stage contracts reject ambiguous grouping and ungrounded conclusions."""
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pipeline(trading):
|
||||
from alphapulse.news_risk import pipeline
|
||||
from alphapulse.news_research import pipeline
|
||||
return pipeline
|
||||
|
||||
|
||||
def match(pipeline, **changes):
|
||||
return dict(event=dict(event_id=None,group_key='event',title='公告',fact='公告',reason='同一具体事项'),package_id=None, group_key='event', title='公告', category='macro', entities=['央行'],
|
||||
fact='公告', evidence='公告', urgent=False, tags=['央行'], content_kind='report', **changes)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('package_id,group_key', [(None, None), (1, 'event')])
|
||||
def test_grouping_target_is_exclusive(pipeline, package_id, group_key):
|
||||
data = match(pipeline)
|
||||
data.update(package_id=package_id, group_key=group_key)
|
||||
with pytest.raises(ValueError): pipeline.Match(**data)
|
||||
|
||||
|
||||
def test_all_domains_require_nonempty_distinct_packages(pipeline):
|
||||
item = pipeline.Match(**match(pipeline))
|
||||
with pytest.raises(ValueError, match='重复关联'):
|
||||
pipeline.Grouping(reason='政治公告', packages=[item, item])
|
||||
with pytest.raises(ValueError):
|
||||
pipeline.Grouping(reason='说明', packages=[])
|
||||
assert pipeline.Grouping(reason='文化说明', packages=[item]).packages
|
||||
|
||||
|
||||
@pytest.mark.parametrize('scope,enabled,published', [('shared', True, 1), ('news', False, 1), ('news', True, None)])
|
||||
def test_unpublished_disabled_and_shared_skills_never_reach_model(pipeline, scope, enabled, published):
|
||||
node = SimpleNamespace(scope=scope, kind='skill', published_version_id=published, enabled=enabled, parent_id=None)
|
||||
session = SimpleNamespace(get=AsyncMock(return_value=node))
|
||||
with pytest.raises(ValueError):
|
||||
asyncio.run(pipeline.configuration(session, SimpleNamespace(grouping_skill_id=1), 'grouping'))
|
||||
|
||||
|
||||
def test_evidence_cannot_cross_article_boundaries(pipeline):
|
||||
articles = {1: {'title': '央行维持利率'}, 2: {'title': '交易所暂停提现'}}
|
||||
with pytest.raises(ValueError, match='原文引用'):
|
||||
@@ -47,45 +17,10 @@ def test_evidence_cannot_cross_article_boundaries(pipeline):
|
||||
pipeline.validate_evidence([pipeline.Evidence(article_id=2, quote='暂停提现')], articles)
|
||||
|
||||
|
||||
def test_old_backfill_is_not_selected_by_ingestion_time(pipeline):
|
||||
from datetime import UTC, datetime
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.dialects.postgresql import dialect
|
||||
from alphapulse.models import NewsItem
|
||||
from alphapulse.news_risk.worker import eligible
|
||||
sql = str(select(NewsItem.id).where(eligible(datetime.now(UTC))).compile(dialect=dialect()))
|
||||
assert 'greatest(news_items.published_at, news_items.source_updated_at)' in sql
|
||||
assert 'first_seen_at' not in sql and 'last_seen_at' not in sql
|
||||
|
||||
|
||||
def event_result(pipeline, refs):
|
||||
return pipeline.EventResult(id=1, title='Synthetic event', summary='Synthetic summary', category='macro', entities=[],
|
||||
return pipeline.ReviewResult(id=1, title='Synthetic event', summary='Synthetic summary', category='macro', entities=[],
|
||||
risk_stars=1, importance_stars=4, direction='unknown', verification='insufficient', 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=[pipeline.Evidence.model_validate(r).model_dump() for r in refs])], facts='Synthetic', timeline='Synthetic', asset_impacts='Unknown', opinions='Unknown', counter_evidence='Unknown', watchpoints='Unknown', claims=[pipeline.Claim(statement='Synthetic', verdict='insufficient', evidence_refs=[])], related_packages=[], excluded_article_ids=[], evidence_refs=refs)
|
||||
|
||||
|
||||
def test_long_event_uses_bounded_layers_and_retains_all_sources(pipeline, monkeypatch):
|
||||
provider = SimpleNamespace(max_tokens=2000, context_window=12000)
|
||||
articles = [{'id': i+1, 'title': 'Synthetic', 'summary': '', 'content': 'b'*20000} for i in range(20)]
|
||||
calls = []
|
||||
|
||||
async def complete(provider, system, payload, schema):
|
||||
assert pipeline.fits_context(provider, system, payload, schema)
|
||||
calls.append(payload['task'])
|
||||
if payload['task'] == 'extract':
|
||||
return pipeline.Extraction(facts='x'*600, evidence_refs=[pipeline.Evidence(
|
||||
article_id=payload['article_id'], quote=payload['content'].strip()[0])]), {}
|
||||
refs = {r['article_id']: r for e in payload['extractions'] for r in e['evidence_refs']}
|
||||
if payload['task'] == 'consolidate':
|
||||
return pipeline.Extraction(facts='Consolidated facts and conflicts', evidence_refs=list(refs.values())), {}
|
||||
assert set(refs) == {a['id'] for a in articles}
|
||||
return event_result(pipeline, list(refs.values())), {}
|
||||
|
||||
monkeypatch.setattr(pipeline, 'complete', complete)
|
||||
result, usage = asyncio.run(pipeline.summarize_articles(provider, 'rules', articles, {'id': 1, 'fact': 'Synthetic event'}))
|
||||
assert result.id == 1 and calls[-1] == 'event'
|
||||
assert calls.count('extract') > len(articles) and calls.count('consolidate') > 1
|
||||
assert len(usage['parts']) == len(calls)-1
|
||||
timeline_points=[dict(occurred_at=None, time_precision='unknown', time_text='Unknown', reported_at=None, description='Synthetic', verification='insufficient', evidence_refs=[pipeline.Evidence.model_validate(r).model_dump() for r in refs])], facts='Synthetic', timeline='Synthetic', asset_impacts='Unknown', opinions='Unknown', counter_evidence='Unknown', watchpoints='Unknown', claims=[pipeline.Claim(statement='Synthetic', verdict='insufficient', evidence_refs=[])], excluded_article_ids=[], evidence_refs=refs)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('invalid', ['missing_source', 'rewritten_quote', 'no_progress'])
|
||||
@@ -105,131 +40,11 @@ def test_compaction_rejects_lost_provenance_and_stops_without_progress(pipeline,
|
||||
monkeypatch.setattr(pipeline, 'complete', complete)
|
||||
message = '未缩减输入' if invalid == 'no_progress' else '遗漏来源或改写'
|
||||
with pytest.raises(ValueError, match=message):
|
||||
asyncio.run(pipeline.compact_extractions(provider, 'rules', {'task': 'event', 'id': 1, 'extractions': extracts}, pipeline.EventResult))
|
||||
asyncio.run(pipeline.compact_extractions(provider, 'rules', {'task': 'event', 'id': 1, 'extractions': extracts}, pipeline.ReviewResult))
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
def test_large_candidate_catalog_does_not_extract_or_drop_short_article(pipeline, monkeypatch):
|
||||
provider = SimpleNamespace(max_tokens=2000, context_window=12000)
|
||||
article = {'id': 1, 'title': 'Synthetic', 'summary': '', 'content': 'Original text'}
|
||||
candidates = [{'id': i+1, 'title': 'x'*200, 'published_at': '2026-10-31T00:00:00Z', 'entities': ['entity']} for i in range(80)]
|
||||
complete = AsyncMock(side_effect=AssertionError('Candidate overflow must not trigger source extraction'))
|
||||
monkeypatch.setattr(pipeline, 'complete', complete)
|
||||
payload, usage = asyncio.run(pipeline.grouping_payload(provider, 'rules', article, candidates))
|
||||
assert payload['article'] == article and not usage
|
||||
assert 0 < len(payload['candidates']) < len(candidates)
|
||||
assert payload['candidates'] == candidates[:len(payload['candidates'])]
|
||||
assert pipeline.fits_context(provider, 'rules', payload, pipeline.Grouping)
|
||||
corrected = pipeline.pack_candidates(provider, 'rules', {**payload, 'correction': 'Invalid output '*100}, payload['candidates'])
|
||||
assert pipeline.fits_context(provider, 'rules', corrected, pipeline.Grouping)
|
||||
assert len(corrected['candidates']) < len(payload['candidates'])
|
||||
complete.assert_not_awaited()
|
||||
|
||||
|
||||
def test_long_grouping_source_is_compacted_before_candidates(pipeline, monkeypatch):
|
||||
provider = SimpleNamespace(max_tokens=2000, context_window=12000)
|
||||
article = {'id': 1, 'title': 'Synthetic', 'summary': '', 'content': 'b'*200000}
|
||||
calls = []
|
||||
|
||||
async def complete(provider, system, payload, schema):
|
||||
assert pipeline.fits_context(provider, system, payload, schema)
|
||||
calls.append(payload['task'])
|
||||
if payload['task'] == 'extract':
|
||||
return pipeline.Extraction(facts='x'*600, evidence_refs=[pipeline.Evidence(article_id=1, quote='b')]), {}
|
||||
return pipeline.Extraction(facts='Compressed facts', evidence_refs=[pipeline.Evidence(article_id=1, quote='b')]), {}
|
||||
|
||||
monkeypatch.setattr(pipeline, 'complete', complete)
|
||||
payload, _ = asyncio.run(pipeline.grouping_payload(provider, 'rules', article, [{'id': 2, 'title': 'Candidate'}]))
|
||||
assert 'consolidate' in calls
|
||||
assert payload['article_id'] == 1 and payload['candidates'][0]['id'] == 2
|
||||
assert pipeline.fits_context(provider, 'rules', payload, pipeline.Grouping)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('length', [50, 20000])
|
||||
def test_package_scope_survives_direct_and_extracted_roundup(pipeline, monkeypatch, length):
|
||||
provider = SimpleNamespace(max_tokens=2000, context_window=12000)
|
||||
article = {'id': 1, 'title': 'Roundup', 'summary': '', 'content': 'b'*length}
|
||||
observed = []
|
||||
async def complete(provider, system, payload, schema):
|
||||
observed.append(payload)
|
||||
if payload['task'] == 'extract':
|
||||
return schema(facts=payload['package']['fact'], evidence_refs=[pipeline.Evidence(article_id=1, quote='b')]), {}
|
||||
return event_result(pipeline, [pipeline.Evidence(article_id=1, quote='b')]).model_copy(update={'id': payload['id']}), {}
|
||||
monkeypatch.setattr(pipeline, 'complete', complete)
|
||||
for identifier, fact in [(1, '央行公布利率'), (2, '公司发布新产品')]:
|
||||
package = {'id': identifier, 'title': fact, 'fact': fact, 'tags': [fact], 'category': 'general', 'content_kind': 'report'}
|
||||
before = len(observed)
|
||||
result, _ = asyncio.run(pipeline.summarize_articles(provider, 'rules', [article], package))
|
||||
assert result.id == identifier
|
||||
assert all(payload['package'] == package for payload in observed[before:])
|
||||
assert observed[-1]['package']['fact'] == '公司发布新产品'
|
||||
|
||||
|
||||
def test_long_event_reserves_real_research_tools_before_extraction(pipeline, monkeypatch):
|
||||
from alphapulse.news_risk import research
|
||||
provider = SimpleNamespace(max_tokens=1000, context_window=12000, reasoning_effort=None)
|
||||
article = {'id': 1, 'title': 'Report', 'summary': '', 'content': ''}
|
||||
package = {'id': 1, 'fact': '单个事实'}
|
||||
while pipeline.fits_context(provider, 'rules', {'task': 'event', 'id': 1, 'package': package,
|
||||
'articles': [{**article, 'content': article['content']+'b'*100}]}, pipeline.EventResult):
|
||||
article['content'] += 'b'*100
|
||||
review = research.Research()
|
||||
assert not review.fits(provider, 'rules', {'task': 'event', 'id': 1, 'package': package, 'articles': [article]}, pipeline.EventResult)
|
||||
extracted = []
|
||||
async def complete(provider, system, payload, schema):
|
||||
extracted.append(payload)
|
||||
return pipeline.Extraction(facts='原文中的事实', evidence_refs=[pipeline.Evidence(article_id=1, quote='b')]), {}
|
||||
async def stream(provider, system, messages, tools, effort):
|
||||
assert review.fits_messages(provider, system, messages, pipeline.EventResult)
|
||||
assert 'extractions' in messages[0]['text']
|
||||
result = event_result(pipeline, [pipeline.Evidence(article_id=1, quote='b')])
|
||||
yield research.llm.Completion(tool_calls=[{'id': 'done', 'name': 'submit', 'arguments': result.model_dump()}])
|
||||
monkeypatch.setattr(pipeline, 'complete', complete)
|
||||
monkeypatch.setattr(research.llm, 'stream', stream)
|
||||
result, _ = asyncio.run(pipeline.summarize_articles(provider, 'rules', [article], package, review))
|
||||
assert result.id == 1 and extracted
|
||||
|
||||
|
||||
@pytest.mark.parametrize('has_related_fact', [True, False])
|
||||
def test_package_roundup_skips_empty_slices_but_requires_related_evidence(pipeline, monkeypatch, has_related_fact):
|
||||
provider = SimpleNamespace(max_tokens=1000, context_window=18000)
|
||||
article = {'id': 1, 'title': 'Roundup', 'summary': '',
|
||||
'content': ('RATE decision. ' if has_related_fact else '')+'Unrelated product announcement. '*1500}
|
||||
package = {'id': 1, 'fact': 'RATE decision'}
|
||||
system = pipeline.BASE+'\n'+pipeline.TEMPLATES['analysis'][1]
|
||||
extracted, submitted = [], []
|
||||
|
||||
async def complete(provider, system, payload, schema):
|
||||
if payload['task'] == 'extract':
|
||||
related = 'RATE' in payload['content']
|
||||
extracted.append(related)
|
||||
return schema.model_validate({'facts': 'RATE decision' if related else '',
|
||||
'evidence_refs': [{'article_id': 1, 'quote': 'RATE'}] if related else []}), {'input_tokens': 1}
|
||||
assert payload['task'] == 'event'
|
||||
submitted.append(payload)
|
||||
assert all(e['facts'] and e['evidence_refs'] for e in payload['extractions'])
|
||||
return event_result(pipeline, [pipeline.Evidence(article_id=1, quote='RATE')]), {}
|
||||
|
||||
monkeypatch.setattr(pipeline, 'complete', complete)
|
||||
if has_related_fact:
|
||||
result, usage = asyncio.run(pipeline.summarize_articles(provider, system, [article], package))
|
||||
assert result.evidence_refs[0].quote == 'RATE' and len(submitted) == 1
|
||||
assert len(usage['parts']) == len(extracted)
|
||||
assert len(submitted[0]['extractions']) == sum(extracted)
|
||||
else:
|
||||
with pytest.raises(ValueError, match='未提取到当前包相关证据'):
|
||||
asyncio.run(pipeline.summarize_articles(provider, system, [article], package))
|
||||
assert not submitted
|
||||
assert len(extracted) > 1 and False in extracted
|
||||
|
||||
|
||||
@pytest.mark.parametrize('facts,refs', [('Unsupported fact', []), ('', [{'article_id': 1, 'quote': 'Original'}])])
|
||||
def test_package_extraction_requires_facts_and_evidence_together(pipeline, facts, refs):
|
||||
with pytest.raises(ValueError, match='分段事实与证据必须同时为空或同时提供'):
|
||||
pipeline.PackageExtraction(facts=facts, evidence_refs=refs)
|
||||
|
||||
|
||||
def test_grouping_extraction_and_final_summary_still_require_evidence(pipeline, monkeypatch):
|
||||
def test_extraction_requires_original_evidence(pipeline, monkeypatch):
|
||||
async def complete(provider, system, payload, schema):
|
||||
return schema.model_validate({'facts': '', 'evidence_refs': []}), {}
|
||||
monkeypatch.setattr(pipeline, 'complete', complete)
|
||||
@@ -238,3 +53,24 @@ def test_grouping_extraction_and_final_summary_still_require_evidence(pipeline,
|
||||
[{'id': 1, 'title': 'Original', 'content': '', 'summary': ''}]))
|
||||
with pytest.raises(ValueError):
|
||||
event_result(pipeline, [])
|
||||
|
||||
|
||||
def test_long_extraction_preserves_complete_originals_and_source_ids(pipeline, monkeypatch):
|
||||
provider = SimpleNamespace(max_tokens=1000, context_window=12000)
|
||||
articles = [{'id': i, 'title': f'来源{i}', 'summary': '', 'content': '完整原文。' * 5000} for i in (1, 2)]
|
||||
fragments = {1: [], 2: []}
|
||||
|
||||
async def complete(provider, rules, payload, schema):
|
||||
assert pipeline.fits_context(provider, rules, payload, schema)
|
||||
identifier, content = payload['article_id'], payload['content']
|
||||
fragments[identifier].append(content)
|
||||
return pipeline.Extraction(facts='保留事实。', evidence_refs=[
|
||||
pipeline.Evidence(article_id=identifier, quote='完整原文。')]), {'input_tokens': 10}
|
||||
|
||||
monkeypatch.setattr(pipeline, 'complete', complete)
|
||||
extracts, usage = asyncio.run(pipeline.extract_articles(provider, '按原文提取事实。', articles))
|
||||
for article in articles:
|
||||
assert ''.join(fragments[article['id']]) == article['title']+'\n\n'+article['content']
|
||||
assert len(fragments[article['id']]) > 1
|
||||
assert len(usage) == len(extracts) == sum(len(parts) for parts in fragments.values())
|
||||
assert {ref['article_id'] for part in extracts for ref in part['evidence_refs']} == {1, 2}
|
||||
@@ -1,94 +0,0 @@
|
||||
"""Reprocessing checkpoints and retries isolate work by the failed stage."""
|
||||
import asyncio
|
||||
from types import SimpleNamespace as N
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
import pytest
|
||||
|
||||
|
||||
def session_mock():
|
||||
session = MagicMock()
|
||||
session.__aenter__ = AsyncMock(return_value=session)
|
||||
session.__aexit__ = AsyncMock(return_value=None)
|
||||
for name in ('get', 'scalar', 'scalars', 'commit', 'rollback', 'flush', 'execute'):
|
||||
setattr(session, name, AsyncMock())
|
||||
return session
|
||||
|
||||
|
||||
def test_pause_after_claim_does_not_count_old_success(trading, monkeypatch):
|
||||
from alphapulse.news_risk import reprocess, worker
|
||||
from alphapulse.models import NewsItem
|
||||
from alphapulse.models.risk import RiskSettings, ReprocessJob, ArticleAnalysis
|
||||
job = N(id=20, state='pending', position=0, items=[{'id':21,'hash':'abc'}], failures=[], settings={})
|
||||
old = N(id=25, state='done', error=None, result={'old_success':True})
|
||||
session = session_mock()
|
||||
session.scalar.side_effect = [None, None, job, None, old]
|
||||
session.get.side_effect = lambda model, *a, **kw: {
|
||||
RiskSettings:N(enabled=True), NewsItem:N(id=21,content_hash='abc'), ArticleAnalysis:old, ReprocessJob:job}[model]
|
||||
paused = session_mock()
|
||||
paused.get.return_value = N(enabled=False)
|
||||
model = AsyncMock(side_effect=AssertionError('Paused work must not call a model'))
|
||||
monkeypatch.setattr(reprocess, 'SessionLocal', lambda:session)
|
||||
monkeypatch.setattr(reprocess, 'frozen_configuration', AsyncMock(return_value=(N(),'rules',{})))
|
||||
monkeypatch.setattr(worker, 'SessionLocal', lambda:paused)
|
||||
monkeypatch.setattr(worker, 'complete', model)
|
||||
asyncio.run(reprocess.process_job())
|
||||
assert job.position == 0 and job.failures == []
|
||||
assert old.result == {'old_success':True}
|
||||
model.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('stage', ['analysis', 'events', 'outlook'])
|
||||
def test_downstream_failure_does_not_mark_unrelated_news_failed(trading, monkeypatch, stage):
|
||||
from alphapulse.news_risk import reprocess, events
|
||||
job = N(id=10,state='running',position=2,items=[{'id':11},{'id':12}],failures=[],
|
||||
settings={'configurations':{'outlook':{'rules':{}}}})
|
||||
failed = N(id=31,error='Synthetic failure',state='failed',quotes={})
|
||||
session = session_mock()
|
||||
session.get.return_value = N(enabled=True)
|
||||
session.scalar.side_effect = [None,None,job,None,None,failed]
|
||||
session.scalars.side_effect = [N(all=lambda:[failed] if stage == 'analysis' else []),
|
||||
N(all=lambda:[failed] if stage == 'events' else [])]
|
||||
monkeypatch.setattr(reprocess,'SessionLocal',lambda:session)
|
||||
monkeypatch.setattr(events,'reading_inputs',AsyncMock(return_value=[]))
|
||||
asyncio.run(reprocess.process_job())
|
||||
assert job.state == 'failed'
|
||||
assert job.failures == [{'stage':stage,'id':31,'error':'Synthetic failure'}]
|
||||
assert job.position == 2 and len(job.items) == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize('stage', ['grouping', 'analysis', 'events', 'outlook'])
|
||||
def test_retry_resumes_only_failed_stage(trading, stage):
|
||||
from alphapulse.api.risk import reprocess_retry
|
||||
job = N(id=10,state='failed',position=2,settings={},items=[{'id':11},{'id':12}],
|
||||
failures=[{'stage':stage,'id':11,'error':'Synthetic failure'}])
|
||||
failed = N(id=11,state='failed',quotes={})
|
||||
session = session_mock()
|
||||
session.get.return_value = job
|
||||
session.scalar.return_value = None
|
||||
session.scalars.side_effect = [N(all=lambda:[failed] if stage == 'outlook' else []),
|
||||
N(all=lambda:[failed] if stage == 'analysis' else []), N(all=lambda:[failed] if stage == 'events' else [])]
|
||||
asyncio.run(reprocess_retry(job.id,session))
|
||||
assert job.state == 'running' and job.failures == []
|
||||
if stage == 'grouping':
|
||||
assert job.items == [{'id':11, 'state':'pending'}] and job.position == 0
|
||||
else:
|
||||
assert job.items == [{'id':11},{'id':12}] and job.position == 2
|
||||
if stage == 'outlook': assert failed.quotes['retry_requested'] is True
|
||||
else: assert failed.state == 'pending' and failed.error is None
|
||||
|
||||
|
||||
def test_job_outlook_retry_waits_for_its_grouping_retry(trading, monkeypatch):
|
||||
from alphapulse.news_risk import reprocess, worker, events
|
||||
session = session_mock()
|
||||
session.get.return_value = N(enabled=True)
|
||||
session.scalar.side_effect = [None, N(quotes={'job_id':10}), N(id=10,position=0,settings={},items=[{'id':11}])]
|
||||
monkeypatch.setattr(worker,'SessionLocal',lambda:session)
|
||||
monkeypatch.setattr(events,'reading_inputs',AsyncMock(return_value=[]))
|
||||
current = AsyncMock(side_effect=AssertionError('Must not use current settings for a job retry'))
|
||||
frozen = AsyncMock(side_effect=AssertionError('Must wait for grouping'))
|
||||
monkeypatch.setattr(worker,'configuration',current)
|
||||
monkeypatch.setattr(reprocess,'frozen_configuration',frozen)
|
||||
asyncio.run(worker.outlook())
|
||||
current.assert_not_awaited()
|
||||
frozen.assert_not_awaited()
|
||||
session.add.assert_not_called()
|
||||
@@ -6,7 +6,7 @@ import pytest
|
||||
|
||||
|
||||
def test_private_and_mixed_dns_are_rejected(trading, monkeypatch):
|
||||
from alphapulse.news_risk.research import public_address
|
||||
from alphapulse.core.public_http import public_address
|
||||
async def check():
|
||||
loop = asyncio.get_running_loop()
|
||||
monkeypatch.setattr(loop, 'getaddrinfo', AsyncMock(return_value=[(None, None, None, None, ('127.0.0.1', 80))]))
|
||||
@@ -20,7 +20,7 @@ def test_private_and_mixed_dns_are_rejected(trading, monkeypatch):
|
||||
|
||||
@pytest.mark.parametrize('discovered', [False, True])
|
||||
def test_read_web_supports_direct_urls_and_budget(trading, monkeypatch, discovered):
|
||||
from alphapulse.news_risk import research as module
|
||||
from alphapulse.news_research import tools as module
|
||||
reader = module.Research()
|
||||
url = 'https://example.invalid/official'
|
||||
if discovered:
|
||||
@@ -37,36 +37,8 @@ def test_read_web_supports_direct_urls_and_budget(trading, monkeypatch, discover
|
||||
assert fetch.await_count == 1
|
||||
|
||||
|
||||
def test_briefing_covers_all_packages_across_batches(trading, monkeypatch):
|
||||
from alphapulse.news_risk import briefing
|
||||
provider = SimpleNamespace(max_tokens=100, context_window=7000)
|
||||
inputs = [{'id': i, 'revision': 2, 'importance_stars': 3, 'result': {'summary': 'x'*1600}} for i in range(15)]
|
||||
batches = []
|
||||
async def complete(provider, system, payload, schema):
|
||||
assert briefing.fits_context(provider, system, payload, schema)
|
||||
if schema is briefing.Ranked:
|
||||
batches.append(payload['packages'])
|
||||
return briefing.Ranked(reading=[briefing.Reading(id=p['id'], revision=2, priority=p['id'], reason='Read') for p in payload['packages']], notes='Notes'), {}
|
||||
trend = briefing.Trend(summary='Unknown', conditions='Unknown', counter_evidence='Unknown', watchpoints='Unknown')
|
||||
return briefing.Synthesis(summary='Unknown', market='Unknown', short_term=trend, medium_term=trend, references=[]), {}
|
||||
monkeypatch.setattr(briefing, 'complete', complete)
|
||||
result = asyncio.run(briefing.generate(provider, 'rules', inputs))
|
||||
assert len(batches) > 1
|
||||
assert [p['id'] for p in result['reading']] == list(reversed(range(15)))
|
||||
|
||||
|
||||
@pytest.mark.parametrize('ids', [[], [1, 1], [2]])
|
||||
def test_briefing_rejects_missing_duplicate_and_unknown_ids(trading, monkeypatch, ids):
|
||||
from alphapulse.news_risk import briefing
|
||||
async def complete(*args):
|
||||
return briefing.Ranked(reading=[briefing.Reading(id=i, revision=1, priority=50, reason='Read') for i in ids], notes='Notes'), {}
|
||||
monkeypatch.setattr(briefing, 'complete', complete)
|
||||
with pytest.raises(ValueError, match='遗漏、重复'):
|
||||
asyncio.run(briefing.generate(SimpleNamespace(max_tokens=100, context_window=10000), 'rules', [{'id': 1, 'revision': 1, 'importance_stars': 5}]))
|
||||
|
||||
|
||||
def test_high_importance_positive_package_is_valid(trading):
|
||||
from alphapulse.news_risk import pipeline
|
||||
from alphapulse.news_research import pipeline
|
||||
from test_news_pipeline import event_result
|
||||
result = event_result(pipeline, [pipeline.Evidence(article_id=1, quote='突破')])
|
||||
result.risk_stars = 0; result.importance_stars = 5
|
||||
@@ -76,7 +48,7 @@ def test_high_importance_positive_package_is_valid(trading):
|
||||
|
||||
def test_public_fetch_pins_address_and_rechecks_redirect(trading, monkeypatch):
|
||||
import httpx
|
||||
from alphapulse.news_risk import research
|
||||
from alphapulse.core import public_http as research
|
||||
calls = []
|
||||
original = httpx.AsyncClient
|
||||
async def resolve(url):
|
||||
@@ -96,7 +68,7 @@ def test_public_fetch_pins_address_and_rechecks_redirect(trading, monkeypatch):
|
||||
|
||||
|
||||
def test_failed_review_can_reuse_saved_evidence_without_new_io(trading):
|
||||
from alphapulse.news_risk.research import Research
|
||||
from alphapulse.news_research.tools import Research
|
||||
research = Research()
|
||||
record = {'tool': 'read_web', 'arguments': {'url': 'https://example.invalid'}, 'result': {'article_id': -1, 'content': 'Original'}}
|
||||
research.restore([record])
|
||||
@@ -107,7 +79,7 @@ def test_failed_review_can_reuse_saved_evidence_without_new_io(trading):
|
||||
def test_restored_news_is_sent_before_reusing_its_reference(trading, monkeypatch):
|
||||
import json
|
||||
from pydantic import BaseModel
|
||||
from alphapulse.news_risk import research
|
||||
from alphapulse.news_research import tools as research
|
||||
class Result(BaseModel):
|
||||
summary: str
|
||||
review = research.Research({'id': 42})
|
||||
@@ -135,7 +107,7 @@ def test_restored_news_is_sent_before_reusing_its_reference(trading, monkeypatch
|
||||
|
||||
def test_current_input_takes_precedence_over_restored_news(trading, monkeypatch):
|
||||
from pydantic import BaseModel
|
||||
from alphapulse.news_risk import research
|
||||
from alphapulse.news_research import tools as research
|
||||
class Result(BaseModel):
|
||||
summary: str
|
||||
review = research.Research()
|
||||
@@ -157,7 +129,7 @@ def test_current_input_takes_precedence_over_restored_news(trading, monkeypatch)
|
||||
|
||||
|
||||
def test_research_stops_after_eight_tool_rounds_and_retains_failures(trading, monkeypatch):
|
||||
from alphapulse.news_risk import research
|
||||
from alphapulse.news_research import tools as research
|
||||
from pydantic import BaseModel
|
||||
class Result(BaseModel):
|
||||
summary: str
|
||||
@@ -177,135 +149,8 @@ def test_research_stops_after_eight_tool_rounds_and_retains_failures(trading, mo
|
||||
assert all('error' in record['result'] for record in review.records)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('mode,created', [('unchanged', False), ('ordinary_wait', False), ('ordinary_due', True), ('urgent_wait', False), ('urgent_due', True), ('expired', True)])
|
||||
def test_reading_updates_only_for_changes_and_respects_aggregation(trading, monkeypatch, mode, created):
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from alphapulse.news_risk import worker, events
|
||||
now = datetime.now(UTC)
|
||||
event = SimpleNamespace(id=1, published_revision=2, importance_stars=5 if mode.startswith('urgent') else 3,
|
||||
category='politics', tags=['政策'], result={'summary': '当前说明'}, analyzed_at=now)
|
||||
semantic = [{'kind': 'package', 'id': event.id, 'importance_stars': event.importance_stars, 'result': event.result}]
|
||||
fingerprint = worker.briefing.fingerprint(semantic, {}) if mode == 'unchanged' else 'previous'
|
||||
latest = SimpleNamespace(created_at=now-timedelta(seconds=30 if mode == 'urgent_wait' else 120), quotes={'fingerprint': fingerprint})
|
||||
if mode in ('ordinary_due', 'expired'): latest.quotes['dirty_since'] = (now-timedelta(minutes=16)).isoformat()
|
||||
session = AsyncMock()
|
||||
session.__aenter__.return_value = session
|
||||
session.get.return_value = SimpleNamespace(enabled=True)
|
||||
session.scalar.side_effect = [latest, None, None] + ([None] if mode == 'expired' else [])
|
||||
session.scalars.return_value = SimpleNamespace(all=lambda: [] if mode == 'expired' else [event])
|
||||
monkeypatch.setattr(events, 'reading_inputs', AsyncMock(return_value=[] if mode == 'expired' else [{'kind': 'package', 'id': event.id, 'revision': event.published_revision, 'importance_stars': event.importance_stars, 'category': event.category, 'tags': event.tags, 'result': event.result}]))
|
||||
added = []
|
||||
session.add = added.append
|
||||
monkeypatch.setattr(worker, 'SessionLocal', lambda: session)
|
||||
monkeypatch.setattr(worker, 'configuration', AsyncMock(return_value=(SimpleNamespace(model='advanced'), 'rules', {}, 'rules-key')))
|
||||
monkeypatch.setattr(worker.briefing, 'generate', AsyncMock(return_value={'reading': [], 'synthesis': None, 'usage': []}))
|
||||
asyncio.run(worker.outlook())
|
||||
assert bool(added) is created
|
||||
if created: assert added[0].state == 'done'
|
||||
if mode == 'ordinary_wait': assert latest.quotes['dirty_since']
|
||||
|
||||
|
||||
def test_truncated_reading_batches_split_until_all_versions_are_ranked(trading, monkeypatch):
|
||||
from alphapulse.news_risk import briefing
|
||||
from alphapulse.news_risk.service import OutputTruncated
|
||||
inputs = [{'id': i, 'revision': 3, 'importance_stars': 3, 'result': {'summary': 'Short'}} for i in range(1, 41)]
|
||||
calls = []
|
||||
async def complete(provider, system, payload, schema):
|
||||
calls.append(payload)
|
||||
if schema is briefing.Ranked:
|
||||
if len(payload['packages']) > 5: raise OutputTruncated('Synthetic output limit', {'output_tokens': 256})
|
||||
return briefing.Ranked(reading=[briefing.Reading(id=p['id'], revision=3, priority=100-p['id'], reason='Read') for p in payload['packages']], notes='Notes'), {'output_tokens': 100}
|
||||
trend = briefing.Trend(summary='Unknown', conditions='Unknown', counter_evidence='Unknown', watchpoints='Unknown')
|
||||
return briefing.Synthesis(summary='Unknown', market='Unknown', short_term=trend, medium_term=trend, references=[]), {}
|
||||
monkeypatch.setattr(briefing, 'complete', complete)
|
||||
result = asyncio.run(briefing.generate(SimpleNamespace(max_tokens=256, context_window=200000), 'rules', inputs))
|
||||
assert [r['id'] for r in result['reading']] == list(range(1, 41))
|
||||
assert all(r['revision'] == 3 for r in result['reading'])
|
||||
assert len(result['usage']) == len(calls)
|
||||
assert any(u.get('output_tokens') == 256 for u in result['usage'])
|
||||
|
||||
|
||||
def test_single_package_truncation_is_bounded_and_does_not_retry_other_errors(trading, monkeypatch):
|
||||
from alphapulse.news_risk import briefing
|
||||
from alphapulse.news_risk.service import OutputTruncated
|
||||
complete = AsyncMock(side_effect=OutputTruncated('Synthetic output limit'))
|
||||
monkeypatch.setattr(briefing, 'complete', complete)
|
||||
with pytest.raises(OutputTruncated, match='单包排序'):
|
||||
asyncio.run(briefing.generate(SimpleNamespace(max_tokens=256, context_window=200000), 'rules', [{'id': 1, 'revision': 1, 'importance_stars': 3}]))
|
||||
assert complete.await_count == 1
|
||||
complete = AsyncMock(side_effect=ValueError('Malformed ranking'))
|
||||
monkeypatch.setattr(briefing, 'complete', complete)
|
||||
with pytest.raises(ValueError, match='Malformed'):
|
||||
asyncio.run(briefing.generate(SimpleNamespace(max_tokens=256, context_window=200000), 'rules', [{'id': 1, 'revision': 1}, {'id': 2, 'revision': 1}]))
|
||||
assert complete.await_count == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize('split', [False, True])
|
||||
def test_terminal_truncation_keeps_completed_and_split_attempt_usage(trading, monkeypatch, split):
|
||||
from alphapulse.news_risk import briefing
|
||||
from alphapulse.news_risk.service import OutputTruncated
|
||||
calls = []
|
||||
async def complete(provider, system, payload, schema):
|
||||
calls.append(payload)
|
||||
if split and len(calls) == 1:
|
||||
raise OutputTruncated('Split batch', {'output_tokens': 30})
|
||||
if len(calls) == (2 if split else 1):
|
||||
return briefing.Ranked(reading=[briefing.Reading(**p, priority=50, reason='Read') for p in payload['expected']],
|
||||
notes='Notes'), {'output_tokens': 120}
|
||||
raise OutputTruncated('Single item', {'output_tokens': 20})
|
||||
monkeypatch.setattr(briefing, 'complete', complete)
|
||||
inputs = [{'id': i, 'revision': 1, 'importance_stars': 3} for i in range(2 if split else 13)]
|
||||
with pytest.raises(OutputTruncated, match='单包排序') as error:
|
||||
asyncio.run(briefing.generate(SimpleNamespace(max_tokens=1000, context_window=200000), 'rules', inputs, ranking_only=True))
|
||||
assert [u['output_tokens'] for u in error.value.usage['rounds']] == ([30, 120, 20] if split else [120, 20])
|
||||
assert len(calls) == (3 if split else 2)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('change,expected', [('links', False), ('verdict', True), ('risk', True), ('fact', True)])
|
||||
def test_reposts_do_not_trigger_advanced_calls_but_review_changes_do(trading, monkeypatch, change, expected):
|
||||
from copy import deepcopy
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from alphapulse.news_risk import worker, briefing
|
||||
now = datetime.now(UTC)
|
||||
inputs = [{'id': 1, 'revision': 1, 'importance_stars': 5, 'result': {'facts': '公开说明', 'risk_stars': 1,
|
||||
'evidence_refs': [{'article_id': 1, 'quote': '公开说明'}],
|
||||
'claims': [{'statement': '公开说明', 'verdict': 'partial', 'evidence_refs': [{'article_id': 1, 'quote': '公开说明'}]}]}}]
|
||||
semantic = briefing.semantic_packages(inputs)
|
||||
latest = SimpleNamespace(created_at=now-timedelta(minutes=2), quotes={'fingerprint': briefing.fingerprint(semantic, {}),
|
||||
'important': {'1': briefing.fingerprint(semantic[0], {})}})
|
||||
updated = deepcopy(inputs)
|
||||
updated[0]['revision'] = 2
|
||||
updated[0]['result']['evidence_refs'].append({'article_id': 2, 'quote': '公开说明'})
|
||||
updated[0]['result']['claims'][0]['evidence_refs'].append({'article_id': 2, 'quote': '公开说明'})
|
||||
if change == 'verdict': updated[0]['result']['claims'][0]['verdict'] = 'supported'
|
||||
if change == 'risk': updated[0]['result']['risk_stars'] = 5
|
||||
if change == 'fact': updated[0]['result']['facts'] = '官方修订说明'
|
||||
assert (briefing.semantic_packages(updated) != semantic) is expected
|
||||
session = AsyncMock(); session.__aenter__.return_value = session
|
||||
session.get.return_value = SimpleNamespace(enabled=True); session.scalar.side_effect = [latest, None, None]
|
||||
session.scalars.return_value = SimpleNamespace(all=lambda: [SimpleNamespace(id=1, published_revision=2,
|
||||
importance_stars=5, category='politics', tags=[], result=updated[0]['result'], analyzed_at=now)])
|
||||
from alphapulse.news_risk import events
|
||||
monkeypatch.setattr(events, 'reading_inputs', AsyncMock(return_value=[{**p, 'kind': 'package', 'category': 'politics', 'tags': []} for p in updated]))
|
||||
session.add = lambda value: None
|
||||
generated = AsyncMock(return_value={'reading': [], 'synthesis': None, 'usage': []})
|
||||
monkeypatch.setattr(worker, 'SessionLocal', lambda: session)
|
||||
monkeypatch.setattr(worker, 'configuration', AsyncMock(return_value=(SimpleNamespace(model='advanced'), 'rules', {}, 'key')))
|
||||
monkeypatch.setattr(briefing, 'generate', generated)
|
||||
asyncio.run(worker.outlook())
|
||||
assert bool(generated.await_count) is expected
|
||||
|
||||
|
||||
def test_current_package_read_needs_no_io_or_budget(trading):
|
||||
from alphapulse.news_risk.research import Research
|
||||
research = Research({'id': 42, 'revision': 3})
|
||||
result = asyncio.run(research.tool('read_package', {'id': 42}))
|
||||
assert result['status'] == 'already_provided' and result['revision'] == 3
|
||||
assert research.counts['read'] == 0 and not research.records
|
||||
|
||||
|
||||
def test_provided_original_is_reused_without_io(trading, monkeypatch):
|
||||
from alphapulse.news_risk import research
|
||||
from alphapulse.news_research import tools as research
|
||||
from pydantic import BaseModel
|
||||
class Result(BaseModel):
|
||||
summary: str
|
||||
@@ -326,7 +171,7 @@ def test_provided_original_is_reused_without_io(trading, monkeypatch):
|
||||
|
||||
|
||||
def test_web_unavailable_short_circuits_only_this_review(trading, monkeypatch):
|
||||
from alphapulse.news_risk import research
|
||||
from alphapulse.news_research import tools as research
|
||||
session = AsyncMock()
|
||||
session.__aenter__.return_value = session
|
||||
monkeypatch.setattr(research, 'SessionLocal', lambda: session)
|
||||
@@ -343,90 +188,8 @@ def test_web_unavailable_short_circuits_only_this_review(trading, monkeypatch):
|
||||
assert research.Research().web_unavailable is None
|
||||
|
||||
|
||||
def test_ranking_repairs_identity_once_and_accounts_usage(trading, monkeypatch):
|
||||
from alphapulse.news_risk import briefing
|
||||
calls = []
|
||||
async def complete(provider, system, payload, schema):
|
||||
calls.append(payload)
|
||||
ids = [] if len(calls) == 1 else [p['id'] for p in payload['packages']]
|
||||
return briefing.Ranked(reading=[briefing.Reading(id=i, revision=2, priority=50, reason='Read') for i in ids], notes='Notes'), {'output_tokens': 10}
|
||||
monkeypatch.setattr(briefing, 'complete', complete)
|
||||
result = asyncio.run(briefing.generate(SimpleNamespace(max_tokens=100, context_window=10000), '',
|
||||
[{'id': 1, 'revision': 2, 'importance_stars': 3}], ranking_only=True))
|
||||
assert len(calls) == 2 and calls[1]['correction']['errors']['missing'] == [('package', 1, 2)]
|
||||
assert len(result['usage']) == 2 and result['reading'][0]['id'] == 1
|
||||
|
||||
|
||||
def test_ranking_missing_revision_repaired_with_explicit_identity(trading, monkeypatch):
|
||||
from alphapulse.news_risk import briefing
|
||||
from alphapulse.news_risk.service import SubmissionFailure
|
||||
from pydantic import ValidationError
|
||||
calls = []
|
||||
async def complete(provider, system, payload, schema):
|
||||
calls.append(payload)
|
||||
if len(calls) == 1:
|
||||
try: briefing.Ranked.model_validate({'reading': [{'id': 1, 'priority': 50, 'reason': 'Read'}], 'notes': 'Notes'})
|
||||
except ValidationError as error: raise SubmissionFailure('Missing revision', {'output_tokens': 10}) from error
|
||||
return briefing.Ranked(reading=[briefing.Reading(**payload['expected'][0], priority=50, reason='Read')], notes='Notes'), {}
|
||||
monkeypatch.setattr(briefing, 'complete', complete)
|
||||
result = asyncio.run(briefing.generate(SimpleNamespace(max_tokens=100, context_window=10000), '',
|
||||
[{'id': 1, 'revision': 2, 'importance_stars': 3}], ranking_only=True))
|
||||
assert len(calls) == 2 and len(result['usage']) == 2
|
||||
|
||||
|
||||
def test_ranking_batches_are_bounded_and_cover_every_identity(trading, monkeypatch):
|
||||
from alphapulse.news_risk import briefing
|
||||
counts = []
|
||||
async def complete(provider, system, payload, schema):
|
||||
counts.append(len(payload['packages']))
|
||||
return briefing.Ranked(reading=[briefing.Reading(**p, priority=50, reason='Read') for p in payload['expected']], notes='Notes'), {}
|
||||
monkeypatch.setattr(briefing, 'complete', complete)
|
||||
result = asyncio.run(briefing.generate(SimpleNamespace(max_tokens=1000, context_window=200000), '',
|
||||
[{'id': i, 'revision': 1, 'importance_stars': 3} for i in range(25)], ranking_only=True))
|
||||
assert counts == [12, 12, 1] and len(result['reading']) == 25
|
||||
|
||||
|
||||
def test_package_search_excludes_current_package(trading, monkeypatch):
|
||||
from alphapulse.news_risk import research
|
||||
from sqlalchemy.dialects.postgresql import dialect
|
||||
statements = []
|
||||
class Session:
|
||||
async def __aenter__(self): return self
|
||||
async def __aexit__(self, *args): pass
|
||||
async def scalars(self, statement):
|
||||
statements.append(statement.compile(dialect=dialect()))
|
||||
return SimpleNamespace(all=lambda: [])
|
||||
monkeypatch.setattr(research, 'SessionLocal', Session)
|
||||
review = research.Research({'id': 42})
|
||||
asyncio.run(review.tool('search_packages', {'query': 'policy', 'keywords': ['policy']}))
|
||||
assert 'news_ai_summaries.id !=' in str(statements[0]) and 42 in statements[0].params.values()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('result,expected', [({'title': '媒体称11月2日拟开会'}, '媒体称11月2日拟开会'), (None, '明天开会')])
|
||||
def test_package_api_returns_fact_title_without_legacy_rating(trading, result, expected):
|
||||
from alphapulse.api.risk import package_out
|
||||
from alphapulse.models.risk import NewsSummary
|
||||
session = AsyncMock()
|
||||
session.scalar.return_value = 1
|
||||
session.get.return_value = None
|
||||
package = NewsSummary(id=1, title='明天开会', result=result, replacement_ids=[])
|
||||
assert asyncio.run(package_out(package, session))['title'] == '明天开会'
|
||||
|
||||
|
||||
def test_ranking_invalid_identity_stops_after_one_repair(trading, monkeypatch):
|
||||
from alphapulse.news_risk import briefing
|
||||
from alphapulse.news_risk.service import SubmissionFailure
|
||||
complete = AsyncMock(return_value=(briefing.Ranked(reading=[], notes='Notes'), {'output_tokens': 10}))
|
||||
monkeypatch.setattr(briefing, 'complete', complete)
|
||||
with pytest.raises(SubmissionFailure, match='missing') as error:
|
||||
asyncio.run(briefing.generate(SimpleNamespace(max_tokens=100, context_window=10000), '',
|
||||
[{'id': 1, 'revision': 2, 'importance_stars': 3}], ranking_only=True))
|
||||
assert complete.await_count == 2
|
||||
assert len(error.value.usage['failed_batch']['rounds']) == 2
|
||||
|
||||
|
||||
def test_direct_web_read_preserves_public_reader_rejection(trading, monkeypatch):
|
||||
from alphapulse.news_risk import research as module
|
||||
from alphapulse.news_research import tools as module
|
||||
reader = module.Research()
|
||||
fetch = AsyncMock(side_effect=ValueError('不允许访问非公开地址'))
|
||||
monkeypatch.setattr(module, 'fetch_public', fetch)
|
||||
|
||||
@@ -246,7 +246,7 @@ def test_retry_refreshes_unavailable_search_but_reuses_successful_results(
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock
|
||||
from alphapulse.news_research.service import Research
|
||||
from alphapulse.news_risk import research as tools
|
||||
from alphapulse.news_research import tools as tools
|
||||
|
||||
session = AsyncMock()
|
||||
session.__aenter__.return_value = session
|
||||
@@ -634,7 +634,7 @@ def test_long_event_compaction_uses_final_research_context(trading, monkeypatch,
|
||||
from alphapulse.news_research import service
|
||||
from alphapulse.news_research.contracts import RESEARCH_SKILL, ResearchResult
|
||||
from alphapulse.news_research.models import Version
|
||||
from alphapulse.news_risk import pipeline
|
||||
from alphapulse.news_research import pipeline
|
||||
|
||||
provider = N(max_tokens=32000, context_window=250000)
|
||||
skill = pipeline.BASE + "\n" + RESEARCH_SKILL
|
||||
|
||||
@@ -1,187 +1,22 @@
|
||||
"""Risk evidence, identity and model boundaries use synthetic inputs only."""
|
||||
import asyncio
|
||||
from datetime import UTC, datetime
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def risk(trading):
|
||||
from alphapulse.news_risk import service
|
||||
from alphapulse.news_research import model as service
|
||||
return service
|
||||
|
||||
|
||||
def label(risk, **values):
|
||||
return risk.Label(id=1, title='交易所安全事件', summary='媒体报道交易所安全事件', category='security',
|
||||
entities=['Exchange'], stars=5, direction='bearish', verification='unverified', confidence='low',
|
||||
reasoning='重大资金安全风险', uncertainty='规模尚待核实', evidence='安全事件', event_id=None, **values)
|
||||
|
||||
|
||||
def test_risk_start_is_beijing_midnight(risk):
|
||||
assert risk.START == datetime(2026, 10, 25, 16, tzinfo=UTC)
|
||||
|
||||
|
||||
def test_extreme_unverified_event_preserves_uncertainty(risk):
|
||||
item = label(risk)
|
||||
risk.validate_labels(risk.Labels(items=[item]), {1: {'title': '安全事件'}}, set())
|
||||
assert item.stars == 5 and item.verification == 'unverified'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('evidence', ['安全事件', ' 安全事件\n', '“安全事件”', '「安全事件」', '‘安全事件’', '"安全事件"', "'安全事件'"])
|
||||
def test_evidence_presentation_quotes_preserve_literal_source(risk, evidence):
|
||||
item = label(risk).model_copy(update={'evidence': evidence})
|
||||
risk.validate_labels(risk.Labels(items=[item]), {1: {'content': '媒体报道安全事件,规模待核实。'}}, set())
|
||||
assert item.evidence == '安全事件'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('evidence', ['“资产已被盗”', '安全…事件', 'security incident', ' ', '“”', '标题\n正文'])
|
||||
def test_evidence_must_be_nonempty_and_from_one_source_field(risk, evidence):
|
||||
item = label(risk).model_copy(update={'evidence': evidence})
|
||||
with pytest.raises(ValueError, match='模型证据并非输入原文引用'):
|
||||
risk.validate_labels(risk.Labels(items=[item]), {1: {'title': '标题', 'summary': '正文', 'content': '安全事件'}}, set())
|
||||
|
||||
|
||||
def test_evidence_retains_quotes_present_in_source(risk):
|
||||
item = label(risk).model_copy(update={'evidence': '“安全事件”'})
|
||||
risk.validate_labels(risk.Labels(items=[item]), {1: {'content': '媒体称这是“安全事件”。'}}, set())
|
||||
assert item.evidence == '“安全事件”'
|
||||
|
||||
|
||||
def mock_enable(risk, monkeypatch, valid=True, verification='reported', direction='neutral'):
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from alphapulse.core import db
|
||||
gate = MagicMock()
|
||||
gate.__aenter__ = AsyncMock(return_value=gate)
|
||||
gate.__aexit__ = AsyncMock(return_value=None)
|
||||
gate.scalar = AsyncMock(return_value=True)
|
||||
gate.execute = AsyncMock()
|
||||
gate.commit = AsyncMock()
|
||||
monkeypatch.setattr(db, "engine", SimpleNamespace(connect=lambda: gate))
|
||||
from alphapulse.news_risk import pipeline, briefing
|
||||
from alphapulse.api import risk as api
|
||||
provider = SimpleNamespace(model='news-only', max_tokens=1000, context_window=200000)
|
||||
session = SimpleNamespace(get=AsyncMock(return_value=None), merge=AsyncMock(), commit=AsyncMock())
|
||||
monkeypatch.setattr(pipeline, 'configuration', AsyncMock(return_value=(provider, 'rules', {}, 'fingerprint')))
|
||||
async def complete(provider, system, payload, schema):
|
||||
if schema is pipeline.Grouping:
|
||||
return pipeline.Grouping( reason='财经维护通知', packages=[pipeline.Match(event=dict(event_id=None,group_key='maintenance',title='维护',fact='维护',reason='维护通知'),package_id=None,
|
||||
group_key='maintenance', title='维护', category='company', entities=['示例公司'], fact='维护',
|
||||
evidence=payload['article']['summary'] if valid else '捏造损失', urgent=False, tags=['维护'], content_kind='statement')]), {}
|
||||
if schema is risk.Outlook:
|
||||
return risk.Outlook(windows=[risk.Scenario(hours=h, direction='unknown', summary='不足', conditions='不足',
|
||||
counter_factors='不足', uncertainty='不足', event_ids=[]) for h in (24, 48, 168)]), {}
|
||||
article = payload['articles'][0]
|
||||
from alphapulse.news_risk.event_research import EventResult
|
||||
result = EventResult(**{**label(risk).model_dump(exclude={'event_id', 'same_event_as', 'evidence', 'stars'}), 'verification': verification,
|
||||
'direction': direction}, timeline_points=[dict(package_id=1,package_revision=1,occurred_at=None, time_precision='unknown', time_text='Unknown', reported_at=None, description='Synthetic', verification='insufficient', evidence_refs=[{'article_id':1,'quote':article['summary']}])], facts='维护', timeline='维护',
|
||||
asset_impacts='未知', risk_stars=0, importance_stars=3, opinions='待观察', counter_evidence='不足', watchpoints='恢复', claims=[pipeline.Claim(statement='维护', verdict='insufficient', evidence_refs=[])], excluded_article_ids=[],
|
||||
evidence_refs=[pipeline.Evidence(article_id=1, quote=article['summary'])])
|
||||
return result, {}
|
||||
async def high(provider, system, payload, schema):
|
||||
if schema is briefing.Ranked:
|
||||
return briefing.Ranked(reading=[briefing.Reading(kind=p.get('kind','event'),id=p['id'], revision=p['revision'], priority=50, reason='重要') for p in payload['packages']], notes='虚构测试'), {}
|
||||
trend = briefing.Trend(summary='未知', conditions='不足', counter_evidence='不足', watchpoints='后续')
|
||||
return briefing.Synthesis(summary='不足', market='未知', short_term=trend, medium_term=trend, references=[]), {}
|
||||
monkeypatch.setattr(briefing, 'complete', high)
|
||||
monkeypatch.setattr(api, 'complete', complete)
|
||||
monkeypatch.setattr(pipeline, 'complete', complete)
|
||||
return session
|
||||
|
||||
|
||||
def enable_response(session):
|
||||
from fastapi import FastAPI
|
||||
import httpx
|
||||
from alphapulse.api import risk as api
|
||||
app = FastAPI()
|
||||
app.include_router(api.router)
|
||||
app.dependency_overrides[api.require_admin] = lambda: None
|
||||
app.dependency_overrides[api.get_session] = lambda: session
|
||||
async def check():
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app), base_url='http://test') as client:
|
||||
return await client.post('/api/risk/enable', json={'provider_id': 1, 'model': 'news-only'})
|
||||
return asyncio.run(check())
|
||||
|
||||
|
||||
@pytest.mark.parametrize('valid', [True, False])
|
||||
def test_enable_validates_evidence_before_saving_settings(risk, monkeypatch, valid):
|
||||
session = mock_enable(risk, monkeypatch, valid)
|
||||
response = enable_response(session)
|
||||
assert response.status_code == (200 if valid else 502), response.text
|
||||
if valid:
|
||||
saved = session.merge.call_args.args[0]
|
||||
assert saved.enabled and saved.start_at is not None
|
||||
assert session.commit.await_count == 4
|
||||
else:
|
||||
assert '原文引用' in response.text
|
||||
session.merge.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('verification,direction,status', [
|
||||
('insufficient', 'unknown', 200), ('insufficient', 'neutral', 502), ('insufficient', 'bullish', 502),
|
||||
('insufficient', 'bearish', 502), ('insufficient', 'mixed', 502), ('reported', 'neutral', 200),
|
||||
])
|
||||
def test_enable_requires_consistent_uncertainty_without_changing_model_output(risk, monkeypatch, verification, direction, status):
|
||||
session = mock_enable(risk, monkeypatch, verification=verification, direction=direction)
|
||||
response = enable_response(session)
|
||||
assert response.status_code == status, response.text
|
||||
if status == 200:
|
||||
session.merge.assert_awaited_once()
|
||||
else:
|
||||
assert '无法判断方向' in response.text
|
||||
session.merge.assert_not_awaited()
|
||||
|
||||
|
||||
def test_uncertainty_contract_reaches_the_model_with_tool_schema(risk, monkeypatch):
|
||||
from types import SimpleNamespace
|
||||
item = label(risk).model_copy(update={'verification': 'insufficient', 'direction': 'unknown'})
|
||||
|
||||
async def stream(provider, system, messages, tools, effort):
|
||||
assert 'verification="insufficient", direction="unknown"' in system
|
||||
fields = tools[0].parameters['$defs']['Label']['properties']
|
||||
assert 'insufficient' in fields['direction']['description']
|
||||
assert 'unknown' in fields['verification']['description']
|
||||
yield risk.llm.Completion(tool_calls=[{'name': 'submit', 'arguments': {'items': [item.model_dump()]}}])
|
||||
|
||||
monkeypatch.setattr(risk.llm, 'stream', stream)
|
||||
payload = {'news': [{'id': 1, 'title': '安全事件'}], 'candidates': []}
|
||||
provider = SimpleNamespace(max_tokens=256, context_window=200000, reasoning_effort=None)
|
||||
result, _ = asyncio.run(risk.complete(provider, risk.SYSTEM, payload, risk.Labels))
|
||||
risk.validate_labels(result, {1: payload['news'][0]}, set())
|
||||
assert result.items[0].direction == 'unknown'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('kind', ['missing', 'duplicate', 'unknown', 'evidence', 'candidate', 'insufficient'])
|
||||
def test_invalid_results_are_rejected(risk, kind):
|
||||
item = label(risk)
|
||||
if kind == 'missing': items = []
|
||||
elif kind == 'duplicate': items = [item, item]
|
||||
else:
|
||||
if kind == 'unknown': item.id = 2
|
||||
if kind == 'evidence': item.evidence = '捏造的跌幅6%'
|
||||
if kind == 'candidate': item.event_id = 999
|
||||
if kind == 'insufficient': item.verification = 'insufficient'
|
||||
items = [item]
|
||||
with pytest.raises(ValueError):
|
||||
risk.validate_labels(risk.Labels(items=items), {1: {'title': '安全事件'}}, set())
|
||||
|
||||
|
||||
def test_same_batch_grouping_must_not_cycle(risk):
|
||||
first = label(risk)
|
||||
second = label(risk).model_copy(update={'id': 2, 'same_event_as': 1})
|
||||
inputs = {1: {'title': '安全事件'}, 2: {'title': '安全事件'}}
|
||||
risk.validate_labels(risk.Labels(items=[second, first]), inputs, set())
|
||||
first.same_event_as = 2
|
||||
with pytest.raises(ValueError):
|
||||
risk.validate_labels(risk.Labels(items=[first, second]), inputs, set())
|
||||
|
||||
|
||||
def test_truncated_completion_is_not_a_safe_label(risk, monkeypatch):
|
||||
from alphapulse.news_research.contracts import IndexResult
|
||||
from types import SimpleNamespace
|
||||
async def stream(*args):
|
||||
yield risk.llm.Completion(truncated=True, usage={'output_tokens': 100})
|
||||
monkeypatch.setattr(risk.llm, 'stream', stream)
|
||||
with pytest.raises(risk.OutputTruncated, match='截断') as exc:
|
||||
asyncio.run(risk.complete(SimpleNamespace(max_tokens=100, context_window=10000, reasoning_effort=None), '', {}, risk.Labels))
|
||||
asyncio.run(risk.complete(SimpleNamespace(max_tokens=100, context_window=10000, reasoning_effort=None), '', {}, IndexResult))
|
||||
assert exc.value.usage == {'output_tokens': 100}
|
||||
|
||||
|
||||
@@ -196,128 +31,8 @@ def test_model_override_never_changes_shared_provider(risk):
|
||||
assert session.detached == 'original' and result.model == 'LUNA'
|
||||
|
||||
|
||||
def test_debounce_is_bounded_and_urgent_bypasses_wait(risk):
|
||||
from datetime import timedelta
|
||||
from types import SimpleNamespace
|
||||
from alphapulse.news_risk.worker import schedule
|
||||
now = datetime.now(UTC)
|
||||
event = SimpleNamespace(revision=1, result={'summary': '原总结'}, dirty_since=now-timedelta(seconds=170))
|
||||
schedule(event, now, False)
|
||||
assert event.due_at == now+timedelta(seconds=10)
|
||||
assert event.revision == 2
|
||||
schedule(event, now, True)
|
||||
assert event.due_at == now
|
||||
|
||||
|
||||
def test_long_sources_are_split_without_losing_text(risk, monkeypatch):
|
||||
from types import SimpleNamespace
|
||||
from alphapulse.news_risk import pipeline
|
||||
provider = SimpleNamespace(max_tokens=100, context_window=10000)
|
||||
article = {'id': 1, 'title': '安全事件', 'summary': '', 'content': '安'*16000}
|
||||
seen = []
|
||||
async def complete(provider, system, payload, schema):
|
||||
if payload['task'] == 'extract':
|
||||
seen.append(payload['content'])
|
||||
return schema(facts='事实', evidence_refs=[pipeline.Evidence(article_id=1, quote=payload['content'][:1])]), {}
|
||||
return pipeline.EventResult(**label(risk).model_dump(exclude={'event_id', 'same_event_as', 'evidence', 'stars'}), timeline_points=[dict(package_id=1,package_revision=1,occurred_at=None, time_precision='unknown', time_text='Unknown', reported_at=None, description='Synthetic', verification='insufficient', evidence_refs=[{'article_id':1,'quote':'安全事件'}])], facts='事实', timeline='时间', asset_impacts='资产',
|
||||
risk_stars=5, importance_stars=5, opinions='待观察', counter_evidence='不足', watchpoints='恢复', claims=[pipeline.Claim(statement='事件', verdict='insufficient', evidence_refs=[])], related_packages=[], excluded_article_ids=[], evidence_refs=[pipeline.Evidence(article_id=1, quote='安全事件')]), {}
|
||||
monkeypatch.setattr(pipeline, 'complete', complete)
|
||||
result, _ = asyncio.run(pipeline.summarize_articles(provider, 'rules', [article], {'id': 1, 'fact': 'Synthetic event'}))
|
||||
assert ''.join(seen) == article['title']+'\n\n'+article['content']
|
||||
assert result.risk_stars == 5
|
||||
|
||||
|
||||
def test_context_budget_includes_unicode_and_tool_schema(risk):
|
||||
from alphapulse.news_research.contracts import IndexResult
|
||||
from types import SimpleNamespace
|
||||
provider = SimpleNamespace(max_tokens=256, context_window=4096)
|
||||
assert not risk.fits_context(provider, risk.SYSTEM, {'content': '风'*1000}, risk.Labels)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('endpoint', ['overview', 'events'])
|
||||
@pytest.mark.parametrize('hours,status', [(None, 200), ('24', 200), ('48', 200), ('168', 200), ('25', 422), ('0', 422), ('abc', 422)])
|
||||
def test_window_query_parameters_over_http(risk, monkeypatch, endpoint, hours, status):
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
from fastapi import FastAPI
|
||||
import httpx
|
||||
from alphapulse.api import risk as api
|
||||
|
||||
session = SimpleNamespace(get=AsyncMock(return_value=None), execute=AsyncMock(return_value=SimpleNamespace(all=lambda: [])),
|
||||
scalar=AsyncMock(return_value=None), scalars=AsyncMock(return_value=SimpleNamespace(all=lambda: [])))
|
||||
app = FastAPI()
|
||||
app.include_router(api.router)
|
||||
app.dependency_overrides[api.require_admin] = lambda: None
|
||||
app.dependency_overrides[api.get_session] = lambda: session
|
||||
monkeypatch.setattr(api, 'coverage', AsyncMock(return_value={'pending': 0}))
|
||||
monkeypatch.setattr(api.redis, 'get', AsyncMock(return_value=None))
|
||||
|
||||
async def check():
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app), base_url='http://test') as client:
|
||||
response = await client.get('/api/risk/'+endpoint, params={} if hours is None else {'hours': hours})
|
||||
assert response.status_code == status, response.text
|
||||
if status == 200:
|
||||
assert response.json()['code'] == 0
|
||||
asyncio.run(check())
|
||||
|
||||
|
||||
def test_fresh_settings_can_be_saved_without_editing_any_fields(risk):
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
from fastapi import FastAPI
|
||||
import httpx
|
||||
from alphapulse.api import risk as api
|
||||
session = SimpleNamespace(get=AsyncMock(return_value=None), scalar=AsyncMock(return_value=None),
|
||||
merge=AsyncMock(), commit=AsyncMock())
|
||||
app = FastAPI()
|
||||
app.include_router(api.router)
|
||||
app.dependency_overrides[api.require_admin] = lambda: None
|
||||
app.dependency_overrides[api.get_session] = lambda: session
|
||||
async def check():
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app), base_url='http://test') as client:
|
||||
response = await client.get('/api/risk/settings')
|
||||
assert response.status_code == 200
|
||||
value = response.json()['data']
|
||||
assert value['second_model'] == value['third_model'] == ''
|
||||
response = await client.put('/api/risk/settings', json=value)
|
||||
assert response.status_code == 200, response.text
|
||||
asyncio.run(check())
|
||||
saved = session.merge.call_args.args[0]
|
||||
assert saved.enabled is False and saved.third_model == ''
|
||||
session.commit.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('state,published,reason', [('done', 3, '成功结果'), ('pending', 2, '排队'), ('processing', 2, '正在处理')])
|
||||
def test_old_failed_analysis_cannot_requeue_a_completed_or_running_revision(risk, state, published, reason):
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
from fastapi import HTTPException
|
||||
from alphapulse.api import risk as api
|
||||
event = SimpleNamespace(revision=3, published_revision=published, state=state)
|
||||
row = SimpleNamespace(state='failed', event_id=1, revision=3)
|
||||
session = SimpleNamespace(get=AsyncMock(side_effect=[row, event]), commit=AsyncMock())
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
asyncio.run(api.retry(7, session, stage='events'))
|
||||
assert exc.value.status_code == 409
|
||||
assert event.state == state
|
||||
session.commit.assert_not_awaited()
|
||||
|
||||
|
||||
def test_completed_version_recovers_a_previously_stuck_pending_package(risk, monkeypatch):
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
from alphapulse.news_risk import worker
|
||||
event = SimpleNamespace(review_config=None, result=None, id=1, revision=3, published_revision=3, state='pending', error='Old failure',
|
||||
dirty_since=datetime.now(UTC), title='Statement', fact='Statement', category='politics', tags=['Statement'], content_kind='statement')
|
||||
session = AsyncMock()
|
||||
session.__aenter__.return_value = session
|
||||
session.get.return_value = SimpleNamespace(enabled=True)
|
||||
session.scalar.side_effect = [event, SimpleNamespace(state='done')]
|
||||
session.scalars.return_value = SimpleNamespace(all=lambda: [SimpleNamespace(id=1, news_id=10, content_hash='current', input={'title': 'Statement'})])
|
||||
monkeypatch.setattr(worker, 'SessionLocal', lambda: session)
|
||||
monkeypatch.setattr(worker, 'configuration', AsyncMock(return_value=(SimpleNamespace(model='medium'), 'rules', {}, 'key')))
|
||||
analyze = AsyncMock()
|
||||
monkeypatch.setattr(worker, 'summarize_articles', analyze)
|
||||
asyncio.run(worker.summarize())
|
||||
assert event.state == 'done' and event.error is None and event.dirty_since is None
|
||||
session.commit.assert_awaited_once()
|
||||
analyze.assert_not_awaited()
|
||||
provider = SimpleNamespace(max_tokens=256, context_window=3500)
|
||||
assert not risk.fits_context(provider, '只使用原文证据', {'content': '风'*1000}, IndexResult)
|
||||
@@ -1,46 +0,0 @@
|
||||
"""Replay orchestration must drain or cancel every synthetic task."""
|
||||
import asyncio
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
|
||||
|
||||
def load_replay():
|
||||
path=Path(__file__).resolve().parents[2]/'scripts/replay_news_sample.py'
|
||||
spec=importlib.util.spec_from_file_location('sample_replay_test',path)
|
||||
module=importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_replay_refills_before_slow_task_finishes(trading):
|
||||
replay=load_replay()
|
||||
async def run():
|
||||
third_started=asyncio.Event()
|
||||
order=[]
|
||||
async def action(identifier):
|
||||
if identifier == 1: await third_started.wait()
|
||||
if identifier == 3: third_started.set()
|
||||
order.append(identifier)
|
||||
await asyncio.wait_for(replay.bounded([1,2,3],action,2),1)
|
||||
assert order.index(3)<order.index(1)
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_replay_failure_cancels_and_drains_other_tasks(trading):
|
||||
replay=load_replay()
|
||||
async def run():
|
||||
started=asyncio.Event()
|
||||
drained=[]
|
||||
async def action(identifier):
|
||||
if identifier == 1:
|
||||
await started.wait()
|
||||
raise ValueError('synthetic failure')
|
||||
try:
|
||||
started.set()
|
||||
await asyncio.Event().wait()
|
||||
finally: drained.append(identifier)
|
||||
with pytest.raises(ValueError,match='synthetic failure'):
|
||||
await replay.bounded([1,2],action,2)
|
||||
assert drained==[2]
|
||||
asyncio.run(run())
|
||||
@@ -87,11 +87,15 @@ def test_published_skill_retains_base_instructions(trading, monkeypatch):
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
from alphapulse.news_research import service
|
||||
provider = SimpleNamespace(id=7, model='test', reasoning_effort='medium')
|
||||
from alphapulse.news_research.pipeline import BASE
|
||||
provider = SimpleNamespace(id=7, model='test', reasoning_effort='medium', max_tokens=8192, context_window=200000)
|
||||
monkeypatch.setattr(service, 'provider_for', AsyncMock(return_value=provider))
|
||||
monkeypatch.setattr(service, 'configuration', AsyncMock(return_value=(provider, 'BASE\nPUBLISHED', {'content': 'PUBLISHED', 'base': 'BASE'}, 'hash')))
|
||||
_, skill, rules = asyncio.run(service.model_config(None, {'index': {'provider_id': 7, 'model': 'test', 'skill_id': 9}}, 'index'))
|
||||
assert skill == 'BASE\nPUBLISHED' and rules['base'] == 'BASE'
|
||||
session = SimpleNamespace(get=AsyncMock(side_effect=[
|
||||
SimpleNamespace(id=9, scope='news', kind='skill', enabled=True, parent_id=None, published_version_id=10),
|
||||
SimpleNamespace(version_no=3, content='PUBLISHED'),
|
||||
]))
|
||||
_, skill, rules = asyncio.run(service.model_config(session, {'index': {'provider_id': 7, 'model': 'test', 'skill_id': 9}}, 'index'))
|
||||
assert skill == BASE+'\nPUBLISHED' and rules['base'] == BASE
|
||||
|
||||
|
||||
def test_pinned_reference_requires_later_natural_update_without_consumer_rerun(scenario):
|
||||
|
||||
@@ -341,7 +341,7 @@ def test_new_rss_is_paused_and_discovered_without_manager_restart(trading, monke
|
||||
from unittest.mock import AsyncMock
|
||||
from alphapulse.api.crawler import RssIn, create_rss
|
||||
from alphapulse.crawler import manager
|
||||
from alphapulse.news_risk import research
|
||||
from alphapulse.core import public_http as research
|
||||
added = []
|
||||
session = SimpleNamespace(add=added.append, commit=AsyncMock())
|
||||
monkeypatch.setattr(research, 'public_address', AsyncMock())
|
||||
@@ -376,7 +376,7 @@ def test_custom_feed_preserves_declared_xml_encoding_through_public_downloader(t
|
||||
from unittest.mock import AsyncMock
|
||||
import httpx
|
||||
from alphapulse.crawler.spiders import rss
|
||||
from alphapulse.news_risk import research
|
||||
from alphapulse.core import public_http as research
|
||||
url = 'https://example.invalid/feed.xml'
|
||||
item_xml = ITEM.replace('Bitcoin & Ethereum', title).replace('公开摘要 & 数据', 'Public summary')
|
||||
body = f'<?xml version="1.0" encoding="{encoding}"?><rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/"><channel>{item_xml}</channel></rss>'.encode(encoding)
|
||||
@@ -401,7 +401,7 @@ def test_custom_feed_keeps_http_failure_status_for_scrapy_retry(trading, monkeyp
|
||||
from unittest.mock import AsyncMock
|
||||
import httpx
|
||||
from alphapulse.crawler.spiders import rss
|
||||
from alphapulse.news_risk import research
|
||||
from alphapulse.core import public_http as research
|
||||
url = 'https://example.invalid/feed.xml'
|
||||
original_client = httpx.AsyncClient
|
||||
monkeypatch.setattr(research, 'public_address', AsyncMock(return_value=(urlsplit(url), '8.8.8.8')))
|
||||
|
||||
@@ -10,8 +10,8 @@ import pytest
|
||||
@pytest.mark.parametrize('mode', ['success', 'exhausted', 'partial', 'timeout', 'auth', 'long_wait', 'tool_fragment'])
|
||||
def test_news_transport_retry_budget_and_output_guard(trading, monkeypatch, mode):
|
||||
from alphapulse.ai import llm
|
||||
from alphapulse.news_risk.service import model_stream
|
||||
from alphapulse.news_risk.progress import current
|
||||
from alphapulse.news_research.model import model_stream
|
||||
from alphapulse.news_research.progress import current
|
||||
calls = []
|
||||
async def adapter(*args):
|
||||
calls.append(args)
|
||||
@@ -55,8 +55,8 @@ def test_retry_after_seconds_and_dates(trading):
|
||||
|
||||
def test_cancelling_backoff_releases_context_and_progress(trading, monkeypatch):
|
||||
from alphapulse.ai import llm
|
||||
from alphapulse.news_risk.service import model_stream
|
||||
from alphapulse.news_risk import progress
|
||||
from alphapulse.news_research.model import model_stream
|
||||
from alphapulse.news_research import progress
|
||||
entered = asyncio.Event()
|
||||
async def adapter(*args):
|
||||
raise openai.APIError('Concurrency limit exceeded', httpx.Request('POST', 'https://example.invalid'), body={})
|
||||
|
||||
@@ -1,259 +1,25 @@
|
||||
"""Workflow tuning preserves evidence boundaries and limits expensive work."""
|
||||
import asyncio
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace as N
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
import pytest
|
||||
|
||||
|
||||
def test_keyword_queries_match_independently_and_escape_literals(trading):
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.dialects.postgresql import dialect
|
||||
from alphapulse.models.risk import NewsSummary
|
||||
from alphapulse.news_risk.retrieval import terms_for, matching
|
||||
from alphapulse.models import NewsItem
|
||||
from alphapulse.news_research.retrieval import terms_for, matching
|
||||
terms = terms_for('小鹏 交付 50%')
|
||||
condition, score = matching(NewsSummary, terms)
|
||||
query = select(NewsSummary.id).where(condition).order_by(score.desc()).compile(dialect=dialect())
|
||||
condition, score = matching(NewsItem, terms)
|
||||
query = select(NewsItem.id).where(condition).order_by(score.desc()).compile(dialect=dialect())
|
||||
assert ' OR ' in str(query)
|
||||
assert any('50/%' in str(v) for v in query.params.values())
|
||||
assert terms_for('ignored', ['央行']) == ['央行','中国人民银行']
|
||||
with pytest.raises(ValueError): terms_for(' ')
|
||||
|
||||
|
||||
def test_low_star_shortlist_rotates_domains_and_keeps_recent(trading):
|
||||
from alphapulse.news_risk.briefing import shortlist
|
||||
items = [{'id':i,'category':str(i%4),'importance_stars':2,'updated_at':f'2026-11-{i+1:02}'} for i in range(24)]
|
||||
chosen = shortlist(items)
|
||||
assert len(chosen) == 12
|
||||
assert len({p['category'] for p in chosen[:4]}) == 4
|
||||
assert chosen[0]['id'] == 23
|
||||
|
||||
|
||||
def test_plan_time_is_distinct_from_occurrence(trading):
|
||||
from alphapulse.news_risk.contracts import PackageTimePoint
|
||||
from alphapulse.news_research.time_contracts import PackageTimePoint
|
||||
point = dict(occurred_at=None,time_precision='unknown',time_text='明天',reported_at=None,
|
||||
description='拟发布',verification='partial',evidence_refs=[{'article_id':1,'quote':'明天'}])
|
||||
value=PackageTimePoint(**point,planned_at='2026-11-03T00:00:00+08:00',planned_precision='day',period_text='2026年10月')
|
||||
assert value.occurred_at is None and value.planned_at.day == 3
|
||||
with pytest.raises(ValueError): PackageTimePoint(**point,planned_precision='day')
|
||||
|
||||
|
||||
def test_attempted_selection_needs_no_date_range(trading):
|
||||
from alphapulse.api.risk import ReprocessIn
|
||||
assert ReprocessIn(selection='attempted',modes=['review']).start is None
|
||||
with pytest.raises(ValueError): ReprocessIn(modes=['review'])
|
||||
|
||||
|
||||
def test_exclusive_grouping_does_not_admit_realtime(trading,monkeypatch):
|
||||
from alphapulse.news_risk import scheduler,worker
|
||||
session=MagicMock()
|
||||
session.__aenter__=AsyncMock(return_value=session); session.__aexit__=AsyncMock()
|
||||
session.get=AsyncMock(return_value=N(enabled=True,start_at='configured'))
|
||||
job=N(id=7,settings={'request':{'run_mode':'history_only'}})
|
||||
session.scalar=AsyncMock(side_effect=[job,1,job])
|
||||
session.scalars=AsyncMock(return_value=N(all=lambda:[]))
|
||||
monkeypatch.setattr(scheduler,'SessionLocal',lambda:session)
|
||||
enqueue=AsyncMock();outlook=AsyncMock()
|
||||
monkeypatch.setattr(worker,'enqueue',enqueue);monkeypatch.setattr(worker,'outlook',outlook)
|
||||
assert asyncio.run(scheduler.candidate(set(),0)) is None
|
||||
enqueue.assert_not_awaited();outlook.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('snapshot_state', [None, 'processing', 'done', 'failed'])
|
||||
def test_exclusive_completion_waits_for_reading_and_then_pauses(trading,snapshot_state,monkeypatch):
|
||||
from alphapulse.news_risk.backfill import reconcile
|
||||
from alphapulse.models.risk import ReprocessJob
|
||||
job=N(id=7,state='running',settings={'request':{'run_mode':'history_only'},'configurations':{'outlook':{'rules':{}}}},failures=[])
|
||||
from alphapulse.news_risk import events
|
||||
monkeypatch.setattr(events,'reading_inputs',AsyncMock(return_value=[]))
|
||||
settings=N(enabled=True)
|
||||
session=MagicMock()
|
||||
session.get=AsyncMock(side_effect=lambda model,*a,**k: job if model is ReprocessJob else settings)
|
||||
session.scalars=AsyncMock(return_value=N(all=lambda:[]))
|
||||
snapshot=None if snapshot_state is None else N(id=9,state=snapshot_state,error='Synthetic',quotes={})
|
||||
session.scalar=AsyncMock(side_effect=[None,snapshot])
|
||||
session.commit=AsyncMock()
|
||||
asyncio.run(reconcile(session,job))
|
||||
if snapshot_state in (None,'processing'):
|
||||
assert job.state=='running' and settings.enabled
|
||||
else:
|
||||
assert job.state==('done' if snapshot_state=='done' else 'failed')
|
||||
assert not settings.enabled
|
||||
|
||||
|
||||
def test_expired_exclusive_preview_never_calls_paid_validation(trading,monkeypatch):
|
||||
from fastapi import HTTPException
|
||||
from alphapulse.api import risk
|
||||
from alphapulse.news_risk import backfill
|
||||
from alphapulse.core import db
|
||||
gate=MagicMock();gate.__aenter__=AsyncMock(return_value=gate);gate.__aexit__=AsyncMock(return_value=False)
|
||||
gate.scalar=AsyncMock(return_value=True);gate.commit=AsyncMock();gate.execute=AsyncMock()
|
||||
monkeypatch.setattr(db,'engine',N(connect=lambda:gate))
|
||||
monkeypatch.setattr(risk,'default_settings',AsyncMock(return_value=N(enabled=False)))
|
||||
monkeypatch.setattr(backfill,'prepare_start',AsyncMock(side_effect=ValueError('预览已过期')))
|
||||
validate=AsyncMock();monkeypatch.setattr(risk,'validate_enable',validate)
|
||||
with pytest.raises(HTTPException) as error:
|
||||
asyncio.run(risk.reprocess_start(risk.ReprocessStart(preview_id='a'*24,run_mode='history_only'),MagicMock()))
|
||||
assert error.value.status_code==409
|
||||
validate.assert_not_awaited()
|
||||
gate.execute.assert_awaited_once()
|
||||
|
||||
|
||||
def test_ranking_only_skips_synthesis_and_synthesis_reuses_priority(trading,monkeypatch):
|
||||
from alphapulse.news_risk import briefing
|
||||
inputs=[{'id':1,'revision':2,'importance_stars':3,'result':{'summary':'Synthetic'}}]
|
||||
calls=[]
|
||||
async def complete(provider,system,payload,schema):
|
||||
calls.append(payload['task'])
|
||||
reading={'id':1,'revision':2,'priority':70,'reason':'Verified update'}
|
||||
if schema is briefing.Ranked: return schema(reading=[reading],notes='Synthetic'),{}
|
||||
trend={'summary':'Unknown','conditions':'Unknown','counter_evidence':'Unknown','watchpoints':'Unknown'}
|
||||
return schema(summary='Synthetic',market='Unknown',short_term=trend,medium_term=trend,
|
||||
references=[{**reading,'priority':99,'reason':'Changed'}]),{}
|
||||
monkeypatch.setattr(briefing,'complete',complete)
|
||||
provider=N(max_tokens=100,context_window=20000)
|
||||
ranked=asyncio.run(briefing.generate(provider,'rules',inputs,ranking_only=True))
|
||||
assert calls==['ranking']
|
||||
result=asyncio.run(briefing.generate(provider,'rules',inputs,rankings=ranked['reading']))
|
||||
assert calls==['ranking','synthesis']
|
||||
assert result['synthesis']['references']==ranked['reading']
|
||||
|
||||
|
||||
def test_large_synthesis_compacts_package_notes_in_batches(trading,monkeypatch):
|
||||
import json
|
||||
from alphapulse.news_risk import briefing
|
||||
inputs=[{'id':i,'revision':1,'importance_stars':3,'result':{'summary':'x'*800}} for i in range(3)]
|
||||
rankings=[{'kind':'package','id':i,'revision':1,'priority':50,'reason':'Read'} for i in range(3)]
|
||||
calls=[]
|
||||
def fits(provider,system,payload,schema): return len(json.dumps(payload)) < 1600
|
||||
async def complete(provider,system,payload,schema):
|
||||
calls.append(payload['task'])
|
||||
if schema is briefing.Notes: return schema(notes='Compacted'),{}
|
||||
trend={'summary':'Unknown','conditions':'Unknown','counter_evidence':'Unknown','watchpoints':'Unknown'}
|
||||
return schema(summary='Synthetic',market='Unknown',short_term=trend,medium_term=trend,references=[]),{}
|
||||
monkeypatch.setattr(briefing,'fits_context',fits);monkeypatch.setattr(briefing,'complete',complete)
|
||||
result=asyncio.run(briefing.generate(N(),'rules',inputs,rankings=rankings))
|
||||
assert calls==['synthesis_notes']*3+['synthesis']
|
||||
assert len(result['reading'])==3
|
||||
|
||||
|
||||
@pytest.mark.parametrize('original_start', [None, datetime(2026,11,1,tzinfo=UTC)])
|
||||
def test_exclusive_start_persists_dispatch_start_and_preserves_existing_boundary(trading,monkeypatch,original_start):
|
||||
from alphapulse.news_risk import backfill,scheduler
|
||||
settings=N(enabled=False,start_at=original_start)
|
||||
session=MagicMock()
|
||||
session.get=AsyncMock(return_value=settings);session.flush=AsyncMock();session.commit=AsyncMock()
|
||||
session.add=lambda job:setattr(job,'id',7)
|
||||
value={'items':[{'id':1,'hash':'current','mode':'review'}],'config':{},'request':{'selection':'range'}}
|
||||
monkeypatch.setattr(backfill,'prepare_start',AsyncMock(return_value=value))
|
||||
monkeypatch.setattr(backfill.redis,'delete',AsyncMock())
|
||||
before=datetime.now(UTC)
|
||||
result=asyncio.run(backfill.start(session,'preview','history_only',validated=True))
|
||||
assert result=={'id':7} and settings.enabled
|
||||
if original_start is None: assert before<=settings.start_at<=datetime.now(UTC)
|
||||
else: assert settings.start_at==original_start
|
||||
session.__aenter__=AsyncMock(return_value=session);session.__aexit__=AsyncMock(return_value=False)
|
||||
job=N(id=7,settings={'request':{'run_mode':'history_only'}})
|
||||
session.scalar=AsyncMock(side_effect=[job,1,job,None])
|
||||
session.scalars=AsyncMock(return_value=N(all=lambda:[N(id=1,news_id=1,content_hash='current')]))
|
||||
monkeypatch.setattr(scheduler,'SessionLocal',lambda:session)
|
||||
picked=asyncio.run(scheduler.candidate(set(),0))
|
||||
assert picked[0]==('backfill',1)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('event_state', ['pending','processing','failed'])
|
||||
def test_exclusive_batch_tracks_event_without_package_research(trading,monkeypatch,event_state):
|
||||
from alphapulse.news_risk import backfill
|
||||
from alphapulse.models.risk import ReprocessJob,NewsEvent,RiskSettings
|
||||
config={'request':{'run_mode':'history_only'}}
|
||||
job=N(id=7,state='running',settings=config,failures=[])
|
||||
event=N(id=9,state=event_state,published_revision=0,error='Synthetic')
|
||||
settings=N(enabled=True)
|
||||
session=MagicMock()
|
||||
session.get=AsyncMock(side_effect=lambda model,*a,**k:{ReprocessJob:job,NewsEvent:event,RiskSettings:settings}[model])
|
||||
session.scalar=AsyncMock(return_value=None)
|
||||
session.scalars=AsyncMock(side_effect=[N(all=lambda:[]),N(all=lambda:[N(kind='events',object_id=9,revision=1)])])
|
||||
session.commit=AsyncMock()
|
||||
enroll=AsyncMock();monkeypatch.setattr(backfill,'target',enroll)
|
||||
asyncio.run(backfill.reconcile(session,job))
|
||||
enroll.assert_not_awaited()
|
||||
assert job.state==('failed' if event_state=='failed' else 'running')
|
||||
assert settings.enabled == (event_state!='failed')
|
||||
|
||||
|
||||
def test_exclusive_batch_finishes_after_event_dependency_is_published(trading,monkeypatch):
|
||||
from alphapulse.news_risk import backfill,events
|
||||
from alphapulse.models.risk import ReprocessJob,NewsEvent,NewsSummary,RiskSettings,EventMember
|
||||
config={'request':{'run_mode':'history_only'},'configurations':{'outlook':{'rules':{}}}}
|
||||
job=N(id=7,state='running',settings=config,failures=[])
|
||||
event=N(id=9,state='done',published_revision=1)
|
||||
dependency=N(id=2,state='done',revision=2,published_revision=2,replacement_ids=[])
|
||||
targets=[N(kind='events',object_id=9,revision=1),N(kind='analysis',object_id=2,revision=2)]
|
||||
settings=N(enabled=True)
|
||||
session=MagicMock()
|
||||
session.get=AsyncMock(side_effect=lambda model,*a,**k:{
|
||||
ReprocessJob:job,NewsEvent:event,NewsSummary:dependency,RiskSettings:settings,EventMember:None}[model])
|
||||
session.scalar=AsyncMock(side_effect=[None,N(id=3,state='done',quotes={})])
|
||||
session.scalars=AsyncMock(side_effect=[N(all=lambda:[]),N(all=lambda:targets),N(all=lambda:[])])
|
||||
session.commit=AsyncMock()
|
||||
monkeypatch.setattr(events,'reading_inputs',AsyncMock(return_value=[]))
|
||||
asyncio.run(backfill.reconcile(session,job))
|
||||
assert job.state=='done' and not settings.enabled
|
||||
|
||||
|
||||
@pytest.mark.parametrize('failed_stage', ['grouping','events',None])
|
||||
@pytest.mark.parametrize('prepare_only', [True,False])
|
||||
def test_exclusive_outlook_never_admits_failed_batch_results(trading,monkeypatch,failed_stage,prepare_only):
|
||||
from alphapulse.news_risk import worker,events,reprocess
|
||||
from alphapulse.models.risk import ReprocessJob,ReprocessItem,NewsSummary,NewsEvent
|
||||
job=N(id=7,state='running',position=0,items=[],settings={'request':{'run_mode':'history_only'}})
|
||||
failed_model={'grouping':ReprocessItem,'analysis':NewsSummary,'events':NewsEvent}.get(failed_stage)
|
||||
session=MagicMock()
|
||||
session.__aenter__=AsyncMock(return_value=session);session.__aexit__=AsyncMock(return_value=False)
|
||||
session.get=AsyncMock(return_value=N(enabled=True));session.commit=AsyncMock()
|
||||
async def scalar(query):
|
||||
model=query.column_descriptions[0]['entity']
|
||||
if model is ReprocessJob: return job
|
||||
if model is failed_model:
|
||||
# Emulate filtering a stored failed row, without storage or model I/O.
|
||||
return 1 if any(isinstance(value,list) and 'failed' in value for value in query.compile().params.values()) else None
|
||||
return None
|
||||
session.scalar=AsyncMock(side_effect=scalar)
|
||||
inputs=[{'id':1,'kind':'package','revision':1,'importance_stars':3,'category':'company','result':{'summary':'Previous successful result'}}]
|
||||
monkeypatch.setattr(worker,'SessionLocal',lambda:session)
|
||||
monkeypatch.setattr(events,'reading_inputs',AsyncMock(return_value=inputs))
|
||||
configuration=AsyncMock(return_value=(N(model='advanced'),'rules',{}))
|
||||
monkeypatch.setattr(reprocess,'frozen_configuration',configuration)
|
||||
generate=AsyncMock(return_value={'reading':[],'synthesis':None,'usage':[]})
|
||||
monkeypatch.setattr(worker.briefing,'generate',generate)
|
||||
ready=asyncio.run(worker.outlook(prepare_only=prepare_only))
|
||||
if failed_stage:
|
||||
assert ready is None
|
||||
configuration.assert_not_awaited();generate.assert_not_awaited();session.add.assert_not_called()
|
||||
elif prepare_only:
|
||||
assert ready is True
|
||||
generate.assert_not_awaited()
|
||||
else:
|
||||
assert generate.await_count==2
|
||||
assert session.add.call_args.args[0].state=='done'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('case', ['valid','wrong_date','wrong_text','wrong_quote','extra_evidence'])
|
||||
def test_event_plan_binds_date_precision_text_and_its_original_quote(trading,case):
|
||||
from alphapulse.news_risk.events import EventResult,validate_timeline
|
||||
def point(day):
|
||||
return dict(occurred_at=None,time_precision='unknown',planned_at=f'2026-11-{day}T00:00:00Z',
|
||||
planned_precision='day',time_text=f'11月{day}日',reported_at=None,description='Plan',verification='partial',
|
||||
evidence_refs=[{'article_id':1,'quote':f'计划11月{day}日开始'}])
|
||||
first,second=point('10'),point('20')
|
||||
submitted={**first,'package_id':1,'package_revision':1}
|
||||
if case=='wrong_date': submitted={**second,'planned_at':first['planned_at'],'package_id':1,'package_revision':1}
|
||||
if case=='wrong_text': submitted['time_text']=second['time_text']
|
||||
if case=='wrong_quote': submitted['evidence_refs']=second['evidence_refs']
|
||||
if case=='extra_evidence': submitted['evidence_refs']=first['evidence_refs']+second['evidence_refs']
|
||||
result=EventResult(title='Synthetic',summary='Synthetic',category='company',importance_stars=3,risk_stars=0,
|
||||
uncertainty='Unknown',opinions='Unknown',counter_evidence='Unknown',watchpoints='Unknown',timeline_points=[submitted])
|
||||
packages=[{'id':1,'revision':1,'result':{'timeline_points':[first,second]}}]
|
||||
if case in ('valid','extra_evidence'): validate_timeline(result,packages)
|
||||
else:
|
||||
with pytest.raises(ValueError,match='已核验计划'): validate_timeline(result,packages)
|
||||
@@ -146,7 +146,7 @@ def test_budget_counts_transport_failures_before_retry(trading, tmp_path):
|
||||
|
||||
|
||||
def test_supplemental_citation_must_use_returned_article_id(trading):
|
||||
from alphapulse.news_risk.pipeline import validate_evidence, Evidence
|
||||
from alphapulse.news_research.pipeline import validate_evidence, Evidence
|
||||
|
||||
sources = {-1: {"news_id": 1004, "content": "Original correction"}}
|
||||
validate_evidence([Evidence(article_id=-1, quote="Original correction")], sources)
|
||||
|
||||
@@ -53,26 +53,6 @@ def test_patch_provider_default_contract(trading, monkeypatch, initial, requeste
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('enabled', [True, False])
|
||||
def test_delete_provider_handles_news_risk_reference(trading, monkeypatch, enabled):
|
||||
from fastapi import HTTPException
|
||||
from alphapulse.api import settings
|
||||
reference = SimpleNamespace(enabled=enabled, provider_id=1, second_provider_id=1, third_provider_id=1)
|
||||
provider = SimpleNamespace(id=1, is_default=False)
|
||||
session = SimpleNamespace(scalar=AsyncMock(side_effect=[None, reference]), delete=AsyncMock(),
|
||||
flush=AsyncMock(), commit=AsyncMock())
|
||||
monkeypatch.setattr(settings, 'get_provider', AsyncMock(return_value=provider))
|
||||
if enabled:
|
||||
with pytest.raises(HTTPException) as error:
|
||||
asyncio.run(settings.delete_provider(1, session))
|
||||
assert error.value.status_code == 409
|
||||
session.delete.assert_not_awaited()
|
||||
assert reference.provider_id == 1
|
||||
else:
|
||||
asyncio.run(settings.delete_provider(1, session))
|
||||
assert reference.provider_id is None
|
||||
session.delete.assert_awaited_once_with(provider)
|
||||
session.commit.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('protocol,base_url', [('openai', 'https://example.invalid/v1/'), ('anthropic', 'https://example.invalid/')])
|
||||
|
||||
@@ -5,7 +5,7 @@ from types import SimpleNamespace
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from alphapulse.news_risk.contracts import PackageTimePoint
|
||||
from alphapulse.news_research.time_contracts import PackageTimePoint
|
||||
|
||||
|
||||
class Result(BaseModel):
|
||||
@@ -14,7 +14,7 @@ class Result(BaseModel):
|
||||
|
||||
@pytest.mark.parametrize('outcome', ['success', 'invalid', 'query', 'truncated', 'context'])
|
||||
def test_time_repair_is_bounded_and_accounts_for_calls(trading, monkeypatch, outcome):
|
||||
from alphapulse.news_risk import service
|
||||
from alphapulse.news_research import model as service
|
||||
from alphapulse.ai.llm import Completion
|
||||
payload = {'timeline_points': [{'occurred_at': None, 'time_precision': 'day', 'time_text': '',
|
||||
'reported_at': None, 'description': 'Unknown occurrence time', 'verification': 'insufficient',
|
||||
@@ -49,7 +49,7 @@ def test_time_repair_is_bounded_and_accounts_for_calls(trading, monkeypatch, out
|
||||
|
||||
|
||||
def test_unrelated_validation_does_not_retry(trading, monkeypatch):
|
||||
from alphapulse.news_risk import service
|
||||
from alphapulse.news_research import model as service
|
||||
from alphapulse.ai.llm import Completion
|
||||
calls = []
|
||||
async def stream(*args):
|
||||
@@ -63,8 +63,8 @@ def test_unrelated_validation_does_not_retry(trading, monkeypatch):
|
||||
|
||||
|
||||
def test_research_time_repair_preserves_round_budget_and_thinking(trading, monkeypatch):
|
||||
from alphapulse.news_risk import service
|
||||
from alphapulse.news_risk.research import Research
|
||||
from alphapulse.news_research import model as service
|
||||
from alphapulse.news_research.tools import Research
|
||||
from alphapulse.ai.llm import Completion
|
||||
calls = []
|
||||
args = {'timeline_points': [{'occurred_at': None, 'time_precision': 'day', 'time_text': '', 'reported_at': None,
|
||||
@@ -100,7 +100,7 @@ def usage_tokens(usage):
|
||||
@pytest.mark.parametrize('failure', ['second_call', 'evidence'])
|
||||
def test_extraction_failure_keeps_completed_and_failed_call_usage(trading, monkeypatch, failure):
|
||||
from unittest.mock import AsyncMock
|
||||
from alphapulse.news_risk import pipeline, service
|
||||
from alphapulse.news_research import pipeline, model as service
|
||||
extract = pipeline.Extraction(facts='Source', evidence_refs=[pipeline.Evidence(article_id=1, quote='Source')])
|
||||
complete = AsyncMock(side_effect=[(extract, {'input_tokens': 10}), service.SubmissionFailure('synthetic failure', {'input_tokens': 40})])
|
||||
monkeypatch.setattr(pipeline, 'complete', complete)
|
||||
@@ -114,7 +114,7 @@ def test_extraction_failure_keeps_completed_and_failed_call_usage(trading, monke
|
||||
@pytest.mark.parametrize('failure', ['second_call', 'evidence', 'no_shrink'])
|
||||
def test_compaction_failure_keeps_every_paid_call(trading, monkeypatch, failure):
|
||||
from unittest.mock import AsyncMock
|
||||
from alphapulse.news_risk import pipeline, service
|
||||
from alphapulse.news_research import pipeline, model as service
|
||||
group = {'facts': 'Very long source fact ' * 50, 'evidence_refs': [{'article_id': 1, 'quote': 'Source'}]}
|
||||
extract = pipeline.Extraction(facts='Source' if failure != 'no_shrink' else 'Longer' * 1000,
|
||||
evidence_refs=[pipeline.Evidence(article_id=1, quote='Changed' if failure == 'evidence' else 'Source')])
|
||||
@@ -122,25 +122,16 @@ def test_compaction_failure_keeps_every_paid_call(trading, monkeypatch, failure)
|
||||
monkeypatch.setattr(pipeline, 'complete', complete)
|
||||
monkeypatch.setattr(pipeline, 'fits_context', lambda *args: True)
|
||||
with pytest.raises(service.SubmissionFailure) as error:
|
||||
asyncio.run(pipeline.compact_extractions(SimpleNamespace(), 'rules', {'extractions': [group]}, pipeline.EventResult, fits=lambda *args: False))
|
||||
asyncio.run(pipeline.compact_extractions(SimpleNamespace(), 'rules', {'extractions': [group]}, pipeline.ReviewResult, fits=lambda *args: False))
|
||||
assert usage_tokens(error.value.usage) == (50 if failure == 'second_call' else 10)
|
||||
|
||||
|
||||
def test_summary_keeps_extraction_usage_when_compaction_fails(trading, monkeypatch):
|
||||
from unittest.mock import AsyncMock
|
||||
from alphapulse.news_risk import pipeline, service
|
||||
monkeypatch.setattr(pipeline, 'fits_context', lambda *args: False)
|
||||
monkeypatch.setattr(pipeline, 'extract_articles', AsyncMock(return_value=([{'facts': 'Source'}], [{'input_tokens': 123}])))
|
||||
monkeypatch.setattr(pipeline, 'compact_extractions', AsyncMock(side_effect=service.SubmissionFailure('synthetic failure', {'input_tokens': 456})))
|
||||
with pytest.raises(service.SubmissionFailure) as error:
|
||||
asyncio.run(pipeline.summarize_articles(SimpleNamespace(), 'rules', [], {'id': 1}))
|
||||
assert usage_tokens(error.value.usage) == 579
|
||||
|
||||
|
||||
@pytest.mark.parametrize('error_kind', ['missing', 'extra'])
|
||||
def test_structural_repair_submits_full_result_once(trading, monkeypatch, error_kind):
|
||||
from pydantic import ConfigDict
|
||||
from alphapulse.news_risk import service
|
||||
from alphapulse.news_research import model as service
|
||||
from alphapulse.ai.llm import Completion
|
||||
class Category(BaseModel):
|
||||
model_config = ConfigDict(extra='forbid')
|
||||
|
||||
Reference in new issue
Block a user