refactor(news): remove retired research workflow
This commit is contained in:
1 parent
f3894c3728
commit
d79a10c2e6
69 files changed
+460
-6713
No files matched your search
@@ -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'])
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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"][
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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())
|
||||
@@ -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):
|
||||
|
||||
Reference in new issue
Block a user