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,32 @@
|
||||
"""Normalize historical items and track the batch's downstream targets."""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
revision = 'f60123456789'
|
||||
down_revision = 'e5f701234567'
|
||||
branch_labels = depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table('news_ai_reprocess_items',
|
||||
sa.Column('id', sa.BigInteger(), primary_key=True),
|
||||
sa.Column('job_id', sa.BigInteger(), sa.ForeignKey('news_ai_reprocess_jobs.id'), nullable=False, index=True),
|
||||
sa.Column('news_id', sa.BigInteger(), sa.ForeignKey('news_items.id'), nullable=False, index=True),
|
||||
sa.Column('content_hash', sa.String(64), nullable=False),
|
||||
sa.Column('mode', sa.String(16), nullable=False),
|
||||
sa.Column('state', sa.String(16), nullable=False, index=True), sa.Column('error', sa.Text()),
|
||||
sa.Column('started_at', sa.DateTime(timezone=True)), sa.Column('finished_at', sa.DateTime(timezone=True)),
|
||||
sa.UniqueConstraint('job_id', 'news_id'))
|
||||
op.create_table('news_ai_reprocess_targets',
|
||||
sa.Column('id', sa.BigInteger(), primary_key=True),
|
||||
sa.Column('job_id', sa.BigInteger(), sa.ForeignKey('news_ai_reprocess_jobs.id'), nullable=False, index=True),
|
||||
sa.Column('kind', sa.String(16), nullable=False), sa.Column('object_id', sa.BigInteger(), nullable=False),
|
||||
sa.Column('revision', sa.Integer(), nullable=False), sa.UniqueConstraint('job_id', 'kind', 'object_id'))
|
||||
op.execute("""INSERT INTO news_ai_reprocess_items(job_id,news_id,content_hash,mode,state)
|
||||
SELECT j.id,(i->>'id')::bigint,i->>'hash','legacy',i->>'state'
|
||||
FROM news_ai_reprocess_jobs j, jsonb_array_elements(j.items) i
|
||||
JOIN news_items n ON n.id=(i->>'id')::bigint ON CONFLICT DO NOTHING""")
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table('news_ai_reprocess_targets')
|
||||
op.drop_table('news_ai_reprocess_items')
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Authenticated news risk research endpoints."""
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from enum import IntEnum
|
||||
from typing import Annotated
|
||||
from typing import Annotated, Literal
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
from sqlalchemy import select, func, text
|
||||
@@ -195,7 +195,9 @@ async def overview(session: DB, hours: RiskWindow = RiskWindow.DAY):
|
||||
if runtime: concurrency.update(json.loads(runtime))
|
||||
from alphapulse.models.risk import ReprocessJob
|
||||
jobs = (await session.scalars(select(ReprocessJob).where(ReprocessJob.state.in_(['pending', 'running'])))).all()
|
||||
from alphapulse.models.risk import ReprocessItem
|
||||
historical_pending = sum(i['state'] == 'pending' for job in jobs for i in job.items)
|
||||
historical_pending += await session.scalar(select(func.count()).select_from(ReprocessItem).where(ReprocessItem.job_id.in_([j.id for j in jobs if j.settings.get('request')]), ReprocessItem.state=='pending')) or 0
|
||||
coverage_data = await coverage(session)
|
||||
concurrency['queued'] = historical_pending + sum(stage2.get(k, 0) for k in ('pending',)) + event_states.get('pending', 0) + coverage_data['pending']
|
||||
return success({'concurrency': concurrency, 'event_states': event_states, 'stage2': stage2, 'stage3': stage3, 'as_of': now, 'counts': counts, 'coverage': coverage_data, 'scenario': scenario, 'briefing': snapshot.result if snapshot else None,
|
||||
@@ -372,11 +374,13 @@ async def event_timeline(identifier: int, session: DB):
|
||||
|
||||
|
||||
class ReprocessIn(BaseModel):
|
||||
modes: list[Literal['missing','review']] = Field(min_length=1,max_length=2)
|
||||
start: datetime
|
||||
end: datetime
|
||||
|
||||
@model_validator(mode='after')
|
||||
def range_valid(self):
|
||||
if len(set(self.modes)) != len(self.modes): raise ValueError('回补选项不能重复')
|
||||
if self.start.tzinfo is None or self.end.tzinfo is None or self.start >= self.end:
|
||||
raise ValueError('请提供带时区的有效起止时间')
|
||||
return self
|
||||
@@ -384,15 +388,19 @@ class ReprocessIn(BaseModel):
|
||||
|
||||
@router.post('/reprocess/preview')
|
||||
async def reprocess_preview(body: ReprocessIn, session: DB):
|
||||
from alphapulse.news_risk.reprocess import preview
|
||||
counts, _ = await preview(session, body.start, body.end)
|
||||
return success(counts)
|
||||
from alphapulse.news_risk.backfill import preview
|
||||
try: return success(await preview(session,body.start,body.end,body.modes))
|
||||
except ValueError as error: raise HTTPException(409,str(error)) from error
|
||||
|
||||
|
||||
class ReprocessStart(BaseModel):
|
||||
preview_id: str = Field(min_length=20,max_length=80,pattern='^[A-Za-z0-9_-]+$')
|
||||
|
||||
|
||||
@router.post('/reprocess', status_code=202)
|
||||
async def reprocess_start(body: ReprocessIn, session: DB):
|
||||
from alphapulse.news_risk.reprocess import start_job
|
||||
try: result = await start_job(session, body.start, body.end)
|
||||
async def reprocess_start(body: ReprocessStart, session: DB):
|
||||
from alphapulse.news_risk.backfill import start
|
||||
try: result = await start(session, body.preview_id)
|
||||
except ValueError as error: raise HTTPException(409, str(error)) from error
|
||||
return success(result)
|
||||
|
||||
@@ -401,8 +409,8 @@ async def reprocess_start(body: ReprocessIn, session: DB):
|
||||
async def reprocess_list(session: DB):
|
||||
from alphapulse.models.risk import ReprocessJob
|
||||
rows = (await session.scalars(select(ReprocessJob).order_by(ReprocessJob.id.desc()).limit(20))).all()
|
||||
return success([{'id': r.id, 'state': r.state, 'total': len(r.items), 'completed': r.position, 'failures': r.failures,
|
||||
'created_at': r.created_at} for r in rows])
|
||||
from alphapulse.news_risk.queue import job_out
|
||||
return success([await job_out(session,r) for r in rows])
|
||||
|
||||
|
||||
@router.post('/reprocess/{identifier}/cancel')
|
||||
@@ -423,6 +431,10 @@ async def reprocess_retry(identifier: int, session: DB):
|
||||
if job is None or job.state != 'failed': raise HTTPException(409, '仅可重试失败任务')
|
||||
if await session.scalar(select(ReprocessJob.id).where(ReprocessJob.state.in_(['pending', 'running'])).limit(1)):
|
||||
raise HTTPException(409, '已有重整理任务')
|
||||
if job.settings.get('request'):
|
||||
from alphapulse.models.risk import ReprocessItem
|
||||
from sqlalchemy import update
|
||||
await session.execute(update(ReprocessItem).where(ReprocessItem.job_id==job.id,ReprocessItem.state=='failed').values(state='pending',error=None))
|
||||
outlook_ids = {f['id'] for f in job.failures if f['stage'] == 'outlook'}
|
||||
snapshots = (await session.scalars(select(RiskSnapshot).where(RiskSnapshot.id.in_(outlook_ids), RiskSnapshot.state == 'failed',
|
||||
RiskSnapshot.quotes['job_id'].as_integer() == job.id))).all()
|
||||
@@ -442,3 +454,24 @@ async def reprocess_retry(identifier: int, session: DB):
|
||||
job.position = len(job.items)
|
||||
job.failures = []; job.state = 'running'
|
||||
await session.commit(); return success(None)
|
||||
|
||||
|
||||
@router.get('/queue')
|
||||
async def queue(session: DB, state: Literal['running','queued','waiting','failed','done'] | None = None,
|
||||
stage: Literal['grouping','analysis','events','outlook','backfill'] | None = None,
|
||||
origin: Literal['history','realtime'] | None = None, job_id: int | None = Query(None,gt=0),
|
||||
page: int = Query(1,ge=1), limit: int = Query(20,ge=1,le=100)):
|
||||
from alphapulse.news_risk.queue import listing
|
||||
return success(await listing(session,state,stage,origin,job_id,page,limit))
|
||||
|
||||
|
||||
@router.get('/reprocess/{identifier}')
|
||||
async def reprocess_detail(identifier: int, session: DB, page: int = Query(1,ge=1), limit: int = Query(20,ge=1,le=100)):
|
||||
from alphapulse.models.risk import ReprocessJob, ReprocessItem
|
||||
from alphapulse.news_risk.queue import job_out
|
||||
job=await session.get(ReprocessJob,identifier)
|
||||
if job is None: raise HTTPException(404,'回补批次不存在')
|
||||
rows=(await session.execute(select(ReprocessItem,NewsItem.title,NewsItem.url).join(NewsItem).where(
|
||||
ReprocessItem.job_id==identifier).order_by(ReprocessItem.id).offset((page-1)*limit).limit(limit))).all()
|
||||
return success({**await job_out(session,job),'items':[{'id':i.id,'news_id':i.news_id,'title':title,'url':url,
|
||||
'mode':i.mode,'state':i.state,'error':i.error} for i,title,url in rows]})
|
||||
@@ -224,3 +224,27 @@ class EventLink(Base):
|
||||
event_id: Mapped[int] = mapped_column(ForeignKey('news_ai_events.id'), primary_key=True)
|
||||
reason: Mapped[str] = mapped_column(Text)
|
||||
package_revision: Mapped[int] = mapped_column(Integer)
|
||||
|
||||
|
||||
class ReprocessItem(Base):
|
||||
__tablename__ = 'news_ai_reprocess_items'
|
||||
__table_args__ = (UniqueConstraint('job_id', 'news_id'),)
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
job_id: Mapped[int] = mapped_column(ForeignKey('news_ai_reprocess_jobs.id'), index=True)
|
||||
news_id: Mapped[int] = mapped_column(ForeignKey('news_items.id'), index=True)
|
||||
content_hash: Mapped[str] = mapped_column(String(64))
|
||||
mode: Mapped[str] = mapped_column(String(16))
|
||||
state: Mapped[str] = mapped_column(String(16), default='pending', index=True)
|
||||
error: Mapped[str | None] = mapped_column(Text)
|
||||
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
|
||||
class ReprocessTarget(Base):
|
||||
__tablename__ = 'news_ai_reprocess_targets'
|
||||
__table_args__ = (UniqueConstraint('job_id', 'kind', 'object_id'),)
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
job_id: Mapped[int] = mapped_column(ForeignKey('news_ai_reprocess_jobs.id'), index=True)
|
||||
kind: Mapped[str] = mapped_column(String(16))
|
||||
object_id: Mapped[int] = mapped_column(BigInteger)
|
||||
revision: Mapped[int] = mapped_column(Integer)
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Snapshot-selected historical completion and explicit full rechecks."""
|
||||
import json
|
||||
import secrets
|
||||
from datetime import UTC, datetime
|
||||
from sqlalchemy import select, func, text, exists, or_, any_, literal, BigInteger
|
||||
from sqlalchemy.dialects.postgresql import insert, ARRAY
|
||||
from alphapulse.core.db import SessionLocal
|
||||
from alphapulse.core.redis import redis
|
||||
from alphapulse.models import NewsItem
|
||||
from alphapulse.models.risk import (RiskSettings, ReprocessJob, ReprocessItem, ReprocessTarget,
|
||||
ArticleAnalysis, SummaryArticle, NewsSummary, NewsEvent, EventMember)
|
||||
from alphapulse.news_risk.reprocess import capture
|
||||
|
||||
|
||||
async def classification(session, news):
|
||||
article = await session.scalar(select(ArticleAnalysis).where(ArticleAnalysis.news_id == news.id, ArticleAnalysis.content_hash == news.content_hash))
|
||||
packages = [] if article is None else (await session.scalars(select(NewsSummary).join(SummaryArticle).where(
|
||||
SummaryArticle.analysis_id == article.id, SummaryArticle.active.is_(True), NewsSummary.replacement_ids == [], NewsSummary.state != 'withdrawn'))).all()
|
||||
complete = bool(article and article.result and packages and all(p.result and p.published_revision == p.revision for p in packages))
|
||||
return complete, article, packages
|
||||
|
||||
|
||||
async def preview(session, start, end, modes):
|
||||
linked = select(NewsSummary.id).join(SummaryArticle).where(
|
||||
SummaryArticle.analysis_id == ArticleAnalysis.id, SummaryArticle.active.is_(True),
|
||||
NewsSummary.replacement_ids == [], NewsSummary.state != 'withdrawn')
|
||||
incomplete = linked.where(or_(NewsSummary.result.is_(None), func.jsonb_typeof(NewsSummary.result) == 'null',
|
||||
NewsSummary.published_revision != NewsSummary.revision))
|
||||
complete = exists(select(ArticleAnalysis.id).where(ArticleAnalysis.news_id == NewsItem.id,
|
||||
ArticleAnalysis.content_hash == NewsItem.content_hash, ArticleAnalysis.result.is_not(None),
|
||||
func.jsonb_typeof(ArticleAnalysis.result) != 'null', exists(linked), ~exists(incomplete)))
|
||||
rows = (await session.execute(select(NewsItem.id, NewsItem.content_hash, complete.label('complete')).where(
|
||||
NewsItem.published_at >= start, NewsItem.published_at < end, NewsItem.published_at <= datetime.now(UTC))
|
||||
.order_by(NewsItem.published_at, NewsItem.id))).all()
|
||||
counts = {'total':len(rows), 'missing':0, 'review':0}
|
||||
items = []
|
||||
for row in rows:
|
||||
mode = 'review' if row.complete else 'missing'
|
||||
counts[mode] += 1
|
||||
if mode in modes: items.append({'id':row.id, 'hash':row.content_hash, 'mode':mode})
|
||||
packages = select(NewsSummary.id).join(SummaryArticle).join(ArticleAnalysis).join(NewsItem).where(
|
||||
NewsItem.id == any_(literal([i['id'] for i in items],type_=ARRAY(BigInteger))), ArticleAnalysis.content_hash == NewsItem.content_hash,
|
||||
SummaryArticle.active.is_(True), NewsSummary.replacement_ids == [], NewsSummary.state != 'withdrawn').distinct()
|
||||
counts['articles'] = len(items)
|
||||
counts['packages'] = await session.scalar(select(func.count()).select_from(packages.subquery()))
|
||||
counts['events'] = await session.scalar(select(func.count(func.distinct(EventMember.event_id))).where(EventMember.package_id.in_(packages)))
|
||||
settings = await session.get(RiskSettings, 1)
|
||||
if settings is None: raise ValueError('请先配置新闻AI')
|
||||
config = await capture(session, settings)
|
||||
identifier = secrets.token_urlsafe(24)
|
||||
value = {'items':items, 'config':config, 'request':{'start':start.isoformat(),'end':end.isoformat(),'modes':modes}}
|
||||
await redis.set('ap:risk:preview:'+identifier, json.dumps(value, default=str), ex=900)
|
||||
return {**counts, 'preview_id':identifier, 'models':{k:{field:v['provider'][field] for field in ('model','reasoning_effort')} for k,v in config['configurations'].items()}}
|
||||
|
||||
|
||||
async def start(session, identifier):
|
||||
raw = await redis.get('ap:risk:preview:'+identifier)
|
||||
if not raw: raise ValueError('预览已过期,请重新预览')
|
||||
value = json.loads(raw)
|
||||
await session.execute(text('SELECT pg_advisory_xact_lock(20261101, 2)'))
|
||||
if await session.scalar(select(ReprocessJob.id).where(ReprocessJob.state.in_(['pending','running']))):
|
||||
raise ValueError('已有回补任务')
|
||||
settings = await session.get(RiskSettings, 1)
|
||||
if settings is None or await capture(session, settings) != value['config']:
|
||||
raise ValueError('模型或Skill配置已变化,请重新预览')
|
||||
if not value['items']: raise ValueError('没有符合选择条件的新闻')
|
||||
job = ReprocessJob(state='pending', items=[], position=0, failures=[], settings=value['config'])
|
||||
session.add(job); await session.flush()
|
||||
job.settings = {**job.settings, 'job_id':job.id, 'request':value['request']}
|
||||
session.add_all([ReprocessItem(job_id=job.id, news_id=i['id'], content_hash=i['hash'], mode=i['mode'], state='pending') for i in value['items']])
|
||||
await session.commit()
|
||||
await redis.delete('ap:risk:preview:'+identifier)
|
||||
return {'id':job.id}
|
||||
|
||||
|
||||
async def target(session, job_id, kind, obj):
|
||||
await session.execute(insert(ReprocessTarget).values(job_id=job_id, kind=kind, object_id=obj.id, revision=obj.revision)
|
||||
.on_conflict_do_update(index_elements=['job_id','kind','object_id'], set_={'revision':obj.revision}))
|
||||
|
||||
|
||||
async def process(identifier):
|
||||
from alphapulse.news_risk import worker
|
||||
async with SessionLocal() as session:
|
||||
item = await session.get(ReprocessItem, identifier, with_for_update=True)
|
||||
if item is None or item.state != 'pending': return
|
||||
job = await session.get(ReprocessJob, item.job_id, with_for_update=True)
|
||||
settings = await session.get(RiskSettings, 1)
|
||||
if job.state not in ('pending','running') or not settings.enabled: return
|
||||
job_id, news_id, mode, content_hash = item.job_id, item.news_id, item.mode, item.content_hash
|
||||
item.state = 'processing'; item.error = None; item.finished_at = None; item.started_at = datetime.now(UTC); job.state = 'running'
|
||||
await session.commit()
|
||||
try:
|
||||
news = await session.get(NewsItem, item.news_id)
|
||||
if news is None or news.content_hash != item.content_hash: raise ValueError('原文版本变化,请重新预览范围')
|
||||
complete, article, packages = await classification(session, news)
|
||||
if mode == 'missing' and complete:
|
||||
item.state = 'skipped'; item.finished_at = datetime.now(UTC); await session.commit(); return
|
||||
needs_grouping = mode == 'review' or not article or not article.result or not packages
|
||||
if article is None:
|
||||
article = ArticleAnalysis(news_id=news.id, content_hash=news.content_hash, state='reprocess', input={
|
||||
'title':news.title,'summary':news.summary,'content':news.content_text,'url':news.url,'source':news.source_code,
|
||||
'source_tags':news.tags,'revision_no':news.revision_no,'published_at':news.published_at.isoformat(),
|
||||
'progress_at':max(news.published_at,news.source_updated_at or news.published_at).isoformat()})
|
||||
session.add(article); await session.flush()
|
||||
article_id = article.id
|
||||
await session.commit()
|
||||
if needs_grouping:
|
||||
outcome = await worker.analyze(article_id, job.settings)
|
||||
if outcome == 'deferred':
|
||||
item.state = 'pending'; await session.commit(); return
|
||||
if outcome != 'done':
|
||||
row = await session.get(ArticleAnalysis, article_id, populate_existing=True)
|
||||
raise ValueError(row.error or '原文已变化,分包未完成')
|
||||
await session.rollback()
|
||||
job = await session.get(ReprocessJob, job_id, populate_existing=True)
|
||||
news = await session.get(NewsItem,news_id,populate_existing=True)
|
||||
if news.content_hash != content_hash: raise ValueError('原文版本变化,请重新预览范围')
|
||||
_, _, packages = await classification(session,news)
|
||||
for package in packages:
|
||||
if package.result and package.published_revision == package.revision and mode == 'missing': continue
|
||||
previous = await session.scalar(select(ReprocessTarget).where(ReprocessTarget.job_id==job.id,
|
||||
ReprocessTarget.kind=='analysis',ReprocessTarget.object_id==package.id))
|
||||
if previous is None or package.revision > previous.revision:
|
||||
if package.state not in ('pending','processing'):
|
||||
worker.schedule(package,datetime.now(UTC),True)
|
||||
package.review_config = job.settings
|
||||
await target(session,job.id,'analysis',package)
|
||||
item = await session.get(ReprocessItem, identifier, populate_existing=True)
|
||||
item.state = 'done'; item.finished_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
except Exception as error:
|
||||
await session.rollback()
|
||||
item = await session.get(ReprocessItem,identifier)
|
||||
item.state='failed'; item.error=str(error)[:1500]; item.finished_at=datetime.now(UTC)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def reconcile(session, job):
|
||||
job = await session.get(ReprocessJob,job.id,with_for_update={"key_share":True},populate_existing=True)
|
||||
if job.state not in ('pending','running'): return
|
||||
if await session.scalar(select(ReprocessItem.id).where(ReprocessItem.job_id==job.id,ReprocessItem.state.in_(['pending','processing'])).limit(1)): return
|
||||
items = (await session.scalars(select(ReprocessItem).where(ReprocessItem.job_id==job.id,ReprocessItem.state=='failed'))).all()
|
||||
targets = (await session.scalars(select(ReprocessTarget).where(ReprocessTarget.job_id==job.id))).all()
|
||||
failures = [{'stage':'grouping','id':i.news_id,'error':i.error} for i in items if i.state=='failed']
|
||||
waiting = False
|
||||
for row in targets:
|
||||
obj = await session.get(NewsSummary if row.kind=='analysis' else NewsEvent,row.object_id)
|
||||
if obj is None: continue
|
||||
if row.kind=='analysis' and (obj.replacement_ids or obj.state=='withdrawn'):
|
||||
for replacement in obj.replacement_ids:
|
||||
linked=await session.get(NewsSummary,replacement)
|
||||
await target(session,job.id,'analysis',linked)
|
||||
if obj.replacement_ids:
|
||||
await session.delete(row); waiting=True
|
||||
continue
|
||||
if obj.state=='retired': continue
|
||||
if obj.state=='failed': failures.append({'stage':row.kind,'id':obj.id,'error':obj.error})
|
||||
elif row.kind == 'events' and await session.scalar(select(NewsSummary.id).join(EventMember).where(
|
||||
EventMember.event_id == obj.id, NewsSummary.state == 'failed', NewsSummary.replacement_ids == []).limit(1)):
|
||||
failures.append({'stage':'events','id':obj.id,'error':'组成包研判失败,请先重试失败包'})
|
||||
elif obj.state not in ('done','retired') or obj.published_revision < row.revision: waiting=True
|
||||
if row.kind=='analysis':
|
||||
membership=await session.get(EventMember,obj.id)
|
||||
if membership:
|
||||
event=await session.get(NewsEvent,membership.event_id)
|
||||
await target(session,job.id,'events',event)
|
||||
if not any(t.kind=='events' and t.object_id==event.id for t in targets): waiting=True
|
||||
if event.state=='failed': failures.append({'stage':'events','id':event.id,'error':event.error})
|
||||
if waiting: await session.commit(); return
|
||||
job.failures=list({(f['stage'],f['id']):f for f in failures}.values()); job.state='failed' if failures else 'done'
|
||||
await session.commit()
|
||||
@@ -67,6 +67,9 @@ async def apply_correction(session, proposal: Correction, current_id, researched
|
||||
link = SummaryArticle(summary_id=package.id, analysis_id=assignment.analysis_id); session.add(link)
|
||||
link.active = True; link.unit = {'fact':replacement.fact,'evidence':assignment.evidence,'correction':True}
|
||||
schedule(package, now, True); outputs.append(package.id)
|
||||
if package.review_config and package.review_config.get('request'):
|
||||
from alphapulse.news_risk.backfill import target
|
||||
await target(session, package.review_config['job_id'], 'analysis', package)
|
||||
for package in packages:
|
||||
if package.id not in outputs:
|
||||
package.replacement_ids = outputs; package.state = 'superseded'
|
||||
|
||||
@@ -7,6 +7,7 @@ from alphapulse.core.db import SessionLocal
|
||||
from alphapulse.news_risk.pipeline import configuration
|
||||
from alphapulse.news_risk.contracts import PackageTimePoint
|
||||
from alphapulse.news_risk.service import complete
|
||||
from alphapulse.news_risk.progress import step
|
||||
|
||||
|
||||
class TimelinePoint(PackageTimePoint):
|
||||
@@ -54,6 +55,9 @@ async def dirty_event(session, package_id, review_config=None):
|
||||
event.review_config = review_config
|
||||
event.state = 'pending'
|
||||
event.due_at = datetime.now(UTC)+timedelta(seconds=60)
|
||||
if review_config and review_config.get('request'):
|
||||
from alphapulse.news_risk.backfill import target
|
||||
await target(session, review_config['job_id'], 'events', event)
|
||||
|
||||
|
||||
async def associate(session, package, result, researched_ids, researched_events=None):
|
||||
@@ -89,6 +93,9 @@ async def associate(session, package, result, researched_ids, researched_events=
|
||||
event.review_config = package.review_config
|
||||
event.state = 'pending'; event.due_at = datetime.now(UTC)+timedelta(seconds=60)
|
||||
event.updated_at = max(p.updated_at for p in members)
|
||||
if package.review_config and package.review_config.get('request'):
|
||||
from alphapulse.news_risk.backfill import target
|
||||
await target(session, package.review_config['job_id'], 'events', event)
|
||||
|
||||
|
||||
async def organize(target_id=None):
|
||||
@@ -122,7 +129,9 @@ async def organize(target_id=None):
|
||||
version_id, event_id = version.id, event.id
|
||||
try:
|
||||
if config_error: raise ValueError(config_error)
|
||||
step('梳理事件时间线')
|
||||
result, usage = await complete(provider, system, {'task': 'event_timeline', 'previous': event.result, 'packages': packages}, EventResult)
|
||||
step('核对时间线与证据')
|
||||
validate_timeline(result, packages)
|
||||
version.result = result.model_dump(mode='json'); version.usage = usage; version.state = 'done'
|
||||
from alphapulse.news_risk.worker import publication_lock
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Best-effort actual execution steps, scoped to background news tasks."""
|
||||
from contextvars import ContextVar
|
||||
from datetime import UTC, datetime
|
||||
import logging
|
||||
|
||||
current = ContextVar('news_ai_progress', default=None)
|
||||
active = {}
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def step(phase, **details):
|
||||
record = current.get()
|
||||
if record is not None:
|
||||
record.update(phase=phase, updated_at=datetime.now(UTC).isoformat(), **details)
|
||||
|
||||
|
||||
async def execute(key, action):
|
||||
identity = str(key)
|
||||
record = {'key': identity, 'stage': key[0], 'object_id': key[1], 'phase': '准备输入',
|
||||
'started_at': datetime.now(UTC).isoformat(), 'updated_at': datetime.now(UTC).isoformat()}
|
||||
active[identity] = record
|
||||
token = current.set(record)
|
||||
try:
|
||||
return await action()
|
||||
finally:
|
||||
current.reset(token)
|
||||
active.pop(identity, None)
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Queue presentation combines durable work with short-lived execution steps."""
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from sqlalchemy import select, func, or_, exists
|
||||
from alphapulse.models import NewsItem
|
||||
from alphapulse.models.risk import (ReprocessItem, ReprocessTarget, ArticleAnalysis, NewsSummary, NewsEvent, RiskSnapshot, RiskSettings, ReprocessJob)
|
||||
from alphapulse.core.redis import redis
|
||||
|
||||
|
||||
async def job_out(session, job):
|
||||
counts = dict((await session.execute(select(ReprocessItem.state,func.count()).where(ReprocessItem.job_id==job.id).group_by(ReprocessItem.state))).all())
|
||||
if not job.settings.get('request'):
|
||||
counts = {state:sum(i.get('state','pending')==state for i in job.items) for state in ('pending','processing','done','failed')}
|
||||
return {'id':job.id,'state':job.state,'total':len(job.items),'completed':job.position,'failures':job.failures,'request':None,'counts':counts}
|
||||
stages = {}
|
||||
for kind, model in (('analysis',NewsSummary),('events',NewsEvent)):
|
||||
stages[kind] = dict((await session.execute(select(model.state,func.count()).join(
|
||||
ReprocessTarget,ReprocessTarget.object_id==model.id).where(ReprocessTarget.job_id==job.id,
|
||||
ReprocessTarget.kind==kind).group_by(model.state))).all())
|
||||
return {'id':job.id,'state':job.state,'request':job.settings['request'],'counts':counts,'stages':stages,
|
||||
'total':sum(counts.values()),'completed':sum(counts.get(s,0) for s in ('done','failed','skipped')),'failures':job.failures}
|
||||
|
||||
|
||||
async def listing(session, state, stage, origin, job_id, page, limit):
|
||||
now = datetime.now(UTC)
|
||||
runtime = await redis.get('ap:risk:concurrency')
|
||||
runtime = json.loads(runtime) if runtime else None
|
||||
raw = await redis.get('ap:risk:active')
|
||||
active = json.loads(raw) if raw else None
|
||||
settings = await session.get(RiskSettings,1)
|
||||
entries=[]
|
||||
total=0
|
||||
offset=(page-1)*limit
|
||||
for kind,model in [('grouping',ArticleAnalysis),('analysis',NewsSummary),('events',NewsEvent),('outlook',RiskSnapshot),('backfill',ReprocessItem)]:
|
||||
if stage and stage!=kind:continue
|
||||
query=select(model)
|
||||
busy = [a['object_id'] for a in (active or []) if a['stage']==kind]
|
||||
if kind=='grouping':
|
||||
held = exists(select(ReprocessItem.id).join(ReprocessJob).where(ReprocessItem.news_id==model.news_id,
|
||||
ReprocessItem.content_hash==model.content_hash, ReprocessItem.state.in_(['pending','processing']),
|
||||
ReprocessJob.state.in_(['pending','running'])))
|
||||
running = model.id.in_(busy) if active is not None else (
|
||||
(model.state=='processing') & ~held.where(ReprocessItem.state=='processing'))
|
||||
query=query.join(NewsItem).where(model.content_hash==NewsItem.content_hash,or_(~held,running))
|
||||
pending = model.state.in_(['pending','processing','reprocess'])
|
||||
due = getattr(model,'due_at',None)
|
||||
waiting = due > now if due is not None else False
|
||||
if kind=='events':
|
||||
from alphapulse.models.risk import EventMember
|
||||
blocked=select(EventMember.event_id).join(NewsSummary,NewsSummary.id==EventMember.package_id).where(
|
||||
NewsSummary.replacement_ids==[],NewsSummary.state!='withdrawn',
|
||||
or_(NewsSummary.state!='done',NewsSummary.result==None))
|
||||
waiting=or_(waiting,model.id.in_(blocked))
|
||||
if state=='running': query=query.where(model.id.in_(busy) if active is not None else model.state=='processing')
|
||||
elif state=='waiting': query=query.where(pending,model.id.not_in(busy),waiting)
|
||||
elif state=='queued': query=query.where(pending,model.state!='processing' if active is None else True,model.id.not_in(busy),~waiting if due is not None else True)
|
||||
elif state in ('failed','done'):query=query.where(model.state==state)
|
||||
else:query=query.where(pending)
|
||||
if kind=='backfill':
|
||||
query=query.join(ReprocessJob,ReprocessJob.id==model.job_id).where(or_(ReprocessJob.state.in_(['pending','running']),model.state=='processing') if state not in ('failed','done') else True)
|
||||
if origin=='realtime':continue
|
||||
if job_id:query=query.where(model.job_id==job_id)
|
||||
elif kind in ('analysis','events'):
|
||||
batch=model.review_config['job_id'].as_integer()
|
||||
if origin:query=query.where(batch.is_not(None) if origin=='history' else batch.is_(None))
|
||||
if job_id:
|
||||
tracked=exists(select(ReprocessTarget.id).where(ReprocessTarget.job_id==job_id,
|
||||
ReprocessTarget.kind==kind,ReprocessTarget.object_id==model.id))
|
||||
query=query.where(or_(tracked,batch==job_id))
|
||||
elif kind=='outlook':
|
||||
if job_id:query=query.where(model.quotes['job_id'].as_integer()==job_id)
|
||||
if origin:query=query.where(model.quotes['job_id'].as_integer().is_not(None) if origin=='history' else model.quotes['job_id'].as_integer().is_(None))
|
||||
elif job_id or origin=='history':continue
|
||||
count = await session.scalar(select(func.count()).select_from(query.subquery()))
|
||||
total += count
|
||||
if offset >= count:
|
||||
offset -= count; continue
|
||||
if len(entries) == limit: continue
|
||||
rows=(await session.scalars(query.order_by(model.id.desc()).offset(offset).limit(limit-len(entries)))).all()
|
||||
offset=0
|
||||
for obj in rows:
|
||||
url = None
|
||||
if kind=='backfill':
|
||||
news=await session.get(NewsItem,obj.news_id); title=news.title if news else f'新闻 #{obj.news_id}'
|
||||
batch=obj.job_id
|
||||
url=news.url if news else None
|
||||
elif kind=='grouping':title=obj.input.get('title','');batch=None;url=obj.input.get('url')
|
||||
elif kind=='outlook':title='72小时阅读与趋势';batch=obj.quotes.get('job_id')
|
||||
else:title=obj.title;batch=(obj.review_config or {}).get('job_id')
|
||||
source='history' if batch else 'realtime'
|
||||
config_stage = 'grouping' if kind=='backfill' else 'analysis' if kind=='events' else kind
|
||||
frozen = (await session.get(ReprocessJob,batch)).settings if batch else None
|
||||
config = frozen['configurations'][config_stage]['provider'] if frozen else {}
|
||||
if not config and settings:
|
||||
prefix = {'grouping':'','analysis':'second_','outlook':'third_'}[config_stage]
|
||||
from alphapulse.models import AiProvider
|
||||
provider = await session.get(AiProvider,getattr(settings,prefix+'provider_id')) if getattr(settings,prefix+'provider_id') else None
|
||||
config = {'model':getattr(settings,prefix+'model') or (provider.model if provider else None),
|
||||
'reasoning_effort':getattr(settings,prefix+'reasoning_effort') if getattr(settings,prefix+'reasoning_effort') is not None else provider.reasoning_effort if provider else None}
|
||||
actual=next((a for a in (active or []) if a['stage']==kind and a['object_id']==obj.id),None)
|
||||
waiting = getattr(obj,'due_at',now)>now
|
||||
status='running' if actual else 'waiting' if waiting else 'queued' if obj.state in ('pending','processing','reprocess') else obj.state
|
||||
if state: status=state
|
||||
if obj.state=='processing' and active is None: status='unknown'
|
||||
entries.append({'key':f'{kind}:{obj.id}','stage':kind,'object_id':obj.id,'title':title,'job_id':batch,
|
||||
'url':url,'finished_at':getattr(obj,'finished_at',None),'origin':source,'state':status,'phase':actual.get('phase') if actual else '已完成' if status=='done' else '处理失败' if status=='failed' else '等待条件或聚合时间' if status=='waiting' else 'Worker心跳过期,执行状态未知' if status=='unknown' else 'AI已暂停' if settings and not settings.enabled else '等待调度',
|
||||
'started_at':actual.get('started_at') if actual else getattr(obj,'started_at',None),
|
||||
'updated_at':actual.get('updated_at') if actual else None,'round':actual.get('round') if actual else None,
|
||||
'tool':actual.get('tool') if actual else None,'model':(actual or {}).get('model') or config.get('model') or getattr(obj,'model',None),
|
||||
'reasoning_effort':actual.get('reasoning_effort',config.get('reasoning_effort')) if actual else config.get('reasoning_effort'),'error':getattr(obj,'error',None)})
|
||||
order = {'grouping':0,'analysis':1,'events':2,'outlook':3,'backfill':4}
|
||||
entries.sort(key=lambda r:(order[r['stage']],-r['object_id']))
|
||||
return {'items':entries,'total':total,'as_of':now,'worker_alive':active is not None,'concurrency':runtime,'enabled':bool(settings and settings.enabled),'limit':settings.max_concurrency if settings else 5}
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Explicit bounded historical jobs, sharing the normal news pipeline."""
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy import select, text, update
|
||||
from alphapulse.core.db import SessionLocal
|
||||
from alphapulse.models import NewsItem
|
||||
from alphapulse.models.risk import RiskSettings, ReprocessJob, ArticleAnalysis, NewsSummary, EventMember
|
||||
from alphapulse.models.risk import RiskSettings, ReprocessJob, ReprocessItem, ArticleAnalysis, NewsSummary, EventMember
|
||||
from alphapulse.news_risk.pipeline import configuration
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ async def capture(session, settings):
|
||||
from alphapulse.api.risk import config_out
|
||||
value = config_out(settings)
|
||||
value.pop('start_at')
|
||||
value.pop('enabled', None)
|
||||
value.pop('max_concurrency', None)
|
||||
configurations = {}
|
||||
for stage in ('grouping', 'analysis', 'outlook'):
|
||||
@@ -80,6 +81,11 @@ async def process_job(item_index=None):
|
||||
if item.get('state') not in (None, 'pending'): return
|
||||
job.items = [{**entry, 'state': 'processing'} if n == index else entry for n, entry in enumerate(job.items)]
|
||||
job_id = job.id
|
||||
durable = await session.scalar(select(ReprocessItem).where(ReprocessItem.job_id==job.id,ReprocessItem.news_id==item['id']))
|
||||
if durable:
|
||||
durable.state='processing'
|
||||
from alphapulse.news_risk.progress import step
|
||||
step('历史新闻分包',stage='backfill',object_id=durable.id)
|
||||
try:
|
||||
for stage in ('grouping', 'analysis', 'outlook'):
|
||||
await frozen_configuration(session, job.settings, stage)
|
||||
@@ -98,6 +104,7 @@ async def process_job(item_index=None):
|
||||
if outcome == 'deferred':
|
||||
job = await session.get(ReprocessJob, job_id, populate_existing=True, with_for_update=True)
|
||||
job.items = [{**entry, 'state': 'pending'} if n == index else entry for n, entry in enumerate(job.items)]
|
||||
await session.execute(update(ReprocessItem).where(ReprocessItem.job_id==job_id,ReprocessItem.news_id==item['id']).values(state='pending'))
|
||||
await session.commit(); return
|
||||
row = await session.get(ArticleAnalysis, row_id, populate_existing=True)
|
||||
if outcome != 'done' or row.state != 'done' or row.error: raise ValueError(row.error or '原文已变化')
|
||||
@@ -111,6 +118,7 @@ async def process_job(item_index=None):
|
||||
job = await session.get(ReprocessJob, job_id, populate_existing=True, with_for_update=True)
|
||||
job.items = [{**entry, 'state': terminal} if n == index else entry for n, entry in enumerate(job.items)]
|
||||
job.position = sum(entry.get('state') in ('done', 'failed') for entry in job.items)
|
||||
await session.execute(update(ReprocessItem).where(ReprocessItem.job_id==job_id,ReprocessItem.news_id==item['id']).values(state=terminal))
|
||||
await session.commit()
|
||||
|
||||
|
||||
|
||||
@@ -124,6 +124,8 @@ class Research:
|
||||
|
||||
async def tool(self, name, arguments):
|
||||
if name not in TOOLS: raise ValueError('未知研究工具')
|
||||
from alphapulse.news_risk.progress import step
|
||||
step('查询资料', tool=name)
|
||||
args = TOOLS[name][0].model_validate(arguments)
|
||||
key = name+json.dumps(arguments, sort_keys=True)
|
||||
if key in self.cache: return self.cache[key]
|
||||
@@ -196,7 +198,9 @@ class Research:
|
||||
submit = llm.ToolSpec('submit', '提交完整研判,引用实际读取来源', schema.model_json_schema())
|
||||
messages = [{'role': 'user', 'text': json.dumps(payload, ensure_ascii=False)}]
|
||||
usages = []
|
||||
from alphapulse.news_risk.progress import step, current
|
||||
for round_no in range(9):
|
||||
if current.get() is not None: step('模型研判', round=round_no+1, model=provider.model, reasoning_effort=provider.reasoning_effort)
|
||||
if not self.fits_messages(provider, system, messages, schema):
|
||||
raise ValueError('研究上下文不足,请增大上下文配置;证据未截断')
|
||||
completion = None
|
||||
|
||||
@@ -7,7 +7,7 @@ from datetime import UTC, datetime
|
||||
from sqlalchemy import select, text, func
|
||||
from alphapulse.core.db import SessionLocal
|
||||
from alphapulse.core.redis import redis
|
||||
from alphapulse.models.risk import RiskSettings, NewsSummary, NewsEvent, ArticleAnalysis, ReprocessJob
|
||||
from alphapulse.models.risk import RiskSettings, NewsSummary, NewsEvent, ArticleAnalysis, ReprocessJob, ReprocessItem
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
GATE = 20261102
|
||||
@@ -18,7 +18,8 @@ class Pool:
|
||||
self.tasks = {}
|
||||
|
||||
def start(self, key, action):
|
||||
self.tasks[asyncio.create_task(action())] = key
|
||||
from alphapulse.news_risk.progress import execute
|
||||
self.tasks[asyncio.create_task(execute(key, action))] = key
|
||||
|
||||
async def reap(self, timeout=1):
|
||||
if not self.tasks:
|
||||
@@ -50,6 +51,9 @@ async def candidate(active, cursor, allow_outlook=True):
|
||||
await worker.enqueue(session, settings)
|
||||
history_news = []
|
||||
for kind, identity in active:
|
||||
if kind == 'backfill':
|
||||
item = await session.get(ReprocessItem, identity)
|
||||
history_news.append(item.news_id)
|
||||
if kind == 'history' and identity[1] >= 0:
|
||||
job = await session.get(ReprocessJob, identity[0])
|
||||
history_news.append(job.items[identity[1]]['id'])
|
||||
@@ -61,7 +65,12 @@ async def candidate(active, cursor, allow_outlook=True):
|
||||
busy = [key[1] for key in active if key[0] == stage]
|
||||
if stage == 'outlook':
|
||||
if not busy and allow_outlook:
|
||||
return (stage, 0), worker.outlook, (cursor + offset + 1) % 4
|
||||
try:
|
||||
ready = await worker.outlook(prepare_only=True)
|
||||
except ValueError:
|
||||
logger.exception('阅读编排配置检查失败')
|
||||
ready = False
|
||||
if ready: return (stage, 0), worker.outlook, (cursor + offset + 1) % 4
|
||||
continue
|
||||
model = {'grouping': ArticleAnalysis, 'analysis': NewsSummary, 'events': NewsEvent}[stage]
|
||||
query = select(model.id).where(model.state == 'pending', model.id.not_in(busy))
|
||||
@@ -81,17 +90,29 @@ async def candidate(active, cursor, allow_outlook=True):
|
||||
action = {'grouping': worker.analyze, 'analysis': worker.summarize, 'events': events.organize}[stage]
|
||||
return (stage, identifier), lambda action=action, identifier=identifier: action(identifier), (cursor + offset + 1) % 4
|
||||
job = await session.scalar(select(ReprocessJob).where(ReprocessJob.state.in_(['pending', 'running'])).order_by(ReprocessJob.id).limit(1))
|
||||
if job and job.settings.get('request'):
|
||||
from alphapulse.news_risk.backfill import process
|
||||
busy = [k[1] for k in active if k[0]=='backfill']
|
||||
candidates = (await session.scalars(select(ReprocessItem).where(ReprocessItem.job_id==job.id,
|
||||
ReprocessItem.state=='pending',ReprocessItem.id.not_in(busy),ReprocessItem.news_id.not_in(history_news))
|
||||
.order_by(ReprocessItem.id).limit(len(active)+1))).all()
|
||||
for item in candidates:
|
||||
article_id = await session.scalar(select(ArticleAnalysis.id).where(ArticleAnalysis.news_id==item.news_id,
|
||||
ArticleAnalysis.content_hash==item.content_hash))
|
||||
if ('grouping',article_id) not in active:
|
||||
return ('backfill',item.id), lambda identifier=item.id: process(identifier), cursor
|
||||
return None
|
||||
if job:
|
||||
for index, item in enumerate(job.items):
|
||||
key = ('history', (job.id, index))
|
||||
if item.get('state', 'pending') == 'pending' and key not in active:
|
||||
if item.get('state', 'pending') == 'pending' and key not in active and item['id'] not in history_news:
|
||||
# Real-time and historical grouping share the article identity.
|
||||
row_id = await session.scalar(select(ArticleAnalysis.id).where(ArticleAnalysis.news_id == item['id'], ArticleAnalysis.content_hash == item['hash']))
|
||||
if row_id is not None and ('grouping', row_id) in active:
|
||||
continue
|
||||
return key, lambda index=index: reprocess.process_job(index), cursor
|
||||
if not any(k[0] == 'history' for k in active):
|
||||
return ('history', (job.id, -1)), lambda: reprocess.process_job(-1), cursor
|
||||
await reprocess.process_job(-1)
|
||||
return None
|
||||
|
||||
|
||||
@@ -103,6 +124,11 @@ async def run(lock):
|
||||
# Interrupted historical items are reclaimed independently of completion order.
|
||||
async with SessionLocal() as session:
|
||||
jobs = (await session.scalars(select(ReprocessJob).where(ReprocessJob.state.in_(['pending', 'running'])))).all()
|
||||
from sqlalchemy import update
|
||||
interrupted = (await session.scalars(select(ReprocessItem).where(ReprocessItem.state=='processing'))).all()
|
||||
for item in interrupted:
|
||||
await session.execute(update(ArticleAnalysis).where(ArticleAnalysis.news_id==item.news_id, ArticleAnalysis.content_hash==item.content_hash, ArticleAnalysis.state=='pending').values(state='reprocess'))
|
||||
await session.execute(update(ReprocessItem).where(ReprocessItem.state=='processing').values(state='pending'))
|
||||
for job in jobs:
|
||||
for item in job.items:
|
||||
if item['state'] == 'processing':
|
||||
@@ -136,9 +162,16 @@ async def run(lock):
|
||||
async with SessionLocal() as session:
|
||||
settings = await session.get(RiskSettings, 1)
|
||||
limit, enabled = settings.max_concurrency, settings.enabled
|
||||
async with SessionLocal() as session:
|
||||
from alphapulse.news_risk.backfill import reconcile
|
||||
jobs = (await session.scalars(select(ReprocessJob).where(ReprocessJob.state.in_(['pending','running'])))).all()
|
||||
for job in jobs:
|
||||
if job.settings.get('request'): await reconcile(session,job)
|
||||
now = datetime.now(UTC).isoformat()
|
||||
counts = {stage: sum(k[0] == stage for k in pool.tasks.values()) for stage in ('grouping', 'analysis', 'events', 'outlook', 'history')}
|
||||
counts = {stage: sum(k[0] == stage for k in pool.tasks.values()) for stage in ('grouping', 'analysis', 'events', 'outlook', 'history', 'backfill')}
|
||||
try:
|
||||
from alphapulse.news_risk.progress import active
|
||||
await redis.set('ap:risk:active', json.dumps(list(active.values())), ex=15)
|
||||
await redis.set('ap:risk:concurrency', json.dumps({'running': len(pool.tasks), 'stages': counts, 'updated_at': now}), ex=15)
|
||||
for stage in ('', 'analysis:', 'events:', 'outlook:', 'reprocess:'):
|
||||
await redis.set('ap:risk:' + stage + 'heartbeat', now, ex=15)
|
||||
|
||||
@@ -76,6 +76,8 @@ class OutputTruncated(ValueError):
|
||||
|
||||
|
||||
async def complete(provider: AiProvider, system: str, payload: dict, schema: type[BaseModel]):
|
||||
from alphapulse.news_risk.progress import step, current
|
||||
if current.get() is not None: step('等待模型响应', model=provider.model, reasoning_effort=provider.reasoning_effort)
|
||||
text = json.dumps(payload, ensure_ascii=False)
|
||||
if not fits_context(provider, system, payload, schema):
|
||||
raise ValueError('新闻输入超出模型上下文;未截断或发送,请调整模型配置')
|
||||
|
||||
@@ -11,6 +11,7 @@ from alphapulse.news_risk.service import complete
|
||||
from alphapulse.news_risk.pipeline import configuration, Grouping, summarize_articles, validate_evidence, Evidence, grouping_payload, pack_candidates
|
||||
from alphapulse.news_risk.research import Research
|
||||
from alphapulse.news_risk import briefing
|
||||
from alphapulse.news_risk.progress import step
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
publication_lock = asyncio.Lock()
|
||||
@@ -73,6 +74,7 @@ async def _analyze(target_id=None, frozen=None):
|
||||
row_id = row.id
|
||||
row_input = row.input
|
||||
try:
|
||||
step('提取与归并新闻')
|
||||
payload, usages = await grouping_payload(provider, system, {'id': row.id, **row.input}, candidate_inputs)
|
||||
for attempt in range(2):
|
||||
try:
|
||||
@@ -87,6 +89,7 @@ async def _analyze(target_id=None, frozen=None):
|
||||
if attempt:
|
||||
raise
|
||||
payload = pack_candidates(provider, system, {**payload, 'correction': str(error)}, payload['candidates'])
|
||||
step('校验并保存分包结果')
|
||||
async with publication_lock:
|
||||
# Check the article again before changing event membership.
|
||||
current = await session.get(NewsItem, row.news_id, populate_existing=True)
|
||||
@@ -133,6 +136,9 @@ async def _analyze(target_id=None, frozen=None):
|
||||
if event.id in changed:
|
||||
event.review_config = frozen
|
||||
schedule(event, now, any(m.urgent for m in result.packages))
|
||||
if frozen and frozen.get('request'):
|
||||
from alphapulse.news_risk.backfill import target
|
||||
await target(session, frozen['job_id'], 'analysis', event)
|
||||
session.add(GroupingVersion(analysis_id=row.id, input=row.input, result=result.model_dump(mode='json'), rules=rules, state='done'))
|
||||
row.result = result.model_dump(mode='json'); row.usage = {'attempts': usages}; row.state = 'done'
|
||||
row.error = None; row.finished_at = now
|
||||
@@ -201,6 +207,7 @@ async def summarize(target_id=None):
|
||||
try:
|
||||
if config_error: raise ValueError(config_error)
|
||||
result, usage = await summarize_articles(provider, system, articles, package, research)
|
||||
step('校验并保存包研判')
|
||||
async with publication_lock:
|
||||
event = await session.get(NewsSummary, event_id, populate_existing=True, with_for_update=True)
|
||||
if event.replacement_ids or event.state == 'withdrawn':
|
||||
@@ -240,7 +247,7 @@ async def summarize(target_id=None):
|
||||
await session.commit(); logger.exception('事件总结失败')
|
||||
|
||||
|
||||
async def outlook():
|
||||
async def outlook(prepare_only=False):
|
||||
async with SessionLocal() as session:
|
||||
settings = await session.get(RiskSettings, 1)
|
||||
if not settings or not settings.enabled: return
|
||||
@@ -252,6 +259,7 @@ async def outlook():
|
||||
inputs = await reading_inputs(session, now-timedelta(hours=72))
|
||||
from alphapulse.models.risk import ReprocessJob
|
||||
job = await session.scalar(select(ReprocessJob).where(ReprocessJob.state == 'running').limit(1))
|
||||
if job and job.settings.get('request'): job = None
|
||||
if job and job.position < len(job.items):
|
||||
if requested and requested.quotes.get('job_id') == job.id: return
|
||||
job = None
|
||||
@@ -266,7 +274,17 @@ async def outlook():
|
||||
semantic = briefing.semantic_packages(inputs)
|
||||
key = briefing.fingerprint(semantic, rules)
|
||||
important = {p.get('kind', 'package')+':'+str(p['id']): briefing.fingerprint(p, {}) for p in semantic if p['importance_stars'] == 5}
|
||||
if requested is None and latest and latest.quotes.get('fingerprint') == key and (job is None or latest.quotes.get('job_id') == job.id): return
|
||||
unchanged = requested is None and latest and latest.quotes.get('fingerprint') == key and (job is None or latest.quotes.get('job_id') == job.id)
|
||||
if unchanged and (inputs or latest.state == 'done'): return
|
||||
if not inputs:
|
||||
# Empty reading windows are durable housekeeping, without an AI slot.
|
||||
session.add(RiskSnapshot(state='done', model=provider.model,
|
||||
quotes={'fingerprint':key,'important':{},**({'job_id':job.id} if job else {})},
|
||||
result={'reading':[],'synthesis':None,'usage':[],'expansion':[],'expansion_usage':[],
|
||||
'inputs':[],'rules':rules,'as_of':now.isoformat()}))
|
||||
if requested: requested.quotes = {k:v for k,v in requested.quotes.items() if k != 'retry_requested'}
|
||||
await session.commit()
|
||||
return
|
||||
if latest and requested is None and job is None:
|
||||
urgent_change = any(latest.quotes.get('important', {}).get(identifier) != value for identifier, value in important.items())
|
||||
if urgent_change:
|
||||
@@ -277,8 +295,11 @@ async def outlook():
|
||||
latest.quotes = {**latest.quotes, 'dirty_since': now.isoformat()}
|
||||
await session.commit(); return
|
||||
if (now-datetime.fromisoformat(dirty)).total_seconds() < 900: return
|
||||
if prepare_only: return True
|
||||
step('整理72小时阅读与趋势')
|
||||
snapshot = RiskSnapshot(state='processing', model=provider.model, quotes={'fingerprint': key, 'important': important, **({'job_id': job.id} if job else {})})
|
||||
session.add(snapshot); await session.commit()
|
||||
step('生成阅读与趋势', object_id=snapshot.id)
|
||||
try:
|
||||
main = [p for p in inputs if p['importance_stars'] >= 3]
|
||||
low = [p for p in inputs if p['importance_stars'] < 3]
|
||||
@@ -294,6 +315,7 @@ async def outlook():
|
||||
if len(extra) == 3: break
|
||||
snapshot.result = {**result, 'expansion': extra, 'inputs': inputs, 'rules': rules,
|
||||
'expansion_usage': expansion.get('usage', []), 'as_of': now.isoformat()}
|
||||
step('保存阅读与趋势')
|
||||
snapshot.state = 'done'
|
||||
except Exception as error:
|
||||
snapshot.state = 'failed'; snapshot.error = str(error)[:2000]
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -122,3 +122,45 @@ individual states, so completion order does not change progress or skip unfinish
|
||||
|
||||
Migration `e5f701234567` adds the limit and converts legacy historical checkpoints to
|
||||
item states. Deployment and execution of this migration require separate authorization.
|
||||
|
||||
### Historical news AI backfill and queue
|
||||
|
||||
The home page's **AI 历史回补** selects publication times in `[start, end)` using
|
||||
stored current news revisions. `missing` completes unfinished grouping/package analysis
|
||||
and reuses published successes; `review` explicitly repeats grouping/TAG and package
|
||||
verification, preserving previous successful versions. Selecting both includes all
|
||||
news in the range. Related event timelines are refreshed; a batch waits only for its
|
||||
own package/event targets, while the home reading window stays at 72 hours.
|
||||
Corrections register every resulting package in the same transaction, including new
|
||||
branches when a split retains the original ID. An empty reading window saves a cleared
|
||||
snapshot without a model call or concurrency slot.
|
||||
|
||||
`POST /api/risk/reprocess/preview` accepts `start`, `end`, and `modes`. Its opaque
|
||||
`preview_id` expires after 15 minutes and fixes news IDs, content hashes, models,
|
||||
reasoning efforts, and published Skills. `POST /api/risk/reprocess` accepts that ID;
|
||||
changed configuration requires a new preview, and changed source content fails its
|
||||
individual item explicitly. A missing item completed before execution is skipped.
|
||||
Only one batch runs at a time. Batches use spare slots in the shared concurrency
|
||||
pool, respect global AI pause, support cancellation of further news admissions and
|
||||
explicit retry of failed items, and reclaim interrupted news items after restart.
|
||||
Already scheduled downstream package/event updates continue after cancellation.
|
||||
An in-flight news item also keeps its identity reserved after cancellation, so a new
|
||||
batch waits for that item to finish while unrelated news can still use spare slots.
|
||||
|
||||
`GET /api/risk/reprocess/{id}` returns paginated per-news states and downstream
|
||||
stage counts. `GET /api/risk/queue` filters by `state`, `stage`, `origin`, `job_id`
|
||||
and page. The home queue refreshes every three seconds while visible. Actual model
|
||||
waits, research rounds, tool calls and save steps are transient telemetry under
|
||||
`ap:risk:active`; PostgreSQL remains the durable queue. Heartbeat expiry means unknown
|
||||
execution state. Empty outlook polls and batch completion checks do not consume AI
|
||||
slots or appear as running model tasks.
|
||||
Batch filters include durable package/event targets even after real-time updates
|
||||
change their execution configuration; displayed model/source metadata describes the
|
||||
current execution. Open news details refresh with the batch list, retaining their page
|
||||
and rejecting responses from an older selection or session.
|
||||
|
||||
Migration `f60123456789` adds normalized batch news items and package/event targets,
|
||||
retaining legacy job JSON and execution semantics. Isolated acceptance includes
|
||||
`scripts/smoke_backfill.py`; it mocks all model/Redis I/O and checks mode behavior,
|
||||
frozen previews, source changes, target completion and queue queries. Live migration,
|
||||
deployment and real backfill execution require separate authorization.
|
||||
@@ -183,6 +183,8 @@ def smoke(build, label):
|
||||
input=(ROOT / 'scripts/smoke_risk.py').read_text()), flush=True)
|
||||
print(run('docker', 'exec', '-i', names['api'], 'python', '-',
|
||||
input=(ROOT / 'scripts/smoke_events.py').read_text()), flush=True)
|
||||
print(run('docker', 'exec', '-i', names['api'], 'python', '-',
|
||||
input=(ROOT / 'scripts/smoke_backfill.py').read_text()), flush=True)
|
||||
print(run('docker', 'exec', '-i', names['api'], 'python', '-',
|
||||
input=(ROOT / 'scripts/smoke_calendar.py').read_text()), flush=True)
|
||||
output = run('docker', 'exec', '-i', '-e', 'SMOKE_WEB=' + names['web'], names['api'], 'python', '-',
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
"""Validate backfill modes and queue queries only in a disposable CI database."""
|
||||
import asyncio
|
||||
import os
|
||||
import json
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from types import SimpleNamespace
|
||||
from urllib.parse import urlparse
|
||||
from sqlalchemy import select, delete, update, func
|
||||
from alphapulse.core.db import SessionLocal, engine
|
||||
from alphapulse.models import AiProvider, NewsItem, SkillVersion, SkillNode
|
||||
from alphapulse.models.risk import (RiskSettings, ArticleAnalysis, SummaryArticle, NewsSummary,
|
||||
GroupingVersion, ReprocessJob, ReprocessItem, ReprocessTarget, NewsEvent, EventMember, PackageCorrection, RiskSnapshot)
|
||||
from alphapulse.ai.skills import create_node, add_version
|
||||
from alphapulse.news_risk import backfill, pipeline, worker, queue, scheduler, events, briefing
|
||||
|
||||
|
||||
async def main():
|
||||
url = urlparse(os.environ['DATABASE_URL'])
|
||||
assert url.username == 'ci' and url.password == 'ci-only' and url.path == '/ci'
|
||||
now = datetime.now(UTC) - timedelta(hours=3)
|
||||
storage = {}
|
||||
async def save(key, value, **_): storage[key] = value
|
||||
async def remove(key): storage.pop(key, None)
|
||||
fake_redis = type('Redis', (), {'set': AsyncMock(side_effect=save), 'get': AsyncMock(side_effect=lambda k: storage.get(k)), 'delete': AsyncMock(side_effect=remove)})()
|
||||
async with SessionLocal() as session:
|
||||
assert not await session.scalar(select(func.count()).select_from(RiskSettings))
|
||||
provider = AiProvider(name='backfill-ci', protocol='openai', base_url='https://example.invalid',
|
||||
api_key_encrypted=b'fake', api_key_hint='fake', model='shared', max_tokens=8192, context_window=200000)
|
||||
session.add(provider); await session.flush()
|
||||
skills = {}
|
||||
for stage, (name, content) in pipeline.TEMPLATES.items():
|
||||
node = await create_node(session, None, 'skill', name, scope='news')
|
||||
version = await add_version(session, node, content, 'user', 'CI')
|
||||
node.published_version_id = version.id; skills[stage+'_skill_id'] = node.id
|
||||
session.add(RiskSettings(id=1, enabled=True, start_at=now+timedelta(days=1), provider_id=provider.id,
|
||||
second_provider_id=provider.id, third_provider_id=provider.id, model='luna', second_model='reviewer',
|
||||
third_model='senior', reasoning_effort='low', second_reasoning_effort='high', **skills))
|
||||
news_ids, package_ids = [], []
|
||||
for i in range(3):
|
||||
news = NewsItem(source_code='wscn_news', external_id='ci-backfill-'+str(i),
|
||||
url='https://example.invalid/'+str(i), title='Evidence '+str(i), summary='',
|
||||
content_text='Evidence '+str(i), content_hash='ci-backfill-'+str(i), published_at=now+timedelta(minutes=i))
|
||||
session.add(news); await session.flush(); news_ids.append(news.id)
|
||||
if i == 2: continue
|
||||
article = ArticleAnalysis(news_id=news.id, content_hash=news.content_hash, state='done', result={'old':True},
|
||||
input={'title':news.title,'summary':'','content':news.content_text,'url':news.url,'source':'wscn_news',
|
||||
'revision_no':1,'published_at':news.published_at.isoformat(),'progress_at':news.published_at.isoformat()})
|
||||
session.add(article); await session.flush()
|
||||
package = NewsSummary(title=news.title, fact=news.title, category='science', entities=[], tags=[],
|
||||
published_at=now, updated_at=now, changed_at=now, due_at=now, revision=1, published_revision=1 if i==0 else 0,
|
||||
state='done' if i==0 else 'failed', result={'summary':'Original'} if i==0 else None)
|
||||
session.add(package); await session.flush(); package_ids.append(package.id)
|
||||
session.add(SummaryArticle(summary_id=package.id, analysis_id=article.id, active=True))
|
||||
await session.commit()
|
||||
grouping_calls = []
|
||||
async def group(provider, system, payload, schema):
|
||||
grouping_calls.append(provider.model)
|
||||
article = payload['article']
|
||||
return pipeline.Grouping(reason='Same concrete fact', packages=[dict(package_id=package_ids[1], group_key=None,
|
||||
title='Evidence', fact='Evidence', evidence=article['title'], category='science', entities=[], tags=['science'],
|
||||
urgent=False, change_kind='repost', content_kind='report')]), {}
|
||||
async def prepare(start, end, modes):
|
||||
async with SessionLocal() as session: return await backfill.preview(session, start, end, modes)
|
||||
async def begin(value):
|
||||
async with SessionLocal() as session: return (await backfill.start(session, value['preview_id']))['id']
|
||||
async def process_all(job_id):
|
||||
async with SessionLocal() as session:
|
||||
ids = (await session.scalars(select(ReprocessItem.id).where(ReprocessItem.job_id==job_id))).all()
|
||||
for identifier in ids: await backfill.process(identifier)
|
||||
with patch.object(backfill, 'redis', fake_redis), patch.object(queue, 'redis', fake_redis), patch.object(worker, 'complete', group):
|
||||
window = (now-timedelta(seconds=1), now+timedelta(minutes=4))
|
||||
preview = await prepare(*window, ['missing'])
|
||||
assert (preview['total'],preview['missing'],preview['review'],preview['articles']) == (3,2,1,2), preview
|
||||
job_id = await begin(preview)
|
||||
await process_all(job_id)
|
||||
assert grouping_calls == ['luna'], grouping_calls
|
||||
async with SessionLocal() as session:
|
||||
items = (await session.scalars(select(ReprocessItem).where(ReprocessItem.job_id==job_id))).all()
|
||||
assert all(i.state=='done' for i in items), [(i.state,i.error) for i in items]
|
||||
targets = (await session.scalars(select(ReprocessTarget).where(ReprocessTarget.job_id==job_id))).all()
|
||||
assert len(targets)==1 and targets[0].object_id==package_ids[1]
|
||||
package = await session.get(NewsSummary, package_ids[1]); revision = package.revision
|
||||
package.state='done'; package.result={'summary':'Verified'}; package.published_revision=revision
|
||||
# An unrelated pending package must not hold this batch open.
|
||||
other = NewsSummary(title='Unrelated',fact='Other',category='science',entities=[],published_at=now,
|
||||
updated_at=now,changed_at=now,due_at=now,state='pending')
|
||||
session.add(other); await session.commit()
|
||||
job = await session.get(ReprocessJob,job_id); await backfill.reconcile(session,job)
|
||||
assert job.state=='done', (job.state,job.failures)
|
||||
assert (await queue.job_out(session,job))['completed']==2
|
||||
for state in ('running','queued','waiting','failed','done',None):
|
||||
result = await queue.listing(session,state,None,None,None,1,2)
|
||||
assert result['total']>=len(result['items'])
|
||||
result = await queue.listing(session,'done','backfill','history',job_id,1,1)
|
||||
next_page = await queue.listing(session,'done','backfill','history',job_id,2,1)
|
||||
assert result['total']==2 and result['items'][0]['key']!=next_page['items'][0]['key']
|
||||
# Recheck must rerun grouping, preserve grouping history and refresh old/new targets.
|
||||
preview = await prepare(now-timedelta(seconds=1),now+timedelta(seconds=1),['review'])
|
||||
review_id = await begin(preview)
|
||||
await process_all(review_id)
|
||||
assert grouping_calls==['luna','luna']
|
||||
async with SessionLocal() as session:
|
||||
item = await session.scalar(select(ReprocessItem).where(ReprocessItem.job_id==review_id))
|
||||
assert item.state=='done', item.error
|
||||
assert await session.scalar(select(func.count()).select_from(GroupingVersion))==2
|
||||
targets = (await session.scalars(select(ReprocessTarget).where(ReprocessTarget.job_id==review_id))).all()
|
||||
assert {t.object_id for t in targets}==set(package_ids)
|
||||
for target in targets:
|
||||
package=await session.get(NewsSummary,target.object_id)
|
||||
package.state='done'; package.result={'summary':'Verified'}; package.published_revision=package.revision
|
||||
event=NewsEvent(title='Related event',updated_at=now,due_at=now,state='pending',revision=1)
|
||||
session.add(event); await session.flush()
|
||||
session.add(EventMember(package_id=package_ids[1],event_id=event.id,reason='Synthetic'))
|
||||
await session.commit()
|
||||
job=await session.get(ReprocessJob,review_id); await backfill.reconcile(session,job)
|
||||
assert job.state=='running'
|
||||
event.state='done'; event.published_revision=1; await session.commit()
|
||||
await backfill.reconcile(session,job); assert job.state=='done'
|
||||
# The fixed preview refuses changed settings, and a changed source fails visibly.
|
||||
preview = await prepare(now+timedelta(minutes=2),now+timedelta(minutes=3),['review'])
|
||||
async with SessionLocal() as session:
|
||||
settings=await session.get(RiskSettings,1); settings.model='changed'; await session.commit()
|
||||
try: await backfill.start(session,preview['preview_id'])
|
||||
except ValueError as error: assert '配置已变化' in str(error)
|
||||
else: raise AssertionError('Changed configuration accepted')
|
||||
settings.model='luna'; await session.commit()
|
||||
changed_id = await begin(preview)
|
||||
async with SessionLocal() as session:
|
||||
news=await session.get(NewsItem,news_ids[2]); news.content_hash='changed'; await session.commit()
|
||||
await process_all(changed_id)
|
||||
async with SessionLocal() as session:
|
||||
job=await session.get(ReprocessJob,changed_id); await backfill.reconcile(session,job)
|
||||
item=await session.scalar(select(ReprocessItem).where(ReprocessItem.job_id==changed_id))
|
||||
assert job.state=='failed' and '版本变化' in item.error
|
||||
assert grouping_calls==['luna','luna']
|
||||
# Missing items completed after preview are skipped, and pause/cancel stop admissions.
|
||||
async with SessionLocal() as session:
|
||||
package=await session.get(NewsSummary,package_ids[1]); package.published_revision=0; package.state='pending'
|
||||
await session.commit()
|
||||
preview=await prepare(now-timedelta(seconds=1),now+timedelta(seconds=1),['missing'])
|
||||
skip_id=await begin(preview)
|
||||
async with SessionLocal() as session:
|
||||
settings=await session.get(RiskSettings,1); settings.enabled=False
|
||||
package=await session.get(NewsSummary,package_ids[1]); package.published_revision=package.revision; package.state='done'
|
||||
await session.commit()
|
||||
await process_all(skip_id)
|
||||
async with SessionLocal() as session:
|
||||
item=await session.scalar(select(ReprocessItem).where(ReprocessItem.job_id==skip_id)); assert item.state=='pending'
|
||||
settings=await session.get(RiskSettings,1); settings.enabled=True; await session.commit()
|
||||
await process_all(skip_id)
|
||||
async with SessionLocal() as session:
|
||||
item=await session.scalar(select(ReprocessItem).where(ReprocessItem.job_id==skip_id)); assert item.state=='skipped'
|
||||
job=await session.get(ReprocessJob,skip_id); await backfill.reconcile(session,job); assert job.state=='done'
|
||||
# A failed member must fail its batch without waiting forever for the blocked event.
|
||||
job=await session.get(ReprocessJob,review_id); job.state='running'
|
||||
package=await session.get(NewsSummary,package_ids[1]); package.state='failed'; package.error='Synthetic failure'
|
||||
event=await session.scalar(select(NewsEvent)); event.state='pending'; event.revision+=1
|
||||
await session.commit()
|
||||
await backfill.reconcile(session,job)
|
||||
assert job.state=='failed' and {f['stage'] for f in job.failures}=={'analysis','events'}, job.failures
|
||||
from alphapulse.api.risk import reprocess_retry
|
||||
await reprocess_retry(job.id,session)
|
||||
assert (await session.scalar(select(ReprocessItem).where(ReprocessItem.job_id==job.id))).state=='done'
|
||||
assert (await session.get(NewsSummary,package_ids[1])).state=='pending'
|
||||
job.state='cancelled'; await session.commit()
|
||||
preview=await prepare(now-timedelta(seconds=1),now+timedelta(seconds=1),['review'])
|
||||
cancel_id=await begin(preview)
|
||||
async with SessionLocal() as session:
|
||||
item=await session.scalar(select(ReprocessItem).where(ReprocessItem.job_id==cancel_id)); item.state='processing'
|
||||
article=await session.scalar(select(ArticleAnalysis).where(ArticleAnalysis.news_id==item.news_id)); article.state='pending'
|
||||
await session.commit()
|
||||
lock=SimpleNamespace(scalar=AsyncMock(return_value=True),execute=AsyncMock(),commit=AsyncMock())
|
||||
with patch.object(scheduler,'candidate',AsyncMock(side_effect=asyncio.CancelledError)):
|
||||
try: await scheduler.run(lock)
|
||||
except asyncio.CancelledError: pass
|
||||
else: raise AssertionError('Restart test did not reach admission')
|
||||
async with SessionLocal() as session:
|
||||
item=await session.scalar(select(ReprocessItem).where(ReprocessItem.job_id==cancel_id)); assert item.state=='pending'
|
||||
article=await session.scalar(select(ArticleAnalysis).where(ArticleAnalysis.news_id==item.news_id)); assert article.state=='reprocess'
|
||||
job=await session.get(ReprocessJob,cancel_id); job.state='cancelled'; await session.commit()
|
||||
await process_all(cancel_id)
|
||||
async with SessionLocal() as session:
|
||||
job=await session.get(ReprocessJob,cancel_id); await backfill.reconcile(session,job)
|
||||
assert job.state=='cancelled'
|
||||
assert grouping_calls==['luna','luna']
|
||||
# Cancelled calls drain before another batch can review the same news.
|
||||
from alphapulse.api.risk import reprocess_cancel
|
||||
async with SessionLocal() as session:
|
||||
config=json.loads(json.dumps((await session.get(ReprocessJob,cancel_id)).settings))
|
||||
config['configurations']['grouping']['provider']['model']='luna-old'
|
||||
old=ReprocessJob(state='pending',items=[],position=0,failures=[],settings=config)
|
||||
session.add(old); await session.flush(); old.settings={**old.settings,'job_id':old.id}
|
||||
news=await session.get(NewsItem,news_ids[0])
|
||||
old_item=ReprocessItem(job_id=old.id,news_id=news.id,content_hash=news.content_hash,mode='review',state='pending')
|
||||
session.add(old_item); await session.commit()
|
||||
old_id,old_item_id=old.id,old_item.id
|
||||
entered=asyncio.Event(); release=asyncio.Event()
|
||||
async def controlled(provider,system,payload,schema):
|
||||
if provider.model=='luna-old':
|
||||
entered.set(); await release.wait()
|
||||
result,usage=await group(provider,system,payload,schema)
|
||||
result.reason=provider.model
|
||||
return result,usage
|
||||
with patch.object(worker,'complete',controlled),patch.object(worker,'outlook',AsyncMock(return_value=False)):
|
||||
old_task=asyncio.create_task(backfill.process(old_item_id))
|
||||
try:
|
||||
async with asyncio.timeout(5): await entered.wait()
|
||||
async with SessionLocal() as session:
|
||||
await reprocess_cancel(old_id,session)
|
||||
config=json.loads(json.dumps((await session.get(ReprocessJob,old_id)).settings))
|
||||
config['configurations']['grouping']['provider']['model']='luna-new'
|
||||
new=ReprocessJob(state='pending',items=[],position=0,failures=[],settings=config)
|
||||
session.add(new); await session.flush(); new.settings={**new.settings,'job_id':new.id}
|
||||
retried=ReprocessItem(job_id=new.id,news_id=news_ids[0],content_hash='ci-backfill-0',mode='review',state='pending')
|
||||
other_item=ReprocessItem(job_id=new.id,news_id=news_ids[1],content_hash='ci-backfill-1',mode='review',state='pending')
|
||||
session.add_all([retried,other_item]); await session.commit()
|
||||
new_id,retried_id,other_item_id=new.id,retried.id,other_item.id
|
||||
active={('analysis',i) for i in (await session.scalars(select(NewsSummary.id))).all()}
|
||||
active.update(('events',i) for i in (await session.scalars(select(NewsEvent.id))).all())
|
||||
picked=await scheduler.candidate(active|{('backfill',old_item_id)},0)
|
||||
assert picked and picked[0]==('backfill',other_item_id), picked
|
||||
assert 'luna-new' not in grouping_calls
|
||||
finally:
|
||||
release.set(); await old_task
|
||||
picked=await scheduler.candidate(active,0)
|
||||
assert picked and picked[0]==('backfill',retried_id), picked
|
||||
await picked[1]()
|
||||
async with SessionLocal() as session:
|
||||
article=await session.scalar(select(ArticleAnalysis).where(ArticleAnalysis.news_id==news_ids[0]))
|
||||
assert article.result['reason']=='luna-new' and article.model=='luna-new'
|
||||
new=await session.get(ReprocessJob,new_id); new.state='cancelled'; await session.commit()
|
||||
# A pending backfill must not hide real-time grouping that won admission.
|
||||
async with SessionLocal() as session:
|
||||
job=ReprocessJob(state='running',items=[],position=0,failures=[],settings=(await session.get(ReprocessJob,cancel_id)).settings)
|
||||
session.add(job); await session.flush()
|
||||
job.settings={**job.settings,'job_id':job.id}
|
||||
news=await session.get(NewsItem,news_ids[0])
|
||||
item=ReprocessItem(job_id=job.id,news_id=news.id,content_hash=news.content_hash,mode='review',state='pending')
|
||||
session.add(item)
|
||||
article=await session.scalar(select(ArticleAnalysis).where(ArticleAnalysis.news_id==news.id)); article.state='processing'
|
||||
await session.commit()
|
||||
task={'stage':'grouping','object_id':article.id,'phase':'等待模型响应',
|
||||
'started_at':now.isoformat(),'updated_at':now.isoformat()}
|
||||
storage['ap:risk:active']=json.dumps([task])
|
||||
result=await queue.listing(session,'running','grouping',None,None,1,20)
|
||||
assert [r['object_id'] for r in result['items']]==[article.id] and result['total']==1
|
||||
storage.pop('ap:risk:active')
|
||||
result=await queue.listing(session,'running','grouping',None,None,1,20)
|
||||
assert len(result['items'])==1 and result['items'][0]['state']=='unknown'
|
||||
item.state='processing'; await session.commit()
|
||||
storage['ap:risk:active']=json.dumps([{**task,'stage':'backfill','object_id':item.id}])
|
||||
assert (await queue.listing(session,'running','grouping',None,None,1,20))['total']==0
|
||||
assert (await queue.listing(session,'running','backfill',None,job.id,1,20))['total']==1
|
||||
storage.pop('ap:risk:active')
|
||||
# Batch membership follows durable targets; execution metadata remains real-time.
|
||||
shared=await session.get(NewsSummary,package_ids[1]); shared.review_config=None; shared.state='pending'; shared.due_at=now
|
||||
await backfill.target(session,job.id,'analysis',shared)
|
||||
await session.commit()
|
||||
result=await queue.listing(session,'queued','analysis',None,job.id,1,20)
|
||||
assert result['total']==1 and result['items'][0]['object_id']==shared.id
|
||||
assert result['items'][0]['origin']=='realtime' and result['items'][0]['model']=='reviewer'
|
||||
assert (await queue.listing(session,'queued','analysis','realtime',job.id,1,20))['total']==1
|
||||
assert (await queue.listing(session,'queued','analysis','history',job.id,1,20))['total']==0
|
||||
event=await session.scalar(select(NewsEvent)); event.review_config=None; event.state='pending'; event.due_at=now
|
||||
shared.state='done'; shared.published_revision=shared.revision
|
||||
await backfill.target(session,job.id,'events',event)
|
||||
await session.commit()
|
||||
result=await queue.listing(session,'queued','events',None,job.id,1,20)
|
||||
assert result['total']==1 and result['items'][0]['object_id']==event.id
|
||||
await session.execute(delete(ReprocessTarget).where(ReprocessTarget.job_id==job.id))
|
||||
await session.commit()
|
||||
# Retaining the original package ID during a split must track both branches.
|
||||
article.state='done'; item.state='done'
|
||||
package=NewsSummary(title='Two facts',fact='Two facts',category='science',entities=[],tags=['science'],
|
||||
published_at=now,updated_at=now,changed_at=now,due_at=now,state='done',revision=1,published_revision=1,
|
||||
result={'summary':'Original'},review_config=job.settings)
|
||||
session.add(package); await session.flush()
|
||||
session.add(SummaryArticle(summary_id=package.id,analysis_id=article.id,active=True))
|
||||
await session.flush()
|
||||
await backfill.target(session,job.id,'analysis',package)
|
||||
from alphapulse.news_risk.contracts import Correction
|
||||
from alphapulse.news_risk.corrections import apply_correction
|
||||
proposal=Correction(packages=[{'id':package.id,'revision':package.revision}],replacements=[
|
||||
{'keep_id':package.id,'title':'First fact','fact':'First fact','category':'science','tags':['first'],
|
||||
'content_kind':'report','assignments':[{'analysis_id':article.id,'evidence':article.input['content']}]},
|
||||
{'keep_id':None,'title':'Second fact','fact':'Second fact','category':'science','tags':['second'],
|
||||
'content_kind':'report','assignments':[{'analysis_id':article.id,'evidence':article.input['content']}]}],
|
||||
reason='Two distinct facts in one article')
|
||||
outputs=await apply_correction(session,proposal,package.id,set(),{'backfill-split':True})
|
||||
package.state='done'; package.published_revision=package.revision
|
||||
await session.commit()
|
||||
await backfill.reconcile(session,job)
|
||||
assert job.state=='running'
|
||||
assert set((await session.scalars(select(ReprocessTarget.object_id).where(
|
||||
ReprocessTarget.job_id==job.id,ReprocessTarget.kind=='analysis'))).all())==set(outputs)
|
||||
created=await session.get(NewsSummary,outputs[1]); created.state='failed'; created.error='Synthetic split failure'
|
||||
await session.commit(); await backfill.reconcile(session,job)
|
||||
assert job.state=='failed' and job.failures==[{'stage':'analysis','id':created.id,'error':created.error}]
|
||||
await reprocess_retry(job.id,session)
|
||||
assert (await session.get(NewsSummary,created.id)).state=='pending'
|
||||
created.state='done'; created.published_revision=created.revision; created.result={'summary':'Verified split'}
|
||||
await session.commit(); await backfill.reconcile(session,job); assert job.state=='done'
|
||||
# An empty 72h window clears old reading immediately, without models or a pool slot.
|
||||
async with SessionLocal() as session:
|
||||
previous=RiskSnapshot(state='done',model='senior',quotes={'fingerprint':'obsolete','important':{}},
|
||||
result={'reading':[{'id':package.id}],'inputs':[{'id':package.id}],'synthesis':{'summary':'Obsolete'}})
|
||||
session.add(previous); await session.commit()
|
||||
count=await session.scalar(select(func.count()).select_from(RiskSnapshot))
|
||||
no_model=AsyncMock(side_effect=AssertionError('Empty windows must not call a model'))
|
||||
with patch.object(events,'reading_inputs',AsyncMock(return_value=[])),patch.object(briefing,'generate',no_model):
|
||||
assert not await worker.outlook(prepare_only=True)
|
||||
assert not await worker.outlook(prepare_only=True)
|
||||
async with SessionLocal() as session:
|
||||
latest=await session.scalar(select(RiskSnapshot).order_by(RiskSnapshot.id.desc()).limit(1))
|
||||
assert latest.state=='done' and latest.result['inputs']==[] and latest.result['reading']==[]
|
||||
assert latest.result['expansion']==[] and latest.result['synthesis'] is None
|
||||
assert await session.scalar(select(func.count()).select_from(RiskSnapshot))==count+1
|
||||
latest.state='failed'; latest.quotes={**latest.quotes,'retry_requested':True}; await session.commit()
|
||||
failed_id=latest.id
|
||||
assert not await worker.outlook(prepare_only=True)
|
||||
async with SessionLocal() as session:
|
||||
previous=await session.get(RiskSnapshot,failed_id); assert 'retry_requested' not in previous.quotes
|
||||
assert await session.scalar(select(func.count()).select_from(RiskSnapshot))==count+2
|
||||
assert not await worker.outlook(prepare_only=True)
|
||||
no_model.assert_not_awaited()
|
||||
async with SessionLocal() as session:
|
||||
for model in (ReprocessTarget,ReprocessItem,ReprocessJob,EventMember,NewsEvent,PackageCorrection,RiskSnapshot,GroupingVersion,SummaryArticle,NewsSummary,ArticleAnalysis):
|
||||
await session.execute(delete(model))
|
||||
await session.execute(delete(NewsItem).where(NewsItem.id.in_(news_ids)))
|
||||
await session.execute(delete(RiskSettings))
|
||||
await session.execute(update(SkillNode).where(SkillNode.id.in_(skills.values())).values(published_version_id=None))
|
||||
await session.execute(delete(SkillVersion).where(SkillVersion.skill_id.in_(skills.values())))
|
||||
await session.execute(delete(SkillNode).where(SkillNode.id.in_(skills.values())))
|
||||
await session.execute(delete(AiProvider).where(AiProvider.id==provider.id)); await session.commit()
|
||||
await engine.dispose()
|
||||
print('Historical backfill: selection, frozen preview, reuse, recheck, targets, source revisions and queue passed')
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -15,9 +15,12 @@ export const createNewsSkillTemplatesApi = (refreshExisting = false) => request<
|
||||
export const listNewsEventsApi = (page = 1) => request<{ items: Risk.NewsEvent[], total: number }>({ url: "/risk/events", method: "get", params: { page } })
|
||||
export const getNewsEventApi = (id: number) => request<Risk.EventDetail>({ url: `/risk/events/${id}`, method: "get" })
|
||||
export const previewReprocessApi = (data: Risk.ReprocessRange) => request<Risk.ReprocessPreview>({ url: "/risk/reprocess/preview", method: "post", data })
|
||||
export const startReprocessApi = (data: Risk.ReprocessRange) => request<{ id: number }>({ url: "/risk/reprocess", method: "post", data })
|
||||
export const startReprocessApi = (data: { preview_id: string }) => request<{ id: number }>({ url: "/risk/reprocess", method: "post", data })
|
||||
export const listReprocessApi = () => request<Risk.ReprocessJob[]>({ url: "/risk/reprocess", method: "get" })
|
||||
export const cancelReprocessApi = (id: number) => request<null>({ url: `/risk/reprocess/${id}/cancel`, method: "post" })
|
||||
export const retryReprocessApi = (id: number) => request<null>({ url: `/risk/reprocess/${id}/retry`, method: "post" })
|
||||
|
||||
export const saveRiskConcurrencyApi = (max_concurrency: number) => request<{ max_concurrency: number }>({ url: "/risk/settings/concurrency", method: "patch", data: { max_concurrency } })
|
||||
|
||||
export const listRiskQueueApi = (params: { state?: string, stage?: string, origin?: string, job_id?: number, page?: number }) => request<Risk.Queue>({ url: "/risk/queue", method: "get", params })
|
||||
export const getReprocessDetailApi = (id: number, page = 1) => request<Risk.ReprocessJob & { items: { id: number, news_id: number, title: string, url: string, mode: string, state: string, error: string | null }[] }>({ url: `/risk/reprocess/${id}`, method: "get", params: { page } })
|
||||
@@ -118,6 +118,17 @@ export interface EventDetail extends NewsEvent {
|
||||
packages: RiskEvent[]
|
||||
versions: { id: number, revision: number, state: string, error: string | null, result: NewsEvent["result"] }[]
|
||||
}
|
||||
export interface ReprocessRange { start: string, end: string }
|
||||
export interface ReprocessPreview { articles: number, packages: number, events: number }
|
||||
export interface ReprocessJob { id: number, state: string, total: number, completed: number, failures: { stage: "grouping" | "analysis" | "events" | "outlook", id: number, error: string }[] }
|
||||
export interface ReprocessRange { start: string, end: string, modes: ("missing" | "review")[] }
|
||||
export interface ReprocessPreview { articles: number, packages: number, events: number, total: number, missing: number, review: number, preview_id: string, models: Record<string, { model: string, reasoning_effort: string | null }> }
|
||||
export interface ReprocessJob { request?: { start: string, end: string, modes: string[] }, counts?: Record<string, number>, stages?: Record<string, Record<string, number>>, id: number, state: string, total: number, completed: number, failures: { stage: "grouping" | "analysis" | "events" | "outlook", id: number, error: string }[] }
|
||||
|
||||
export interface QueueItem { key: string, stage: string, object_id: number, title: string, job_id: number | null, origin: string, state: string, phase: string, model: string | null, reasoning_effort: string | null, started_at: string | null, updated_at: string | null, finished_at: string | null, url: string | null, round: number | null, tool: string | null, error: string | null }
|
||||
export interface Queue {
|
||||
items: QueueItem[]
|
||||
total: number
|
||||
as_of: string
|
||||
worker_alive: boolean
|
||||
enabled: boolean
|
||||
limit: number
|
||||
concurrency: { running: number, stages: Record<string, number> } | null
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<script setup lang="ts">
|
||||
import type * as Risk from "@@/apis/risk/type"
|
||||
import { formatTime } from "@@/utils/text"
|
||||
import { listRiskQueueApi } from "@/common/apis/risk"
|
||||
import { useUserStore } from "@/pinia/stores/user"
|
||||
|
||||
const emit = defineEmits<{ package: [id: number], event: [id: number] }>()
|
||||
const user = useUserStore()
|
||||
const result = ref<Risk.Queue | null>(null)
|
||||
const expanded = ref(false)
|
||||
const state = ref("running")
|
||||
const stage = ref("")
|
||||
const origin = ref("")
|
||||
const jobId = ref<number | undefined>()
|
||||
const page = ref(1)
|
||||
const error = ref(false)
|
||||
let version = 0
|
||||
let busy = false
|
||||
let alive = true
|
||||
let timer: ReturnType<typeof setInterval> | undefined
|
||||
const labels: Record<string, string> = { grouping: "新闻分包", analysis: "包研判", events: "事件时间线", outlook: "阅读编排", backfill: "历史新闻", running: "运行中", queued: "排队", waiting: "等待条件", failed: "失败", done: "已完成", unknown: "状态未知" }
|
||||
async function load() {
|
||||
if (busy) return
|
||||
busy = true
|
||||
const seq = ++version
|
||||
const session = user.sessionVersion
|
||||
const query = { state: state.value || undefined, stage: stage.value || undefined, origin: origin.value || undefined, job_id: jobId.value || undefined, page: page.value }
|
||||
const sameQuery = () => query.state === (state.value || undefined) && query.stage === (stage.value || undefined) && query.origin === (origin.value || undefined) && query.job_id === (jobId.value || undefined) && query.page === page.value
|
||||
try {
|
||||
const data = await listRiskQueueApi(query)
|
||||
if (!sameQuery()) return
|
||||
if (alive && seq === version && session === user.sessionVersion) {
|
||||
result.value = data
|
||||
error.value = false
|
||||
}
|
||||
} catch {
|
||||
if (alive && seq === version && session === user.sessionVersion && sameQuery()) error.value = true
|
||||
} finally {
|
||||
if (seq === version) busy = false
|
||||
}
|
||||
}
|
||||
watch([state, stage, origin, jobId], () => {
|
||||
page.value = 1
|
||||
void load()
|
||||
})
|
||||
watch(page, () => void load())
|
||||
watch(() => user.sessionVersion, () => {
|
||||
version++
|
||||
busy = false
|
||||
result.value = null
|
||||
})
|
||||
function visible() {
|
||||
if (!document.hidden) void load()
|
||||
}
|
||||
onMounted(() => {
|
||||
void load()
|
||||
timer = setInterval(visible, 3000)
|
||||
document.addEventListener("visibilitychange", visible)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
alive = false
|
||||
version++
|
||||
clearInterval(timer)
|
||||
document.removeEventListener("visibilitychange", visible)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<b>AI 任务队列</b><el-button class="ml-3" link @click="expanded = !expanded">
|
||||
{{ expanded ? '收起详情' : '展开完整队列' }}
|
||||
</el-button>
|
||||
</template>
|
||||
<el-alert v-if="error" title="队列读取失败,保留上次状态" type="warning" :closable="false" />
|
||||
<el-alert v-if="result && !result.worker_alive" title="Worker 心跳已过期,运行状态未知;保留数据库任务进度" type="warning" :closable="false" />
|
||||
<p v-if="result">
|
||||
{{ result.enabled ? 'AI 已启用' : 'AI 已暂停' }} · 并发上限 {{ result.limit }} · 当前执行 {{ result.concurrency?.running ?? '未知' }} 项
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<el-select v-model="state" class="!w-32">
|
||||
<el-option v-for="s in ['running', 'queued', 'waiting', 'failed', 'done']" :key="s" :label="labels[s]" :value="s" />
|
||||
</el-select>
|
||||
<el-select v-if="expanded" v-model="stage" clearable placeholder="全部阶段" class="!w-36">
|
||||
<el-option v-for="s in ['grouping', 'analysis', 'events', 'outlook', 'backfill']" :key="s" :label="labels[s]" :value="s" />
|
||||
</el-select>
|
||||
<el-select v-if="expanded" v-model="origin" clearable placeholder="全部来源" class="!w-36">
|
||||
<el-option label="实时" value="realtime" /><el-option label="回补" value="history" />
|
||||
</el-select>
|
||||
<el-input-number v-if="expanded" v-model="jobId" :min="1" :precision="0" placeholder="批次编号" controls-position="right" />
|
||||
<el-button @click="load">
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
<p v-if="result">
|
||||
{{ labels[state] }} {{ result.total }} 项 · 更新 {{ formatTime(result.as_of) }}
|
||||
</p>
|
||||
<div v-for="task in result?.items.slice(0, expanded ? 20 : 5)" :key="task.key" class="my-3 break-words border-b pb-2">
|
||||
<b>{{ labels[task.stage] }} · {{ task.title }}</b>
|
||||
<a v-if="task.url && /^https?:\/\//i.test(task.url)" :href="task.url" target="_blank" rel="noopener noreferrer" class="ml-2 text-blue-600">新闻原文</a>
|
||||
<el-button v-if="task.stage === 'analysis'" link @click="emit('package', task.object_id)">
|
||||
查看包
|
||||
</el-button>
|
||||
<el-button v-if="task.stage === 'events'" link @click="emit('event', task.object_id)">
|
||||
查看事件
|
||||
</el-button>
|
||||
<p>{{ labels[task.state] }} · {{ task.phase }} {{ task.round ? `第${task.round}轮` : '' }} {{ task.tool ?? '' }} · {{ task.model ?? '待选定模型' }} {{ task.reasoning_effort ?? '' }}</p>
|
||||
<p v-if="task.updated_at">
|
||||
最近步骤 {{ formatTime(task.updated_at) }}
|
||||
</p>
|
||||
<p>开始 {{ formatTime(task.started_at) }} · {{ task.started_at ? `${Math.max(0, Math.floor((Date.parse(task.finished_at || result!.as_of) - Date.parse(task.started_at)) / 1000))}秒` : '等待领取' }} · {{ task.job_id ? `回补 #${task.job_id}` : '实时' }}</p>
|
||||
<p v-if="task.error" class="text-red-600">
|
||||
{{ task.error }}
|
||||
</p>
|
||||
</div>
|
||||
<el-empty v-if="result && !result.items.length" description="当前筛选下没有任务" :image-size="40" />
|
||||
<el-pagination v-if="expanded" v-model:current-page="page" :total="result?.total ?? 0" :page-size="20" layout="prev, pager, next" />
|
||||
</el-card>
|
||||
</template>
|
||||
@@ -14,8 +14,33 @@ const opened = ref(false)
|
||||
const detailError = ref(false)
|
||||
const detailId = ref(0)
|
||||
const range = ref<[Date, Date] | null>(null)
|
||||
const modes = ref<("missing" | "review")[]>(["missing"])
|
||||
const preview = ref<Risk.ReprocessPreview | null>(null)
|
||||
const jobs = ref<Risk.ReprocessJob[]>([])
|
||||
const batch = ref<Awaited<ReturnType<typeof api.getReprocessDetailApi>> | null>(null)
|
||||
const batchId = ref(0)
|
||||
const batchPage = ref(1)
|
||||
const batchLoading = ref(false)
|
||||
const batchError = ref(false)
|
||||
let batchSequence = 0
|
||||
async function openBatch(id: number, page = 1) {
|
||||
if (batchId.value !== id) batch.value = null
|
||||
batchId.value = id
|
||||
batchPage.value = page
|
||||
batchLoading.value = true
|
||||
const version = user.sessionVersion
|
||||
const seq = ++batchSequence
|
||||
try {
|
||||
const data = await api.getReprocessDetailApi(id, page)
|
||||
if (!active || seq !== batchSequence || version !== user.sessionVersion) return
|
||||
batch.value = data
|
||||
batchError.value = false
|
||||
} catch {
|
||||
if (active && seq === batchSequence && version === user.sessionVersion) batchError.value = true
|
||||
} finally {
|
||||
if (active && seq === batchSequence && version === user.sessionVersion) batchLoading.value = false
|
||||
}
|
||||
}
|
||||
const busy = ref(false)
|
||||
const error = ref(false)
|
||||
let sequence = 0
|
||||
@@ -32,6 +57,7 @@ async function load() {
|
||||
total.value = events.total
|
||||
jobs.value = runs
|
||||
error.value = false
|
||||
if (batchId.value && !batchLoading.value) await openBatch(batchId.value, batchPage.value)
|
||||
} catch {
|
||||
if (active && seq === sequence && version === user.sessionVersion) error.value = true
|
||||
}
|
||||
@@ -53,12 +79,12 @@ async function open(id: number) {
|
||||
async function inspect() {
|
||||
if (!range.value || busy.value) return
|
||||
const version = user.sessionVersion
|
||||
const data = { start: range.value[0].toISOString(), end: range.value[1].toISOString() }
|
||||
const data = { start: range.value[0].toISOString(), end: range.value[1].toISOString(), modes: [...modes.value] }
|
||||
const op = ++operation
|
||||
busy.value = true
|
||||
try {
|
||||
const result = await api.previewReprocessApi(data)
|
||||
if (active && version === user.sessionVersion && range.value && data.start === range.value[0].toISOString() && data.end === range.value[1].toISOString()) preview.value = result
|
||||
if (active && version === user.sessionVersion && range.value && data.start === range.value[0].toISOString() && data.end === range.value[1].toISOString() && data.modes.join() === modes.value.join()) preview.value = result
|
||||
} finally {
|
||||
if (active && op === operation && version === user.sessionVersion) busy.value = false
|
||||
}
|
||||
@@ -66,13 +92,15 @@ async function inspect() {
|
||||
async function start() {
|
||||
if (!range.value || !preview.value || busy.value) return
|
||||
const version = user.sessionVersion
|
||||
const data = { start: range.value[0].toISOString(), end: range.value[1].toISOString() }
|
||||
await ElMessageBox.confirm(`重整理 ${preview.value.articles} 篇新闻将产生模型费用,关联包和事件也会更新。是否启动?`, "重整理")
|
||||
const data = { start: range.value[0].toISOString(), end: range.value[1].toISOString(), modes: [...modes.value] }
|
||||
const previewId = preview.value.preview_id
|
||||
await ElMessageBox.confirm(`回补 ${preview.value.articles} 篇新闻将产生模型费用,关联包和事件也会更新。是否启动?`, "AI 历史回补")
|
||||
if (!active || version !== user.sessionVersion || busy.value) return
|
||||
const op = ++operation
|
||||
busy.value = true
|
||||
try {
|
||||
await api.startReprocessApi(data)
|
||||
if (!preview.value || preview.value.preview_id !== previewId || data.modes.join() !== modes.value.join() || !range.value || data.start !== range.value[0].toISOString() || data.end !== range.value[1].toISOString()) return
|
||||
await api.startReprocessApi({ preview_id: previewId })
|
||||
if (active && version === user.sessionVersion) {
|
||||
preview.value = null
|
||||
await load()
|
||||
@@ -86,11 +114,12 @@ async function action(id: number, retry: boolean) {
|
||||
await (retry ? api.retryReprocessApi(id) : api.cancelReprocessApi(id))
|
||||
if (active && version === user.sessionVersion) await load()
|
||||
}
|
||||
watch(range, () => {
|
||||
watch([range, modes], () => {
|
||||
preview.value = null
|
||||
})
|
||||
watch(page, load)
|
||||
watch(() => user.sessionVersion, () => {
|
||||
batchSequence++
|
||||
sequence++
|
||||
detailSequence++
|
||||
operation++
|
||||
@@ -98,6 +127,10 @@ watch(() => user.sessionVersion, () => {
|
||||
detailError.value = false
|
||||
items.value = []
|
||||
jobs.value = []
|
||||
batch.value = null
|
||||
batchId.value = 0
|
||||
batchLoading.value = false
|
||||
batchError.value = false
|
||||
detail.value = null
|
||||
preview.value = null
|
||||
opened.value = false
|
||||
@@ -114,6 +147,7 @@ onMounted(() => {
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
active = false
|
||||
batchSequence++
|
||||
sequence++
|
||||
detailSequence++
|
||||
clearInterval(timer)
|
||||
@@ -144,20 +178,43 @@ defineExpose({ open })
|
||||
</div>
|
||||
<el-pagination v-model:current-page="page" :total="total" :page-size="20" layout="prev, pager, next" />
|
||||
<details class="mt-4">
|
||||
<summary>按时间范围重整理新闻</summary>
|
||||
<summary>AI 历史回补</summary>
|
||||
<p>按新闻发布时间选择范围,使用现存最新修订。不会还原历史时点;共享包和事件可能同时更新。AI 暂停时任务等待。</p>
|
||||
<el-checkbox-group v-model="modes">
|
||||
<el-checkbox value="missing">
|
||||
补齐未完成研判
|
||||
</el-checkbox>
|
||||
<el-checkbox value="review">
|
||||
已研判重新核对(重新分包及核验)
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
<el-date-picker v-model="range" type="datetimerange" class="!max-w-full" start-placeholder="起始时间" end-placeholder="结束时间" />
|
||||
<el-button :loading="busy" @click="inspect">
|
||||
<el-button :loading="busy" :disabled="!range || !modes.length" @click="inspect">
|
||||
预览范围
|
||||
</el-button>
|
||||
<template v-if="preview">
|
||||
<p>{{ preview.articles }} 篇新闻 · {{ preview.packages }} 个包 · {{ preview.events }} 个事件</p>
|
||||
<el-button :loading="busy" @click="start">
|
||||
启动重整理
|
||||
<p>范围共 {{ preview.total }} 篇 · 未完成 {{ preview.missing }} · 已研判 {{ preview.review }} · 本次 {{ preview.articles }} 篇新闻 · {{ preview.packages }} 个包 · {{ preview.events }} 个事件</p>
|
||||
<p v-for="(model, stage) in preview.models" :key="stage">
|
||||
{{ ({ grouping: '分包', analysis: '研判', outlook: '阅读编排' })[stage] }}:{{ model.model }} · 思考强度 {{ model.reasoning_effort ?? '模型默认' }}
|
||||
</p>
|
||||
<el-button :loading="busy" :disabled="!preview.articles" @click="start">
|
||||
启动回补
|
||||
</el-button>
|
||||
</template>
|
||||
<div v-for="job in jobs" :key="job.id" class="my-2">
|
||||
#{{ job.id }} · {{ job.state }} · {{ job.completed }}/{{ job.total }}
|
||||
#{{ job.id }} · {{ job.state }} · 新闻处理 {{ job.completed }}/{{ job.total }}
|
||||
<p v-if="job.request">
|
||||
{{ formatTime(job.request.start) }} 至 {{ formatTime(job.request.end) }} · {{ job.request.modes.map(m => m === 'missing' ? '补齐未完成' : '重新核对').join('、') }}
|
||||
</p>
|
||||
<p v-if="job.counts">
|
||||
成功 {{ job.counts.done ?? 0 }} · 处理中 {{ job.counts.processing ?? 0 }} · 待处理 {{ job.counts.pending ?? 0 }} · 失败 {{ job.counts.failed ?? 0 }} · 跳过 {{ job.counts.skipped ?? 0 }}
|
||||
</p>
|
||||
<p v-for="(counts, stage) in job.stages" :key="stage">
|
||||
{{ stage === 'analysis' ? '包研判' : '事件时间线' }}:待处理 {{ counts.pending ?? 0 }} · 运行 {{ counts.processing ?? 0 }} · 完成 {{ counts.done ?? 0 }} · 失败 {{ counts.failed ?? 0 }}
|
||||
</p>
|
||||
<el-button link @click="openBatch(job.id)">
|
||||
查看新闻明细
|
||||
</el-button>
|
||||
<el-button v-if="['pending', 'running'].includes(job.state)" link @click="action(job.id, false)">
|
||||
取消
|
||||
</el-button>
|
||||
@@ -168,6 +225,17 @@ defineExpose({ open })
|
||||
{{ ({ grouping: "新闻初筛", analysis: "包研判", events: "事件时间线", outlook: "阅读编排" })[failure.stage] }} #{{ failure.id }}:{{ failure.error }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="batchId">
|
||||
<h4>回补 #{{ batchId }} 新闻明细</h4>
|
||||
<el-alert v-if="batchError" title="新闻明细读取失败,保留上次进度,可刷新重试" type="warning" :closable="false" />
|
||||
<el-button :loading="batchLoading" @click="openBatch(batchId, batchPage)">
|
||||
刷新明细
|
||||
</el-button>
|
||||
<p v-for="item in batch?.items" :key="item.id">
|
||||
{{ item.title }}(新闻 #{{ item.news_id }})· {{ item.mode === 'missing' ? '补齐' : item.mode === 'review' ? '重新核对' : '旧批次' }} · {{ item.state }} {{ item.error }}
|
||||
</p>
|
||||
<el-pagination v-if="batch" :current-page="batchPage" :total="batch.total" :page-size="20" layout="prev, pager, next" @current-change="openBatch(batchId, $event)" />
|
||||
</div>
|
||||
</details>
|
||||
</el-card>
|
||||
<el-drawer v-model="opened" title="事件发展时间线" size="min(760px, 100vw)">
|
||||
|
||||
@@ -9,6 +9,7 @@ import * as api from "@/common/apis/risk"
|
||||
import { listProviderModelsApi, listProvidersApi } from "@/common/apis/settings"
|
||||
import { useUserStore } from "@/pinia/stores/user"
|
||||
|
||||
import AiQueue from "./components/AiQueue.vue"
|
||||
import EventResearch from "./components/EventResearch.vue"
|
||||
|
||||
const eventResearch = ref<InstanceType<typeof EventResearch> | null>(null)
|
||||
@@ -257,6 +258,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<template>
|
||||
<div class="mx-auto max-w-7xl flex flex-col gap-4">
|
||||
<AiQueue @package="openEvent" @event="openReading({ kind: 'event', id: $event, revision: 0, priority: 0, reason: '' })" />
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div class="flex-1">
|
||||
<h1 class="m-0 text-xl font-semibold">
|
||||
|
||||
@@ -882,3 +882,171 @@ test("新闻并发独立保存保留模型草稿,重复点击和旧会话响
|
||||
await current
|
||||
assert.equal(page.concurrencySaving.value, false)
|
||||
})
|
||||
|
||||
test("AI 队列隔离旧会话与旧筛选响应,轮询隐藏页面时暂停并在卸载后清理", async () => {
|
||||
const user = vue.reactive({ sessionVersion: 1 })
|
||||
const pending: ReturnType<typeof Promise.withResolvers>[] = []
|
||||
const queries: any[] = []
|
||||
let poll = () => {}
|
||||
let unmount = () => {}
|
||||
let cleared = false
|
||||
const doc = { hidden: true, addEventListener() {}, removeEventListener() {} }
|
||||
const load = moduleLoader({
|
||||
"@/pinia/stores/user": { useUserStore: () => user },
|
||||
"@/common/apis/risk": { listRiskQueueApi: (query: unknown) => {
|
||||
queries.push(query)
|
||||
const request = Promise.withResolvers()
|
||||
pending.push(request)
|
||||
return request.promise
|
||||
} },
|
||||
"@@/utils/text": { formatTime: (value: string) => value }
|
||||
}, { ...globals, document: doc, setInterval: (callback: () => void) => {
|
||||
poll = callback
|
||||
return 1
|
||||
}, clearInterval: () => {
|
||||
cleared = true
|
||||
}, onMounted: (callback: () => void) => callback(), onBeforeUnmount: (callback: () => void) => {
|
||||
unmount = callback
|
||||
} })
|
||||
const page = load("@/pages/home/components/AiQueue.vue").default.setup({}, { expose() {}, emit() {} })
|
||||
assert.equal(queries.length, 1)
|
||||
poll()
|
||||
assert.equal(queries.length, 1)
|
||||
user.sessionVersion++
|
||||
await vue.nextTick()
|
||||
const current = page.load()
|
||||
pending[1].resolve({ total: 7, items: [] })
|
||||
await current
|
||||
pending[0].resolve({ total: 99, items: [] })
|
||||
await setImmediate()
|
||||
assert.equal(page.result.value.total, 7)
|
||||
page.state.value = "queued"
|
||||
await vue.nextTick()
|
||||
assert.equal(queries[2].state, "queued")
|
||||
page.stage.value = "analysis"
|
||||
await vue.nextTick()
|
||||
pending[2].resolve({ total: 999, items: [] })
|
||||
await setImmediate()
|
||||
assert.equal(page.result.value.total, 7)
|
||||
doc.hidden = false
|
||||
poll()
|
||||
pending[3].resolve({ total: 4, items: [] })
|
||||
await setImmediate()
|
||||
assert.equal(queries[3].stage, "analysis")
|
||||
assert.equal(page.result.value.total, 4)
|
||||
const late = page.load()
|
||||
unmount()
|
||||
pending[4].resolve({ total: 100, items: [] })
|
||||
await late
|
||||
assert.equal(page.result.value.total, 4)
|
||||
assert.equal(cleared, true)
|
||||
})
|
||||
|
||||
test("回补确认绑定原预览,确认期间替换预览不能启动另一批次", async () => {
|
||||
const confirmation = Promise.withResolvers()
|
||||
const started: unknown[] = []
|
||||
const load = moduleLoader({
|
||||
"@/pinia/stores/user": { useUserStore: () => ({ sessionVersion: 1 }) },
|
||||
"@/common/apis/risk": { startReprocessApi: async (body: unknown) => {
|
||||
started.push(body)
|
||||
} },
|
||||
"@@/utils/text": { formatTime: (value: string) => value }
|
||||
}, { ...globals, ElMessageBox: { confirm: () => confirmation.promise } })
|
||||
const page = load("@/pages/home/components/EventResearch.vue").default.setup({}, { expose() {}, emit() {} })
|
||||
page.range.value = [new Date("2026-10-01"), new Date("2026-11-01")]
|
||||
await vue.nextTick()
|
||||
page.preview.value = { preview_id: "accepted-preview", articles: 2 }
|
||||
const action = page.start()
|
||||
page.preview.value = { preview_id: "replacement-preview", articles: 10 }
|
||||
confirmation.resolve(true)
|
||||
await action
|
||||
assert.deepEqual(started, [])
|
||||
})
|
||||
|
||||
test("回补新闻明细随轮询和手动刷新更新,保留批次分页并支持失败重试", async () => {
|
||||
const user = vue.reactive({ sessionVersion: 1 })
|
||||
let state = "pending"
|
||||
let fail = false
|
||||
const requests: [number, number][] = []
|
||||
let poll = () => {}
|
||||
const doc = { hidden: false }
|
||||
const load = moduleLoader({
|
||||
"@/pinia/stores/user": { useUserStore: () => user },
|
||||
"@/common/apis/risk": {
|
||||
listNewsEventsApi: async () => ({ items: [], total: 0 }),
|
||||
listReprocessApi: async () => [{ id: 7, state }],
|
||||
getReprocessDetailApi: async (id: number, page: number) => {
|
||||
requests.push([id, page])
|
||||
if (fail) throw new Error("Synthetic detail failure")
|
||||
return { id, state, total: 40, items: [{ id: 21, state }] }
|
||||
}
|
||||
},
|
||||
"@@/utils/text": { formatTime: (value: string) => value }
|
||||
}, { ...globals, document: doc, onMounted: (callback: () => void) => callback(), setInterval: (callback: () => void) => {
|
||||
poll = callback
|
||||
return 1
|
||||
} })
|
||||
const page = load("@/pages/home/components/EventResearch.vue").default.setup({}, { expose() {}, emit() {} })
|
||||
await setImmediate()
|
||||
await page.openBatch(7, 2)
|
||||
state = "done"
|
||||
poll()
|
||||
await setImmediate()
|
||||
assert.equal(page.batch.value.items[0].state, "done")
|
||||
assert.equal(page.batchPage.value, 2)
|
||||
assert.deepEqual(requests, [[7, 2], [7, 2]])
|
||||
doc.hidden = true
|
||||
poll()
|
||||
await setImmediate()
|
||||
assert.equal(requests.length, 2)
|
||||
fail = true
|
||||
await page.load()
|
||||
assert.equal(page.batchError.value, true)
|
||||
assert.equal(page.batch.value.items[0].state, "done")
|
||||
assert.equal(page.batchLoading.value, false)
|
||||
fail = false
|
||||
await page.openBatch(7, 2)
|
||||
assert.equal(page.batchError.value, false)
|
||||
})
|
||||
|
||||
test("回补明细刷新不抢占新选择,旧会话与旧请求不能覆盖当前批次", async () => {
|
||||
const user = vue.reactive({ sessionVersion: 1 })
|
||||
const requests: { id: number, page: number, promise: ReturnType<typeof Promise.withResolvers> }[] = []
|
||||
const load = moduleLoader({
|
||||
"@/pinia/stores/user": { useUserStore: () => user },
|
||||
"@/common/apis/risk": {
|
||||
listNewsEventsApi: async () => ({ items: [], total: 0 }),
|
||||
listReprocessApi: async () => [],
|
||||
getReprocessDetailApi: (id: number, page: number) => {
|
||||
const promise = Promise.withResolvers()
|
||||
requests.push({ id, page, promise })
|
||||
return promise.promise
|
||||
}
|
||||
},
|
||||
"@@/utils/text": { formatTime: (value: string) => value }
|
||||
}, globals)
|
||||
const page = load("@/pages/home/components/EventResearch.vue").default.setup({}, { expose() {}, emit() {} })
|
||||
const old = page.openBatch(7, 2)
|
||||
const selected = page.openBatch(8, 1)
|
||||
await page.load()
|
||||
assert.deepEqual(requests.map(r => [r.id, r.page]), [[7, 2], [8, 1]])
|
||||
requests[0].promise.resolve({ id: 7, items: [] })
|
||||
await old
|
||||
assert.equal(page.batchLoading.value, true)
|
||||
requests[1].promise.resolve({ id: 8, items: [] })
|
||||
await selected
|
||||
assert.equal(page.batch.value.id, 8)
|
||||
const late = page.openBatch(8, 2)
|
||||
user.sessionVersion++
|
||||
await vue.nextTick()
|
||||
const current = page.openBatch(9, 1)
|
||||
requests[2].promise.reject(new Error("Old session failure"))
|
||||
await late
|
||||
assert.equal(page.batchLoading.value, true)
|
||||
assert.equal(page.batchError.value, false)
|
||||
requests[3].promise.resolve({ id: 9, items: [] })
|
||||
await current
|
||||
assert.equal(page.batch.value.id, 9)
|
||||
assert.equal(page.batchId.value, 9)
|
||||
assert.equal(page.batchPage.value, 1)
|
||||
})
|
||||
Reference in new issue
Block a user