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:
1 parent
ae35d3d608
commit
80d266dba7
44 files changed
+2682
-574
No files matched your search
@@ -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
|
||||
@@ -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
@@ -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()
|
||||
@@ -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"><p>Public &amp; original</p></{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 & Ethereum', title).replace('公开摘要 & 数据', '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'
|
||||
@@ -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())
|
||||
|
||||
Reference in new issue
Block a user