335 lines
20 KiB
Python
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()
|