372 lines
13 KiB
Python
372 lines
13 KiB
Python
"""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())
|
|
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)
|
|
|
|
with ExitStack() as stack:
|
|
for name in ("_stream_openai", "_stream_anthropic"):
|
|
stack.enter_context(
|
|
patch.object(llm, name, budget.wrap(getattr(llm, name)))
|
|
)
|
|
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))
|