From 44b8ebb56da0fbe3f04ff886a520f233602217a0 Mon Sep 17 00:00:00 2001 From: mac Date: Thu, 11 Jun 2026 23:09:23 +0800 Subject: [PATCH] Replace watchlist match reasons with MatchReason enum --- src/paperscout/models.py | 13 ++++++++++--- src/paperscout/scout.py | 4 ++-- src/paperscout/storage.py | 14 +++++++------- tests/test_monitor.py | 12 ++++++------ tests/test_scout.py | 10 +++++----- tests/test_storage.py | 6 ++++-- 6 files changed, 34 insertions(+), 25 deletions(-) diff --git a/src/paperscout/models.py b/src/paperscout/models.py index d137308..e63413d 100644 --- a/src/paperscout/models.py +++ b/src/paperscout/models.py @@ -159,6 +159,13 @@ class Tier(str, Enum): COLD = "cold" +class MatchReason(str, Enum): + """Why a watchlist entry matched a paper or probe hit.""" + + AUTHOR = "author" + PAPER = "paper" + + @dataclass(slots=True) class ProbeHit: """Successful HEAD to an unpublished draft URL plus optional excerpt text.""" @@ -211,7 +218,7 @@ def __post_init__(self) -> None: @dataclass class PerUserMatches: - """One user's watchlist hits: ``(paper|hit, 'author'|'paper')`` tuples.""" + """One user's watchlist hits: ``(paper|hit, MatchReason)`` tuples.""" - papers: list[tuple[Paper, str]] = field(default_factory=list) - probe_hits: list[tuple[ProbeHit, str]] = field(default_factory=list) + papers: list[tuple[Paper, MatchReason]] = field(default_factory=list) + probe_hits: list[tuple[ProbeHit, MatchReason]] = field(default_factory=list) diff --git a/src/paperscout/scout.py b/src/paperscout/scout.py index 54bb8da..783c333 100644 --- a/src/paperscout/scout.py +++ b/src/paperscout/scout.py @@ -521,7 +521,7 @@ def notify_users(app: App, result: PollResult, mq: MessageQueue) -> None: lines.append("*:rotating_light: Papers matching your watchlist:*") for paper, reason in matches.papers: p_link = _paper_link(paper) - tag = f"[{reason} match]" + tag = f"[{reason.value} match]" lines.append(f"• {p_link} — {paper.title} (by *{paper.author}*) {tag}") if matches.probe_hits: @@ -529,7 +529,7 @@ def notify_users(app: App, result: PollResult, mq: MessageQueue) -> None: for hit, reason in matches.probe_hits: h_link = _hit_label(hit.url, hit.prefix, hit.number, hit.revision, hit.extension) lm = _fmt_lm(hit.last_modified) - tag = f"[{reason} match]" + tag = f"[{reason.value} match]" lines.append(f"• {h_link} — {lm} {tag}") if not lines: diff --git a/src/paperscout/storage.py b/src/paperscout/storage.py index 1a02615..866078d 100644 --- a/src/paperscout/storage.py +++ b/src/paperscout/storage.py @@ -10,7 +10,7 @@ from contextlib import contextmanager from typing import TYPE_CHECKING, Any, cast -from .models import PerUserMatches +from .models import MatchReason, PerUserMatches if TYPE_CHECKING: from psycopg2.pool import ThreadedConnectionPool @@ -371,29 +371,29 @@ def matches_for_users( authors = user_authors.get(uid, []) paper_nums = user_papers.get(uid, set()) - matched_papers: list[tuple[Paper, str]] = [] + matched_papers: list[tuple[Paper, MatchReason]] = [] for paper in new_papers: # Author match if authors and paper.author: author_lower = paper.author.lower() if any(a in author_lower for a in authors): - matched_papers.append((paper, "author")) + matched_papers.append((paper, MatchReason.AUTHOR)) continue # Paper-number match if paper_nums and paper.number is not None and paper.number in paper_nums: - matched_papers.append((paper, "paper")) + matched_papers.append((paper, MatchReason.PAPER)) - matched_hits: list[tuple[ProbeHit, str]] = [] + matched_hits: list[tuple[ProbeHit, MatchReason]] = [] for hit in probe_hits: # Author match via front_text if authors and hit.front_text: text_lower = hit.front_text.lower() if any(a in text_lower for a in authors): - matched_hits.append((hit, "author")) + matched_hits.append((hit, MatchReason.AUTHOR)) continue # Paper-number match via probe hit number if paper_nums and hit.number in paper_nums: - matched_hits.append((hit, "paper")) + matched_hits.append((hit, MatchReason.PAPER)) if matched_papers or matched_hits: result[uid] = PerUserMatches( diff --git a/tests/test_monitor.py b/tests/test_monitor.py index 94b4991..7434dc9 100644 --- a/tests/test_monitor.py +++ b/tests/test_monitor.py @@ -12,7 +12,7 @@ import pytest from paperscout.errors import ConfigurationError -from paperscout.models import CycleResult, CycleStatus, Paper, PerUserMatches, ProbeHit +from paperscout.models import CycleResult, CycleStatus, MatchReason, Paper, PerUserMatches, ProbeHit from paperscout.monitor import ( DiffResult, PollResult, @@ -189,7 +189,7 @@ def test_explicit_dp_transitions(self): def test_explicit_per_user_matches(self): diff = DiffResult(new_papers=[], updated_papers=[]) paper = Paper(id="P2300R11") - pum = PerUserMatches(papers=[(paper, "author")], probe_hits=[]) + pum = PerUserMatches(papers=[(paper, MatchReason.AUTHOR)], probe_hits=[]) result = PollResult(diff=diff, probe_hits=[], per_user_matches={"U1": pum}) assert "U1" in result.per_user_matches @@ -358,7 +358,7 @@ async def test_poll_once_populates_per_user_matches(self, fake_pool): prober.run_cycle = AsyncMock(return_value=_empty_cycle()) user_watchlist.matches_for_users.return_value = { - "U123": PerUserMatches(papers=[(new_paper, "author")], probe_hits=[]) + "U123": PerUserMatches(papers=[(new_paper, MatchReason.AUTHOR)], probe_hits=[]) } result = await scheduler.poll_once() assert "U123" in result.per_user_matches @@ -373,7 +373,7 @@ async def test_poll_once_per_user_probe_hit(self, fake_pool): index.papers = {} user_watchlist.matches_for_users.return_value = { - "U123": PerUserMatches(papers=[], probe_hits=[(hit, "author")]) + "U123": PerUserMatches(papers=[], probe_hits=[(hit, MatchReason.AUTHOR)]) } result = await scheduler.poll_once() assert "U123" in result.per_user_matches @@ -403,7 +403,7 @@ async def test_restart_with_prior_poll_notifies_seed_hits(self, fake_pool): hit = _recent_hit() prober.run_cycle = AsyncMock(return_value=_success_cycle([hit])) user_watchlist.matches_for_users.return_value = { - "U123": PerUserMatches(papers=[], probe_hits=[(hit, "author")]) + "U123": PerUserMatches(papers=[], probe_hits=[(hit, MatchReason.AUTHOR)]) } result = await scheduler.poll_once() assert len(notified) == 1 @@ -418,7 +418,7 @@ async def test_restart_with_discovered_urls_notifies(self, fake_pool): hit = _recent_hit() prober.run_cycle = AsyncMock(return_value=_success_cycle([hit])) user_watchlist.matches_for_users.return_value = { - "U123": PerUserMatches(papers=[], probe_hits=[(hit, "author")]) + "U123": PerUserMatches(papers=[], probe_hits=[(hit, MatchReason.AUTHOR)]) } result = await scheduler.poll_once() assert len(notified) == 1 diff --git a/tests/test_scout.py b/tests/test_scout.py index 7693ccd..c443ef6 100644 --- a/tests/test_scout.py +++ b/tests/test_scout.py @@ -5,7 +5,7 @@ from datetime import datetime, timedelta, timezone from unittest.mock import MagicMock, patch -from paperscout.models import Paper, PerUserMatches, ProbeHit +from paperscout.models import MatchReason, Paper, PerUserMatches, ProbeHit from paperscout.monitor import DiffResult, DPTransition, PollResult from paperscout.scout import ( _batch_lines, @@ -278,7 +278,7 @@ def test_author_match_sends_dm(self): paper = Paper( id="P2300R11", title="Senders", author="Eric Niebler", url="https://wg21.link/P2300R11" ) - pum = PerUserMatches(papers=[(paper, "author")], probe_hits=[]) + pum = PerUserMatches(papers=[(paper, MatchReason.AUTHOR)], probe_hits=[]) result = _make_result(per_user_matches={"U123": pum}) notify_users(app, result, mq) mq.enqueue.assert_called_once() @@ -291,7 +291,7 @@ def test_paper_match_sends_dm(self): app = MagicMock() mq = MagicMock() paper = Paper(id="P2300R11", title="X", author="Someone", url="https://wg21.link/P2300R11") - pum = PerUserMatches(papers=[(paper, "paper")], probe_hits=[]) + pum = PerUserMatches(papers=[(paper, MatchReason.PAPER)], probe_hits=[]) result = _make_result(per_user_matches={"U456": pum}) notify_users(app, result, mq) channel, text = mq.enqueue.call_args[0] @@ -302,7 +302,7 @@ def test_probe_hit_match_sends_dm(self): app = MagicMock() mq = MagicMock() hit = _recent_hit() - pum = PerUserMatches(papers=[], probe_hits=[(hit, "author")]) + pum = PerUserMatches(papers=[], probe_hits=[(hit, MatchReason.AUTHOR)]) result = _make_result(per_user_matches={"U789": pum}) notify_users(app, result, mq) mq.enqueue.assert_called_once() @@ -313,7 +313,7 @@ def test_multiple_users_get_separate_dms(self): app = MagicMock() mq = MagicMock() paper = Paper(id="P2300R11", title="X", author="Niebler") - pum = PerUserMatches(papers=[(paper, "author")], probe_hits=[]) + pum = PerUserMatches(papers=[(paper, MatchReason.AUTHOR)], probe_hits=[]) result = _make_result(per_user_matches={"U1": pum, "U2": pum}) notify_users(app, result, mq) assert mq.enqueue.call_count == 2 diff --git a/tests/test_storage.py b/tests/test_storage.py index 31466b4..0dc2459 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -8,7 +8,7 @@ import pytest -from paperscout.models import Paper +from paperscout.models import MatchReason, Paper from paperscout.storage import ( PaperCache, ProbeState, @@ -309,6 +309,8 @@ def test_matches_for_users_author_match(self, fake_pool): assert "U1" in result matched_papers = [p for p, _ in result["U1"].papers] assert paper in matched_papers + _, reason = result["U1"].papers[0] + assert reason is MatchReason.AUTHOR def test_matches_for_users_paper_match(self, fake_pool): wl = UserWatchlist(fake_pool) @@ -406,7 +408,7 @@ def test_matches_skips_bad_paper_row_author_match_still_works(self, fake_pool): result = wl.matches_for_users([paper], []) assert "U1" in result reasons = [r for _, r in result["U1"].papers] - assert "author" in reasons + assert MatchReason.AUTHOR in reasons def test_matches_paper_with_none_number_never_paper_matched(self, fake_pool): wl = UserWatchlist(fake_pool)