feat: organize news packages into verified event timelines
Add optional events, evidence-backed timelines, versioned package corrections and bounded historical reprocessing. Keep independent packages readable and isolate retries by failed stage. Configure reasoning effort per model stage and refresh default news Skill drafts without publishing.
This commit is contained in:
1 parent
2f1e07b7b1
commit
f1d060d475
28 files changed
+1983
-149
No files matched your search
@@ -0,0 +1,88 @@
|
||||
"""News stage overrides and template refreshes preserve shared state."""
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.parametrize('stage,field', [('grouping', 'reasoning_effort'), ('analysis', 'second_reasoning_effort'), ('outlook', 'third_reasoning_effort')])
|
||||
@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):
|
||||
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')
|
||||
class Session:
|
||||
async def get(self, model, identifier):
|
||||
if model is AiProvider: return SimpleNamespace(**vars(provider))
|
||||
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)
|
||||
async def check():
|
||||
_, _, _, before = await pipeline.configuration(Session(), settings, stage)
|
||||
setattr(settings, field, selected)
|
||||
actual, _, rules, fingerprint = await pipeline.configuration(Session(), settings, stage)
|
||||
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
|
||||
asyncio.run(check())
|
||||
|
||||
|
||||
@pytest.mark.parametrize('field', ['reasoning_effort', 'second_reasoning_effort', 'third_reasoning_effort'])
|
||||
def test_effort_validation_and_default(trading, field):
|
||||
from pydantic import ValidationError
|
||||
from alphapulse.api.risk import SettingsIn
|
||||
assert getattr(SettingsIn(), field) is None
|
||||
with pytest.raises(ValidationError): SettingsIn(**{field: '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}
|
||||
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()
|
||||
@@ -0,0 +1,125 @@
|
||||
"""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,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,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)
|
||||
@@ -12,24 +12,24 @@ def pipeline(trading):
|
||||
|
||||
|
||||
def match(pipeline, **changes):
|
||||
return dict(event_id=None, group_key='event', title='公告', category='macro', entities=['央行'],
|
||||
return dict(package_id=None, group_key='event', title='公告', category='macro', entities=['央行'],
|
||||
fact='公告', evidence='公告', urgent=False, tags=['央行'], content_kind='report', **changes)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('event_id,group_key', [(None, None), (1, 'event')])
|
||||
def test_grouping_target_is_exclusive(pipeline, event_id, group_key):
|
||||
@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(event_id=event_id, group_key=group_key)
|
||||
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='政治公告', events=[item, item])
|
||||
pipeline.Grouping(reason='政治公告', packages=[item, item])
|
||||
with pytest.raises(ValueError):
|
||||
pipeline.Grouping(reason='说明', events=[])
|
||||
assert pipeline.Grouping(reason='文化说明', events=[item]).events
|
||||
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)])
|
||||
@@ -61,7 +61,7 @@ def test_old_backfill_is_not_selected_by_ingestion_time(pipeline):
|
||||
def event_result(pipeline, refs):
|
||||
return pipeline.EventResult(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',
|
||||
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)
|
||||
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):
|
||||
@@ -152,8 +152,8 @@ def test_package_scope_survives_direct_and_extracted_roundup(pipeline, monkeypat
|
||||
observed = []
|
||||
async def complete(provider, system, payload, schema):
|
||||
observed.append(payload)
|
||||
if schema is pipeline.Extraction:
|
||||
return pipeline.Extraction(facts=payload['package']['fact'], evidence_refs=[pipeline.Evidence(article_id=1, quote='b')]), {}
|
||||
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, '公司发布新产品')]:
|
||||
@@ -188,3 +188,53 @@ def test_long_event_reserves_real_research_tools_before_extraction(pipeline, mon
|
||||
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):
|
||||
async def complete(provider, system, payload, schema):
|
||||
return schema.model_validate({'facts': '', 'evidence_refs': []}), {}
|
||||
monkeypatch.setattr(pipeline, 'complete', complete)
|
||||
with pytest.raises(ValueError):
|
||||
asyncio.run(pipeline.extract_articles(SimpleNamespace(max_tokens=100, context_window=12000), 'rules',
|
||||
[{'id': 1, 'title': 'Original', 'content': '', 'summary': ''}]))
|
||||
with pytest.raises(ValueError):
|
||||
event_result(pipeline, [])
|
||||
@@ -0,0 +1,94 @@
|
||||
"""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, 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,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,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}] 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,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()
|
||||
@@ -119,19 +119,20 @@ def test_research_stops_after_eight_tool_rounds_and_retains_failures(trading, mo
|
||||
@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
|
||||
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 = [{'id': event.id, 'importance_stars': event.importance_stars, 'result': event.result}]
|
||||
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]
|
||||
session.scalar.side_effect = [latest, None, None]
|
||||
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)
|
||||
@@ -199,9 +200,11 @@ def test_reposts_do_not_trigger_advanced_calls_but_review_changes_do(trading, mo
|
||||
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]
|
||||
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)
|
||||
|
||||
@@ -56,7 +56,7 @@ def mock_enable(risk, monkeypatch, valid=True, verification='reported', directio
|
||||
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='财经维护通知', events=[pipeline.Match(event_id=None,
|
||||
return pipeline.Grouping( reason='财经维护通知', packages=[pipeline.Match(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:
|
||||
@@ -64,7 +64,7 @@ def mock_enable(risk, monkeypatch, valid=True, verification='reported', directio
|
||||
counter_factors='不足', uncertainty='不足', event_ids=[]) for h in (24, 48, 168)]), {}
|
||||
article = payload['articles'][0]
|
||||
result = pipeline.EventResult(**{**label(risk).model_dump(exclude={'event_id', 'same_event_as', 'evidence', 'stars'}), 'verification': verification,
|
||||
'direction': direction}, facts='维护', timeline='维护',
|
||||
'direction': direction}, timeline_points=[dict(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=[])], related_packages=[], excluded_article_ids=[],
|
||||
evidence_refs=[pipeline.Evidence(article_id=1, quote=article['summary'])])
|
||||
return result, {}
|
||||
@@ -207,10 +207,10 @@ def test_long_sources_are_split_without_losing_text(risk, monkeypatch):
|
||||
article = {'id': 1, 'title': '安全事件', 'summary': '', 'content': '安'*16000}
|
||||
seen = []
|
||||
async def complete(provider, system, payload, schema):
|
||||
if schema is pipeline.Extraction:
|
||||
if payload['task'] == 'extract':
|
||||
seen.append(payload['content'])
|
||||
return pipeline.Extraction(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'}), facts='事实', timeline='时间', asset_impacts='资产',
|
||||
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(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'}))
|
||||
@@ -297,7 +297,7 @@ def test_completed_version_recovers_a_previously_stuck_pending_package(risk, mon
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
from alphapulse.news_risk import worker
|
||||
event = SimpleNamespace(id=1, revision=3, published_revision=3, state='pending', error='Old failure',
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user