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

+383
View File
@@ -0,0 +1,383 @@
"""Bounded two-stage acceptance in an empty disposable CI database only."""
import argparse
import asyncio
from contextlib import ExitStack
from datetime import datetime
import fcntl
import hashlib
import json
import os
from pathlib import Path
from unittest.mock import patch
from urllib.parse import urlparse
from sqlalchemy import select, func
from alphapulse.ai import llm
from alphapulse.core.crypto import encrypt
from alphapulse.core.db import SessionLocal, engine
from alphapulse.models import AiProvider, NewsItem
from alphapulse.news_research import service
from alphapulse.news_research.models import (
Package,
Attempt,
Event,
Version,
Membership,
Reference,
VocabularyVersion,
)
class BudgetExceeded(RuntimeError):
pass
class Budget:
def __init__(self, path, limit=240):
self.path, self.limit = path, limit
def take(self):
with self.path.open("a+") as file:
fcntl.flock(file, fcntl.LOCK_EX)
file.seek(0)
count = int(file.read() or "0")
if count >= self.limit:
raise BudgetExceeded(f"真实模型请求已达{self.limit}次上限")
file.seek(0)
file.truncate()
file.write(str(count + 1))
file.flush()
os.fsync(file.fileno())
def wrap(self, original):
async def stream(*args, **kwargs):
self.take()
async for event in original(*args, **kwargs):
yield event
return stream
async def main(args):
if args.output.exists():
raise ValueError("报告已存在,请使用新路径,保留既有验收记录")
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")
):
raise ValueError("只能写入独立本地CI数据库")
raw = args.sample.read_bytes()
source = json.loads(raw)
news = source["news"] if args.synthetic else source["original_news"]
if len(news) != (12 if args.synthetic else 30):
raise ValueError("样本数量不符合冻结范围")
credentials = json.loads(args.credentials.read_text())
if not args.synthetic and credentials.get("searxng_url"):
from alphapulse.core.config import get_settings
get_settings().searxng_url = credentials["searxng_url"]
config = {"isolated_acceptance": True, "concurrency": 3}
async with SessionLocal() as session:
if await session.scalar(select(func.count()).select_from(NewsItem)):
raise ValueError("验收数据库必须为空")
for stage in ("index", "research"):
fields = credentials[stage]
provider = AiProvider(
name="accept-" + 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()
config[stage] = {
"provider_id": provider.id,
"model": "gpt-6.1-terra",
"reasoning_effort": "medium" if stage == "index" else "xhigh",
"skill_id": None,
}
session.add(
VocabularyVersion(
revision=1,
entries=[
{"canonical": "币安", "aliases": ["Binance"]},
{"canonical": "星桥交易所", "aliases": ["XBridge"]},
{"canonical": "中国外交部", "aliases": ["中华人民共和国外交部"]},
],
)
)
columns = set(NewsItem.__table__.columns.keys())
for item in news:
values = {k: v for k, v in item.items() if k in 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()
budget = Budget(args.budget)
batches = (
source["batches"]
if args.synthetic
else [
[n["id"] for n in sorted(news, key=lambda n: (n["published_at"], n["id"]))]
]
)
semaphore = asyncio.Semaphore(3)
checkpoints = []
async def indexed(identifier):
async with semaphore:
await service.process_package(identifier, config)
async def researched(identifier):
async with semaphore:
await service.process_event(identifier, config, force=True)
async def controlled_web(self, name, arguments):
if name in ("search_web", "read_web"):
return {"status": "unavailable", "reason": "虚构验收组不使用真实外部网络"}
return await original_tool(self, name, arguments)
original_tool = service.Research.tool
with ExitStack() as stack:
for name in ("_stream_openai", "_stream_anthropic"):
stack.enter_context(
patch.object(llm, name, budget.wrap(getattr(llm, name)))
)
if args.synthetic:
stack.enter_context(patch.object(service.Research, "tool", controlled_web))
for batch in batches:
if args.budget.exists() and int(args.budget.read_text() or "0") >= 240:
break
async with SessionLocal() as session:
await service.enroll(session, batch)
packages = list(
await session.scalars(
select(Package).where(Package.news_id.in_(batch))
)
)
ordered = {p.news_id: p.id for p in packages}
await asyncio.gather(*(indexed(ordered[n]) for n in batch))
for n in batch:
await service.process_package(ordered[n], config)
async with SessionLocal() as session:
pending = list(
await session.scalars(
select(Event.id).where(Event.state == "pending")
)
)
await asyncio.gather(*(researched(i) for i in pending))
async with SessionLocal() as session:
checkpoints.append(
{
"batch": batch,
"versions": [
(v.event_id, v.id, v.revision, v.state)
for v in await session.scalars(select(Version))
],
}
)
print(
json.dumps(
{
"group": "synthetic" if args.synthetic else "real",
"batch": batch,
"requests": int(args.budget.read_text()),
},
ensure_ascii=False,
),
flush=True,
)
async with SessionLocal() as session:
tables = {}
for model in (
Package,
Attempt,
Event,
Version,
Membership,
Reference,
VocabularyVersion,
):
tables[model.__tablename__] = [
{c.name: getattr(row, c.name) for c in model.__table__.columns}
for row in await session.scalars(select(model))
]
packages = tables["news_research_packages"]
memberships = tables["news_research_memberships"]
links = {
p["news_id"]: {m["event_id"] for m in memberships if m["package_id"] == p["id"]}
for p in packages
}
for item in news:
links.setdefault(item["id"], set())
assertions = []
must_link = (
source["must_link"]
if args.synthetic
else [
[463197, 463198, 463199, 463205],
[463206, 463208, 463218],
[463192, 463193],
[463201, 463202],
[463200, 463204],
[463209, 463212],
[463194, 463213],
[463216, 463220],
]
)
for group in must_link:
assertions.append(
{
"name": "same_event",
"news_ids": group,
"passed": bool(set.intersection(*(links[n] for n in group))),
}
)
if args.synthetic:
from itertools import combinations
for a, b in combinations(source["must_separate"], 2):
assertions.append(
{
"name": "different_events",
"news_ids": [a, b],
"passed": not (links[a] & links[b]),
}
)
versions = {v["id"]: v for v in tables["news_research_versions"]}
for a, b in source["references"]:
matching = [
ref
for ref in tables["news_research_references"]
if versions[ref["version_id"]]["event_id"] in links[a]
and versions[ref["target_version_id"]]["event_id"] in links[b]
]
assertions.append(
{
"name": "immutable_reference",
"news_ids": [a, b],
"passed": bool(matching)
and all(
versions[ref["target_version_id"]]["state"] == "done"
and versions[ref["target_version_id"]]["id"]
< max(
v["id"]
for v in versions.values()
if v["event_id"] in links[b] and v["state"] == "done"
)
for ref in matching
),
}
)
main_events = links[1001]
good = [
v
for v in versions.values()
if v["event_id"] in main_events and v["state"] == "done"
]
assertions.append(
{"name": "three_incremental_versions", "passed": len(good) == 3}
)
assertions.append(
{
"name": "repost_no_research",
"passed": len(checkpoints) >= 2
and checkpoints[0]["versions"] == checkpoints[1]["versions"],
}
)
if not args.synthetic:
from itertools import combinations
for a, b in combinations([463194, 463200, 463203, 463209, 463214], 2):
assertions.append(
{
"name": "independent_foreign_ministry_topics",
"news_ids": [a, b],
"passed": not (links[a] & links[b]),
}
)
report = {
"quality_accepted": False,
"automated_passed": all(a["passed"] for a in assertions)
and all(p["state"] == "done" for p in packages)
and all(e["state"] == "done" for e in tables["news_research_events"]),
"human_review_required": [
"事实与边界",
"时间精度与统计期",
"评级与冲突保留",
"引用原文证据",
],
"sample_sha256": hashlib.sha256(raw).hexdigest(),
"group": "synthetic" if args.synthetic else "real",
"original_news": news,
"model_choices": {
k: {f: v for f, v in config[k].items() if f != "provider_id"}
for k in ("index", "research")
},
"request_count_total": int(args.budget.read_text()),
"request_cap": 240,
"not_completed_news_ids": [
n["id"]
for n in news
if not any(
p["news_id"] == n["id"] and p["state"] == "done" for p in packages
)
],
"budget_blocked": {
name: [
row["id"]
for row in tables[name]
if "真实模型请求已达240次上限" in (row.get("error") or "")
]
for name in ("news_research_attempts", "news_research_versions")
},
"failed_processing": {
name: [
row["id"]
for row in tables[name]
if row["state"] == "failed"
and "真实模型请求已达240次上限" not in (row.get("error") or "")
]
for name in ("news_research_attempts", "news_research_versions")
},
"assertions": assertions,
"checkpoints": checkpoints,
"tables": tables,
"usage_note": "缺失usage不等于零消耗;请求数不是费用;人工复核后才可质量放行。",
}
args.output.write_text(
json.dumps(report, ensure_ascii=False, indent=2, default=str)
)
args.output.chmod(0o600)
await engine.dispose()
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
inputs = parser.add_mutually_exclusive_group(required=True)
inputs.add_argument("--sample", type=Path)
inputs.add_argument("--scenario-manifest", type=Path)
parser.add_argument("--resume", action="store_true")
parser.add_argument("--finish", action="store_true")
parser.add_argument("--request-limit", type=int)
parser.add_argument("--repair-references", action="store_true")
parser.add_argument("--credentials", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--budget", type=Path, required=True)
parser.add_argument("--synthetic", action="store_true")
args = parser.parse_args()
if args.scenario_manifest:
from accept_news_scenario import run
asyncio.run(run(args))
else:
if args.resume or args.finish or args.request_limit is not None:
parser.error("续跑选项仅用于场景验收")
asyncio.run(main(args))
+413
View File
@@ -0,0 +1,413 @@
"""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, SkillVersion
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
def official_assertions(manifest, tables, external):
packages = {p['id']: p['news_id'] for p in tables['news_research_packages']}
links = {n: {m['event_id'] for m in tables['news_research_memberships']
if packages[m['package_id']] == n} for n in manifest['external_news_ids']}
checks = []
for news_id, source in zip(manifest['external_news_ids'], manifest['official_sources'], strict=True):
valid = False
for version in tables['news_research_versions']:
if version['state'] != 'done' or version['event_id'] not in links[news_id] or version['event_id'] not in external:
continue
output = version.get('output') or {}
refs = [ref for claim in output.get('claims', []) if claim.get('verdict') in ('supported', 'partial')
for ref in claim.get('evidence_refs', [])]
for record in version['evidence']:
body = record.get('result', {})
if record['tool'] != 'read_web' or body.get('error') or not body.get('content'):
continue
if urlparse(body.get('url', '')).hostname != urlparse(source['url']).hostname:
continue
if not all(a.casefold() in body['content'].casefold() for a in source['anchors']):
continue
if any(ref['article_id'] == body.get('article_id') and ref['quote'].strip()
and ref['quote'] in body['content'] for ref in refs):
valid = True
checks.append({'name': 'official_claim_evidence', 'news_id': news_id,
'domain': urlparse(source['url']).hostname, 'passed': valid})
return checks
def official_hints(manifest, news_ids):
return '\n验收提供的公开来源线索(链接不是证据):' + '\n'.join(
source['url'] for news_id, source in zip(manifest['external_news_ids'], manifest['official_sources'], strict=True)
if news_id in news_ids)
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('历史计数不匹配')
# Preflight precedes all paid calls, using the application's public reader.
from alphapulse.news_risk.research import fetch_public
preflight = []
for source in manifest['official_sources']:
result = await fetch_public(source['url'])
if not all(anchor.lower() in result['content'].lower() for anchor in source['anchors']):
raise ValueError('官方正文缺少预期事实锚点')
preflight.append({**result, 'sha256': digest(result['content'])})
credentials = json.loads(args.credentials.read_text())
if credentials.get('searxng_url'):
from alphapulse.core.config import get_settings
get_settings().searxng_url = credentials['searxng_url']
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, 'external': []}
args.budget.write_text('0'); args.budget.chmod(0o600)
save(state_path, state)
save(args.output / 'preflight.json', preflight)
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)
original_tool = service.Research.tool
async def chronological_tool(self, name, arguments):
if name in ('search_web', 'read_web'):
result = {'status': 'unavailable', 'error': '历史顺序回放不读取当前互联网,最终单独核验'}
self.records.append({'tool': name, 'arguments': arguments, 'result': result})
return result
return await original_tool(self, name, arguments)
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 patch.object(service.Research, 'tool', chronological_tool):
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
if state['batch'] == len(manifest['batches']) and int(args.budget.read_text()) < limit:
async with SessionLocal() as session:
targets = list(await session.scalars(select(Membership.event_id).join(Package, Package.id == Membership.package_id).where(Package.news_id.in_(manifest['external_news_ids'])).distinct().order_by(Membership.event_id)))
for identifier in targets:
if identifier in state['external'] or int(args.budget.read_text()) >= limit:
continue
async with SessionLocal() as session:
# Supply only this event's official leads, never unrelated sources.
news_ids = set(await session.scalars(select(Package.news_id).join(Membership).where(Membership.event_id == identifier)))
content = RESEARCH_SKILL + official_hints(manifest, news_ids)
node = await session.get(SkillNode, config['research']['skill_id'])
version = await session.scalar(select(SkillVersion).where(SkillVersion.skill_id == node.id, SkillVersion.content == content).order_by(SkillVersion.version_no.desc()).limit(1))
if version is None:
version = await add_version(session, node, content, 'user', 'Event-specific official leads for final live verification')
node.published_version_id = version.id
event = await session.get(Event, identifier)
if event.state == 'done':
event.revision += 1
event.state = 'pending'
await session.commit()
state['rules'] = await freeze_config(config)
save(state_path, state)
await service.process_event(identifier, config, force=True)
async with SessionLocal() as session:
event = await session.get(Event, identifier)
if event.state == 'done':
state['external'].append(identifier)
state['requests'] = int(args.budget.read_text())
save(state_path, state)
tables = await snapshot()
checks = assertions(manifest, tables, state['checkpoints'])
checks.extend(await reference_api_assertion(manifest, tables))
checks.extend(official_assertions(manifest, tables, state['external']))
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()
+1 -5
View File
@@ -181,11 +181,7 @@ def smoke(build, label):
wait_command('docker', 'exec', names['api'], 'python', '-c',
'import urllib.request; urllib.request.urlopen("http://localhost:8000/api/health", timeout=3)')
print(run('docker', 'exec', '-i', names['api'], 'python', '-',
input=(ROOT / 'scripts/smoke_risk.py').read_text()), flush=True)
print(run('docker', 'exec', '-i', names['api'], 'python', '-',
input=(ROOT / 'scripts/smoke_events.py').read_text()), flush=True)
print(run('docker', 'exec', '-i', names['api'], 'python', '-',
input=(ROOT / 'scripts/smoke_backfill.py').read_text()), flush=True)
input=(ROOT / 'scripts/smoke_news_research.py').read_text()), flush=True)
print(run('docker', 'exec', '-i', names['api'], 'python', '-',
input=(ROOT / 'scripts/smoke_calendar.py').read_text()), flush=True)
output = run('docker', 'exec', '-i', '-e', 'SMOKE_WEB=' + names['web'], names['api'], 'python', '-',
+27 -1
View File
@@ -292,17 +292,43 @@ 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', 'install-entrypoints'))
parser.add_argument('command', choices=('update', 'recover', 'rollback', 'reset-news-ai', 'reset-news-derived', 'install-entrypoints'))
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 == 'install-entrypoints':
with locked(INSTANCE / '.deploy.lock'):
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':
+64
View File
@@ -0,0 +1,64 @@
"""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))
+172
View File
@@ -0,0 +1,172 @@
"""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))
+61
View File
@@ -0,0 +1,61 @@
"""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))
+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())
+3 -7
View File
@@ -40,18 +40,14 @@ async def main():
try:
for path in ['/api/auth/me', '/api/news?q=CI', '/api/crawler/sources', '/api/market/symbols',
'/api/skills/tree', '/api/trading/status', '/api/chat/session',
'/api/risk/settings', '/api/risk/overview', '/api/risk/summaries',
'/api/risk/settings', '/api/risk/events', '/api/risk/packages',
'/api/calendar?start=' + now.strftime('%Y-%m-%d')]:
response = await client.get(path)
response.raise_for_status()
assert response.json()['code'] == 0, path
assert 'CI synthetic news' in (await client.get('/api/news?q=CI')).text
for endpoint in ('overview', 'summaries'):
for hours in (24, 48, 72, 168):
response = await client.get('/api/risk/'+endpoint, params={'hours': hours})
response.raise_for_status()
assert response.json()['code'] == 0
assert (await client.get('/api/risk/'+endpoint, params={'hours': 25})).status_code == 422
for endpoint in ('events', 'packages'):
assert (await client.get('/api/risk/'+endpoint, params={'page': 0})).status_code == 422
item = (await client.get('/api/news?q=CI')).json()['data']['items'][0]
selected = await client.get('/api/news', params=[('ids', item['id']), ('ids', 2147483647)])
assert selected.status_code == 200 and [n['id'] for n in selected.json()['data']['items']] == [item['id']]
+160 -338
View File
@@ -1,361 +1,183 @@
"""Validate backfill modes and queue queries only in a disposable CI database."""
"""Validate event completion, queue visibility and historical scheduling in CI."""
import asyncio
import os
import json
from datetime import UTC, datetime, timedelta
from unittest.mock import AsyncMock, patch
import json
import os
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from urllib.parse import urlparse
from sqlalchemy import select, delete, update, func
from sqlalchemy import select, func, delete, update
from alphapulse.core.db import SessionLocal, engine
from alphapulse.models import AiProvider, NewsItem, SkillVersion, SkillNode
from alphapulse.models.risk import (RiskSettings, ArticleAnalysis, SummaryArticle, NewsSummary,
GroupingVersion, ReprocessJob, ReprocessItem, ReprocessTarget, NewsEvent, EventMember, PackageCorrection, RiskSnapshot)
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)
storage = {}
async def save(key, value, **_): storage[key] = value
async def remove(key): storage.pop(key, None)
fake_redis = type('Redis', (), {'set': AsyncMock(side_effect=save), 'get': AsyncMock(side_effect=lambda k: storage.get(k)), 'delete': AsyncMock(side_effect=remove)})()
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.scalar(select(func.count()).select_from(RiskSettings))
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()
skills = {}
for stage, (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[stage+'_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', reasoning_effort='low', second_reasoning_effort='high', **skills))
news_ids, package_ids = [], []
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='ci-backfill-'+str(i), published_at=now+timedelta(minutes=i))
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()})
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='science', entities=[], tags=[],
published_at=now, updated_at=now, changed_at=now, due_at=now, revision=1, published_revision=1 if i==0 else 0,
state='done' if i==0 else 'failed', result={'summary':'Original'} if i==0 else None)
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))
await session.commit()
grouping_calls = []
async def group(provider, system, payload, schema):
grouping_calls.append(provider.model)
article = payload['article']
return pipeline.Grouping(reason='Same concrete fact', packages=[dict(package_id=package_ids[1], group_key=None,
title='Evidence', fact='Evidence', evidence=article['title'], category='science', entities=[], tags=['science'],
urgent=False, change_kind='repost', content_kind='report')]), {}
async def prepare(start, end, modes):
async with SessionLocal() as session: return await backfill.preview(session, start, end, modes)
async def begin(value):
async with SessionLocal() as session: return (await backfill.start(session, value['preview_id']))['id']
async def process_all(job_id):
async with SessionLocal() as session:
ids = (await session.scalars(select(ReprocessItem.id).where(ReprocessItem.job_id==job_id))).all()
for identifier in ids: await backfill.process(identifier)
with patch.object(backfill, 'redis', fake_redis), patch.object(queue, 'redis', fake_redis), patch.object(worker, 'complete', group):
window = (now-timedelta(seconds=1), now+timedelta(minutes=4))
preview = await prepare(*window, ['missing'])
assert (preview['total'],preview['missing'],preview['review'],preview['articles']) == (3,2,1,2), preview
job_id = await begin(preview)
await process_all(job_id)
assert grouping_calls == ['luna'], grouping_calls
async with SessionLocal() as session:
items = (await session.scalars(select(ReprocessItem).where(ReprocessItem.job_id==job_id))).all()
assert all(i.state=='done' for i in items), [(i.state,i.error) for i in items]
targets = (await session.scalars(select(ReprocessTarget).where(ReprocessTarget.job_id==job_id))).all()
assert len(targets)==1 and targets[0].object_id==package_ids[1]
package = await session.get(NewsSummary, package_ids[1]); revision = package.revision
package.state='done'; package.result={'summary':'Verified'}; package.published_revision=revision
# An unrelated pending package must not hold this batch open.
other = NewsSummary(title='Unrelated',fact='Other',category='science',entities=[],published_at=now,
updated_at=now,changed_at=now,due_at=now,state='pending')
session.add(other); await session.commit()
job = await session.get(ReprocessJob,job_id); await backfill.reconcile(session,job)
assert job.state=='done', (job.state,job.failures)
assert (await queue.job_out(session,job))['completed']==2
for state in ('running','queued','waiting','failed','done',None):
result = await queue.listing(session,state,None,None,None,1,2)
assert result['total']>=len(result['items'])
result = await queue.listing(session,'done','backfill','history',job_id,1,1)
next_page = await queue.listing(session,'done','backfill','history',job_id,2,1)
assert result['total']==2 and result['items'][0]['key']!=next_page['items'][0]['key']
# Recheck must rerun grouping, preserve grouping history and refresh old/new targets.
preview = await prepare(now-timedelta(seconds=1),now+timedelta(seconds=1),['review'])
review_id = await begin(preview)
await process_all(review_id)
assert grouping_calls==['luna','luna']
async with SessionLocal() as session:
item = await session.scalar(select(ReprocessItem).where(ReprocessItem.job_id==review_id))
assert item.state=='done', item.error
assert await session.scalar(select(func.count()).select_from(GroupingVersion))==2
targets = (await session.scalars(select(ReprocessTarget).where(ReprocessTarget.job_id==review_id))).all()
assert {t.object_id for t in targets}==set(package_ids)
for target in targets:
package=await session.get(NewsSummary,target.object_id)
package.state='done'; package.result={'summary':'Verified'}; package.published_revision=package.revision
event=NewsEvent(title='Related event',updated_at=now,due_at=now,state='pending',revision=1)
session.add(event); await session.flush()
session.add(EventMember(package_id=package_ids[1],event_id=event.id,reason='Synthetic'))
await session.commit()
job=await session.get(ReprocessJob,review_id); await backfill.reconcile(session,job)
assert job.state=='running'
event.state='done'; event.published_revision=1; await session.commit()
await backfill.reconcile(session,job); assert job.state=='done'
# The fixed preview refuses changed settings, and a changed source fails visibly.
preview = await prepare(now+timedelta(minutes=2),now+timedelta(minutes=3),['review'])
async with SessionLocal() as session:
settings=await session.get(RiskSettings,1); settings.model='changed'; await session.commit()
try: await backfill.start(session,preview['preview_id'])
except ValueError as error: assert '配置已变化' in str(error)
else: raise AssertionError('Changed configuration accepted')
settings.model='luna'; await session.commit()
changed_id = await begin(preview)
async with SessionLocal() as session:
news=await session.get(NewsItem,news_ids[2]); news.content_hash='changed'; await session.commit()
await process_all(changed_id)
async with SessionLocal() as session:
job=await session.get(ReprocessJob,changed_id); await backfill.reconcile(session,job)
item=await session.scalar(select(ReprocessItem).where(ReprocessItem.job_id==changed_id))
assert job.state=='failed' and '版本变化' in item.error
assert grouping_calls==['luna','luna']
# Missing items completed after preview are skipped, and pause/cancel stop admissions.
async with SessionLocal() as session:
package=await session.get(NewsSummary,package_ids[1]); package.published_revision=0; package.state='pending'
await session.commit()
preview=await prepare(now-timedelta(seconds=1),now+timedelta(seconds=1),['missing'])
skip_id=await begin(preview)
async with SessionLocal() as session:
settings=await session.get(RiskSettings,1); settings.enabled=False
package=await session.get(NewsSummary,package_ids[1]); package.published_revision=package.revision; package.state='done'
await session.commit()
await process_all(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_all(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'
# A failed member must fail its batch without waiting forever for the blocked event.
job=await session.get(ReprocessJob,review_id); job.state='running'
package=await session.get(NewsSummary,package_ids[1]); package.state='failed'; package.error='Synthetic failure'
event=await session.scalar(select(NewsEvent)); event.state='pending'; event.revision+=1
await session.commit()
await backfill.reconcile(session,job)
assert job.state=='failed' and {f['stage'] for f in job.failures}=={'analysis','events'}, job.failures
from alphapulse.api.risk import reprocess_retry
await reprocess_retry(job.id,session)
assert (await session.scalar(select(ReprocessItem).where(ReprocessItem.job_id==job.id))).state=='done'
assert (await session.get(NewsSummary,package_ids[1])).state=='pending'
job.state='cancelled'; await session.commit()
preview=await prepare(now-timedelta(seconds=1),now+timedelta(seconds=1),['review'])
cancel_id=await begin(preview)
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
else: raise AssertionError('Restart test did not reach admission')
async with SessionLocal() as session:
item=await session.scalar(select(ReprocessItem).where(ReprocessItem.job_id==cancel_id)); assert item.state=='pending'
article=await session.scalar(select(ArticleAnalysis).where(ArticleAnalysis.news_id==item.news_id)); assert article.state=='reprocess'
job=await session.get(ReprocessJob,cancel_id); job.state='cancelled'; await session.commit()
await process_all(cancel_id)
async with SessionLocal() as session:
job=await session.get(ReprocessJob,cancel_id); await backfill.reconcile(session,job)
assert job.state=='cancelled'
assert grouping_calls==['luna','luna']
# Cancelled calls drain before another batch can review the same news.
from alphapulse.api.risk import reprocess_cancel
async with SessionLocal() as session:
config=json.loads(json.dumps((await session.get(ReprocessJob,cancel_id)).settings))
config['configurations']['grouping']['provider']['model']='luna-old'
old=ReprocessJob(state='pending',items=[],position=0,failures=[],settings=config)
session.add(old); await session.flush(); old.settings={**old.settings,'job_id':old.id}
news=await session.get(NewsItem,news_ids[0])
old_item=ReprocessItem(job_id=old.id,news_id=news.id,content_hash=news.content_hash,mode='review',state='pending')
session.add(old_item); await session.commit()
old_id,old_item_id=old.id,old_item.id
entered=asyncio.Event(); release=asyncio.Event()
async def controlled(provider,system,payload,schema):
if provider.model=='luna-old':
entered.set(); await release.wait()
result,usage=await group(provider,system,payload,schema)
result.reason=provider.model
return result,usage
with patch.object(worker,'complete',controlled),patch.object(worker,'outlook',AsyncMock(return_value=False)):
old_task=asyncio.create_task(backfill.process(old_item_id))
try:
async with asyncio.timeout(5): await entered.wait()
async with SessionLocal() as session:
await reprocess_cancel(old_id,session)
config=json.loads(json.dumps((await session.get(ReprocessJob,old_id)).settings))
config['configurations']['grouping']['provider']['model']='luna-new'
new=ReprocessJob(state='pending',items=[],position=0,failures=[],settings=config)
session.add(new); await session.flush(); new.settings={**new.settings,'job_id':new.id}
retried=ReprocessItem(job_id=new.id,news_id=news_ids[0],content_hash='ci-backfill-0',mode='review',state='pending')
other_item=ReprocessItem(job_id=new.id,news_id=news_ids[1],content_hash='ci-backfill-1',mode='review',state='pending')
session.add_all([retried,other_item]); await session.commit()
new_id,retried_id,other_item_id=new.id,retried.id,other_item.id
active={('analysis',i) for i in (await session.scalars(select(NewsSummary.id))).all()}
active.update(('events',i) for i in (await session.scalars(select(NewsEvent.id))).all())
picked=await scheduler.candidate(active|{('backfill',old_item_id)},0)
assert picked and picked[0]==('backfill',other_item_id), picked
assert 'luna-new' not in grouping_calls
finally:
release.set(); await old_task
picked=await scheduler.candidate(active,0)
assert picked and picked[0]==('backfill',retried_id), picked
await picked[1]()
async with SessionLocal() as session:
article=await session.scalar(select(ArticleAnalysis).where(ArticleAnalysis.news_id==news_ids[0]))
assert article.result['reason']=='luna-new' and article.model=='luna-new'
new=await session.get(ReprocessJob,new_id); new.state='cancelled'; await session.commit()
# A pending backfill must not hide real-time grouping that won admission.
async with SessionLocal() as session:
job=ReprocessJob(state='running',items=[],position=0,failures=[],settings=(await session.get(ReprocessJob,cancel_id)).settings)
session.add(job); await session.flush()
job.settings={**job.settings,'job_id':job.id}
news=await session.get(NewsItem,news_ids[0])
item=ReprocessItem(job_id=job.id,news_id=news.id,content_hash=news.content_hash,mode='review',state='pending')
session.add(item)
article=await session.scalar(select(ArticleAnalysis).where(ArticleAnalysis.news_id==news.id)); article.state='processing'
await session.commit()
task={'stage':'grouping','object_id':article.id,'phase':'等待模型响应',
'started_at':now.isoformat(),'updated_at':now.isoformat()}
storage['ap:risk:active']=json.dumps([task])
result=await queue.listing(session,'running','grouping',None,None,1,20)
assert [r['object_id'] for r in result['items']]==[article.id] and result['total']==1
storage.pop('ap:risk:active')
result=await queue.listing(session,'running','grouping',None,None,1,20)
assert len(result['items'])==1 and result['items'][0]['state']=='unknown'
item.state='processing'; await session.commit()
storage['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,job.id,1,20))['total']==1
storage.pop('ap:risk:active')
# Batch membership follows durable targets; execution metadata remains real-time.
shared=await session.get(NewsSummary,package_ids[1]); shared.review_config=None; shared.state='pending'; shared.due_at=now
await backfill.target(session,job.id,'analysis',shared)
await session.commit()
result=await queue.listing(session,'queued','analysis',None,job.id,1,20)
assert result['total']==1 and result['items'][0]['object_id']==shared.id
assert result['items'][0]['origin']=='realtime' and result['items'][0]['model']=='reviewer'
assert (await queue.listing(session,'queued','analysis','realtime',job.id,1,20))['total']==1
assert (await queue.listing(session,'queued','analysis','history',job.id,1,20))['total']==0
event=await session.scalar(select(NewsEvent)); event.review_config=None; event.state='pending'; event.due_at=now
shared.state='done'; shared.published_revision=shared.revision
await backfill.target(session,job.id,'events',event)
await session.commit()
result=await queue.listing(session,'queued','events',None,job.id,1,20)
assert result['total']==1 and result['items'][0]['object_id']==event.id
await session.execute(delete(ReprocessTarget).where(ReprocessTarget.job_id==job.id))
await session.commit()
# Retaining the original package ID during a split must track both branches.
article.state='done'; item.state='done'
package=NewsSummary(title='Two facts',fact='Two facts',category='science',entities=[],tags=['science'],
published_at=now,updated_at=now,changed_at=now,due_at=now,state='done',revision=1,published_revision=1,
result={'summary':'Original'},review_config=job.settings)
session.add(package); await session.flush()
session.add(SummaryArticle(summary_id=package.id,analysis_id=article.id,active=True))
await session.flush()
await backfill.target(session,job.id,'analysis',package)
from alphapulse.news_risk.contracts import Correction
from alphapulse.news_risk.corrections import apply_correction
proposal=Correction(packages=[{'id':package.id,'revision':package.revision}],replacements=[
{'keep_id':package.id,'title':'First fact','fact':'First fact','category':'science','tags':['first'],
'content_kind':'report','assignments':[{'analysis_id':article.id,'evidence':article.input['content']}]},
{'keep_id':None,'title':'Second fact','fact':'Second fact','category':'science','tags':['second'],
'content_kind':'report','assignments':[{'analysis_id':article.id,'evidence':article.input['content']}]}],
reason='Two distinct facts in one article')
outputs=await apply_correction(session,proposal,package.id,set(),{'backfill-split':True})
package.state='done'; package.published_revision=package.revision
await session.commit()
await backfill.reconcile(session,job)
assert job.state=='running'
assert set((await session.scalars(select(ReprocessTarget.object_id).where(
ReprocessTarget.job_id==job.id,ReprocessTarget.kind=='analysis'))).all())==set(outputs)
created=await session.get(NewsSummary,outputs[1]); created.state='failed'; created.error='Synthetic split failure'
await session.commit(); await backfill.reconcile(session,job)
assert job.state=='failed' and job.failures==[{'stage':'analysis','id':created.id,'error':created.error}]
await reprocess_retry(job.id,session)
assert (await session.get(NewsSummary,created.id)).state=='pending'
created.state='done'; created.published_revision=created.revision; created.result={'summary':'Verified split'}
await session.commit(); await backfill.reconcile(session,job); assert job.state=='done'
# An empty 72h window clears old reading immediately, without models or a pool slot.
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:
previous=RiskSnapshot(state='done',model='senior',quotes={'fingerprint':'obsolete','important':{}},
result={'reading':[{'id':package.id}],'inputs':[{'id':package.id}],'synthesis':{'summary':'Obsolete'}})
session.add(previous); await session.commit()
count=await session.scalar(select(func.count()).select_from(RiskSnapshot))
no_model=AsyncMock(side_effect=AssertionError('Empty windows must not call a model'))
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)
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:
latest=await session.scalar(select(RiskSnapshot).order_by(RiskSnapshot.id.desc()).limit(1))
assert latest.state=='done' and latest.result['inputs']==[] and latest.result['reading']==[]
assert latest.result['expansion']==[] and latest.result['synthesis'] is None
assert await session.scalar(select(func.count()).select_from(RiskSnapshot))==count+1
latest.state='failed'; latest.quotes={**latest.quotes,'retry_requested':True}; await session.commit()
failed_id=latest.id
assert not await worker.outlook(prepare_only=True)
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:
previous=await session.get(RiskSnapshot,failed_id); assert 'retry_requested' not in previous.quotes
assert await session.scalar(select(func.count()).select_from(RiskSnapshot))==count+2
assert not await worker.outlook(prepare_only=True)
no_model.assert_not_awaited()
# JSON batch references must preserve the BIGINT identity used by job rows.
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:
original=await session.get(ReprocessJob,job_id)
large=ReprocessJob(id=2147483648,state='failed',settings={**original.settings,'job_id':2147483648},
items=[],position=0,failures=[])
package=await session.get(NewsSummary,package_ids[0])
package.review_config=large.settings; package.state='failed'
event=NewsEvent(title='Large batch',updated_at=now,due_at=now,state='failed',review_config=large.settings)
snapshot=RiskSnapshot(state='failed',model='senior',quotes={'job_id':large.id})
session.add_all([large,event,snapshot]); await session.flush()
large.failures=[{'stage':'outlook','id':snapshot.id,'error':'Synthetic large batch failure'}]
await session.commit()
result=await queue.listing(session,'failed',None,'history',large.id,1,20)
assert {(r['stage'],r['object_id']) for r in result['items']}=={
('analysis',package.id),('events',event.id),('outlook',snapshot.id)}
for state in ('running','queued','waiting','failed','done',None):
result=await queue.listing(session,state,None,None,9223372036854775807,1,20)
assert result['total']==0 and result['items']==[]
from alphapulse.api.risk import reprocess_retry
await reprocess_retry(large.id,session)
assert (await session.get(RiskSnapshot,snapshot.id)).quotes['retry_requested']
async with SessionLocal() as session:
for model in (ReprocessTarget,ReprocessItem,ReprocessJob,EventMember,NewsEvent,PackageCorrection,RiskSnapshot,GroupingVersion,SummaryArticle,NewsSummary,ArticleAnalysis):
await session.execute(delete(model))
await session.execute(delete(NewsItem).where(NewsItem.id.in_(news_ids)))
await session.execute(delete(RiskSettings))
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()
print('Historical backfill: selection, frozen preview, reuse, recheck, targets, source revisions and queue passed')
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())
+69 -234
View File
@@ -1,18 +1,15 @@
"""Exercise event aggregation and correction transactions in the disposable CI DB."""
"""Exercise atomic event and fact-package corrections in a disposable database."""
import asyncio
from datetime import UTC, datetime, timedelta
from datetime import UTC, datetime
import os
from types import SimpleNamespace
from unittest.mock import patch
from urllib.parse import urlparse
import httpx
from fastapi import FastAPI
from sqlalchemy import select, func, delete, null
from sqlalchemy import select, delete, func
from alphapulse.core.db import SessionLocal, engine
from alphapulse.models import AiProvider, NewsItem, SkillNode, SkillVersion
from alphapulse.models.risk import NewsSummary, ArticleAnalysis, SummaryArticle, RiskSettings, NewsEvent, EventMember, EventVersion, ReprocessJob, PackageCorrection, GroupingVersion, SummaryVersion, PackageEvidence, RiskSnapshot, EventLink
from alphapulse.ai.skills import create_node, add_version
from alphapulse.news_risk import pipeline, events, reprocess, worker, briefing
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
@@ -20,232 +17,70 @@ 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)
async with SessionLocal() as session:
assert await session.scalar(select(func.count()).select_from(RiskSettings))==0
provider=AiProvider(name='event-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()
ids={}
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
ids[role+'_skill_id']=node.id
settings=RiskSettings(id=1,enabled=True,start_at=now-timedelta(minutes=5),provider_id=provider.id,second_provider_id=provider.id,third_provider_id=provider.id,model='luna',second_model='reviewer',third_model='senior',**ids)
session.add(settings)
packages=[]; analyses=[]
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' if i==1 else 'Response',summary='',content_text='Original evidence '+str(i),content_hash=str(i),published_at=now)
session.add(news); await session.flush()
analysis=ArticleAnalysis(news_id=news.id,content_hash=news.content_hash,state='done',result={'previous':True},input={'title':news.title,'summary':'','content':news.content_text,'url':news.url,'source':'wscn_news','revision_no':1,'published_at':now.isoformat(),'progress_at':now.isoformat()})
session.add(analysis); await session.flush(); analyses.append(analysis)
result={'title':news.title,'summary':news.title,'importance_stars':4,'risk_stars':3,'category':'security','evidence_refs':[{'article_id':analysis.id,'quote':news.content_text}],'claims':[]}
package=NewsSummary(title=news.title,fact=news.title,category='security',tags=['exchange'],entities=['Exchange'],content_kind='report',published_at=now,updated_at=now,changed_at=now,due_at=now,state='done',revision=1,published_revision=1,result=result,importance_stars=4)
session.add(package); await session.flush(); packages.append(package)
session.add(SummaryArticle(summary_id=package.id,analysis_id=analysis.id,active=True,unit={'fact':news.title,'evidence':news.content_text}))
await session.flush()
result=SimpleNamespace(related_events=[],event_proposal=pipeline.EventProposal(title='Exchange incident',package_ids=[packages[0].id],reason='Official response to the same incident'))
await events.associate(session,packages[1],result,{packages[0].id})
event=await session.scalar(select(NewsEvent)); event.due_at=now
package_ids=[p.id for p in packages]; event_id=event.id
withdrawn = NewsSummary(title='Withdrawn',fact='Withdrawn',category='security',tags=['exchange'],entities=[],
content_kind='report',published_at=now,updated_at=now,changed_at=now,due_at=now,state='withdrawn',revision=1,published_revision=0)
session.add(withdrawn); await session.flush()
session.add(EventMember(package_id=withdrawn.id,event_id=event_id,reason='Previous member'))
await session.commit()
async with SessionLocal() as session:
nested = await session.begin_nested()
await session.execute(NewsEvent.__table__.update().where(NewsEvent.id == event_id).values(result=None))
await session.execute(NewsSummary.__table__.update().where(NewsSummary.id.in_(package_ids)).values(state='pending'))
for empty in (None, null()):
session.add(NewsSummary(title='Unreviewed',fact='Pending',category='security',entities=[],
published_at=now,updated_at=now,changed_at=now,due_at=now,state='pending',result=empty))
session.add(NewsEvent(title='Unreviewed',updated_at=now,due_at=now,state='pending',result=empty))
for empty in (None, null(), {'inputs':None}):
session.add(RiskSnapshot(state='done',result=empty,quotes={},model='synthetic'))
await session.flush()
inputs = await events.reading_inputs(session,now-timedelta(hours=72))
assert len(briefing.semantic_packages(inputs)) == 2
assert {(p['kind'],p['id']) for p in inputs} == {('package',i) for i in package_ids}
from alphapulse.api import risk as api
app = FastAPI()
app.include_router(api.router)
app.dependency_overrides[api.require_admin] = lambda: None
app.dependency_overrides[api.get_session] = lambda: session
async with httpx.AsyncClient(transport=httpx.ASGITransport(app),base_url='http://ci') as client:
for hours in (24,48,72,168):
response = await client.get('/api/risk/overview',params={'hours':hours})
assert response.status_code == 200, response.text
data = response.json()['data']
assert data['briefing'] is None and data['stale']
assert sum(data['counts'].values()) == 2
response = await client.get('/api/risk/summaries')
assert response.status_code == 200, response.text
data = response.json()['data']
assert data['total'] == 2 and {p['id'] for p in data['items']} == set(package_ids)
await nested.rollback()
print('Reading API: SQL NULL and JSON null results excluded; pending events preserve independent packages')
async def complete(provider,system,payload,schema):
points=[]
for p in payload['packages']:
points.append(events.TimelinePoint(occurred_at=None,time_precision='unknown',time_text='Time unknown',reported_at=now,description=p['result']['summary'],verification='partial',package_id=p['id'],package_revision=p['revision'],evidence_refs=p['result']['evidence_refs']))
return schema(title='Exchange incident',summary='Disclosure followed by response',category='security',importance_stars=4,risk_stars=3,uncertainty='Time unknown',opinions='Wait',counter_evidence='Unknown',watchpoints='Investigation',timeline_points=points),{}
with patch.object(events,'complete',complete):
await events.organize()
await events.organize()
async with SessionLocal() as session:
event=await session.get(NewsEvent,event_id)
assert event.state=='done' and len(event.result['timeline_points'])==2
inputs=await events.reading_inputs(session,now-timedelta(hours=72))
assert len(inputs)==1 and inputs[0]['kind']=='event'
assert await session.scalar(select(func.count()).select_from(EventVersion))==1
counts,_=await reprocess.preview(session,now-timedelta(seconds=1),now+timedelta(seconds=1))
assert counts=={'articles':2,'packages':2,'events':1}
started=await reprocess.start_job(session,now-timedelta(seconds=1),now+timedelta(seconds=1))
job=await session.get(ReprocessJob,started['id'])
assert len(job.items)==2
provider=await session.get(AiProvider,provider.id); provider.model='changed'; await session.commit()
frozen,_,_=await reprocess.frozen_configuration(session,job.settings,'grouping')
assert frozen.model=='luna'
job_id = job.id
await session.commit()
async with SessionLocal() as session:
event = await session.get(NewsEvent, event_id)
event.state = 'pending'; event.revision += 1; event.due_at = now
published = event.published_revision
await session.commit()
async def change_during_timeline(*args):
now=datetime.now(UTC); packages=[]; events=[]; news_ids=[]
try:
async with SessionLocal() as session:
package = await session.get(NewsSummary, package_ids[0])
worker.schedule(package, now, True)
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()
return await complete(*args)
with patch.object(events, 'complete', change_during_timeline): await events.organize()
async with SessionLocal() as session:
event = await session.get(NewsEvent, event_id)
assert event.state == 'pending' and event.published_revision == published
latest = await session.scalar(select(EventVersion).order_by(EventVersion.id.desc()).limit(1))
assert latest.state == 'superseded'
package = await session.get(NewsSummary, package_ids[0])
package.state = 'done'; package.published_revision = package.revision
await session.commit()
with patch.object(events, 'complete', complete): await events.organize()
analyze = worker.analyze
async def pause_before_grouping(*args):
async with SessionLocal() as session:
settings = await session.get(RiskSettings, 1); settings.enabled = False
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()
return await analyze(*args)
with patch.object(worker, 'analyze', pause_before_grouping): await reprocess.process_job()
async with SessionLocal() as session:
job = await session.get(ReprocessJob, job_id)
assert job.position == 0 and not job.failures
assert (await session.get(ArticleAnalysis, analyses[0].id)).result == {'previous':True}
settings = await session.get(RiskSettings, 1); settings.enabled = True
await session.commit()
async def fail_grouping(*args):
from alphapulse.api.risk import reprocess_cancel
async with SessionLocal() as other:
job = await other.get(ReprocessJob, job_id)
if job.state != 'cancelled': await reprocess_cancel(job_id, other)
raise ValueError('Synthetic grouping failure during cancellation')
with patch.object(worker, 'complete', fail_grouping): await reprocess.process_job()
async with SessionLocal() as session:
job = await session.get(ReprocessJob, job_id)
assert job.state == 'cancelled' and job.position == 1 and len(job.failures) == 1
old = await session.get(ArticleAnalysis, analyses[0].id)
assert old.result == {'previous':True} and old.state == 'done' and old.error
assert await session.scalar(select(func.count()).select_from(SummaryArticle).where(SummaryArticle.active.is_(True))) == 2
assert await session.scalar(select(func.count()).select_from(GroupingVersion).where(GroupingVersion.state == 'failed')) == 1
second = await reprocess.start_job(session, now-timedelta(seconds=1), now+timedelta(seconds=1))
job = await session.get(ReprocessJob, second['id'])
job.state = 'running'; job.position = len(job.items)
package = await session.get(NewsSummary, package_ids[0])
package.review_config = job.settings; package.state = 'failed'; package.error = 'Synthetic review failure'
await session.commit()
await reprocess.process_job()
async with SessionLocal() as session:
job = await session.get(ReprocessJob, second['id'])
assert job.state == 'failed' and job.failures == [{'stage':'analysis','id':package_ids[0],'error':'Synthetic review failure'}]
from alphapulse.api.risk import reprocess_retry
await reprocess_retry(job.id, session)
assert job.position == len(job.items) and job.state == 'running'
assert (await session.get(NewsSummary, package_ids[1])).state == 'done'
assert (await session.get(NewsSummary, package_ids[0])).state == 'pending'
assert await session.scalar(select(func.count()).select_from(GroupingVersion)) == 1
job.state = 'cancelled'
package = await session.get(NewsSummary, package_ids[0])
package.state = 'done'; package.error = None; package.review_config = None
news = NewsItem(source_code='wscn_news', external_id='ci-event-repost', url='https://example.invalid/repost',
title='Incident', summary='', content_text='Original evidence 1', content_hash='repost', published_at=now)
session.add(news); await session.flush()
repost = ArticleAnalysis(news_id=news.id, content_hash=news.content_hash, state='pending', input={
'title':news.title,'summary':'','content':news.content_text,'url':news.url,'source':'wscn_news',
'revision_no':1,'published_at':now.isoformat(),'progress_at':now.isoformat()})
session.add(repost); await session.commit(); analyses.append(repost)
old_revision, old_progress = package.revision, package.updated_at
async def group_repost(provider, system, payload, schema):
return schema(reason='Same concrete fact', packages=[dict(package_id=package_ids[0], group_key=None,
title='Incident', category='security', entities=['Exchange'], fact='Incident', evidence='Original evidence 1',
urgent=False, tags=['exchange'], content_kind='report', change_kind='repost')]), {}
with patch.object(worker, 'complete', group_repost): await worker.analyze(repost.id)
async with SessionLocal() as session:
package = await session.get(NewsSummary, package_ids[0])
assert package.revision == old_revision and package.updated_at == old_progress and package.state == 'done'
third = await reprocess.start_job(session, now-timedelta(seconds=1), now+timedelta(seconds=1))
job = await session.get(ReprocessJob, third['id'])
job.state = 'running'; job.position = len(job.items)
await session.commit()
await reprocess.process_job()
async with SessionLocal() as session:
assert (await session.get(ReprocessJob, third['id'])).state == 'running'
async def senior(provider, system, payload, schema):
assert provider.model == 'senior'
if schema is briefing.Ranked:
return schema(reading=[dict(kind=p['kind'], id=p['id'], revision=p['revision'], priority=80, reason='Synthetic')
for p in payload['packages']], notes='Synthetic'), {}
trend = briefing.Trend(summary='Unknown', conditions='Unknown', counter_evidence='Unknown', watchpoints='Updates')
return schema(summary='Synthetic', market='Unknown', short_term=trend, medium_term=trend, references=[]), {}
async def failed_senior(*args): raise ValueError('Synthetic outlook failure')
with patch.object(briefing, 'complete', failed_senior): await worker.outlook()
await reprocess.process_job()
async with SessionLocal() as session:
job = await session.get(ReprocessJob, third['id'])
assert job.state == 'failed' and len(job.failures) == 1 and job.failures[0]['stage'] == 'outlook'
groups_before = await session.scalar(select(func.count()).select_from(GroupingVersion))
await reprocess_retry(job.id, session)
assert job.position == len(job.items) == 3
with patch.object(briefing, 'complete', senior): await worker.outlook()
await reprocess.process_job()
async with SessionLocal() as session:
assert (await session.get(ReprocessJob, third['id'])).state == 'done'
assert await session.scalar(select(func.count()).select_from(GroupingVersion)) == groups_before
packages=[await session.get(NewsSummary,i) for i in package_ids]
proposal=Correction(packages=[{'id':p.id,'revision':p.revision} for p in packages],replacements=[{'keep_id':min(package_ids),'title':'Corrected shared fact','fact':'Same fact','category':'security','tags':['exchange'],'content_kind':'report','assignments':[{'analysis_id':a.id,'evidence':a.input['content']} for a in analyses]}],reason='Synthetic duplicate correction')
output=await apply_correction(session,proposal,package_ids[0],{package_ids[1]},{'test':True})
await session.commit()
assert output==[min(package_ids)]
assert (await session.get(NewsSummary,max(package_ids))).replacement_ids==output
assert await session.scalar(select(func.count()).select_from(PackageCorrection))==1
assert await session.scalar(select(func.count()).select_from(SummaryArticle).where(SummaryArticle.active.is_(True)))==3
assert await session.scalar(select(func.count()).select_from(NewsItem).where(NewsItem.external_id.startswith('ci-event-')))==3
await events.organize()
async with SessionLocal() as session:
event=await session.get(NewsEvent,event_id); event.due_at=now; await session.commit()
await events.organize()
async with SessionLocal() as session:
assert (await session.get(NewsEvent,event_id)).state=='retired'
assert len(await events.reading_inputs(session,now-timedelta(hours=72)))==1
async with SessionLocal() as session:
for model in (EventLink, EventVersion, EventMember, NewsEvent, ReprocessJob, PackageCorrection,
GroupingVersion, PackageEvidence, SummaryVersion, SummaryArticle, NewsSummary, ArticleAnalysis, RiskSnapshot, RiskSettings):
await session.execute(delete(model))
await session.execute(delete(NewsItem).where(NewsItem.external_id.startswith('ci-event-')))
await session.execute(delete(AiProvider).where(AiProvider.id == provider.id))
await session.execute(SkillNode.__table__.update().where(SkillNode.id.in_(ids.values())).values(published_version_id=None))
await session.execute(delete(SkillVersion).where(SkillVersion.skill_id.in_(ids.values())))
await session.execute(delete(SkillNode).where(SkillNode.id.in_(ids.values())))
await session.commit()
await engine.dispose()
print('Events: original evidence, independent packages, aggregation, timeline, preview, frozen config and atomic merge history passed')
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())
File diff suppressed because it is too large. Load diff
+132 -138
View File
@@ -1,176 +1,170 @@
"""Exercise both news stages against the disposable CI database and fake models."""
"""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
from fastapi import HTTPException
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, PackageEvidence, GroupingVersion
from alphapulse.news_risk import service, pipeline, worker, briefing
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'
assert url.hostname.startswith('ap-ci-') or url.hostname in ('127.0.0.1', 'localhost')
now = datetime.now(UTC)
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 await session.scalar(select(func.count()).select_from(RiskSettings)) == 0
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=2048, context_window=200000)
session.add(provider); await session.flush(); provider_id = provider.id
skill_ids = {}
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
skill_ids[role+'_skill_id'] = node.id
await session.flush()
assert not await published_skills(session), 'News skills leaked into Chat or trading'
session.add(RiskSettings(id=1, enabled=True, provider_id=provider_id, model='luna', second_provider_id=provider_id,
second_model='reviewer', third_provider_id=provider_id, third_model='senior',
reasoning_effort='low', second_reasoning_effort='high', third_reasoning_effort='xhigh', start_at=now-timedelta(minutes=2), **skill_ids))
for key, at, content in [('old', now-timedelta(days=1), 'old'), ('first', now-timedelta(minutes=1), 'original'),
('duplicate', now-timedelta(seconds=30), 'repost')]:
session.add(NewsItem(source_code='wscn_news', external_id='ci-news-'+key, url='https://example.invalid/'+key,
title='Exchange security incident', summary='', content_text=content, content_hash=key, published_at=at))
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
inject_update = False
finish_during_grouping = False
async def stream(provider, system, messages, tools, effort):
nonlocal inject_update, finish_during_grouping
assert effort == {'luna': 'low', 'reviewer': 'high', 'senior': 'xhigh'}[provider.model]
data = json.loads(messages[0]['text']); calls.append((provider.model, data))
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:
assert provider.model == 'luna'
if finish_during_grouping:
finish_during_grouping = False
await worker.summarize()
candidates = data['candidates']
result = pipeline.Grouping( reason='Financial event', packages=[pipeline.Match(
package_id=candidates[0]['id'] if candidates else None, group_key=None if candidates else 'security',
title='Exchange security incident', category='security', entities=['Exchange'], fact='incident',
evidence=data['article']['title'], urgent=True, tags=['Exchange'], content_kind='report')])
elif data.get('task') == 'event':
assert provider.model == 'reviewer'
if broken: raise ValueError('Synthetic validation failure')
if inject_update:
inject_update = False
async with SessionLocal() as other:
changed = await other.get(NewsSummary, data['id'], with_for_update=True)
worker.schedule(changed, datetime.now(UTC), True)
await other.commit()
correction = any(a['content'] == 'correction' for a in data['articles'])
result = pipeline.EventResult(id=data['id'], title='Exchange security incident', summary='Correction' if correction else 'Incident',
category='security', entities=['Exchange'], risk_stars=1 if correction else 5, importance_stars=5, direction='unknown',
verification='refuted' if correction else 'reported', confidence='low', reasoning='Synthetic', uncertainty='Synthetic',
timeline_points=[dict(occurred_at=None, time_precision='unknown', time_text='Unknown', reported_at=None, description='Synthetic', verification='insufficient', evidence_refs=[{'article_id':a['id'],'quote':a['title']} for a in data['articles']])], facts='Synthetic', timeline='Synthetic',
asset_impacts='Exchange', opinions='Uncertain', counter_evidence='Unknown', watchpoints='Updates', claims=[pipeline.Claim(statement='Synthetic', verdict='insufficient', evidence_refs=[])], related_packages=[], excluded_article_ids=[],
evidence_refs=[pipeline.Evidence(article_id=a['id'], quote=a['title']) for a in data['articles']])
elif data.get('task') == 'ranking':
assert provider.model == 'senior'
result = briefing.Ranked(reading=[briefing.Reading(id=p['id'], revision=p['revision'], priority=90, reason='Synthetic') for p in data['packages']], notes='Synthetic notes')
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()}], usage={'input_tokens': 10})
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):
await worker.analyze()
finish_during_grouping = True
await worker.analyze()
with patch.object(llm,'stream',stream):
assert await worker.analyze()=='done'
async with SessionLocal() as session:
event = await session.scalar(select(NewsSummary))
assert event.state == 'pending' and event.revision == 2 and event.published_revision == 1, 'Grouping lost an update after concurrent summary completion'
await worker.summarize()
event=await session.scalar(select(NewsEvent)); event_id=event.id; revision=event.revision
assert await worker.analyze()=='done'
async with SessionLocal() as session:
assert await session.scalar(select(func.count()).select_from(ArticleAnalysis)) == 2
event = await session.scalar(select(NewsSummary)); event_id = event.id
assert event.stars == 5 and event.revision == 2
assert await session.scalar(select(func.count()).select_from(SummaryArticle)) == 2
article = await session.scalar(select(NewsItem).where(NewsItem.external_id == 'ci-news-first'))
article.content_hash = 'corrected'; article.content_text = 'correction'; article.revision_no = 2
article.source_updated_at = datetime.now(UTC)
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()
await worker.analyze(); await worker.summarize()
before=len(calls)
await event_research.review(event_id)
assert len(calls)==before, 'Outdated originals reached a model'
async with SessionLocal() as session:
event = await session.get(NewsSummary, event_id)
assert event.stars == 1 and event.peak_stars == 5 and event.result['verification'] == 'refuted'
version = await session.scalar(select(SummaryVersion).order_by(SummaryVersion.id.desc()).limit(1))
assert len(version.input['articles']) == 2, 'Superseded article revision entered the current summary'
event.revision += 1; event.state = 'pending'; event.due_at = datetime.now(UTC)
await session.commit()
inject_update = True
await worker.summarize()
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(NewsSummary, event_id)
assert event.state == 'pending' and event.revision > event.published_revision, 'Concurrent update was lost'
broken = True
await worker.summarize()
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(NewsSummary, event_id)
assert event.state == 'failed' and event.stars == 1 and event.result['summary'] == 'Correction'
broken = False
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:
failed = RiskSnapshot(state='failed', model='reviewer', quotes={}, error='Synthetic old failure',
created_at=datetime.now(UTC)-timedelta(minutes=20))
session.add(failed); await session.commit(); failed_id = failed.id
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 snapshot.result['inputs'][0]['id'] == event_id
assert (await session.get(AiProvider, provider_id)).model == 'shared'
from fastapi import FastAPI
import httpx
from alphapulse.api import risk as risk_api, news as news_api
app = FastAPI()
app.include_router(risk_api.router); app.include_router(news_api.router)
app.dependency_overrides[risk_api.require_admin] = lambda: None
with patch.object(risk_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/summaries',
f'/api/risk/summaries/{event_id}', '/api/news'):
response = await client.get(path)
assert response.status_code == 200, (path, response.text)
assert response.json()['code'] == 0
response = await client.get(f'/api/risk/summaries/{event_id}')
assert response.json()['data']['event']['article_count'] == 2
assert response.json()['data']['versions']
response = await client.get('/api/risk/summaries', params={'hours': 25})
assert response.status_code == 422
response = await client.post(f'/api/risk/tasks/{failed_id}/retry', params={'stage': 'outlook'})
assert response.status_code == 202, response.text
await worker.outlook()
async with SessionLocal() as session:
assert await session.scalar(select(func.count()).select_from(RiskSnapshot)) == 3, 'Old failed outlook retry was ignored'
assert not (await session.get(RiskSnapshot, failed_id)).quotes.get('retry_requested')
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 model provider deletion accepted')
settings = await session.get(RiskSettings, 1); settings.enabled = False
await session.commit(); await delete_provider(provider_id, session)
assert settings.provider_id is None and settings.second_provider_id is None and settings.third_provider_id is None
print('Three-stage packages: scope, cutoff, grouping, reposts, revisions, corrections, peak risk, retained success, outlook and provider isolation passed')
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 (GroupingVersion, PackageEvidence, SummaryVersion, SummaryArticle, NewsSummary, ArticleAnalysis, RiskSnapshot, RiskSettings):
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_(skill_ids.values())).values(published_version_id=None))
await session.execute(delete(SkillVersion).where(SkillVersion.skill_id.in_(skill_ids.values())))
await session.execute(delete(SkillNode).where(SkillNode.id.in_(skill_ids.values())))
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())
+24
View File
@@ -152,6 +152,30 @@ class NewsResetTests(unittest.TestCase):
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):
def test_rollback_removes_new_calendar_worker_and_checks_previous_services(self):