Files
AlphaPulse/scripts/accept_news_scenario.py
T
eezd 89da4df785
Development CI / check-and-build (push) Successful in 3m23s
GitHub Git mirror / mirror (push) Successful in 6s
Development CI / deploy-development (push) Successful in 5s
feat(news): restrict research to local sources and process frozen batches
2026-11-05 20:43:31 +08:00

335 lines
20 KiB
Python

"""Fixed chronological acceptance; credentials and writes stay in disposable CI."""
import asyncio
from contextlib import ExitStack
from datetime import datetime
import hashlib
import json
import os
from pathlib import Path
from unittest.mock import patch
from urllib.parse import urlparse
from sqlalchemy import func, select
from alphapulse.ai import llm
from alphapulse.ai.skills import create_node, add_version
from alphapulse.core.crypto import encrypt, decrypt
from alphapulse.core.db import SessionLocal, engine
from alphapulse.models import AiProvider, NewsItem, SkillNode
from alphapulse.news_research import service
from alphapulse.news_research.contracts import INDEX_SKILL, ASSIGN_SKILL, RESEARCH_SKILL
from alphapulse.news_research.models import Package, Attempt, Event, Version, Membership, Reference, VocabularyVersion
from accept_news_research import Budget
MODELS = (Package, Attempt, Event, Version, Membership, Reference, VocabularyVersion)
def digest(value):
return hashlib.sha256(json.dumps(value, sort_keys=True, default=str).encode()).hexdigest()
def save(path, data):
temporary = path.with_suffix('.pending')
with open(temporary, 'w', opener=lambda p, f: os.open(p, f, 0o600)) as file:
json.dump(data, file, ensure_ascii=False, indent=2, default=str)
file.flush()
os.fsync(file.fileno())
temporary.replace(path)
def validate_manifest(manifest):
ids = [n['id'] for n in manifest['news']]
batches = [i for batch in manifest['batches'] for i in batch]
if len(ids) != 12 or len(set(ids)) != 12 or sorted(ids) != sorted(batches):
raise ValueError('场景必须包含12条唯一新闻,且每条恰好出现在一个批次')
by_id = {n['id']: n for n in manifest['news']}
if len({urlparse(s['url']).hostname for s in manifest.get('official_sources', [])}) < 2:
raise ValueError('必须冻结两个独立官方域名')
previous = None
for batch in manifest['batches']:
times = [datetime.fromisoformat(by_id[i]['published_at']) for i in batch]
if not times or (previous and min(times) < previous):
raise ValueError('批次必须按报道时间排序')
previous = max(times)
if manifest['baseline_requests'] != 199 or manifest['additional_limit'] != 120:
raise ValueError('预算必须保留历史199次并追加最多120次')
async def snapshot():
async with SessionLocal() as session:
return {m.__tablename__: [{c.name: getattr(row, c.name) for c in m.__table__.columns}
for row in await session.scalars(select(m).order_by(*m.__table__.primary_key.columns))]
for m in MODELS}
async def freeze_config(config):
async with SessionLocal() as session:
return {stage: (await service.model_config(session, config, stage))[2]
for stage in ('index', 'research')}
async def initialize(credentials):
config = {'concurrency': 3}
async with SessionLocal() as session:
if await session.scalar(select(func.count()).select_from(NewsItem)) or await session.scalar(select(func.count()).select_from(SkillNode)):
raise ValueError('必须使用空的独立CI数据库')
for stage, content in [('index', INDEX_SKILL + '\n' + ASSIGN_SKILL), ('research', RESEARCH_SKILL)]:
fields = credentials[stage]
provider = AiProvider(name='scenario-' + stage, protocol=fields['protocol'], base_url=fields['base_url'],
api_key_encrypted=encrypt(fields['api_key']), api_key_hint='isolated', model=fields['model'],
max_tokens=fields['max_tokens'], context_window=fields['context_window'])
session.add(provider)
await session.flush()
skill = await create_node(session, None, 'skill', 'scenario-' + stage, scope='news')
version = await add_version(session, skill, content, 'user', 'Frozen isolated acceptance')
skill.published_version_id = version.id
config[stage] = {'provider_id': provider.id, 'model': 'gpt-6.1-terra',
'reasoning_effort': 'medium' if stage == 'index' else 'xhigh', 'skill_id': skill.id}
session.add(VocabularyVersion(revision=1, entries=[{'canonical': '币安', 'aliases': ['Binance']}]))
await session.commit()
return config
def assertions(manifest, tables, checkpoints):
packages = tables['news_research_packages']
members = tables['news_research_memberships']
versions = {v['id']: v for v in tables['news_research_versions']}
links = {n['id']: {m['event_id'] for m in members for p in packages if m['package_id'] == p['id'] and p['news_id'] == n['id']}
for n in manifest['news']}
checks = []
for group in manifest['must_link']:
checks.append({'name': 'same_event', 'news_ids': group, 'passed': bool(set.intersection(*(links[i] for i in group)))})
for a, b in manifest['must_separate']:
checks.append({'name': 'different_events', 'news_ids': [a, b], 'passed': bool(links[a] and links[b]) and not bool(links[a] & links[b])})
main = links[manifest['must_link'][0][0]]
natural = {v['id']: v for c in checkpoints for v in c['versions'] if v['event_id'] in main and v['state'] == 'done'}
revisions = {v['revision'] for v in natural.values()}
checks.append({'name': 'three_natural_revisions', 'passed': len(revisions) >= 3})
latest = max(natural.values(), key=lambda v: v['id'], default={})
covered = {p['news_id'] for p in latest.get('input', {}).get('packages', [])}
checks.append({'name': 'latest_natural_version_covers_all_main_members', 'passed': set(manifest['must_link'][0]).issubset(covered)})
for consumer, target in manifest['references']:
independent = links[consumer] - links[target]
refs = [r for r in tables['news_research_references'] if versions[r['version_id']]['event_id'] in independent
and versions[r['target_version_id']]['event_id'] in links[target]]
event_rows = {e['id']: e for e in tables['news_research_events']}
pinned = [r for r in refs if versions[r['version_id']]['state'] == 'done'
and versions[r['target_version_id']]['state'] == 'done' and any(
v['event_id'] == versions[r['target_version_id']]['event_id']
and v['revision'] > versions[r['target_version_id']]['revision']
and v['id'] > r['version_id'] for v in natural.values())]
stable = bool(pinned) and all(
event_rows.get(versions[r['target_version_id']]['event_id'], {}).get('revision', 0)
> versions[r['target_version_id']]['revision']
and not any(v['event_id'] == versions[r['version_id']]['event_id']
and v['id'] > r['version_id'] for v in versions.values())
for r in pinned)
checks.append({'name': 'pinned_reference_without_cascade', 'passed': stable})
checks.append({'name': 'all_completed', 'passed': len(packages) == 12 and all(p['state'] == 'done' for p in packages)
and all(e['state'] == 'done' for e in tables['news_research_events'])})
return checks
async def reference_api_assertion(manifest, tables):
from alphapulse.news_research.api import version_references
packages = {p['id']: p['news_id'] for p in tables['news_research_packages']}
versions = {v['id']: v for v in tables['news_research_versions']}
checks = []
async with SessionLocal() as session:
for consumer, target in manifest['references']:
consumers = {m['event_id'] for m in tables['news_research_memberships'] if packages[m['package_id']] == consumer}
targets = {m['event_id'] for m in tables['news_research_memberships'] if packages[m['package_id']] == target}
refs = [r for r in tables['news_research_references']
if versions[r['version_id']]['event_id'] in consumers - targets
and versions[r['target_version_id']]['event_id'] in targets]
passed = False
for ref in refs:
actual = await version_references(session, ref['version_id'])
passed |= any(r['version_id'] == ref['target_version_id'] and r['changed'] for r in actual)
checks.append({'name': 'reference_api_changed_and_pinned', 'passed': passed})
return checks
async def _run(args):
limit = request_limit(args)
url = urlparse(os.environ['DATABASE_URL'])
if (url.username, url.password, url.path) != ('ci', 'ci-only', '/ci') or url.hostname not in ('localhost', '127.0.0.1'):
raise ValueError('只能写入独立本地CI数据库')
manifest = json.loads(args.scenario_manifest.read_text())
validate_manifest(manifest)
baseline = Path(manifest['baseline_counter'])
if int(baseline.read_text()) != 199 or hashlib.sha256(baseline.read_bytes()).hexdigest() != manifest['baseline_counter_sha256']:
raise ValueError('历史计数不匹配')
credentials = json.loads(args.credentials.read_text())
state_path = args.output / 'state.json'
if args.resume:
state = json.loads(state_path.read_text())
if state['manifest_sha256'] != digest(manifest) or state['credentials_sha256'] != digest(credentials):
raise ValueError('冻结输入或配置已变化,不能续跑')
if not args.budget.exists() or int(args.budget.read_text()) < state['requests']:
raise ValueError('续跑计数缺失或回退')
if int(args.budget.read_text()) > limit:
raise ValueError('请求账本已超过所选累计上限,请显式提供已授权上限')
if await freeze_config(state['config']) != state['rules']:
raise ValueError('已发布Skill或模型配置已变化')
else:
if args.output.exists() or args.budget.exists():
raise ValueError('场景目录或计数已存在,请使用--resume')
args.output.mkdir(mode=0o700)
config = await initialize(credentials)
state = {'manifest_sha256': digest(manifest), 'credentials_sha256': digest(credentials), 'config': config,
'rules': await freeze_config(config), 'batch': 0, 'checkpoints': [], 'requests': 0}
args.budget.write_text('0'); args.budget.chmod(0o600)
save(state_path, state)
async with SessionLocal() as session:
for stage in ('index', 'research'):
provider = await session.get(AiProvider, state['config'][stage]['provider_id'])
actual = {k: getattr(provider, k) for k in ('protocol', 'base_url', 'model', 'max_tokens', 'context_window')}
actual['api_key'] = decrypt(provider.api_key_encrypted)
if actual != credentials[stage]:
raise ValueError('隔离模型连接配置与冻结配置不一致')
vocabulary = list(await session.scalars(select(VocabularyVersion)))
if len(vocabulary) != 1 or vocabulary[0].revision != 1 or vocabulary[0].entries != [{'canonical': '币安', 'aliases': ['Binance']}]:
raise ValueError('冻结词库已变化')
allowed = {i for batch in manifest['batches'][:state['batch'] + 1] for i in batch}
originals = {n['id']: n for n in manifest['news']}
for row in await session.scalars(select(NewsItem)):
if row.id not in allowed:
raise ValueError('隔离库含未来批次或无关原文')
actual = {c.name: getattr(row, c.name) for c in NewsItem.__table__.columns}
if digest(actual) != digest(originals[row.id]):
raise ValueError('隔离原文与冻结清单不一致')
if args.resume:
async with SessionLocal() as session:
for attempt in await session.scalars(select(Attempt).where(Attempt.state == 'processing')):
attempt.state = 'failed'
attempt.error = 'Isolated acceptance interrupted before checkpoint; retry preserves successful stages'
package = await session.get(Package, attempt.package_id)
package.state = attempt.stage + '_failed'
for version in await session.scalars(select(Version).where(Version.state == 'processing')):
version.state = 'failed'
version.error = 'Isolated acceptance interrupted before checkpoint'
event = await session.get(Event, version.event_id)
event.state = 'failed'
await session.commit()
config = state['config']
# An exclusive run lock prevents concurrent resumes from scheduling duplicate work.
import fcntl
with (args.output / '.run.lock').open('w') as lock:
fcntl.flock(lock.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
state['request_limit'] = limit
save(state_path, state)
budget = Budget(args.budget, limit)
async def process(identifier):
await service.process_package(identifier, config)
async def research_pending():
async with SessionLocal() as session:
pending = list(await session.scalars(select(Event.id).where(Event.state == 'pending').order_by(Event.id)))
for identifier in pending:
await service.process_event(identifier, config, force=True)
by_id = {n['id']: n for n in manifest['news']}
with ExitStack() as stack:
for name in ('_stream_openai', '_stream_anthropic'):
stack.enter_context(patch.object(llm, name, budget.wrap(getattr(llm, name))))
with ExitStack():
if args.repair_references:
if state['batch'] >= len(manifest['batches']):
raise ValueError('引用修复须在最终自然更新批次之前执行')
save(args.output / f'before-reference-repair-{int(args.budget.read_text())}.json', await snapshot())
async with SessionLocal() as session:
node = await session.get(SkillNode, config['research']['skill_id'])
version = await add_version(session, node, RESEARCH_SKILL, 'user', 'Repair missing explicit incident reference')
node.published_version_id = version.id
consumers = list(await session.scalars(select(Membership.event_id).join(Package, Package.id == Membership.package_id).where(Package.news_id == manifest['references'][0][0])))
main_ids = set(await session.scalars(select(Membership.event_id).join(Package, Package.id == Membership.package_id).where(Package.news_id == manifest['references'][0][1])))
for identifier in consumers:
if identifier not in main_ids:
event = await session.get(Event, identifier)
event.revision += 1
event.state = 'pending'
await session.commit()
state['rules'] = await freeze_config(config)
save(state_path, state)
for identifier in consumers:
if identifier not in main_ids:
await service.process_event(identifier, config, force=True)
state['requests'] = int(args.budget.read_text())
save(state_path, state)
while state['batch'] < len(manifest['batches']) and int(args.budget.read_text()) < limit:
batch = manifest['batches'][state['batch']]
async with SessionLocal() as session:
for identifier in batch:
existing = await session.get(NewsItem, identifier)
if existing:
if existing.content_hash != by_id[identifier]['content_hash']:
raise ValueError('已导入原文发生变化')
else:
values = {k: v for k, v in by_id[identifier].items() if k in NewsItem.__table__.columns}
for k, v in list(values.items()):
if k.endswith('_at') and v:
values[k] = datetime.fromisoformat(v)
session.add(NewsItem(**values))
await session.commit()
await service.enroll(session, batch)
packages = list(await session.scalars(select(Package).where(Package.news_id.in_(batch)).order_by(Package.id)))
for package in packages:
if package.state.endswith('_failed'):
latest = await session.scalar(select(Attempt).where(Attempt.package_id == package.id).order_by(Attempt.id.desc()).limit(1))
package.state = latest.stage + '_pending'
for event in await session.scalars(select(Event).where(Event.state == 'failed')):
event.state = 'pending'
await session.commit()
identifiers = [p.id for p in packages]
for offset in range(0, len(identifiers), 3):
await asyncio.gather(*(process(i) for i in identifiers[offset:offset + 3]))
for identifier in identifiers:
await process(identifier)
await research_pending()
tables = await snapshot()
checkpoint = {'batch': batch, 'versions': tables['news_research_versions']}
state['requests'] = int(args.budget.read_text())
complete = all(p['state'] == 'done' for p in tables['news_research_packages']) and all(e['state'] == 'done' for e in tables['news_research_events'])
if complete:
state['checkpoints'].append(checkpoint)
state['batch'] += 1
save(state_path, state)
save(args.output / f'attempt-{len(list(args.output.glob("attempt-*.json"))):03}.json', tables)
print(json.dumps({'batch': state['batch'], 'requests': state['requests'], 'complete': complete}), flush=True)
if not complete:
break
tables = await snapshot()
checks = assertions(manifest, tables, state['checkpoints'])
checks.extend(await reference_api_assertion(manifest, tables))
checks.append({'name': 'station_only_tools', 'passed': all(r['tool'] not in ('search_web', 'read_web') for v in tables['news_research_versions'] for r in v.get('evidence', []))})
report = {'quality_accepted': False, 'long_running_quality_verified': False, 'automated_passed': all(c['passed'] for c in checks),
'assertions': checks, 'requests_new': int(args.budget.read_text()), 'requests_historical': 199,
'requests_cumulative': 199 + int(args.budget.read_text()), 'new_cap': limit, 'manifest': manifest,
'rules': state['rules'], 'checkpoints': state['checkpoints'], 'tables': tables,
'usage_note': '缺失usage不等于零;URL由验收提供;历史回放关闭外网;人工审阅后仅限定范围放行。'}
save(args.output / f'report-{len(list(args.output.glob("report-*.json"))):03}.json', report)
await engine.dispose()
def request_limit(args):
limit = getattr(args, 'request_limit', None)
if limit is None:
return 120 if args.finish else 90
if not args.resume or not args.finish or limit < 120:
raise ValueError('追加上限仅用于--resume --finish,且不能低于原120次')
return limit
async def run(args):
import fcntl
# Hold the lease before reading checkpoints or changing any run files.
lock_path = args.output.with_name(args.output.name + '.lock')
with open(lock_path, 'a', opener=lambda p, f: os.open(p, f, 0o600)) as lock:
fcntl.flock(lock.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
try:
await _run(args)
finally:
await engine.dispose()