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:
eezd committed 2026-11-04 12:09:58 +08:00
1 parent 07252fea0f
commit 4929a32a0a
69 files changed
+8215 -2092

No files matched your search

+131
View File
@@ -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())