feat: organize news packages into verified event timelines
Development CI / check-and-build (push) Successful in 2m33s
GitHub Git mirror / mirror (push) Successful in 5s
Development CI / deploy-development (push) Failing after 7s

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:
eezd committed 2026-11-01 11:46:11 +08:00
1 parent 2f1e07b7b1
commit f1d060d475
28 files changed
+1983 -149

No files matched your search

+88
View File
@@ -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()
+125
View File
@@ -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)
+60 -10
View File
@@ -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, [])
+94
View File
@@ -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()
+7 -4
View File
@@ -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)
+6 -6
View File
@@ -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