feat(news): index news packages and research persistent events

Extract reusable literal keywords once per source revision, assign complete news packages to long-lived events, and research all event members with immutable evidence and references.

Add additive research storage, vocabulary snapshots, the two-stage workspace, isolated acceptance coverage, and a paused rollout with an initial fixed 50-news production trial.
This commit is contained in:
eezd committed 2026-11-04 12:09:58 +08:00
1 parent 07252fea0f
commit 4929a32a0a
69 files changed
+8215 -2092

No files matched your search

+199
View File
@@ -0,0 +1,199 @@
{
"news": [
{
"id": 1001,
"source_code": "wscn_news",
"external_id": "synthetic-1001",
"title": "虚构验收材料",
"summary": "",
"content_text": "以下为完全虚构的隔离验收新闻,不对应现实事件。星桥交易所(XBridge)公告:2025年12月15日发生A号钱包被盗案,初步损失100万元。",
"content_hash": "f02937d18c6bdd36dab4b3c24ec0a5bc2479c5804b520cdd21860ad9fecc27b7",
"url": "https://example.invalid/1001",
"published_at": "2025-12-16T12:00:00+00:00"
},
{
"id": 1002,
"source_code": "wscn_news",
"external_id": "synthetic-1002",
"title": "虚构验收材料",
"summary": "",
"content_text": "以下为完全虚构的隔离验收新闻,不对应现实事件。本消息转载星桥交易所12月16日公告:2025年12月15日发生A号钱包被盗案,初步损失100万元。无新增事实。",
"content_hash": "27affb99835472e5217ced064faa963cb74232555ad87ab8545d03b6c892448a",
"url": "https://example.invalid/1002",
"published_at": "2025-12-17T12:00:00+00:00"
},
{
"id": 1003,
"source_code": "wscn_news",
"external_id": "synthetic-1003",
"title": "虚构验收材料",
"summary": "",
"content_text": "以下为完全虚构的隔离验收新闻,不对应现实事件。独立审计机构完成链上核查,确认XBridge的2025年12月15日A号钱包被盗案存在未经授权的转出,为该案新增独立证据。",
"content_hash": "5f5cc2c46643ac24456a94510309bdd43da7ba9b2445e4cf48f3d2df924e3fe6",
"url": "https://example.invalid/1003",
"published_at": "2026-02-02T12:00:00+00:00"
},
{
"id": 1004,
"source_code": "wscn_news",
"external_id": "synthetic-1004",
"title": "虚构验收材料",
"summary": "",
"content_text": "以下为完全虚构的隔离验收新闻,不对应现实事件。星桥交易所更正2025年12月15日A号钱包被盗案损失:经审计为80万元,原初步100万元被更正。",
"content_hash": "0fe85ccb8202bfc3e70a7d0ab93d7586d1ee0363e98ffde32192b1ace462ac7b",
"url": "https://example.invalid/1004",
"published_at": "2026-05-20T12:00:00+00:00"
},
{
"id": 1005,
"source_code": "wscn_news",
"external_id": "synthetic-1005",
"title": "虚构验收材料",
"summary": "",
"content_text": "以下为完全虚构的隔离验收新闻,不对应现实事件。星桥交易所宣布,2025年12月15日A号钱包被盗案的80万元赔付已于2026年7月19日完成,后续调查披露时间未知。",
"content_hash": "eeac23a20c8c1ae898559c8522dde9b3fbff4b561bb8916ef68f0432b1dc7114",
"url": "https://example.invalid/1005",
"published_at": "2026-07-20T12:00:00+00:00"
},
{
"id": 1006,
"source_code": "wscn_news",
"external_id": "synthetic-1006",
"title": "虚构验收材料",
"summary": "",
"content_text": "以下为完全虚构的隔离验收新闻,不对应现实事件。星桥交易所公告:2026年5月24日发生独立的B号热钱包被盗案,与2025年12月15日A号案不同,损失20万元。A号案仅作区别背景。",
"content_hash": "5595a83480c4b4d94ebdaf57794452851fbe32f6138ed301819ce0cf486d2c8f",
"url": "https://example.invalid/1006",
"published_at": "2026-05-25T12:00:00+00:00"
},
{
"id": 1007,
"source_code": "wscn_news",
"external_id": "synthetic-1007",
"title": "虚构验收材料",
"summary": "",
"content_text": "以下为完全虚构的隔离验收新闻,不对应现实事件。虚构监管机构发布新的全行业钱包托管规则。这是独立政策决策,明确以星桥交易所2025年12月15日A号被盗案及其审计更正结论为制定依据,不是A号案调查进展。",
"content_hash": "ce097bc5befd683ba24b1e9d6e3a6c9b9c86e2bb52d02563d2e3ba8c5aa9df71",
"url": "https://example.invalid/1007",
"published_at": "2026-06-01T12:00:00+00:00"
},
{
"id": 1008,
"source_code": "wscn_news",
"external_id": "synthetic-1008",
"title": "虚构验收材料",
"summary": "",
"content_text": "以下为完全虚构的隔离验收新闻,不对应现实事件。虚构标准协会发布独立的行业安全标准,以星桥交易所2025年12月15日A号被盗案为研究案例和对象,引用其审计更正结论。标准覆盖全行业,并非该案处置公告。",
"content_hash": "c439d7b7ab09e6038cbce1e9cec9ef1787b5ebbc3c61f17d488dce9b5be95918",
"url": "https://example.invalid/1008",
"published_at": "2026-06-02T12:00:00+00:00"
},
{
"id": 1009,
"source_code": "wscn_news",
"external_id": "synthetic-1009",
"title": "虚构验收材料",
"summary": "",
"content_text": "以下为完全虚构的隔离验收新闻,不对应现实事件。虚构甲国统计局5月10日发布2026年4月CPI,同比增长2%,环比0.1%。统计期为2026年4月。",
"content_hash": "39c3f342f2529c59a3313d414dd3e16556bf44be03267e3c0461c8741051ddd2",
"url": "https://example.invalid/1009",
"published_at": "2026-05-10T12:00:00+00:00"
},
{
"id": 1010,
"source_code": "wscn_news",
"external_id": "synthetic-1010",
"title": "虚构验收材料",
"summary": "",
"content_text": "以下为完全虚构的隔离验收新闻,不对应现实事件。受甲国5月10日发布的2026年4月CPI数据直接影响,甲币当日上涨0.2%。",
"content_hash": "cd75149b7264d821c050be40d766b3bdfb5c1ca122fdffd85e63666474bbdfb1",
"url": "https://example.invalid/1010",
"published_at": "2026-05-10T12:00:00+00:00"
},
{
"id": 1011,
"source_code": "wscn_news",
"external_id": "synthetic-1011",
"title": "虚构验收材料",
"summary": "",
"content_text": "以下为完全虚构的隔离验收新闻,不对应现实事件。虚构甲国统计局6月10日发布2026年5月CPI,同比增长3%。统计期为2026年5月,与4月不同。",
"content_hash": "fd6f8e1a0a660791af60376840f4ce35403cfc27aa29f56b92dc8f6971fd53fc",
"url": "https://example.invalid/1011",
"published_at": "2026-06-10T12:00:00+00:00"
},
{
"id": 1012,
"source_code": "wscn_news",
"external_id": "synthetic-1012",
"title": "虚构验收材料",
"summary": "",
"content_text": "以下为完全虚构的隔离验收新闻,不对应现实事件。虚构甲国央行6月11日作出独立降息决策,下调利率25个基点。决策参考2026年5月CPI,不能视为CPI数据发布本身。",
"content_hash": "d85455695fe1b07b217af1c7999c1339e6d052ace3a9284c8dbfa98fc92d89d0",
"url": "https://example.invalid/1012",
"published_at": "2026-06-11T12:00:00+00:00"
}
],
"batches": [
[
1001
],
[
1002
],
[
1003,
1004
],
[
1006,
1007,
1008
],
[
1005
],
[
1009,
1010
],
[
1011
],
[
1012
]
],
"must_link": [
[
1001,
1002,
1003,
1004,
1005
],
[
1009,
1010
]
],
"must_separate": [
1001,
1006,
1007,
1008,
1009,
1011,
1012
],
"references": [
[
1007,
1001
],
[
1008,
1001
]
],
"expected_main_versions": 3
}
+329
View File
@@ -0,0 +1,329 @@
"""Event-scoped research preserves fact coverage and amortizes repeated work."""
import asyncio
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace as N
from unittest.mock import AsyncMock, MagicMock
import pytest
def test_event_debounce_has_hard_deadline_and_sticky_urgency(trading):
from alphapulse.news_risk.event_research import schedule
now=datetime.now(UTC)
event=N(revision=0,state='pending',dirty_since=None,due_at=now,error=None)
schedule(event,now)
assert event.due_at==now+timedelta(minutes=5)
for minute in (4,8,12,14): schedule(event,now+timedelta(minutes=minute))
assert event.due_at==now+timedelta(minutes=15)
event.state='done'; event.dirty_since=None
schedule(event,now+timedelta(minutes=20),True)
schedule(event,now+timedelta(minutes=20))
assert event.due_at==now+timedelta(minutes=20)
@pytest.mark.parametrize('event_id,group_key',[(None,None),(1,'new')])
def test_stage_one_event_identity_is_exclusive(trading,event_id,group_key):
from alphapulse.news_risk.pipeline import EventTarget
with pytest.raises(ValueError): EventTarget(event_id=event_id,group_key=group_key,title='Title',fact='Fact',reason='Reason')
def test_pure_repost_updates_tags_without_research(trading):
from alphapulse.models.risk import EventMember,NewsEvent
from alphapulse.news_risk.event_research import attach
now=datetime.now(UTC)
event=N(id=2,state='done',tags=['old'],revision=3,updated_at=now,review_config=None)
membership=N(event_id=2)
session=N(get=AsyncMock(side_effect=lambda model,*a,**kw:membership if model is EventMember else event),add=MagicMock())
target=N(event_id=2,group_key=None)
asyncio.run(attach(session,N(id=1,tags=['new'],updated_at=now),target,{},False,False,None))
assert event.revision==3 and event.state=='done' and event.tags==['new','old']
assert session.get.call_args.args[0] is NewsEvent
def test_existing_package_cannot_be_moved_by_grouping(trading):
from alphapulse.news_risk.event_research import attach
session=N(get=AsyncMock(return_value=N(event_id=2)))
with pytest.raises(ValueError,match='沿用主事件'):
asyncio.run(attach(session,N(id=1),N(event_id=3),{},True,False,None))
@pytest.fixture
def event_database(trading):
from sqlalchemy import create_engine
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.ext.compiler import compiles, deregister
from alphapulse.models.risk import NewsEvent, EventMember
@compiles(JSONB, 'sqlite')
def jsonb_sqlite(type_, compiler, **kw):
return 'JSON'
engine = create_engine('sqlite:///:memory:')
try:
NewsEvent.__table__.create(engine)
EventMember.__table__.create(engine)
yield engine
finally:
engine.dispose()
deregister(JSONB)
@pytest.mark.parametrize('action', ['attach_new', 'attach_existing', 'dirty'])
@pytest.mark.parametrize('retired', [False, True])
def test_event_mutations_refresh_cached_versions_and_reject_retirement(event_database, action, retired):
from sqlalchemy import select, update
from sqlalchemy.orm import Session
from alphapulse.models.risk import NewsEvent, EventMember
from alphapulse.news_risk.event_research import attach
from alphapulse.news_risk.events import dirty_event
now = datetime.now(UTC).replace(tzinfo=None)
with Session(event_database) as seed:
seed.add(NewsEvent(id=7, title='Incident', fact='Fact', tags=[], revision=10,
published_revision=10, state='done', updated_at=now, due_at=now))
if action != 'attach_new':
seed.add(EventMember(package_id=1, event_id=7, reason='Original'))
seed.commit()
with Session(event_database, expire_on_commit=False) as first:
cached = first.scalar(select(NewsEvent))
first.commit()
with Session(event_database) as writer:
writer.execute(update(NewsEvent).where(NewsEvent.id == 7).values(
revision=12, state='retired' if retired else 'done'))
writer.commit()
class Adapter:
async def get(self, *args, **kwargs):
return first.get(*args, **kwargs)
def add(self, obj):
first.add(obj)
async def flush(self):
first.flush()
async def mutate():
if action == 'dirty':
await dirty_event(Adapter(), 1)
else:
await attach(Adapter(), N(id=1, tags=['new'], updated_at=now),
N(event_id=7, group_key=None, reason='Followup'), {}, True, True, None)
if retired:
with pytest.raises(ValueError, match='事件已失效'):
asyncio.run(mutate())
else:
asyncio.run(mutate())
assert cached.revision == 13 and cached.state == 'pending'
first.commit()
with Session(event_database) as check:
event = check.get(NewsEvent, 7)
assert (event.revision, event.state) == ((12, 'retired') if retired else (13, 'pending'))
if retired and action == 'attach_new':
assert check.get(EventMember, 1) is None
@pytest.mark.parametrize('package_config', [None, {'job_id': 98, 'request': {}}])
@pytest.mark.parametrize('keep_id', [1, None])
def test_package_correction_keeps_owning_event_frozen_configuration(trading, monkeypatch, package_config, keep_id):
from alphapulse.models import NewsItem
from alphapulse.models.risk import ArticleAnalysis, EventMember, NewsEvent, NewsSummary
from alphapulse.news_risk import backfill
from alphapulse.news_risk.contracts import Correction
from alphapulse.news_risk.corrections import apply_correction
now = datetime.now(UTC)
config = {'job_id': 99, 'request': {'run_mode': 'history_only'}}
package = N(id=1, revision=1, replacement_ids=[], fact='Original', tags=['old'], review_config=package_config)
article = N(id=11, news_id=21, content_hash='hash', input={'content': 'Original',
'published_at': now.isoformat(), 'progress_at': now.isoformat()})
link = N(summary_id=1, analysis_id=11, active=True, unit={})
membership = N(event_id=7)
event = N(id=7, revision=1, state='processing', dirty_since=now, due_at=now, review_config=config)
added = []
async def get(model, identifier, **kwargs):
if model is EventMember:
return membership if identifier == 1 else None
return {ArticleAnalysis: article, NewsItem: N(content_hash='hash'), NewsEvent: event}[model]
async def flush():
for obj in added:
if isinstance(obj, NewsSummary) and obj.id is None:
obj.id = 2
session = N(scalars=AsyncMock(side_effect=[N(all=lambda: [package]), N(all=lambda: [link]), [7]]),
scalar=AsyncMock(return_value=None), get=get, add=added.append, flush=flush, delete=AsyncMock())
enroll = AsyncMock()
monkeypatch.setattr(backfill, 'target', enroll)
proposal = Correction(packages=[{'id': 1, 'revision': 1}], replacements=[{'keep_id': keep_id,
'title': 'Corrected', 'fact': 'Original', 'category': '其他', 'tags': ['corrected'], 'content_kind': 'report',
'assignments': [{'analysis_id': 11, 'evidence': 'Original'}]}], reason='Correct boundary')
asyncio.run(apply_correction(session, proposal, 1, {1}, {}))
assert event.state == 'pending' and event.revision == 2 and event.review_config == config
enroll.assert_awaited_once_with(session, 99, 'events', event)
output = package if keep_id else next(obj for obj in added if isinstance(obj, NewsSummary))
assert output.review_config == config
@pytest.mark.parametrize('tool', ['read_event', 'read_package'])
def test_retry_refreshes_mutable_reads_and_caches_them_only_within_the_attempt(trading, monkeypatch, tool):
from alphapulse.models.risk import NewsEvent, NewsSummary
from alphapulse.news_risk import research
reader = research.Research(event={'id': 7, 'revision': 1})
reader.restore([{'tool': tool, 'arguments': {'id': 2}, 'result': {'id': 2, 'revision': 1}}])
assert not reader.read_event_ids and not reader.read_package_ids
row = N(id=2, revision=2, title='Current', fact='Current', tags=[], result=None, replacement_ids=[], state='done')
session = MagicMock()
session.__aenter__ = AsyncMock(return_value=session)
session.__aexit__ = AsyncMock(return_value=False)
session.get = AsyncMock(side_effect=lambda model, *a, **kw: row if model in (NewsEvent, NewsSummary) else None)
empty = MagicMock()
empty.all.return_value = []
empty.__iter__.return_value = iter([])
session.scalars = AsyncMock(return_value=empty)
storage = MagicMock(return_value=session)
monkeypatch.setattr(research, 'SessionLocal', storage)
async def check():
first = await reader.tool(tool, {'id': 2})
assert first['revision'] == 2
assert await reader.tool(tool, {'id': 2}) == first
asyncio.run(check())
assert reader.counts['read'] == 1 and len(reader.records) == 1
storage.assert_called_once_with()
if tool == 'read_event':
assert reader.read_event_versions == {2: 2}
else:
assert reader.read_package_ids == {2}
def result(trading,package_ids=(1,2)):
from alphapulse.news_risk.event_research import EventResult
refs=[{'article_id':i,'quote':f'Original {i}'} for i in package_ids]
return EventResult(id=7,title='Incident',summary='Summary',category='安全',entities=[],risk_stars=2,importance_stars=3,
direction='unknown',verification='insufficient',confidence='low',reasoning='Unknown',uncertainty='Unknown',
facts='Facts',timeline='Timeline',asset_impacts='Unknown',opinions='Unknown',counter_evidence='Unknown',watchpoints='Updates',
claims=[{'statement':'Claim','verdict':'insufficient','evidence_refs':[]}],evidence_refs=refs,
timeline_points=[{'package_id':i,'package_revision':1,'occurred_at':None,'time_precision':'unknown','time_text':'',
'reported_at':None,'description':'Fact','verification':'partial','evidence_refs':[r]} for i,r in zip(package_ids,refs,strict=True)])
@pytest.mark.parametrize('fault',['omitted','revision','wrong_member','invented_quote','direction'])
def test_event_validation_covers_each_member_with_its_own_original(trading,fault):
from alphapulse.news_risk.event_research import validate
output=result(trading)
packages=[{'id':i,'revision':1,'article_ids':[i]} for i in (1,2)]
sources={i:{'content':f'Original {i}'} for i in (1,2)}
if fault=='omitted':output.timeline_points.pop()
if fault=='revision':output.timeline_points[0].package_revision=2
if fault=='wrong_member':output.timeline_points[0].evidence_refs=[{'article_id':2,'quote':'Original 2'}]
if fault=='invented_quote':output.evidence_refs[0].quote='Invented'
if fault=='direction':output.direction='bullish'
with pytest.raises(ValueError):validate(output,packages,sources)
def test_event_schema_has_no_per_package_research_or_event_proposal(trading):
from alphapulse.news_risk.event_research import EventResult
fields=EventResult.model_fields
assert 'event_proposal' not in fields and 'related_packages' not in fields
assert 'package_correction' in fields and 'correction' in fields
def test_self_event_and_supplied_package_reads_do_not_use_storage(trading,monkeypatch):
from alphapulse.news_risk import research
guard=MagicMock(side_effect=AssertionError('Supplied inputs must not trigger storage'))
monkeypatch.setattr(research,'SessionLocal',guard)
reader=research.Research(event={'id':7,'revision':2})
reader.provided_packages={1:{'id':1,'revision':1,'fact':'Provided'}}
async def check():
assert (await reader.tool('read_event',{'id':7}))['status']=='already_provided'
assert (await reader.tool('read_package',{'id':1}))['status']=='already_provided'
asyncio.run(check());guard.assert_not_called()
assert reader.counts['read']==0
def test_grouping_budget_keeps_both_package_and_event_candidates(trading):
from alphapulse.news_risk.pipeline import grouping_payload,Grouping,fits_context
provider=N(max_tokens=1000,context_window=14000)
candidates=[{'id':i,'fact':'p'*500} for i in range(50)]
events=[{'id':i,'fact':'e'*500} for i in range(50)]
payload,_=asyncio.run(grouping_payload(provider,'rules',{'id':1,'title':'Original','content':'Original'},candidates,events))
assert payload['candidates'] and payload['events']
assert len(payload['candidates'])<50 and len(payload['events'])<50
assert fits_context(provider,'rules',payload,Grouping)
def test_long_event_rejects_extraction_that_loses_an_original(trading,monkeypatch):
from alphapulse.news_risk import event_research as module
reader=N(fits=lambda *a:False,run=AsyncMock())
monkeypatch.setattr(module,'extract_articles',AsyncMock(return_value=([{'facts':'One','evidence_refs':[{'article_id':1,'quote':'One'}]}],[{'input_tokens':9}])))
with pytest.raises(ValueError,match='遗漏事件原文') as error:
asyncio.run(module.generate(N(),'rules',{'articles':[{'id':1},{'id':2}],'packages':[]},reader))
assert error.value.usage['parts']==[{'input_tokens':9}]
reader.run.assert_not_awaited()
def test_long_event_extracts_every_original_and_keeps_all_member_ids(trading,monkeypatch):
from alphapulse.news_risk import event_research as module, pipeline
from alphapulse.news_risk.research import Research
provider=N(max_tokens=1500,context_window=15000)
reader=Research()
output=result(trading)
articles=[{'id':i,'title':f'Original {i}','content':'x'*20000,'summary':''} for i in (1,2)]
inputs=[{'id':i,'revision':1,'article_ids':[i]} for i in (1,2)]
async def complete(provider,system,payload,schema):
if payload['task']=='extract':
return pipeline.Extraction(facts='Extracted',evidence_refs=[{'article_id':payload['article_id'],'quote':payload['content'][:1]}]),{'input_tokens':1}
raise AssertionError('Small extracts do not need another merge')
async def research(provider,system,payload,schema):
assert {p['id'] for p in payload['packages']}=={1,2}
assert {r['article_id'] for e in payload['extractions'] for r in e['evidence_refs']}=={1,2}
assert 'articles' not in payload
return output,{'rounds':[{'output_tokens':5}]}
monkeypatch.setattr(pipeline,'complete',complete)
monkeypatch.setattr(reader,'run',research)
actual,usage=asyncio.run(module.generate(provider,'rules',{'id':7,'event':{'id':7},'articles':articles,'packages':inputs},reader))
assert actual is output and len(usage['parts'])>=2 and usage['final']['rounds'][0]['output_tokens']==5
def test_derived_reset_allowlist_protects_originals_and_configuration(trading):
import importlib.util
from pathlib import Path
path=Path(__file__).resolve().parents[2]/'scripts/reset_news_ai.py'
spec=importlib.util.spec_from_file_location('reset_news_ai_test',path)
module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module)
names={model.__tablename__ for model in module.DERIVED}
assert not names & {'news_items','news_revisions','risk_settings','ai_providers','skill_nodes','skill_versions','chat_sessions','economic_releases'}
assert {'news_ai_events','news_ai_summaries','news_ai_event_evidence','news_ai_reprocess_jobs','risk_analyses'}<=names
report={'real_models':True,'quality_accepted':True,'baseline_sha256':module.hashlib.sha256(b'baseline').hexdigest(),
'baseline_packages':20,'originals':27,'failed':0,'unassigned':0}
assert module.accepted(report,b'baseline')
for changes in ({'real_models':False},{'quality_accepted':False},{'failed':1},{'unassigned':1},{'originals':26}):
assert not module.accepted({**report,**changes},b'baseline')
assert not module.accepted(report,b'other')
def test_usage_accumulates_retries_without_double_counting(trading):
from alphapulse.news_risk.service import accumulate_usage
first={'parts':[{'input_tokens':4}],'final':{'output_tokens':2}}
second={'rounds':[{'input_tokens':5}]}
usage=accumulate_usage(None,first)
usage=accumulate_usage(usage,second)
assert usage=={'attempts':[first,second]}
assert accumulate_usage({'input_tokens':7},second)=={'attempts':[{'input_tokens':7},second]}
def test_updating_events_do_not_erase_last_successful_briefing(trading,monkeypatch):
from alphapulse.news_risk import worker,events
session=MagicMock()
session.__aenter__=AsyncMock(return_value=session);session.__aexit__=AsyncMock(return_value=False)
session.get=AsyncMock(return_value=N(enabled=True))
session.scalar=AsyncMock(side_effect=[N(state='done'),None,None,7])
monkeypatch.setattr(worker,'SessionLocal',lambda:session)
monkeypatch.setattr(events,'reading_inputs',AsyncMock(return_value=[]))
model=AsyncMock(side_effect=AssertionError('Wait for event research'))
monkeypatch.setattr(worker,'configuration',model)
assert asyncio.run(worker.outlook(prepare_only=True)) is None
session.add.assert_not_called();model.assert_not_awaited()
+2 -2
View File
@@ -18,9 +18,9 @@ def test_progress_is_scoped_and_removed_on_failure():
@pytest.mark.parametrize('published,revisions,expected', [(True,(2,2),True),(True,(1,2),False),(False,(2,2),False)])
def test_all_current_packages_require_published_results(trading,published,revisions,expected):
def test_all_member_events_require_published_results(trading,published,revisions,expected):
from alphapulse.news_risk.backfill import classification
package=N(result={'summary':'ok'} if published else None,published_revision=revisions[0],revision=revisions[1])
package=N(id=1,state='done',result={'summary':'ok'} if published else None,published_revision=revisions[0],revision=revisions[1])
session=N(scalar=AsyncMock(return_value=N(id=3,result={'packages':[]})),scalars=AsyncMock(return_value=N(all=lambda:[package])))
actual,_,_=asyncio.run(classification(session,N(id=1,content_hash='current')))
assert actual is expected
+11 -18
View File
@@ -41,18 +41,16 @@ def test_reading_distinguishes_event_and_package_with_same_id(trading, monkeypat
assert {r['kind'] for r in result['reading']}=={'event','package'}
def test_members_are_aggregated_once_and_old_independent_package_expires(trading):
def test_reading_uses_event_rating_and_never_package_fallback(trading):
from alphapulse.news_risk.events import reading_inputs
now=datetime.now(UTC)
packages=[SimpleNamespace(id=i,updated_at=now if i<3 else now-timedelta(days=4),importance_stars=4,
result={'title':str(i)},published_revision=2,category='security',tags=[]) for i in (1,2,3)]
event=SimpleNamespace(id=1,updated_at=now,published_revision=2,result={'title':'Incident','importance_stars':2,'category':'security','timeline_points':[{'package_id':i} for i in (1,2)]})
event=SimpleNamespace(id=1,updated_at=now,published_revision=2,tags=['security'],
result={'title':'Incident','importance_stars':2,'category':'security'})
session=AsyncMock()
values=[packages,[SimpleNamespace(package_id=i,event_id=1) for i in (1,2)],[event]]
session.scalars.side_effect=[SimpleNamespace(all=lambda rows=rows:rows) for rows in values]
session.scalar.return_value=SimpleNamespace(revision=2)
session.scalars.return_value=SimpleNamespace(all=lambda:[event])
inputs=asyncio.run(reading_inputs(session,now-timedelta(hours=72)))
assert len(inputs)==1 and inputs[0]['kind']=='event' and inputs[0]['importance_stars']==4
assert len(inputs)==1 and inputs[0]['kind']=='event' and inputs[0]['importance_stars']==2
assert session.scalars.await_count==1
def test_reprocess_range_requires_order_and_timezone(trading):
@@ -72,18 +70,13 @@ def test_event_timeline_cannot_omit_a_member_or_invent_known_times(trading):
with pytest.raises(ValueError, match='已核验时间点'): validate_timeline(result,packages[:1])
def test_new_member_remains_readable_until_event_represents_it(trading):
def test_unpublished_events_do_not_emit_independent_packages(trading):
from alphapulse.news_risk.events import reading_inputs
now=datetime.now(UTC)
packages=[SimpleNamespace(id=i,updated_at=now,importance_stars=4,result={'title':str(i)},published_revision=1,
category='security',tags=[]) for i in (1,2,3)]
event=SimpleNamespace(id=1,updated_at=now,published_revision=1,result={'title':'Incident','importance_stars':4,'category':'security',
'timeline_points':[{'package_id':1},{'package_id':2}]})
session=AsyncMock()
rows=[packages,[SimpleNamespace(package_id=i,event_id=1) for i in (1,2,3)],[event]]
session.scalars.side_effect=[SimpleNamespace(all=lambda rows=rows:rows) for rows in rows]
inputs=asyncio.run(reading_inputs(session,now-timedelta(hours=72)))
assert [(p['kind'],p['id']) for p in inputs]==[('event',1),('package',3)]
session.scalars.return_value=SimpleNamespace(all=lambda:[])
assert asyncio.run(reading_inputs(session,datetime.now(UTC)-timedelta(hours=72)))==[]
sql=str(session.scalars.call_args.args[0])
assert 'published_revision = news_ai_events.revision' in sql
@pytest.mark.parametrize('withdraw_during_call', [False, True])
+1 -1
View File
@@ -12,7 +12,7 @@ def pipeline(trading):
def match(pipeline, **changes):
return dict(package_id=None, group_key='event', title='公告', category='macro', entities=['央行'],
return dict(event=dict(event_id=None,group_key='event',title='公告',fact='公告',reason='同一具体事项'),package_id=None, group_key='event', title='公告', category='macro', entities=['央行'],
fact='公告', evidence='公告', urgent=False, tags=['央行'], content_kind='report', **changes)
+230 -9
View File
@@ -18,14 +18,23 @@ def test_private_and_mixed_dns_are_rejected(trading, monkeypatch):
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
@pytest.mark.parametrize('discovered', [False, True])
def test_read_web_supports_direct_urls_and_budget(trading, monkeypatch, discovered):
from alphapulse.news_risk import research as module
reader = module.Research()
url = 'https://example.invalid/official'
if discovered:
reader.urls.add(url)
fetch = AsyncMock(return_value={'url': url, 'content': 'Official original'})
monkeypatch.setattr(module, 'fetch_public', fetch)
result = asyncio.run(reader.tool('read_web', {'url': url}))
assert result['discovery_method'] == ('discovered' if discovered else 'direct')
assert result['article_id'] < 0
fetch.assert_awaited_once_with(url)
reader.counts['read_web'] = 4
with pytest.raises(ValueError, match='预算'):
asyncio.run(research.tool('read_web', {'url': 'https://example.invalid'}))
asyncio.run(reader.tool('read_web', {'url': url + '/other'}))
assert fetch.await_count == 1
def test_briefing_covers_all_packages_across_batches(trading, monkeypatch):
@@ -95,6 +104,58 @@ def test_failed_review_can_reuse_saved_evidence_without_new_io(trading):
assert not research.records and research.counts['read_web'] == 0
def test_restored_news_is_sent_before_reusing_its_reference(trading, monkeypatch):
import json
from pydantic import BaseModel
from alphapulse.news_risk import research
class Result(BaseModel):
summary: str
review = research.Research({'id': 42})
review.restore([{'tool': 'read_news', 'arguments': {'id': 123},
'result': {'article_id': -1, 'news_id': 123, 'content': 'Restored original'}}])
rounds = []
async def stream(provider, system, messages, tools, effort):
rounds.append(len(messages))
if len(rounds) == 2:
assert json.loads(messages[-1]['result'])['content'] == 'Restored original'
if len(rounds) == 3:
reply = json.loads(messages[-1]['result'])
assert reply['article_id'] == -1 and reply['status'] == 'already_provided'
assert 'content' not in reply
yield research.llm.Completion(tool_calls=[{'id': 'submit', 'name': 'submit',
'arguments': {'summary': 'Restored original'}}])
else:
yield research.llm.Completion(tool_calls=[{'id': str(len(rounds)), 'name': 'read_news', 'arguments': {'id': 123}}])
monkeypatch.setattr(research.llm, 'stream', stream)
result, _ = asyncio.run(review.run(SimpleNamespace(max_tokens=1000, context_window=200000, reasoning_effort=None),
'rules', {'articles': []}, Result))
assert result.summary == 'Restored original' and len(rounds) == 3
assert review.counts['read'] == 0 and not review.records
def test_current_input_takes_precedence_over_restored_news(trading, monkeypatch):
from pydantic import BaseModel
from alphapulse.news_risk import research
class Result(BaseModel):
summary: str
review = research.Research()
review.restore([{'tool': 'read_news', 'arguments': {'id': 123},
'result': {'article_id': -1, 'news_id': 123, 'content': 'Old original'}}])
async def stream(provider, system, messages, tools, effort):
if len(messages) == 1:
yield research.llm.Completion(tool_calls=[{'id': 'read', 'name': 'read_news', 'arguments': {'id': 123}}])
else:
import json
reply = json.loads(messages[-1]['result'])
assert reply['article_id'] == 9 and reply['status'] == 'already_provided'
assert 'Old original' not in json.dumps(messages)
yield research.llm.Completion(tool_calls=[{'id': 'submit', 'name': 'submit', 'arguments': {'summary': 'Current original'}}])
monkeypatch.setattr(research.llm, 'stream', stream)
asyncio.run(review.run(SimpleNamespace(max_tokens=1000, context_window=200000, reasoning_effort=None), 'rules',
{'articles': [{'id': 9, 'news_id': 123, 'content': 'Current original'}]}, Result))
assert review.counts['read'] == 0
def test_research_stops_after_eight_tool_rounds_and_retains_failures(trading, monkeypatch):
from alphapulse.news_risk import research
from pydantic import BaseModel
@@ -130,7 +191,7 @@ def test_reading_updates_only_for_changes_and_respects_aggregation(trading, monk
session = AsyncMock()
session.__aenter__.return_value = session
session.get.return_value = SimpleNamespace(enabled=True)
session.scalar.side_effect = [latest, None, None]
session.scalar.side_effect = [latest, None, None] + ([None] if mode == 'expired' else [])
session.scalars.return_value = SimpleNamespace(all=lambda: [] if mode == 'expired' else [event])
monkeypatch.setattr(events, 'reading_inputs', AsyncMock(return_value=[] if mode == 'expired' else [{'kind': 'package', 'id': event.id, 'revision': event.published_revision, 'importance_stars': event.importance_stars, 'category': event.category, 'tags': event.tags, 'result': event.result}]))
added = []
@@ -175,10 +236,31 @@ def test_single_package_truncation_is_bounded_and_does_not_retry_other_errors(tr
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}]))
asyncio.run(briefing.generate(SimpleNamespace(max_tokens=256, context_window=200000), 'rules', [{'id': 1, 'revision': 1}, {'id': 2, 'revision': 1}]))
assert complete.await_count == 1
@pytest.mark.parametrize('split', [False, True])
def test_terminal_truncation_keeps_completed_and_split_attempt_usage(trading, monkeypatch, split):
from alphapulse.news_risk import briefing
from alphapulse.news_risk.service import OutputTruncated
calls = []
async def complete(provider, system, payload, schema):
calls.append(payload)
if split and len(calls) == 1:
raise OutputTruncated('Split batch', {'output_tokens': 30})
if len(calls) == (2 if split else 1):
return briefing.Ranked(reading=[briefing.Reading(**p, priority=50, reason='Read') for p in payload['expected']],
notes='Notes'), {'output_tokens': 120}
raise OutputTruncated('Single item', {'output_tokens': 20})
monkeypatch.setattr(briefing, 'complete', complete)
inputs = [{'id': i, 'revision': 1, 'importance_stars': 3} for i in range(2 if split else 13)]
with pytest.raises(OutputTruncated, match='单包排序') as error:
asyncio.run(briefing.generate(SimpleNamespace(max_tokens=1000, context_window=200000), 'rules', inputs, ranking_only=True))
assert [u['output_tokens'] for u in error.value.usage['rounds']] == ([30, 120, 20] if split else [120, 20])
assert len(calls) == (3 if split else 2)
@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
@@ -212,3 +294,142 @@ def test_reposts_do_not_trigger_advanced_calls_but_review_changes_do(trading, mo
monkeypatch.setattr(briefing, 'generate', generated)
asyncio.run(worker.outlook())
assert bool(generated.await_count) is expected
def test_current_package_read_needs_no_io_or_budget(trading):
from alphapulse.news_risk.research import Research
research = Research({'id': 42, 'revision': 3})
result = asyncio.run(research.tool('read_package', {'id': 42}))
assert result['status'] == 'already_provided' and result['revision'] == 3
assert research.counts['read'] == 0 and not research.records
def test_provided_original_is_reused_without_io(trading, monkeypatch):
from alphapulse.news_risk import research
from pydantic import BaseModel
class Result(BaseModel):
summary: str
async def stream(provider, system, messages, tools, effort):
if len(messages) == 1:
yield research.llm.Completion(tool_calls=[{'id': 'read', 'name': 'read_news', 'arguments': {'id': 123}}])
else:
import json
response = json.loads(messages[-1]['result'])
assert response['article_id'] == 9 and 'content' not in response
yield research.llm.Completion(tool_calls=[{'id': 'submit', 'name': 'submit', 'arguments': {'summary': 'Done'}}])
monkeypatch.setattr(research.llm, 'stream', stream)
review = research.Research()
asyncio.run(review.run(SimpleNamespace(max_tokens=1000, context_window=200000, reasoning_effort=None), '',
{'articles': [{'id': 9, 'news_id': 123, 'content': 'Original'}]}, Result))
assert review.sources[9]['content'] == 'Original'
assert review.counts['read'] == 0 and not review.records
def test_web_unavailable_short_circuits_only_this_review(trading, monkeypatch):
from alphapulse.news_risk import research
session = AsyncMock()
session.__aenter__.return_value = session
monkeypatch.setattr(research, 'SessionLocal', lambda: session)
client = AsyncMock()
client.__aenter__.return_value = client
response = SimpleNamespace(raise_for_status=lambda: None,
json=lambda: {'results': [], 'unresponsive_engines': [['engine', 'CAPTCHA']]})
client.get.return_value = response
monkeypatch.setattr(research.httpx, 'AsyncClient', lambda **kwargs: client)
review = research.Research()
assert asyncio.run(review.tool('search_web', {'query': 'first query'}))['status'] == 'unavailable'
assert asyncio.run(review.tool('search_web', {'query': 'different query'}))['status'] == 'unavailable'
assert client.get.await_count == 1 and review.counts['search_web'] == 1
assert research.Research().web_unavailable is None
def test_ranking_repairs_identity_once_and_accounts_usage(trading, monkeypatch):
from alphapulse.news_risk import briefing
calls = []
async def complete(provider, system, payload, schema):
calls.append(payload)
ids = [] if len(calls) == 1 else [p['id'] for p in payload['packages']]
return briefing.Ranked(reading=[briefing.Reading(id=i, revision=2, priority=50, reason='Read') for i in ids], notes='Notes'), {'output_tokens': 10}
monkeypatch.setattr(briefing, 'complete', complete)
result = asyncio.run(briefing.generate(SimpleNamespace(max_tokens=100, context_window=10000), '',
[{'id': 1, 'revision': 2, 'importance_stars': 3}], ranking_only=True))
assert len(calls) == 2 and calls[1]['correction']['errors']['missing'] == [('package', 1, 2)]
assert len(result['usage']) == 2 and result['reading'][0]['id'] == 1
def test_ranking_missing_revision_repaired_with_explicit_identity(trading, monkeypatch):
from alphapulse.news_risk import briefing
from alphapulse.news_risk.service import SubmissionFailure
from pydantic import ValidationError
calls = []
async def complete(provider, system, payload, schema):
calls.append(payload)
if len(calls) == 1:
try: briefing.Ranked.model_validate({'reading': [{'id': 1, 'priority': 50, 'reason': 'Read'}], 'notes': 'Notes'})
except ValidationError as error: raise SubmissionFailure('Missing revision', {'output_tokens': 10}) from error
return briefing.Ranked(reading=[briefing.Reading(**payload['expected'][0], priority=50, reason='Read')], notes='Notes'), {}
monkeypatch.setattr(briefing, 'complete', complete)
result = asyncio.run(briefing.generate(SimpleNamespace(max_tokens=100, context_window=10000), '',
[{'id': 1, 'revision': 2, 'importance_stars': 3}], ranking_only=True))
assert len(calls) == 2 and len(result['usage']) == 2
def test_ranking_batches_are_bounded_and_cover_every_identity(trading, monkeypatch):
from alphapulse.news_risk import briefing
counts = []
async def complete(provider, system, payload, schema):
counts.append(len(payload['packages']))
return briefing.Ranked(reading=[briefing.Reading(**p, priority=50, reason='Read') for p in payload['expected']], notes='Notes'), {}
monkeypatch.setattr(briefing, 'complete', complete)
result = asyncio.run(briefing.generate(SimpleNamespace(max_tokens=1000, context_window=200000), '',
[{'id': i, 'revision': 1, 'importance_stars': 3} for i in range(25)], ranking_only=True))
assert counts == [12, 12, 1] and len(result['reading']) == 25
def test_package_search_excludes_current_package(trading, monkeypatch):
from alphapulse.news_risk import research
from sqlalchemy.dialects.postgresql import dialect
statements = []
class Session:
async def __aenter__(self): return self
async def __aexit__(self, *args): pass
async def scalars(self, statement):
statements.append(statement.compile(dialect=dialect()))
return SimpleNamespace(all=lambda: [])
monkeypatch.setattr(research, 'SessionLocal', Session)
review = research.Research({'id': 42})
asyncio.run(review.tool('search_packages', {'query': 'policy', 'keywords': ['policy']}))
assert 'news_ai_summaries.id !=' in str(statements[0]) and 42 in statements[0].params.values()
@pytest.mark.parametrize('result,expected', [({'title': '媒体称11月2日拟开会'}, '媒体称11月2日拟开会'), (None, '明天开会')])
def test_package_api_returns_fact_title_without_legacy_rating(trading, result, expected):
from alphapulse.api.risk import package_out
from alphapulse.models.risk import NewsSummary
session = AsyncMock()
session.scalar.return_value = 1
session.get.return_value = None
package = NewsSummary(id=1, title='明天开会', result=result, replacement_ids=[])
assert asyncio.run(package_out(package, session))['title'] == '明天开会'
def test_ranking_invalid_identity_stops_after_one_repair(trading, monkeypatch):
from alphapulse.news_risk import briefing
from alphapulse.news_risk.service import SubmissionFailure
complete = AsyncMock(return_value=(briefing.Ranked(reading=[], notes='Notes'), {'output_tokens': 10}))
monkeypatch.setattr(briefing, 'complete', complete)
with pytest.raises(SubmissionFailure, match='missing') as error:
asyncio.run(briefing.generate(SimpleNamespace(max_tokens=100, context_window=10000), '',
[{'id': 1, 'revision': 2, 'importance_stars': 3}], ranking_only=True))
assert complete.await_count == 2
assert len(error.value.usage['failed_batch']['rounds']) == 2
def test_direct_web_read_preserves_public_reader_rejection(trading, monkeypatch):
from alphapulse.news_risk import research as module
reader = module.Research()
fetch = AsyncMock(side_effect=ValueError('不允许访问非公开地址'))
monkeypatch.setattr(module, 'fetch_public', fetch)
with pytest.raises(ValueError, match='非公开'):
asyncio.run(reader.tool('read_web', {'url': 'http://127.0.0.1'}))
assert not reader.sources
@@ -0,0 +1,356 @@
"""Behavioral contracts for one-news packages and reusable keyword indexes."""
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace as N
import pytest
def test_storage_is_additive_and_membership_is_many_to_many(trading):
from alphapulse.news_research.models import TABLES, Package, Membership
assert all(t.name.startswith("news_research_") for t in TABLES)
assert {c.name for c in Membership.__table__.primary_key} == {
"event_id",
"package_id",
}
assert any(
{c.name for c in constraint.columns} == {"news_id"}
for constraint in Package.__table__.constraints
if constraint.__class__.__name__ == "UniqueConstraint"
)
def test_keywords_require_original_aliases_and_literal_evidence(trading):
from alphapulse.news_research.contracts import IndexResult
from alphapulse.news_research.service import validate_keywords
source = {"id": 7, "content": "Binance reported a security incident."}
output = IndexResult(
keywords=[
{
"canonical": "币安",
"aliases": ["Binance"],
"quote": "Binance reported a security incident.",
}
]
)
validate_keywords(output, source)
output.keywords[0].aliases = ["Invented"]
with pytest.raises(ValueError, match="别名"):
validate_keywords(output, source)
output.keywords[0].aliases = ["Binance"]
output.keywords[0].quote = "Binance was hacked"
with pytest.raises(ValueError, match="原文"):
validate_keywords(output, source)
def test_one_package_can_be_assigned_to_multiple_events_without_splitting(trading):
from alphapulse.news_research.contracts import Assignment
from alphapulse.news_research.service import validate_assignment
base = {
"new_key": None,
"title": "Incident",
"reason": "Follow-up",
"quote": "Original",
"change_kind": "new_fact",
"urgent": False,
}
result = Assignment(targets=[{**base, "event_id": 1}, {**base, "event_id": 2}])
validate_assignment(result, {"id": 8, "content": "Original"}, {1, 2})
with pytest.raises(ValueError, match="未知事件"):
validate_assignment(result, {"id": 8, "content": "Original"}, {1})
with pytest.raises(ValueError, match="重复"):
Assignment(targets=[{**base, "event_id": 1}, {**base, "event_id": 1}])
def test_event_refresh_keeps_hard_deadline_and_urgency(trading):
from alphapulse.news_research.service import dirty
now = datetime.now(UTC)
event = N(
revision=2,
state="done",
dirty_since=now - timedelta(minutes=14),
due_at=now,
error="Old",
)
dirty(event)
assert event.due_at <= event.dirty_since + timedelta(minutes=15)
assert event.revision == 3 and event.state == "pending" and event.error is None
dirty(event, True)
immediate = event.due_at
dirty(event)
assert event.due_at <= datetime.now(UTC) and event.due_at >= immediate
@pytest.mark.parametrize("urgent", [False, True])
def test_new_event_waits_for_aggregation_unless_urgent(trading, urgent):
from alphapulse.news_research.service import dirty
event = N(
revision=0,
state="pending",
dirty_since=None,
due_at=datetime.now(UTC) - timedelta(seconds=1),
)
dirty(event, urgent)
assert event.due_at - event.dirty_since == timedelta(minutes=0 if urgent else 5)
@pytest.mark.parametrize("state", ["failed", "pending", "done"])
def test_event_retry_preserves_content_revision_until_content_changes(trading, state):
import asyncio
from unittest.mock import AsyncMock
from alphapulse.news_research.api import retry_event
event = N(
revision=7,
state=state,
dirty_since=None,
due_at=datetime.now(UTC),
error="Previous failure",
)
session = N(get=AsyncMock(return_value=event), commit=AsyncMock())
asyncio.run(retry_event(1, session))
assert event.revision == (8 if state == "done" else 7)
assert event.state == "pending" and event.error is None
assert event.due_at <= datetime.now(UTC)
@pytest.mark.parametrize("revision", [None, 2])
def test_news_detail_does_not_attach_old_keywords_to_updated_original(
trading, revision
):
import asyncio
from unittest.mock import AsyncMock
from alphapulse.news_research.api import news_package
news = N(revision_no=2, content_hash="new")
package = N(content_hash="old", keywords=[{"canonical": "Old fact"}])
session = N(
get=AsyncMock(return_value=news), scalar=AsyncMock(return_value=package)
)
result = asyncio.run(news_package(1, session, revision_no=revision))
assert result.data == []
def test_two_stage_contract_rejects_package_summaries(trading):
from alphapulse.news_research.contracts import (
IndexResult,
Assignment,
ResearchResult,
)
assert set(IndexResult.model_fields) == {"keywords"}
assert set(Assignment.model_fields) == {"targets"}
assert "references" in ResearchResult.model_fields
assert "package_correction" not in ResearchResult.model_fields
def test_timeline_distinguishes_member_progress_from_external_background(trading):
from alphapulse.news_research.service import validate_timeline
point = N(
package_ids=[17], evidence_refs=[{"article_id": -3, "quote": "External source"}]
)
result = N(timeline_points=[point])
with pytest.raises(ValueError, match="对应新闻包"):
validate_timeline(result, {17: 463205})
point.package_ids = []
validate_timeline(result, {17: 463205})
point.evidence_refs = [{"article_id": 463205, "quote": "Member source"}]
with pytest.raises(ValueError, match="标注"):
validate_timeline(result, {17: 463205})
point.package_ids = [17]
validate_timeline(result, {17: 463205})
def test_retry_reuses_original_snapshots_and_keeps_the_evidence_auditable(trading):
import asyncio
from alphapulse.news_research.service import Research
research = Research(event={"id": 8})
snapshot = {"article_id": -1, "news_id": 7, "content": "Original evidence"}
research.restore(
[{"tool": "read_news", "arguments": {"id": 7}, "result": snapshot}]
)
result = asyncio.run(research.tool("read_news", {"id": 7}))
assert result == snapshot
assert research.counts["read"] == 0
assert research.records[0]["reused"] is True
assert research.records[0]["result"]["article_id"] == -1
@pytest.mark.parametrize("tool", ["search_packages", "search_events"])
@pytest.mark.parametrize("cached", [[], [{"id": 1}]])
def test_retry_refreshes_local_search_with_current_vocabulary(
trading, monkeypatch, tool, cached
):
import asyncio
from unittest.mock import AsyncMock
from alphapulse.news_research import service
session = AsyncMock()
session.__aenter__.return_value = session
session.execute.return_value = [
(N(id=2, news_id=12, content_hash="current", keywords=[]), "current")
]
session.scalars.return_value = [N(id=2, title="Fresh event", tags=["binance"])]
monkeypatch.setattr(service, "SessionLocal", lambda: session)
reader = service.Research(event={"id": 8})
reader.vocabulary = {
"revision": 2,
"entries": [{"canonical": "币安", "aliases": ["Binance", "BNC"]}],
}
arguments = {"query": "BNC", "keywords": []}
reader.restore([{"tool": tool, "arguments": arguments, "result": cached}])
result = asyncio.run(reader.tool(tool, arguments))
assert [row["id"] for row in result] == [2]
assert reader.counts["search"] == 1
assert len(reader.records) == 1 and not reader.records[0].get("reused")
@pytest.mark.parametrize("status", ["unavailable", "no_matches", "matched"])
def test_retry_refreshes_unavailable_search_but_reuses_successful_results(
trading, monkeypatch, status
):
import asyncio
from unittest.mock import AsyncMock
from alphapulse.news_research.service import Research
from alphapulse.news_risk import research as tools
session = AsyncMock()
session.__aenter__.return_value = session
monkeypatch.setattr(tools, "SessionLocal", lambda: session)
client = AsyncMock()
client.__aenter__.return_value = client
client.get.return_value = N(
raise_for_status=lambda: None,
json=lambda: {"results": [], "unresponsive_engines": []},
)
monkeypatch.setattr(tools.httpx, "AsyncClient", lambda **kwargs: client)
cached = {
"status": status,
"results": [{"url": "https://example.invalid/retry"}]
if status == "matched"
else [],
"errors": [["engine", "CAPTCHA"]] if status == "unavailable" else [],
}
reader = Research(event={"id": 8})
reader.restore(
[{"tool": "search_web", "arguments": {"query": "same query"}, "result": cached}]
)
result = asyncio.run(reader.tool("search_web", {"query": "same query"}))
if status == "unavailable":
assert client.get.await_count == 1 and result["status"] == "no_matches"
assert len(reader.records) == 1 and not reader.records[0].get("reused")
else:
assert client.get.await_count == 0 and result == cached
assert reader.records[0]["reused"] is True
def test_settings_contract_rejects_third_stage_and_unknown_efforts(trading):
from alphapulse.news_research.api import SettingsInput
model = {
"provider_id": 1,
"model": "selected",
"skill_id": 1,
"reasoning_effort": None,
}
assert SettingsInput(index=model, research=model).concurrency == 3
with pytest.raises(ValueError, match="extra_forbidden"):
SettingsInput(index=model, research=model, outlook=model)
with pytest.raises(ValueError):
SettingsInput(
index={**model, "reasoning_effort": "unsupported"}, research=model
)
@pytest.mark.parametrize("tool", ["read_news", "read_package"])
def test_compressed_members_read_frozen_positive_sources_and_reject_old_cache(
trading, tool
):
import asyncio
from alphapulse.news_research.service import Research, validate_timeline
research = Research(event={"id": 8})
source = {"id": 101, "news_id": 101, "content": "Frozen original"}
research.register_members(
{"articles": [source], "packages": [{"id": 10, "news_id": 101}]}
)
research.provided_packages = {10: {"id": 10, "news_id": 101}}
research.restore(
[
{
"tool": "read_news",
"arguments": {"id": 101},
"result": {"article_id": -1, "news_id": 101, "content": "Old cache"},
}
]
)
assert not research.provided_news and -1 not in research.sources
result = asyncio.run(
research.tool(tool, {"id": 101 if tool == "read_news" else 10})
)
assert result["article_id"] == 101 and result["content"] == "Frozen original"
validate_timeline(
N(
timeline_points=[
N(
package_ids=[10],
evidence_refs=[
{"article_id": result["article_id"], "quote": "Frozen original"}
],
)
]
),
{10: 101},
)
repeated = asyncio.run(research.tool("read_news", {"id": 101}))
assert repeated["status"] == "already_provided" and research.counts["read"] == 1
def test_assignment_gate_serializes_only_assignment_and_releases_after_failure(
trading, monkeypatch
):
import asyncio
from alphapulse.news_research import service
class Session:
async def __aenter__(self):
return self
async def __aexit__(self, *args):
pass
async def get(self, model, identifier):
return N(state="index_pending" if identifier == 3 else "assign_pending")
async def scenario():
monkeypatch.setattr(service, "assignment_lock", asyncio.Lock())
entered, release = asyncio.Event(), asyncio.Event()
calls = []
async def process(identifier, config, stage):
calls.append((identifier, stage))
if identifier == 1:
entered.set()
await release.wait()
raise ValueError("Failed model")
monkeypatch.setattr(service, "SessionLocal", Session)
monkeypatch.setattr(service, "_process_package", process)
first = asyncio.create_task(service.process_package(1, {}))
await entered.wait()
second = asyncio.create_task(service.process_package(2, {}))
await service.process_package(3, {})
assert calls == [(1, "assign"), (3, "index")]
release.set()
await asyncio.gather(first, second, return_exceptions=True)
assert calls[-1] == (2, "assign") and not service.assignment_lock.locked()
asyncio.run(scenario())
+12 -11
View File
@@ -64,21 +64,22 @@ def mock_enable(risk, monkeypatch, valid=True, verification='reported', directio
monkeypatch.setattr(pipeline, 'configuration', AsyncMock(return_value=(provider, 'rules', {}, 'fingerprint')))
async def complete(provider, system, payload, schema):
if schema is pipeline.Grouping:
return pipeline.Grouping( reason='财经维护通知', packages=[pipeline.Match(package_id=None,
return pipeline.Grouping( reason='财经维护通知', packages=[pipeline.Match(event=dict(event_id=None,group_key='maintenance',title='维护',fact='维护',reason='维护通知'),package_id=None,
group_key='maintenance', title='维护', category='company', entities=['示例公司'], fact='维护',
evidence=payload['article']['summary'] if valid else '捏造损失', urgent=False, tags=['维护'], content_kind='statement')]), {}
if schema is risk.Outlook:
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}, timeline_points=[dict(occurred_at=None, time_precision='unknown', time_text='Unknown', reported_at=None, description='Synthetic', verification='insufficient', evidence_refs=[{'article_id':1,'quote':article['summary']}])], facts='维护', timeline='维护',
asset_impacts='未知', risk_stars=0, importance_stars=3, opinions='待观察', counter_evidence='不足', watchpoints='恢复', claims=[pipeline.Claim(statement='维护', verdict='insufficient', evidence_refs=[])], related_packages=[], excluded_article_ids=[],
from alphapulse.news_risk.event_research import EventResult
result = EventResult(**{**label(risk).model_dump(exclude={'event_id', 'same_event_as', 'evidence', 'stars'}), 'verification': verification,
'direction': direction}, timeline_points=[dict(package_id=1,package_revision=1,occurred_at=None, time_precision='unknown', time_text='Unknown', reported_at=None, description='Synthetic', verification='insufficient', evidence_refs=[{'article_id':1,'quote':article['summary']}])], facts='维护', timeline='维护',
asset_impacts='未知', risk_stars=0, importance_stars=3, opinions='待观察', counter_evidence='不足', watchpoints='恢复', claims=[pipeline.Claim(statement='维护', verdict='insufficient', evidence_refs=[])], 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='虚构测试'), {}
return briefing.Ranked(reading=[briefing.Reading(kind=p.get('kind','event'),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)
@@ -218,7 +219,7 @@ def test_long_sources_are_split_without_losing_text(risk, monkeypatch):
if payload['task'] == 'extract':
seen.append(payload['content'])
return schema(facts='事实', evidence_refs=[pipeline.Evidence(article_id=1, quote=payload['content'][:1])]), {}
return pipeline.EventResult(**label(risk).model_dump(exclude={'event_id', 'same_event_as', 'evidence', 'stars'}), timeline_points=[dict(occurred_at=None, time_precision='unknown', time_text='Unknown', reported_at=None, description='Synthetic', verification='insufficient', evidence_refs=[{'article_id':1,'quote':'安全事件'}])], facts='事实', timeline='时间', asset_impacts='资产',
return pipeline.EventResult(**label(risk).model_dump(exclude={'event_id', 'same_event_as', 'evidence', 'stars'}), timeline_points=[dict(package_id=1,package_revision=1,occurred_at=None, time_precision='unknown', time_text='Unknown', reported_at=None, description='Synthetic', verification='insufficient', evidence_refs=[{'article_id':1,'quote':'安全事件'}])], facts='事实', timeline='时间', asset_impacts='资产',
risk_stars=5, importance_stars=5, opinions='待观察', counter_evidence='不足', watchpoints='恢复', claims=[pipeline.Claim(statement='事件', verdict='insufficient', evidence_refs=[])], related_packages=[], excluded_article_ids=[], evidence_refs=[pipeline.Evidence(article_id=1, quote='安全事件')]), {}
monkeypatch.setattr(pipeline, 'complete', complete)
result, _ = asyncio.run(pipeline.summarize_articles(provider, 'rules', [article], {'id': 1, 'fact': 'Synthetic event'}))
@@ -232,7 +233,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', 'summaries'])
@pytest.mark.parametrize('endpoint', ['overview', 'events'])
@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
@@ -292,11 +293,11 @@ def test_old_failed_analysis_cannot_requeue_a_completed_or_running_revision(risk
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)
row = SimpleNamespace(state='failed', event_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
asyncio.run(api.retry(7, session, stage='events'))
assert exc.value.status_code == 409
assert event.state == state
session.commit.assert_not_awaited()
@@ -311,7 +312,7 @@ def test_completed_version_recovers_a_previously_stuck_pending_package(risk, mon
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'})])
session.scalars.return_value = SimpleNamespace(all=lambda: [SimpleNamespace(id=1, news_id=10, content_hash='current', input={'title': 'Statement'})])
monkeypatch.setattr(worker, 'SessionLocal', lambda: session)
monkeypatch.setattr(worker, 'configuration', AsyncMock(return_value=(SimpleNamespace(model='medium'), 'rules', {}, 'key')))
analyze = AsyncMock()
+46
View File
@@ -0,0 +1,46 @@
"""Replay orchestration must drain or cancel every synthetic task."""
import asyncio
import importlib.util
from pathlib import Path
import pytest
def load_replay():
path=Path(__file__).resolve().parents[2]/'scripts/replay_news_sample.py'
spec=importlib.util.spec_from_file_location('sample_replay_test',path)
module=importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def test_replay_refills_before_slow_task_finishes(trading):
replay=load_replay()
async def run():
third_started=asyncio.Event()
order=[]
async def action(identifier):
if identifier == 1: await third_started.wait()
if identifier == 3: third_started.set()
order.append(identifier)
await asyncio.wait_for(replay.bounded([1,2,3],action,2),1)
assert order.index(3)<order.index(1)
asyncio.run(run())
def test_replay_failure_cancels_and_drains_other_tasks(trading):
replay=load_replay()
async def run():
started=asyncio.Event()
drained=[]
async def action(identifier):
if identifier == 1:
await started.wait()
raise ValueError('synthetic failure')
try:
started.set()
await asyncio.Event().wait()
finally: drained.append(identifier)
with pytest.raises(ValueError,match='synthetic failure'):
await replay.bounded([1,2],action,2)
assert drained==[2]
asyncio.run(run())
+212
View File
@@ -0,0 +1,212 @@
"""Acceptance guards must fail closed without models or production access."""
import copy
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
import pytest
@pytest.fixture
def scenario(trading, monkeypatch):
monkeypatch.syspath_prepend(str(Path(__file__).resolve().parents[2] / 'scripts'))
import accept_news_scenario
return accept_news_scenario
def manifest():
return {'news': [{'id': i, 'published_at': f'2025-02-{i:02}T00:00:00+00:00'} for i in range(1, 13)],
'batches': [[i] for i in range(1, 13)], 'baseline_requests': 199, 'additional_limit': 120, 'official_sources': [{'url': 'https://one.invalid'}, {'url': 'https://two.invalid'}]}
@pytest.mark.parametrize('failure', ['duplicate', 'order', 'budget'])
def test_manifest_rejects_ambiguous_or_changed_scope(scenario, failure):
value = manifest()
scenario.validate_manifest(value)
if failure == 'duplicate':
value['batches'][-1] = [1]
elif failure == 'order':
value['batches'].reverse()
else:
value['additional_limit'] = 121
with pytest.raises(ValueError):
scenario.validate_manifest(value)
def test_budget_is_shared_atomic_and_resume_does_not_reset(scenario, tmp_path):
from accept_news_research import Budget, BudgetExceeded
path = tmp_path / 'count'
path.write_text('199')
budget = Budget(path, limit=205)
def take(_):
try:
budget.take()
return True
except BudgetExceeded:
return False
with ThreadPoolExecutor(max_workers=8) as executor:
assert sum(executor.map(take, range(20))) == 6
assert path.read_text() == '205'
with pytest.raises(BudgetExceeded, match='205'):
Budget(path, limit=205).take()
def test_extended_request_limit_requires_explicit_resume_finish(scenario):
from types import SimpleNamespace
def args(resume=False, finish=False, limit=None):
return SimpleNamespace(resume=resume, finish=finish, request_limit=limit)
assert scenario.request_limit(args()) == 90
assert scenario.request_limit(args(finish=True)) == 120
assert scenario.request_limit(args(True, True, 180)) == 180
for value in (args(limit=180), args(True, False, 180), args(False, True, 180), args(True, True, 119)):
with pytest.raises(ValueError, match='追加上限'):
scenario.request_limit(value)
def test_missing_samples_never_pass_boundary_assertions(scenario):
value = manifest()
value.update(must_link=[[1, 2]], must_separate=[[3, 4]], references=[[3, 1]])
tables = {m.__tablename__: [] for m in scenario.MODELS}
checks = scenario.assertions(value, tables, [])
assert checks and not any(c['passed'] for c in checks)
def test_external_or_duplicate_versions_do_not_count_as_natural_revisions(scenario):
value = manifest()
value.update(must_link=[[1]], must_separate=[], references=[])
tables = {m.__tablename__: [] for m in scenario.MODELS}
tables['news_research_packages'] = [{'id': 1, 'news_id': 1, 'state': 'done'}]
tables['news_research_memberships'] = [{'package_id': 1, 'event_id': 1}]
versions = [{'id': i, 'event_id': 1, 'revision': i, 'state': 'done'} for i in range(1, 5)]
tables['news_research_versions'] = copy.deepcopy(versions)
checkpoints = [{'versions': [versions[0]]}, {'versions': [versions[0]]}]
checks = scenario.assertions(value, tables, checkpoints)
assert not next(c['passed'] for c in checks if c['name'] == 'three_natural_revisions')
def test_published_skill_retains_base_instructions(trading, monkeypatch):
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock
from alphapulse.news_research import service
provider = SimpleNamespace(id=7, model='test', reasoning_effort='medium')
monkeypatch.setattr(service, 'provider_for', AsyncMock(return_value=provider))
monkeypatch.setattr(service, 'configuration', AsyncMock(return_value=(provider, 'BASE\nPUBLISHED', {'content': 'PUBLISHED', 'base': 'BASE'}, 'hash')))
_, skill, rules = asyncio.run(service.model_config(None, {'index': {'provider_id': 7, 'model': 'test', 'skill_id': 9}}, 'index'))
assert skill == 'BASE\nPUBLISHED' and rules['base'] == 'BASE'
def test_pinned_reference_requires_later_natural_update_without_consumer_rerun(scenario):
value = manifest()
value.update(must_link=[[1]], must_separate=[], references=[[2, 1]])
target = {'id': 1, 'event_id': 1, 'revision': 1, 'state': 'done'}
consumer = {'id': 3, 'event_id': 2, 'revision': 1, 'state': 'done'}
update = {'id': 2, 'event_id': 1, 'revision': 2, 'state': 'done'}
tables = {m.__tablename__: [] for m in scenario.MODELS}
tables.update(news_research_packages=[{'id': 1, 'news_id': 1, 'state': 'done'}, {'id': 2, 'news_id': 2, 'state': 'done'}],
news_research_memberships=[{'package_id': 1, 'event_id': 1}, {'package_id': 2, 'event_id': 2}],
news_research_events=[{'id': 1, 'revision': 2}, {'id': 2, 'revision': 1}],
news_research_versions=[target, consumer, update],
news_research_references=[{'version_id': 3, 'target_version_id': 1}])
checkpoints = [{'versions': [target, update]}]
def passed():
return next(c['passed'] for c in scenario.assertions(value, tables, checkpoints)
if c['name'] == 'pinned_reference_without_cascade')
assert not passed()
update['id'] = 4
assert passed()
tables['news_research_versions'].append({'id': 5, 'event_id': 2, 'revision': 2, 'state': 'failed'})
assert not passed()
def test_checkpoint_is_atomic_private_and_can_be_replaced(scenario, tmp_path):
import json
path = tmp_path / 'state.json'
scenario.save(path, {'batch': 0})
scenario.save(path, {'batch': 1})
assert json.loads(path.read_text()) == {'batch': 1}
assert path.stat().st_mode & 0o777 == 0o600
assert not path.with_suffix('.pending').exists()
def test_official_evidence_requires_success_correct_event_and_cited_claim(scenario):
tables = {m.__tablename__: [] for m in scenario.MODELS}
value = {'external_news_ids': [1], 'official_sources': [{'url': 'https://official.invalid', 'anchors': ['Official fact']}]}
tables['news_research_packages'] = [{'id': 10, 'news_id': 1}]
tables['news_research_memberships'] = [{'package_id': 10, 'event_id': 2}]
version = {'id': 3, 'event_id': 2, 'state': 'done', 'evidence': [
{'tool': 'read_web', 'result': {'url': 'https://official.invalid/article', 'content': 'Official fact was confirmed.', 'article_id': -1}}],
'output': {'claims': [{'verdict': 'supported', 'evidence_refs': [{'article_id': -1, 'quote': 'Official fact was confirmed.'}]}]}}
tables['news_research_versions'] = [version]
assert scenario.official_assertions(value, tables, [2])[0]['passed']
for field, invalid in [('state', 'failed'), ('event_id', 99), ('output', {'claims': []})]:
old = version[field]
version[field] = invalid
assert not scenario.official_assertions(value, tables, [2])[0]['passed']
version[field] = old
def test_official_hints_are_scoped_to_event_members(scenario):
value = {'external_news_ids': [11, 22], 'official_sources': [
{'url': 'https://one.invalid/incident'}, {'url': 'https://two.invalid/licence'}]}
hints = scenario.official_hints(value, {11, 33})
assert 'https://one.invalid/incident' in hints and 'https://two.invalid/licence' not in hints
hints = scenario.official_hints(value, {22})
assert 'https://two.invalid/licence' in hints and 'https://one.invalid/incident' not in hints
value['official_sources'].pop()
with pytest.raises(ValueError):
scenario.official_hints(value, {11})
def test_reference_api_assertion_checks_changed_and_original_version(scenario, monkeypatch):
import asyncio
from unittest.mock import AsyncMock
from alphapulse.news_research import api
tables = {m.__tablename__: [] for m in scenario.MODELS}
tables.update(news_research_packages=[{'id': 1, 'news_id': 11}, {'id': 2, 'news_id': 22}],
news_research_memberships=[{'package_id': 1, 'event_id': 1}, {'package_id': 2, 'event_id': 2}],
news_research_versions=[{'id': 5, 'event_id': 1}, {'id': 4, 'event_id': 2}],
news_research_references=[{'version_id': 5, 'target_version_id': 4}])
class Session:
async def __aenter__(self): return self
async def __aexit__(self, *args): return False
monkeypatch.setattr(scenario, 'SessionLocal', Session)
read = AsyncMock(return_value=[{'version_id': 4, 'changed': True}])
monkeypatch.setattr(api, 'version_references', read)
manifest = {'references': [[11, 22]]}
assert asyncio.run(scenario.reference_api_assertion(manifest, tables))[0]['passed']
read.return_value = [{'version_id': 4, 'changed': False}]
assert not asyncio.run(scenario.reference_api_assertion(manifest, tables))[0]['passed']
read.return_value = [{'version_id': 99, 'changed': True}]
assert not asyncio.run(scenario.reference_api_assertion(manifest, tables))[0]['passed']
def test_restore_rejects_changed_versions_and_incomplete_batches(scenario):
import restore_news_scenario as recovery
value = manifest()
for row in value['news']:
row['content_hash'] = 'frozen'
tables = {m.__tablename__: [] for m in scenario.MODELS}
tables['news_research_packages'] = [{'id': 1, 'news_id': 1, 'state': 'done', 'content_hash': 'frozen'}]
version = {'id': 1, 'event_id': 1, 'revision': 1, 'state': 'done', 'input': {'articles': [{'id': 1}]}}
tables['news_research_versions'] = [version]
report = {'manifest': value, 'tables': copy.deepcopy(tables), 'checkpoints': [{'versions': copy.deepcopy([version])}]}
_, originals, batch, _ = recovery.prepare(report, tables)
assert len(originals) == 1 and batch == 1
version['revision'] += 1
with pytest.raises(ValueError, match='版本'):
recovery.prepare(report, tables)
version['revision'] -= 1
tables['news_research_packages'][0]['state'] = 'assign_pending'
with pytest.raises(ValueError, match='批次'):
recovery.prepare(report, tables)
def test_restore_preserves_extended_cap_and_never_rewinds_counter(scenario):
import restore_news_scenario as recovery
report = {'manifest': manifest(), 'new_cap': 180, 'requests_new': 147}
assert recovery.counter_limit(report, 147) == 180
for count in (146, 181):
with pytest.raises(ValueError, match='请求账本'):
recovery.counter_limit(report, count)
report['new_cap'] = 119
with pytest.raises(ValueError, match='累计上限'):
recovery.counter_limit(report, 147)
+110
View File
@@ -0,0 +1,110 @@
"""News retry budgets use synthetic providers only."""
import asyncio
from types import SimpleNamespace as N
from unittest.mock import AsyncMock
import httpx
import openai
import pytest
@pytest.mark.parametrize('mode', ['success', 'exhausted', 'partial', 'timeout', 'auth', 'long_wait', 'tool_fragment'])
def test_news_transport_retry_budget_and_output_guard(trading, monkeypatch, mode):
from alphapulse.ai import llm
from alphapulse.news_risk.service import model_stream
from alphapulse.news_risk.progress import current
calls = []
async def adapter(*args):
calls.append(args)
if mode == 'partial': yield llm.TextDelta('started')
if mode == 'tool_fragment': llm._news_output()
if mode == 'success' and len(calls) == 3:
yield llm.Completion(usage={'input_tokens': 1})
return
request = httpx.Request('POST', 'https://example.invalid/v1/chat/completions')
if mode == 'timeout': raise openai.APITimeoutError(request=request)
if mode == 'auth': raise openai.AuthenticationError('denied', response=httpx.Response(401, request=request), body={})
if mode == 'long_wait': raise openai.RateLimitError('wait', response=httpx.Response(429, headers={'retry-after': '121'}, request=request), body={})
raise openai.APIError('Concurrency limit exceeded for account', request, body={'error': {'message': 'Concurrency limit exceeded for account'}})
monkeypatch.setattr(llm, '_stream_openai', adapter)
sleep = AsyncMock()
monkeypatch.setattr(llm.asyncio, 'sleep', sleep)
record = {}
async def run():
token = current.set(record)
try: return [item async for item in model_stream(N(protocol='openai'), '', [], [], 'max')]
finally: current.reset(token)
if mode == 'success': assert asyncio.run(run())[-1].usage == {'input_tokens': 1}
else:
with pytest.raises(llm.LlmError): asyncio.run(run())
retries = mode in ('success', 'exhausted')
assert len(calls) == (3 if retries else 1)
assert [call.args[0] for call in sleep.await_args_list] == ([30, 60] if retries else [])
assert llm.news_retry.get() is None
if retries: assert '2/2' in record['phase']
def test_retry_after_seconds_and_dates(trading):
from alphapulse.ai import llm
from datetime import UTC, datetime, timedelta
from email.utils import format_datetime
request = httpx.Request('POST', 'https://example.invalid')
for value in ('90', format_datetime(datetime.now(UTC) + timedelta(seconds=95))):
error = openai.RateLimitError('wait', response=httpx.Response(429, headers={'retry-after': value}, request=request), body={})
assert 89 <= llm._news_retry_delay(error, 0) <= 96
def test_cancelling_backoff_releases_context_and_progress(trading, monkeypatch):
from alphapulse.ai import llm
from alphapulse.news_risk.service import model_stream
from alphapulse.news_risk import progress
entered = asyncio.Event()
async def adapter(*args):
raise openai.APIError('Concurrency limit exceeded', httpx.Request('POST', 'https://example.invalid'), body={})
yield
async def sleep(delay):
entered.set()
await asyncio.Event().wait()
monkeypatch.setattr(llm, '_stream_openai', adapter)
monkeypatch.setattr(llm.asyncio, 'sleep', sleep)
async def run():
async def consume():
try:
async for _ in model_stream(N(protocol='openai'), '', [], [], None): pass
finally: assert llm.news_retry.get() is None
task=asyncio.create_task(progress.execute(('events', 4), consume))
await entered.wait()
assert '重试' in next(iter(progress.active.values()))['phase']
task.cancel()
with pytest.raises(asyncio.CancelledError): await task
assert not progress.active
asyncio.run(run())
@pytest.mark.parametrize('protocol', ['openai', 'anthropic'])
@pytest.mark.parametrize('news', [True, False])
def test_sdk_retry_is_disabled_only_for_news(trading, monkeypatch, protocol, news):
from alphapulse.ai import llm
options=[]
class Client:
def __init__(self, **kwargs):
options.append(kwargs)
self.chat=N(completions=N(create=self.create))
self.messages=N(create=self.create)
async def __aenter__(self): return self
async def __aexit__(self, *args): pass
async def create(self, **kwargs):
async def events():
if protocol == 'openai':
yield N(usage=None, choices=[N(finish_reason='stop', delta=N(content=None,tool_calls=[]))])
else: yield N(type='message_stop')
return events()
monkeypatch.setattr(llm, 'decrypt', lambda _: 'synthetic')
monkeypatch.setattr(llm.openai if protocol == 'openai' else llm.anthropic,
'AsyncOpenAI' if protocol == 'openai' else 'AsyncAnthropic', Client)
provider=N(protocol=protocol,api_key_encrypted='test',base_url='https://example.invalid',model='test',max_tokens=10)
async def run():
token=llm.news_retry.set({'output':False} if news else None)
try: return [item async for item in llm.stream(provider,'rules',[],[],None)]
finally: llm.news_retry.reset(token)
assert asyncio.run(run())[-1] == llm.Completion()
assert options[0]['max_retries'] == (0 if news else llm.MAX_RETRIES)
+10 -19
View File
@@ -161,33 +161,24 @@ def test_exclusive_start_persists_dispatch_start_and_preserves_existing_boundary
assert picked[0]==('backfill',1)
@pytest.mark.parametrize('dependency_state', ['pending','failed','done'])
def test_exclusive_event_enrolls_only_unfinished_dependencies_and_preserves_failures(trading,monkeypatch,dependency_state):
@pytest.mark.parametrize('event_state', ['pending','processing','failed'])
def test_exclusive_batch_tracks_event_without_package_research(trading,monkeypatch,event_state):
from alphapulse.news_risk import backfill
from alphapulse.models.risk import ReprocessJob,NewsEvent,RiskSettings
config={'request':{'run_mode':'history_only'}}
job=N(id=7,state='running',settings=config,failures=[])
event=N(id=9,state='pending',published_revision=0)
dependency=N(id=2,state=dependency_state,revision=2,published_revision=1,result=None,
review_config=None,error='Synthetic' if dependency_state=='failed' else None,dirty_since=None)
target=N(kind='events',object_id=9,revision=1)
event=N(id=9,state=event_state,published_revision=0,error='Synthetic')
settings=N(enabled=True)
session=MagicMock()
session.get=AsyncMock(side_effect=lambda model,*a,**k:{ReprocessJob:job,NewsEvent:event,RiskSettings:settings}[model])
session.scalar=AsyncMock(side_effect=[None,2 if dependency_state=='failed' else None])
session.scalars=AsyncMock(side_effect=[N(all=lambda:[]),N(all=lambda:[target]),N(all=lambda:[dependency])])
session.scalar=AsyncMock(return_value=None)
session.scalars=AsyncMock(side_effect=[N(all=lambda:[]),N(all=lambda:[N(kind='events',object_id=9,revision=1)])])
session.commit=AsyncMock()
enroll=AsyncMock();monkeypatch.setattr(backfill,'target',enroll)
asyncio.run(backfill.reconcile(session,job))
assert dependency.review_config==config
enroll.assert_awaited_once_with(session,7,'analysis',dependency)
if dependency_state=='failed':
assert dependency.state=='failed' and job.state=='failed' and not settings.enabled
assert any(f['stage']=='analysis' and f['id']==2 for f in job.failures)
else:
assert dependency.state=='pending' and job.state=='running' and settings.enabled
assert dependency.due_at<=datetime.now(UTC)
assert dependency.revision==(3 if dependency_state=='done' else 2)
enroll.assert_not_awaited()
assert job.state==('failed' if event_state=='failed' else 'running')
assert settings.enabled == (event_state!='failed')
def test_exclusive_batch_finishes_after_event_dependency_is_published(trading,monkeypatch):
@@ -202,7 +193,7 @@ def test_exclusive_batch_finishes_after_event_dependency_is_published(trading,mo
session=MagicMock()
session.get=AsyncMock(side_effect=lambda model,*a,**k:{
ReprocessJob:job,NewsEvent:event,NewsSummary:dependency,RiskSettings:settings,EventMember:None}[model])
session.scalar=AsyncMock(side_effect=[None,None,N(id=3,state='done',quotes={})])
session.scalar=AsyncMock(side_effect=[None,N(id=3,state='done',quotes={})])
session.scalars=AsyncMock(side_effect=[N(all=lambda:[]),N(all=lambda:targets),N(all=lambda:[])])
session.commit=AsyncMock()
monkeypatch.setattr(events,'reading_inputs',AsyncMock(return_value=[]))
@@ -210,7 +201,7 @@ def test_exclusive_batch_finishes_after_event_dependency_is_published(trading,mo
assert job.state=='done' and not settings.enabled
@pytest.mark.parametrize('failed_stage', ['grouping','analysis','events',None])
@pytest.mark.parametrize('failed_stage', ['grouping','events',None])
@pytest.mark.parametrize('prepare_only', [True,False])
def test_exclusive_outlook_never_admits_failed_batch_results(trading,monkeypatch,failed_stage,prepare_only):
from alphapulse.news_risk import worker,events,reprocess
+156
View File
@@ -0,0 +1,156 @@
"""Vocabulary and fixed-version behavior, isolated from production I/O."""
import asyncio
from types import SimpleNamespace as N
from unittest.mock import AsyncMock
import pytest
def test_equivalence_preserves_literal_aliases_and_quotes(trading):
from alphapulse.news_research.vocabulary import normalize, expand, VocabularyInput
data = VocabularyInput(
base_revision=0, entries=[{"canonical": "币安", "aliases": ["Binance"]}]
)
snapshot = {"revision": 1, "entries": [e.model_dump() for e in data.entries]}
original = [
{
"canonical": "Binance交易所",
"aliases": ["Binance"],
"quote": "Binance reported",
}
]
assert normalize(original, snapshot) == [{**original[0], "canonical": "币安"}]
assert original[0]["canonical"] == "Binance交易所"
assert expand([" BINANCE "], snapshot) == ["币安", "binance"]
assert expand(["Bank of Canada"], snapshot) == ["bank of canada"]
@pytest.mark.parametrize(
"entries",
[
[{"canonical": " ", "aliases": []}],
[{"canonical": "币安", "aliases": ["Binance", "BINANCE"]}],
[
{"canonical": "币安", "aliases": ["Binance"]},
{"canonical": "别家", "aliases": ["binance"]},
],
[{"canonical": "币安", "aliases": []}, {"canonical": "币安", "aliases": []}],
],
)
def test_reject_ambiguous_or_empty_vocabulary(trading, entries):
from alphapulse.news_research.vocabulary import VocabularyInput
with pytest.raises(ValueError):
VocabularyInput(base_revision=0, entries=entries)
def test_merging_tags_never_invents_literal_alias(trading):
from alphapulse.news_research.vocabulary import normalize
snapshot = {"entries": [{"canonical": "币安", "aliases": ["Binance"]}]}
result = normalize(
[
{"canonical": c, "aliases": [a], "quote": a}
for c, a in [("币安", "币安"), ("Binance", "Binance")]
],
snapshot,
)
assert len(result) == 1 and result[0]["aliases"] == ["币安", "Binance"]
def test_stale_vocabulary_save_conflicts_without_writing(trading, monkeypatch):
from fastapi import HTTPException
from alphapulse.news_research import api
monkeypatch.setattr(
api.vocab, "snapshot", AsyncMock(return_value={"revision": 2, "entries": []})
)
session = N(execute=AsyncMock(), add=AsyncMock(), commit=AsyncMock())
with pytest.raises(HTTPException) as error:
asyncio.run(
api.save_vocabulary(
api.vocab.VocabularyInput(base_revision=1, entries=[]), session
)
)
assert error.value.status_code == 409
session.add.assert_not_called()
session.commit.assert_not_called()
@pytest.mark.parametrize(
"event_id,state", [(2, "done"), (1, "failed"), (1, "superseded")]
)
def test_fixed_version_rejects_wrong_event_and_unsuccessful_versions(
trading, event_id, state
):
from fastapi import HTTPException
from alphapulse.news_research.api import event_version
session = N(get=AsyncMock(return_value=N(event_id=event_id, state=state)))
with pytest.raises(HTTPException) as error:
asyncio.run(event_version(1, 9, session))
assert error.value.status_code == 404
def test_acceptance_budget_is_shared_and_counts_failed_streams(trading, tmp_path):
import importlib.util
from pathlib import Path
spec = importlib.util.spec_from_file_location(
"acceptance_budget",
Path(__file__).resolve().parents[2] / "scripts/accept_news_research.py",
)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
counter = tmp_path / "requests"
first = module.Budget(counter, limit=2)
second = module.Budget(counter, limit=2)
first.take()
second.take()
with pytest.raises(module.BudgetExceeded):
first.take()
assert counter.read_text() == "2"
def test_budget_counts_transport_failures_before_retry(trading, tmp_path):
import importlib.util
from pathlib import Path
spec = importlib.util.spec_from_file_location(
"failed_stream_budget",
Path(__file__).resolve().parents[2] / "scripts/accept_news_research.py",
)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
budget = module.Budget(tmp_path / "counter", limit=2)
sent = []
async def failing():
sent.append(True)
raise ConnectionError("isolated failure")
yield
async def attempt():
async for _ in budget.wrap(failing)():
pass
async def run():
return await asyncio.gather(
*(attempt() for _ in range(3)), return_exceptions=True
)
errors = asyncio.run(run())
assert len(sent) == 2
assert sum(isinstance(e, module.BudgetExceeded) for e in errors) == 1
def test_supplemental_citation_must_use_returned_article_id(trading):
from alphapulse.news_risk.pipeline import validate_evidence, Evidence
sources = {-1: {"news_id": 1004, "content": "Original correction"}}
validate_evidence([Evidence(article_id=-1, quote="Original correction")], sources)
with pytest.raises(ValueError):
validate_evidence(
[Evidence(article_id=1004, quote="Original correction")], sources
)
+12 -1
View File
@@ -59,7 +59,7 @@ def test_delete_provider_handles_news_risk_reference(trading, monkeypatch, enabl
from alphapulse.api import settings
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(),
session = SimpleNamespace(scalar=AsyncMock(side_effect=[None, reference]), delete=AsyncMock(),
flush=AsyncMock(), commit=AsyncMock())
monkeypatch.setattr(settings, 'get_provider', AsyncMock(return_value=provider))
if enabled:
@@ -154,3 +154,14 @@ def test_provider_model_catalog_http_contract(trading, monkeypatch, failure):
assert response.status_code == ({None: 200, 'unavailable': 502, 'missing': 404}[failure])
if failure is None: assert response.json()['data'] == ['model-a', 'model-b']
asyncio.run(check())
def test_delete_provider_rejects_two_stage_binding_even_when_paused(trading, monkeypatch):
from fastapi import HTTPException
from alphapulse.api import settings
session=SimpleNamespace(scalar=AsyncMock(return_value=SimpleNamespace(enabled=False)),delete=AsyncMock())
monkeypatch.setattr(settings,'get_provider',AsyncMock(return_value=SimpleNamespace(id=1)))
with pytest.raises(HTTPException) as error:
asyncio.run(settings.delete_provider(1,session))
assert error.value.status_code==409
session.delete.assert_not_awaited()
+27 -1
View File
@@ -54,7 +54,7 @@ def test_unrelated_validation_does_not_retry(trading, monkeypatch):
calls = []
async def stream(*args):
calls.append(args)
yield Completion(tool_calls=[{'id': '1', 'name': 'submit', 'arguments': {}}], usage={'output_tokens': 4})
yield Completion(tool_calls=[{'id': '1', 'name': 'submit', 'arguments': {'timeline_points': 'invalid'}}], usage={'output_tokens': 4})
monkeypatch.setattr(service.llm, 'stream', stream)
provider = SimpleNamespace(model='synthetic', reasoning_effort='low', max_tokens=100, context_window=200000)
with pytest.raises(service.SubmissionFailure):
@@ -135,3 +135,29 @@ def test_summary_keeps_extraction_usage_when_compaction_fails(trading, monkeypat
with pytest.raises(service.SubmissionFailure) as error:
asyncio.run(pipeline.summarize_articles(SimpleNamespace(), 'rules', [], {'id': 1}))
assert usage_tokens(error.value.usage) == 579
@pytest.mark.parametrize('error_kind', ['missing', 'extra'])
def test_structural_repair_submits_full_result_once(trading, monkeypatch, error_kind):
from pydantic import ConfigDict
from alphapulse.news_risk import service
from alphapulse.ai.llm import Completion
class Category(BaseModel):
model_config = ConfigDict(extra='forbid')
category: str
calls = []
async def stream(provider, system, messages, tools, effort):
calls.append(messages)
args = ({'category': '能源'} if error_kind == 'extra' else {}) if len(calls) == 1 else {'category': '能源'}
if len(calls) == 1 and error_kind == 'extra': args['wrong_level'] = 'reason'
yield Completion(tool_calls=[{'id': str(len(calls)), 'name': 'submit', 'arguments': args}], usage={'input_tokens': 7})
monkeypatch.setattr(service.llm, 'stream', stream)
provider = SimpleNamespace(model='synthetic', reasoning_effort='max', max_tokens=100, context_window=200000)
result, usage = asyncio.run(service.complete(provider, 'rules', {}, Category))
assert result.category == '能源' and len(calls) == 2
assert len(usage['rounds']) == 2
assert 'errors' in calls[1][-1]['result']
calls.clear()
with pytest.raises(service.SubmissionFailure):
asyncio.run(service.complete(provider, 'rules', {'correction': 'already corrected'}, Category))
assert len(calls) == 1