126 lines
7.8 KiB
Python
126 lines
7.8 KiB
Python
"""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_members_are_aggregated_once_and_old_independent_package_expires(trading):
|
|
from alphapulse.news_risk.events import reading_inputs
|
|
now=datetime.now(UTC)
|
|
packages=[SimpleNamespace(id=i,updated_at=now if i<3 else now-timedelta(days=4),importance_stars=4,
|
|
result={'title':str(i)},published_revision=2,category='security',tags=[]) for i in (1,2,3)]
|
|
event=SimpleNamespace(id=1,updated_at=now,published_revision=2,result={'title':'Incident','importance_stars':2,'category':'security','timeline_points':[{'package_id':i} for i in (1,2)]})
|
|
session=AsyncMock()
|
|
values=[packages,[SimpleNamespace(package_id=i,event_id=1) for i in (1,2)],[event]]
|
|
session.scalars.side_effect=[SimpleNamespace(all=lambda rows=rows:rows) for rows in values]
|
|
session.scalar.return_value=SimpleNamespace(revision=2)
|
|
inputs=asyncio.run(reading_inputs(session,now-timedelta(hours=72)))
|
|
assert len(inputs)==1 and inputs[0]['kind']=='event' and inputs[0]['importance_stars']==4
|
|
|
|
|
|
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_new_member_remains_readable_until_event_represents_it(trading):
|
|
from alphapulse.news_risk.events import reading_inputs
|
|
now=datetime.now(UTC)
|
|
packages=[SimpleNamespace(id=i,updated_at=now,importance_stars=4,result={'title':str(i)},published_revision=1,
|
|
category='security',tags=[]) for i in (1,2,3)]
|
|
event=SimpleNamespace(id=1,updated_at=now,published_revision=1,result={'title':'Incident','importance_stars':4,'category':'security',
|
|
'timeline_points':[{'package_id':1},{'package_id':2}]})
|
|
session=AsyncMock()
|
|
rows=[packages,[SimpleNamespace(package_id=i,event_id=1) for i in (1,2,3)],[event]]
|
|
session.scalars.side_effect=[SimpleNamespace(all=lambda rows=rows:rows) for rows in rows]
|
|
inputs=asyncio.run(reading_inputs(session,now-timedelta(hours=72)))
|
|
assert [(p['kind'],p['id']) for p in inputs]==[('event',1),('package',3)]
|
|
|
|
|
|
@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)
|