Files
AlphaPulse/scripts/smoke_news_research.py
T
eezd d79a10c2e6
Development CI / check-and-build (push) Successful in 2m53s
GitHub Git mirror / mirror (push) Successful in 7s
Development CI / deploy-development (push) Successful in 2m31s
refactor(news): remove retired research workflow
2026-11-05 18:38:25 +08:00

1379 lines
62 KiB
Python

"""Verify two-stage behavior in a disposable CI database using mock model I/O."""
import asyncio
from copy import deepcopy
from datetime import UTC, datetime, timedelta
import os
from unittest.mock import patch
from types import SimpleNamespace
from urllib.parse import urlparse
from sqlalchemy import select, func, text
from alphapulse.core.db import SessionLocal, engine
from alphapulse.models import NewsItem
from alphapulse.news_research import service
from alphapulse.news_research.models import Package, Event, Membership, Attempt, Version
from alphapulse.news_research.contracts import IndexResult, Assignment, ResearchResult
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 s:
news = NewsItem(
source_code="wscn_news",
external_id="ci-research-" + str(now.timestamp()),
title="Original incident and response",
summary="",
content_text="Original incident and response",
content_hash="c" * 64,
url="https://example.invalid/ci",
published_at=now,
)
s.add(news)
await s.commit()
news_id = news.id
await service.enroll(s, [news_id])
await service.enroll(s, [news_id])
package = await s.scalar(select(Package).where(Package.news_id == news_id))
identifier = package.id
assert (
await s.scalar(
select(func.count())
.select_from(Package)
.where(Package.news_id == news_id)
)
== 1
)
calls = []
model_inputs = {}
async def config(*args):
return SimpleNamespace(context_window=1000000, max_tokens=1000), "test", {}
async def complete(provider, skill, payload, schema):
calls.append(schema.__name__)
model_inputs[schema.__name__] = deepcopy(payload)
if schema is IndexResult:
return IndexResult(
keywords=[
{
"canonical": "incident",
"aliases": ["incident"],
"quote": "Original incident and response",
}
]
), {}
return Assignment(
targets=[
{
"event_id": None,
"new_key": str(i),
"title": "Incident " + str(i),
"reason": "A related part",
"quote": "Original incident and response",
"keyword_canonicals": ["incident"],
"change_kind": "new_fact",
"urgent": False,
}
for i in (1, 2)
]
), {}
with (
patch.object(service, "model_config", config),
patch.object(service, "complete", complete),
):
await service.process_package(identifier, {})
await service.process_package(identifier, {})
assert calls == ["IndexResult", "Assignment"]
async with SessionLocal() as s:
attempts = list(
await s.scalars(select(Attempt).where(Attempt.package_id == identifier))
)
assert {a.stage: a.input for a in attempts} == {
"index": model_inputs["IndexResult"],
"assign": model_inputs["Assignment"],
}, "Persist the exact model input, including assignment keywords and candidates"
for event in await s.scalars(select(Event)):
assert event.due_at - event.dirty_since == timedelta(minutes=5)
assert (
await s.scalar(
select(func.count())
.select_from(Membership)
.where(Membership.package_id == identifier)
)
== 2
)
assert (
await s.get(NewsItem, news_id)
).content_text == "Original incident and response"
package = await s.get(Package, identifier)
package.state = "assign_pending"
await s.commit()
with (
patch.object(service, "model_config", config),
patch.object(
service, "complete", side_effect=ValueError("classification failure")
),
):
await service.process_package(identifier, {})
async with SessionLocal() as s:
package = await s.get(Package, identifier)
assert package.state == "assign_failed" and package.keywords
failed = await s.scalar(
select(Attempt)
.where(Attempt.package_id == identifier, Attempt.stage == "assign")
.order_by(Attempt.id.desc())
)
assert failed.input["keywords"] == package.keywords
assert len(failed.input["events"]) == 2
assert (
await s.scalar(
select(func.count())
.select_from(Attempt)
.where(Attempt.package_id == identifier, Attempt.stage == "index")
)
== 1
)
# Evidence validation failure is retained as a failed version, never a success.
event = await s.scalar(
select(Event)
.join(Membership)
.where(Membership.package_id == identifier)
.limit(1)
)
package.state = "done"
event.state = "pending"
event.due_at = now
await s.commit()
event_id = event.id
async def run(self, *args):
self.records.append(
{
"tool": "read_news",
"arguments": {"id": 2147483647},
"result": {
"article_id": -1,
"news_id": 2147483647,
"content": "Previously verified background",
},
}
)
result = ResearchResult(
id=event_id,
title="Incident",
summary="Summary",
category="安全",
entities=[],
risk_stars=1,
importance_stars=2,
direction="unknown",
verification="insufficient",
confidence="low",
reasoning="Unknown",
uncertainty="Unknown",
facts="Original",
timeline="Unknown",
asset_impacts="Unknown",
opinions="Unknown",
counter_evidence="Unknown",
watchpoints="Unknown",
claims=[
{
"statement": "Original",
"verdict": "insufficient",
"evidence_refs": [],
}
],
evidence_refs=[{"article_id": news_id, "quote": "Invented"}],
timeline_points=[
{
"package_ids": [identifier],
"occurred_at": None,
"time_precision": "unknown",
"time_text": "",
"reported_at": now,
"description": "Original",
"verification": "partial",
"evidence_refs": [
{
"article_id": news_id,
"quote": "Original incident and response",
}
],
}
],
references=[],
excluded_article_ids=[],
)
return result, {}
with (
patch.object(service, "model_config", config),
patch.object(service.Research, "run", run),
):
await service.process_event(event_id, {}, force=True)
async with SessionLocal() as s:
event = await s.get(Event, event_id)
assert event.state == "failed" and event.result is None
version = await s.scalar(select(Version).where(Version.event_id == event_id))
assert (
version.state == "failed"
and version.output["evidence_refs"][0]["quote"] == "Invented"
)
assert (
await s.scalar(
text(
"SELECT count(*) FROM information_schema.tables WHERE table_name LIKE 'news_research_%'"
)
)
== 8
)
# Exercise actual routes and published-Skill resolution against isolated storage.
import httpx
from fastapi import FastAPI
from alphapulse.news_research import api
from alphapulse.models import AiProvider, SkillNode, SkillVersion
from alphapulse.core.crypto import encrypt
from alphapulse.news_research import model as model_service
app = FastAPI()
app.include_router(api.router)
app.dependency_overrides[api.require_admin] = lambda: None
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app), base_url="http://ci"
) as client:
assert (await client.get("/api/risk/events?page=0")).status_code == 422
assert (await client.get("/api/risk/packages/2147483647")).status_code == 404
assert (
len((await client.get(f"/api/risk/news/{news_id}/packages")).json()["data"])
== 1
)
assert (
await client.get(f"/api/risk/news/{news_id}/packages?revision_no=99")
).json()["data"] == []
async with SessionLocal() as s:
published = dict(
(
await s.execute(
select(SkillNode.id, SkillNode.published_version_id)
)
).all()
)
ids = (await client.post("/api/risk/skill-templates")).json()["data"]
async with SessionLocal() as s:
provider = AiProvider(
name="ci-only-" + str(now.timestamp()),
protocol="openai",
base_url="https://example.invalid",
api_key_encrypted=encrypt("ci-only"),
api_key_hint="ci",
model="ci-only",
max_tokens=1000,
context_window=100000,
reasoning_effort=None,
)
s.add(provider)
for identifier in ids.values():
node = await s.get(SkillNode, identifier)
assert (
node.scope == "news"
and node.published_version_id == published.get(identifier)
)
node.published_version_id = await s.scalar(
select(SkillVersion.id)
.where(SkillVersion.skill_id == identifier)
.order_by(SkillVersion.id.desc())
.limit(1)
)
await s.commit()
provider_id = provider.id
config = {
stage: {
"provider_id": provider_id,
"model": "ci-only",
"reasoning_effort": None,
"skill_id": ids[stage],
}
for stage in ("index", "research")
}
config["concurrency"] = 2
assert (await client.put("/api/risk/settings", json=config)).status_code == 200
assert (await client.get("/api/risk/settings")).json()["data"][
"enabled"
] is False
async with SessionLocal() as s:
for stage in ("index", "research"):
provider, skill, rules = await service.model_config(s, config, stage)
assert (
rules["skill_id"] == ids[stage]
and skill
and provider.model == "ci-only"
)
async def validation(provider, skill, payload, schema):
source = payload.get("article") or payload["articles"][0]
quote = source["content"]
if schema is IndexResult:
return schema(
keywords=[
{
"canonical": "示例公司",
"aliases": ["示例公司"],
"quote": quote,
}
]
), {}
if schema is Assignment:
return schema(
targets=[
{
"event_id": None,
"new_key": "ci",
"title": "Test",
"reason": "Test",
"quote": quote,
"keyword_canonicals": ["示例公司"],
"change_kind": "new_fact",
"urgent": False,
}
]
), {}
result = dict(version.output)
result.update(
id=1,
evidence_refs=[{"article_id": 1, "quote": quote}],
timeline_points=[
{
**result["timeline_points"][0],
"package_ids": [1],
"evidence_refs": [{"article_id": 1, "quote": quote}],
}
],
)
return schema.model_validate(result), {}
with patch.object(model_service, "complete", validation):
response = await client.post("/api/risk/enable")
assert response.status_code == 200, response.text
assert (await client.get("/api/risk/settings")).json()["data"][
"enabled"
] is True
assert (await client.post("/api/risk/enable")).status_code == 409
assert (await client.post("/api/risk/pause")).status_code == 200
for action in ("pause", "save"):
interrupted = False
async def interrupted_validation(
provider, skill, payload, schema, *, action=action
):
nonlocal interrupted
if not interrupted:
interrupted = True
response = (
await client.post("/api/risk/pause")
if action == "pause"
else await client.put("/api/risk/settings", json=config)
)
assert response.status_code == 200
return await validation(provider, skill, payload, schema)
with patch.object(model_service, "complete", interrupted_validation):
response = await client.post("/api/risk/enable")
assert response.status_code == 409, response.text
assert (await client.get("/api/risk/settings")).json()["data"][
"enabled"
] is False
package_id = (await client.get(f"/api/risk/news/{news_id}/packages")).json()[
"data"
][0]["id"]
assert (
await client.post(f"/api/risk/packages/{package_id}/retry")
).status_code == 200
async with SessionLocal() as s:
row = await s.get(Package, package_id)
assert row.state == "assign_pending" and row.keywords
event_detail = (await client.get(f"/api/risk/events/{event_id}")).json()["data"]
assert (
event_detail["packages"]
and event_detail["versions"][0]["state"] == "failed"
)
assert (
await client.post(f"/api/risk/events/{event_id}/retry")
).status_code == 200
async with SessionLocal() as s:
assert (await s.get(Event, event_id)).revision == version.revision
package = await s.get(Package, package_id)
package.state = "done"
await s.commit()
other_id = await s.scalar(
select(Membership.event_id).where(
Membership.package_id == package_id, Membership.event_id != event_id
)
)
async def success(self, provider, skill, payload, schema):
if payload["id"] == event_id:
cached = await self.tool("read_news", {"id": 2147483647})
assert cached["content"] == "Previously verified background"
assert self.counts["read"] == 0
assert any(r.get("reused") for r in self.records)
result = dict(version.output)
quote = payload["articles"][0]["content"]
result.update(
id=payload["id"],
evidence_refs=[{"article_id": news_id, "quote": quote}],
references=[],
timeline_points=[
{
**result["timeline_points"][0],
"package_ids": [package_id],
"evidence_refs": [{"article_id": news_id, "quote": quote}],
}
],
)
if payload["id"] == other_id:
target = await self.tool("read_event", {"id": event_id})
result["references"] = [
{
"version_id": target["version_id"],
"role": "background",
"reason": "Original source already provided",
}
]
return schema.model_validate(result), {}
with patch.object(service.Research, "run", success):
await service.process_event(event_id, config, force=True)
await service.process_event(other_id, config, force=True)
detail = (await client.get(f"/api/risk/events/{other_id}")).json()["data"]
assert detail["state"] == "done" and detail["references"][0]["changed"] is False
assert (
await client.post(f"/api/risk/events/{event_id}/retry")
).status_code == 200
detail = (await client.get(f"/api/risk/events/{other_id}")).json()["data"]
assert detail["state"] == "done" and detail["references"][0]["changed"] is True
# Referenced changes do not automatically enqueue the referring event.
# Old source failures cannot replace work for a newly enrolled source revision.
async with SessionLocal() as s:
changed_news = NewsItem(
source_code="wscn_news",
external_id="ci-source-race-" + str(now.timestamp()),
title="Original incident and response",
content_text="Original incident and response",
content_hash="d" * 64,
url="https://example.invalid/source-race",
published_at=now,
)
s.add(changed_news)
await s.commit()
changed_news_id = changed_news.id
await service.enroll(s, [changed_news_id])
changed_package_id = await s.scalar(
select(Package.id).where(Package.news_id == changed_news_id)
)
with patch.object(service, "complete", complete):
await service.process_package(changed_package_id, config)
async def old_request_failure(*args):
async with SessionLocal() as s:
changed_news = await s.get(NewsItem, changed_news_id)
changed_news.content_text += " updated"
changed_news.content_hash = "e" * 64
changed_news.revision_no = 2
await s.commit()
response = await client.get(
f"/api/risk/news/{changed_news_id}/packages?revision_no=2"
)
assert response.json()["data"] == []
detail = (
await client.get(f"/api/risk/packages/{changed_package_id}")
).json()["data"]
assert not detail["keywords"] and not detail["events"]
assert detail["state"] == "index_pending"
async with SessionLocal() as s:
await service.enroll(s, [changed_news_id])
raise TimeoutError("Old source request timed out")
with patch.object(service, "complete", old_request_failure):
await service.process_package(changed_package_id, config)
async with SessionLocal() as s:
package = await s.get(Package, changed_package_id)
assert package.content_hash == "e" * 64
assert package.state == "index_pending" and package.error is None
attempt = await s.scalar(
select(Attempt)
.where(Attempt.package_id == changed_package_id)
.order_by(Attempt.id.desc())
.limit(1)
)
assert attempt.state == "failed" and attempt.content_hash == "d" * 64
with patch.object(service, "complete", complete):
await service.process_package(changed_package_id, config)
async with SessionLocal() as s:
assert (await s.get(Package, changed_package_id)).state == "assign_pending"
# Date-free titles still have bounded original evidence across a long history.
async with SessionLocal() as s:
for days in (180, 60, 5, 2, 1):
published_at = now - timedelta(days=days)
quote = f"{published_at.date()} Original incident and response"
historical = NewsItem(
source_code="wscn_news",
external_id=f"ci-candidate-{days}-{now.timestamp()}",
title="Recurring report",
content_text=quote,
content_hash=str(days).zfill(64),
url="https://example.invalid/candidate",
published_at=published_at,
)
s.add(historical)
await s.flush()
package = Package(
news_id=historical.id,
content_hash=historical.content_hash,
state="done",
keywords=[],
)
s.add(package)
await s.flush()
s.add(
Membership(
event_id=event_id,
package_id=package.id,
evidence=quote,
reason="Same incident follow-up",
change_kind="new_fact",
)
)
await s.commit()
event = await s.get(Event, event_id)
context = (await service.candidate_context(s, [event]))[0]
assert context["reporting_range"] == {
"first": (now - timedelta(days=180)).isoformat(),
"latest": now.isoformat(),
}
assert len(context["evidence"]) == 3
assert [e["published_at"] for e in context["evidence"]] == [
(now - timedelta(days=180)).isoformat(),
(now - timedelta(days=1)).isoformat(),
now.isoformat(),
]
assert (
str((now - timedelta(days=180)).date())
in context["evidence"][0]["quote"]
)
assert (await s.get(NewsItem, news_id)).content_hash == "c" * 64
await review_regressions(config, complete, changed_package_id, changed_news_id)
await enrollment_regressions()
await phrase_search_regressions()
await event_search_regressions()
await compound_candidate_regressions()
await vocabulary_regressions()
await retrieval_revision_regressions()
await membership_tag_regressions()
await research_scope_regressions()
await engine.dispose()
print(
"Two-stage smoke passed: indexing, membership, evidence reuse, pause cancellation, source races and dated event candidates."
)
async def review_regressions(config, complete, package_id, news_id):
# Publication must not overwrite another session's newer source enrollment.
async def superseded_success(*args):
result = await complete(*args)
async with SessionLocal() as session:
news = await session.get(NewsItem, news_id)
news.content_text += " newer revision"
news.content_hash = "f" * 64
await session.commit()
await service.enroll(session, [news_id])
return result
with patch.object(service, "complete", superseded_success):
await service.process_package(package_id, config)
async with SessionLocal() as session:
package = await session.get(Package, package_id)
assert (
package.content_hash == "f" * 64
and package.state == "index_pending"
and package.keywords == []
)
attempt = await session.scalar(
select(Attempt)
.where(Attempt.package_id == package_id)
.order_by(Attempt.id.desc())
.limit(1)
)
assert attempt.state == "superseded"
package.state = "assign_pending"
await session.commit()
with patch.object(service, "complete", complete):
await service.process_package(package_id, config)
async with SessionLocal() as session:
package = await session.get(Package, package_id)
assert package.state == "assign_failed" and "关键词" in package.error
from alphapulse.news_research.api import retry_package
await retry_package(package_id, session)
assert package.state == "index_pending"
# One canonical group cannot gain ranking weight through its aliases.
old = Event(
title="Specific old incident",
tags=["binance", "hack"],
updated_at=datetime.now(UTC) - timedelta(days=180),
)
session.add(old)
session.add_all(
[
Event(
title=f"Recent company item {i}",
tags=["binance"],
updated_at=datetime.now(UTC),
)
for i in range(65)
]
)
package.keywords = [
{"canonical": "币安", "aliases": ["Binance"], "quote": "Binance"}
]
await session.commit()
catalog = await service.candidates(
session,
[
{"canonical": "binance", "aliases": ["BINANCE"]},
{"canonical": "hack", "aliases": ["HACK"]},
],
)
assert len(catalog) == 60 and catalog[0].id == old.id
for term in ("Binance", " binANCE ", "币安"):
condition, score = service.package_matching([term])
matches = list(
await session.scalars(
select(Package.id)
.join(NewsItem)
.where(condition)
.order_by(score.desc())
)
)
assert package_id in matches
assert package.keywords[0]["aliases"] == ["Binance"]
# Two simultaneous callers must classify against consecutive committed catalogs.
package_ids = []
async with SessionLocal() as session:
for i in range(2):
news = NewsItem(
source_code="wscn_news",
external_id=f"ci-serial-{datetime.now(UTC).timestamp()}-{i}",
title="Serial incident",
url="https://example.invalid/serial",
content_text="serialincident frozen evidence",
content_hash=str(i) * 64,
published_at=datetime.now(UTC),
)
session.add(news)
await session.flush()
package = Package(news_id=news.id, content_hash=news.content_hash)
session.add(package)
await session.flush()
package_ids.append(package.id)
await session.commit()
submissions = []
async def serial_complete(provider, skill, payload, schema):
submissions.append(schema.__name__)
if schema is IndexResult:
return schema(
keywords=[
{
"canonical": "serialincident",
"aliases": ["serialincident"],
"quote": "serialincident",
}
]
), {}
existing = next(
(e for e in payload["events"] if e["title"] == "Serial incident"), None
)
await asyncio.sleep(0.01)
return schema(
targets=[
{
"event_id": existing["id"] if existing else None,
"new_key": None if existing else "serial",
"title": "Serial incident",
"reason": "Same incident",
"quote": "serialincident frozen evidence",
"keyword_canonicals": ["serialincident"],
"change_kind": "new_fact",
"urgent": False,
}
]
), {}
with patch.object(service, "complete", serial_complete):
await asyncio.gather(*(service.process_package(i, config) for i in package_ids))
await asyncio.gather(*(service.process_package(i, config) for i in package_ids))
assert (
submissions.count("IndexResult") == 2 and submissions.count("Assignment") == 2
)
async with SessionLocal() as session:
memberships = list(
await session.scalars(
select(Membership).where(Membership.package_id.in_(package_ids))
)
)
assert len(memberships) == 2 and len({m.event_id for m in memberships}) == 1
event_id = memberships[0].event_id
template = (
await session.scalar(
select(Version)
.where(Version.output.is_not(None))
.order_by(Version.id)
.limit(1)
)
).output
# Force compression, then exercise both member read tools with frozen originals.
async def extract(provider, rules, articles):
assert "当前事件及各成员归属边界" in rules
assert '"keyword_canonicals": ["serialincident"]' in rules
return [
{
"evidence_refs": [
{"article_id": a["id"], "quote": "serialincident"} for a in articles
]
}
], []
async def compact(provider, rules, request, schema, fits):
return request, []
async def compressed_run(self, provider, skill, payload, schema):
assert payload["articles"] == [] and "extractions" in payload
assert all(p["scope_selection"] == "assignment" and p["keyword_canonicals"] == ["serialincident"]
for p in payload["packages"])
assert not self.provided_news
refs = []
for i, package in enumerate(payload["packages"]):
result = await self.tool(
"read_news" if i == 0 else "read_package",
{"id": package["news_id"] if i == 0 else package["id"]},
)
assert (
result["article_id"] == package["news_id"]
and result["content"] == "serialincident frozen evidence"
)
refs.append(
{"article_id": result["article_id"], "quote": result["content"]}
)
point = {
**template["timeline_points"][0],
"package_ids": payload["package_ids"],
"evidence_refs": refs,
"description": "serialincident",
"time_text": "",
}
return schema(
**{
**template,
"id": event_id,
"evidence_refs": refs,
"timeline_points": [point],
"references": [],
}
), {}
with (
patch.object(service.Research, "fits", return_value=False),
patch.object(service, "extract_articles", extract),
patch.object(service, "compact_extractions", compact),
patch.object(service.Research, "run", compressed_run),
):
await service.process_event(event_id, config, force=True)
async with SessionLocal() as session:
event = await session.get(Event, event_id)
assert event.state == "done", event.error
print("Serial assignment and compressed member tool integration passed.")
print(
"Review regressions passed: superseded success, durable empty-index failure, long-lived ranking and literal alias search."
)
async def enrollment_regressions():
for cache in ("news", "package", "both"):
async with SessionLocal() as session:
news = NewsItem(
source_code="wscn_news",
external_id=f"ci-cache-{cache}-{datetime.now(UTC).timestamp()}",
title="Cache fixture",
url="https://example.invalid/cache",
content_text="Second revision",
content_hash="2" * 64,
published_at=datetime.now(UTC),
)
session.add(news)
await session.flush()
package = Package(
news_id=news.id,
content_hash=news.content_hash,
state="done",
keywords=[
{
"canonical": "Second",
"aliases": ["Second"],
"quote": "Second revision",
}
],
)
event = Event(title="Cache event", revision=7, state="done")
session.add_all([package, event])
await session.flush()
session.add(
Membership(
package_id=package.id,
event_id=event.id,
evidence="Second revision",
reason="Same event",
change_kind="new_fact",
)
)
await session.commit()
news_id, package_id, event_id = news.id, package.id, event.id
async with SessionLocal() as stale:
# Keep strong references so ORM identity-map entries remain cached.
cached_news = (
await stale.get(NewsItem, news_id)
if cache in ("news", "both")
else None
)
cached_package = (
await stale.get(Package, package_id)
if cache in ("package", "both")
else None
)
async with SessionLocal() as fresh:
news = await fresh.get(NewsItem, news_id)
package = await fresh.get(Package, package_id)
news.content_hash, news.content_text = "3" * 64, "Third revision"
package.content_hash, package.state = (
news.content_hash,
"assign_processing",
)
package.keywords = [
{
"canonical": "Third",
"aliases": ["Third"],
"quote": "Third revision",
}
]
await fresh.commit()
await service.enroll(stale, [news_id])
assert cached_news is not None or cached_package is not None
async with SessionLocal() as session:
package = await session.get(Package, package_id)
event = await session.get(Event, event_id)
assert (
package.content_hash == "3" * 64
and package.state == "assign_processing"
and package.keywords[0]["canonical"] == "Third"
), cache
assert event.revision == 7, cache
news = await session.get(NewsItem, news_id)
news.content_hash, news.content_text = "4" * 64, "Fourth revision"
await session.commit()
await service.enroll(session, [news_id])
await session.refresh(package)
await session.refresh(event)
assert (
package.content_hash == "4" * 64
and package.state == "index_pending"
and package.keywords == []
)
assert event.revision == 8
await service.enroll(session, [news_id])
await session.refresh(event)
assert event.revision == 8
print("Enrollment cached-source and cached-package regressions passed.")
async def phrase_search_regressions():
async with SessionLocal() as session:
target_id = None
for i in range(23):
news = NewsItem(
source_code="wscn_news",
external_id=f"ci-phrase-{datetime.now(UTC).timestamp()}-{i}",
title="Generic report" if i == 0 else "Federal policy Reserve outlook",
url="https://example.invalid/phrase",
content_text="Federal Reserve announcement",
content_hash="5" * 64,
published_at=datetime.now(UTC),
)
session.add(news)
await session.flush()
package = Package(
news_id=news.id,
content_hash=news.content_hash,
state="done",
keywords=[
{
"canonical": "美联储",
"aliases": ["Federal Reserve"],
"quote": news.content_text,
}
]
if i == 0
else [],
)
session.add(package)
await session.flush()
if i == 0:
target_id = package.id
await session.commit()
for args in (
{"query": "Federal Reserve"},
{"query": " fEDERAL rESERVE "},
{
"query": "Federal Reserve",
"keywords": ["one", "two", "three", "four", "five", "six"],
},
{"query": "unrelated", "keywords": ["Federal Reserve"]},
{
"query": "absent",
"keywords": ["one", "two", "three", "four", "five", "Federal Reserve"],
},
{"query": "美联储"},
):
rows = await service.Research(event={"id": 0}).tool("search_packages", args)
assert rows and rows[0]["id"] == target_id, args
assert len(rows) <= 20
for args in (
{"query": "Federal policy"},
{"query": "absent", "keywords": ["Federal"]},
):
assert await service.Research(event={"id": 0}).tool("search_packages", args)
async with SessionLocal() as session:
package = await session.get(Package, target_id)
assert package.keywords[0]["aliases"] == ["Federal Reserve"]
print("Phrase retrieval and top-20 ranking regressions passed.")
async def event_search_regressions():
async with SessionLocal() as session:
target = Event(
title="加拿大央行利率决议",
tags=["bank of canada", "加拿大央行"],
updated_at=datetime.now(UTC) - timedelta(days=180),
)
own = Event(title="Current event", tags=["bank of canada"])
retired = Event(
title="Retired event", tags=["bank of canada"], state="retired"
)
session.add_all([target, own, retired])
session.add_all(
Event(title=f"Bank lending of Canada {i}", tags=[]) for i in range(23)
)
await session.commit()
target_id, own_id, retired_id = target.id, own.id, retired.id
for args in (
{"query": "Bank of Canada"},
{"query": " bANK oF cANADA "},
{
"query": "Bank of Canada",
"keywords": ["one", "two", "three", "four", "five", "six"],
},
{"query": "absent", "keywords": ["Bank of Canada"]},
{"query": "加拿大央行"},
):
rows = await service.Research(event={"id": own_id}).tool("search_events", args)
assert rows and rows[0]["id"] == target_id, args
assert len(rows) <= 20
assert not {own_id, retired_id} & {row["id"] for row in rows}
print("Event phrase retrieval, relevance ranking and visibility regressions passed.")
async def compound_candidate_regressions():
now = datetime.now(UTC)
async with SessionLocal() as session:
target = Event(title="Historical exchange incident", tags=["bybit"], updated_at=now-timedelta(days=180))
title_only = Event(title="Bybit frozen-funds follow-up", tags=[])
unrelated = Event(title="Bybitx unrelated announcement", tags=["bybitx"])
retired = Event(title="Retired Bybit incident", tags=["bybit"], state="retired")
currency = Event(title="USDC reserve", tags=["usdc"])
exact = Event(title="Earlier full-phrase match", tags=["bybit hack"], updated_at=now-timedelta(days=400))
chinese_old = Event(title="隔离中文候选", tags=[], updated_at=now-timedelta(days=10))
chinese_new = Event(title="隔离中文候选", tags=[], updated_at=now)
session.add_all([target, title_only, unrelated, retired, currency, exact, chinese_old, chinese_new])
await session.flush()
keys = [{"canonical": "Bybit黑客事件", "aliases": ["Bybit Hack"]}]
rows = await service.candidates(session, keys)
ids = {row.id for row in rows}
assert {target.id, title_only.id, exact.id} <= ids
assert not {unrelated.id, retired.id} & ids
assert rows[0].id == exact.id
rows = await service.candidates(session, [{"canonical": "美元抵押", "aliases": ["USD collateral"]}])
assert currency.id not in {row.id for row in rows}
rows = await service.candidates(session, [{"canonical": "隔离中文候选", "aliases": ["隔离中文候选"]}])
assert [row.id for row in rows] == [chinese_new.id, chinese_old.id]
session.add_all(Event(title=f"Recent Bybit update {i}", tags=["bybit"]) for i in range(65))
await session.flush()
rows = await service.candidates(session, keys)
assert len(rows) == 60 and rows[0].id == exact.id
await session.rollback()
print("Compound-alias candidate recall, whole-token boundaries, ranking and sixty-result cap passed.")
async def vocabulary_regressions():
from alphapulse.news_research import api
now = datetime.now(UTC)
# Vocabulary changes affect recall without rewriting prior evidence or paid work.
from alphapulse.news_research import vocabulary as vocab
from alphapulse.news_research.models import Reference
async with SessionLocal() as session:
saved = await api.save_vocabulary(vocab.VocabularyInput(base_revision=0, entries=[{"canonical": "币安", "aliases": ["Binance"]}]), session)
assert saved.data["revision"] == 1
old = Event(title="Historical incident A", tags=["binance", "incident-a"], revision=1, state="done", updated_at=now-timedelta(days=400))
session.add(old)
session.add_all([Event(title=f"Unrelated subject occurrence {i}", tags=["binance"], revision=1, state="done") for i in range(65)])
story = NewsItem(source_code="wscn_news", external_id=f"ci-vocab-{now.timestamp()}", title="Original evidence", content_text="Binance incident-a", content_hash="f"*64, published_at=now-timedelta(days=400), url="https://example.invalid/vocab")
session.add(story)
await session.flush()
package = Package(news_id=story.id, content_hash=story.content_hash, state="done", keywords=[{"canonical": "Binance", "aliases": ["Binance"], "quote": "Binance incident-a"}])
session.add(package)
await session.flush()
frozen = service.article(story)
target = Version(event_id=old.id, revision=1, state="done", input={"packages": [{"id": package.id, "news_id": story.id}], "articles": [frozen]}, output={"title": "Historical title", "summary": "Frozen summary"}, rules={})
session.add(target)
await session.commit()
keys = [{"canonical": "币安", "aliases": ["Binance"]}, {"canonical": "incident-a", "aliases": ["incident-a"]}]
assert (await service.candidates(session, keys))[0].id == old.id
rows = await api.packages(session, q="币安", page=1)
assert package.id in {row["id"] for row in rows.data["items"]}
await api.save_vocabulary(vocab.VocabularyInput(base_revision=1, entries=[{"canonical": "币安", "aliases": ["Binance", "BNC"]}]), session)
rows = await api.packages(session, q=" BNC ", page=1)
assert package.id in {row["id"] for row in rows.data["items"]}
assert package.keywords[0]["canonical"] == "Binance"
snapshot = await api.event_version(old.id, target.id, session)
assert snapshot.data["articles"][0]["content"] == "Binance incident-a"
consumers = []
for i in range(2):
consumer = Event(title=f"Independent rule {i}", tags=[], revision=1, state="done")
session.add(consumer)
await session.flush()
version = Version(event_id=consumer.id, revision=1, state="done", input={}, output={}, rules={})
session.add(version)
await session.flush()
session.add(Reference(version_id=version.id, target_version_id=target.id, role="support", reason="Independent source confirms context"))
consumers.append(version)
await session.commit()
old.title = "Current different title"
service.dirty(old)
story.content_text = "Updated original"
await session.commit()
for consumer in consumers:
refs = await api.version_references(session, consumer.id)
assert refs[0]["changed"] and refs[0]["title"] == "Historical title"
assert refs[0]["version_id"] == target.id
assert (await api.event_version(old.id, target.id, session)).data["articles"][0]["content"] == "Binance incident-a"
assert (await session.get(Package, package.id)).state == "done"
old_id = old.id
results = await service.Research(event={"id": 0}).tool("search_events", {"query": "BNC", "keywords": ["incident-a"]})
assert results[0]["id"] == old_id
print("Vocabulary API, cross-year recall above 60 distractors, alias updates and two immutable references passed.")
async def retrieval_revision_regressions():
from alphapulse.news_research import api, vocabulary as vocab
now = datetime.now(UTC)
async with SessionLocal() as session:
news = NewsItem(
source_code="wscn_news",
external_id=f"ci-stale-tags-{now.timestamp()}",
title="Corrected headline for retrieval regression",
content_text="Binance corrected company announcement",
content_hash="8" * 64,
published_at=now,
url="https://example.invalid/retrieval-revision",
)
session.add(news)
await session.flush()
package = Package(
news_id=news.id,
content_hash="7" * 64,
state="done",
keywords=[{"canonical": "obsolete-retrieval-tag", "aliases": ["obsolete-retrieval-tag"], "quote": "obsolete-retrieval-tag"}],
)
session.add(package)
await session.commit()
package_id = package.id
stale = await api.packages(session, q="obsolete-retrieval-tag", page=1)
assert stale.data["total"] == 0
current = await api.packages(session, q=news.title, page=1)
assert current.data["items"][0]["id"] == package_id
assert current.data["items"][0]["keywords"] == []
query = {"query": "obsolete-retrieval-tag"}
assert await service.Research(event={"id": 0}).tool("search_packages", query) == []
current = await service.Research(event={"id": 0}).tool("search_packages", {"query": "Corrected headline for retrieval regression"})
assert current[0]["id"] == package_id and current[0]["keywords"] == []
async with SessionLocal() as session:
package = await session.get(Package, package_id)
package.content_hash = "8" * 64
package.keywords = [{"canonical": "Binance", "aliases": ["Binance"], "quote": "Binance"}]
current = await vocab.snapshot(session)
before = {"revision": current["revision"], "entries": current["entries"]}
entries = [{**e, "aliases": [*e["aliases"], "BNC-reviewed"]} if e["canonical"] == "币安" else e for e in current["entries"]]
await api.save_vocabulary(vocab.VocabularyInput(base_revision=current["revision"], entries=entries), session)
after = await vocab.snapshot(session)
event = Event(title="Reviewed independent incident", tags=["binance"])
session.add(event)
await session.commit()
event_id = event.id
for tool, target in (("search_packages", package_id), ("search_events", event_id)):
args = {"query": "BNC-reviewed", "keywords": []}
prior = service.Research(event={"id": 0})
prior.vocabulary = before
assert await prior.tool(tool, args) == []
retry = service.Research(event={"id": 0})
retry.vocabulary = after
retry.restore(prior.records)
rows = await retry.tool(tool, args)
assert target in {row["id"] for row in rows}
assert len(retry.records) == 1 and not retry.records[0].get("reused")
async with SessionLocal() as session:
fresh = await api.packages(session, q="BNC-reviewed", page=1)
assert package_id in {row["id"] for row in fresh.data["items"]}
print("Stale TAG exclusion, current-title lookup and vocabulary retry refresh passed.")
async def membership_tag_regressions():
original = "高盛公司警示美股风险。作者鲍比·莫拉维(Bobby Molavi)。民主党另有选情更新。"
revised = "公司英伟达发布GPU。"
followup = "Bobby Molavi补充材料支持原报告。"
package_ids = []
news_ids = []
async with SessionLocal() as session:
for i, content in enumerate((original, followup)):
news = NewsItem(
source_code="wscn_news", external_id=f"ci-membership-tags-{datetime.now(UTC).timestamp()}-{i}",
title="Risk report" if i == 0 else "Follow-up", content_text=content,
content_hash=str(i) * 64, published_at=datetime.now(UTC),
url="https://example.invalid/membership-tags",
)
session.add(news)
await session.flush()
news_ids.append(news.id)
await session.commit()
await service.enroll(session, news_ids)
for news_id in news_ids:
package_ids.append(await session.scalar(select(Package.id).where(Package.news_id == news_id)))
async def config(*args):
return SimpleNamespace(context_window=1000000, max_tokens=1000), "test", {}
event_id = None
reject_revision = True
async def complete(provider, skill, payload, schema):
content = payload["article"]["content"]
if schema is IndexResult:
if content == original:
pairs = [("高盛", ["高盛", "公司"]), ("鲍比·莫拉维", ["鲍比·莫拉维", "Bobby Molavi"]),
("美国民主党", ["民主党"])]
elif content == revised:
pairs = [("英伟达", ["英伟达", "公司"])]
else:
pairs = [("原报告", ["原报告"]), ("鲍比·莫拉维", ["Bobby Molavi"])]
return schema(keywords=[{"canonical": c, "aliases": a, "quote": content} for c, a in pairs]), {}
if content == revised and reject_revision:
raise ValueError("Synthetic assignment failure after reindexing")
existing = event_id if content == followup else None
return schema(targets=[{
"event_id": existing, "new_key": None if existing else "report",
"title": "高盛风险警示" if content == original else "GPU发布",
"reason": "Same report" if existing else "Independent matter",
"quote": "高盛公司警示美股风险。" if content == original else content,
"keyword_canonicals": ["高盛", "鲍比·莫拉维"] if content == original else ["英伟达"] if content == revised else ["原报告", "鲍比·莫拉维"],
"change_kind": "new_fact", "urgent": False,
}]), {}
with patch.object(service, "model_config", config), patch.object(service, "complete", complete):
await service.process_package(package_ids[0], {})
await service.process_package(package_ids[0], {})
async with SessionLocal() as session:
package = await session.get(Package, package_ids[0])
assert package.state == "done", package.error
event_id = await session.scalar(select(Membership.event_id).where(Membership.package_id == package.id))
event = await session.get(Event, event_id)
assert set(event.tags) == {"高盛", "公司", "鲍比·莫拉维", "bobby molavi"}
catalog = await service.candidates(session, [{"canonical": "鲍比·莫拉维", "aliases": ["Bobby Molavi"]}])
assert event_id in {e.id for e in catalog}, "Aliases outside the scope quote must recall the existing event"
news = await session.get(NewsItem, news_ids[0])
news.content_text, news.content_hash = revised, "9" * 64
await session.commit()
await service.enroll(session, [news.id])
# Another assignment rebuilds this event after the revised index was published.
await service.process_package(package_ids[0], {})
await service.process_package(package_ids[1], {})
await service.process_package(package_ids[1], {})
await service.process_package(package_ids[0], {})
async with SessionLocal() as session:
other = await session.get(Package, package_ids[1])
assert other.state == "done", other.error
package = await session.get(Package, package_ids[0])
assert package.state == "assign_failed" and package.keywords[0]["canonical"] == "英伟达"
event = await session.get(Event, event_id)
await service.refresh_event_tags(session, event)
assert "bobby molavi" in event.tags and "英伟达" not in event.tags and "民主党" not in event.tags
package.state = "assign_pending"
await session.commit()
# A failure after writing memberships must roll back snapshots and event creation.
reject_revision = False
with patch.object(service, "refresh_event_tags", side_effect=ValueError("Synthetic publication failure")):
await service.process_package(package_ids[0], {})
async with SessionLocal() as session:
package = await session.get(Package, package_ids[0])
assert package.state == "assign_failed" and package.error == "Synthetic publication failure"
assert await session.scalar(select(Membership.event_id).where(Membership.package_id == package.id)) == event_id
assert not await session.scalar(select(Event.id).where(Event.title == "GPU发布"))
event = await session.get(Event, event_id)
await service.refresh_event_tags(session, event)
assert "bobby molavi" in event.tags and "英伟达" not in event.tags
package.state = "assign_pending"
await session.commit()
# A successful reassignment removes only this member's old contribution.
await service.process_package(package_ids[0], {})
async with SessionLocal() as session:
package = await session.get(Package, package_ids[0])
assert package.state == "done", package.error
remaining_tags = ["bobby molavi", "原报告", "鲍比·莫拉维"]
assert (await session.get(Event, event_id)).tags == remaining_tags
moved_id = await session.scalar(select(Membership.event_id).where(Membership.package_id == package.id))
assert moved_id != event_id and set((await session.get(Event, moved_id)).tags) == {"公司", "英伟达"}
# Legacy records use the historical input, even when the live index changes.
legacy = await session.scalar(select(Attempt).where(
Attempt.package_id == package_ids[1], Attempt.stage == "assign", Attempt.state == "done"))
legacy.rules = {k: v for k, v in legacy.rules.items() if k != "membership_tags"}
(await session.get(Package, package_ids[1])).keywords = package.keywords
await session.flush()
event = await session.get(Event, event_id)
await service.refresh_event_tags(session, event)
assert event.tags == remaining_tags
await session.rollback()
print("Membership TAG selection, English entity recall, revision interleaving and publication rollback passed.")
async def research_scope_regressions():
content = "革命卫队公告称四艘油轮触雷。作者Ali提供事故说明。另有两艘船被拦截。"
keywords = [
{"canonical": "油轮触雷", "aliases": ["油轮触雷"], "quote": "四艘油轮触雷"},
{"canonical": "Ali", "aliases": ["Ali"], "quote": "作者Ali提供事故说明"},
{"canonical": "船舶拦截", "aliases": ["船被拦截"], "quote": "两艘船被拦截"},
]
async with SessionLocal() as session:
news = NewsItem(source_code="wscn_news", external_id=f"ci-research-scope-{datetime.now(UTC).timestamp()}",
title="Two independent incidents", content_text=content, content_hash="8" * 64,
url="https://example.invalid/research-scope", published_at=datetime.now(UTC))
session.add(news)
await session.commit()
news_id = news.id
await service.enroll(session, [news_id])
package_id = await session.scalar(select(Package.id).where(Package.news_id == news_id))
template = (await session.scalar(select(Version).where(Version.output.is_not(None)).order_by(Version.id).limit(1))).output
targets = [
{"event_id": None, "new_key": "mine", "title": "油轮触雷", "reason": "触雷事故",
"quote": "革命卫队公告称四艘油轮触雷。", "keyword_canonicals": ["油轮触雷", "Ali"],
"change_kind": "new_fact", "urgent": False},
{"event_id": None, "new_key": "interception", "title": "船舶拦截", "reason": "独立拦截行动",
"quote": "另有两艘船被拦截。", "keyword_canonicals": ["船舶拦截"],
"change_kind": "new_fact", "urgent": False},
]
async def config(*args):
return SimpleNamespace(context_window=1000000, max_tokens=1000), "test", {}
async def complete(provider, skill, payload, schema):
return (schema(keywords=keywords) if schema is IndexResult else schema(targets=targets)), {}
with patch.object(service, "model_config", config), patch.object(service, "complete", complete):
await service.process_package(package_id, {})
await service.process_package(package_id, {})
async with SessionLocal() as session:
event_ids = list(await session.scalars(select(Membership.event_id).where(Membership.package_id == package_id).order_by(Membership.event_id)))
assert len(event_ids) == 2
assignment = await session.scalar(select(Attempt).where(Attempt.package_id == package_id, Attempt.stage == "assign", Attempt.state == "done"))
assert assignment.rules["membership_targets"] == {str(event_id): target for event_id, target in zip(event_ids, targets, strict=True)}
# A newer failed assignment and mutable index must not replace the snapshot.
session.add(Attempt(package_id=package_id, content_hash="8" * 64, stage="assign", state="failed",
input={"keywords": []}, rules={}, output=None))
(await session.get(Package, package_id)).keywords = [{"canonical": "Unrelated", "aliases": ["Unrelated"]}]
await session.commit()
seen = []
async def run(self, provider, skill, payload, schema):
index = event_ids.index(payload["id"])
target, other = targets[index], targets[1-index]
package = payload["packages"][0]
assert package["keyword_canonicals"] == target["keyword_canonicals"]
assert package["keywords"] == [k for k in keywords if k["canonical"] in target["keyword_canonicals"]]
assert package["other_scopes"] == [{"title": other["title"], "scope_quote": other["quote"], "reason": other["reason"]}]
assert payload["articles"][0]["content"] == content
seen.append(deepcopy(payload))
refs = [{"article_id": news_id, "quote": target["quote"]}]
point = {**template["timeline_points"][0], "package_ids": [package_id], "evidence_refs": refs,
"description": target["title"], "occurred_at": None, "time_precision": "unknown", "time_text": ""}
return schema(**{**template, "id": payload["id"], "title": target["title"], "summary": target["title"],
"facts": target["quote"],
"claims": [{"statement": target["quote"], "verdict": "supported", "evidence_refs": refs}],
"evidence_refs": refs, "references": [],
"timeline_points": [point]}), {}
with patch.object(service, "model_config", config), patch.object(service.Research, "run", run):
for event_id in event_ids:
await service.process_event(event_id, {}, force=True)
async with SessionLocal() as session:
for event_id, payload in zip(event_ids, seen, strict=True):
event = await session.get(Event, event_id)
assert event.state == "done", event.error
version = await session.scalar(select(Version).where(Version.event_id == event_id))
assert version.input == payload and version.state == "done"
assert len(seen) == 2
# Missing ownership fails durably before calling the model.
assignment = await session.get(Attempt, assignment.id)
assignment.state = "failed"
event = await session.get(Event, event_ids[0])
service.dirty(event)
await session.commit()
with patch.object(service, "model_config", side_effect=AssertionError("No model without snapshot")):
await service.process_event(event_ids[0], {}, force=True)
async with SessionLocal() as session:
event = await session.get(Event, event_ids[0])
assert event.state == "failed" and "快照" in event.error
assert event.result is not None
version = await session.scalar(select(Version).where(Version.event_id == event.id).order_by(Version.id.desc()).limit(1))
assert version.state == "failed" and "快照" in version.error
assignment = await session.get(Attempt, assignment.id)
assignment.state, assignment.content_hash = "done", "9" * 64
event.state = "pending"
await session.commit()
with patch.object(service, "model_config", side_effect=AssertionError("No model with stale snapshot")):
await service.process_event(event_ids[0], {}, force=True)
async with SessionLocal() as session:
event = await session.get(Event, event_ids[0])
assert event.state == "failed" and "版本不一致" in event.error
print("Research receives immutable event-specific TAGs, sibling boundaries and full originals; missing snapshots fail durably.")
asyncio.run(main())