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

+42 -9
View File
@@ -1,7 +1,7 @@
"""Authenticated news risk research endpoints."""
from datetime import UTC, datetime, timedelta
from enum import IntEnum
from typing import Annotated
from typing import Annotated, Literal
from fastapi import APIRouter, Body, Depends, HTTPException, Query
from pydantic import BaseModel, Field, model_validator
from sqlalchemy import select, func, text
@@ -195,7 +195,9 @@ async def overview(session: DB, hours: RiskWindow = RiskWindow.DAY):
if runtime: concurrency.update(json.loads(runtime))
from alphapulse.models.risk import ReprocessJob
jobs = (await session.scalars(select(ReprocessJob).where(ReprocessJob.state.in_(['pending', 'running'])))).all()
from alphapulse.models.risk import ReprocessItem
historical_pending = sum(i['state'] == 'pending' for job in jobs for i in job.items)
historical_pending += await session.scalar(select(func.count()).select_from(ReprocessItem).where(ReprocessItem.job_id.in_([j.id for j in jobs if j.settings.get('request')]), ReprocessItem.state=='pending')) or 0
coverage_data = await coverage(session)
concurrency['queued'] = historical_pending + sum(stage2.get(k, 0) for k in ('pending',)) + event_states.get('pending', 0) + coverage_data['pending']
return success({'concurrency': concurrency, 'event_states': event_states, 'stage2': stage2, 'stage3': stage3, 'as_of': now, 'counts': counts, 'coverage': coverage_data, 'scenario': scenario, 'briefing': snapshot.result if snapshot else None,
@@ -372,11 +374,13 @@ async def event_timeline(identifier: int, session: DB):
class ReprocessIn(BaseModel):
modes: list[Literal['missing','review']] = Field(min_length=1,max_length=2)
start: datetime
end: datetime
@model_validator(mode='after')
def range_valid(self):
if len(set(self.modes)) != len(self.modes): raise ValueError('回补选项不能重复')
if self.start.tzinfo is None or self.end.tzinfo is None or self.start >= self.end:
raise ValueError('请提供带时区的有效起止时间')
return self
@@ -384,15 +388,19 @@ class ReprocessIn(BaseModel):
@router.post('/reprocess/preview')
async def reprocess_preview(body: ReprocessIn, session: DB):
from alphapulse.news_risk.reprocess import preview
counts, _ = await preview(session, body.start, body.end)
return success(counts)
from alphapulse.news_risk.backfill import preview
try: return success(await preview(session,body.start,body.end,body.modes))
except ValueError as error: raise HTTPException(409,str(error)) from error
class ReprocessStart(BaseModel):
preview_id: str = Field(min_length=20,max_length=80,pattern='^[A-Za-z0-9_-]+$')
@router.post('/reprocess', status_code=202)
async def reprocess_start(body: ReprocessIn, session: DB):
from alphapulse.news_risk.reprocess import start_job
try: result = await start_job(session, body.start, body.end)
async def reprocess_start(body: ReprocessStart, session: DB):
from alphapulse.news_risk.backfill import start
try: result = await start(session, body.preview_id)
except ValueError as error: raise HTTPException(409, str(error)) from error
return success(result)
@@ -401,8 +409,8 @@ async def reprocess_start(body: ReprocessIn, session: DB):
async def reprocess_list(session: DB):
from alphapulse.models.risk import ReprocessJob
rows = (await session.scalars(select(ReprocessJob).order_by(ReprocessJob.id.desc()).limit(20))).all()
return success([{'id': r.id, 'state': r.state, 'total': len(r.items), 'completed': r.position, 'failures': r.failures,
'created_at': r.created_at} for r in rows])
from alphapulse.news_risk.queue import job_out
return success([await job_out(session,r) for r in rows])
@router.post('/reprocess/{identifier}/cancel')
@@ -423,6 +431,10 @@ async def reprocess_retry(identifier: int, session: DB):
if job is None or job.state != 'failed': raise HTTPException(409, '仅可重试失败任务')
if await session.scalar(select(ReprocessJob.id).where(ReprocessJob.state.in_(['pending', 'running'])).limit(1)):
raise HTTPException(409, '已有重整理任务')
if job.settings.get('request'):
from alphapulse.models.risk import ReprocessItem
from sqlalchemy import update
await session.execute(update(ReprocessItem).where(ReprocessItem.job_id==job.id,ReprocessItem.state=='failed').values(state='pending',error=None))
outlook_ids = {f['id'] for f in job.failures if f['stage'] == 'outlook'}
snapshots = (await session.scalars(select(RiskSnapshot).where(RiskSnapshot.id.in_(outlook_ids), RiskSnapshot.state == 'failed',
RiskSnapshot.quotes['job_id'].as_integer() == job.id))).all()
@@ -442,3 +454,24 @@ async def reprocess_retry(identifier: int, session: DB):
job.position = len(job.items)
job.failures = []; job.state = 'running'
await session.commit(); return success(None)
@router.get('/queue')
async def queue(session: DB, state: Literal['running','queued','waiting','failed','done'] | None = None,
stage: Literal['grouping','analysis','events','outlook','backfill'] | None = None,
origin: Literal['history','realtime'] | None = None, job_id: int | None = Query(None,gt=0),
page: int = Query(1,ge=1), limit: int = Query(20,ge=1,le=100)):
from alphapulse.news_risk.queue import listing
return success(await listing(session,state,stage,origin,job_id,page,limit))
@router.get('/reprocess/{identifier}')
async def reprocess_detail(identifier: int, session: DB, page: int = Query(1,ge=1), limit: int = Query(20,ge=1,le=100)):
from alphapulse.models.risk import ReprocessJob, ReprocessItem
from alphapulse.news_risk.queue import job_out
job=await session.get(ReprocessJob,identifier)
if job is None: raise HTTPException(404,'回补批次不存在')
rows=(await session.execute(select(ReprocessItem,NewsItem.title,NewsItem.url).join(NewsItem).where(
ReprocessItem.job_id==identifier).order_by(ReprocessItem.id).offset((page-1)*limit).limit(limit))).all()
return success({**await job_out(session,job),'items':[{'id':i.id,'news_id':i.news_id,'title':title,'url':url,
'mode':i.mode,'state':i.state,'error':i.error} for i,title,url in rows]})
+24
View File
@@ -224,3 +224,27 @@ class EventLink(Base):
event_id: Mapped[int] = mapped_column(ForeignKey('news_ai_events.id'), primary_key=True)
reason: Mapped[str] = mapped_column(Text)
package_revision: Mapped[int] = mapped_column(Integer)
class ReprocessItem(Base):
__tablename__ = 'news_ai_reprocess_items'
__table_args__ = (UniqueConstraint('job_id', 'news_id'),)
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
job_id: Mapped[int] = mapped_column(ForeignKey('news_ai_reprocess_jobs.id'), index=True)
news_id: Mapped[int] = mapped_column(ForeignKey('news_items.id'), index=True)
content_hash: Mapped[str] = mapped_column(String(64))
mode: Mapped[str] = mapped_column(String(16))
state: Mapped[str] = mapped_column(String(16), default='pending', index=True)
error: Mapped[str | None] = mapped_column(Text)
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class ReprocessTarget(Base):
__tablename__ = 'news_ai_reprocess_targets'
__table_args__ = (UniqueConstraint('job_id', 'kind', 'object_id'),)
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
job_id: Mapped[int] = mapped_column(ForeignKey('news_ai_reprocess_jobs.id'), index=True)
kind: Mapped[str] = mapped_column(String(16))
object_id: Mapped[int] = mapped_column(BigInteger)
revision: Mapped[int] = mapped_column(Integer)
@@ -0,0 +1,171 @@
"""Snapshot-selected historical completion and explicit full rechecks."""
import json
import secrets
from datetime import UTC, datetime
from sqlalchemy import select, func, text, exists, or_, any_, literal, BigInteger
from sqlalchemy.dialects.postgresql import insert, ARRAY
from alphapulse.core.db import SessionLocal
from alphapulse.core.redis import redis
from alphapulse.models import NewsItem
from alphapulse.models.risk import (RiskSettings, ReprocessJob, ReprocessItem, ReprocessTarget,
ArticleAnalysis, SummaryArticle, NewsSummary, NewsEvent, EventMember)
from alphapulse.news_risk.reprocess import capture
async def classification(session, news):
article = await session.scalar(select(ArticleAnalysis).where(ArticleAnalysis.news_id == news.id, ArticleAnalysis.content_hash == news.content_hash))
packages = [] if article is None else (await session.scalars(select(NewsSummary).join(SummaryArticle).where(
SummaryArticle.analysis_id == article.id, SummaryArticle.active.is_(True), NewsSummary.replacement_ids == [], NewsSummary.state != 'withdrawn'))).all()
complete = bool(article and article.result and packages and all(p.result and p.published_revision == p.revision for p in packages))
return complete, article, packages
async def preview(session, start, end, modes):
linked = select(NewsSummary.id).join(SummaryArticle).where(
SummaryArticle.analysis_id == ArticleAnalysis.id, SummaryArticle.active.is_(True),
NewsSummary.replacement_ids == [], NewsSummary.state != 'withdrawn')
incomplete = linked.where(or_(NewsSummary.result.is_(None), func.jsonb_typeof(NewsSummary.result) == 'null',
NewsSummary.published_revision != NewsSummary.revision))
complete = exists(select(ArticleAnalysis.id).where(ArticleAnalysis.news_id == NewsItem.id,
ArticleAnalysis.content_hash == NewsItem.content_hash, ArticleAnalysis.result.is_not(None),
func.jsonb_typeof(ArticleAnalysis.result) != 'null', exists(linked), ~exists(incomplete)))
rows = (await session.execute(select(NewsItem.id, NewsItem.content_hash, complete.label('complete')).where(
NewsItem.published_at >= start, NewsItem.published_at < end, NewsItem.published_at <= datetime.now(UTC))
.order_by(NewsItem.published_at, NewsItem.id))).all()
counts = {'total':len(rows), 'missing':0, 'review':0}
items = []
for row in rows:
mode = 'review' if row.complete else 'missing'
counts[mode] += 1
if mode in modes: items.append({'id':row.id, 'hash':row.content_hash, 'mode':mode})
packages = select(NewsSummary.id).join(SummaryArticle).join(ArticleAnalysis).join(NewsItem).where(
NewsItem.id == any_(literal([i['id'] for i in items],type_=ARRAY(BigInteger))), ArticleAnalysis.content_hash == NewsItem.content_hash,
SummaryArticle.active.is_(True), NewsSummary.replacement_ids == [], NewsSummary.state != 'withdrawn').distinct()
counts['articles'] = len(items)
counts['packages'] = await session.scalar(select(func.count()).select_from(packages.subquery()))
counts['events'] = await session.scalar(select(func.count(func.distinct(EventMember.event_id))).where(EventMember.package_id.in_(packages)))
settings = await session.get(RiskSettings, 1)
if settings is None: raise ValueError('请先配置新闻AI')
config = await capture(session, settings)
identifier = secrets.token_urlsafe(24)
value = {'items':items, 'config':config, 'request':{'start':start.isoformat(),'end':end.isoformat(),'modes':modes}}
await redis.set('ap:risk:preview:'+identifier, json.dumps(value, default=str), ex=900)
return {**counts, 'preview_id':identifier, 'models':{k:{field:v['provider'][field] for field in ('model','reasoning_effort')} for k,v in config['configurations'].items()}}
async def start(session, identifier):
raw = await redis.get('ap:risk:preview:'+identifier)
if not raw: raise ValueError('预览已过期,请重新预览')
value = json.loads(raw)
await session.execute(text('SELECT pg_advisory_xact_lock(20261101, 2)'))
if await session.scalar(select(ReprocessJob.id).where(ReprocessJob.state.in_(['pending','running']))):
raise ValueError('已有回补任务')
settings = await session.get(RiskSettings, 1)
if settings is None or await capture(session, settings) != value['config']:
raise ValueError('模型或Skill配置已变化,请重新预览')
if not value['items']: raise ValueError('没有符合选择条件的新闻')
job = ReprocessJob(state='pending', items=[], position=0, failures=[], settings=value['config'])
session.add(job); await session.flush()
job.settings = {**job.settings, 'job_id':job.id, 'request':value['request']}
session.add_all([ReprocessItem(job_id=job.id, news_id=i['id'], content_hash=i['hash'], mode=i['mode'], state='pending') for i in value['items']])
await session.commit()
await redis.delete('ap:risk:preview:'+identifier)
return {'id':job.id}
async def target(session, job_id, kind, obj):
await session.execute(insert(ReprocessTarget).values(job_id=job_id, kind=kind, object_id=obj.id, revision=obj.revision)
.on_conflict_do_update(index_elements=['job_id','kind','object_id'], set_={'revision':obj.revision}))
async def process(identifier):
from alphapulse.news_risk import worker
async with SessionLocal() as session:
item = await session.get(ReprocessItem, identifier, with_for_update=True)
if item is None or item.state != 'pending': return
job = await session.get(ReprocessJob, item.job_id, with_for_update=True)
settings = await session.get(RiskSettings, 1)
if job.state not in ('pending','running') or not settings.enabled: return
job_id, news_id, mode, content_hash = item.job_id, item.news_id, item.mode, item.content_hash
item.state = 'processing'; item.error = None; item.finished_at = None; item.started_at = datetime.now(UTC); job.state = 'running'
await session.commit()
try:
news = await session.get(NewsItem, item.news_id)
if news is None or news.content_hash != item.content_hash: raise ValueError('原文版本变化,请重新预览范围')
complete, article, packages = await classification(session, news)
if mode == 'missing' and complete:
item.state = 'skipped'; item.finished_at = datetime.now(UTC); await session.commit(); return
needs_grouping = mode == 'review' or not article or not article.result or not packages
if article is None:
article = ArticleAnalysis(news_id=news.id, content_hash=news.content_hash, state='reprocess', input={
'title':news.title,'summary':news.summary,'content':news.content_text,'url':news.url,'source':news.source_code,
'source_tags':news.tags,'revision_no':news.revision_no,'published_at':news.published_at.isoformat(),
'progress_at':max(news.published_at,news.source_updated_at or news.published_at).isoformat()})
session.add(article); await session.flush()
article_id = article.id
await session.commit()
if needs_grouping:
outcome = await worker.analyze(article_id, job.settings)
if outcome == 'deferred':
item.state = 'pending'; await session.commit(); return
if outcome != 'done':
row = await session.get(ArticleAnalysis, article_id, populate_existing=True)
raise ValueError(row.error or '原文已变化,分包未完成')
await session.rollback()
job = await session.get(ReprocessJob, job_id, populate_existing=True)
news = await session.get(NewsItem,news_id,populate_existing=True)
if news.content_hash != content_hash: raise ValueError('原文版本变化,请重新预览范围')
_, _, packages = await classification(session,news)
for package in packages:
if package.result and package.published_revision == package.revision and mode == 'missing': continue
previous = await session.scalar(select(ReprocessTarget).where(ReprocessTarget.job_id==job.id,
ReprocessTarget.kind=='analysis',ReprocessTarget.object_id==package.id))
if previous is None or package.revision > previous.revision:
if package.state not in ('pending','processing'):
worker.schedule(package,datetime.now(UTC),True)
package.review_config = job.settings
await target(session,job.id,'analysis',package)
item = await session.get(ReprocessItem, identifier, populate_existing=True)
item.state = 'done'; item.finished_at = datetime.now(UTC)
await session.commit()
except Exception as error:
await session.rollback()
item = await session.get(ReprocessItem,identifier)
item.state='failed'; item.error=str(error)[:1500]; item.finished_at=datetime.now(UTC)
await session.commit()
async def reconcile(session, job):
job = await session.get(ReprocessJob,job.id,with_for_update={"key_share":True},populate_existing=True)
if job.state not in ('pending','running'): return
if await session.scalar(select(ReprocessItem.id).where(ReprocessItem.job_id==job.id,ReprocessItem.state.in_(['pending','processing'])).limit(1)): return
items = (await session.scalars(select(ReprocessItem).where(ReprocessItem.job_id==job.id,ReprocessItem.state=='failed'))).all()
targets = (await session.scalars(select(ReprocessTarget).where(ReprocessTarget.job_id==job.id))).all()
failures = [{'stage':'grouping','id':i.news_id,'error':i.error} for i in items if i.state=='failed']
waiting = False
for row in targets:
obj = await session.get(NewsSummary if row.kind=='analysis' else NewsEvent,row.object_id)
if obj is None: continue
if row.kind=='analysis' and (obj.replacement_ids or obj.state=='withdrawn'):
for replacement in obj.replacement_ids:
linked=await session.get(NewsSummary,replacement)
await target(session,job.id,'analysis',linked)
if obj.replacement_ids:
await session.delete(row); waiting=True
continue
if obj.state=='retired': continue
if obj.state=='failed': failures.append({'stage':row.kind,'id':obj.id,'error':obj.error})
elif row.kind == 'events' and await session.scalar(select(NewsSummary.id).join(EventMember).where(
EventMember.event_id == obj.id, NewsSummary.state == 'failed', NewsSummary.replacement_ids == []).limit(1)):
failures.append({'stage':'events','id':obj.id,'error':'组成包研判失败,请先重试失败包'})
elif obj.state not in ('done','retired') or obj.published_revision < row.revision: waiting=True
if row.kind=='analysis':
membership=await session.get(EventMember,obj.id)
if membership:
event=await session.get(NewsEvent,membership.event_id)
await target(session,job.id,'events',event)
if not any(t.kind=='events' and t.object_id==event.id for t in targets): waiting=True
if event.state=='failed': failures.append({'stage':'events','id':event.id,'error':event.error})
if waiting: await session.commit(); return
job.failures=list({(f['stage'],f['id']):f for f in failures}.values()); job.state='failed' if failures else 'done'
await session.commit()
@@ -67,6 +67,9 @@ async def apply_correction(session, proposal: Correction, current_id, researched
link = SummaryArticle(summary_id=package.id, analysis_id=assignment.analysis_id); session.add(link)
link.active = True; link.unit = {'fact':replacement.fact,'evidence':assignment.evidence,'correction':True}
schedule(package, now, True); outputs.append(package.id)
if package.review_config and package.review_config.get('request'):
from alphapulse.news_risk.backfill import target
await target(session, package.review_config['job_id'], 'analysis', package)
for package in packages:
if package.id not in outputs:
package.replacement_ids = outputs; package.state = 'superseded'
@@ -7,6 +7,7 @@ from alphapulse.core.db import SessionLocal
from alphapulse.news_risk.pipeline import configuration
from alphapulse.news_risk.contracts import PackageTimePoint
from alphapulse.news_risk.service import complete
from alphapulse.news_risk.progress import step
class TimelinePoint(PackageTimePoint):
@@ -54,6 +55,9 @@ async def dirty_event(session, package_id, review_config=None):
event.review_config = review_config
event.state = 'pending'
event.due_at = datetime.now(UTC)+timedelta(seconds=60)
if review_config and review_config.get('request'):
from alphapulse.news_risk.backfill import target
await target(session, review_config['job_id'], 'events', event)
async def associate(session, package, result, researched_ids, researched_events=None):
@@ -89,6 +93,9 @@ async def associate(session, package, result, researched_ids, researched_events=
event.review_config = package.review_config
event.state = 'pending'; event.due_at = datetime.now(UTC)+timedelta(seconds=60)
event.updated_at = max(p.updated_at for p in members)
if package.review_config and package.review_config.get('request'):
from alphapulse.news_risk.backfill import target
await target(session, package.review_config['job_id'], 'events', event)
async def organize(target_id=None):
@@ -122,7 +129,9 @@ async def organize(target_id=None):
version_id, event_id = version.id, event.id
try:
if config_error: raise ValueError(config_error)
step('梳理事件时间线')
result, usage = await complete(provider, system, {'task': 'event_timeline', 'previous': event.result, 'packages': packages}, EventResult)
step('核对时间线与证据')
validate_timeline(result, packages)
version.result = result.model_dump(mode='json'); version.usage = usage; version.state = 'done'
from alphapulse.news_risk.worker import publication_lock
@@ -0,0 +1,27 @@
"""Best-effort actual execution steps, scoped to background news tasks."""
from contextvars import ContextVar
from datetime import UTC, datetime
import logging
current = ContextVar('news_ai_progress', default=None)
active = {}
logger = logging.getLogger(__name__)
def step(phase, **details):
record = current.get()
if record is not None:
record.update(phase=phase, updated_at=datetime.now(UTC).isoformat(), **details)
async def execute(key, action):
identity = str(key)
record = {'key': identity, 'stage': key[0], 'object_id': key[1], 'phase': '准备输入',
'started_at': datetime.now(UTC).isoformat(), 'updated_at': datetime.now(UTC).isoformat()}
active[identity] = record
token = current.set(record)
try:
return await action()
finally:
current.reset(token)
active.pop(identity, None)
+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."""
from sqlalchemy import select, text
from sqlalchemy import select, text, update
from alphapulse.core.db import SessionLocal
from alphapulse.models import NewsItem
from alphapulse.models.risk import RiskSettings, ReprocessJob, ArticleAnalysis, NewsSummary, EventMember
from alphapulse.models.risk import RiskSettings, ReprocessJob, ReprocessItem, ArticleAnalysis, NewsSummary, EventMember
from alphapulse.news_risk.pipeline import configuration
@@ -21,6 +21,7 @@ async def capture(session, settings):
from alphapulse.api.risk import config_out
value = config_out(settings)
value.pop('start_at')
value.pop('enabled', None)
value.pop('max_concurrency', None)
configurations = {}
for stage in ('grouping', 'analysis', 'outlook'):
@@ -80,6 +81,11 @@ async def process_job(item_index=None):
if item.get('state') not in (None, 'pending'): return
job.items = [{**entry, 'state': 'processing'} if n == index else entry for n, entry in enumerate(job.items)]
job_id = job.id
durable = await session.scalar(select(ReprocessItem).where(ReprocessItem.job_id==job.id,ReprocessItem.news_id==item['id']))
if durable:
durable.state='processing'
from alphapulse.news_risk.progress import step
step('历史新闻分包',stage='backfill',object_id=durable.id)
try:
for stage in ('grouping', 'analysis', 'outlook'):
await frozen_configuration(session, job.settings, stage)
@@ -98,6 +104,7 @@ async def process_job(item_index=None):
if outcome == 'deferred':
job = await session.get(ReprocessJob, job_id, populate_existing=True, with_for_update=True)
job.items = [{**entry, 'state': 'pending'} if n == index else entry for n, entry in enumerate(job.items)]
await session.execute(update(ReprocessItem).where(ReprocessItem.job_id==job_id,ReprocessItem.news_id==item['id']).values(state='pending'))
await session.commit(); return
row = await session.get(ArticleAnalysis, row_id, populate_existing=True)
if outcome != 'done' or row.state != 'done' or row.error: raise ValueError(row.error or '原文已变化')
@@ -111,6 +118,7 @@ async def process_job(item_index=None):
job = await session.get(ReprocessJob, job_id, populate_existing=True, with_for_update=True)
job.items = [{**entry, 'state': terminal} if n == index else entry for n, entry in enumerate(job.items)]
job.position = sum(entry.get('state') in ('done', 'failed') for entry in job.items)
await session.execute(update(ReprocessItem).where(ReprocessItem.job_id==job_id,ReprocessItem.news_id==item['id']).values(state=terminal))
await session.commit()
@@ -124,6 +124,8 @@ class Research:
async def tool(self, name, arguments):
if name not in TOOLS: raise ValueError('未知研究工具')
from alphapulse.news_risk.progress import step
step('查询资料', tool=name)
args = TOOLS[name][0].model_validate(arguments)
key = name+json.dumps(arguments, sort_keys=True)
if key in self.cache: return self.cache[key]
@@ -196,7 +198,9 @@ class Research:
submit = llm.ToolSpec('submit', '提交完整研判,引用实际读取来源', schema.model_json_schema())
messages = [{'role': 'user', 'text': json.dumps(payload, ensure_ascii=False)}]
usages = []
from alphapulse.news_risk.progress import step, current
for round_no in range(9):
if current.get() is not None: step('模型研判', round=round_no+1, model=provider.model, reasoning_effort=provider.reasoning_effort)
if not self.fits_messages(provider, system, messages, schema):
raise ValueError('研究上下文不足,请增大上下文配置;证据未截断')
completion = None
+39 -6
View File
@@ -7,7 +7,7 @@ from datetime import UTC, datetime
from sqlalchemy import select, text, func
from alphapulse.core.db import SessionLocal
from alphapulse.core.redis import redis
from alphapulse.models.risk import RiskSettings, NewsSummary, NewsEvent, ArticleAnalysis, ReprocessJob
from alphapulse.models.risk import RiskSettings, NewsSummary, NewsEvent, ArticleAnalysis, ReprocessJob, ReprocessItem
logger = logging.getLogger(__name__)
GATE = 20261102
@@ -18,7 +18,8 @@ class Pool:
self.tasks = {}
def start(self, key, action):
self.tasks[asyncio.create_task(action())] = key
from alphapulse.news_risk.progress import execute
self.tasks[asyncio.create_task(execute(key, action))] = key
async def reap(self, timeout=1):
if not self.tasks:
@@ -50,6 +51,9 @@ async def candidate(active, cursor, allow_outlook=True):
await worker.enqueue(session, settings)
history_news = []
for kind, identity in active:
if kind == 'backfill':
item = await session.get(ReprocessItem, identity)
history_news.append(item.news_id)
if kind == 'history' and identity[1] >= 0:
job = await session.get(ReprocessJob, identity[0])
history_news.append(job.items[identity[1]]['id'])
@@ -61,7 +65,12 @@ async def candidate(active, cursor, allow_outlook=True):
busy = [key[1] for key in active if key[0] == stage]
if stage == 'outlook':
if not busy and allow_outlook:
return (stage, 0), worker.outlook, (cursor + offset + 1) % 4
try:
ready = await worker.outlook(prepare_only=True)
except ValueError:
logger.exception('阅读编排配置检查失败')
ready = False
if ready: return (stage, 0), worker.outlook, (cursor + offset + 1) % 4
continue
model = {'grouping': ArticleAnalysis, 'analysis': NewsSummary, 'events': NewsEvent}[stage]
query = select(model.id).where(model.state == 'pending', model.id.not_in(busy))
@@ -81,17 +90,29 @@ async def candidate(active, cursor, allow_outlook=True):
action = {'grouping': worker.analyze, 'analysis': worker.summarize, 'events': events.organize}[stage]
return (stage, identifier), lambda action=action, identifier=identifier: action(identifier), (cursor + offset + 1) % 4
job = await session.scalar(select(ReprocessJob).where(ReprocessJob.state.in_(['pending', 'running'])).order_by(ReprocessJob.id).limit(1))
if job and job.settings.get('request'):
from alphapulse.news_risk.backfill import process
busy = [k[1] for k in active if k[0]=='backfill']
candidates = (await session.scalars(select(ReprocessItem).where(ReprocessItem.job_id==job.id,
ReprocessItem.state=='pending',ReprocessItem.id.not_in(busy),ReprocessItem.news_id.not_in(history_news))
.order_by(ReprocessItem.id).limit(len(active)+1))).all()
for item in candidates:
article_id = await session.scalar(select(ArticleAnalysis.id).where(ArticleAnalysis.news_id==item.news_id,
ArticleAnalysis.content_hash==item.content_hash))
if ('grouping',article_id) not in active:
return ('backfill',item.id), lambda identifier=item.id: process(identifier), cursor
return None
if job:
for index, item in enumerate(job.items):
key = ('history', (job.id, index))
if item.get('state', 'pending') == 'pending' and key not in active:
if item.get('state', 'pending') == 'pending' and key not in active and item['id'] not in history_news:
# Real-time and historical grouping share the article identity.
row_id = await session.scalar(select(ArticleAnalysis.id).where(ArticleAnalysis.news_id == item['id'], ArticleAnalysis.content_hash == item['hash']))
if row_id is not None and ('grouping', row_id) in active:
continue
return key, lambda index=index: reprocess.process_job(index), cursor
if not any(k[0] == 'history' for k in active):
return ('history', (job.id, -1)), lambda: reprocess.process_job(-1), cursor
await reprocess.process_job(-1)
return None
@@ -103,6 +124,11 @@ async def run(lock):
# Interrupted historical items are reclaimed independently of completion order.
async with SessionLocal() as session:
jobs = (await session.scalars(select(ReprocessJob).where(ReprocessJob.state.in_(['pending', 'running'])))).all()
from sqlalchemy import update
interrupted = (await session.scalars(select(ReprocessItem).where(ReprocessItem.state=='processing'))).all()
for item in interrupted:
await session.execute(update(ArticleAnalysis).where(ArticleAnalysis.news_id==item.news_id, ArticleAnalysis.content_hash==item.content_hash, ArticleAnalysis.state=='pending').values(state='reprocess'))
await session.execute(update(ReprocessItem).where(ReprocessItem.state=='processing').values(state='pending'))
for job in jobs:
for item in job.items:
if item['state'] == 'processing':
@@ -136,9 +162,16 @@ async def run(lock):
async with SessionLocal() as session:
settings = await session.get(RiskSettings, 1)
limit, enabled = settings.max_concurrency, settings.enabled
async with SessionLocal() as session:
from alphapulse.news_risk.backfill import reconcile
jobs = (await session.scalars(select(ReprocessJob).where(ReprocessJob.state.in_(['pending','running'])))).all()
for job in jobs:
if job.settings.get('request'): await reconcile(session,job)
now = datetime.now(UTC).isoformat()
counts = {stage: sum(k[0] == stage for k in pool.tasks.values()) for stage in ('grouping', 'analysis', 'events', 'outlook', 'history')}
counts = {stage: sum(k[0] == stage for k in pool.tasks.values()) for stage in ('grouping', 'analysis', 'events', 'outlook', 'history', 'backfill')}
try:
from alphapulse.news_risk.progress import active
await redis.set('ap:risk:active', json.dumps(list(active.values())), ex=15)
await redis.set('ap:risk:concurrency', json.dumps({'running': len(pool.tasks), 'stages': counts, 'updated_at': now}), ex=15)
for stage in ('', 'analysis:', 'events:', 'outlook:', 'reprocess:'):
await redis.set('ap:risk:' + stage + 'heartbeat', now, ex=15)
@@ -76,6 +76,8 @@ class OutputTruncated(ValueError):
async def complete(provider: AiProvider, system: str, payload: dict, schema: type[BaseModel]):
from alphapulse.news_risk.progress import step, current
if current.get() is not None: step('等待模型响应', model=provider.model, reasoning_effort=provider.reasoning_effort)
text = json.dumps(payload, ensure_ascii=False)
if not fits_context(provider, system, payload, schema):
raise ValueError('新闻输入超出模型上下文;未截断或发送,请调整模型配置')
+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.research import Research
from alphapulse.news_risk import briefing
from alphapulse.news_risk.progress import step
logger = logging.getLogger(__name__)
publication_lock = asyncio.Lock()
@@ -73,6 +74,7 @@ async def _analyze(target_id=None, frozen=None):
row_id = row.id
row_input = row.input
try:
step('提取与归并新闻')
payload, usages = await grouping_payload(provider, system, {'id': row.id, **row.input}, candidate_inputs)
for attempt in range(2):
try:
@@ -87,6 +89,7 @@ async def _analyze(target_id=None, frozen=None):
if attempt:
raise
payload = pack_candidates(provider, system, {**payload, 'correction': str(error)}, payload['candidates'])
step('校验并保存分包结果')
async with publication_lock:
# Check the article again before changing event membership.
current = await session.get(NewsItem, row.news_id, populate_existing=True)
@@ -133,6 +136,9 @@ async def _analyze(target_id=None, frozen=None):
if event.id in changed:
event.review_config = frozen
schedule(event, now, any(m.urgent for m in result.packages))
if frozen and frozen.get('request'):
from alphapulse.news_risk.backfill import target
await target(session, frozen['job_id'], 'analysis', event)
session.add(GroupingVersion(analysis_id=row.id, input=row.input, result=result.model_dump(mode='json'), rules=rules, state='done'))
row.result = result.model_dump(mode='json'); row.usage = {'attempts': usages}; row.state = 'done'
row.error = None; row.finished_at = now
@@ -201,6 +207,7 @@ async def summarize(target_id=None):
try:
if config_error: raise ValueError(config_error)
result, usage = await summarize_articles(provider, system, articles, package, research)
step('校验并保存包研判')
async with publication_lock:
event = await session.get(NewsSummary, event_id, populate_existing=True, with_for_update=True)
if event.replacement_ids or event.state == 'withdrawn':
@@ -240,7 +247,7 @@ async def summarize(target_id=None):
await session.commit(); logger.exception('事件总结失败')
async def outlook():
async def outlook(prepare_only=False):
async with SessionLocal() as session:
settings = await session.get(RiskSettings, 1)
if not settings or not settings.enabled: return
@@ -252,6 +259,7 @@ async def outlook():
inputs = await reading_inputs(session, now-timedelta(hours=72))
from alphapulse.models.risk import ReprocessJob
job = await session.scalar(select(ReprocessJob).where(ReprocessJob.state == 'running').limit(1))
if job and job.settings.get('request'): job = None
if job and job.position < len(job.items):
if requested and requested.quotes.get('job_id') == job.id: return
job = None
@@ -266,7 +274,17 @@ async def outlook():
semantic = briefing.semantic_packages(inputs)
key = briefing.fingerprint(semantic, rules)
important = {p.get('kind', 'package')+':'+str(p['id']): briefing.fingerprint(p, {}) for p in semantic if p['importance_stars'] == 5}
if requested is None and latest and latest.quotes.get('fingerprint') == key and (job is None or latest.quotes.get('job_id') == job.id): return
unchanged = requested is None and latest and latest.quotes.get('fingerprint') == key and (job is None or latest.quotes.get('job_id') == job.id)
if unchanged and (inputs or latest.state == 'done'): return
if not inputs:
# Empty reading windows are durable housekeeping, without an AI slot.
session.add(RiskSnapshot(state='done', model=provider.model,
quotes={'fingerprint':key,'important':{},**({'job_id':job.id} if job else {})},
result={'reading':[],'synthesis':None,'usage':[],'expansion':[],'expansion_usage':[],
'inputs':[],'rules':rules,'as_of':now.isoformat()}))
if requested: requested.quotes = {k:v for k,v in requested.quotes.items() if k != 'retry_requested'}
await session.commit()
return
if latest and requested is None and job is None:
urgent_change = any(latest.quotes.get('important', {}).get(identifier) != value for identifier, value in important.items())
if urgent_change:
@@ -277,8 +295,11 @@ async def outlook():
latest.quotes = {**latest.quotes, 'dirty_since': now.isoformat()}
await session.commit(); return
if (now-datetime.fromisoformat(dirty)).total_seconds() < 900: return
if prepare_only: return True
step('整理72小时阅读与趋势')
snapshot = RiskSnapshot(state='processing', model=provider.model, quotes={'fingerprint': key, 'important': important, **({'job_id': job.id} if job else {})})
session.add(snapshot); await session.commit()
step('生成阅读与趋势', object_id=snapshot.id)
try:
main = [p for p in inputs if p['importance_stars'] >= 3]
low = [p for p in inputs if p['importance_stars'] < 3]
@@ -294,6 +315,7 @@ async def outlook():
if len(extra) == 3: break
snapshot.result = {**result, 'expansion': extra, 'inputs': inputs, 'rules': rules,
'expansion_usage': expansion.get('usage', []), 'as_of': now.isoformat()}
step('保存阅读与趋势')
snapshot.state = 'done'
except Exception as error:
snapshot.state = 'failed'; snapshot.error = str(error)[:2000]