feat: add historical news AI backfill and visible task queue
Development CI / check-and-build (push) Successful in 2m44s
GitHub Git mirror / mirror (push) Successful in 6s
Development CI / deploy-development (push) Failing after 7s

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:
eezd committed 2026-11-01 14:47:54 +08:00
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')
+42 -9
View File
@@ -1,7 +1,7 @@
"""Authenticated news risk research endpoints.""" """Authenticated news risk research endpoints."""
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from enum import IntEnum from enum import IntEnum
from typing import Annotated from typing import Annotated, Literal
from fastapi import APIRouter, Body, Depends, HTTPException, Query from fastapi import APIRouter, Body, Depends, HTTPException, Query
from pydantic import BaseModel, Field, model_validator from pydantic import BaseModel, Field, model_validator
from sqlalchemy import select, func, text 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)) if runtime: concurrency.update(json.loads(runtime))
from alphapulse.models.risk import ReprocessJob from alphapulse.models.risk import ReprocessJob
jobs = (await session.scalars(select(ReprocessJob).where(ReprocessJob.state.in_(['pending', 'running'])))).all() 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 = 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) 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'] 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, 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): class ReprocessIn(BaseModel):
modes: list[Literal['missing','review']] = Field(min_length=1,max_length=2)
start: datetime start: datetime
end: datetime end: datetime
@model_validator(mode='after') @model_validator(mode='after')
def range_valid(self): 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: if self.start.tzinfo is None or self.end.tzinfo is None or self.start >= self.end:
raise ValueError('请提供带时区的有效起止时间') raise ValueError('请提供带时区的有效起止时间')
return self return self
@@ -384,15 +388,19 @@ class ReprocessIn(BaseModel):
@router.post('/reprocess/preview') @router.post('/reprocess/preview')
async def reprocess_preview(body: ReprocessIn, session: DB): async def reprocess_preview(body: ReprocessIn, session: DB):
from alphapulse.news_risk.reprocess import preview from alphapulse.news_risk.backfill import preview
counts, _ = await preview(session, body.start, body.end) try: return success(await preview(session,body.start,body.end,body.modes))
return success(counts) 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) @router.post('/reprocess', status_code=202)
async def reprocess_start(body: ReprocessIn, session: DB): async def reprocess_start(body: ReprocessStart, session: DB):
from alphapulse.news_risk.reprocess import start_job from alphapulse.news_risk.backfill import start
try: result = await start_job(session, body.start, body.end) try: result = await start(session, body.preview_id)
except ValueError as error: raise HTTPException(409, str(error)) from error except ValueError as error: raise HTTPException(409, str(error)) from error
return success(result) return success(result)
@@ -401,8 +409,8 @@ async def reprocess_start(body: ReprocessIn, session: DB):
async def reprocess_list(session: DB): async def reprocess_list(session: DB):
from alphapulse.models.risk import ReprocessJob from alphapulse.models.risk import ReprocessJob
rows = (await session.scalars(select(ReprocessJob).order_by(ReprocessJob.id.desc()).limit(20))).all() 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, from alphapulse.news_risk.queue import job_out
'created_at': r.created_at} for r in rows]) return success([await job_out(session,r) for r in rows])
@router.post('/reprocess/{identifier}/cancel') @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 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)): if await session.scalar(select(ReprocessJob.id).where(ReprocessJob.state.in_(['pending', 'running'])).limit(1)):
raise HTTPException(409, '已有重整理任务') 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'} 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', snapshots = (await session.scalars(select(RiskSnapshot).where(RiskSnapshot.id.in_(outlook_ids), RiskSnapshot.state == 'failed',
RiskSnapshot.quotes['job_id'].as_integer() == job.id))).all() 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.position = len(job.items)
job.failures = []; job.state = 'running' job.failures = []; job.state = 'running'
await session.commit(); return success(None) 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]})
+24
View File
@@ -224,3 +224,27 @@ class EventLink(Base):
event_id: Mapped[int] = mapped_column(ForeignKey('news_ai_events.id'), primary_key=True) event_id: Mapped[int] = mapped_column(ForeignKey('news_ai_events.id'), primary_key=True)
reason: Mapped[str] = mapped_column(Text) reason: Mapped[str] = mapped_column(Text)
package_revision: Mapped[int] = mapped_column(Integer) 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 = 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} link.active = True; link.unit = {'fact':replacement.fact,'evidence':assignment.evidence,'correction':True}
schedule(package, now, True); outputs.append(package.id) 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: for package in packages:
if package.id not in outputs: if package.id not in outputs:
package.replacement_ids = outputs; package.state = 'superseded' 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.pipeline import configuration
from alphapulse.news_risk.contracts import PackageTimePoint from alphapulse.news_risk.contracts import PackageTimePoint
from alphapulse.news_risk.service import complete from alphapulse.news_risk.service import complete
from alphapulse.news_risk.progress import step
class TimelinePoint(PackageTimePoint): class TimelinePoint(PackageTimePoint):
@@ -54,6 +55,9 @@ async def dirty_event(session, package_id, review_config=None):
event.review_config = review_config event.review_config = review_config
event.state = 'pending' event.state = 'pending'
event.due_at = datetime.now(UTC)+timedelta(seconds=60) 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): 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.review_config = package.review_config
event.state = 'pending'; event.due_at = datetime.now(UTC)+timedelta(seconds=60) event.state = 'pending'; event.due_at = datetime.now(UTC)+timedelta(seconds=60)
event.updated_at = max(p.updated_at for p in members) 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): async def organize(target_id=None):
@@ -122,7 +129,9 @@ async def organize(target_id=None):
version_id, event_id = version.id, event.id version_id, event_id = version.id, event.id
try: try:
if config_error: raise ValueError(config_error) if config_error: raise ValueError(config_error)
step('梳理事件时间线')
result, usage = await complete(provider, system, {'task': 'event_timeline', 'previous': event.result, 'packages': packages}, EventResult) result, usage = await complete(provider, system, {'task': 'event_timeline', 'previous': event.result, 'packages': packages}, EventResult)
step('核对时间线与证据')
validate_timeline(result, packages) validate_timeline(result, packages)
version.result = result.model_dump(mode='json'); version.usage = usage; version.state = 'done' version.result = result.model_dump(mode='json'); version.usage = usage; version.state = 'done'
from alphapulse.news_risk.worker import publication_lock 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)
+113
View File
@@ -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}
+10 -2
View File
@@ -1,8 +1,8 @@
"""Explicit bounded historical jobs, sharing the normal news pipeline.""" """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.core.db import SessionLocal
from alphapulse.models import NewsItem 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 from alphapulse.news_risk.pipeline import configuration
@@ -21,6 +21,7 @@ async def capture(session, settings):
from alphapulse.api.risk import config_out from alphapulse.api.risk import config_out
value = config_out(settings) value = config_out(settings)
value.pop('start_at') value.pop('start_at')
value.pop('enabled', None)
value.pop('max_concurrency', None) value.pop('max_concurrency', None)
configurations = {} configurations = {}
for stage in ('grouping', 'analysis', 'outlook'): 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 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.items = [{**entry, 'state': 'processing'} if n == index else entry for n, entry in enumerate(job.items)]
job_id = job.id 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: try:
for stage in ('grouping', 'analysis', 'outlook'): for stage in ('grouping', 'analysis', 'outlook'):
await frozen_configuration(session, job.settings, stage) await frozen_configuration(session, job.settings, stage)
@@ -98,6 +104,7 @@ async def process_job(item_index=None):
if outcome == 'deferred': if outcome == 'deferred':
job = await session.get(ReprocessJob, job_id, populate_existing=True, with_for_update=True) 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)] 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 await session.commit(); return
row = await session.get(ArticleAnalysis, row_id, populate_existing=True) 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 '原文已变化') 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 = 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.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) 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() await session.commit()
@@ -124,6 +124,8 @@ class Research:
async def tool(self, name, arguments): async def tool(self, name, arguments):
if name not in TOOLS: raise ValueError('未知研究工具') if name not in TOOLS: raise ValueError('未知研究工具')
from alphapulse.news_risk.progress import step
step('查询资料', tool=name)
args = TOOLS[name][0].model_validate(arguments) args = TOOLS[name][0].model_validate(arguments)
key = name+json.dumps(arguments, sort_keys=True) key = name+json.dumps(arguments, sort_keys=True)
if key in self.cache: return self.cache[key] if key in self.cache: return self.cache[key]
@@ -196,7 +198,9 @@ class Research:
submit = llm.ToolSpec('submit', '提交完整研判,引用实际读取来源', schema.model_json_schema()) submit = llm.ToolSpec('submit', '提交完整研判,引用实际读取来源', schema.model_json_schema())
messages = [{'role': 'user', 'text': json.dumps(payload, ensure_ascii=False)}] messages = [{'role': 'user', 'text': json.dumps(payload, ensure_ascii=False)}]
usages = [] usages = []
from alphapulse.news_risk.progress import step, current
for round_no in range(9): 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): if not self.fits_messages(provider, system, messages, schema):
raise ValueError('研究上下文不足,请增大上下文配置;证据未截断') raise ValueError('研究上下文不足,请增大上下文配置;证据未截断')
completion = None completion = None
+39 -6
View File
@@ -7,7 +7,7 @@ from datetime import UTC, datetime
from sqlalchemy import select, text, func from sqlalchemy import select, text, func
from alphapulse.core.db import SessionLocal from alphapulse.core.db import SessionLocal
from alphapulse.core.redis import redis 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__) logger = logging.getLogger(__name__)
GATE = 20261102 GATE = 20261102
@@ -18,7 +18,8 @@ class Pool:
self.tasks = {} self.tasks = {}
def start(self, key, action): 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): async def reap(self, timeout=1):
if not self.tasks: if not self.tasks:
@@ -50,6 +51,9 @@ async def candidate(active, cursor, allow_outlook=True):
await worker.enqueue(session, settings) await worker.enqueue(session, settings)
history_news = [] history_news = []
for kind, identity in active: 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: if kind == 'history' and identity[1] >= 0:
job = await session.get(ReprocessJob, identity[0]) job = await session.get(ReprocessJob, identity[0])
history_news.append(job.items[identity[1]]['id']) 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] busy = [key[1] for key in active if key[0] == stage]
if stage == 'outlook': if stage == 'outlook':
if not busy and allow_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 continue
model = {'grouping': ArticleAnalysis, 'analysis': NewsSummary, 'events': NewsEvent}[stage] model = {'grouping': ArticleAnalysis, 'analysis': NewsSummary, 'events': NewsEvent}[stage]
query = select(model.id).where(model.state == 'pending', model.id.not_in(busy)) 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] 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 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)) 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: if job:
for index, item in enumerate(job.items): for index, item in enumerate(job.items):
key = ('history', (job.id, index)) 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. # 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'])) 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: if row_id is not None and ('grouping', row_id) in active:
continue continue
return key, lambda index=index: reprocess.process_job(index), cursor return key, lambda index=index: reprocess.process_job(index), cursor
if not any(k[0] == 'history' for k in active): 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 return None
@@ -103,6 +124,11 @@ async def run(lock):
# Interrupted historical items are reclaimed independently of completion order. # Interrupted historical items are reclaimed independently of completion order.
async with SessionLocal() as session: async with SessionLocal() as session:
jobs = (await session.scalars(select(ReprocessJob).where(ReprocessJob.state.in_(['pending', 'running'])))).all() 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 job in jobs:
for item in job.items: for item in job.items:
if item['state'] == 'processing': if item['state'] == 'processing':
@@ -136,9 +162,16 @@ async def run(lock):
async with SessionLocal() as session: async with SessionLocal() as session:
settings = await session.get(RiskSettings, 1) settings = await session.get(RiskSettings, 1)
limit, enabled = settings.max_concurrency, settings.enabled 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() 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: 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) 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:'): for stage in ('', 'analysis:', 'events:', 'outlook:', 'reprocess:'):
await redis.set('ap:risk:' + stage + 'heartbeat', now, ex=15) 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]): 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) text = json.dumps(payload, ensure_ascii=False)
if not fits_context(provider, system, payload, schema): if not fits_context(provider, system, payload, schema):
raise ValueError('新闻输入超出模型上下文;未截断或发送,请调整模型配置') raise ValueError('新闻输入超出模型上下文;未截断或发送,请调整模型配置')
+24 -2
View File
@@ -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.pipeline import configuration, Grouping, summarize_articles, validate_evidence, Evidence, grouping_payload, pack_candidates
from alphapulse.news_risk.research import Research from alphapulse.news_risk.research import Research
from alphapulse.news_risk import briefing from alphapulse.news_risk import briefing
from alphapulse.news_risk.progress import step
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
publication_lock = asyncio.Lock() publication_lock = asyncio.Lock()
@@ -73,6 +74,7 @@ async def _analyze(target_id=None, frozen=None):
row_id = row.id row_id = row.id
row_input = row.input row_input = row.input
try: try:
step('提取与归并新闻')
payload, usages = await grouping_payload(provider, system, {'id': row.id, **row.input}, candidate_inputs) payload, usages = await grouping_payload(provider, system, {'id': row.id, **row.input}, candidate_inputs)
for attempt in range(2): for attempt in range(2):
try: try:
@@ -87,6 +89,7 @@ async def _analyze(target_id=None, frozen=None):
if attempt: if attempt:
raise raise
payload = pack_candidates(provider, system, {**payload, 'correction': str(error)}, payload['candidates']) payload = pack_candidates(provider, system, {**payload, 'correction': str(error)}, payload['candidates'])
step('校验并保存分包结果')
async with publication_lock: async with publication_lock:
# Check the article again before changing event membership. # Check the article again before changing event membership.
current = await session.get(NewsItem, row.news_id, populate_existing=True) 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: if event.id in changed:
event.review_config = frozen event.review_config = frozen
schedule(event, now, any(m.urgent for m in result.packages)) 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')) 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.result = result.model_dump(mode='json'); row.usage = {'attempts': usages}; row.state = 'done'
row.error = None; row.finished_at = now row.error = None; row.finished_at = now
@@ -201,6 +207,7 @@ async def summarize(target_id=None):
try: try:
if config_error: raise ValueError(config_error) if config_error: raise ValueError(config_error)
result, usage = await summarize_articles(provider, system, articles, package, research) result, usage = await summarize_articles(provider, system, articles, package, research)
step('校验并保存包研判')
async with publication_lock: async with publication_lock:
event = await session.get(NewsSummary, event_id, populate_existing=True, with_for_update=True) event = await session.get(NewsSummary, event_id, populate_existing=True, with_for_update=True)
if event.replacement_ids or event.state == 'withdrawn': if event.replacement_ids or event.state == 'withdrawn':
@@ -240,7 +247,7 @@ async def summarize(target_id=None):
await session.commit(); logger.exception('事件总结失败') await session.commit(); logger.exception('事件总结失败')
async def outlook(): async def outlook(prepare_only=False):
async with SessionLocal() as session: async with SessionLocal() as session:
settings = await session.get(RiskSettings, 1) settings = await session.get(RiskSettings, 1)
if not settings or not settings.enabled: return if not settings or not settings.enabled: return
@@ -252,6 +259,7 @@ async def outlook():
inputs = await reading_inputs(session, now-timedelta(hours=72)) inputs = await reading_inputs(session, now-timedelta(hours=72))
from alphapulse.models.risk import ReprocessJob from alphapulse.models.risk import ReprocessJob
job = await session.scalar(select(ReprocessJob).where(ReprocessJob.state == 'running').limit(1)) 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 job and job.position < len(job.items):
if requested and requested.quotes.get('job_id') == job.id: return if requested and requested.quotes.get('job_id') == job.id: return
job = None job = None
@@ -266,7 +274,17 @@ async def outlook():
semantic = briefing.semantic_packages(inputs) semantic = briefing.semantic_packages(inputs)
key = briefing.fingerprint(semantic, rules) 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} 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: 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()) urgent_change = any(latest.quotes.get('important', {}).get(identifier) != value for identifier, value in important.items())
if urgent_change: if urgent_change:
@@ -277,8 +295,11 @@ async def outlook():
latest.quotes = {**latest.quotes, 'dirty_since': now.isoformat()} latest.quotes = {**latest.quotes, 'dirty_since': now.isoformat()}
await session.commit(); return await session.commit(); return
if (now-datetime.fromisoformat(dirty)).total_seconds() < 900: 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 {})}) 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() session.add(snapshot); await session.commit()
step('生成阅读与趋势', object_id=snapshot.id)
try: try:
main = [p for p in inputs if p['importance_stars'] >= 3] main = [p for p in inputs if p['importance_stars'] >= 3]
low = [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 if len(extra) == 3: break
snapshot.result = {**result, 'expansion': extra, 'inputs': inputs, 'rules': rules, snapshot.result = {**result, 'expansion': extra, 'inputs': inputs, 'rules': rules,
'expansion_usage': expansion.get('usage', []), 'as_of': now.isoformat()} 'expansion_usage': expansion.get('usage', []), 'as_of': now.isoformat()}
step('保存阅读与趋势')
snapshot.state = 'done' snapshot.state = 'done'
except Exception as error: except Exception as error:
snapshot.state = 'failed'; snapshot.error = str(error)[:2000] snapshot.state = 'failed'; snapshot.error = str(error)[:2000]
+63
View File
@@ -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)
+2 -1
View File
@@ -12,6 +12,7 @@ def fake_session(settings):
session.get = AsyncMock(return_value=settings) session.get = AsyncMock(return_value=settings)
session.scalars = AsyncMock(return_value=N(all=lambda: [])) session.scalars = AsyncMock(return_value=N(all=lambda: []))
session.commit = AsyncMock() session.commit = AsyncMock()
session.execute = AsyncMock()
return session 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)]) items=[{'id':i,'hash':'current','state':'pending'} for i in (1,2,3)])
article = N(id=30, state='done', error=None) article = N(id=30, state='done', error=None)
session = fake_session(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: { session.get = AsyncMock(side_effect=lambda model,*a,**kw: {
RiskSettings:N(enabled=True), ReprocessJob:job, RiskSettings:N(enabled=True), ReprocessJob:job,
ArticleAnalysis:article, NewsItem:N(id=3, content_hash='current')}[model]) ArticleAnalysis:article, NewsItem:N(id=3, content_hash='current')}[model])
+3 -3
View File
@@ -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={}) 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}) old = N(id=25, state='done', error=None, result={'old_success':True})
session = session_mock() 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: { session.get.side_effect = lambda model, *a, **kw: {
RiskSettings:N(enabled=True), NewsItem:N(id=21,content_hash='abc'), ArticleAnalysis:old, ReprocessJob:job}[model] RiskSettings:N(enabled=True), NewsItem:N(id=21,content_hash='abc'), ArticleAnalysis:old, ReprocessJob:job}[model]
paused = session_mock() 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']) @pytest.mark.parametrize('stage', ['grouping', 'analysis', 'events', 'outlook'])
def test_retry_resumes_only_failed_stage(trading, stage): def test_retry_resumes_only_failed_stage(trading, stage):
from alphapulse.api.risk import reprocess_retry 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'}]) failures=[{'stage':stage,'id':11,'error':'Synthetic failure'}])
failed = N(id=11,state='failed',quotes={}) failed = N(id=11,state='failed',quotes={})
session = session_mock() 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 from alphapulse.news_risk import reprocess, worker, events
session = session_mock() session = session_mock()
session.get.return_value = N(enabled=True) 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(worker,'SessionLocal',lambda:session)
monkeypatch.setattr(events,'reading_inputs',AsyncMock(return_value=[])) monkeypatch.setattr(events,'reading_inputs',AsyncMock(return_value=[]))
current = AsyncMock(side_effect=AssertionError('Must not use current settings for a job retry')) current = AsyncMock(side_effect=AssertionError('Must not use current settings for a job retry'))
+42
View File
@@ -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 Migration `e5f701234567` adds the limit and converts legacy historical checkpoints to
item states. Deployment and execution of this migration require separate authorization. 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.
+2
View File
@@ -183,6 +183,8 @@ def smoke(build, label):
input=(ROOT / 'scripts/smoke_risk.py').read_text()), flush=True) input=(ROOT / 'scripts/smoke_risk.py').read_text()), flush=True)
print(run('docker', 'exec', '-i', names['api'], 'python', '-', print(run('docker', 'exec', '-i', names['api'], 'python', '-',
input=(ROOT / 'scripts/smoke_events.py').read_text()), flush=True) 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', '-', print(run('docker', 'exec', '-i', names['api'], 'python', '-',
input=(ROOT / 'scripts/smoke_calendar.py').read_text()), flush=True) input=(ROOT / 'scripts/smoke_calendar.py').read_text()), flush=True)
output = run('docker', 'exec', '-i', '-e', 'SMOKE_WEB=' + names['web'], names['api'], 'python', '-', output = run('docker', 'exec', '-i', '-e', 'SMOKE_WEB=' + names['web'], names['api'], 'python', '-',
+340
View File
@@ -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())
+4 -1
View File
@@ -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 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 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 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 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 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 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 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 } })
+14 -3
View File
@@ -118,6 +118,17 @@ export interface EventDetail extends NewsEvent {
packages: RiskEvent[] packages: RiskEvent[]
versions: { id: number, revision: number, state: string, error: string | null, result: NewsEvent["result"] }[] versions: { id: number, revision: number, state: string, error: string | null, result: NewsEvent["result"] }[]
} }
export interface ReprocessRange { start: string, end: string } export interface ReprocessRange { start: string, end: string, modes: ("missing" | "review")[] }
export interface ReprocessPreview { articles: number, packages: number, events: number } 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 { id: number, state: string, total: number, completed: number, failures: { stage: "grouping" | "analysis" | "events" | "outlook", id: number, error: string }[] } 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
}
+119
View File
@@ -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>
+80 -12
View File
@@ -14,8 +14,33 @@ const opened = ref(false)
const detailError = ref(false) const detailError = ref(false)
const detailId = ref(0) const detailId = ref(0)
const range = ref<[Date, Date] | null>(null) const range = ref<[Date, Date] | null>(null)
const modes = ref<("missing" | "review")[]>(["missing"])
const preview = ref<Risk.ReprocessPreview | null>(null) const preview = ref<Risk.ReprocessPreview | null>(null)
const jobs = ref<Risk.ReprocessJob[]>([]) 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 busy = ref(false)
const error = ref(false) const error = ref(false)
let sequence = 0 let sequence = 0
@@ -32,6 +57,7 @@ async function load() {
total.value = events.total total.value = events.total
jobs.value = runs jobs.value = runs
error.value = false error.value = false
if (batchId.value && !batchLoading.value) await openBatch(batchId.value, batchPage.value)
} catch { } catch {
if (active && seq === sequence && version === user.sessionVersion) error.value = true if (active && seq === sequence && version === user.sessionVersion) error.value = true
} }
@@ -53,12 +79,12 @@ async function open(id: number) {
async function inspect() { async function inspect() {
if (!range.value || busy.value) return if (!range.value || busy.value) return
const version = user.sessionVersion 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 const op = ++operation
busy.value = true busy.value = true
try { try {
const result = await api.previewReprocessApi(data) 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 { } finally {
if (active && op === operation && version === user.sessionVersion) busy.value = false if (active && op === operation && version === user.sessionVersion) busy.value = false
} }
@@ -66,13 +92,15 @@ async function inspect() {
async function start() { async function start() {
if (!range.value || !preview.value || busy.value) return if (!range.value || !preview.value || busy.value) return
const version = user.sessionVersion 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] }
await ElMessageBox.confirm(`重整理 ${preview.value.articles} 篇新闻将产生模型费用,关联包和事件也会更新。是否启动?`, "重整理") const previewId = preview.value.preview_id
await ElMessageBox.confirm(`回补 ${preview.value.articles} 篇新闻将产生模型费用,关联包和事件也会更新。是否启动?`, "AI 历史回补")
if (!active || version !== user.sessionVersion || busy.value) return if (!active || version !== user.sessionVersion || busy.value) return
const op = ++operation const op = ++operation
busy.value = true busy.value = true
try { 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) { if (active && version === user.sessionVersion) {
preview.value = null preview.value = null
await load() await load()
@@ -86,11 +114,12 @@ async function action(id: number, retry: boolean) {
await (retry ? api.retryReprocessApi(id) : api.cancelReprocessApi(id)) await (retry ? api.retryReprocessApi(id) : api.cancelReprocessApi(id))
if (active && version === user.sessionVersion) await load() if (active && version === user.sessionVersion) await load()
} }
watch(range, () => { watch([range, modes], () => {
preview.value = null preview.value = null
}) })
watch(page, load) watch(page, load)
watch(() => user.sessionVersion, () => { watch(() => user.sessionVersion, () => {
batchSequence++
sequence++ sequence++
detailSequence++ detailSequence++
operation++ operation++
@@ -98,6 +127,10 @@ watch(() => user.sessionVersion, () => {
detailError.value = false detailError.value = false
items.value = [] items.value = []
jobs.value = [] jobs.value = []
batch.value = null
batchId.value = 0
batchLoading.value = false
batchError.value = false
detail.value = null detail.value = null
preview.value = null preview.value = null
opened.value = false opened.value = false
@@ -114,6 +147,7 @@ onMounted(() => {
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
active = false active = false
batchSequence++
sequence++ sequence++
detailSequence++ detailSequence++
clearInterval(timer) clearInterval(timer)
@@ -144,20 +178,43 @@ defineExpose({ open })
</div> </div>
<el-pagination v-model:current-page="page" :total="total" :page-size="20" layout="prev, pager, next" /> <el-pagination v-model:current-page="page" :total="total" :page-size="20" layout="prev, pager, next" />
<details class="mt-4"> <details class="mt-4">
<summary>按时间范围重整理新闻</summary> <summary>AI 历史回补</summary>
<p>按新闻发布时间选择范围,使用现存最新修订。不会还原历史时点;共享包和事件可能同时更新。AI 暂停时任务等待。</p> <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-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> </el-button>
<template v-if="preview"> <template v-if="preview">
<p>{{ preview.articles }} 篇新闻 · {{ preview.packages }} 个包 · {{ preview.events }} 个事件</p> <p>范围共 {{ preview.total }} 篇 · 未完成 {{ preview.missing }} · 已研判 {{ preview.review }} · 本次 {{ preview.articles }} 篇新闻 · {{ preview.packages }} 个包 · {{ preview.events }} 个事件</p>
<el-button :loading="busy" @click="start"> <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> </el-button>
</template> </template>
<div v-for="job in jobs" :key="job.id" class="my-2"> <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 v-if="['pending', 'running'].includes(job.state)" link @click="action(job.id, false)">
取消 取消
</el-button> </el-button>
@@ -168,6 +225,17 @@ defineExpose({ open })
{{ ({ grouping: "新闻初筛", analysis: "包研判", events: "事件时间线", outlook: "阅读编排" })[failure.stage] }} #{{ failure.id }}:{{ failure.error }} {{ ({ grouping: "新闻初筛", analysis: "包研判", events: "事件时间线", outlook: "阅读编排" })[failure.stage] }} #{{ failure.id }}:{{ failure.error }}
</p> </p>
</div> </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> </details>
</el-card> </el-card>
<el-drawer v-model="opened" title="事件发展时间线" size="min(760px, 100vw)"> <el-drawer v-model="opened" title="事件发展时间线" size="min(760px, 100vw)">
+2
View File
@@ -9,6 +9,7 @@ import * as api from "@/common/apis/risk"
import { listProviderModelsApi, listProvidersApi } from "@/common/apis/settings" import { listProviderModelsApi, listProvidersApi } from "@/common/apis/settings"
import { useUserStore } from "@/pinia/stores/user" import { useUserStore } from "@/pinia/stores/user"
import AiQueue from "./components/AiQueue.vue"
import EventResearch from "./components/EventResearch.vue" import EventResearch from "./components/EventResearch.vue"
const eventResearch = ref<InstanceType<typeof EventResearch> | null>(null) const eventResearch = ref<InstanceType<typeof EventResearch> | null>(null)
@@ -257,6 +258,7 @@ onBeforeUnmount(() => {
<template> <template>
<div class="mx-auto max-w-7xl flex flex-col gap-4"> <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 flex-wrap items-center gap-3">
<div class="flex-1"> <div class="flex-1">
<h1 class="m-0 text-xl font-semibold"> <h1 class="m-0 text-xl font-semibold">
+168
View File
@@ -882,3 +882,171 @@ test("新闻并发独立保存保留模型草稿,重复点击和旧会话响
await current await current
assert.equal(page.concurrencySaving.value, false) 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)
})