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:
1 parent
07252fea0f
commit
4929a32a0a
69 files changed
+8215
-2092
No files matched your search
+199
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Reference in new issue
Block a user