feat(news): index news packages and research persistent events
Extract reusable literal keywords once per source revision, assign complete news packages to long-lived events, and research all event members with immutable evidence and references. Add additive research storage, vocabulary snapshots, the two-stage workspace, isolated acceptance coverage, and a paused rollout with an initial fixed 50-news production trial.
This commit is contained in:
1 parent
07252fea0f
commit
4929a32a0a
69 files changed
+8215
-2092
No files matched your search
@@ -0,0 +1,131 @@
|
||||
"""Restore a frozen acceptance checkpoint into an empty local CI database only.
|
||||
|
||||
No network tools or model calls run here. Historical attempts retain their rules;
|
||||
new work uses explicitly supplied providers and newly frozen isolated Skills.
|
||||
"""
|
||||
import argparse
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from sqlalchemy import DateTime, func, select, text
|
||||
from alphapulse.core.db import SessionLocal, engine
|
||||
from alphapulse.models import NewsItem
|
||||
from accept_news_scenario import MODELS, digest, freeze_config, initialize, save, validate_manifest
|
||||
|
||||
|
||||
def prepare(report, tables):
|
||||
manifest = report['manifest']
|
||||
validate_manifest(manifest)
|
||||
ids = {p['news_id'] for p in tables['news_research_packages']}
|
||||
imported = set()
|
||||
batch_index = None
|
||||
for index, batch in enumerate(manifest['batches'], 1):
|
||||
imported.update(batch)
|
||||
if imported == ids:
|
||||
batch_index = index
|
||||
break
|
||||
if batch_index is None or any(p['state'] != 'done' for p in tables['news_research_packages']):
|
||||
raise ValueError('快照不是完整的已完成时间批次')
|
||||
if any(e['state'] not in ('done', 'retired') for e in tables['news_research_events']):
|
||||
raise ValueError('快照包含未完成事件')
|
||||
originals = {n['id']: n for n in manifest['news']}
|
||||
if any(p['content_hash'] != originals[p['news_id']]['content_hash'] for p in tables['news_research_packages']):
|
||||
raise ValueError('快照原文哈希与冻结样本不一致')
|
||||
versions = {v['id']: v for v in tables['news_research_versions']}
|
||||
if any(v['state'] == 'processing' for v in versions.values()):
|
||||
raise ValueError('不能从进行中的版本恢复')
|
||||
known = {v['id']: v for v in report['tables']['news_research_versions']}
|
||||
if any(digest(v) != digest(known.get(v['id'])) for v in versions.values()):
|
||||
raise ValueError('快照版本与原报告不一致')
|
||||
checkpoints = report['checkpoints'][:batch_index]
|
||||
if len(checkpoints) != batch_index or {v['id'] for v in checkpoints[-1]['versions']} != set(versions):
|
||||
raise ValueError('快照与报告时间检查点不一致')
|
||||
if any(n['id'] not in ids for v in versions.values() for n in v['input']['articles']):
|
||||
raise ValueError('快照包含未来原文')
|
||||
return manifest, [n for n in manifest['news'] if n['id'] in ids], batch_index, checkpoints
|
||||
|
||||
|
||||
def values(model, row):
|
||||
result = {c.name: row[c.name] for c in model.__table__.columns if c.name in row}
|
||||
for column in model.__table__.columns:
|
||||
if isinstance(column.type, DateTime) and isinstance(result.get(column.name), str):
|
||||
result[column.name] = datetime.fromisoformat(result[column.name])
|
||||
return result
|
||||
|
||||
|
||||
def counter_limit(report, count):
|
||||
limit = report.get('new_cap', report['manifest']['additional_limit'])
|
||||
if limit < report['manifest']['additional_limit'] or not report['requests_new'] <= count <= limit:
|
||||
raise ValueError('必须沿用未回退的原始请求账本及报告累计上限')
|
||||
return limit
|
||||
|
||||
|
||||
async def restore(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数据库')
|
||||
report = json.loads(args.report.read_text())
|
||||
tables = json.loads(args.snapshot.read_text())
|
||||
manifest, originals, batch, checkpoints = prepare(report, tables)
|
||||
if args.output.exists():
|
||||
raise ValueError('恢复目录必须是新目录,禁止覆盖旧报告')
|
||||
count = int(args.budget.read_text())
|
||||
limit = counter_limit(report, count)
|
||||
baseline = Path(manifest['baseline_counter'])
|
||||
if int(baseline.read_text()) != manifest['baseline_requests'] or hashlib.sha256(baseline.read_bytes()).hexdigest() != manifest['baseline_counter_sha256']:
|
||||
raise ValueError('历史账本已变化')
|
||||
async with SessionLocal() as session:
|
||||
for model in (NewsItem, *MODELS):
|
||||
if await session.scalar(select(func.count()).select_from(model)):
|
||||
raise ValueError('恢复数据库必须为空')
|
||||
credentials = json.loads(args.credentials.read_text())
|
||||
config = await initialize(credentials)
|
||||
async with SessionLocal() as session:
|
||||
await session.execute(NewsItem.__table__.insert(), [values(NewsItem, row) for row in originals])
|
||||
# Foreign keys require parents to be restored first; vocabulary was frozen by initialize.
|
||||
from alphapulse.news_research.models import Package, Event, Membership, Attempt, Version, Reference, VocabularyVersion
|
||||
# Compare content/revision, since the new row has a different creation time.
|
||||
old = [(r['revision'], r['entries']) for r in tables[VocabularyVersion.__tablename__]]
|
||||
new = [(r['revision'], r['entries']) for r in await _vocabulary(session)]
|
||||
if old != new:
|
||||
raise ValueError('恢复词库与冻结词库不一致')
|
||||
for model in (Package, Event, Membership, Attempt, Version, Reference):
|
||||
rows = tables[model.__tablename__]
|
||||
if rows:
|
||||
await session.execute(model.__table__.insert(), [values(model, row) for row in rows])
|
||||
for model in (NewsItem, Package, Event, Attempt, Version, Reference):
|
||||
# Table identifiers are fixed model metadata, never user input.
|
||||
name = model.__tablename__
|
||||
await session.execute(text(f"SELECT setval(pg_get_serial_sequence('{name}', 'id'), COALESCE(MAX(id), 1), MAX(id) IS NOT NULL) FROM {name}"))
|
||||
await session.commit()
|
||||
args.output.mkdir(mode=0o700)
|
||||
state = {'manifest_sha256': digest(manifest), 'credentials_sha256': digest(credentials), 'config': config,
|
||||
'rules': await freeze_config(config), 'batch': batch, 'checkpoints': checkpoints,
|
||||
'requests': count, 'request_limit': limit, 'external': [], 'restored_from': {
|
||||
'report_sha256': hashlib.sha256(args.report.read_bytes()).hexdigest(),
|
||||
'snapshot_sha256': hashlib.sha256(args.snapshot.read_bytes()).hexdigest(),
|
||||
'historical_rules_preserved': True, 'new_rules_require_model_revalidation': True}}
|
||||
save(args.output / 'state.json', state)
|
||||
print(json.dumps({'restored_news': len(originals), 'next_batch': batch, 'requests_new': count, 'model_calls': 0}))
|
||||
|
||||
|
||||
async def _vocabulary(session):
|
||||
from alphapulse.news_research.models import VocabularyVersion
|
||||
return [{'revision': v.revision, 'entries': v.entries} for v in await session.scalars(select(VocabularyVersion))]
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
for name in ('report', 'snapshot', 'credentials', 'output', 'budget'):
|
||||
parser.add_argument('--' + name, type=Path, required=True)
|
||||
async def main():
|
||||
try:
|
||||
await restore(parser.parse_args())
|
||||
finally:
|
||||
await engine.dispose()
|
||||
asyncio.run(main())
|
||||
Reference in new issue
Block a user