Select assignment-specific keyword snapshots, improve model-token recall and calendar dates, and reuse supplied originals. Keep extraction boundaries while checking compaction against the final research context.
1379 lines
62 KiB
Python
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_risk import service as legacy_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(legacy_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(legacy_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())
|