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.
694 lines
30 KiB
Python
694 lines
30 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",
|
|
"keyword_canonicals": ["Original"],
|
|
"change_kind": "new_fact",
|
|
"urgent": False,
|
|
}
|
|
result = Assignment(targets=[{**base, "event_id": 1}, {**base, "event_id": 2}])
|
|
keywords = [{"canonical": "Original", "aliases": ["Original"]}]
|
|
validate_assignment(result, {"id": 8, "content": "Original"}, {1, 2}, keywords)
|
|
with pytest.raises(ValueError, match="未知事件"):
|
|
validate_assignment(result, {"id": 8, "content": "Original"}, {1}, keywords)
|
|
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())
|
|
|
|
|
|
def test_digest_tags_are_selected_per_membership(trading):
|
|
from alphapulse.news_research.service import selected_tags
|
|
|
|
keywords = [
|
|
{"canonical": "中国人民银行", "aliases": ["央行"]},
|
|
{"canonical": "美国民主党", "aliases": ["民主党"]},
|
|
{"canonical": "股票回购增持再贷款", "aliases": ["股票回购增持再贷款"]},
|
|
{"canonical": "日经225指数", "aliases": ["日经225指数"]},
|
|
]
|
|
assert selected_tags(keywords, ["中国人民银行", "股票回购增持再贷款"]) == {
|
|
"中国人民银行", "央行", "股票回购增持再贷款",
|
|
}
|
|
assert selected_tags(keywords, ["美国民主党"]) == {"美国民主党", "民主党"}
|
|
assert len(keywords) == 4
|
|
|
|
|
|
@pytest.mark.parametrize("selection", [[], ["Invented"], ["AMD", "AMD"], ["amd"]])
|
|
def test_assignment_rejects_empty_unknown_duplicate_or_rewritten_tags(trading, selection):
|
|
from alphapulse.news_research.contracts import Assignment
|
|
from alphapulse.news_research.service import validate_assignment
|
|
|
|
with pytest.raises(ValueError):
|
|
result = Assignment(targets=[{
|
|
"event_id": 1, "new_key": None, "title": "AMD", "reason": "Same event",
|
|
"quote": "AMD shipped", "keyword_canonicals": selection,
|
|
"change_kind": "new_fact", "urgent": False,
|
|
}])
|
|
validate_assignment(result, {"id": 1, "content": "AMD shipped"}, {1},
|
|
[{"canonical": "AMD", "aliases": ["AMD"]}])
|
|
|
|
|
|
def test_assignment_keeps_entity_aliases_outside_scope_quote(trading):
|
|
from alphapulse.news_research.contracts import Assignment
|
|
from alphapulse.news_research.service import selected_tags, validate_assignment
|
|
|
|
source = {"id": 1, "content": "高盛警示美股风险。研报作者为鲍比·莫拉维(Bobby Molavi)。"}
|
|
keywords = [
|
|
{"canonical": "高盛", "aliases": ["高盛"]},
|
|
{"canonical": "鲍比·莫拉维", "aliases": ["鲍比·莫拉维", "Bobby Molavi"]},
|
|
{"canonical": "美国民主党", "aliases": ["民主党"]},
|
|
]
|
|
selected = ["高盛", "鲍比·莫拉维"]
|
|
result = Assignment(targets=[{
|
|
"event_id": 1, "new_key": None, "title": "高盛警示美股风险", "reason": "Same report",
|
|
"quote": "高盛警示美股风险。", "keyword_canonicals": selected,
|
|
"change_kind": "new_fact", "urgent": False,
|
|
}])
|
|
validate_assignment(result, source, {1}, keywords)
|
|
assert selected_tags(keywords, selected) == {"高盛", "鲍比·莫拉维", "bobby molavi"}
|
|
|
|
|
|
def test_event_tags_are_rebuilt_instead_of_retaining_removed_noise(trading):
|
|
import asyncio
|
|
from unittest.mock import AsyncMock
|
|
from alphapulse.news_research.service import refresh_event_tags
|
|
|
|
event = N(id=1, tags=["民主党", "旧事件"])
|
|
session = N(execute=AsyncMock(return_value=[(
|
|
N(package_id=1, evidence="央行下调利率"),
|
|
N(rules={"membership_tags": {"1": ["中国人民银行", "央行"]}}),
|
|
)]))
|
|
asyncio.run(refresh_event_tags(session, event))
|
|
assert event.tags == ["中国人民银行", "央行"]
|
|
session.execute.return_value = []
|
|
asyncio.run(refresh_event_tags(session, event))
|
|
assert event.tags == []
|
|
|
|
|
|
def test_event_rebuild_uses_successful_snapshot_not_mutable_package_index(trading):
|
|
import asyncio
|
|
from unittest.mock import AsyncMock
|
|
from sqlalchemy.dialects import postgresql
|
|
from alphapulse.news_research.service import refresh_event_tags
|
|
|
|
session = N(execute=AsyncMock(return_value=[(
|
|
N(package_id=1, evidence="公司遭到黑客攻击。"),
|
|
N(rules={"membership_tags": {"1": ["旧公司", "黑客攻击"]}}),
|
|
)]))
|
|
event = N(id=1, tags=["英伟达"])
|
|
asyncio.run(refresh_event_tags(session, event))
|
|
assert event.tags == ["旧公司", "黑客攻击"]
|
|
statement = str(session.execute.call_args.args[0].compile(
|
|
dialect=postgresql.dialect(), compile_kwargs={"literal_binds": True}))
|
|
assert "news_research_packages" not in statement
|
|
assert "news_research_attempts.state = 'done'" in statement
|
|
assert "max(news_research_attempts.id)" in statement
|
|
|
|
|
|
def test_legacy_assignment_retains_its_original_keyword_snapshot(trading):
|
|
import asyncio
|
|
from unittest.mock import AsyncMock
|
|
from alphapulse.news_research.service import refresh_event_tags
|
|
|
|
event = N(id=1, tags=[])
|
|
session = N(execute=AsyncMock(return_value=[(
|
|
N(package_id=1, evidence="公司遭到黑客攻击。"),
|
|
N(rules={}, input={"keywords": [
|
|
{"canonical": "旧公司", "aliases": ["OldCorp"]},
|
|
]}),
|
|
)]))
|
|
asyncio.run(refresh_event_tags(session, event))
|
|
assert event.tags == ["oldcorp", "旧公司"]
|
|
|
|
|
|
@pytest.mark.parametrize("snapshot", [None, N(rules={}, input={}),
|
|
N(rules={"membership_tags": {"2": ["wrong"]}})])
|
|
def test_event_rebuild_fails_without_owning_assignment_snapshot(trading, snapshot):
|
|
import asyncio
|
|
from unittest.mock import AsyncMock
|
|
from alphapulse.news_research.service import refresh_event_tags
|
|
|
|
event = N(id=1, tags=["preserved"])
|
|
session = N(execute=AsyncMock(return_value=[(N(package_id=1), snapshot)]))
|
|
with pytest.raises(ValueError, match="快照"):
|
|
asyncio.run(refresh_event_tags(session, event))
|
|
assert event.tags == ["preserved"]
|
|
|
|
|
|
@pytest.mark.parametrize("offset", ["+03:30", "+08:00", "-05:00"])
|
|
@pytest.mark.parametrize("precision,day", [("day", 4), ("month", 1)])
|
|
def test_coarse_timeline_dates_are_consistent_calendar_markers(trading, offset, precision, day):
|
|
from alphapulse.news_research.contracts import Point
|
|
|
|
p = Point(occurred_at=f"2026-11-04T13:25:00{offset}", time_precision=precision,
|
|
planned_at=f"2026-12-04T13:25:00{offset}", planned_precision=precision,
|
|
time_text="4日", reported_at=None, description="公告", verification="supported",
|
|
evidence_refs=[{"article_id": 1, "quote": "4日公告"}], package_ids=[1])
|
|
assert p.occurred_at == datetime(2026, 11, day, tzinfo=UTC)
|
|
assert p.planned_at == datetime(2026, 12, day, tzinfo=UTC)
|
|
assert p.time_text == "4日"
|
|
|
|
|
|
def test_minute_precision_retains_real_instant_and_unknown_stays_null(trading):
|
|
from alphapulse.news_research.contracts import Point
|
|
|
|
p = Point(occurred_at="2026-11-04T13:25:00+03:30", time_precision="minute",
|
|
time_text="13:25", reported_at=None, description="公告", verification="supported",
|
|
evidence_refs=[{"article_id": 1, "quote": "13:25公告"}], package_ids=[1])
|
|
assert p.occurred_at.isoformat() == "2026-11-04T13:25:00+03:30"
|
|
p = Point(**{**p.model_dump(), "occurred_at": None, "time_precision": "unknown"})
|
|
assert p.occurred_at is None
|
|
|
|
|
|
def test_research_scope_uses_resolved_assignment_and_keeps_full_selected_records(trading):
|
|
from copy import deepcopy
|
|
from alphapulse.news_research.service import research_scope
|
|
|
|
keywords = [
|
|
{"canonical": "高盛", "aliases": ["高盛"], "quote": "高盛警示风险"},
|
|
{"canonical": "鲍比·莫拉维", "aliases": ["Bobby Molavi"], "quote": "作者Bobby Molavi"},
|
|
{"canonical": "美国民主党", "aliases": ["民主党"], "quote": "民主党选情更新"},
|
|
]
|
|
main = {"title": "风险报告", "quote": "高盛警示风险", "reason": "同一报告",
|
|
"keyword_canonicals": ["高盛", "鲍比·莫拉维"]}
|
|
other = {"title": "选情更新", "quote": "民主党选情更新", "reason": "独立事项",
|
|
"keyword_canonicals": ["美国民主党"]}
|
|
attempt = N(input={"keywords": keywords},
|
|
rules={"membership_targets": {"1": main, "2": other}})
|
|
before = deepcopy(attempt.input)
|
|
scope = research_scope(N(package_id=10, event_id=1), attempt)
|
|
assert scope["keywords"] == keywords[:2]
|
|
assert scope["keyword_canonicals"] == ["高盛", "鲍比·莫拉维"]
|
|
assert scope["scope_selection"] == "assignment"
|
|
assert scope["other_scopes"] == [{"title": "选情更新", "scope_quote": "民主党选情更新", "reason": "独立事项"}]
|
|
assert research_scope(N(package_id=10, event_id=2), attempt)["keywords"] == keywords[2:]
|
|
assert attempt.input == before
|
|
|
|
|
|
@pytest.mark.parametrize("existing", [False, True])
|
|
def test_research_recovers_unambiguous_selected_tags_from_older_assignment(trading, existing):
|
|
from alphapulse.news_research.service import research_scope
|
|
|
|
keyword = {"canonical": "触雷", "aliases": ["水雷"]}
|
|
target = {"event_id": 1 if existing else None, "quote": "四艘油轮触雷", "reason": "具体事故",
|
|
"keyword_canonicals": ["触雷"]}
|
|
other = {"event_id": None, "title": "船舶拦截", "quote": "另有两艘船被拦截", "reason": "独立行动"}
|
|
attempt = N(input={"keywords": [keyword, {"canonical": "拦截", "aliases": ["拦截"]}]},
|
|
rules={}, output={"targets": [target, other]})
|
|
scope = research_scope(N(package_id=10, event_id=1, evidence=target["quote"], reason=target["reason"]), attempt)
|
|
assert scope["keywords"] == [keyword]
|
|
assert scope["other_scopes"][0]["scope_quote"] == "另有两艘船被拦截"
|
|
|
|
|
|
@pytest.mark.parametrize("targets", [[], [
|
|
{"event_id": None, "quote": "同一引文", "reason": "同一理由", "keyword_canonicals": ["甲"]},
|
|
{"event_id": None, "quote": "同一引文", "reason": "同一理由", "keyword_canonicals": ["乙"]},
|
|
]])
|
|
def test_research_legacy_does_not_invent_a_selected_scope(trading, targets):
|
|
from alphapulse.news_research.service import research_scope
|
|
|
|
keywords = [{"canonical": "甲", "aliases": ["甲"]}, {"canonical": "乙", "aliases": ["乙"]}]
|
|
scope = research_scope(N(package_id=10, event_id=1, evidence="同一引文", reason="同一理由"),
|
|
N(input={"keywords": keywords}, rules={}, output={"targets": targets}))
|
|
assert scope["scope_selection"] == "legacy_unscoped"
|
|
assert scope["keyword_canonicals"] is None and scope["keywords"] == keywords
|
|
assert scope["other_scopes"] == []
|
|
|
|
|
|
def test_legacy_research_retains_known_sibling_boundaries_without_inventing_tags(trading):
|
|
from alphapulse.news_research.service import research_scope
|
|
|
|
keywords = [{"canonical": "甲", "aliases": ["甲"]}]
|
|
own = {"event_id": 1, "title": "事项甲", "quote": "原文甲", "reason": "主线甲"}
|
|
other = {"event_id": 2, "title": "事项乙", "quote": "原文乙", "reason": "主线乙"}
|
|
scope = research_scope(N(package_id=10, event_id=1),
|
|
N(input={"keywords": keywords}, rules={}, output={"targets": [own, other]}))
|
|
assert scope["scope_selection"] == "legacy_unscoped" and scope["keyword_canonicals"] is None
|
|
assert scope["keywords"] == keywords
|
|
assert scope["other_scopes"] == [{"title": "事项乙", "scope_quote": "原文乙", "reason": "主线乙"}]
|
|
|
|
|
|
@pytest.mark.parametrize("attempt", [None, N(input={}, rules={}), N(
|
|
input={"keywords": [{"canonical": "甲", "aliases": ["甲"]}]},
|
|
rules={"membership_targets": {"2": {}}},
|
|
)])
|
|
def test_research_fails_without_owning_assignment_snapshot(trading, attempt):
|
|
from alphapulse.news_research.service import research_scope
|
|
|
|
with pytest.raises(ValueError, match="快照"):
|
|
research_scope(N(package_id=10, event_id=1), attempt)
|
|
|
|
|
|
@pytest.mark.parametrize("tool,identifier", [("read_news", 101), ("read_package", 10)])
|
|
def test_provided_member_reads_are_audited_without_reading_or_spending_budget(trading, tool, identifier):
|
|
import asyncio
|
|
from alphapulse.news_research.service import Research
|
|
|
|
reader = Research(event={"id": 1})
|
|
source = {"id": 101, "news_id": 101, "content": "完整原文"}
|
|
payload = {"articles": [source], "packages": [{"id": 10, "news_id": 101}]}
|
|
reader.register_members(payload)
|
|
reader.provided_news = {101: 101}
|
|
result = asyncio.run(reader.tool(tool, {"id": identifier}))
|
|
assert result["status"] == "already_provided" and result["article_id"] == 101
|
|
assert "复用" in result["instruction"] and "独立补证查询合并同轮" in result["instruction"]
|
|
assert "content" not in result and reader.counts["read"] == 0
|
|
assert reader.records == [{"tool": tool, "arguments": {"id": identifier}, "result": result}]
|
|
retry = Research(event={"id": 1})
|
|
retry.register_members(payload)
|
|
retry.restore(reader.records)
|
|
assert not retry.provided_news and not retry.records
|
|
reread = asyncio.run(retry.tool(tool, {"id": identifier}))
|
|
assert reread["content"] == "完整原文" and retry.counts["read"] == 1
|
|
|
|
|
|
@pytest.mark.parametrize("needs_compaction", [False, True])
|
|
def test_long_event_compaction_uses_final_research_context(trading, monkeypatch, needs_compaction):
|
|
import asyncio
|
|
from unittest.mock import AsyncMock
|
|
from alphapulse.news_research import service
|
|
from alphapulse.news_research.contracts import RESEARCH_SKILL, ResearchResult
|
|
from alphapulse.news_research.models import Version
|
|
from alphapulse.news_risk import pipeline
|
|
|
|
provider = N(max_tokens=32000, context_window=250000)
|
|
skill = pipeline.BASE + "\n" + RESEARCH_SKILL
|
|
quote = "该公司披露事故调查及资金追踪的最新进展。"
|
|
keywords = [{"canonical": f"因素{j}", "aliases": [f"Factors{j}"], "quote": quote * 4}
|
|
for j in range(8)]
|
|
target = {"title": "事故调查", "quote": quote, "reason": "同一事故的后续处置。",
|
|
"keyword_canonicals": [k["canonical"] for k in keywords]}
|
|
event = N(id=1, revision=1, title="事故调查", state="pending", result=None,
|
|
due_at=datetime.now(UTC) - timedelta(seconds=1))
|
|
members, attempts, versions = [], [], []
|
|
for identifier in range(1, 41):
|
|
news = N(id=identifier, title=quote, summary="", content_text=quote * 2000,
|
|
content_hash=str(identifier), url="https://example.invalid/event",
|
|
published_at=datetime.now(UTC))
|
|
members.append((N(id=identifier, news_id=identifier, state="done", content_hash=news.content_hash),
|
|
N(event_id=1, package_id=identifier, evidence=quote, reason=target["reason"]), news))
|
|
attempts.append(N(package_id=identifier, content_hash=news.content_hash,
|
|
input={"keywords": keywords}, rules={"membership_targets": {"1": target}}))
|
|
extracts = [{"facts": "detail " * 400 if needs_compaction else "调查继续。",
|
|
"evidence_refs": [{"article_id": p.id, "quote": quote}]} for p, _, _ in members]
|
|
|
|
class Session:
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *args):
|
|
pass
|
|
|
|
def add(self, version):
|
|
version.id = 1
|
|
versions.append(version)
|
|
|
|
session = Session()
|
|
session.get = AsyncMock(side_effect=lambda model, *args, **kwargs: versions[0] if model is Version else event)
|
|
session.execute = AsyncMock(side_effect=[members, N(all=lambda: [(n.id, n.content_hash) for _, _, n in members])])
|
|
session.scalars = AsyncMock(return_value=attempts)
|
|
session.scalar = AsyncMock(return_value=None)
|
|
session.commit = AsyncMock()
|
|
session.rollback = AsyncMock()
|
|
monkeypatch.setattr(service, "SessionLocal", lambda: session)
|
|
monkeypatch.setattr(service, "model_config", AsyncMock(return_value=(provider, skill, {})))
|
|
monkeypatch.setattr(service.vocab, "snapshot", AsyncMock(return_value={}))
|
|
extract = AsyncMock(return_value=(extracts, []))
|
|
monkeypatch.setattr(service, "extract_articles", extract)
|
|
# Quote and timeline validation are independent of this context-budget regression.
|
|
monkeypatch.setattr(service, "validate_event", lambda *args: None)
|
|
monkeypatch.setattr(service, "validate_timeline", lambda *args: None)
|
|
|
|
async def consolidate(provider, rules, payload, schema):
|
|
assert schema is pipeline.Extraction
|
|
assert "当前事件及各成员归属边界" in rules and quote in rules
|
|
assert pipeline.fits_context(provider, rules, payload, schema)
|
|
refs = [ref for part in payload["extractions"] for ref in part["evidence_refs"]]
|
|
return pipeline.Extraction(facts="调查继续。", evidence_refs=refs), {"mock": True}
|
|
|
|
complete = AsyncMock(side_effect=consolidate)
|
|
monkeypatch.setattr(pipeline, "complete", complete)
|
|
|
|
research_requests = []
|
|
|
|
async def research(reader, provider, rules, request, schema):
|
|
assert rules == skill and schema is ResearchResult
|
|
assert reader.fits(provider, rules, request, schema)
|
|
assert request["articles"] == [] and len(request["packages"]) == 40
|
|
assert {ref["article_id"] for part in request["extractions"] for ref in part["evidence_refs"]} == set(range(1, 41))
|
|
research_requests.append(request)
|
|
return N(id=1, title=event.title, references=[], model_dump=lambda **kwargs: {"id": 1}), {}
|
|
|
|
monkeypatch.setattr(service.Research, "run", research)
|
|
asyncio.run(service.process_event(1, {}))
|
|
assert versions[0].state == "done", versions[0].error
|
|
assert event.state == "done" and len(research_requests) == 1
|
|
assert bool(complete.await_count) == needs_compaction
|
|
boundary_rules = extract.await_args.args[1]
|
|
compressed = {**versions[0].input, "articles": [], "extractions": extracts}
|
|
reader = service.Research()
|
|
assert not reader.fits(provider, skill, versions[0].input, ResearchResult)
|
|
assert not reader.fits(provider, boundary_rules, compressed, ResearchResult)
|
|
assert reader.fits(provider, skill, compressed, ResearchResult) != needs_compaction
|