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.
This commit is contained in:
eezd committed 2026-10-31 22:08:59 +08:00
1 parent ae35d3d608
commit 80d266dba7
44 files changed
+2682 -574

No files matched your search

+190
View File
@@ -0,0 +1,190 @@
"""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
+211
View File
@@ -0,0 +1,211 @@
"""Research and briefing acceptance uses synthetic I/O only."""
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
def test_private_and_mixed_dns_are_rejected(trading, monkeypatch):
from alphapulse.news_risk.research 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))]))
with pytest.raises(ValueError, match='非公开'): await public_address('https://example.invalid/test')
monkeypatch.setattr(loop, 'getaddrinfo', AsyncMock(return_value=[(None, None, None, None, ('8.8.8.8', 80)), (None, None, None, None, ('10.0.0.1', 80))]))
with pytest.raises(ValueError, match='非公开'): await public_address('https://example.invalid/test')
for url in ('file:///etc/passwd', 'http://user:[email protected]', 'http://example.invalid:8000'):
with pytest.raises(ValueError): await public_address(url)
asyncio.run(check())
def test_read_web_requires_discovered_url_and_budget(trading):
from alphapulse.news_risk.research import Research
research = Research()
with pytest.raises(ValueError, match='本轮搜索'):
asyncio.run(research.tool('read_web', {'url': 'https://example.invalid'}))
research.counts['read_web'] = 4
with pytest.raises(ValueError, match='预算'):
asyncio.run(research.tool('read_web', {'url': 'https://example.invalid'}))
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 test_news_pipeline import event_result
result = event_result(pipeline, [pipeline.Evidence(article_id=1, quote='突破')])
result.risk_stars = 0; result.importance_stars = 5
pipeline.validate_event(result, {1: {'title': '科技突破'}})
assert result.importance_stars == 5 and result.risk_stars == 0
def test_public_fetch_pins_address_and_rechecks_redirect(trading, monkeypatch):
import httpx
from alphapulse.news_risk import research
calls = []
original = httpx.AsyncClient
async def resolve(url):
from urllib.parse import urlsplit
if 'private.invalid' in url: raise ValueError('非公开地址')
return urlsplit(url), '8.8.8.8'
async def handle(request):
calls.append(request)
assert request.url.host == '8.8.8.8' and request.headers['host'] == 'public.invalid'
assert request.extensions['sni_hostname'] == 'public.invalid'
return httpx.Response(302, headers={'location': 'http://private.invalid'})
monkeypatch.setattr(research, 'public_address', resolve)
monkeypatch.setattr(httpx, 'AsyncClient', lambda **kwargs: original(transport=httpx.MockTransport(handle)))
with pytest.raises(ValueError, match='非公开'):
asyncio.run(research.fetch_public('https://public.invalid'))
assert len(calls) == 1
def test_failed_review_can_reuse_saved_evidence_without_new_io(trading):
from alphapulse.news_risk.research import Research
research = Research()
record = {'tool': 'read_web', 'arguments': {'url': 'https://example.invalid'}, 'result': {'article_id': -1, 'content': 'Original'}}
research.restore([record])
assert asyncio.run(research.tool('read_web', record['arguments']))['content'] == 'Original'
assert not research.records and research.counts['read_web'] == 0
def test_research_stops_after_eight_tool_rounds_and_retains_failures(trading, monkeypatch):
from alphapulse.news_risk import research
from pydantic import BaseModel
class Result(BaseModel):
summary: str
rounds = []
async def stream(provider, system, messages, tools, effort):
rounds.append(tools)
if len(rounds) == 9:
assert [tool.name for tool in tools] == ['submit']
yield research.llm.Completion(tool_calls=[{'id': 'submit', 'name': 'submit', 'arguments': {'summary': '证据不足'}}])
else:
yield research.llm.Completion(tool_calls=[{'id': str(len(rounds)), 'name': 'unknown_tool', 'arguments': {}}])
monkeypatch.setattr(research.llm, 'stream', stream)
review = research.Research()
result, _ = asyncio.run(review.run(SimpleNamespace(max_tokens=1000, context_window=200000, reasoning_effort=None), 'rules', {}, Result))
assert result.summary == '证据不足'
assert len(rounds) == 9 and len(review.records) == 8
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
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}]
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.scalars.return_value = SimpleNamespace(all=lambda: [] if mode == 'expired' else [event])
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}, {'id': 2}]))
assert complete.await_count == 1
@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]
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)])
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
+146 -122
View File
@@ -1,6 +1,5 @@
"""Risk evidence, identity and model boundaries use synthetic inputs only."""
import asyncio
import json
from datetime import UTC, datetime
import pytest
@@ -47,44 +46,65 @@ def test_evidence_retains_quotes_present_in_source(risk):
assert item.evidence == '“安全事件”'
@pytest.mark.parametrize('valid', [True, False])
def test_enable_validates_evidence_before_saving_settings(risk, monkeypatch, valid):
def mock_enable(risk, monkeypatch, valid=True, verification='reported', direction='neutral'):
from types import SimpleNamespace
from unittest.mock import AsyncMock
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='财经维护通知', events=[pipeline.Match(event_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]
result = pipeline.EventResult(**{**label(risk).model_dump(exclude={'event_id', 'same_event_as', 'evidence', 'stars'}), 'verification': verification,
'direction': direction}, 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, {}
async def high(provider, system, payload, schema):
if schema is briefing.Ranked:
return briefing.Ranked(reading=[briefing.Reading(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
session = SimpleNamespace(merge=AsyncMock(), commit=AsyncMock())
provider = SimpleNamespace(model='news-only')
monkeypatch.setattr(api, 'provider_for', AsyncMock(return_value=provider))
async def complete(actual_provider, system, payload, schema):
assert actual_provider is provider
news = payload['news'][0]
assert news['content'] and news['summary']
evidence = '“'+news['summary']+'”' if valid else '虚构的资产损失6%'
return risk.Labels(items=[label(risk).model_copy(update={'evidence': evidence})]), {}
monkeypatch.setattr(api, 'complete', complete)
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.post('/api/risk/enable', json={'provider_id': 1, 'model': 'news-only'})
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.model == 'news-only'
session.commit.assert_awaited_once()
else:
assert '模型证据并非输入原文引用' in response.json()['detail']
session.merge.assert_not_awaited()
session.commit.assert_not_awaited()
asyncio.run(check())
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
session.commit.assert_awaited_once()
else:
assert '原文引用' in response.text
session.merge.assert_not_awaited()
@pytest.mark.parametrize('verification,direction,status', [
@@ -92,40 +112,14 @@ def test_enable_validates_evidence_before_saving_settings(risk, monkeypatch, val
('insufficient', 'bearish', 502), ('insufficient', 'mixed', 502), ('reported', 'neutral', 200),
])
def test_enable_requires_consistent_uncertainty_without_changing_model_output(risk, monkeypatch, verification, direction, 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(merge=AsyncMock(), commit=AsyncMock())
monkeypatch.setattr(api, 'provider_for', AsyncMock(return_value=object()))
item = label(risk).model_copy(update={'verification': verification, 'direction': direction})
async def complete(provider, system, payload, schema):
item.evidence = payload['news'][0]['summary']
return risk.Labels(items=[item]), {}
monkeypatch.setattr(api, 'complete', complete)
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.post('/api/risk/enable', json={'provider_id': 1, 'model': 'news-only'})
assert response.status_code == status, response.text
assert item.direction == direction
if status == 200:
session.merge.assert_awaited_once()
session.commit.assert_awaited_once()
else:
assert 'direction 必须为 unknown' in response.json()['detail']
assert direction in response.json()['detail']
session.merge.assert_not_awaited()
session.commit.assert_not_awaited()
asyncio.run(check())
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):
@@ -175,10 +169,11 @@ def test_same_batch_grouping_must_not_cycle(risk):
def test_truncated_completion_is_not_a_safe_label(risk, monkeypatch):
from types import SimpleNamespace
async def stream(*args):
yield risk.llm.Completion(truncated=True)
yield risk.llm.Completion(truncated=True, usage={'output_tokens': 100})
monkeypatch.setattr(risk.llm, 'stream', stream)
with pytest.raises(ValueError, match='截断'):
with pytest.raises(risk.OutputTruncated, match='截断') as exc:
asyncio.run(risk.complete(SimpleNamespace(max_tokens=100, context_window=10000, reasoning_effort=None), '', {}, risk.Labels))
assert exc.value.usage == {'output_tokens': 100}
def test_model_override_never_changes_shared_provider(risk):
@@ -192,69 +187,35 @@ def test_model_override_never_changes_shared_provider(risk):
assert session.detached == 'original' and result.model == 'LUNA'
def test_worker_correction_and_late_backfill_keep_current_and_peak_ratings(risk, monkeypatch):
def test_debounce_is_bounded_and_urgent_bypasses_wait(risk):
from datetime import timedelta
from types import SimpleNamespace
from unittest.mock import AsyncMock
from alphapulse.news_risk import worker
from alphapulse.models import NewsItem
from alphapulse.models.risk import RiskAnalysis, RiskEvent, RiskSettings
from alphapulse.news_risk.worker import schedule
now = datetime.now(UTC)
original = label(risk)
event = RiskEvent(id=7, title=original.title, category='security', entities=['Exchange'], stars=5,
peak_stars=5, result=original.model_dump(), published_at=now-timedelta(minutes=2), updated_at=now-timedelta(minutes=2))
rows = [RiskAnalysis(id=i, news_id=i, content_hash=str(i), state='pending', input={
'title': '安全事件', 'published_at': (now-timedelta(minutes=minutes)).isoformat(),
'progress_at': (now-timedelta(minutes=minutes)).isoformat()}) for i, minutes in [(2, 1), (1, 3)]]
correction = original.model_copy(update={'id': 2, 'event_id': 7, 'stars': 1, 'verification': 'refuted'})
historical = original.model_copy(update={'id': 1, 'event_id': 7})
class Session:
def __init__(self): self.selections = iter([rows, [event]])
async def __aenter__(self): return self
async def __aexit__(self, *args): pass
async def get(self, model, identifier, **kwargs):
if model is RiskSettings: return SimpleNamespace(enabled=True)
if model is NewsItem: return SimpleNamespace(content_hash=str(identifier))
if model is RiskEvent: return event
raise AssertionError(model)
async def scalars(self, query):
values = next(self.selections)
return SimpleNamespace(all=lambda: values)
async def commit(self): pass
monkeypatch.setattr(worker, 'SessionLocal', Session)
monkeypatch.setattr(worker, 'provider_for', AsyncMock(return_value=SimpleNamespace(model='news-only', max_tokens=1000, context_window=200000)))
monkeypatch.setattr(worker, 'find_candidates', AsyncMock(return_value=([event], {1: 7, 2: 7})))
monkeypatch.setattr(worker, 'enqueue', AsyncMock())
monkeypatch.setattr(worker, 'complete', AsyncMock(return_value=(risk.Labels(items=[historical, correction]), {'input_tokens': 10})))
asyncio.run(worker.analyze())
assert event.stars == 1 and event.peak_stars == 5
assert event.result['verification'] == 'refuted'
assert event.published_at == now-timedelta(minutes=3)
assert all(row.state == 'done' and row.event_id == 7 for row in rows)
assert rows[0].usage == {'input_tokens': 10} and rows[1].usage is None
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_batch_packing_keeps_other_articles_queued_and_retains_linked_event(risk):
def test_long_sources_are_split_without_losing_text(risk, monkeypatch):
from types import SimpleNamespace
from alphapulse.news_risk.worker import pack_batch, batch_payload
now = datetime.now(UTC)
event = SimpleNamespace(id=7, title='安全事件', category='security', entities=['Exchange'],
published_at=now, updated_at=now, result={'summary': '安全事件'})
rows = [SimpleNamespace(id=i, news_id=i, input={'title': '安全事件', 'content': 'x'*1000}) for i in range(10)]
base = len((risk.SYSTEM + json.dumps(risk.Labels.model_json_schema(), ensure_ascii=False)).encode())
provider = SimpleNamespace(max_tokens=256, context_window=base+2000)
packed, candidates, payload = pack_batch(provider, rows, [event], {0: 7})
assert 0 < len(packed) < len(rows)
assert candidates == [event] and risk.fits_context(provider, risk.SYSTEM, payload, risk.Labels)
assert all(len(r.input['content']) == 1000 for r in rows)
rows[0].input['content'] = 'x'*10000
packed, candidates, payload = pack_batch(provider, rows, [event], {0: 7})
assert packed == [rows[0]] and candidates == [event]
assert not risk.fits_context(provider, risk.SYSTEM, payload, risk.Labels)
assert len(batch_payload(rows[1:], [])['news']) == 9
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 schema is pipeline.Extraction:
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='资产',
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):
@@ -263,7 +224,7 @@ def test_context_budget_includes_unicode_and_tool_schema(risk):
assert not risk.fits_context(provider, risk.SYSTEM, {'content': '风'*1000}, risk.Labels)
@pytest.mark.parametrize('endpoint', ['overview', 'events'])
@pytest.mark.parametrize('endpoint', ['overview', 'summaries'])
@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
@@ -288,3 +249,66 @@ def test_window_query_parameters_over_http(risk, monkeypatch, endpoint, hours, s
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', summary_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='analysis'))
assert exc.value.status_code == 409 and reason in exc.value.detail
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(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, 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()
+93
View File
@@ -316,3 +316,96 @@ def test_bls_transport_error_reaches_existing_scrapy_retry_boundary(trading, mon
with pytest.raises(httpx.ReadTimeout, match="transient timeout") as exc:
asyncio.run(rss.BlsDownloadHandler(15).download_request(Request("https://www.bls.gov/feed/cpi.rss")))
assert isinstance(exc.value, tuple(x for x in rss.BlsSpider.custom_settings["RETRY_EXCEPTIONS"] if isinstance(x, type)))
@pytest.mark.parametrize('kind', ['summary', 'content'])
def test_custom_atom_uses_article_link_and_preserves_html_and_scope(trading, kind):
from alphapulse.crawler.spiders.rss import CustomRssSpider
from alphapulse.crawler.spiders.base import RunState
spider = CustomRssSpider(source_code='rss_'+'a'*24)
now = datetime.now(UTC)
spider.run = RunState(uuid.uuid4(), 'schedule', now, now-timedelta(hours=72))
response = XmlResponse(url='https://example.invalid/feed.xml', body=f'''<feed xmlns="http://www.w3.org/2005/Atom"><entry>
<title>Public statement</title><id>statement-1</id><published>2026-10-31T00:00:00Z</published>
<link rel="self" href="https://example.invalid/entry.xml"/><link rel="alternate" href="https://example.invalid/article"/>
<{kind} type="html">&lt;p&gt;Public &amp;amp; original&lt;/p&gt;</{kind}><category term="Politics"/>
</entry></feed>'''.encode(), request=Request('https://example.invalid/feed.xml', meta={'raw_response_id': 12}))
item, = spider.parse_feed(response)
assert item['url'] == 'https://example.invalid/article'
assert item['content_text'] == 'Public & original'
assert item['tags'] == ['Politics']
assert item['extra']['content_scope'] == ('feed_content' if kind == 'content' else 'feed_summary')
def test_new_rss_is_paused_and_discovered_without_manager_restart(trading, monkeypatch):
from unittest.mock import AsyncMock
from alphapulse.api.crawler import RssIn, create_rss
from alphapulse.crawler import manager
from alphapulse.news_risk import research
added = []
session = SimpleNamespace(add=added.append, commit=AsyncMock())
monkeypatch.setattr(research, 'public_address', AsyncMock())
async def check():
await create_rss(RssIn(name='Global news', url='https://example.invalid/feed.xml'), session)
source, = added
assert source.adapter == 'rss' and source.enabled is False
source.paused_until = None
fake_session = AsyncMock()
fake_session.__aenter__.return_value = fake_session
fake_session.scalars.return_value = SimpleNamespace(all=lambda: [source])
monkeypatch.setattr(manager, 'SessionLocal', lambda: fake_session)
instance = manager.Manager()
supervised = []
async def supervise(worker): supervised.append(worker.code)
instance.supervise = supervise
async with asyncio.TaskGroup() as group:
instance.supervisor_group = group
await instance.schedule_once(set())
await asyncio.sleep(0)
await instance.schedule_once(set())
assert supervised == [source.code]
assert instance.workers[source.code].adapter == 'rss'
asyncio.run(check())
@pytest.mark.parametrize('encoding,title', [('iso-8859-1', 'Café résumé'), ('utf-8', '中文说明 Café')])
@pytest.mark.parametrize('compressed', [False, True])
def test_custom_feed_preserves_declared_xml_encoding_through_public_downloader(trading, monkeypatch, encoding, title, compressed):
import gzip
from urllib.parse import urlsplit
from unittest.mock import AsyncMock
import httpx
from alphapulse.crawler.spiders import rss
from alphapulse.news_risk import research
url = 'https://example.invalid/feed.xml'
item_xml = ITEM.replace('Bitcoin &amp; Ethereum', title).replace('公开摘要 &amp; 数据', '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)
original_client = httpx.AsyncClient
def handle(request):
assert request.url.host == '8.8.8.8' and request.headers['host'] == 'example.invalid'
headers = {'content-type': f'application/rss+xml; charset={encoding}'}
if compressed: headers['content-encoding'] = 'gzip'
return httpx.Response(200, headers=headers, stream=httpx.ByteStream(gzip.compress(body) if compressed else body))
monkeypatch.setattr(research, 'public_address', AsyncMock(return_value=(urlsplit(url), '8.8.8.8')))
monkeypatch.setattr(httpx, 'AsyncClient', lambda **kwargs: original_client(transport=httpx.MockTransport(handle), **kwargs))
response = asyncio.run(rss.PublicFeedDownloadHandler().download_request(Request(url, meta={'raw_response_id': 12})))
assert response.body == body and 'content-encoding' not in response.headers
assert response.headers['content-type'].decode() == f'application/rss+xml; charset={encoding}'
spider = make_spider(rss.CoinDeskSpider)
item, = spider.parse_feed(response)
assert item['title'] == title and not spider.run.errors
def test_custom_feed_keeps_http_failure_status_for_scrapy_retry(trading, monkeypatch):
from urllib.parse import urlsplit
from unittest.mock import AsyncMock
import httpx
from alphapulse.crawler.spiders import rss
from alphapulse.news_risk import 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')))
transport = httpx.MockTransport(lambda request: httpx.Response(503, content=b'Service unavailable'))
monkeypatch.setattr(httpx, 'AsyncClient', lambda **kwargs: original_client(transport=transport, **kwargs))
response = asyncio.run(rss.PublicFeedDownloadHandler().download_request(Request(url)))
assert response.status == 503 and response.body == b'Service unavailable'
+1 -1
View File
@@ -57,7 +57,7 @@ def test_patch_provider_default_contract(trading, monkeypatch, initial, requeste
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)
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(return_value=reference), delete=AsyncMock(),
flush=AsyncMock(), commit=AsyncMock())