refactor(news): remove retired research workflow
Development CI / check-and-build (push) Successful in 2m53s
GitHub Git mirror / mirror (push) Successful in 7s
Development CI / deploy-development (push) Successful in 2m31s

This commit is contained in:
eezd committed 2026-11-05 18:38:25 +08:00
1 parent f3894c3728
commit d79a10c2e6
69 files changed
+460 -6713

No files matched your search

+1 -1
View File
@@ -195,7 +195,7 @@ async def _run(args):
if int(baseline.read_text()) != 199 or hashlib.sha256(baseline.read_bytes()).hexdigest() != manifest['baseline_counter_sha256']:
raise ValueError('历史计数不匹配')
# Preflight precedes all paid calls, using the application's public reader.
from alphapulse.news_risk.research import fetch_public
from alphapulse.news_research.tools import fetch_public
preflight = []
for source in manifest['official_sources']:
result = await fetch_public(source['url'])
+2 -2
View File
@@ -25,10 +25,10 @@ import asyncio,json
from sqlalchemy import select,func
from alphapulse.core.db import SessionLocal,engine
from alphapulse.models import Source,NewsItem
from alphapulse.models.risk import RiskSettings
from alphapulse.news_research.models import ResearchSettings
async def main():
async with SessionLocal() as s:
setting=await s.get(RiskSettings,1)
setting=await s.get(ResearchSettings,1)
rows=(await s.execute(select(NewsItem.source_code,func.count()).group_by(NewsItem.source_code))).all()
print(json.dumps({'news_ai_enabled':bool(setting and setting.enabled),'news_counts':dict(rows)}))
await engine.dispose()
+1 -63
View File
@@ -336,69 +336,11 @@ def remove_cls(build, execute=False):
deploy_locked(build, INSTANCE, maintenance=pending)
def reset_news_ai(build, confirmed=False):
"""Preview or clear only legacy news AI records under the deployment lock."""
validate_images(build)
with locked(INSTANCE / '.deploy.lock'):
require(not (INSTANCE / 'deployment.pending.json').exists(), 'Recover the pending deployment first')
command = ['docker', 'run', '--rm', '-i', '--network', 'dev_default', '--env-file', str(INSTANCE / '.env'),
IMAGES['backend'] + ':' + build['version'], 'python', '-']
run(*command, input=SCHEMA)
if confirmed:
current = json.loads((INSTANCE / 'deployment.json').read_text())
run(*compose(INSTANCE, current), 'stop', 'news-risk')
program = """
import asyncio,json
from sqlalchemy import text
from alphapulse.core.db import engine
async def main():
async with engine.begin() as c:
if not CONFIRMED:
await c.execute(text('SET TRANSACTION READ ONLY'))
counts = {table: await c.scalar(text('SELECT count(*) FROM '+table)) for table in
('risk_analyses','risk_events','risk_snapshots')}
if CONFIRMED:
assert not await c.scalar(text('SELECT count(*) FROM news_ai_article_analyses')), 'New pipeline already contains data'
assert not await c.scalar(text('SELECT count(*) FROM news_ai_summaries')), 'New pipeline already contains data'
await c.execute(text('UPDATE risk_settings SET enabled=false, start_at=NULL'))
for table in ('risk_analyses','risk_events','risk_snapshots'):
await c.execute(text('DELETE FROM '+table))
print(json.dumps({'legacy_counts':counts,'cleared':CONFIRMED}))
await engine.dispose()
asyncio.run(main())
"""
print(run(*command, input='CONFIRMED = '+repr(confirmed)+'\n'+program), flush=True)
def reset_news_derived(build, execute=False, acceptance=None, baseline=None):
"""Run the scoped reset with both deployment and model-admission locks."""
report = json.loads(acceptance.read_text()) if acceptance else None
raw = baseline.read_bytes() if baseline else None
if execute:
require(bool(report and raw and report.get('real_models') is True and report.get('quality_accepted') is True
and report.get('baseline_sha256') == hashlib.sha256(raw).hexdigest()
and report.get('baseline_packages') == 20 and report.get('originals') == 27
and report.get('failed') == 0 and report.get('unassigned') == 0), '固定样本真实模型质量验收尚未通过')
validate_images(build)
with locked(INSTANCE / '.deploy.lock'):
require(not (INSTANCE / 'deployment.pending.json').exists(), 'Recover the pending deployment first')
command = ['docker','run','--rm','-i','--network','dev_default','--env-file',str(INSTANCE / '.env'),
IMAGES['backend']+':'+build['version'],'python','-']
run(*command,input=SCHEMA)
source = (ROOT / 'scripts/reset_news_ai.py').read_text()
program = "import asyncio,json\nnamespace={'__name__':'news_reset'}\nexec("+repr(source)+",namespace)\n"
program += 'print(json.dumps(asyncio.run(namespace["reset"]('+repr(execute)+','+repr(report)+','+repr(raw)+')),ensure_ascii=False))'
print(run(*command,input=program),flush=True)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('command', choices=('update', 'recover', 'rollback', 'reset-news-ai', 'reset-news-derived', 'install-entrypoints', 'remove-cls'))
parser.add_argument('command', choices=('update', 'recover', 'rollback', 'install-entrypoints', 'remove-cls'))
parser.add_argument('--execute', action='store_true')
parser.add_argument('--build', type=Path, default=ROOT / 'build.json')
parser.add_argument('--confirm-delete-old-news-ai', action='store_true')
parser.add_argument('--confirm-reset-news-derived', action='store_true')
parser.add_argument('--acceptance', type=Path)
parser.add_argument('--baseline', type=Path)
args = parser.parse_args()
if args.command == 'remove-cls':
remove_cls(json.loads(args.build.read_text()), args.execute)
@@ -407,10 +349,6 @@ def main():
require(not (INSTANCE / 'deployment.pending.json').exists(), 'Recover the pending deployment first')
install_entrypoints(INSTANCE)
print('实例启停入口已安装;未启动或停止任何服务')
elif args.command == 'reset-news-derived':
reset_news_derived(json.loads(args.build.read_text()),args.confirm_reset_news_derived,args.acceptance,args.baseline)
elif args.command == 'reset-news-ai':
reset_news_ai(json.loads(args.build.read_text()), args.confirm_delete_old_news_ai)
elif args.command == 'update':
deploy(json.loads(args.build.read_text()))
else:
-64
View File
@@ -1,64 +0,0 @@
"""Export the accepted twenty-package baseline through a read-only DB transaction.
Run inside the API environment; stdout contains news and AI results, never provider
credentials. This exports inputs only and cannot start workers or call models.
"""
import asyncio
import json
from datetime import UTC, datetime
from sqlalchemy import select, text
from alphapulse.core.db import SessionLocal, engine
from alphapulse.models import NewsItem
from alphapulse.models.risk import ArticleAnalysis, NewsSummary, SummaryArticle, SummaryVersion, PackageEvidence, RiskSettings
SAMPLE_IDS = [126, 140, 763, 774, 781, 769, 765, 94, 96, 97, 776, 623, 7, 2, 227, 88, 768, 770, 762, 761]
async def export():
start = datetime.fromisoformat('2026-11-01T00:00:00+08:00')
end = datetime.fromisoformat('2026-11-03T00:00:00+08:00')
packages, news = [], {}
try:
async with SessionLocal() as session:
await session.execute(text('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY'))
settings = await session.get(RiskSettings, 1)
if settings.enabled:
raise ValueError('请先暂停线上AI再导出固定样本')
for identifier in SAMPLE_IDS:
package = await session.get(NewsSummary, identifier)
if package is None or package.replacement_ids or package.state != 'done':
raise ValueError(f'样本包#{identifier}已变化,请重新审核样本')
version = await session.scalar(select(SummaryVersion).where(SummaryVersion.summary_id == identifier,
SummaryVersion.revision == package.published_revision, SummaryVersion.state == 'done'))
if version is None:
raise ValueError(f'样本包#{identifier}缺少成功版本')
rows = await session.scalars(select(NewsItem).join(ArticleAnalysis, ArticleAnalysis.news_id == NewsItem.id)
.join(SummaryArticle, SummaryArticle.analysis_id == ArticleAnalysis.id).where(
SummaryArticle.summary_id == identifier, SummaryArticle.active.is_(True),
ArticleAnalysis.content_hash == NewsItem.content_hash))
source_ids = []
for row in rows:
if not start <= row.published_at < end:
raise ValueError(f'样本新闻#{row.id}超出时间范围')
source_ids.append(row.id)
news[row.id] = {key: getattr(row, key) for key in ('id', 'source_code', 'url', 'title', 'summary',
'content_text', 'content_hash', 'revision_no', 'published_at', 'source_updated_at')}
if not source_ids:
raise ValueError(f'样本包#{identifier}无有效原文')
records = list(await session.scalars(select(PackageEvidence).where(PackageEvidence.version_id == version.id)
.order_by(PackageEvidence.id)))
packages.append({'id': identifier, 'title': package.title, 'revision': version.revision,
'news_ids': sorted(set(source_ids)), 'input': version.input, 'result': version.result,
'rules': version.rules, 'model': version.model, 'usage': version.usage,
'started_at': version.started_at, 'finished_at': version.finished_at,
'research': [r.data for r in records]})
return {'exported_at': datetime.now(UTC), 'start': start, 'end': end,
'model_settings': {key: getattr(settings, key) for key in ('model', 'second_model', 'third_model',
'reasoning_effort', 'second_reasoning_effort', 'third_reasoning_effort', 'max_concurrency')},
'packages': packages, 'news': sorted(news.values(), key=lambda n: (n['published_at'], n['id']))}
finally:
await engine.dispose()
if __name__ == '__main__':
print(json.dumps(asyncio.run(export()), ensure_ascii=False, default=str))
-172
View File
@@ -1,172 +0,0 @@
"""Run the fixed baseline only in an empty, disposable CI database.
Credentials are supplied explicitly in a private JSON file. This script never
reads live configuration; reusing a live provider requires explicit operator
authorization. The providers map has grouping/analysis/outlook entries containing
protocol, base_url and api_key. No quality-acceptance flag is set automatically.
"""
import argparse
import asyncio
from datetime import UTC, datetime
import hashlib
import json
import os
import time
from pathlib import Path
from urllib.parse import urlparse
from sqlalchemy import select, func
from alphapulse.core.crypto import encrypt
from alphapulse.core.db import SessionLocal, engine
from alphapulse.models import AiProvider, NewsItem
from alphapulse.models.risk import RiskSettings, ArticleAnalysis, NewsSummary, NewsEvent, EventMember, EventVersion, RiskSnapshot
from alphapulse.news_risk import worker, pipeline, event_research
from alphapulse.ai.skills import create_node, add_version
async def bounded(identifiers, action, limit=4):
pending=iter(identifiers)
tasks=set()
try:
while tasks or pending is not None:
while pending is not None and len(tasks)<limit:
try: identifier=next(pending)
except StopIteration: pending=None; break
tasks.add(asyncio.create_task(action(identifier)))
if tasks:
done,tasks=await asyncio.wait(tasks,return_when=asyncio.FIRST_COMPLETED)
results=await asyncio.gather(*done,return_exceptions=True)
for result in results:
if isinstance(result,BaseException): raise result
finally:
for task in tasks: task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
def tokens(value):
if isinstance(value,list):
result={}
for item in value:
for k,v in tokens(item).items(): result[k]=result.get(k,0)+v
return result
if not isinstance(value,dict): return {}
result={k:v for k,v in value.items() if k in ('input_tokens','output_tokens','cache_read_input_tokens','cache_creation_input_tokens') and isinstance(v,(int,float))}
for item in value.values():
if isinstance(item,(dict,list)):
for k,v in tokens(item).items(): result[k]=result.get(k,0)+v
return result
async def replay(sample_path, credentials_path, output, news_ids=None, supplement=None):
url=urlparse(os.environ['DATABASE_URL'])
if not (url.username=='ci' and url.password=='ci-only' and url.path=='/ci' and
(url.hostname in ('localhost','127.0.0.1') or url.hostname.startswith('ap-ci-'))):
raise ValueError('样本只能写入独立CI数据库')
baseline=sample_path.read_bytes(); sample=json.loads(baseline)
if len(sample['packages'])!=20 or len(sample['news'])!=27: raise ValueError('不是已固定的20包27原文基线')
supplement_hash=None
if supplement:
extra=supplement.read_bytes(); supplement_hash=hashlib.sha256(extra).hexdigest()
existing={n['id']:n for n in sample['news']}
for item in json.loads(extra):
if item['id'] in existing:
if item['content_hash']!=existing[item['id']]['content_hash']: raise ValueError('补充样本与固定原文冲突')
else: existing[item['id']]=item
sample['news']=sorted(existing.values(),key=lambda n:(n['published_at'],n['id']))
if news_ids:
selected=set(news_ids)
if not selected <= {n['id'] for n in sample['news']}: raise ValueError('定向ID必须来自固定基线')
sample['news']=[n for n in sample['news'] if n['id'] in selected]
credentials=json.loads(credentials_path.read_text())
started=time.monotonic()
ids={}; skills={}
try:
async with SessionLocal() as session:
if await session.scalar(select(func.count()).select_from(NewsItem)) or await session.get(RiskSettings,1):
raise ValueError('验收数据库必须为空')
for stage in ('grouping','analysis','outlook'):
config=credentials['providers'][stage]
provider=AiProvider(name='sample-'+stage,protocol=config['protocol'],base_url=config['base_url'],
api_key_encrypted=encrypt(config['api_key']),api_key_hint='sample',model='sample',max_tokens=config.get('max_tokens',16384),context_window=config.get('context_window',200000))
session.add(provider); await session.flush(); ids[stage]=provider.id
name,content=pipeline.TEMPLATES[stage]
node=await create_node(session,None,'skill',name,scope='news')
version=await add_version(session,node,content,'user','Isolated sample')
node.published_version_id=version.id; skills[stage+'_skill_id']=node.id
model=sample['model_settings']
settings=RiskSettings(id=1,enabled=True,start_at=datetime.fromisoformat(sample['start']),max_concurrency=4,
provider_id=ids['grouping'],second_provider_id=ids['analysis'],third_provider_id=ids['outlook'],**skills,
**{k:model[k] for k in ('model','second_model','third_model','reasoning_effort','second_reasoning_effort','third_reasoning_effort')})
session.add(settings)
for item in sample['news']:
session.add(NewsItem(id=item['id'],external_id='sample-'+str(item['id']),**{k:v for k,v in item.items() if k not in ('id','published_at','source_updated_at')},
published_at=datetime.fromisoformat(item['published_at']),source_updated_at=datetime.fromisoformat(item['source_updated_at']) if item['source_updated_at'] else None))
await session.commit()
for _attempt in range(30):
async with SessionLocal() as session:
await worker.enqueue(session,await session.get(RiskSettings,1))
pending=list(await session.scalars(select(ArticleAnalysis.id).where(ArticleAnalysis.state=='pending')))
if not pending: break
await bounded(pending,worker.analyze)
async with SessionLocal() as session:
# Drain the historical sample after grouping, without real-time arrivals.
events=list(await session.scalars(select(NewsEvent).where(NewsEvent.state=='pending')))
for event in events: event.due_at=datetime.now(UTC)
await session.commit()
for _attempt in range(10):
async with SessionLocal() as session:
pending=list(await session.scalars(select(NewsEvent.id).where(NewsEvent.state=='pending')))
for event in list(await session.scalars(select(NewsEvent).where(NewsEvent.id.in_(pending)))): event.due_at=datetime.now(UTC)
await session.commit()
if not pending: break
await bounded(pending,event_research.review)
async with SessionLocal() as session:
unfinished_articles=await session.scalar(select(func.count()).select_from(ArticleAnalysis).where(ArticleAnalysis.state!='done'))
unfinished_events=await session.scalar(select(func.count()).select_from(NewsEvent).where(NewsEvent.state.notin_(['done','retired'])))
reading_generated=not (unfinished_articles or unfinished_events)
if reading_generated: await worker.outlook()
async with SessionLocal() as session:
articles=list(await session.scalars(select(ArticleAnalysis)))
packages=list(await session.scalars(select(NewsSummary).where(NewsSummary.replacement_ids==[])))
events=list(await session.scalars(select(NewsEvent).where(NewsEvent.state!='retired')))
versions=list(await session.scalars(select(EventVersion)))
snapshots=list(await session.scalars(select(RiskSnapshot)))
assigned=set(await session.scalars(select(EventMember.package_id)))
report={'real_models':True,'quality_accepted':False,'supplement_sha256':supplement_hash,'baseline_sha256':hashlib.sha256(baseline).hexdigest(),
'skill_hashes':{stage:hashlib.sha256(content.encode()).hexdigest() for stage,(_,content) in pipeline.TEMPLATES.items()},
'model_settings':sample['model_settings'],'test_max_concurrency':4,
'baseline_packages':20,'originals':len(sample['news']),'selected_news_ids':[n['id'] for n in sample['news']],'reading_generated':reading_generated,'packages':len(packages),'events':len(events),
'unassigned':len({p.id for p in packages}-assigned),
'failed':sum(a.state!='done' for a in articles)+sum(e.state!='done' for e in events)+sum(s.state!='done' for s in snapshots),
'stage1_tokens':tokens([a.usage for a in articles]),'stage2_tokens':tokens([v.usage for v in versions]),
'baseline_stage2_tokens':tokens([p['usage'] for p in sample['packages']]),
'elapsed_seconds':round(time.monotonic()-started,3),
'snapshots':[{'id':s.id,'state':s.state,'result':s.result,'error':s.error} for s in snapshots],
'stage3_usage':[{'usage':(s.result or {}).get('usage'),'expansion_usage':(s.result or {}).get('expansion_usage'),
'failed_usage':s.quotes.get('failed_usage'),'completed_usage':s.quotes.get('completed_usage')} for s in snapshots],
'grouping':[{'news_id':a.news_id,'state':a.state,'error':a.error,'usage':a.usage,'result':a.result} for a in articles],
'package_results':[{'id':p.id,'title':p.title,'fact':p.fact,'revision':p.revision} for p in packages],
'event_versions':[{'event_id':v.event_id,'state':v.state,'input':v.input,'result':v.result,'usage':v.usage} for v in versions],
'members':[{'event_id':m.event_id,'package_id':m.package_id} for m in await session.scalars(select(EventMember))],
'results':[{'id':e.id,'state':e.state,'result':e.result,'error':e.error} for e in events],
'usage_note':'仅统计接口实际返回的usage;中断请求可能缺少用量,不等于供应商账单。',
'note':'质量需逐项检查事实边界、归属、证据和时间线后确认;旧基线仅含包研判费用,不代表旧三阶段总价。'}
output.write_text(json.dumps(report,ensure_ascii=False,indent=2,default=str)); output.chmod(0o600)
settings=await session.get(RiskSettings,1); settings.enabled=False; await session.commit()
print(json.dumps({k:report[k] for k in ('originals','packages','events','failed','unassigned')},ensure_ascii=False))
finally:
async with SessionLocal() as session:
settings=await session.get(RiskSettings,1)
if settings and ids and settings.provider_id==ids.get('grouping'):
settings.enabled=False; await session.commit()
await engine.dispose()
if __name__=='__main__':
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--baseline',type=Path,required=True)
parser.add_argument('--credentials',type=Path,required=True)
parser.add_argument('--output',type=Path,required=True)
parser.add_argument('--news-ids',type=lambda value: [int(i) for i in value.split(',')],help='Run only these original IDs from the fixed baseline')
parser.add_argument('--supplement',type=Path,help='Frozen additional real originals; the original baseline stays unchanged')
args=parser.parse_args()
asyncio.run(replay(args.baseline,args.credentials,args.output,args.news_ids,args.supplement))
-61
View File
@@ -1,61 +0,0 @@
"""Inspect or reset only news AI derivatives after isolated sample acceptance."""
import argparse
import asyncio
import hashlib
import json
from pathlib import Path
from sqlalchemy import delete, func, select, text
from alphapulse.core.db import SessionLocal, engine
from alphapulse.core.redis import redis
from alphapulse.models.risk import (RiskSettings, ReprocessTarget, ReprocessItem, ReprocessJob,
EventEvidence, EventCorrectionRecord, EventLink, EventVersion, EventMember, NewsEvent,
PackageCorrection, PackageEvidence, SummaryVersion, GroupingVersion, SummaryArticle,
NewsSummary, ArticleAnalysis, RiskSnapshot, RiskAnalysis, RiskEvent)
from alphapulse.news_risk.scheduler import GATE
DERIVED = (ReprocessTarget, ReprocessItem, ReprocessJob, EventEvidence, EventCorrectionRecord,
EventLink, EventVersion, EventMember, NewsEvent, PackageCorrection, PackageEvidence,
SummaryVersion, GroupingVersion, SummaryArticle, NewsSummary, ArticleAnalysis,
RiskSnapshot, RiskAnalysis, RiskEvent)
def accepted(report, baseline):
return (report.get('real_models') is True and report.get('quality_accepted') is True
and report.get('baseline_sha256') == hashlib.sha256(baseline).hexdigest()
and report.get('baseline_packages') == 20 and report.get('originals') == 27
and report.get('failed') == 0 and report.get('unassigned') == 0)
async def reset(execute=False, report=None, baseline=None):
if execute and (not report or not baseline or not accepted(report,baseline)):
raise ValueError('缺少通过质量验收的固定20包真实模型报告;禁止清理')
try:
async with engine.connect() as gate:
if not await gate.scalar(text('SELECT pg_try_advisory_lock(:gate,1)'),{'gate':GATE}):
raise ValueError('仍有AI任务运行,不能清理')
try:
async with SessionLocal() as session:
settings=await session.get(RiskSettings,1,with_for_update=True)
if settings and settings.enabled: raise ValueError('必须先暂停新闻AI')
counts={model.__tablename__:await session.scalar(select(func.count()).select_from(model)) for model in DERIVED}
if execute:
for model in DERIVED: await session.execute(delete(model))
await session.commit()
async for key in redis.scan_iter(match='ap:risk:*',count=100): await redis.delete(key)
return {'executed':execute,'counts':counts}
finally:
await gate.execute(text('SELECT pg_advisory_unlock(:gate,1)'),{'gate':GATE})
await gate.commit()
finally:
await engine.dispose()
if __name__=='__main__':
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--execute',action='store_true')
parser.add_argument('--acceptance',type=Path)
parser.add_argument('--baseline',type=Path)
args=parser.parse_args()
print(json.dumps(asyncio.run(reset(args.execute,
json.loads(args.acceptance.read_text()) if args.acceptance else None,
args.baseline.read_bytes() if args.baseline else None)),ensure_ascii=False))
-183
View File
@@ -1,183 +0,0 @@
"""Validate event completion, queue visibility and historical scheduling in CI."""
import asyncio
from datetime import UTC, datetime, timedelta
import json
import os
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from urllib.parse import urlparse
from sqlalchemy import select, func, delete, update
from alphapulse.core.db import SessionLocal, engine
from alphapulse.models import AiProvider, NewsItem, SkillNode, SkillVersion
from alphapulse.models.risk import (RiskSettings, ArticleAnalysis, NewsSummary, SummaryArticle, NewsEvent,
EventMember, ReprocessJob, ReprocessItem, ReprocessTarget, GroupingVersion, 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); store={}; news_ids=[]; package_ids=[]; event_ids=[]; skills={}
async def save(key,value,**_): store[key]=value
async def remove(key): store.pop(key,None)
fake=SimpleNamespace(set=AsyncMock(side_effect=save),get=AsyncMock(side_effect=lambda k:store.get(k)),delete=AsyncMock(side_effect=remove))
async with SessionLocal() as session:
assert not await session.get(RiskSettings,1)
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(); provider_id=provider.id
for role,(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[role+'_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',**skills))
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='source-'+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='科技',entities=[],tags=['science'],
published_at=now,updated_at=now,changed_at=now,due_at=now,revision=1,state='done')
session.add(package); await session.flush(); package_ids.append(package.id)
session.add(SummaryArticle(summary_id=package.id,analysis_id=article.id,active=True))
event=NewsEvent(title=news.title,fact=news.title,updated_at=now,due_at=now,revision=1,
published_revision=1 if i==0 else 0,state='done' if i==0 else 'failed',result={'summary':'Verified'} if i==0 else None)
session.add(event); await session.flush(); event_ids.append(event.id)
session.add(EventMember(package_id=package.id,event_id=event.id,reason='Same occurrence'))
await session.commit()
calls=[]
async def group(provider,system,payload,schema):
calls.append(provider.model)
index=0 if payload['article']['title']=='Evidence 0' else 1
return schema(reason='Concrete fact',packages=[dict(event=dict(event_id=event_ids[index],group_key=None,title='Event',fact='Fact',reason='Same event'),
package_id=package_ids[index],group_key=None,title='Evidence',fact='Evidence',evidence=payload['article']['title'],
category='科技',entities=[],tags=['science'],urgent=False,content_kind='report',change_kind='repost')]),{}
async def preview(modes,start=None,end=None):
async with SessionLocal() as session:
return await backfill.preview(session,start or now-timedelta(seconds=1),end or now+timedelta(minutes=4),modes)
async def begin(value):
async with SessionLocal() as session: return (await backfill.start(session,value['preview_id']))['id']
async def process(job_id):
async with SessionLocal() as session: ids=list(await session.scalars(select(ReprocessItem.id).where(ReprocessItem.job_id==job_id)))
for i in ids: await backfill.process(i)
try:
with patch.object(backfill,'redis',fake),patch.object(queue,'redis',fake),patch.object(worker,'complete',group):
value=await preview(['missing'])
assert (value['total'],value['missing'],value['review'],value['articles'])==(3,2,1,2),value
job_id=await begin(value); await process(job_id)
assert calls==['luna']
async with SessionLocal() as session:
items=list(await session.scalars(select(ReprocessItem).where(ReprocessItem.job_id==job_id)))
assert all(i.state=='done' for i in items),[(i.state,i.error) for i in items]
targets=list(await session.scalars(select(ReprocessTarget).where(ReprocessTarget.job_id==job_id)))
assert len(targets)==1 and targets[0].kind=='events' and targets[0].object_id==event_ids[1]
event=await session.get(NewsEvent,event_ids[1]); event.state='done'; event.result={'summary':'Verified'}; event.published_revision=event.revision
await session.commit(); job=await session.get(ReprocessJob,job_id); await backfill.reconcile(session,job)
assert job.state=='done' and (await queue.job_out(session,job))['completed']==2
for state in ('running','queued','waiting','failed','done',None):
listing=await queue.listing(session,state,None,None,None,1,2)
assert listing['total']>=len(listing['items']) and all(r['stage']!='analysis' for r in listing['items'])
first=await queue.listing(session,'done','backfill','history',job_id,1,1)
second=await queue.listing(session,'done','backfill','history',job_id,2,1)
assert first['total']==2 and first['items'][0]['key']!=second['items'][0]['key']
value=await preview(['review'],now-timedelta(seconds=1),now+timedelta(seconds=1))
review_id=await begin(value); await process(review_id)
assert calls==['luna','luna']
async with SessionLocal() as session:
assert await session.scalar(select(func.count()).select_from(GroupingVersion))==2
event=await session.get(NewsEvent,event_ids[0]); assert event.state=='pending'
job=await session.get(ReprocessJob,review_id); await backfill.reconcile(session,job); assert job.state=='running'
event.state='failed'; event.error='Synthetic failure'; await session.commit()
await backfill.reconcile(session,job); assert job.state=='failed' and job.failures[0]['stage']=='events'
from alphapulse.api.risk import reprocess_retry
await reprocess_retry(job.id,session)
assert event.state=='pending' and calls==['luna','luna']
event.state='done'; event.result={'summary':'Reviewed'}; event.published_revision=event.revision
await session.commit(); await backfill.reconcile(session,job); assert job.state=='done'
value=await preview(['review'],now+timedelta(minutes=2),now+timedelta(minutes=3))
async with SessionLocal() as session:
settings=await session.get(RiskSettings,1); settings.model='changed'; await session.commit()
try: await backfill.start(session,value['preview_id'])
except ValueError as error: assert '配置已变化' in str(error)
else: raise AssertionError('Preview configuration drift accepted')
settings.model='luna'; await session.commit()
changed_id=await begin(value)
async with SessionLocal() as session:
news=await session.get(NewsItem,news_ids[2]); news.content_hash='changed'; await session.commit()
await process(changed_id)
async with SessionLocal() as session:
job=await session.get(ReprocessJob,changed_id); await backfill.reconcile(session,job)
assert job.state=='failed' and '版本变化' in job.failures[0]['error']
event=await session.get(NewsEvent,event_ids[0]); event.state='pending'; event.published_revision=0; await session.commit()
value=await preview(['missing'],now-timedelta(seconds=1),now+timedelta(seconds=1)); skip_id=await begin(value)
async with SessionLocal() as session:
settings=await session.get(RiskSettings,1); settings.enabled=False
event=await session.get(NewsEvent,event_ids[0]); event.state='done'; event.published_revision=event.revision
await session.commit()
await process(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(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'
value=await preview(['review'],now-timedelta(seconds=1),now+timedelta(seconds=1)); cancel_id=await begin(value)
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
async with SessionLocal() as session:
item=await session.get(ReprocessItem,item.id); assert item.state=='pending'
article=await session.get(ArticleAnalysis,article.id); assert article.state=='reprocess'
task={'stage':'grouping','object_id':article.id,'phase':'Waiting','started_at':now.isoformat(),'updated_at':now.isoformat()}
article.state='processing'; await session.commit(); store['ap:risk:active']=json.dumps([task])
listing=await queue.listing(session,'running','grouping',None,None,1,20)
assert listing['total']==1 and listing['items'][0]['object_id']==article.id
store.pop('ap:risk:active')
assert (await queue.listing(session,'running','grouping',None,None,1,20))['items'][0]['state']=='unknown'
item.state='processing'; await session.commit()
store['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,cancel_id,1,20))['total']==1
store.pop('ap:risk:active')
from alphapulse.api.risk import reprocess_cancel
await reprocess_cancel(cancel_id,session)
shared=await session.get(NewsEvent,event_ids[0]); shared.review_config=None; shared.state='pending'; shared.due_at=now
await backfill.target(session,cancel_id,'events',shared); await session.commit()
listing=await queue.listing(session,'queued','events',None,cancel_id,1,20)
assert listing['total']==1 and listing['items'][0]['origin']=='realtime'
large=ReprocessJob(id=2147483648,state='failed',items=[],position=0,failures=[],settings={})
session.add(large); shared.review_config={'job_id':large.id}; shared.state='failed'; await session.commit()
listing=await queue.listing(session,'failed','events','history',large.id,1,20)
assert listing['total']==1
assert (await queue.listing(session,'failed',None,None,9223372036854775807,1,20))['total']==0
no_model=AsyncMock(side_effect=AssertionError('Empty windows must not call models'))
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)
no_model.assert_not_awaited()
assert calls==['luna','luna']
print('Backfill: event-scoped completion, preview snapshots, recheck, retry, pause, skip, restart, cancel, queue identity and BIGINT filters passed')
finally:
async with SessionLocal() as session:
for model in (ReprocessTarget,ReprocessItem,ReprocessJob,EventMember,NewsEvent,RiskSnapshot,GroupingVersion,SummaryArticle,NewsSummary,ArticleAnalysis,RiskSettings):
await session.execute(delete(model))
await session.execute(delete(NewsItem).where(NewsItem.id.in_(news_ids)))
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()
asyncio.run(main())
-86
View File
@@ -1,86 +0,0 @@
"""Exercise atomic event and fact-package corrections in a disposable database."""
import asyncio
from datetime import UTC, datetime
import os
from urllib.parse import urlparse
from sqlalchemy import select, delete, func
from alphapulse.core.db import SessionLocal, engine
from alphapulse.models import NewsItem
from alphapulse.models.risk import (NewsSummary, ArticleAnalysis, SummaryArticle, NewsEvent, EventMember,
EventCorrectionRecord, PackageCorrection, EventVersion, SummaryVersion)
from alphapulse.news_risk import event_research, pipeline
from alphapulse.news_risk.research import Research
from alphapulse.news_risk.contracts import Correction
from alphapulse.news_risk.corrections import apply_correction
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); packages=[]; events=[]; news_ids=[]
try:
async with SessionLocal() as session:
for i in (1,2):
news=NewsItem(source_code='wscn_news',external_id='ci-event-'+str(i),url='https://example.invalid/'+str(i),
title='Incident '+str(i),summary='',content_text='Original '+str(i),content_hash=str(i),published_at=now)
session.add(news); await session.flush(); news_ids.append(news.id)
analysis=ArticleAnalysis(news_id=news.id,content_hash=news.content_hash,state='done',input={
'title':news.title,'content':news.content_text,'published_at':now.isoformat(),'progress_at':now.isoformat()})
session.add(analysis); await session.flush()
package=NewsSummary(title=news.title,fact=news.title,category='安全',tags=['exchange'],entities=[],
published_at=now,updated_at=now,changed_at=now,due_at=now,revision=1,state='done')
session.add(package); await session.flush(); packages.append(package.id)
session.add(SummaryArticle(summary_id=package.id,analysis_id=analysis.id,active=True))
await event_research.attach(session,package,pipeline.EventTarget(event_id=None,group_key=str(i),
title=news.title,fact=news.title,reason='Separate until verified'),{},True,False,None)
await session.flush()
membership=await session.get(EventMember,package.id); events.append(membership.event_id)
await session.commit()
async with SessionLocal() as session:
original=[await session.get(NewsEvent,i) for i in events]
proposal=event_research.EventCorrection(events=[{'id':e.id,'revision':e.revision} for e in original],
replacements=[{'keep_id':min(events),'title':'Same concrete incident','fact':'Disclosure and response',
'package_ids':packages,'reason':'Explicit response to disclosure'}],reason='Verified common event')
research=Research(); research.read_event_ids={max(events)}; research.read_event_versions={max(events):original[1].revision}
# No reader ID is enough without the actual version observed by the tool.
bad=Research(); bad.read_event_ids={max(events)}
try: await event_research.correct(session,proposal,min(events),bad,{})
except ValueError as error: assert '实际读取' in str(error)
else: raise AssertionError('Unobserved correction version accepted')
await event_research.correct(session,proposal,min(events),research,{})
await session.commit()
assert (await session.get(NewsEvent,max(events))).state=='retired'
assert set(await session.scalars(select(EventMember.event_id)))=={min(events)}
assert (await session.get(NewsEvent,min(events))).revision==2
assert await session.scalar(select(func.count()).select_from(EventCorrectionRecord))==1
current=await session.get(NewsEvent,min(events))
split=event_research.EventCorrection(events=[{'id':current.id,'revision':current.revision}],replacements=[
{'keep_id':current.id,'title':'One','fact':'One','package_ids':[packages[0]],'reason':'Split'},
{'keep_id':None,'title':'Two','fact':'Two','package_ids':[packages[1]],'reason':'Split'}],reason='Repeat evidence')
try: await event_research.correct(session,split,current.id,research,{})
except ValueError as error: assert '反复拆合' in str(error)
else: raise AssertionError('Same-source correction loop accepted')
facts=[await session.get(NewsSummary,i) for i in packages]
analyses=list(await session.scalars(select(ArticleAnalysis).where(ArticleAnalysis.news_id.in_(news_ids))))
correction=Correction(packages=[{'id':p.id,'revision':p.revision} for p in facts],replacements=[{
'keep_id':min(packages),'title':'Same fact','fact':'Same fact','category':'安全','tags':['exchange'],'content_kind':'report',
'assignments':[{'analysis_id':a.id,'evidence':a.input['content']} for a in analyses]}],reason='Duplicate fact')
outputs=await apply_correction(session,correction,min(packages),{max(packages)},{'test':True})
await session.commit()
assert outputs==[min(packages)]
assert (await session.get(NewsSummary,max(packages))).replacement_ids==outputs
assert (await session.get(NewsSummary,min(packages))).state=='done'
assert (await session.get(NewsEvent,min(events))).state=='pending'
assert await session.scalar(select(func.count()).select_from(SummaryVersion))==0
assert await session.scalar(select(func.count()).select_from(EventVersion))==0
assert await session.scalar(select(func.count()).select_from(NewsItem).where(NewsItem.id.in_(news_ids)))==2
print('Corrections: single-package events, observed revisions, full assignment, canonical merge, repeat guard and fact-only package correction passed')
finally:
async with SessionLocal() as session:
for model in (EventCorrectionRecord,PackageCorrection,EventMember,NewsEvent,SummaryArticle,NewsSummary,ArticleAnalysis):
await session.execute(delete(model))
await session.execute(delete(NewsItem).where(NewsItem.id.in_(news_ids))); await session.commit()
await engine.dispose()
asyncio.run(main())
+3 -3
View File
@@ -237,7 +237,7 @@ async def main():
from alphapulse.news_research import api
from alphapulse.models import AiProvider, SkillNode, SkillVersion
from alphapulse.core.crypto import encrypt
from alphapulse.news_risk import service as legacy_service
from alphapulse.news_research import model as model_service
app = FastAPI()
app.include_router(api.router)
@@ -355,7 +355,7 @@ async def main():
)
return schema.model_validate(result), {}
with patch.object(legacy_service, "complete", validation):
with patch.object(model_service, "complete", validation):
response = await client.post("/api/risk/enable")
assert response.status_code == 200, response.text
assert (await client.get("/api/risk/settings")).json()["data"][
@@ -380,7 +380,7 @@ async def main():
assert response.status_code == 200
return await validation(provider, skill, payload, schema)
with patch.object(legacy_service, "complete", interrupted_validation):
with patch.object(model_service, "complete", interrupted_validation):
response = await client.post("/api/risk/enable")
assert response.status_code == 409, response.text
assert (await client.get("/api/risk/settings")).json()["data"][
+10 -4
View File
@@ -6,10 +6,9 @@ from sqlalchemy import DateTime, Integer, MetaData, String, insert, text
from sqlalchemy.dialects.postgresql import JSONB
from alphapulse.core.config import get_settings
from alphapulse.core.db import Base, engine
from alphapulse.core.db import engine
from alphapulse.crawler.remove_cls import AI_TABLES, OPTIONAL_AI_TABLES, cleanup
from alphapulse.models import NewsItem, Source
import alphapulse.models.risk # noqa: F401
import alphapulse.news_research.models # noqa: F401
@@ -26,8 +25,15 @@ async def main():
await connection.execute(insert(NewsItem).values(id=identity, source_code=source, external_id='cleanup-ci',
url='https://example.com', published_at=datetime.now(UTC), content_hash=str(identity)))
assert set((await cleanup(connection))['absent_legacy_tables']) == set(OPTIONAL_AI_TABLES)
for name in OPTIONAL_AI_TABLES:
await connection.run_sync(lambda sync, name=name: Base.metadata.tables[name].create(sync))
# These archived tables have no runtime ORM models; exercise their
# optional cleanup with their historical schema in the disposable DB.
await connection.execute(text('''CREATE TABLE news_ai_event_evidence (
id BIGSERIAL PRIMARY KEY,
version_id BIGINT NOT NULL REFERENCES news_ai_event_versions(id),
data JSONB NOT NULL, fetched_at TIMESTAMPTZ NOT NULL DEFAULT now())'''))
await connection.execute(text('''CREATE TABLE news_ai_event_corrections (
id BIGSERIAL PRIMARY KEY, fingerprint VARCHAR(64) UNIQUE NOT NULL,
input JSONB NOT NULL, result JSONB NOT NULL)'''))
reflected = MetaData()
await connection.run_sync(lambda sync: reflected.reflect(sync, only=list((*AI_TABLES, *OPTIONAL_AI_TABLES))))
for table in reflected.sorted_tables:
-170
View File
@@ -1,170 +0,0 @@
"""Exercise event-scoped news research using only the disposable CI database."""
import asyncio
from datetime import UTC, datetime, timedelta
import json
import os
from unittest.mock import AsyncMock, patch
from urllib.parse import urlparse
import httpx
from fastapi import FastAPI, HTTPException
from sqlalchemy import delete, func, select
from alphapulse.ai import llm
from alphapulse.ai.skills import create_node, add_version, published_skills
from alphapulse.api.settings import delete_provider
from alphapulse.core.db import SessionLocal, engine
from alphapulse.models import AiProvider, NewsItem, SkillNode, SkillVersion
from alphapulse.models.risk import (RiskSettings, RiskSnapshot, ArticleAnalysis, NewsSummary,
SummaryArticle, SummaryVersion, GroupingVersion, NewsEvent, EventMember, EventVersion, EventEvidence)
from alphapulse.news_risk import pipeline, worker, briefing, event_research
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)
async with SessionLocal() as session:
assert not await session.scalar(select(func.count()).select_from(RiskSettings))
provider=AiProvider(name='ci-news',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(); provider_id=provider.id
skills={}
for role,(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[role+'_skill_id']=node.id
await session.flush(); assert not await published_skills(session)
session.add(RiskSettings(id=1,enabled=True,start_at=now-timedelta(minutes=2),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',third_reasoning_effort='xhigh',**skills))
for key,at in [('old',now-timedelta(days=1)),('first',now-timedelta(minutes=1)),('repost',now-timedelta(seconds=30))]:
session.add(NewsItem(source_code='wscn_news',external_id='ci-news-'+key,url='https://example.invalid/'+key,
title='' if key=='first' else 'Exchange reports incident',summary='',content_text='original',content_hash=key,published_at=at))
await session.commit()
calls=[]; broken=False; change=False
async def stream(provider,system,messages,tools,effort):
nonlocal change
assert effort=={'luna':'low','reviewer':'high','senior':'xhigh'}[provider.model]
data=json.loads(messages[0]['text']); calls.append((provider.model,data))
if 'article' in data:
candidate=next(iter(data['candidates']),None)
target={'event_id':candidate['main_event_id'] if candidate else None,'group_key':None if candidate else 'incident',
'title':'Exchange incident','fact':'Concrete incident','reason':'Same concrete occurrence'}
result=pipeline.Grouping(reason='Same fact',packages=[dict(event=target,
package_id=candidate['id'] if candidate else None,group_key=None if candidate else 'fact',
title='Exchange reports incident',category='安全',entities=['Exchange'],fact='Concrete incident',
evidence=data['article']['title'] or data['article']['content'],urgent=True,tags=['exchange'],content_kind='report',
change_kind='repost' if candidate and data['article']['revision_no']==1 else 'correction' if candidate else 'new_fact')])
elif data.get('task')=='event_research':
assert provider.model=='reviewer'
if broken: raise ValueError('Synthetic research failure')
if change:
change=False
async with SessionLocal() as session:
event=await session.get(NewsEvent,data['id'])
event_research.schedule(event,datetime.now(UTC),True); await session.commit()
refs=[dict(article_id=a['id'],quote=a['title'] or a['content']) for a in data['articles']]
result=event_research.EventResult(id=data['id'],title='Exchange reports incident',summary='Event summary',category='安全',entities=['Exchange'],
risk_stars=4,importance_stars=4,direction='unknown',verification='reported',confidence='low',reasoning='Synthetic',uncertainty='Unknown',
facts='Original facts',timeline='Incident',asset_impacts='Unknown',opinions='Wait',counter_evidence='Unknown',watchpoints='Response',
claims=[dict(statement='Incident',verdict='supported',evidence_refs=refs)],evidence_refs=refs,
timeline_points=[dict(package_id=p['id'],package_revision=p['revision'],occurred_at=None,time_precision='unknown',
time_text='',reported_at=None,description=p['fact'],verification='partial',
evidence_refs=[r for r in refs if r['article_id'] in p['article_ids']]) for p in data['packages']])
elif data.get('task')=='ranking':
result=briefing.Ranked(reading=[dict(kind=p['kind'],id=p['id'],revision=p['revision'],priority=90,reason='Synthetic') for p in data['packages']],notes='Notes')
else:
trend=briefing.Trend(summary='Unknown',conditions='Unknown',counter_evidence='Unknown',watchpoints='Unknown')
result=briefing.Synthesis(summary='Synthetic',market='Unknown',short_term=trend,medium_term=trend,references=[])
yield llm.Completion(tool_calls=[{'name':'submit','arguments':result.model_dump(mode='json')}],usage={'input_tokens':10})
try:
with patch.object(llm,'stream',stream):
assert await worker.analyze()=='done'
async with SessionLocal() as session:
event=await session.scalar(select(NewsEvent)); event_id=event.id; revision=event.revision
assert await worker.analyze()=='done'
async with SessionLocal() as session:
event=await session.get(NewsEvent,event_id)
assert event.revision==revision, 'Pure repost triggered event research'
package=await session.scalar(select(NewsSummary)); package_id=package.id
assert package.state=='done' and package.result is None
assert await session.scalar(select(func.count()).select_from(SummaryVersion))==0
assert await session.scalar(select(func.count()).select_from(SummaryArticle))==2
await event_research.review(event_id)
async with SessionLocal() as session:
event=await session.get(NewsEvent,event_id)
assert event.state=='done' and event.result['risk_stars']==4
version=await session.scalar(select(EventVersion))
assert len(version.input['articles'])==2
news=await session.scalar(select(NewsItem).where(NewsItem.external_id=='ci-news-first'))
news.content_hash='revised'; news.content_text='correction'; news.revision_no=2; news.source_updated_at=datetime.now(UTC)
event_research.schedule(event,datetime.now(UTC),True)
await session.commit()
before=len(calls)
await event_research.review(event_id)
assert len(calls)==before, 'Outdated originals reached a model'
async with SessionLocal() as session:
blocked=await session.get(NewsEvent,event_id)
assert blocked.state=='failed' and '原文已修订' in blocked.error
assert await worker.analyze()=='done'
await event_research.review(event_id)
async with SessionLocal() as session:
event=await session.get(NewsEvent,event_id)
version=await session.scalar(select(EventVersion).order_by(EventVersion.id.desc()).limit(1))
assert len(version.input['articles'])==2 and any(a['content']=='correction' for a in version.input['articles'])
event_research.schedule(event,datetime.now(UTC),True); await session.commit()
published=event.published_revision
change=True
await event_research.review(event_id)
async with SessionLocal() as session:
event=await session.get(NewsEvent,event_id)
assert event.state=='pending' and event.published_revision==published
assert (await session.scalar(select(EventVersion).order_by(EventVersion.id.desc()).limit(1))).state=='superseded'
broken=True
await event_research.review(event_id)
async with SessionLocal() as session:
event=await session.get(NewsEvent,event_id)
assert event.state=='failed' and event.result['summary']=='Event summary'
event.state='pending'; await session.commit()
broken=False
await event_research.review(event_id)
await worker.outlook()
async with SessionLocal() as session:
snapshot=await session.scalar(select(RiskSnapshot).order_by(RiskSnapshot.id.desc()).limit(1))
assert snapshot.state=='done' and [p['kind'] for p in snapshot.result['inputs']]==['event']
assert (await session.get(AiProvider,provider_id)).model=='shared'
from alphapulse.api import risk as api
app=FastAPI(); app.include_router(api.router); app.dependency_overrides[api.require_admin]=lambda:None
with patch.object(api.redis,'get',AsyncMock(return_value=None)):
async with httpx.AsyncClient(transport=httpx.ASGITransport(app),base_url='http://test') as client:
for path in ('/api/risk/settings','/api/risk/overview','/api/risk/events',f'/api/risk/events/{event_id}',
'/api/risk/packages',f'/api/risk/packages/{package_id}'):
response=await client.get(path); assert response.status_code==200,(path,response.text)
detail=(await client.get(f'/api/risk/events/{event_id}')).json()['data']
assert len(detail['packages'])==1 and detail['versions']
facts=(await client.get(f'/api/risk/packages/{package_id}')).json()['data']['event']
assert facts['article_count']==2 and 'risk_stars' not in facts and 'result' not in facts
assert (await client.get('/api/risk/events',params={'hours':25})).status_code==422
assert (await client.get('/api/risk/summaries')).status_code==404
async with SessionLocal() as session:
try: await delete_provider(provider_id,session)
except HTTPException as error: assert error.status_code==409
else: raise AssertionError('Enabled provider deleted')
settings=await session.get(RiskSettings,1); settings.enabled=False; await session.commit()
await delete_provider(provider_id,session)
assert len([c for c in calls if c[0]=='luna'])==3
assert any(not data['article']['title'] and data['article']['content'] for model,data in calls if model=='luna')
print('Event research: grouping with untitled originals, repost suppression, originals, revision guards, retained success, retries, event-only outlook and APIs passed')
finally:
async with SessionLocal() as session:
for model in (EventEvidence,EventVersion,EventMember,NewsEvent,GroupingVersion,SummaryArticle,NewsSummary,ArticleAnalysis,RiskSnapshot,RiskSettings):
await session.execute(delete(model))
await session.execute(delete(NewsItem).where(NewsItem.external_id.startswith('ci-news-')))
await session.execute(delete(AiProvider).where(AiProvider.id==provider_id))
await session.execute(SkillNode.__table__.update().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.commit()
await engine.dispose()
asyncio.run(main())
-43
View File
@@ -132,49 +132,6 @@ class BuildTests(unittest.TestCase):
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
class NewsResetTests(unittest.TestCase):
def test_preview_never_stops_worker_or_confirms_deletion(self):
with tempfile.TemporaryDirectory() as temporary:
directory = Path(temporary)
build = {'version':'20261101-aaaaaaaa', 'images': {'backend': {'digest': 'synthetic'}}}
with patch.object(deploy, 'INSTANCE', directory), patch.object(deploy, 'validate_images'), patch.object(deploy, 'run', return_value='{}') as run:
deploy.reset_news_ai(build)
self.assertEqual(run.call_count, 2)
self.assertTrue(run.call_args.kwargs['input'].startswith('CONFIRMED = False'))
self.assertFalse(any('stop' in call.args for call in run.call_args_list))
def test_reset_refuses_pending_deployment(self):
with tempfile.TemporaryDirectory() as temporary:
directory = Path(temporary)
ci.save(directory / 'deployment.pending.json', {})
with patch.object(deploy, 'INSTANCE', directory), patch.object(deploy, 'validate_images'), patch.object(deploy, 'run') as run:
with self.assertRaisesRegex(RuntimeError, 'pending deployment'):
deploy.reset_news_ai({}, True)
run.assert_not_called()
def test_full_reset_requires_real_accepted_sample_before_docker(self):
with patch.object(deploy,'validate_images') as images, patch.object(deploy,'run') as run:
with self.assertRaisesRegex(RuntimeError,'验收尚未通过'):
deploy.reset_news_derived({},True)
images.assert_not_called()
run.assert_not_called()
def test_full_reset_preview_keeps_worker_and_data(self):
with tempfile.TemporaryDirectory() as temporary:
with patch.object(deploy,'INSTANCE',Path(temporary)), patch.object(deploy,'validate_images'), patch.object(deploy,'run',return_value='{}') as run:
deploy.reset_news_derived({'version':'synthetic'})
self.assertEqual(run.call_count,2)
self.assertIn('False,None,None',run.call_args.kwargs['input'])
self.assertFalse(any('stop' in call.args for call in run.call_args_list))
def test_full_reset_refuses_pending_deployment(self):
with tempfile.TemporaryDirectory() as temporary:
directory=Path(temporary)
ci.save(directory / 'deployment.pending.json',{})
with patch.object(deploy,'INSTANCE',directory),patch.object(deploy,'validate_images'),patch.object(deploy,'run') as run:
with self.assertRaisesRegex(RuntimeError,'pending deployment'):
deploy.reset_news_derived({})
run.assert_not_called()
class RecoveryTests(unittest.TestCase):