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.
357 lines
13 KiB
Python
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())
|