feat: add historical news AI backfill and visible task queue
Freeze date-range previews and model settings, track per-news progress and downstream targets, and share completion-driven scheduling with realtime analysis. Prevent overlapping retries for the same news, retain batch queue associations, and refresh selected details with session-safe pagination.
This commit is contained in:
1 parent
cbd2618a20
commit
b45981ec30
25 files changed
+1339
-39
No files matched your search
@@ -0,0 +1,63 @@
|
||||
"""Backfill selection and task progress use isolated model and storage boundaries."""
|
||||
import asyncio
|
||||
from types import SimpleNamespace as N
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
import pytest
|
||||
|
||||
|
||||
def test_progress_is_scoped_and_removed_on_failure():
|
||||
from alphapulse.news_risk.progress import execute, step, active, current
|
||||
async def check():
|
||||
async def task():
|
||||
step('查询资料',tool='read_news')
|
||||
assert len(active)==1 and current.get()['tool']=='read_news'
|
||||
raise ValueError('Synthetic')
|
||||
with pytest.raises(ValueError): await execute(('analysis',7),task)
|
||||
assert active=={} and current.get() is None
|
||||
asyncio.run(check())
|
||||
|
||||
|
||||
@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):
|
||||
from alphapulse.news_risk.backfill import classification
|
||||
package=N(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
|
||||
|
||||
|
||||
def test_empty_active_memberships_are_incomplete(trading):
|
||||
from alphapulse.news_risk.backfill import classification
|
||||
session=N(scalar=AsyncMock(return_value=N(id=3,result={'old':True})),scalars=AsyncMock(return_value=N(all=lambda:[])))
|
||||
assert asyncio.run(classification(session,N(id=1,content_hash='current')))[0] is False
|
||||
|
||||
|
||||
def test_expired_preview_does_not_create_work(trading,monkeypatch):
|
||||
from alphapulse.news_risk import backfill
|
||||
monkeypatch.setattr(backfill.redis,'get',AsyncMock(return_value=None))
|
||||
session=MagicMock()
|
||||
with pytest.raises(ValueError,match='预览已过期'):asyncio.run(backfill.start(session,'expired'))
|
||||
session.add.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('modes',[[],['missing','missing'],['review','other'],['other']])
|
||||
def test_range_rejects_empty_duplicate_or_unknown_modes(trading,modes):
|
||||
from pydantic import ValidationError
|
||||
from alphapulse.api.risk import ReprocessIn
|
||||
with pytest.raises(ValidationError):
|
||||
ReprocessIn(start='2026-10-01T00:00:00Z',end='2026-11-01T00:00:00Z',modes=modes)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('failure',[False,True])
|
||||
def test_empty_or_invalid_outlook_poll_does_not_admit_a_task(trading,monkeypatch,failure):
|
||||
from alphapulse.news_risk import scheduler, worker
|
||||
session=MagicMock()
|
||||
session.__aenter__=AsyncMock(return_value=session); session.__aexit__=AsyncMock()
|
||||
session.get=AsyncMock(return_value=N(enabled=True,start_at='configured'))
|
||||
session.scalar=AsyncMock(return_value=None)
|
||||
monkeypatch.setattr(scheduler,'SessionLocal',lambda:session)
|
||||
monkeypatch.setattr(worker,'enqueue',AsyncMock())
|
||||
check=AsyncMock(side_effect=ValueError('Synthetic configuration failure') if failure else None,return_value=False)
|
||||
monkeypatch.setattr(worker,'outlook',check)
|
||||
assert asyncio.run(scheduler.candidate(set(),0)) is None
|
||||
check.assert_awaited_once_with(prepare_only=True)
|
||||
@@ -12,6 +12,7 @@ def fake_session(settings):
|
||||
session.get = AsyncMock(return_value=settings)
|
||||
session.scalars = AsyncMock(return_value=N(all=lambda: []))
|
||||
session.commit = AsyncMock()
|
||||
session.execute = AsyncMock()
|
||||
return session
|
||||
|
||||
|
||||
@@ -117,7 +118,7 @@ def test_history_completion_is_counted_independently_of_item_order(trading, monk
|
||||
items=[{'id':i,'hash':'current','state':'pending'} for i in (1,2,3)])
|
||||
article = N(id=30, state='done', error=None)
|
||||
session = fake_session(None)
|
||||
session.scalar = AsyncMock(side_effect=[job, article])
|
||||
session.scalar = AsyncMock(side_effect=[job, None, article])
|
||||
session.get = AsyncMock(side_effect=lambda model,*a,**kw: {
|
||||
RiskSettings:N(enabled=True), ReprocessJob:job,
|
||||
ArticleAnalysis:article, NewsItem:N(id=3, content_hash='current')}[model])
|
||||
|
||||
@@ -21,7 +21,7 @@ def test_pause_after_claim_does_not_count_old_success(trading, monkeypatch):
|
||||
job = N(id=20, state='pending', position=0, items=[{'id':21,'hash':'abc'}], failures=[], settings={})
|
||||
old = N(id=25, state='done', error=None, result={'old_success':True})
|
||||
session = session_mock()
|
||||
session.scalar.side_effect = [None, None, job, old]
|
||||
session.scalar.side_effect = [None, None, job, None, old]
|
||||
session.get.side_effect = lambda model, *a, **kw: {
|
||||
RiskSettings:N(enabled=True), NewsItem:N(id=21,content_hash='abc'), ArticleAnalysis:old, ReprocessJob:job}[model]
|
||||
paused = session_mock()
|
||||
@@ -59,7 +59,7 @@ def test_downstream_failure_does_not_mark_unrelated_news_failed(trading, monkeyp
|
||||
@pytest.mark.parametrize('stage', ['grouping', 'analysis', 'events', 'outlook'])
|
||||
def test_retry_resumes_only_failed_stage(trading, stage):
|
||||
from alphapulse.api.risk import reprocess_retry
|
||||
job = N(id=10,state='failed',position=2,items=[{'id':11},{'id':12}],
|
||||
job = N(id=10,state='failed',position=2,settings={},items=[{'id':11},{'id':12}],
|
||||
failures=[{'stage':stage,'id':11,'error':'Synthetic failure'}])
|
||||
failed = N(id=11,state='failed',quotes={})
|
||||
session = session_mock()
|
||||
@@ -81,7 +81,7 @@ def test_job_outlook_retry_waits_for_its_grouping_retry(trading, monkeypatch):
|
||||
from alphapulse.news_risk import reprocess, worker, events
|
||||
session = session_mock()
|
||||
session.get.return_value = N(enabled=True)
|
||||
session.scalar.side_effect = [None, N(quotes={'job_id':10}), N(id=10,position=0,items=[{'id':11}])]
|
||||
session.scalar.side_effect = [None, N(quotes={'job_id':10}), N(id=10,position=0,settings={},items=[{'id':11}])]
|
||||
monkeypatch.setattr(worker,'SessionLocal',lambda:session)
|
||||
monkeypatch.setattr(events,'reading_inputs',AsyncMock(return_value=[]))
|
||||
current = AsyncMock(side_effect=AssertionError('Must not use current settings for a job retry'))
|
||||
|
||||
Reference in new issue
Block a user