Export reads the extraction JSON instead of a tag fragment and writes the claim. A logged search stores its project so the nightly check can clear. Claims keep the effect and the subject, a later strand tags an existing DOI, the dedup command prints borderline pairs, and due dates compare the calendar day.
358 lines
10 KiB
Python
358 lines
10 KiB
Python
"""Search is due nightly, and also when a trigger lands. No search is run."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from typer.testing import CliRunner
|
|
|
|
from cli.prisma import app
|
|
from lead.ledger.store import append, open_ledger
|
|
from prisma.search.due import due
|
|
|
|
runner = CliRunner()
|
|
PROJECT = "lead-precursors"
|
|
NOW = "2026-10-31"
|
|
YESTERDAY = "2026-10-30"
|
|
|
|
|
|
def _ledger(tmp_path):
|
|
return open_ledger(str(tmp_path / "ledger.duckdb"))
|
|
|
|
|
|
def _search(
|
|
con, *, searched_on: str, project: str = PROJECT, suffix: str = "s"
|
|
) -> None:
|
|
assert append(
|
|
con,
|
|
"search",
|
|
{
|
|
"project": project,
|
|
"database": "pubmed",
|
|
"interface": "eutils",
|
|
"searched_on": searched_on,
|
|
"query": "lead",
|
|
"filters": "",
|
|
"hit_count": 1,
|
|
"export_sha256": suffix,
|
|
"actor": "tester",
|
|
"content_hash": f"search-{suffix}",
|
|
},
|
|
)
|
|
|
|
|
|
def _claim(con, *, certainty: str, suffix: str, target_id: str = "Q.ACR") -> None:
|
|
assert append(
|
|
con,
|
|
"claim",
|
|
{
|
|
"id": suffix,
|
|
"project": PROJECT,
|
|
"kind": "precursor",
|
|
"target_id": target_id,
|
|
"certainty": certainty,
|
|
"actor": "tester",
|
|
"content_hash": f"claim-{suffix}",
|
|
},
|
|
)
|
|
|
|
|
|
def _finding(con, *, claim_id: str, suffix: str, created_at: str | None = None) -> None:
|
|
row: dict[str, object] = {
|
|
"claim_id": claim_id,
|
|
"relation": "contradicts",
|
|
"grain": "beneficiary",
|
|
"actor": "tester",
|
|
"content_hash": f"finding-{suffix}",
|
|
}
|
|
if created_at is not None:
|
|
row["created_at"] = created_at
|
|
assert append(con, "finding", row)
|
|
|
|
|
|
def test_missing_or_stale_search_is_nightly(tmp_path):
|
|
con = _ledger(tmp_path)
|
|
try:
|
|
assert due(con, project=PROJECT, now=NOW) == ("nightly",)
|
|
_search(con, searched_on=NOW, suffix="today")
|
|
assert due(con, project=PROJECT, now=NOW) == ()
|
|
_search(con, searched_on=YESTERDAY, suffix="yesterday", project="lead-history")
|
|
assert due(con, project=PROJECT, now=NOW) == ()
|
|
finally:
|
|
con.close()
|
|
|
|
con = open_ledger(str(tmp_path / "stale.duckdb"))
|
|
try:
|
|
_search(con, searched_on=YESTERDAY, suffix="old")
|
|
assert due(con, project=PROJECT, now=NOW) == ("nightly",)
|
|
finally:
|
|
con.close()
|
|
|
|
|
|
def test_another_project_does_not_clear_nightly(tmp_path):
|
|
con = _ledger(tmp_path)
|
|
try:
|
|
_search(con, searched_on=NOW, project="lead-history", suffix="other")
|
|
assert due(con, project=PROJECT, now=NOW) == ("nightly",)
|
|
finally:
|
|
con.close()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("certainty", "expected"),
|
|
[("low", ()), ("moderate", ("contradiction",)), ("high", ("contradiction",))],
|
|
)
|
|
def test_contradiction_respects_certainty(tmp_path, certainty, expected):
|
|
con = _ledger(tmp_path)
|
|
try:
|
|
_search(con, searched_on=NOW)
|
|
_claim(con, certainty=certainty, suffix=certainty)
|
|
_finding(con, claim_id=certainty, suffix=certainty, created_at="2026-11-01")
|
|
assert due(con, project=PROJECT, now=NOW) == expected
|
|
finally:
|
|
con.close()
|
|
|
|
|
|
def test_contradiction_on_the_search_day_does_not_refire(tmp_path):
|
|
con = _ledger(tmp_path)
|
|
try:
|
|
_search(con, searched_on=NOW)
|
|
_claim(con, certainty="moderate", suffix="same-day")
|
|
_finding(
|
|
con,
|
|
claim_id="same-day",
|
|
suffix="same-day",
|
|
created_at="2026-10-31T10:00:00Z",
|
|
)
|
|
assert due(con, project=PROJECT, now=NOW) == ()
|
|
finally:
|
|
con.close()
|
|
|
|
|
|
def test_contradiction_without_a_search_stays_nightly(tmp_path):
|
|
con = _ledger(tmp_path)
|
|
try:
|
|
_claim(con, certainty="high", suffix="nosearch")
|
|
_finding(con, claim_id="nosearch", suffix="nosearch", created_at="2026-11-01")
|
|
assert due(con, project=PROJECT, now=NOW) == ("nightly",)
|
|
finally:
|
|
con.close()
|
|
|
|
|
|
def test_exogenous_event_on_a_claimed_target(tmp_path):
|
|
con = _ledger(tmp_path)
|
|
try:
|
|
_search(con, searched_on=NOW)
|
|
assert append(
|
|
con,
|
|
"exogenous",
|
|
{
|
|
"project": PROJECT,
|
|
"target_id": "Q.ACR",
|
|
"series_or_event": "cms guidance",
|
|
"event_on": "2026-11-01",
|
|
"citation": "cms",
|
|
"actor": "tester",
|
|
"content_hash": "exo-1",
|
|
},
|
|
)
|
|
assert due(con, project=PROJECT, now=NOW) == ()
|
|
_claim(con, certainty="low", suffix="qacr")
|
|
assert due(con, project=PROJECT, now=NOW) == ("exogenous",)
|
|
finally:
|
|
con.close()
|
|
|
|
|
|
def test_exogenous_on_the_search_day_does_not_refire(tmp_path):
|
|
con = _ledger(tmp_path)
|
|
try:
|
|
_search(con, searched_on=NOW)
|
|
_claim(con, certainty="low", suffix="same-day")
|
|
assert append(
|
|
con,
|
|
"exogenous",
|
|
{
|
|
"project": PROJECT,
|
|
"target_id": "Q.ACR",
|
|
"series_or_event": "cms guidance",
|
|
"event_on": NOW,
|
|
"citation": "cms",
|
|
"actor": "tester",
|
|
"content_hash": "exo-same-day",
|
|
},
|
|
)
|
|
assert due(con, project=PROJECT, now=NOW) == ()
|
|
finally:
|
|
con.close()
|
|
|
|
|
|
def test_exogenous_without_a_search_stays_nightly(tmp_path):
|
|
con = _ledger(tmp_path)
|
|
try:
|
|
_claim(con, certainty="low", suffix="nosearch")
|
|
assert append(
|
|
con,
|
|
"exogenous",
|
|
{
|
|
"target_id": "Q.ACR",
|
|
"series_or_event": "cms guidance",
|
|
"event_on": "2026-11-01",
|
|
"actor": "tester",
|
|
"content_hash": "exo-nosearch",
|
|
},
|
|
)
|
|
assert due(con, project=PROJECT, now=NOW) == ("nightly",)
|
|
finally:
|
|
con.close()
|
|
|
|
|
|
def test_cms_source_newer_than_the_search(tmp_path):
|
|
con = _ledger(tmp_path)
|
|
try:
|
|
_search(con, searched_on=NOW)
|
|
assert append(
|
|
con,
|
|
"source",
|
|
{
|
|
"project": PROJECT,
|
|
"database": "cms",
|
|
"zotero_key": "ABCD2345",
|
|
"created_at": NOW,
|
|
"actor": "tester",
|
|
"content_hash": "src-same-day",
|
|
},
|
|
)
|
|
assert due(con, project=PROJECT, now=NOW) == ()
|
|
assert append(
|
|
con,
|
|
"source",
|
|
{
|
|
"project": PROJECT,
|
|
"database": "cms",
|
|
"zotero_key": "EFGH5678",
|
|
"created_at": "2026-11-01",
|
|
"actor": "tester",
|
|
"content_hash": "src-later",
|
|
},
|
|
)
|
|
assert due(con, project=PROJECT, now=NOW) == ("cms",)
|
|
finally:
|
|
con.close()
|
|
|
|
|
|
def test_cms_iso_timestamp_on_the_search_day_does_not_fire(tmp_path):
|
|
con = _ledger(tmp_path)
|
|
try:
|
|
_search(con, searched_on=NOW)
|
|
assert append(
|
|
con,
|
|
"source",
|
|
{
|
|
"project": PROJECT,
|
|
"database": "cms",
|
|
"zotero_key": "ABCD2345",
|
|
"created_at": "2026-10-31T10:00:00Z",
|
|
"actor": "tester",
|
|
"content_hash": "src-iso-same",
|
|
},
|
|
)
|
|
assert due(con, project=PROJECT, now=NOW) == ()
|
|
finally:
|
|
con.close()
|
|
|
|
|
|
def test_joined_cms_on_the_search_day_does_not_fire(tmp_path):
|
|
con = _ledger(tmp_path)
|
|
try:
|
|
_search(con, searched_on=NOW)
|
|
assert append(
|
|
con,
|
|
"source",
|
|
{
|
|
"project": PROJECT,
|
|
"database": "citation,cms",
|
|
"zotero_key": "IJKM2345",
|
|
"created_at": "2026-10-31T10:00:00Z",
|
|
"actor": "tester",
|
|
"content_hash": "src-joined-same",
|
|
},
|
|
)
|
|
assert due(con, project=PROJECT, now=NOW) == ()
|
|
finally:
|
|
con.close()
|
|
|
|
|
|
def test_cms_token_in_a_joined_database_fires_after_the_search_day(tmp_path):
|
|
con = _ledger(tmp_path)
|
|
try:
|
|
_search(con, searched_on=NOW)
|
|
assert append(
|
|
con,
|
|
"source",
|
|
{
|
|
"project": PROJECT,
|
|
"database": "citation,cms",
|
|
"zotero_key": "EFGH5678",
|
|
"created_at": "2026-11-01T00:00:00Z",
|
|
"actor": "tester",
|
|
"content_hash": "src-iso-later",
|
|
},
|
|
)
|
|
assert due(con, project=PROJECT, now=NOW) == ("cms",)
|
|
finally:
|
|
con.close()
|
|
|
|
|
|
def test_reasons_stay_in_order(tmp_path):
|
|
con = _ledger(tmp_path)
|
|
try:
|
|
_search(con, searched_on=YESTERDAY, suffix="old")
|
|
_claim(con, certainty="high", suffix="high")
|
|
_finding(con, claim_id="high", suffix="high", created_at="2026-11-01")
|
|
assert append(
|
|
con,
|
|
"exogenous",
|
|
{
|
|
"target_id": "Q.ACR",
|
|
"series_or_event": "cms guidance",
|
|
"event_on": NOW,
|
|
"actor": "tester",
|
|
"content_hash": "exo-order",
|
|
},
|
|
)
|
|
assert append(
|
|
con,
|
|
"source",
|
|
{
|
|
"project": PROJECT,
|
|
"database": "cms",
|
|
"created_at": "2026-11-01",
|
|
"actor": "tester",
|
|
"content_hash": "src-order",
|
|
},
|
|
)
|
|
assert due(con, project=PROJECT, now=NOW) == (
|
|
"nightly",
|
|
"contradiction",
|
|
"exogenous",
|
|
"cms",
|
|
)
|
|
finally:
|
|
con.close()
|
|
|
|
|
|
def test_due_command_is_registered_and_quiet_when_current(tmp_path):
|
|
result = runner.invoke(app, ["search", "due", "--help"])
|
|
assert result.exit_code == 0
|
|
path = tmp_path / "ledger.duckdb"
|
|
con = open_ledger(str(path))
|
|
try:
|
|
_search(con, searched_on=NOW, suffix="cli")
|
|
finally:
|
|
con.close()
|
|
result = runner.invoke(
|
|
app,
|
|
["search", "due", "--db", str(path), "--project", PROJECT, "--now", NOW],
|
|
)
|
|
assert result.exit_code == 0
|
|
assert result.stdout.strip() == ""
|