Files
AlphaPulse/backend/tests/test_news_research_workspace.py
T
eezd 4929a32a0a feat(news): index news packages and research persistent events
Extract reusable literal keywords once per source revision, assign complete news packages to long-lived events, and research all event members with immutable evidence and references.

Add additive research storage, vocabulary snapshots, the two-stage workspace, isolated acceptance coverage, and a paused rollout with an initial fixed 50-news production trial.
2026-11-04 12:09:58 +08:00

357 lines
13 KiB
Python

"""Behavioral contracts for one-news packages and reusable keyword indexes."""
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace as N
import pytest
def test_storage_is_additive_and_membership_is_many_to_many(trading):
from alphapulse.news_research.models import TABLES, Package, Membership
assert all(t.name.startswith("news_research_") for t in TABLES)
assert {c.name for c in Membership.__table__.primary_key} == {
"event_id",
"package_id",
}
assert any(
{c.name for c in constraint.columns} == {"news_id"}
for constraint in Package.__table__.constraints
if constraint.__class__.__name__ == "UniqueConstraint"
)
def test_keywords_require_original_aliases_and_literal_evidence(trading):
from alphapulse.news_research.contracts import IndexResult
from alphapulse.news_research.service import validate_keywords
source = {"id": 7, "content": "Binance reported a security incident."}
output = IndexResult(
keywords=[
{
"canonical": "币安",
"aliases": ["Binance"],
"quote": "Binance reported a security incident.",
}
]
)
validate_keywords(output, source)
output.keywords[0].aliases = ["Invented"]
with pytest.raises(ValueError, match="别名"):
validate_keywords(output, source)
output.keywords[0].aliases = ["Binance"]
output.keywords[0].quote = "Binance was hacked"
with pytest.raises(ValueError, match="原文"):
validate_keywords(output, source)
def test_one_package_can_be_assigned_to_multiple_events_without_splitting(trading):
from alphapulse.news_research.contracts import Assignment
from alphapulse.news_research.service import validate_assignment
base = {
"new_key": None,
"title": "Incident",
"reason": "Follow-up",
"quote": "Original",
"change_kind": "new_fact",
"urgent": False,
}
result = Assignment(targets=[{**base, "event_id": 1}, {**base, "event_id": 2}])
validate_assignment(result, {"id": 8, "content": "Original"}, {1, 2})
with pytest.raises(ValueError, match="未知事件"):
validate_assignment(result, {"id": 8, "content": "Original"}, {1})
with pytest.raises(ValueError, match="重复"):
Assignment(targets=[{**base, "event_id": 1}, {**base, "event_id": 1}])
def test_event_refresh_keeps_hard_deadline_and_urgency(trading):
from alphapulse.news_research.service import dirty
now = datetime.now(UTC)
event = N(
revision=2,
state="done",
dirty_since=now - timedelta(minutes=14),
due_at=now,
error="Old",
)
dirty(event)
assert event.due_at <= event.dirty_since + timedelta(minutes=15)
assert event.revision == 3 and event.state == "pending" and event.error is None
dirty(event, True)
immediate = event.due_at
dirty(event)
assert event.due_at <= datetime.now(UTC) and event.due_at >= immediate
@pytest.mark.parametrize("urgent", [False, True])
def test_new_event_waits_for_aggregation_unless_urgent(trading, urgent):
from alphapulse.news_research.service import dirty
event = N(
revision=0,
state="pending",
dirty_since=None,
due_at=datetime.now(UTC) - timedelta(seconds=1),
)
dirty(event, urgent)
assert event.due_at - event.dirty_since == timedelta(minutes=0 if urgent else 5)
@pytest.mark.parametrize("state", ["failed", "pending", "done"])
def test_event_retry_preserves_content_revision_until_content_changes(trading, state):
import asyncio
from unittest.mock import AsyncMock
from alphapulse.news_research.api import retry_event
event = N(
revision=7,
state=state,
dirty_since=None,
due_at=datetime.now(UTC),
error="Previous failure",
)
session = N(get=AsyncMock(return_value=event), commit=AsyncMock())
asyncio.run(retry_event(1, session))
assert event.revision == (8 if state == "done" else 7)
assert event.state == "pending" and event.error is None
assert event.due_at <= datetime.now(UTC)
@pytest.mark.parametrize("revision", [None, 2])
def test_news_detail_does_not_attach_old_keywords_to_updated_original(
trading, revision
):
import asyncio
from unittest.mock import AsyncMock
from alphapulse.news_research.api import news_package
news = N(revision_no=2, content_hash="new")
package = N(content_hash="old", keywords=[{"canonical": "Old fact"}])
session = N(
get=AsyncMock(return_value=news), scalar=AsyncMock(return_value=package)
)
result = asyncio.run(news_package(1, session, revision_no=revision))
assert result.data == []
def test_two_stage_contract_rejects_package_summaries(trading):
from alphapulse.news_research.contracts import (
IndexResult,
Assignment,
ResearchResult,
)
assert set(IndexResult.model_fields) == {"keywords"}
assert set(Assignment.model_fields) == {"targets"}
assert "references" in ResearchResult.model_fields
assert "package_correction" not in ResearchResult.model_fields
def test_timeline_distinguishes_member_progress_from_external_background(trading):
from alphapulse.news_research.service import validate_timeline
point = N(
package_ids=[17], evidence_refs=[{"article_id": -3, "quote": "External source"}]
)
result = N(timeline_points=[point])
with pytest.raises(ValueError, match="对应新闻包"):
validate_timeline(result, {17: 463205})
point.package_ids = []
validate_timeline(result, {17: 463205})
point.evidence_refs = [{"article_id": 463205, "quote": "Member source"}]
with pytest.raises(ValueError, match="标注"):
validate_timeline(result, {17: 463205})
point.package_ids = [17]
validate_timeline(result, {17: 463205})
def test_retry_reuses_original_snapshots_and_keeps_the_evidence_auditable(trading):
import asyncio
from alphapulse.news_research.service import Research
research = Research(event={"id": 8})
snapshot = {"article_id": -1, "news_id": 7, "content": "Original evidence"}
research.restore(
[{"tool": "read_news", "arguments": {"id": 7}, "result": snapshot}]
)
result = asyncio.run(research.tool("read_news", {"id": 7}))
assert result == snapshot
assert research.counts["read"] == 0
assert research.records[0]["reused"] is True
assert research.records[0]["result"]["article_id"] == -1
@pytest.mark.parametrize("tool", ["search_packages", "search_events"])
@pytest.mark.parametrize("cached", [[], [{"id": 1}]])
def test_retry_refreshes_local_search_with_current_vocabulary(
trading, monkeypatch, tool, cached
):
import asyncio
from unittest.mock import AsyncMock
from alphapulse.news_research import service
session = AsyncMock()
session.__aenter__.return_value = session
session.execute.return_value = [
(N(id=2, news_id=12, content_hash="current", keywords=[]), "current")
]
session.scalars.return_value = [N(id=2, title="Fresh event", tags=["binance"])]
monkeypatch.setattr(service, "SessionLocal", lambda: session)
reader = service.Research(event={"id": 8})
reader.vocabulary = {
"revision": 2,
"entries": [{"canonical": "币安", "aliases": ["Binance", "BNC"]}],
}
arguments = {"query": "BNC", "keywords": []}
reader.restore([{"tool": tool, "arguments": arguments, "result": cached}])
result = asyncio.run(reader.tool(tool, arguments))
assert [row["id"] for row in result] == [2]
assert reader.counts["search"] == 1
assert len(reader.records) == 1 and not reader.records[0].get("reused")
@pytest.mark.parametrize("status", ["unavailable", "no_matches", "matched"])
def test_retry_refreshes_unavailable_search_but_reuses_successful_results(
trading, monkeypatch, status
):
import asyncio
from unittest.mock import AsyncMock
from alphapulse.news_research.service import Research
from alphapulse.news_risk import research as tools
session = AsyncMock()
session.__aenter__.return_value = session
monkeypatch.setattr(tools, "SessionLocal", lambda: session)
client = AsyncMock()
client.__aenter__.return_value = client
client.get.return_value = N(
raise_for_status=lambda: None,
json=lambda: {"results": [], "unresponsive_engines": []},
)
monkeypatch.setattr(tools.httpx, "AsyncClient", lambda **kwargs: client)
cached = {
"status": status,
"results": [{"url": "https://example.invalid/retry"}]
if status == "matched"
else [],
"errors": [["engine", "CAPTCHA"]] if status == "unavailable" else [],
}
reader = Research(event={"id": 8})
reader.restore(
[{"tool": "search_web", "arguments": {"query": "same query"}, "result": cached}]
)
result = asyncio.run(reader.tool("search_web", {"query": "same query"}))
if status == "unavailable":
assert client.get.await_count == 1 and result["status"] == "no_matches"
assert len(reader.records) == 1 and not reader.records[0].get("reused")
else:
assert client.get.await_count == 0 and result == cached
assert reader.records[0]["reused"] is True
def test_settings_contract_rejects_third_stage_and_unknown_efforts(trading):
from alphapulse.news_research.api import SettingsInput
model = {
"provider_id": 1,
"model": "selected",
"skill_id": 1,
"reasoning_effort": None,
}
assert SettingsInput(index=model, research=model).concurrency == 3
with pytest.raises(ValueError, match="extra_forbidden"):
SettingsInput(index=model, research=model, outlook=model)
with pytest.raises(ValueError):
SettingsInput(
index={**model, "reasoning_effort": "unsupported"}, research=model
)
@pytest.mark.parametrize("tool", ["read_news", "read_package"])
def test_compressed_members_read_frozen_positive_sources_and_reject_old_cache(
trading, tool
):
import asyncio
from alphapulse.news_research.service import Research, validate_timeline
research = Research(event={"id": 8})
source = {"id": 101, "news_id": 101, "content": "Frozen original"}
research.register_members(
{"articles": [source], "packages": [{"id": 10, "news_id": 101}]}
)
research.provided_packages = {10: {"id": 10, "news_id": 101}}
research.restore(
[
{
"tool": "read_news",
"arguments": {"id": 101},
"result": {"article_id": -1, "news_id": 101, "content": "Old cache"},
}
]
)
assert not research.provided_news and -1 not in research.sources
result = asyncio.run(
research.tool(tool, {"id": 101 if tool == "read_news" else 10})
)
assert result["article_id"] == 101 and result["content"] == "Frozen original"
validate_timeline(
N(
timeline_points=[
N(
package_ids=[10],
evidence_refs=[
{"article_id": result["article_id"], "quote": "Frozen original"}
],
)
]
),
{10: 101},
)
repeated = asyncio.run(research.tool("read_news", {"id": 101}))
assert repeated["status"] == "already_provided" and research.counts["read"] == 1
def test_assignment_gate_serializes_only_assignment_and_releases_after_failure(
trading, monkeypatch
):
import asyncio
from alphapulse.news_research import service
class Session:
async def __aenter__(self):
return self
async def __aexit__(self, *args):
pass
async def get(self, model, identifier):
return N(state="index_pending" if identifier == 3 else "assign_pending")
async def scenario():
monkeypatch.setattr(service, "assignment_lock", asyncio.Lock())
entered, release = asyncio.Event(), asyncio.Event()
calls = []
async def process(identifier, config, stage):
calls.append((identifier, stage))
if identifier == 1:
entered.set()
await release.wait()
raise ValueError("Failed model")
monkeypatch.setattr(service, "SessionLocal", Session)
monkeypatch.setattr(service, "_process_package", process)
first = asyncio.create_task(service.process_package(1, {}))
await entered.wait()
second = asyncio.create_task(service.process_package(2, {}))
await service.process_package(3, {})
assert calls == [(1, "assign"), (3, "index")]
release.set()
await asyncio.gather(first, second, return_exceptions=True)
assert calls[-1] == (2, "assign") and not service.assignment_lock.locked()
asyncio.run(scenario())