feat(prisma): nightly and triggered search due check
This commit is contained in:
@@ -437,6 +437,25 @@ def search_protocols() -> None:
|
||||
typer.echo(f"{name}\t{query}")
|
||||
|
||||
|
||||
@search_app.command("due")
|
||||
def search_due(
|
||||
project: str = typer.Option(..., "--project", help="Zotero project name."),
|
||||
db: str = typer.Option(..., "--db", help="Path to the DuckDB ledger."),
|
||||
now: str = typer.Option(..., "--now", help="Today as YYYY-MM-DD."),
|
||||
) -> None:
|
||||
"""Print why a search is due. Prints nothing when it is current."""
|
||||
from lead.ledger.store import open_ledger
|
||||
from prisma.search.due import due
|
||||
|
||||
con = open_ledger(db)
|
||||
try:
|
||||
reasons = due(con, project=project, now=now)
|
||||
finally:
|
||||
con.close()
|
||||
for reason in reasons:
|
||||
typer.echo(reason)
|
||||
|
||||
|
||||
@app.command("calibrate")
|
||||
def calibrate_cmd() -> None:
|
||||
"""Calibration gate: sensitivity ≥ 0.95 on 200 title/abstract and 50 full text."""
|
||||
|
||||
93
src/prisma/search/due.py
Normal file
93
src/prisma/search/due.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""Whether a project's searches are due. This does not run a search.
|
||||
|
||||
Dates are ``YYYY-MM-DD`` strings, compared as text. Nightly means the
|
||||
latest ``searched_on`` for the project is missing or earlier than today.
|
||||
The other reasons are a contradiction of moderate or high certainty, a
|
||||
strand-5 event on a target this project has a claim about, and a CMS
|
||||
source dated after that latest search.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import duckdb
|
||||
|
||||
|
||||
def _latest(con: duckdb.DuckDBPyConnection, project: str) -> str | None:
|
||||
row = con.execute(
|
||||
"""
|
||||
SELECT max(searched_on)
|
||||
FROM lead_evidence.search
|
||||
WHERE project = ?
|
||||
""",
|
||||
[project],
|
||||
).fetchone()
|
||||
if row is None or row[0] is None:
|
||||
return None
|
||||
return str(row[0])
|
||||
|
||||
|
||||
def _contradiction(con: duckdb.DuckDBPyConnection, project: str) -> bool:
|
||||
row = con.execute(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM lead_evidence.finding AS f
|
||||
JOIN lead_evidence.claim AS c ON c.id = f.claim_id
|
||||
WHERE c.project = ?
|
||||
AND f.relation = 'contradicts'
|
||||
AND c.certainty IN ('moderate', 'high')
|
||||
LIMIT 1
|
||||
""",
|
||||
[project],
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
def _exogenous(con: duckdb.DuckDBPyConnection, project: str) -> bool:
|
||||
row = con.execute(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM lead_evidence.exogenous AS e
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM lead_evidence.claim AS c
|
||||
WHERE c.project = ?
|
||||
AND c.target_id = e.target_id
|
||||
)
|
||||
LIMIT 1
|
||||
""",
|
||||
[project],
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
def _cms(con: duckdb.DuckDBPyConnection, project: str, latest: str) -> bool:
|
||||
row = con.execute(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM lead_evidence.source
|
||||
WHERE project = ?
|
||||
AND database = 'cms'
|
||||
AND created_at > ?
|
||||
LIMIT 1
|
||||
""",
|
||||
[project, latest],
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
def due(con: duckdb.DuckDBPyConnection, *, project: str, now: str) -> tuple[str, ...]:
|
||||
"""Reasons a search should run, in a fixed order."""
|
||||
reasons: list[str] = []
|
||||
latest = _latest(con, project)
|
||||
if latest is None or latest < now:
|
||||
reasons.append("nightly")
|
||||
if _contradiction(con, project):
|
||||
reasons.append("contradiction")
|
||||
if _exogenous(con, project):
|
||||
reasons.append("exogenous")
|
||||
if latest is not None and _cms(con, project, latest):
|
||||
reasons.append("cms")
|
||||
return tuple(reasons)
|
||||
226
tests/prisma/test_search_due.py
Normal file
226
tests/prisma/test_search_due.py
Normal file
@@ -0,0 +1,226 @@
|
||||
"""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) -> None:
|
||||
assert append(
|
||||
con,
|
||||
"finding",
|
||||
{
|
||||
"claim_id": claim_id,
|
||||
"relation": "contradicts",
|
||||
"grain": "beneficiary",
|
||||
"actor": "tester",
|
||||
"content_hash": f"finding-{suffix}",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
assert due(con, project=PROJECT, now=NOW) == expected
|
||||
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": NOW,
|
||||
"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_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_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")
|
||||
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() == ""
|
||||
Reference in New Issue
Block a user