feat(news): index news packages and research persistent events
Extract reusable literal keywords once per source revision, assign complete news packages to long-lived events, and research all event members with immutable evidence and references. Add additive research storage, vocabulary snapshots, the two-stage workspace, isolated acceptance coverage, and a paused rollout with an initial fixed 50-news production trial.
This commit is contained in:
1 parent
07252fea0f
commit
4929a32a0a
69 files changed
+8215
-2092
No files matched your search
@@ -0,0 +1,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))
|
||||
@@ -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
@@ -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
@@ -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':
|
||||
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
@@ -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())
|
||||
@@ -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
@@ -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
@@ -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
@@ -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())
|
||||
@@ -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):
|
||||
|
||||
Reference in new issue
Block a user