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.
This commit is contained in:
1 parent
07252fea0f
commit
4929a32a0a
69 files changed
+8215
-2092
No files matched your search
@@ -0,0 +1,156 @@
|
||||
"""Vocabulary and fixed-version behavior, isolated from production I/O."""
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace as N
|
||||
from unittest.mock import AsyncMock
|
||||
import pytest
|
||||
|
||||
|
||||
def test_equivalence_preserves_literal_aliases_and_quotes(trading):
|
||||
from alphapulse.news_research.vocabulary import normalize, expand, VocabularyInput
|
||||
|
||||
data = VocabularyInput(
|
||||
base_revision=0, entries=[{"canonical": "币安", "aliases": ["Binance"]}]
|
||||
)
|
||||
snapshot = {"revision": 1, "entries": [e.model_dump() for e in data.entries]}
|
||||
original = [
|
||||
{
|
||||
"canonical": "Binance交易所",
|
||||
"aliases": ["Binance"],
|
||||
"quote": "Binance reported",
|
||||
}
|
||||
]
|
||||
assert normalize(original, snapshot) == [{**original[0], "canonical": "币安"}]
|
||||
assert original[0]["canonical"] == "Binance交易所"
|
||||
assert expand([" BINANCE "], snapshot) == ["币安", "binance"]
|
||||
assert expand(["Bank of Canada"], snapshot) == ["bank of canada"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"entries",
|
||||
[
|
||||
[{"canonical": " ", "aliases": []}],
|
||||
[{"canonical": "币安", "aliases": ["Binance", "BINANCE"]}],
|
||||
[
|
||||
{"canonical": "币安", "aliases": ["Binance"]},
|
||||
{"canonical": "别家", "aliases": ["binance"]},
|
||||
],
|
||||
[{"canonical": "币安", "aliases": []}, {"canonical": "币安", "aliases": []}],
|
||||
],
|
||||
)
|
||||
def test_reject_ambiguous_or_empty_vocabulary(trading, entries):
|
||||
from alphapulse.news_research.vocabulary import VocabularyInput
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
VocabularyInput(base_revision=0, entries=entries)
|
||||
|
||||
|
||||
def test_merging_tags_never_invents_literal_alias(trading):
|
||||
from alphapulse.news_research.vocabulary import normalize
|
||||
|
||||
snapshot = {"entries": [{"canonical": "币安", "aliases": ["Binance"]}]}
|
||||
result = normalize(
|
||||
[
|
||||
{"canonical": c, "aliases": [a], "quote": a}
|
||||
for c, a in [("币安", "币安"), ("Binance", "Binance")]
|
||||
],
|
||||
snapshot,
|
||||
)
|
||||
assert len(result) == 1 and result[0]["aliases"] == ["币安", "Binance"]
|
||||
|
||||
|
||||
def test_stale_vocabulary_save_conflicts_without_writing(trading, monkeypatch):
|
||||
from fastapi import HTTPException
|
||||
from alphapulse.news_research import api
|
||||
|
||||
monkeypatch.setattr(
|
||||
api.vocab, "snapshot", AsyncMock(return_value={"revision": 2, "entries": []})
|
||||
)
|
||||
session = N(execute=AsyncMock(), add=AsyncMock(), commit=AsyncMock())
|
||||
with pytest.raises(HTTPException) as error:
|
||||
asyncio.run(
|
||||
api.save_vocabulary(
|
||||
api.vocab.VocabularyInput(base_revision=1, entries=[]), session
|
||||
)
|
||||
)
|
||||
assert error.value.status_code == 409
|
||||
session.add.assert_not_called()
|
||||
session.commit.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"event_id,state", [(2, "done"), (1, "failed"), (1, "superseded")]
|
||||
)
|
||||
def test_fixed_version_rejects_wrong_event_and_unsuccessful_versions(
|
||||
trading, event_id, state
|
||||
):
|
||||
from fastapi import HTTPException
|
||||
from alphapulse.news_research.api import event_version
|
||||
|
||||
session = N(get=AsyncMock(return_value=N(event_id=event_id, state=state)))
|
||||
with pytest.raises(HTTPException) as error:
|
||||
asyncio.run(event_version(1, 9, session))
|
||||
assert error.value.status_code == 404
|
||||
|
||||
|
||||
def test_acceptance_budget_is_shared_and_counts_failed_streams(trading, tmp_path):
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"acceptance_budget",
|
||||
Path(__file__).resolve().parents[2] / "scripts/accept_news_research.py",
|
||||
)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
counter = tmp_path / "requests"
|
||||
first = module.Budget(counter, limit=2)
|
||||
second = module.Budget(counter, limit=2)
|
||||
first.take()
|
||||
second.take()
|
||||
with pytest.raises(module.BudgetExceeded):
|
||||
first.take()
|
||||
assert counter.read_text() == "2"
|
||||
|
||||
|
||||
def test_budget_counts_transport_failures_before_retry(trading, tmp_path):
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"failed_stream_budget",
|
||||
Path(__file__).resolve().parents[2] / "scripts/accept_news_research.py",
|
||||
)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
budget = module.Budget(tmp_path / "counter", limit=2)
|
||||
sent = []
|
||||
|
||||
async def failing():
|
||||
sent.append(True)
|
||||
raise ConnectionError("isolated failure")
|
||||
yield
|
||||
|
||||
async def attempt():
|
||||
async for _ in budget.wrap(failing)():
|
||||
pass
|
||||
|
||||
async def run():
|
||||
return await asyncio.gather(
|
||||
*(attempt() for _ in range(3)), return_exceptions=True
|
||||
)
|
||||
|
||||
errors = asyncio.run(run())
|
||||
assert len(sent) == 2
|
||||
assert sum(isinstance(e, module.BudgetExceeded) for e in errors) == 1
|
||||
|
||||
|
||||
def test_supplemental_citation_must_use_returned_article_id(trading):
|
||||
from alphapulse.news_risk.pipeline import validate_evidence, Evidence
|
||||
|
||||
sources = {-1: {"news_id": 1004, "content": "Original correction"}}
|
||||
validate_evidence([Evidence(article_id=-1, quote="Original correction")], sources)
|
||||
with pytest.raises(ValueError):
|
||||
validate_evidence(
|
||||
[Evidence(article_id=1004, quote="Original correction")], sources
|
||||
)
|
||||
Reference in new issue
Block a user