feat: add historical news AI backfill and visible task queue
Freeze date-range previews and model settings, track per-news progress and downstream targets, and share completion-driven scheduling with realtime analysis. Prevent overlapping retries for the same news, retain batch queue associations, and refresh selected details with session-safe pagination.
This commit is contained in:
1 parent
cbd2618a20
commit
b45981ec30
25 files changed
+1339
-39
No files matched your search
@@ -0,0 +1,171 @@
|
||||
"""Snapshot-selected historical completion and explicit full rechecks."""
|
||||
import json
|
||||
import secrets
|
||||
from datetime import UTC, datetime
|
||||
from sqlalchemy import select, func, text, exists, or_, any_, literal, BigInteger
|
||||
from sqlalchemy.dialects.postgresql import insert, ARRAY
|
||||
from alphapulse.core.db import SessionLocal
|
||||
from alphapulse.core.redis import redis
|
||||
from alphapulse.models import NewsItem
|
||||
from alphapulse.models.risk import (RiskSettings, ReprocessJob, ReprocessItem, ReprocessTarget,
|
||||
ArticleAnalysis, SummaryArticle, NewsSummary, NewsEvent, EventMember)
|
||||
from alphapulse.news_risk.reprocess import capture
|
||||
|
||||
|
||||
async def classification(session, news):
|
||||
article = await session.scalar(select(ArticleAnalysis).where(ArticleAnalysis.news_id == news.id, ArticleAnalysis.content_hash == news.content_hash))
|
||||
packages = [] if article is None else (await session.scalars(select(NewsSummary).join(SummaryArticle).where(
|
||||
SummaryArticle.analysis_id == article.id, SummaryArticle.active.is_(True), NewsSummary.replacement_ids == [], NewsSummary.state != 'withdrawn'))).all()
|
||||
complete = bool(article and article.result and packages and all(p.result and p.published_revision == p.revision for p in packages))
|
||||
return complete, article, packages
|
||||
|
||||
|
||||
async def preview(session, start, end, modes):
|
||||
linked = select(NewsSummary.id).join(SummaryArticle).where(
|
||||
SummaryArticle.analysis_id == ArticleAnalysis.id, SummaryArticle.active.is_(True),
|
||||
NewsSummary.replacement_ids == [], NewsSummary.state != 'withdrawn')
|
||||
incomplete = linked.where(or_(NewsSummary.result.is_(None), func.jsonb_typeof(NewsSummary.result) == 'null',
|
||||
NewsSummary.published_revision != NewsSummary.revision))
|
||||
complete = exists(select(ArticleAnalysis.id).where(ArticleAnalysis.news_id == NewsItem.id,
|
||||
ArticleAnalysis.content_hash == NewsItem.content_hash, ArticleAnalysis.result.is_not(None),
|
||||
func.jsonb_typeof(ArticleAnalysis.result) != 'null', exists(linked), ~exists(incomplete)))
|
||||
rows = (await session.execute(select(NewsItem.id, NewsItem.content_hash, complete.label('complete')).where(
|
||||
NewsItem.published_at >= start, NewsItem.published_at < end, NewsItem.published_at <= datetime.now(UTC))
|
||||
.order_by(NewsItem.published_at, NewsItem.id))).all()
|
||||
counts = {'total':len(rows), 'missing':0, 'review':0}
|
||||
items = []
|
||||
for row in rows:
|
||||
mode = 'review' if row.complete else 'missing'
|
||||
counts[mode] += 1
|
||||
if mode in modes: items.append({'id':row.id, 'hash':row.content_hash, 'mode':mode})
|
||||
packages = select(NewsSummary.id).join(SummaryArticle).join(ArticleAnalysis).join(NewsItem).where(
|
||||
NewsItem.id == any_(literal([i['id'] for i in items],type_=ARRAY(BigInteger))), ArticleAnalysis.content_hash == NewsItem.content_hash,
|
||||
SummaryArticle.active.is_(True), NewsSummary.replacement_ids == [], NewsSummary.state != 'withdrawn').distinct()
|
||||
counts['articles'] = len(items)
|
||||
counts['packages'] = await session.scalar(select(func.count()).select_from(packages.subquery()))
|
||||
counts['events'] = await session.scalar(select(func.count(func.distinct(EventMember.event_id))).where(EventMember.package_id.in_(packages)))
|
||||
settings = await session.get(RiskSettings, 1)
|
||||
if settings is None: raise ValueError('请先配置新闻AI')
|
||||
config = await capture(session, settings)
|
||||
identifier = secrets.token_urlsafe(24)
|
||||
value = {'items':items, 'config':config, 'request':{'start':start.isoformat(),'end':end.isoformat(),'modes':modes}}
|
||||
await redis.set('ap:risk:preview:'+identifier, json.dumps(value, default=str), ex=900)
|
||||
return {**counts, 'preview_id':identifier, 'models':{k:{field:v['provider'][field] for field in ('model','reasoning_effort')} for k,v in config['configurations'].items()}}
|
||||
|
||||
|
||||
async def start(session, identifier):
|
||||
raw = await redis.get('ap:risk:preview:'+identifier)
|
||||
if not raw: raise ValueError('预览已过期,请重新预览')
|
||||
value = json.loads(raw)
|
||||
await session.execute(text('SELECT pg_advisory_xact_lock(20261101, 2)'))
|
||||
if await session.scalar(select(ReprocessJob.id).where(ReprocessJob.state.in_(['pending','running']))):
|
||||
raise ValueError('已有回补任务')
|
||||
settings = await session.get(RiskSettings, 1)
|
||||
if settings is None or await capture(session, settings) != value['config']:
|
||||
raise ValueError('模型或Skill配置已变化,请重新预览')
|
||||
if not value['items']: raise ValueError('没有符合选择条件的新闻')
|
||||
job = ReprocessJob(state='pending', items=[], position=0, failures=[], settings=value['config'])
|
||||
session.add(job); await session.flush()
|
||||
job.settings = {**job.settings, 'job_id':job.id, 'request':value['request']}
|
||||
session.add_all([ReprocessItem(job_id=job.id, news_id=i['id'], content_hash=i['hash'], mode=i['mode'], state='pending') for i in value['items']])
|
||||
await session.commit()
|
||||
await redis.delete('ap:risk:preview:'+identifier)
|
||||
return {'id':job.id}
|
||||
|
||||
|
||||
async def target(session, job_id, kind, obj):
|
||||
await session.execute(insert(ReprocessTarget).values(job_id=job_id, kind=kind, object_id=obj.id, revision=obj.revision)
|
||||
.on_conflict_do_update(index_elements=['job_id','kind','object_id'], set_={'revision':obj.revision}))
|
||||
|
||||
|
||||
async def process(identifier):
|
||||
from alphapulse.news_risk import worker
|
||||
async with SessionLocal() as session:
|
||||
item = await session.get(ReprocessItem, identifier, with_for_update=True)
|
||||
if item is None or item.state != 'pending': return
|
||||
job = await session.get(ReprocessJob, item.job_id, with_for_update=True)
|
||||
settings = await session.get(RiskSettings, 1)
|
||||
if job.state not in ('pending','running') or not settings.enabled: return
|
||||
job_id, news_id, mode, content_hash = item.job_id, item.news_id, item.mode, item.content_hash
|
||||
item.state = 'processing'; item.error = None; item.finished_at = None; item.started_at = datetime.now(UTC); job.state = 'running'
|
||||
await session.commit()
|
||||
try:
|
||||
news = await session.get(NewsItem, item.news_id)
|
||||
if news is None or news.content_hash != item.content_hash: raise ValueError('原文版本变化,请重新预览范围')
|
||||
complete, article, packages = await classification(session, news)
|
||||
if mode == 'missing' and complete:
|
||||
item.state = 'skipped'; item.finished_at = datetime.now(UTC); await session.commit(); return
|
||||
needs_grouping = mode == 'review' or not article or not article.result or not packages
|
||||
if article is None:
|
||||
article = ArticleAnalysis(news_id=news.id, content_hash=news.content_hash, state='reprocess', input={
|
||||
'title':news.title,'summary':news.summary,'content':news.content_text,'url':news.url,'source':news.source_code,
|
||||
'source_tags':news.tags,'revision_no':news.revision_no,'published_at':news.published_at.isoformat(),
|
||||
'progress_at':max(news.published_at,news.source_updated_at or news.published_at).isoformat()})
|
||||
session.add(article); await session.flush()
|
||||
article_id = article.id
|
||||
await session.commit()
|
||||
if needs_grouping:
|
||||
outcome = await worker.analyze(article_id, job.settings)
|
||||
if outcome == 'deferred':
|
||||
item.state = 'pending'; await session.commit(); return
|
||||
if outcome != 'done':
|
||||
row = await session.get(ArticleAnalysis, article_id, populate_existing=True)
|
||||
raise ValueError(row.error or '原文已变化,分包未完成')
|
||||
await session.rollback()
|
||||
job = await session.get(ReprocessJob, job_id, populate_existing=True)
|
||||
news = await session.get(NewsItem,news_id,populate_existing=True)
|
||||
if news.content_hash != content_hash: raise ValueError('原文版本变化,请重新预览范围')
|
||||
_, _, packages = await classification(session,news)
|
||||
for package in packages:
|
||||
if package.result and package.published_revision == package.revision and mode == 'missing': continue
|
||||
previous = await session.scalar(select(ReprocessTarget).where(ReprocessTarget.job_id==job.id,
|
||||
ReprocessTarget.kind=='analysis',ReprocessTarget.object_id==package.id))
|
||||
if previous is None or package.revision > previous.revision:
|
||||
if package.state not in ('pending','processing'):
|
||||
worker.schedule(package,datetime.now(UTC),True)
|
||||
package.review_config = job.settings
|
||||
await target(session,job.id,'analysis',package)
|
||||
item = await session.get(ReprocessItem, identifier, populate_existing=True)
|
||||
item.state = 'done'; item.finished_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
except Exception as error:
|
||||
await session.rollback()
|
||||
item = await session.get(ReprocessItem,identifier)
|
||||
item.state='failed'; item.error=str(error)[:1500]; item.finished_at=datetime.now(UTC)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def reconcile(session, job):
|
||||
job = await session.get(ReprocessJob,job.id,with_for_update={"key_share":True},populate_existing=True)
|
||||
if job.state not in ('pending','running'): return
|
||||
if await session.scalar(select(ReprocessItem.id).where(ReprocessItem.job_id==job.id,ReprocessItem.state.in_(['pending','processing'])).limit(1)): return
|
||||
items = (await session.scalars(select(ReprocessItem).where(ReprocessItem.job_id==job.id,ReprocessItem.state=='failed'))).all()
|
||||
targets = (await session.scalars(select(ReprocessTarget).where(ReprocessTarget.job_id==job.id))).all()
|
||||
failures = [{'stage':'grouping','id':i.news_id,'error':i.error} for i in items if i.state=='failed']
|
||||
waiting = False
|
||||
for row in targets:
|
||||
obj = await session.get(NewsSummary if row.kind=='analysis' else NewsEvent,row.object_id)
|
||||
if obj is None: continue
|
||||
if row.kind=='analysis' and (obj.replacement_ids or obj.state=='withdrawn'):
|
||||
for replacement in obj.replacement_ids:
|
||||
linked=await session.get(NewsSummary,replacement)
|
||||
await target(session,job.id,'analysis',linked)
|
||||
if obj.replacement_ids:
|
||||
await session.delete(row); waiting=True
|
||||
continue
|
||||
if obj.state=='retired': continue
|
||||
if obj.state=='failed': failures.append({'stage':row.kind,'id':obj.id,'error':obj.error})
|
||||
elif row.kind == 'events' and await session.scalar(select(NewsSummary.id).join(EventMember).where(
|
||||
EventMember.event_id == obj.id, NewsSummary.state == 'failed', NewsSummary.replacement_ids == []).limit(1)):
|
||||
failures.append({'stage':'events','id':obj.id,'error':'组成包研判失败,请先重试失败包'})
|
||||
elif obj.state not in ('done','retired') or obj.published_revision < row.revision: waiting=True
|
||||
if row.kind=='analysis':
|
||||
membership=await session.get(EventMember,obj.id)
|
||||
if membership:
|
||||
event=await session.get(NewsEvent,membership.event_id)
|
||||
await target(session,job.id,'events',event)
|
||||
if not any(t.kind=='events' and t.object_id==event.id for t in targets): waiting=True
|
||||
if event.state=='failed': failures.append({'stage':'events','id':event.id,'error':event.error})
|
||||
if waiting: await session.commit(); return
|
||||
job.failures=list({(f['stage'],f['id']):f for f in failures}.values()); job.state='failed' if failures else 'done'
|
||||
await session.commit()
|
||||
@@ -67,6 +67,9 @@ async def apply_correction(session, proposal: Correction, current_id, researched
|
||||
link = SummaryArticle(summary_id=package.id, analysis_id=assignment.analysis_id); session.add(link)
|
||||
link.active = True; link.unit = {'fact':replacement.fact,'evidence':assignment.evidence,'correction':True}
|
||||
schedule(package, now, True); outputs.append(package.id)
|
||||
if package.review_config and package.review_config.get('request'):
|
||||
from alphapulse.news_risk.backfill import target
|
||||
await target(session, package.review_config['job_id'], 'analysis', package)
|
||||
for package in packages:
|
||||
if package.id not in outputs:
|
||||
package.replacement_ids = outputs; package.state = 'superseded'
|
||||
|
||||
@@ -7,6 +7,7 @@ from alphapulse.core.db import SessionLocal
|
||||
from alphapulse.news_risk.pipeline import configuration
|
||||
from alphapulse.news_risk.contracts import PackageTimePoint
|
||||
from alphapulse.news_risk.service import complete
|
||||
from alphapulse.news_risk.progress import step
|
||||
|
||||
|
||||
class TimelinePoint(PackageTimePoint):
|
||||
@@ -54,6 +55,9 @@ async def dirty_event(session, package_id, review_config=None):
|
||||
event.review_config = review_config
|
||||
event.state = 'pending'
|
||||
event.due_at = datetime.now(UTC)+timedelta(seconds=60)
|
||||
if review_config and review_config.get('request'):
|
||||
from alphapulse.news_risk.backfill import target
|
||||
await target(session, review_config['job_id'], 'events', event)
|
||||
|
||||
|
||||
async def associate(session, package, result, researched_ids, researched_events=None):
|
||||
@@ -89,6 +93,9 @@ async def associate(session, package, result, researched_ids, researched_events=
|
||||
event.review_config = package.review_config
|
||||
event.state = 'pending'; event.due_at = datetime.now(UTC)+timedelta(seconds=60)
|
||||
event.updated_at = max(p.updated_at for p in members)
|
||||
if package.review_config and package.review_config.get('request'):
|
||||
from alphapulse.news_risk.backfill import target
|
||||
await target(session, package.review_config['job_id'], 'events', event)
|
||||
|
||||
|
||||
async def organize(target_id=None):
|
||||
@@ -122,7 +129,9 @@ async def organize(target_id=None):
|
||||
version_id, event_id = version.id, event.id
|
||||
try:
|
||||
if config_error: raise ValueError(config_error)
|
||||
step('梳理事件时间线')
|
||||
result, usage = await complete(provider, system, {'task': 'event_timeline', 'previous': event.result, 'packages': packages}, EventResult)
|
||||
step('核对时间线与证据')
|
||||
validate_timeline(result, packages)
|
||||
version.result = result.model_dump(mode='json'); version.usage = usage; version.state = 'done'
|
||||
from alphapulse.news_risk.worker import publication_lock
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Best-effort actual execution steps, scoped to background news tasks."""
|
||||
from contextvars import ContextVar
|
||||
from datetime import UTC, datetime
|
||||
import logging
|
||||
|
||||
current = ContextVar('news_ai_progress', default=None)
|
||||
active = {}
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def step(phase, **details):
|
||||
record = current.get()
|
||||
if record is not None:
|
||||
record.update(phase=phase, updated_at=datetime.now(UTC).isoformat(), **details)
|
||||
|
||||
|
||||
async def execute(key, action):
|
||||
identity = str(key)
|
||||
record = {'key': identity, 'stage': key[0], 'object_id': key[1], 'phase': '准备输入',
|
||||
'started_at': datetime.now(UTC).isoformat(), 'updated_at': datetime.now(UTC).isoformat()}
|
||||
active[identity] = record
|
||||
token = current.set(record)
|
||||
try:
|
||||
return await action()
|
||||
finally:
|
||||
current.reset(token)
|
||||
active.pop(identity, None)
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Queue presentation combines durable work with short-lived execution steps."""
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from sqlalchemy import select, func, or_, exists
|
||||
from alphapulse.models import NewsItem
|
||||
from alphapulse.models.risk import (ReprocessItem, ReprocessTarget, ArticleAnalysis, NewsSummary, NewsEvent, RiskSnapshot, RiskSettings, ReprocessJob)
|
||||
from alphapulse.core.redis import redis
|
||||
|
||||
|
||||
async def job_out(session, job):
|
||||
counts = dict((await session.execute(select(ReprocessItem.state,func.count()).where(ReprocessItem.job_id==job.id).group_by(ReprocessItem.state))).all())
|
||||
if not job.settings.get('request'):
|
||||
counts = {state:sum(i.get('state','pending')==state for i in job.items) for state in ('pending','processing','done','failed')}
|
||||
return {'id':job.id,'state':job.state,'total':len(job.items),'completed':job.position,'failures':job.failures,'request':None,'counts':counts}
|
||||
stages = {}
|
||||
for kind, model in (('analysis',NewsSummary),('events',NewsEvent)):
|
||||
stages[kind] = dict((await session.execute(select(model.state,func.count()).join(
|
||||
ReprocessTarget,ReprocessTarget.object_id==model.id).where(ReprocessTarget.job_id==job.id,
|
||||
ReprocessTarget.kind==kind).group_by(model.state))).all())
|
||||
return {'id':job.id,'state':job.state,'request':job.settings['request'],'counts':counts,'stages':stages,
|
||||
'total':sum(counts.values()),'completed':sum(counts.get(s,0) for s in ('done','failed','skipped')),'failures':job.failures}
|
||||
|
||||
|
||||
async def listing(session, state, stage, origin, job_id, page, limit):
|
||||
now = datetime.now(UTC)
|
||||
runtime = await redis.get('ap:risk:concurrency')
|
||||
runtime = json.loads(runtime) if runtime else None
|
||||
raw = await redis.get('ap:risk:active')
|
||||
active = json.loads(raw) if raw else None
|
||||
settings = await session.get(RiskSettings,1)
|
||||
entries=[]
|
||||
total=0
|
||||
offset=(page-1)*limit
|
||||
for kind,model in [('grouping',ArticleAnalysis),('analysis',NewsSummary),('events',NewsEvent),('outlook',RiskSnapshot),('backfill',ReprocessItem)]:
|
||||
if stage and stage!=kind:continue
|
||||
query=select(model)
|
||||
busy = [a['object_id'] for a in (active or []) if a['stage']==kind]
|
||||
if kind=='grouping':
|
||||
held = exists(select(ReprocessItem.id).join(ReprocessJob).where(ReprocessItem.news_id==model.news_id,
|
||||
ReprocessItem.content_hash==model.content_hash, ReprocessItem.state.in_(['pending','processing']),
|
||||
ReprocessJob.state.in_(['pending','running'])))
|
||||
running = model.id.in_(busy) if active is not None else (
|
||||
(model.state=='processing') & ~held.where(ReprocessItem.state=='processing'))
|
||||
query=query.join(NewsItem).where(model.content_hash==NewsItem.content_hash,or_(~held,running))
|
||||
pending = model.state.in_(['pending','processing','reprocess'])
|
||||
due = getattr(model,'due_at',None)
|
||||
waiting = due > now if due is not None else False
|
||||
if kind=='events':
|
||||
from alphapulse.models.risk import EventMember
|
||||
blocked=select(EventMember.event_id).join(NewsSummary,NewsSummary.id==EventMember.package_id).where(
|
||||
NewsSummary.replacement_ids==[],NewsSummary.state!='withdrawn',
|
||||
or_(NewsSummary.state!='done',NewsSummary.result==None))
|
||||
waiting=or_(waiting,model.id.in_(blocked))
|
||||
if state=='running': query=query.where(model.id.in_(busy) if active is not None else model.state=='processing')
|
||||
elif state=='waiting': query=query.where(pending,model.id.not_in(busy),waiting)
|
||||
elif state=='queued': query=query.where(pending,model.state!='processing' if active is None else True,model.id.not_in(busy),~waiting if due is not None else True)
|
||||
elif state in ('failed','done'):query=query.where(model.state==state)
|
||||
else:query=query.where(pending)
|
||||
if kind=='backfill':
|
||||
query=query.join(ReprocessJob,ReprocessJob.id==model.job_id).where(or_(ReprocessJob.state.in_(['pending','running']),model.state=='processing') if state not in ('failed','done') else True)
|
||||
if origin=='realtime':continue
|
||||
if job_id:query=query.where(model.job_id==job_id)
|
||||
elif kind in ('analysis','events'):
|
||||
batch=model.review_config['job_id'].as_integer()
|
||||
if origin:query=query.where(batch.is_not(None) if origin=='history' else batch.is_(None))
|
||||
if job_id:
|
||||
tracked=exists(select(ReprocessTarget.id).where(ReprocessTarget.job_id==job_id,
|
||||
ReprocessTarget.kind==kind,ReprocessTarget.object_id==model.id))
|
||||
query=query.where(or_(tracked,batch==job_id))
|
||||
elif kind=='outlook':
|
||||
if job_id:query=query.where(model.quotes['job_id'].as_integer()==job_id)
|
||||
if origin:query=query.where(model.quotes['job_id'].as_integer().is_not(None) if origin=='history' else model.quotes['job_id'].as_integer().is_(None))
|
||||
elif job_id or origin=='history':continue
|
||||
count = await session.scalar(select(func.count()).select_from(query.subquery()))
|
||||
total += count
|
||||
if offset >= count:
|
||||
offset -= count; continue
|
||||
if len(entries) == limit: continue
|
||||
rows=(await session.scalars(query.order_by(model.id.desc()).offset(offset).limit(limit-len(entries)))).all()
|
||||
offset=0
|
||||
for obj in rows:
|
||||
url = None
|
||||
if kind=='backfill':
|
||||
news=await session.get(NewsItem,obj.news_id); title=news.title if news else f'新闻 #{obj.news_id}'
|
||||
batch=obj.job_id
|
||||
url=news.url if news else None
|
||||
elif kind=='grouping':title=obj.input.get('title','');batch=None;url=obj.input.get('url')
|
||||
elif kind=='outlook':title='72小时阅读与趋势';batch=obj.quotes.get('job_id')
|
||||
else:title=obj.title;batch=(obj.review_config or {}).get('job_id')
|
||||
source='history' if batch else 'realtime'
|
||||
config_stage = 'grouping' if kind=='backfill' else 'analysis' if kind=='events' else kind
|
||||
frozen = (await session.get(ReprocessJob,batch)).settings if batch else None
|
||||
config = frozen['configurations'][config_stage]['provider'] if frozen else {}
|
||||
if not config and settings:
|
||||
prefix = {'grouping':'','analysis':'second_','outlook':'third_'}[config_stage]
|
||||
from alphapulse.models import AiProvider
|
||||
provider = await session.get(AiProvider,getattr(settings,prefix+'provider_id')) if getattr(settings,prefix+'provider_id') else None
|
||||
config = {'model':getattr(settings,prefix+'model') or (provider.model if provider else None),
|
||||
'reasoning_effort':getattr(settings,prefix+'reasoning_effort') if getattr(settings,prefix+'reasoning_effort') is not None else provider.reasoning_effort if provider else None}
|
||||
actual=next((a for a in (active or []) if a['stage']==kind and a['object_id']==obj.id),None)
|
||||
waiting = getattr(obj,'due_at',now)>now
|
||||
status='running' if actual else 'waiting' if waiting else 'queued' if obj.state in ('pending','processing','reprocess') else obj.state
|
||||
if state: status=state
|
||||
if obj.state=='processing' and active is None: status='unknown'
|
||||
entries.append({'key':f'{kind}:{obj.id}','stage':kind,'object_id':obj.id,'title':title,'job_id':batch,
|
||||
'url':url,'finished_at':getattr(obj,'finished_at',None),'origin':source,'state':status,'phase':actual.get('phase') if actual else '已完成' if status=='done' else '处理失败' if status=='failed' else '等待条件或聚合时间' if status=='waiting' else 'Worker心跳过期,执行状态未知' if status=='unknown' else 'AI已暂停' if settings and not settings.enabled else '等待调度',
|
||||
'started_at':actual.get('started_at') if actual else getattr(obj,'started_at',None),
|
||||
'updated_at':actual.get('updated_at') if actual else None,'round':actual.get('round') if actual else None,
|
||||
'tool':actual.get('tool') if actual else None,'model':(actual or {}).get('model') or config.get('model') or getattr(obj,'model',None),
|
||||
'reasoning_effort':actual.get('reasoning_effort',config.get('reasoning_effort')) if actual else config.get('reasoning_effort'),'error':getattr(obj,'error',None)})
|
||||
order = {'grouping':0,'analysis':1,'events':2,'outlook':3,'backfill':4}
|
||||
entries.sort(key=lambda r:(order[r['stage']],-r['object_id']))
|
||||
return {'items':entries,'total':total,'as_of':now,'worker_alive':active is not None,'concurrency':runtime,'enabled':bool(settings and settings.enabled),'limit':settings.max_concurrency if settings else 5}
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Explicit bounded historical jobs, sharing the normal news pipeline."""
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy import select, text, update
|
||||
from alphapulse.core.db import SessionLocal
|
||||
from alphapulse.models import NewsItem
|
||||
from alphapulse.models.risk import RiskSettings, ReprocessJob, ArticleAnalysis, NewsSummary, EventMember
|
||||
from alphapulse.models.risk import RiskSettings, ReprocessJob, ReprocessItem, ArticleAnalysis, NewsSummary, EventMember
|
||||
from alphapulse.news_risk.pipeline import configuration
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ async def capture(session, settings):
|
||||
from alphapulse.api.risk import config_out
|
||||
value = config_out(settings)
|
||||
value.pop('start_at')
|
||||
value.pop('enabled', None)
|
||||
value.pop('max_concurrency', None)
|
||||
configurations = {}
|
||||
for stage in ('grouping', 'analysis', 'outlook'):
|
||||
@@ -80,6 +81,11 @@ async def process_job(item_index=None):
|
||||
if item.get('state') not in (None, 'pending'): return
|
||||
job.items = [{**entry, 'state': 'processing'} if n == index else entry for n, entry in enumerate(job.items)]
|
||||
job_id = job.id
|
||||
durable = await session.scalar(select(ReprocessItem).where(ReprocessItem.job_id==job.id,ReprocessItem.news_id==item['id']))
|
||||
if durable:
|
||||
durable.state='processing'
|
||||
from alphapulse.news_risk.progress import step
|
||||
step('历史新闻分包',stage='backfill',object_id=durable.id)
|
||||
try:
|
||||
for stage in ('grouping', 'analysis', 'outlook'):
|
||||
await frozen_configuration(session, job.settings, stage)
|
||||
@@ -98,6 +104,7 @@ async def process_job(item_index=None):
|
||||
if outcome == 'deferred':
|
||||
job = await session.get(ReprocessJob, job_id, populate_existing=True, with_for_update=True)
|
||||
job.items = [{**entry, 'state': 'pending'} if n == index else entry for n, entry in enumerate(job.items)]
|
||||
await session.execute(update(ReprocessItem).where(ReprocessItem.job_id==job_id,ReprocessItem.news_id==item['id']).values(state='pending'))
|
||||
await session.commit(); return
|
||||
row = await session.get(ArticleAnalysis, row_id, populate_existing=True)
|
||||
if outcome != 'done' or row.state != 'done' or row.error: raise ValueError(row.error or '原文已变化')
|
||||
@@ -111,6 +118,7 @@ async def process_job(item_index=None):
|
||||
job = await session.get(ReprocessJob, job_id, populate_existing=True, with_for_update=True)
|
||||
job.items = [{**entry, 'state': terminal} if n == index else entry for n, entry in enumerate(job.items)]
|
||||
job.position = sum(entry.get('state') in ('done', 'failed') for entry in job.items)
|
||||
await session.execute(update(ReprocessItem).where(ReprocessItem.job_id==job_id,ReprocessItem.news_id==item['id']).values(state=terminal))
|
||||
await session.commit()
|
||||
|
||||
|
||||
|
||||
@@ -124,6 +124,8 @@ class Research:
|
||||
|
||||
async def tool(self, name, arguments):
|
||||
if name not in TOOLS: raise ValueError('未知研究工具')
|
||||
from alphapulse.news_risk.progress import step
|
||||
step('查询资料', tool=name)
|
||||
args = TOOLS[name][0].model_validate(arguments)
|
||||
key = name+json.dumps(arguments, sort_keys=True)
|
||||
if key in self.cache: return self.cache[key]
|
||||
@@ -196,7 +198,9 @@ class Research:
|
||||
submit = llm.ToolSpec('submit', '提交完整研判,引用实际读取来源', schema.model_json_schema())
|
||||
messages = [{'role': 'user', 'text': json.dumps(payload, ensure_ascii=False)}]
|
||||
usages = []
|
||||
from alphapulse.news_risk.progress import step, current
|
||||
for round_no in range(9):
|
||||
if current.get() is not None: step('模型研判', round=round_no+1, model=provider.model, reasoning_effort=provider.reasoning_effort)
|
||||
if not self.fits_messages(provider, system, messages, schema):
|
||||
raise ValueError('研究上下文不足,请增大上下文配置;证据未截断')
|
||||
completion = None
|
||||
|
||||
@@ -7,7 +7,7 @@ from datetime import UTC, datetime
|
||||
from sqlalchemy import select, text, func
|
||||
from alphapulse.core.db import SessionLocal
|
||||
from alphapulse.core.redis import redis
|
||||
from alphapulse.models.risk import RiskSettings, NewsSummary, NewsEvent, ArticleAnalysis, ReprocessJob
|
||||
from alphapulse.models.risk import RiskSettings, NewsSummary, NewsEvent, ArticleAnalysis, ReprocessJob, ReprocessItem
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
GATE = 20261102
|
||||
@@ -18,7 +18,8 @@ class Pool:
|
||||
self.tasks = {}
|
||||
|
||||
def start(self, key, action):
|
||||
self.tasks[asyncio.create_task(action())] = key
|
||||
from alphapulse.news_risk.progress import execute
|
||||
self.tasks[asyncio.create_task(execute(key, action))] = key
|
||||
|
||||
async def reap(self, timeout=1):
|
||||
if not self.tasks:
|
||||
@@ -50,6 +51,9 @@ async def candidate(active, cursor, allow_outlook=True):
|
||||
await worker.enqueue(session, settings)
|
||||
history_news = []
|
||||
for kind, identity in active:
|
||||
if kind == 'backfill':
|
||||
item = await session.get(ReprocessItem, identity)
|
||||
history_news.append(item.news_id)
|
||||
if kind == 'history' and identity[1] >= 0:
|
||||
job = await session.get(ReprocessJob, identity[0])
|
||||
history_news.append(job.items[identity[1]]['id'])
|
||||
@@ -61,7 +65,12 @@ async def candidate(active, cursor, allow_outlook=True):
|
||||
busy = [key[1] for key in active if key[0] == stage]
|
||||
if stage == 'outlook':
|
||||
if not busy and allow_outlook:
|
||||
return (stage, 0), worker.outlook, (cursor + offset + 1) % 4
|
||||
try:
|
||||
ready = await worker.outlook(prepare_only=True)
|
||||
except ValueError:
|
||||
logger.exception('阅读编排配置检查失败')
|
||||
ready = False
|
||||
if ready: return (stage, 0), worker.outlook, (cursor + offset + 1) % 4
|
||||
continue
|
||||
model = {'grouping': ArticleAnalysis, 'analysis': NewsSummary, 'events': NewsEvent}[stage]
|
||||
query = select(model.id).where(model.state == 'pending', model.id.not_in(busy))
|
||||
@@ -81,17 +90,29 @@ async def candidate(active, cursor, allow_outlook=True):
|
||||
action = {'grouping': worker.analyze, 'analysis': worker.summarize, 'events': events.organize}[stage]
|
||||
return (stage, identifier), lambda action=action, identifier=identifier: action(identifier), (cursor + offset + 1) % 4
|
||||
job = await session.scalar(select(ReprocessJob).where(ReprocessJob.state.in_(['pending', 'running'])).order_by(ReprocessJob.id).limit(1))
|
||||
if job and job.settings.get('request'):
|
||||
from alphapulse.news_risk.backfill import process
|
||||
busy = [k[1] for k in active if k[0]=='backfill']
|
||||
candidates = (await session.scalars(select(ReprocessItem).where(ReprocessItem.job_id==job.id,
|
||||
ReprocessItem.state=='pending',ReprocessItem.id.not_in(busy),ReprocessItem.news_id.not_in(history_news))
|
||||
.order_by(ReprocessItem.id).limit(len(active)+1))).all()
|
||||
for item in candidates:
|
||||
article_id = await session.scalar(select(ArticleAnalysis.id).where(ArticleAnalysis.news_id==item.news_id,
|
||||
ArticleAnalysis.content_hash==item.content_hash))
|
||||
if ('grouping',article_id) not in active:
|
||||
return ('backfill',item.id), lambda identifier=item.id: process(identifier), cursor
|
||||
return None
|
||||
if job:
|
||||
for index, item in enumerate(job.items):
|
||||
key = ('history', (job.id, index))
|
||||
if item.get('state', 'pending') == 'pending' and key not in active:
|
||||
if item.get('state', 'pending') == 'pending' and key not in active and item['id'] not in history_news:
|
||||
# Real-time and historical grouping share the article identity.
|
||||
row_id = await session.scalar(select(ArticleAnalysis.id).where(ArticleAnalysis.news_id == item['id'], ArticleAnalysis.content_hash == item['hash']))
|
||||
if row_id is not None and ('grouping', row_id) in active:
|
||||
continue
|
||||
return key, lambda index=index: reprocess.process_job(index), cursor
|
||||
if not any(k[0] == 'history' for k in active):
|
||||
return ('history', (job.id, -1)), lambda: reprocess.process_job(-1), cursor
|
||||
await reprocess.process_job(-1)
|
||||
return None
|
||||
|
||||
|
||||
@@ -103,6 +124,11 @@ async def run(lock):
|
||||
# Interrupted historical items are reclaimed independently of completion order.
|
||||
async with SessionLocal() as session:
|
||||
jobs = (await session.scalars(select(ReprocessJob).where(ReprocessJob.state.in_(['pending', 'running'])))).all()
|
||||
from sqlalchemy import update
|
||||
interrupted = (await session.scalars(select(ReprocessItem).where(ReprocessItem.state=='processing'))).all()
|
||||
for item in interrupted:
|
||||
await session.execute(update(ArticleAnalysis).where(ArticleAnalysis.news_id==item.news_id, ArticleAnalysis.content_hash==item.content_hash, ArticleAnalysis.state=='pending').values(state='reprocess'))
|
||||
await session.execute(update(ReprocessItem).where(ReprocessItem.state=='processing').values(state='pending'))
|
||||
for job in jobs:
|
||||
for item in job.items:
|
||||
if item['state'] == 'processing':
|
||||
@@ -136,9 +162,16 @@ async def run(lock):
|
||||
async with SessionLocal() as session:
|
||||
settings = await session.get(RiskSettings, 1)
|
||||
limit, enabled = settings.max_concurrency, settings.enabled
|
||||
async with SessionLocal() as session:
|
||||
from alphapulse.news_risk.backfill import reconcile
|
||||
jobs = (await session.scalars(select(ReprocessJob).where(ReprocessJob.state.in_(['pending','running'])))).all()
|
||||
for job in jobs:
|
||||
if job.settings.get('request'): await reconcile(session,job)
|
||||
now = datetime.now(UTC).isoformat()
|
||||
counts = {stage: sum(k[0] == stage for k in pool.tasks.values()) for stage in ('grouping', 'analysis', 'events', 'outlook', 'history')}
|
||||
counts = {stage: sum(k[0] == stage for k in pool.tasks.values()) for stage in ('grouping', 'analysis', 'events', 'outlook', 'history', 'backfill')}
|
||||
try:
|
||||
from alphapulse.news_risk.progress import active
|
||||
await redis.set('ap:risk:active', json.dumps(list(active.values())), ex=15)
|
||||
await redis.set('ap:risk:concurrency', json.dumps({'running': len(pool.tasks), 'stages': counts, 'updated_at': now}), ex=15)
|
||||
for stage in ('', 'analysis:', 'events:', 'outlook:', 'reprocess:'):
|
||||
await redis.set('ap:risk:' + stage + 'heartbeat', now, ex=15)
|
||||
|
||||
@@ -76,6 +76,8 @@ class OutputTruncated(ValueError):
|
||||
|
||||
|
||||
async def complete(provider: AiProvider, system: str, payload: dict, schema: type[BaseModel]):
|
||||
from alphapulse.news_risk.progress import step, current
|
||||
if current.get() is not None: step('等待模型响应', model=provider.model, reasoning_effort=provider.reasoning_effort)
|
||||
text = json.dumps(payload, ensure_ascii=False)
|
||||
if not fits_context(provider, system, payload, schema):
|
||||
raise ValueError('新闻输入超出模型上下文;未截断或发送,请调整模型配置')
|
||||
|
||||
@@ -11,6 +11,7 @@ from alphapulse.news_risk.service import complete
|
||||
from alphapulse.news_risk.pipeline import configuration, Grouping, summarize_articles, validate_evidence, Evidence, grouping_payload, pack_candidates
|
||||
from alphapulse.news_risk.research import Research
|
||||
from alphapulse.news_risk import briefing
|
||||
from alphapulse.news_risk.progress import step
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
publication_lock = asyncio.Lock()
|
||||
@@ -73,6 +74,7 @@ async def _analyze(target_id=None, frozen=None):
|
||||
row_id = row.id
|
||||
row_input = row.input
|
||||
try:
|
||||
step('提取与归并新闻')
|
||||
payload, usages = await grouping_payload(provider, system, {'id': row.id, **row.input}, candidate_inputs)
|
||||
for attempt in range(2):
|
||||
try:
|
||||
@@ -87,6 +89,7 @@ async def _analyze(target_id=None, frozen=None):
|
||||
if attempt:
|
||||
raise
|
||||
payload = pack_candidates(provider, system, {**payload, 'correction': str(error)}, payload['candidates'])
|
||||
step('校验并保存分包结果')
|
||||
async with publication_lock:
|
||||
# Check the article again before changing event membership.
|
||||
current = await session.get(NewsItem, row.news_id, populate_existing=True)
|
||||
@@ -133,6 +136,9 @@ async def _analyze(target_id=None, frozen=None):
|
||||
if event.id in changed:
|
||||
event.review_config = frozen
|
||||
schedule(event, now, any(m.urgent for m in result.packages))
|
||||
if frozen and frozen.get('request'):
|
||||
from alphapulse.news_risk.backfill import target
|
||||
await target(session, frozen['job_id'], 'analysis', event)
|
||||
session.add(GroupingVersion(analysis_id=row.id, input=row.input, result=result.model_dump(mode='json'), rules=rules, state='done'))
|
||||
row.result = result.model_dump(mode='json'); row.usage = {'attempts': usages}; row.state = 'done'
|
||||
row.error = None; row.finished_at = now
|
||||
@@ -201,6 +207,7 @@ async def summarize(target_id=None):
|
||||
try:
|
||||
if config_error: raise ValueError(config_error)
|
||||
result, usage = await summarize_articles(provider, system, articles, package, research)
|
||||
step('校验并保存包研判')
|
||||
async with publication_lock:
|
||||
event = await session.get(NewsSummary, event_id, populate_existing=True, with_for_update=True)
|
||||
if event.replacement_ids or event.state == 'withdrawn':
|
||||
@@ -240,7 +247,7 @@ async def summarize(target_id=None):
|
||||
await session.commit(); logger.exception('事件总结失败')
|
||||
|
||||
|
||||
async def outlook():
|
||||
async def outlook(prepare_only=False):
|
||||
async with SessionLocal() as session:
|
||||
settings = await session.get(RiskSettings, 1)
|
||||
if not settings or not settings.enabled: return
|
||||
@@ -252,6 +259,7 @@ async def outlook():
|
||||
inputs = await reading_inputs(session, now-timedelta(hours=72))
|
||||
from alphapulse.models.risk import ReprocessJob
|
||||
job = await session.scalar(select(ReprocessJob).where(ReprocessJob.state == 'running').limit(1))
|
||||
if job and job.settings.get('request'): job = None
|
||||
if job and job.position < len(job.items):
|
||||
if requested and requested.quotes.get('job_id') == job.id: return
|
||||
job = None
|
||||
@@ -266,7 +274,17 @@ async def outlook():
|
||||
semantic = briefing.semantic_packages(inputs)
|
||||
key = briefing.fingerprint(semantic, rules)
|
||||
important = {p.get('kind', 'package')+':'+str(p['id']): briefing.fingerprint(p, {}) for p in semantic if p['importance_stars'] == 5}
|
||||
if requested is None and latest and latest.quotes.get('fingerprint') == key and (job is None or latest.quotes.get('job_id') == job.id): return
|
||||
unchanged = requested is None and latest and latest.quotes.get('fingerprint') == key and (job is None or latest.quotes.get('job_id') == job.id)
|
||||
if unchanged and (inputs or latest.state == 'done'): return
|
||||
if not inputs:
|
||||
# Empty reading windows are durable housekeeping, without an AI slot.
|
||||
session.add(RiskSnapshot(state='done', model=provider.model,
|
||||
quotes={'fingerprint':key,'important':{},**({'job_id':job.id} if job else {})},
|
||||
result={'reading':[],'synthesis':None,'usage':[],'expansion':[],'expansion_usage':[],
|
||||
'inputs':[],'rules':rules,'as_of':now.isoformat()}))
|
||||
if requested: requested.quotes = {k:v for k,v in requested.quotes.items() if k != 'retry_requested'}
|
||||
await session.commit()
|
||||
return
|
||||
if latest and requested is None and job is None:
|
||||
urgent_change = any(latest.quotes.get('important', {}).get(identifier) != value for identifier, value in important.items())
|
||||
if urgent_change:
|
||||
@@ -277,8 +295,11 @@ async def outlook():
|
||||
latest.quotes = {**latest.quotes, 'dirty_since': now.isoformat()}
|
||||
await session.commit(); return
|
||||
if (now-datetime.fromisoformat(dirty)).total_seconds() < 900: return
|
||||
if prepare_only: return True
|
||||
step('整理72小时阅读与趋势')
|
||||
snapshot = RiskSnapshot(state='processing', model=provider.model, quotes={'fingerprint': key, 'important': important, **({'job_id': job.id} if job else {})})
|
||||
session.add(snapshot); await session.commit()
|
||||
step('生成阅读与趋势', object_id=snapshot.id)
|
||||
try:
|
||||
main = [p for p in inputs if p['importance_stars'] >= 3]
|
||||
low = [p for p in inputs if p['importance_stars'] < 3]
|
||||
@@ -294,6 +315,7 @@ async def outlook():
|
||||
if len(extra) == 3: break
|
||||
snapshot.result = {**result, 'expansion': extra, 'inputs': inputs, 'rules': rules,
|
||||
'expansion_usage': expansion.get('usage', []), 'as_of': now.isoformat()}
|
||||
step('保存阅读与趋势')
|
||||
snapshot.state = 'done'
|
||||
except Exception as error:
|
||||
snapshot.state = 'failed'; snapshot.error = str(error)[:2000]
|
||||
|
||||
Reference in new issue
Block a user