Files
AlphaPulse/backend/tests/test_news_pipeline.py
T
eezd 80d266dba7 Add three-stage cross-domain news research and reading briefings
Group original news into tagged fact packages, review each package with evidence and read-only research, and build complete 72-hour reading lists with independent model selections.

Add paused RSS/Atom subscriptions, news-scoped Skills, versioned analysis records, and locked deployment support for SearXNG. Preserve original news and keep live migration and legacy AI cleanup separately authorized.

Validate with 604 backend tests, 49 frontend tests, 19 CI control tests, lint, type checks, production build, and disposable PostgreSQL migration and three-stage smoke checks.
2026-10-31 22:08:59 +08:00

191 lines
11 KiB
Python

"""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
return pipeline
def match(pipeline, **changes):
return dict(event_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):
data = match(pipeline)
data.update(event_id=event_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])
with pytest.raises(ValueError):
pipeline.Grouping(reason='说明', events=[])
assert pipeline.Grouping(reason='文化说明', events=[item]).events
@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='原文引用'):
pipeline.validate_evidence([pipeline.Evidence(article_id=1, quote='交易所暂停提现')], articles)
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=[],
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)
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
@pytest.mark.parametrize('invalid', ['missing_source', 'rewritten_quote', 'no_progress'])
def test_compaction_rejects_lost_provenance_and_stops_without_progress(pipeline, monkeypatch, invalid):
provider = SimpleNamespace(max_tokens=100, context_window=8000)
extracts = [{'facts': 'x'*3000, 'evidence_refs': [{'article_id': i, 'quote': 'original'}]} for i in (1, 2)]
calls = []
async def complete(provider, system, payload, schema):
calls.append(payload)
refs = [r for e in payload['extractions'] for r in e['evidence_refs']]
if invalid == 'missing_source': refs = refs[:1]
if invalid == 'rewritten_quote': refs[0] = {**refs[0], 'quote': 'invented'}
facts = 'x'*10000 if invalid == 'no_progress' else 'compressed'
return pipeline.Extraction(facts=facts, evidence_refs=refs), {}
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))
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 schema is pipeline.Extraction:
return pipeline.Extraction(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