From 778e3e41aba2e06b802e79bafd5e7b6a8528f055 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 18:21:25 +0000 Subject: [PATCH 1/2] feat(interests): a second overnight tier for non-lensing papers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The arXiv inbox is strong lensing only. This adds its sibling, `arxiv-interests.md`: the day's ten most relevant papers that are NOT strong lensing β€” black holes, dark matter, galaxy formation, statistics β€” rendered under the inbox on the dashboard with the same five per-paper actions (πŸ“„ PDF, βž• queue, πŸ“₯ intake, πŸ“‘ cite, βœ–οΈ dismiss). It differs from the inbox in one way, and it is the point of it: this is a day-batched BACKLOG, not a seven-day timer. Each nightly run appends one dated batch, the board shows the oldest un-cleared batch only, and one 🧹 clear button drops that whole day and reveals the next β€” so a fortnight away is a fortnight of batches to cycle through rather than a fortnight of lapsed recommendations. Because these papers span domains where every inbox paper routes to "Strong Lensing", each line carries a `[Topic]` naming the reading-queue section βž• files it into; a topic naming no real section falls back to the new `## Interests` section rather than stranding the paper. - scripts/interests_actions.py owns the format and the transitions (append/stamp/clear/add/dismiss), importing inbox_actions rather than restating it. - board.py renders the tier between the inbox and the reading queue, with its own freshness stamp: one digest can break while the other runs. - queue_actions.yml gains interests-add / interests-dismiss / interests-clear; queue_filing.yml learns the third `file:` value. The papers themselves are written by PyAutoMind's arxiv_interests.yml. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YTKUy8F2CevLpEgSz7xbnJ --- .github/workflows/knowledge_board.yml | 5 + .github/workflows/queue_actions.yml | 69 ++++- .github/workflows/queue_filing.yml | 39 +-- README.md | 9 +- arxiv-interests.md | 39 +++ index.md | 10 + reading-queue.md | 4 + scripts/board.py | 248 +++++++++++++++-- scripts/interests_actions.py | 365 ++++++++++++++++++++++++++ scripts/validate_structure.py | 1 + tests/test_board.py | 154 ++++++++++- tests/test_interests_actions.py | 325 +++++++++++++++++++++++ 12 files changed, 1209 insertions(+), 59 deletions(-) create mode 100644 arxiv-interests.md create mode 100644 scripts/interests_actions.py create mode 100644 tests/test_interests_actions.py diff --git a/.github/workflows/knowledge_board.yml b/.github/workflows/knowledge_board.yml index 80f307a..a19084e 100644 --- a/.github/workflows/knowledge_board.yml +++ b/.github/workflows/knowledge_board.yml @@ -18,9 +18,11 @@ on: - "bibliography/**" - "reading-queue.md" - "arxiv-inbox.md" + - "arxiv-interests.md" - "index.md" - "scripts/board.py" - "scripts/inbox_actions.py" + - "scripts/interests_actions.py" schedule: - cron: "45 5 * * 1" workflow_dispatch: @@ -69,6 +71,9 @@ jobs: ensure queue-intake "5319e7" "reading queue: file this paper fully into the wiki" ensure queue-add "fbca04" "arXiv inbox: move this paper into the reading queue" ensure queue-dismiss "d4c5f9" "arXiv inbox: drop this suggestion" + ensure interests-add "fbca04" "arXiv interests: move this paper into the reading queue" + ensure interests-dismiss "d4c5f9" "arXiv interests: drop this suggestion" + ensure interests-clear "b60205" "arXiv interests: clear a whole day's batch" # The family look (hero, palette, stylesheet) is defined once in the # Brain and imported by the renderer β€” never copied in here. diff --git a/.github/workflows/queue_actions.yml b/.github/workflows/queue_actions.yml index 5b969b6..6456621 100644 --- a/.github/workflows/queue_actions.yml +++ b/.github/workflows/queue_actions.yml @@ -1,6 +1,7 @@ name: Queue actions -# The dashboard's mechanical per-paper buttons land here. Three of them: +# The dashboard's mechanical buttons land here β€” five per-paper, one +# per-batch. Six of them: # # βœ… `queue-read` marks a reading-queue.md line `DONE β€” ` # (never deleted β€” the reading history) @@ -8,6 +9,13 @@ name: Queue actions # section, unread # βœ–οΈ `queue-dismiss` drops an arxiv-inbox.md line β€” an un-acted suggestion # is not reading history, and git history holds it +# βž• `interests-add` the same move out of arxiv-interests.md, routed by +# the line's own `[Topic]` rather than to one section +# βœ–οΈ `interests-dismiss` the same drop, out of arxiv-interests.md +# 🧹 `interests-clear` drops a whole day's batch from arxiv-interests.md, +# revealing the next day β€” the interests list is a +# backlog to walk, not a seven-day timer, so this is +# the button that moves it along # # Each commits the file it touched, re-renders the dashboard, and closes the # issue. πŸ“₯ `queue-intake` and πŸ“‘ `queue-cite` are NOT handled here β€” they stay @@ -35,12 +43,16 @@ concurrency: jobs: queue-action: - # One job, three labels. The gate is unchanged: this is a public repo, and - # a stranger's issue must never mutate the queue or the inbox. + # One job, six labels. The gate is unchanged: this is a public repo, and + # a stranger's issue must never mutate the queue, the inbox or the + # interests list. if: >- (contains(github.event.issue.labels.*.name, 'queue-read') || contains(github.event.issue.labels.*.name, 'queue-add') || - contains(github.event.issue.labels.*.name, 'queue-dismiss')) && + contains(github.event.issue.labels.*.name, 'queue-dismiss') || + contains(github.event.issue.labels.*.name, 'interests-add') || + contains(github.event.issue.labels.*.name, 'interests-dismiss') || + contains(github.event.issue.labels.*.name, 'interests-clear')) && contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.issue.author_association) runs-on: ubuntu-latest @@ -67,6 +79,18 @@ jobs: action=dismiss status=$(python scripts/inbox_actions.py dismiss \ --body-file /tmp/issue_body.txt) || true ;; + *,interests-add,*) + action=interests-add + status=$(python scripts/interests_actions.py add \ + --body-file /tmp/issue_body.txt) || true ;; + *,interests-dismiss,*) + action=interests-dismiss + status=$(python scripts/interests_actions.py dismiss \ + --body-file /tmp/issue_body.txt) || true ;; + *,interests-clear,*) + action=interests-clear + status=$(python scripts/interests_actions.py clear \ + --body-file /tmp/issue_body.txt) || true ;; *) action=read status=$(python scripts/queue_mark_done.py \ @@ -76,6 +100,7 @@ jobs: # normalise so the close step reports rather than mis-closing. case "$status" in marked|already-done|added|already-there|dismissed|already-gone) ;; + already-clear|cleared:*) ;; *) status="${status:-error}" ;; esac echo "action=${action}" >> "$GITHUB_OUTPUT" @@ -83,7 +108,7 @@ jobs: echo "queue action ${action}: ${status}" # Did anything actually change on disk? Cheaper and more honest than # inferring it from the status token. - if [ -n "$(git status --porcelain -- reading-queue.md arxiv-inbox.md)" ]; then + if [ -n "$(git status --porcelain -- reading-queue.md arxiv-inbox.md arxiv-interests.md)" ]; then echo "changed=yes" >> "$GITHUB_OUTPUT" else echo "changed=no" >> "$GITHUB_OUTPUT" @@ -97,11 +122,14 @@ jobs: git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" case "$ACTION" in - add) msg="queue: add inbox paper to the reading queue" ;; - dismiss) msg="queue: dismiss inbox paper" ;; - *) msg="queue: mark paper read" ;; + add) msg="queue: add inbox paper to the reading queue" ;; + dismiss) msg="queue: dismiss inbox paper" ;; + interests-add) msg="interests: add paper to the reading queue" ;; + interests-dismiss) msg="interests: dismiss paper" ;; + interests-clear) msg="interests: clear the day's batch" ;; + *) msg="queue: mark paper read" ;; esac - git add reading-queue.md arxiv-inbox.md + git add reading-queue.md arxiv-inbox.md arxiv-interests.md git commit -m "${msg} (#${ISSUE}) [skip ci]" for attempt in 1 2 3; do if git push; then exit 0; fi @@ -117,7 +145,14 @@ jobs: - name: Close or report on the issue env: STATUS: ${{ steps.mark.outputs.status }} + ACTION: ${{ steps.mark.outputs.action }} run: | + # Two suggestion tiers share these statuses, so the comment names + # the one the human actually tapped. + case "$ACTION" in + interests-*) tier="arXiv interests list" ;; + *) tier="arXiv inbox" ;; + esac case "$STATUS" in marked) gh issue close "$ISSUE" --comment \ @@ -127,19 +162,25 @@ jobs: "Already marked read in reading-queue.md β€” nothing to change." ;; added) gh issue close "$ISSUE" --comment \ - "Added to the reading queue and cleared from the arXiv inbox. Dashboard refresh dispatched." ;; + "Added to the reading queue and cleared from the ${tier}. Dashboard refresh dispatched." ;; already-there) gh issue close "$ISSUE" --comment \ - "Already in the reading queue β€” cleared the arXiv inbox line and left the queue alone." ;; + "Already in the reading queue β€” cleared the ${tier} line and left the queue alone." ;; dismissed) gh issue close "$ISSUE" --comment \ - "Dropped from the arXiv inbox. Git history still holds the line. Dashboard refresh dispatched." ;; + "Dropped from the ${tier}. Git history still holds the line. Dashboard refresh dispatched." ;; already-gone) gh issue close "$ISSUE" --comment \ - "Not in the arXiv inbox any more β€” nothing to change (it may already have lapsed)." ;; + "Not in the arXiv inbox or interests list any more β€” nothing to change (it may already have lapsed or been cleared)." ;; + cleared:*) + gh issue close "$ISSUE" --comment \ + "Cleared ${STATUS#cleared:} paper(s) from the arXiv interests list β€” the next day's batch is now on the dashboard. Git history still holds the batch. Dashboard refresh dispatched." ;; + already-clear) + gh issue close "$ISSUE" --comment \ + "That interests batch is already clear β€” nothing to change." ;; no-section) gh issue comment "$ISSUE" --body \ - "Could not process this queue action β€” the target section does not exist in reading-queue.md. Handle manually; leaving the issue open." ;; + "Could not process this queue action β€” neither the named section nor the \`## Interests\` fallback exists in reading-queue.md. Handle manually; leaving the issue open." ;; *) gh issue comment "$ISSUE" --body \ "Could not process this queue action (status: ${STATUS:-error}) β€” the paper's line was not found. Handle manually; leaving the issue open." ;; diff --git a/.github/workflows/queue_filing.yml b/.github/workflows/queue_filing.yml index d0d5841..7af34a9 100644 --- a/.github/workflows/queue_filing.yml +++ b/.github/workflows/queue_filing.yml @@ -1,7 +1,9 @@ name: Queue filing # The dashboard's πŸ“₯ `queue-intake` / πŸ“‘ `queue-cite` buttons open prefilled -# issues; this workflow has Claude file the named paper β€” bibliography entry, +# issues β€” from the reading queue or from either suggestion tier +# (`arxiv-inbox.md`, `arxiv-interests.md`), which the issue's `file:` names; +# this workflow has Claude file the named paper β€” bibliography entry, # wiki sources section (minimal for cite, full stub for intake, the human's # notes folded in), DONE-mark on the queue line β€” and opens a reviewable PR # that closes the issue. Merge stays human. @@ -97,12 +99,17 @@ jobs: Read /tmp/issue_body.txt (a dashboard queue request). Extract: - `file:` β€” which file the line lives in: `reading-queue.md` - (already queued to read) or `arxiv-inbox.md` (an overnight - suggestion, NOT yet in the queue). Absent means reading-queue.md. + (already queued to read), or one of the two overnight suggestion + tiers, NOT yet in the queue β€” `arxiv-inbox.md` (strong lensing) + and `arxiv-interests.md` (everything else). Absent means + reading-queue.md. - `section:` β€” the reading-queue section header, - - `line:` β€” the exact line as written. An arxiv-inbox.md line is - prefixed `<YYYY-MM-DD> β€” `; that date is the inbox timestamp, not - part of the title. Strip it before using the title anywhere. + - `line:` β€” the exact line as written. A suggestion-tier line is + prefixed `<YYYY-MM-DD> β€” `, and an arxiv-interests.md one may + carry a further `[Topic] ` after that date. Neither is part of + the title: strip both before using the title anywhere. (The + topic is the same value as `section:`, kept on the line so the + file round-trips on its own.) - `notes:` β€” the human's free-text notes; treat as absent if it still holds the "(optional β€” replace this…)" placeholder. Read /tmp/issue_label.txt: `queue-intake` (full filing) or @@ -129,18 +136,18 @@ jobs: 4. Record it as read, which depends on `file:`: - `reading-queue.md` β†’ prefix the exact `line:` inside its `## section` with `DONE <today's date> β€” ` (never delete it). - - `arxiv-inbox.md` β†’ there is no queue line yet. Append - `DONE <today's date> β€” <title>[ β€” <ref>]` to the end of the - `## section` in reading-queue.md (the paper is being filed - now, so it enters the queue already read), then delete the - line from arxiv-inbox.md. An inbox line is a suggestion, not - reading history, so deleting it is correct β€” the DONE line it - becomes is the history. + - `arxiv-inbox.md` or `arxiv-interests.md` β†’ there is no queue + line yet. Append `DONE <today's date> β€” <title>[ β€” <ref>]` to + the end of the `## section` in reading-queue.md (the paper is + being filed now, so it enters the queue already read), then + delete the line from whichever of those two files `file:` + names. A suggestion line is not reading history, so deleting + it is correct β€” the DONE line it becomes is the history. 5. Run `make validate` and `python -m pytest tests/ -q`; fix your own filing mistakes until both are green. - Touch ONLY bibliography/, wiki/, reading-queue.md and - arxiv-inbox.md. Do not + Touch ONLY bibliography/, wiki/, reading-queue.md, + arxiv-inbox.md and arxiv-interests.md. Do not commit β€” the workflow handles git. If the paper cannot be identified confidently, write the reason to /tmp/filing_error.txt and stop without editing anything. @@ -164,7 +171,7 @@ jobs: git config user.email "github-actions[bot]@users.noreply.github.com" branch="queue-filing/issue-${ISSUE}" git checkout -b "$branch" - git add bibliography/ wiki/ reading-queue.md arxiv-inbox.md + git add bibliography/ wiki/ reading-queue.md arxiv-inbox.md arxiv-interests.md if git diff --cached --quiet; then echo "::error::Claude produced no changes"; exit 1 fi diff --git a/README.md b/README.md index 9f4f759..173b244 100644 --- a/README.md +++ b/README.md @@ -47,9 +47,12 @@ Self-contained, shared schema: [`bibliography/`](bibliography/README.md) holds the canonical BibTeX metadata every wiki cites against; [`reading-queue.md`](reading-queue.md) -is what's waiting to be read and filed, and -[`arxiv-inbox.md`](arxiv-inbox.md) is the overnight suggestions tier in front -of it. New knowledge updates the metadata +is what's waiting to be read and filed. Two overnight tiers sit in front of it: +[`arxiv-inbox.md`](arxiv-inbox.md) (strong lensing, a seven-day timer) and +[`arxiv-interests.md`](arxiv-interests.md) (everything else β€” black holes, +dark matter, galaxy formation, statistics β€” as one day's ten at a time, a +backlog you clear a day at a time rather than a timer). New knowledge updates +the metadata and the claim support together, then passes `make validate` (CI-enforced on every push). diff --git a/arxiv-interests.md b/arxiv-interests.md new file mode 100644 index 0000000..8ad8ee5 --- /dev/null +++ b/arxiv-interests.md @@ -0,0 +1,39 @@ +# arXiv interests + +The day's ten most relevant arXiv papers that are **not** strong lensing β€” +black holes, dark matter, galaxy formation, statistics and inference, and +whatever else the digest judges worth a look. Strong lensing has its own tier +(`arxiv-inbox.md`); nothing appears on both. + +Format: + +- One paper per line: `<YYYY-MM-DD> β€” [<Topic>] <title>`, optionally followed + by ` β€” <arXiv id or URL>` (the same title/ref convention as + `reading-queue.md`, plus the topic). +- The date is the arXiv **announcement** date the digest ran against, and it is + what groups the file into **day batches**. +- `[<Topic>]` names the `reading-queue.md` section the βž• button files the paper + into β€” these papers span domains, so unlike the strong-lensing inbox there is + no single destination. A topic naming no real section falls back to + `## Interests` (`FALLBACK_SECTION` in `scripts/interests_actions.py`). +- **A backlog, not a timer.** Nothing here lapses. The Dashboard shows the + **oldest un-cleared batch only**, and its 🧹 *clear* button drops that whole + day and reveals the next β€” so a fortnight away is a fortnight of batches to + cycle through, one tap each, not a fortnight of lost recommendations. This is + the one way this list differs from `arxiv-inbox.md`, which lapses after seven + days. +- Each paper carries the same one-tap actions as the inbox: πŸ“„ straight to the + PDF, βž• add to the reading queue, πŸ“₯ intake into memory, πŸ“‘ make citeable, + βœ–οΈ dismiss. +- A cleared batch is not lost: git history holds it. Same reasoning as the + inbox sweep β€” the never-delete rule in `reading-queue.md` protects *reading + history*, and an un-acted suggestion is not history. +- One `last digest: <YYYY-MM-DD>` line, directly below the `---`, records the + last run of the nightly digest β€” **papers or none** β€” so an empty list says + *which* kind of empty it is: dated today it is a genuinely quiet day, four + days stale it means the filing is broken and papers were lost. Replaced, + never accumulated. +- Written by PyAutoMind's `arxiv_interests.yml`. Hand edits are safe but will + race a nightly run; prefer the Dashboard buttons. + +--- diff --git a/index.md b/index.md index 4a049ce..69bc6c4 100644 --- a/index.md +++ b/index.md @@ -37,6 +37,16 @@ the schema in [`wiki/CLAUDE.md`](wiki/CLAUDE.md). (`INBOX_WINDOW_DAYS` in `scripts/inbox_actions.py`). Nothing here has been chosen yet: a suggestion is not reading history, so a lapsed line is dropped rather than DONE-marked, and git history keeps it. +- [`arxiv-interests.md`](arxiv-interests.md) β€” the same tier for everything + that is **not** strong lensing: black holes, dark matter, galaxy formation, + statistics. One line per paper as `<YYYY-MM-DD> β€” [<Topic>] <title>[ β€” <ref>]`, + where the topic names the reading-queue section βž• files it into. It differs + from the inbox in one way: it is a **day-batched backlog, not a timer**. Each + run appends that day's ten, the dashboard shows the **oldest un-cleared batch + only**, and one 🧹 *clear* button drops that whole day and reveals the next β€” + so a fortnight away is a fortnight of batches to cycle through, not a + fortnight of lost recommendations. Same five per-paper actions as the inbox. + Format and transitions live in `scripts/interests_actions.py`. - [`reading-queue.md`](reading-queue.md) β€” the paper queue AND the reading history: `## ` section headers group papers by domain, one title per line, and a read paper is never deleted β€” its line gains a `DONE <date> β€” ` diff --git a/reading-queue.md b/reading-queue.md index 567be2a..5bc9d3e 100644 --- a/reading-queue.md +++ b/reading-queue.md @@ -221,6 +221,10 @@ Bayesian Computation in Astronomy: Novel methods for parallel and gradient-free Finding reproducible cluster partitions for the k-means algorithm Scalable hierarchical BayeSN inference: Investigating dependence of SN Ia host galaxy dust properties on stellar mass and redshift β€” 2401.08755 +## Interests +NOTE Where an `arxiv-interests.md` paper lands when its `[Topic]` names no +NOTE section above β€” the fallback, not a domain. Re-file from here. + ## SETI Imagining the Alien: Human Projections and Cognitive Limitations β€” 2602.07284 Results of ten years of UCLA SETI searches with the Green Bank Telescope β€” 2605.05408 diff --git a/scripts/board.py b/scripts/board.py index 3515b2a..8f4c3bd 100644 --- a/scripts/board.py +++ b/scripts/board.py @@ -24,7 +24,15 @@ The inbox's format, window and transitions live in ``scripts/inbox_actions.py``; this module reads them rather than re-deriving them. -The inbox also carries a **freshness line**: the date of the last digest run, +Below the inbox sits the **arXiv interests list** (``arxiv-interests.md``): +the same overnight machinery pointed at everything that is *not* strong +lensing β€” black holes, dark matter, galaxy formation, statistics β€” as one +day-batch of ten. It renders the same five actions per paper, plus one 🧹 +*clear* button on the batch itself: the list is a backlog rather than a timer, +so the board shows the OLDEST un-cleared day only and clearing it reveals the +next. ``scripts/interests_actions.py`` owns that format and those transitions. + +Both suggestion tiers also carry a **freshness line**: the date of the last digest run, so an empty inbox says *which* kind of empty it is β€” arXiv was quiet, or the filing broke. The date is rendered into the page, but the "this looks broken" warning is computed in the reader's browser (see ``_EXTRA_JS``), because the @@ -73,6 +81,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent)) import arxiv_refs # noqa: E402 import inbox_actions # noqa: E402 +import interests_actions # noqa: E402 # The family look lives once, in the Brain (``board/_theme.py``): the # stylesheet, the hero that redraws this organ's logo as a mark, and the @@ -169,6 +178,14 @@ def collect(root: Path | None = None) -> dict: "queue": [], "inbox": [], "inbox_last_digest": None, + # The interests list is day-batched: `interests` is the oldest + # un-cleared batch (what the board shows), and `interests_batches` / + # `interests_backlog` are how much is queued behind it. + "interests": [], + "interests_date": None, + "interests_batches": 0, + "interests_backlog": 0, + "interests_last_digest": None, "links": {"total": 0, "unique": 0, "wanted": 0}, } @@ -250,6 +267,28 @@ def _flush(): "done_date": None, }) + interests = root / interests_actions.INTERESTS_FILE + if interests.exists(): + text = interests.read_text(errors="replace") + snapshot["interests_last_digest"] = inbox_actions.last_digest(text) + every = interests_actions.batches(text) + snapshot["interests_batches"] = len(every) + snapshot["interests_backlog"] = sum(len(b) for _, b in every) + current = interests_actions.current_batch(text) + if current: + snapshot["interests_date"] = current[0] + for entry in current[1]: + snapshot["interests"].append({ + "title": entry["title"], + "ref": entry["ref"], + "line": entry["line"], + "added": entry["added"], + "topic": entry["topic"], + "section": interests_actions.section_for(entry), + "done": False, + "done_date": None, + }) + slugs = [s.split("|")[0].strip() for s in all_slugs] uniq = set(slugs) snapshot["links"] = { @@ -350,8 +389,9 @@ def _queue_issue_url(snapshot: dict, section: str, paper: dict, Five tiers β€” 'intake' (really interesting: full wiki filing), 'cite' (worth citing, not pivotal: bib entry + a minimal sources section), 'read' - (done with it: DONE-mark only), and, for arXiv-inbox papers, 'add' (into - the reading queue) and 'dismiss' (not for me). All but intake/cite are + (done with it: DONE-mark only), and, for the suggestion tiers + (arXiv-inbox, arXiv-interests), 'add' (into the reading queue) and + 'dismiss' (not for me). All but intake/cite are processed automatically by queue_actions.yml. Nothing changes state until the human submits the issue on GitHub; intake/cite issues stay open as the filing work item, and their `notes:` field is free text the human edits @@ -365,23 +405,32 @@ def _queue_issue_url(snapshot: dict, section: str, paper: dict, repo_url = _repo_url(snapshot) if not repo_url: return "" - from_inbox = source == inbox_actions.INBOX_FILE - whose = "arXiv-inbox" if from_inbox else "reading-queue" + # Two of the three sources are *suggestion* tiers: a paper there has no + # reading-queue line yet, which is what the βž•/βœ–οΈ actions and the filing + # NOTE below both turn on. Naming that once keeps the interests list from + # being a second special case bolted beside the inbox. + staged = source != inbox_actions.QUEUE_FILE + whose, tier = { + inbox_actions.INBOX_FILE: ("arXiv-inbox", "the arXiv inbox"), + interests_actions.INTERESTS_FILE: ("arXiv-interests", + "the arXiv interests list"), + }.get(source, ("reading-queue", "the reading queue")) where = f"file: {source}\nsection: {section}\nline: {paper['line']}" notes = ("notes: (optional β€” replace this with why you added it or " "what was noteworthy, in your own words; whoever files the " "paper folds it in)") + from_interests = source == interests_actions.INTERESTS_FILE if action == "add": issue_title = f"queue add: {paper['title']}"[:200] - label = "queue-add" - body = ("Move this arXiv-inbox paper into the reading queue " + label = "interests-add" if from_interests else "queue-add" + body = (f"Move this {whose} paper into the reading queue " f"(section: {section}) β€” worth reading, not yet read.\n\n" f"{where}\n\n" "Processed automatically by queue_actions.yml.") elif action == "dismiss": issue_title = f"queue dismiss: {paper['title']}"[:200] - label = "queue-dismiss" - body = ("Drop this paper from the arXiv inbox β€” not one for me. The " + label = "interests-dismiss" if from_interests else "queue-dismiss" + body = (f"Drop this paper from {tier} β€” not one for me. The " "line is removed rather than DONE-marked: an un-acted " "suggestion is not reading history, and git history holds it " "either way.\n\n" @@ -422,18 +471,45 @@ def _queue_issue_url(snapshot: dict, section: str, paper: dict, "incorporating the notes above β€” mark its queue line " "'DONE <date> β€” <title>' in reading-queue.md, and run " "make validate.") - if from_inbox and action not in ("add", "dismiss", "read"): - # An inbox paper has no reading-queue line to DONE-mark: filing it - # creates that line, already read, and clears the inbox one. - body += ("\n\nNOTE: this paper is in arxiv-inbox.md and is NOT in the " + if staged and action not in ("add", "dismiss", "read"): + # A suggestion-tier paper has no reading-queue line to DONE-mark: + # filing it creates that line, already read, and clears the tier one. + body += (f"\n\nNOTE: this paper is in {source} and is NOT in the " "reading queue yet, so there is no line to mark. Instead " f"append it to the '{section}' section of reading-queue.md " "already DONE-prefixed (it is being filed now, not queued to " - "read later) and remove its arxiv-inbox.md line.") + f"read later) and remove its {source} line.") return (f"{repo_url}/issues/new?title={_quote(issue_title, safe='')}" f"&body={_quote(body, safe='')}&labels={_quote(label, safe='')}") +def _interests_clear_url(snapshot: dict, date: str, count: int) -> str: + """The 🧹 button: a prefilled issue that drops one whole interests batch. + + The only board action that is not per-paper, because the list is not a + per-paper decision: the human walks a backlog of days, taking what they + want out of a day and clearing the rest of it in one tap. Everything still + goes through the same gate β€” an issue, submitted by hand, processed by + queue_actions.yml β€” so nothing changes state on a mis-tap. + """ + repo_url = _repo_url(snapshot) + if not repo_url: + return "" + title = f"interests clear: {date}" + body = (f"Clear the {date} batch ({count} paper(s)) from " + f"{interests_actions.INTERESTS_FILE} β€” done with this day, show " + "the next one.\n\n" + f"file: {interests_actions.INTERESTS_FILE}\n" + f"date: {date}\n\n" + "The lines are removed rather than DONE-marked: an un-acted " + "suggestion is not reading history, and git history holds the " + "batch either way.\n\n" + "Processed automatically by queue_actions.yml.") + return (f"{repo_url}/issues/new?title={_quote(title, safe='')}" + f"&body={_quote(body, safe='')}" + f"&labels={_quote('interests-clear', safe='')}") + + def _read_trend(snapshot: dict) -> dict: """Read counts from the DONE history, relative to the snapshot's `generated` timestamp (pure β€” no wall clock): papers read in the last @@ -535,13 +611,16 @@ def _stub_prompt(snapshot: dict, wiki: str) -> str: f"in wiki/CLAUDE.md, set status: drafted, and run make validate.") -def _inbox_freshness(snapshot: dict) -> dict: - """The inbox's freshness facts, shared by both renderers. +def _inbox_freshness(snapshot: dict, key: str = "inbox_last_digest") -> dict: + """A suggestion tier's freshness facts, shared by both renderers. + + ``key`` picks the tier β€” the strong-lensing inbox or the interests list. + Each has its own nightly digest and so its own way to be silently broken. ``stale`` is what the *markdown* fallback can say. The HTML page must not trust it for the warning: see ``_EXTRA_JS``. """ - stamp = snapshot.get("inbox_last_digest") + stamp = snapshot.get(key) try: today = datetime.datetime.fromisoformat( str(snapshot.get("generated"))).date() @@ -555,7 +634,7 @@ def _inbox_freshness(snapshot: dict) -> dict: def _inbox_empty_note(fresh: dict) -> str: - """Why the inbox is empty β€” quiet, suspect, or never run. Plain text.""" + """Why a tier is empty β€” quiet, suspect, or never run. Plain text.""" if fresh["stale"]: return (f"no digest since {fresh['stamp']} " f"({fresh['weekdays']} weekdays) β€” the nightly filing may be " @@ -607,6 +686,41 @@ def _render_md(snapshot: dict) -> str: else: warn = ("⚠ " if fresh["stale"] else "") lines += [f"- _(nothing waiting β€” {warn}{_inbox_empty_note(fresh)})_", ""] + + lines += ["", "## arXiv interests", ""] + interests = snapshot.get("interests") or [] + ifresh = _inbox_freshness(snapshot, "interests_last_digest") + if interests: + date = snapshot.get("interests_date") or "?" + left = max(0, (snapshot.get("interests_batches") or 1) - 1) + backlog = (f" Β· {left} more day{'s' if left != 1 else ''} behind it" + if left else " Β· nothing behind it") + clear_url = _interests_clear_url(snapshot, date, len(interests)) + clear = f" Β· [clear this day \U0001f9f9]({clear_url})" if clear_url else "" + lines += [f"_{date} Β· {len(interests)} " + f"paper{'s' if len(interests) != 1 else ''}{backlog}{clear}_", + ""] + for p_ in interests: + label = p_["title"].replace("[", "\\[").replace("]", "\\]") + bits = [f"- [{label}]({paper_url(p_)})"] + if p_.get("topic"): + bits.append(f"_{p_['topic']}_") + pdf = paper_pdf_url(p_) + if pdf: + bits.append(f"[pdf πŸ“„]({pdf})") + for action, chip in (("add", "queue βž•"), ("intake", "intake πŸ“₯"), + ("cite", "cite πŸ“‘"), ("dismiss", "dismiss βœ–οΈ")): + u = _queue_issue_url(snapshot, p_["section"], p_, action, + source=interests_actions.INTERESTS_FILE) + if u: + bits.append(f"[{chip}]({u})") + lines.append(" Β· ".join(bits)) + lines.append("") + else: + warn = ("⚠ " if ifresh["stale"] else "") + lines += [f"- _(no batch waiting β€” {warn}{_inbox_empty_note(ifresh)})_", + ""] + lines += ["## Reading queue", ""] trend = _read_trend(snapshot) if sum(q.get("done", 0) for q in snapshot.get("queue") or []): @@ -681,8 +795,8 @@ def _bar(statuses: dict) -> str: # so this board follows the family accent instead of setting a second palette. _LEDE = ("What the organism knows, and what it still owes a citation. On a " "paper: \U0001f4e5 intake Β· \U0001f4d1 cite Β· \u2705 mark read β€” each opens a " - "prefilled issue (add notes, submit to act). \U0001f4cb copies a Claude Code " - "prompt.") + "prefilled issue (add notes, submit to act). \U0001f9f9 clears a whole " + "interests day. \U0001f4cb copies a Claude Code prompt.") _EXTRA_CSS = """ .bar{display:inline-block;width:90px;height:8px;border-radius:4px; @@ -711,6 +825,12 @@ def _bar(statuses: dict) -> str: a.act:hover{background:var(--tint);border-color:var(--accent); text-decoration:none} a.act.pdf{border-color:var(--accent)} +/* The one batch-level action: it clears a whole day, so it is worded rather + than an icon alone β€” a mis-tap here costs ten papers, not one. */ +a.act.clear{border-color:var(--warn);color:var(--warn);font-weight:600} +a.act.clear:hover{background:var(--warn);color:var(--bg);border-color:var(--warn)} +.topic{color:var(--muted);font-size:.82em;margin-left:.4rem; + padding:.05rem .35rem;border:1px solid var(--line);border-radius:6px} details.hist{margin:.25rem 0 .25rem 1.3rem} details.hist>summary{cursor:pointer} #pfilter{width:100%;padding:.45rem .6rem;margin:.25rem 0 .5rem; @@ -755,7 +875,11 @@ def _bar(statuses: dict) -> str: freshness(); document.addEventListener("click",function(e){ var b=e.target.closest("button.copy"); - if(b&&b.closest("summary")){e.preventDefault();}},true); + if(b&&b.closest("summary")){e.preventDefault();} + // The 🧹 clear chip lives in a <summary> too: let the link win rather than + // collapsing the batch the human is looking at. + var a=e.target.closest("a.act"); + if(a&&a.closest("summary")){e.stopPropagation();}},true); function flt(q){q=q.toLowerCase(); var secs=document.querySelectorAll('details.qsec'); for(var i=0;i<secs.length;i++){var d=secs[i],any=false,histHit=false; @@ -764,9 +888,10 @@ def _bar(statuses: dict) -> str: var hit=!q||li.textContent.toLowerCase().indexOf(q)>=0; li.style.display=hit?'':'none'; if(hit){any=true;if(li.className==='done')histHit=true;}} - var isInbox=d.classList.contains('inbox'); + var openByDefault=d.classList.contains('inbox')|| + d.classList.contains('interests'); if(q){d.style.display=any?'':'none';d.open=any;} - else{d.style.display='';d.open=isInbox;} + else{d.style.display='';d.open=openByDefault;} var h=d.querySelector('details.hist'); if(h){h.style.display=(q&&!histHit)?'none':'';h.open=!!q&&histHit;}}} """ @@ -884,7 +1009,10 @@ def _render_html(snapshot: dict) -> str: stale_tmpl = (f"⚠ no digest since {fresh['stamp']} ({{n}} weekdays) β€” the " f"nightly filing may be broken") if fresh["stamp"] else "" - def _fresh_span(now_text: str, stale_text: str, cls: str = "meta") -> str: + def _fresh_span(fresh: dict, now_text: str, stale_text: str, + cls: str = "meta") -> str: + """`fresh` is explicit because the page renders two tiers, each with + its own digest, its own stamp and its own way of going silent.""" if not fresh["stamp"]: return f"<span class='{cls}'>{_html.escape(now_text)}</span>" if fresh["stale"]: @@ -900,6 +1028,7 @@ def _fresh_span(now_text: str, stale_text: str, cls: str = "meta") -> str: if inbox_items: digest = f" Β· last digest {fresh['stamp']}" if fresh["stamp"] else "" meta = _fresh_span( + fresh, f"{len(inbox_items)} waiting{digest} Β· lapse after " f"{inbox_actions.INBOX_WINDOW_DAYS}d", f"{len(inbox_items)} waiting Β· {stale_tmpl}") @@ -910,9 +1039,70 @@ def _fresh_span(now_text: str, stale_text: str, cls: str = "meta") -> str: else: note = _inbox_empty_note(fresh) inbox_block = ("<p>" + _fresh_span( + fresh, f"nothing waiting β€” {note}", f"nothing waiting β€” {stale_tmpl}") + "</p>") + interests = snapshot.get("interests") or [] + interest_items = [] + for p in interests: + acts = [_pdf_act(p)] + for action, icon, hint in ( + ("add", "\u2795", "add to the reading queue: worth reading β€” opens " + "a prefilled issue; submitting files the line"), + ("intake", "\U0001f4e5", "intake into memory: really interesting β€” " + "opens a prefilled issue (add your notes) " + "that becomes the full filing work item"), + ("cite", "\U0001f4d1", "make citeable: worth citing, not pivotal β€” " + "opens a prefilled issue (add your notes) for a " + "bib entry + minimal sources section"), + ("dismiss", "\u2716\ufe0f", "not for me: opens a prefilled issue; " + "submitting drops the line from this batch")): + u = _queue_issue_url(snapshot, p["section"], p, action, + source=interests_actions.INTERESTS_FILE) + if u: + acts.append(f"<a class='act' href=\"{_html.escape(u, quote=True)}\" " + f"title='{_html.escape(hint, quote=True)}'>{icon}</a>") + topic = (f" <span class='topic'>{_html.escape(p['topic'])}</span>" + if p.get("topic") else "") + interest_items.append( + f"<li><a href=\"{_html.escape(paper_url(p), quote=True)}\">" + f"{_html.escape(p['title'])}</a>{topic}{''.join(acts)}</li>") + ifresh = _inbox_freshness(snapshot, "interests_last_digest") + istale_tmpl = (f"\u26a0 no digest since {ifresh['stamp']} ({{n}} weekdays) β€” " + f"the nightly filing may be broken") if ifresh["stamp"] else "" + if interest_items: + idate = snapshot.get("interests_date") or "?" + left = max(0, (snapshot.get("interests_batches") or 1) - 1) + behind = (f" Β· {left} more day{'s' if left != 1 else ''} behind it" + if left else "") + # The one non-per-paper action on the board. It sits in the <summary> + # beside the count, because that is what it acts on: the day, not a + # paper. The click handler in _EXTRA_JS lets it through without + # toggling the section open (a link inside a <summary> would). + clear_url = _interests_clear_url(snapshot, idate, len(interest_items)) + clear = (f"<a class='act clear' href=\"{_html.escape(clear_url, quote=True)}\" " + f"title='clear this whole day: opens a prefilled issue; " + f"submitting drops the {len(interest_items)} paper(s) left in " + f"the {idate} batch and reveals the next day'>" + f"\U0001f9f9 clear</a>" if clear_url else "") + imeta = _fresh_span( + ifresh, + f"{len(interest_items)} paper" + f"{'s' if len(interest_items) != 1 else ''}{behind}", + f"{len(interest_items)} paper(s) Β· {istale_tmpl}") + # The batch's date IS its name β€” this is a backlog of days, and which + # day you are looking at is the first thing to know. + interests_block = ( + f"<details class='qsec interests' open><summary><span class='name'>" + f"{_html.escape(idate)}</span> {imeta} {clear}</summary>" + f"<ul class='papers'>{''.join(interest_items)}</ul></details>") + else: + interests_block = ("<p>" + _fresh_span( + ifresh, + f"no batch waiting β€” {_inbox_empty_note(ifresh)}", + f"no batch waiting β€” {istale_tmpl}") + "</p>") + trend = _read_trend(snapshot) total_done = sum(q.get("done", 0) for q in snapshot.get("queue") or []) trend_bits = f"{t['queued']} papers waiting" @@ -960,6 +1150,12 @@ def _fresh_span(now_text: str, stale_text: str, cls: str = "meta") -> str: f"{_copy_btn('/memory ' + w['name'], 'copy: recall this domain')}" f"</td></tr>") + n_batches = snapshot.get("interests_batches") or 0 + interests_backlog_note = ( + f"; {n_batches} day{'s' if n_batches != 1 else ''} of backlog, " + f"{snapshot.get('interests_backlog') or 0} papers" + if n_batches else "") + hero = t_.hero(BOARD_KEY, "Dashboard", _LEDE) stats = t_.stats((t["pages"], "Pages"), (f"{pct}%", "Cited"), (t["todo"], "To cite"), (t["queued"], "Queued")) @@ -976,6 +1172,10 @@ def _fresh_span(now_text: str, stale_text: str, cls: str = "meta") -> str: <h2>arXiv inbox <span class="muted">(suggested overnight β€” un-acted papers lapse after {inbox_actions.INBOX_WINDOW_DAYS} days)</span></h2> {inbox_block} +<h2>arXiv interests <span class="muted">(everything that is not strong + lensing β€” one day's ten at a time; \U0001f9f9 clears the day and shows the + next{interests_backlog_note})</span></h2> +{interests_block} <h2>Reading queue <span class="muted">({trend_bits})</span>{spark}</h2> {filter_box} {''.join(queue_blocks)} diff --git a/scripts/interests_actions.py b/scripts/interests_actions.py new file mode 100644 index 0000000..d680969 --- /dev/null +++ b/scripts/interests_actions.py @@ -0,0 +1,365 @@ +"""scripts/interests_actions.py β€” the arXiv interests list: append, stamp, clear, add, dismiss. + +The **second** suggestion tier, sibling of ``inbox_actions.py``. The arXiv +inbox is strong lensing only; this one is everything else the human reads for β€” +black holes, dark matter, galaxy formation, statistics β€” as the day's ten most +relevant non-lensing papers, written by PyAutoMind's ``arxiv_interests.yml``. + +It is a **day-batched backlog**, and that is the one real difference from the +inbox: + +* The inbox is a flat list on a seven-day timer: act on a line or it lapses. +* This list never lapses. Each run appends one dated batch, the Dashboard shows + the **oldest un-cleared batch only**, and one 🧹 *clear* button drops that + whole day and reveals the next. So a week away is a week of batches to cycle + through, not a week of lost recommendations β€” the human asked to see every + day's ten, not the last seven days' worth of whatever survived. + +Nothing else differs. The line format is the inbox's (``<date> β€” <text>``) with +one addition β€” an optional ``[Topic]`` naming the reading-queue section the +βž• button files into, because these papers span domains where the inbox's all +route to "Strong Lensing". The per-paper actions are the same four, and the +freshness stamp is the same line, parsed by the same code: this module imports +``inbox_actions`` rather than restating any of it. + +A cleared batch is not lost β€” git history holds it, exactly as for a swept +inbox line, and for the same reason: a suggestion nobody acted on is not +reading history. + +Each subcommand prints exactly one status token on stdout. + +Usage: + python scripts/interests_actions.py append --papers-file picks.json + python scripts/interests_actions.py stamp + python scripts/interests_actions.py clear --date 2026-08-25 + python scripts/interests_actions.py add --body-file /tmp/issue_body.txt + python scripts/interests_actions.py dismiss --body-file /tmp/issue_body.txt +""" + +from __future__ import annotations + +import argparse +import datetime +import json +import re +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import inbox_actions # noqa: E402 + +INTERESTS_FILE = "arxiv-interests.md" +QUEUE_FILE = inbox_actions.QUEUE_FILE + +#: How many papers one day's batch holds. The digest picks them; this is the +#: guard on its output, so a prompt that miscounts cannot flood a day. +BATCH_SIZE = 10 + +#: Where a paper goes when its ``[Topic]`` names no section in the reading +#: queue β€” or when a hand-written line carries no topic at all. A real section +#: rather than an error: the topic is a routing hint from a nightly prompt, and +#: a hint that misses should still land the paper somewhere retrievable. +FALLBACK_SECTION = "Interests" + +#: ``<YYYY-MM-DD> β€” [Topic] <title>[ β€” <ref>]``. The topic is optional and the +#: rest is queue-shaped, so ``board.py`` hands it to its own ``_parse_paper`` +#: exactly as it does an inbox line. +LINE_RE = re.compile( + r"^(\d{4}-\d{2}-\d{2})\s+β€”\s+(?:\[([^\]]+)\]\s+)?(\S.*)$") + +BODY_DATE_RE = re.compile(r"^date:\s*(\d{4}-\d{2}-\d{2})\s*$", re.MULTILINE) + + +# --- parsing ------------------------------------------------------------------ +def parse_line(raw: str) -> tuple[str, str | None, str] | None: + """``(added_date, topic | None, paper_text)`` for a paper line, else None.""" + m = LINE_RE.match(raw.strip()) + if not m: + return None + topic = (m.group(2) or "").strip() or None + return (m.group(1), topic, m.group(3)) + + +def format_line(date: str, topic: str | None, text: str) -> str: + """The inverse of :func:`parse_line` β€” the one place a line is written.""" + tag = f"[{topic.strip()}] " if topic and topic.strip() else "" + return f"{date} β€” {tag}{text.strip()}" + + +def papers(text: str) -> list[dict]: + """Every paper line in the list, in file order (oldest batch first).""" + out = [] + for raw in text.splitlines(): + parsed = parse_line(raw) + if not parsed: + continue + added, topic, rest = parsed + title, ref = inbox_actions.split_ref(rest) + out.append({"added": added, "topic": topic, "title": title, "ref": ref, + "text": rest, "line": raw.strip()}) + return out + + +def batches(text: str) -> list[tuple[str, list[dict]]]: + """The list grouped into day batches, oldest first. + + Grouped by date rather than taken in file order: a re-run that appends to a + date already present must extend that batch, not open a second one. + """ + grouped: dict[str, list[dict]] = {} + for p in papers(text): + grouped.setdefault(p["added"], []).append(p) + return [(d, grouped[d]) for d in sorted(grouped)] + + +def current_batch(text: str) -> tuple[str, list[dict]] | None: + """The oldest un-cleared batch β€” what the Dashboard shows. None if empty. + + Oldest, not newest: the backlog is a queue the human walks forward through, + so clearing today's ten would skip the ones already waiting. + """ + got = batches(text) + return got[0] if got else None + + +def section_for(paper: dict) -> str: + """The reading-queue section a paper's βž• files into.""" + return paper.get("topic") or FALLBACK_SECTION + + +# --- transitions -------------------------------------------------------------- +def append(interests_text: str, known_texts: list[str], entries: list[dict], + date: str, limit: int = BATCH_SIZE) -> tuple[str, int]: + """Append a day's batch, skipping papers already known anywhere. + + ``entries`` are ``{"title": str, "ref": str | None, "topic": str | None}``. + ``known_texts`` are the other files a paper may already live in β€” the + reading queue and the strong-lensing inbox β€” so a paper never arrives here + while it is already waiting somewhere else. Returns ``(new_text, added)``. + """ + known = {inbox_actions.identity(p["text"]) for p in papers(interests_text)} + for text in known_texts: + for raw in text.splitlines(): + s = raw.strip() + if not s or s.startswith("#"): + continue + # The other two files each carry their own prefixes; identity only + # needs the tail, so strip whichever is present. + got = inbox_actions.parse_line(s) + if got: + s = got[1] + s = re.sub(r"^DONE\s+\d{4}-\d{2}-\d{2}\s+β€”\s+", "", s) + known.add(inbox_actions.identity(s)) + + fresh = [] + for e in entries: + if limit is not None and len(fresh) >= limit: + break + text = e["title"].strip() + if e.get("ref"): + text = f"{text} β€” {e['ref']}" + key = inbox_actions.identity(text) + if key in known: + continue + known.add(key) + fresh.append(format_line(date, e.get("topic"), text)) + if not fresh: + return interests_text, 0 + + lines = interests_text.rstrip("\n").splitlines() + last = max((i for i, r in enumerate(lines) if parse_line(r)), default=None) + at = len(lines) if last is None else last + 1 + lines[at:at] = fresh + return "\n".join(lines) + "\n", len(fresh) + + +def clear(interests_text: str, date: str) -> tuple[str, int]: + """Drop a whole day's batch. Returns ``(new_text, dropped_count)``. + + Clearing a date that is not there is zero dropped, not an error: the button + is a one-tap issue and the same day can be tapped twice. + """ + out, dropped = [], 0 + for raw in interests_text.rstrip("\n").splitlines(): + parsed = parse_line(raw) + if parsed and parsed[0] == date: + dropped += 1 + continue + out.append(raw) + return "\n".join(out) + "\n", dropped + + +def remove(interests_text: str, line: str) -> tuple[str, str]: + """Drop one line. Status: ``done`` / ``not-found``.""" + return inbox_actions.remove(interests_text, line) + + +def add_to_queue(queue_text: str, paper_text: str, + section: str) -> tuple[str, str]: + """File a paper into the reading queue, falling back when the topic misses. + + The topic is a nightly prompt's guess at a section name; a guess that names + nothing real must not strand the paper, so it lands in + :data:`FALLBACK_SECTION` instead. Only a queue with no fallback section + either still returns ``no-section``. + """ + text, status = inbox_actions.add_to_queue(queue_text, paper_text, section) + if status == "no-section" and section != FALLBACK_SECTION: + return inbox_actions.add_to_queue(queue_text, paper_text, + FALLBACK_SECTION) + return text, status + + +# --- issue bodies ------------------------------------------------------------- +def parse_body(body: str) -> dict | None: + """The ``file:`` / ``line:`` / ``section:`` / ``date:`` an action names. + + A per-paper action (βž• βœ–οΈ) names a ``line:``; the 🧹 clear action names a + ``date:`` and no line. Either is enough; neither is None. + """ + parsed = inbox_actions.parse_body(body) + m_date = BODY_DATE_RE.search(body) + if parsed is None: + if not m_date: + return None + parsed = {"line": None, "file": INTERESTS_FILE} + # inbox_actions defaults a missing section to ITS target ("Strong + # Lensing"), which is the one section these papers are never for. An + # absent section here means "use the line's own topic" instead. + m_section = inbox_actions.BODY_SECTION_RE.search(body) + parsed["section"] = m_section.group(1).strip() if m_section else None + parsed["date"] = m_date.group(1) if m_date else None + return parsed + + +# --- CLI ---------------------------------------------------------------------- +def _today(ns) -> datetime.date: + if ns.date_override: + return datetime.date.fromisoformat(ns.date_override) + return datetime.datetime.now(datetime.timezone.utc).date() + + +def _read(path: str) -> str: + p = Path(path) + return p.read_text(errors="replace") if p.exists() else "" + + +def _cmd_append(ns) -> tuple[str, int]: + raw = json.loads(Path(ns.papers_file).read_text(errors="replace")) + entries = raw["papers"] if isinstance(raw, dict) else raw + entries = [{"title": e["title"], "ref": e.get("ref") or e.get("id"), + "topic": e.get("topic")} for e in entries] + interests = Path(ns.interests) + text, n = append(interests.read_text(errors="replace"), + [_read(ns.queue), _read(ns.inbox)], + entries, _today(ns).isoformat(), limit=ns.limit) + if n: + interests.write_text(text) + return (f"appended:{n}", 0) + + +def _cmd_stamp(ns) -> tuple[str, int]: + date = _today(ns).isoformat() + interests = Path(ns.interests) + interests.write_text( + inbox_actions.set_last_digest( + interests.read_text(errors="replace"), date)) + return (f"stamped:{date}", 0) + + +def _cmd_clear(ns) -> tuple[str, int]: + interests = Path(ns.interests) + text = interests.read_text(errors="replace") + date = ns.batch_date + if date is None: + parsed = parse_body(Path(ns.body_file).read_text(errors="replace")) \ + if ns.body_file else None + if parsed is None or not parsed.get("date"): + return ("bad-body", 2) + date = parsed["date"] + new_text, n = clear(text, date) + if n: + interests.write_text(new_text) + return (f"cleared:{n}", 0) + return ("already-clear", 0) + + +def _cmd_add(ns) -> tuple[str, int]: + parsed = parse_body(Path(ns.body_file).read_text(errors="replace")) + if parsed is None or not parsed.get("line"): + return ("bad-body", 2) + interests, queue = Path(ns.interests), Path(ns.queue) + line = parsed["line"] + got = parse_line(line) + paper_text = got[2] if got else line + section = parsed.get("section") or (got[1] if got else None) \ + or FALLBACK_SECTION + queue_text, status = add_to_queue(queue.read_text(errors="replace"), + paper_text, section) + if status == "no-section": + return ("no-section", 3) + if status == "added": + queue.write_text(queue_text) + interests_text, _ = remove(interests.read_text(errors="replace"), line) + interests.write_text(interests_text) + return (status, 0) + + +def _cmd_dismiss(ns) -> tuple[str, int]: + parsed = parse_body(Path(ns.body_file).read_text(errors="replace")) + if parsed is None or not parsed.get("line"): + return ("bad-body", 2) + interests = Path(ns.interests) + text, status = remove(interests.read_text(errors="replace"), + parsed["line"]) + if status == "done": + interests.write_text(text) + return ("dismissed", 0) + return ("already-gone", 0) + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(prog="scripts/interests_actions.py", + description=__doc__) + ap.add_argument("--interests", default=INTERESTS_FILE) + ap.add_argument("--queue", default=QUEUE_FILE) + ap.add_argument("--inbox", default=inbox_actions.INBOX_FILE) + # `--today`, not inbox_actions' `--date`: this module's `clear` takes a + # `--date` of its own (the batch), and two flags spelled the same meaning + # different things is a trap in a workflow's shell. + ap.add_argument("--today", dest="date_override", default=None, + help="override today (YYYY-MM-DD)") + sub = ap.add_subparsers(dest="cmd", required=True) + + p = sub.add_parser("append", help="append a day's batch") + p.add_argument("--papers-file", required=True, + help="JSON: [{title, ref, topic}] or {\"papers\": [...]}") + p.add_argument("--limit", type=int, default=BATCH_SIZE, + help=f"most papers one batch may hold (default {BATCH_SIZE})") + p.set_defaults(fn=_cmd_append) + + p = sub.add_parser("stamp", help="record that the digest ran today") + p.set_defaults(fn=_cmd_stamp) + + p = sub.add_parser("clear", help="drop a whole day's batch") + p.add_argument("--date", dest="batch_date", default=None, + help="the batch to clear (YYYY-MM-DD)") + p.add_argument("--body-file", default=None, + help="file holding the clear issue body (names the date)") + p.set_defaults(fn=_cmd_clear) + + for name, fn, helptext in (("add", _cmd_add, "interests β†’ reading queue"), + ("dismiss", _cmd_dismiss, "drop one line")): + p = sub.add_parser(name, help=helptext) + p.add_argument("--body-file", required=True, + help="file holding the action issue body") + p.set_defaults(fn=fn) + + ns = ap.parse_args(argv) + status, code = ns.fn(ns) + print(status) + return code + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/validate_structure.py b/scripts/validate_structure.py index 1ae51ea..b6c99bb 100644 --- a/scripts/validate_structure.py +++ b/scripts/validate_structure.py @@ -36,6 +36,7 @@ "README.md", "SPAWNED_FROM", # provenance marker stamped into spawned templates "arxiv-inbox.md", + "arxiv-interests.md", "index.md", "logo.png", "reading-queue.md", diff --git a/tests/test_board.py b/tests/test_board.py index 720db7a..b8a4370 100644 --- a/tests/test_board.py +++ b/tests/test_board.py @@ -20,6 +20,7 @@ import board # noqa: E402 import inbox_actions # noqa: E402 +import interests_actions # noqa: E402 BODY_MARKER = "the-secret-claim-text-that-must-never-leak" @@ -52,6 +53,13 @@ def _tree(tmp_path: Path) -> Path: "# arXiv inbox\n\nintro prose\n\n---\n" "2026-08-24 β€” Suggested One β€” 2608.00001\n" "2026-08-24 β€” Suggested Two\n") + # Two day batches, oldest first: the board must show only the first. + (tmp_path / "arxiv-interests.md").write_text( + "# arXiv interests\n\nintro prose\n\n---\n" + "last digest: 2026-08-26\n" + "2026-08-25 β€” [Demo Papers] Interest One β€” 2608.10001\n" + "2026-08-25 β€” [Nowhere] Interest Two β€” 2608.10002\n" + "2026-08-26 β€” [Demo Papers] Interest Three β€” 2608.10003\n") return tmp_path @@ -297,6 +305,10 @@ def _inbox(tmp_path, stamp=None, papers=()): if stamp: text = inbox_actions.set_last_digest(text, stamp) (root / "arxiv-inbox.md").write_text(text) + # The page carries two stamped tiers; blank the other one so these assert + # about the inbox's freshness and not the interests list's. + (root / "arxiv-interests.md").write_text( + "# arXiv interests\n\nintro prose\n\n---\n") snap = board.collect(root) snap["owner"], snap["repo"] = "PyAutoLabs", "PyAutoMemory" snap["generated"] = "2026-08-25T09:00:00+00:00" @@ -385,8 +397,9 @@ def test_no_pdf_button_where_there_is_no_ref(tmp_path): out = board.render(snap, fmt) # Title One / Title Three / Suggested Two are ref-less, and the # heading and the two annotations are not papers at all; the only PDF - # links on the page belong to the four papers that carry a ref. - assert out.count("arxiv.org/pdf/") == 4 + # links on the page belong to the six papers that carry a ref β€” four + # in the queue and inbox, plus the current interests batch's two. + assert out.count("arxiv.org/pdf/") == 6 bare = {"title": "Title One", "ref": None} assert board.paper_pdf_url(bare) == "" # a non-arXiv ref is still a link, but there is no PDF to derive from it @@ -435,3 +448,140 @@ def test_a_bare_link_is_a_paper_only_when_it_points_at_arxiv(tmp_path): i = html.index("example.org β†’") assert "issues/new" not in html[i:html.index("</li>", i)] + +# --- the arXiv interests list ------------------------------------------------- +# The sibling suggestion tier. What is tested here is only what makes it +# DIFFERENT from the inbox: it is a backlog of day batches with the oldest one +# current, the 🧹 button clears that whole day, and its papers route by their +# own topic rather than to one section. The per-paper actions themselves are +# the inbox's, already covered above. +def _interests(tmp_path, lines, stamp=None): + base = tmp_path / f"t{len(list(tmp_path.iterdir()))}" + base.mkdir() + root = _tree(base) + text = "# arXiv interests\n\nintro prose\n\n---\n" + "".join( + f"{line}\n" for line in lines) + if stamp: + text = inbox_actions.set_last_digest(text, stamp) + (root / "arxiv-interests.md").write_text(text) + (root / "arxiv-inbox.md").write_text( + "# arXiv inbox\n\nintro prose\n\n---\n") + snap = board.collect(root) + snap["owner"], snap["repo"] = "PyAutoLabs", "PyAutoMemory" + return snap + + +def test_only_the_oldest_batch_is_collected(tmp_path): + snap = _snap_with_remote(tmp_path) + assert snap["interests_date"] == "2026-08-25" + assert [p["title"] for p in snap["interests"]] == ["Interest One", + "Interest Two"] + assert snap["interests_batches"] == 2 + assert snap["interests_backlog"] == 3 + + +def test_a_missing_interests_file_is_not_an_error(tmp_path): + root = _tree(tmp_path) + (root / "arxiv-interests.md").unlink() + snap = board.collect(root) + assert snap["interests"] == [] + assert snap["interests_date"] is None + for fmt in ("md", "html", "json", "badge", "md-brief"): + board.render({**snap, "owner": "o", "repo": "r"}, fmt) + + +def test_interests_papers_carry_the_same_five_actions(tmp_path): + snap = _snap_with_remote(tmp_path) + html = board._render_html(snap) + for label in ("interests-add", "queue-intake", "queue-cite", + "interests-dismiss"): + assert label in html, label + assert "arxiv.org/pdf/2608.10001" in html + + +def test_interests_actions_name_the_interests_file(tmp_path): + snap = _snap_with_remote(tmp_path) + body = unquote(board._queue_issue_url( + snap, "Demo Papers", snap["interests"][0], "add", + source=interests_actions.INTERESTS_FILE)) + assert "file: arxiv-interests.md" in body + assert "line: 2026-08-25 β€” [Demo Papers] Interest One β€” 2608.10001" in body + + +def test_interests_filing_actions_warn_there_is_no_queue_line(tmp_path): + snap = _snap_with_remote(tmp_path) + body = unquote(board._queue_issue_url( + snap, "Demo Papers", snap["interests"][0], "intake", + source=interests_actions.INTERESTS_FILE)) + assert "NOT in the reading queue yet" in body + assert "arxiv-interests.md" in body + assert "arxiv-inbox.md" not in body + + +def test_a_paper_routes_to_its_own_topic(tmp_path): + snap = _snap_with_remote(tmp_path) + assert snap["interests"][0]["section"] == "Demo Papers" + body = unquote(board._queue_issue_url( + snap, snap["interests"][0]["section"], snap["interests"][0], "add", + source=interests_actions.INTERESTS_FILE)) + assert "section: Demo Papers" in body + + +def test_a_topicless_paper_falls_back_rather_than_stranding(tmp_path): + snap = _interests(tmp_path, ["2026-08-25 β€” No topic here β€” 2608.10001"]) + assert snap["interests"][0]["section"] == interests_actions.FALLBACK_SECTION + + +def test_the_clear_button_names_the_batch_date(tmp_path): + snap = _snap_with_remote(tmp_path) + html = board._render_html(snap) + assert "interests-clear" in html + url = board._interests_clear_url(snap, "2026-08-25", 2) + body = unquote(url) + assert "file: arxiv-interests.md" in body + assert "date: 2026-08-25" in body + # A batch clear is not a per-paper action: it must not carry a line. + assert "line:" not in body + + +def test_no_clear_button_without_a_remote(tmp_path): + snap = board.collect(_tree(tmp_path)) + assert board._interests_clear_url(snap, "2026-08-25", 2) == "" + assert "interests-clear" not in board.render(snap, "html") + + +def test_interests_render_between_the_inbox_and_the_queue(tmp_path): + """The human asked for it under strong lensing: inbox, then this, then + the backlog it feeds.""" + snap = _snap_with_remote(tmp_path) + for text in (board._render_md(snap), board._render_html(snap)): + assert text.index("arXiv inbox") < text.index("arXiv interests") + assert text.index("arXiv interests") < text.index("Reading queue") + + +def test_the_backlog_depth_is_on_the_page(tmp_path): + """A day cleared is a day revealed β€” the human needs to know how many are + behind the one they are looking at.""" + snap = _snap_with_remote(tmp_path) + assert "1 more day behind it" in board._render_html(snap) + assert "2 days of backlog" in board._render_html(snap) + assert "1 more day behind it" in board._render_md(snap) + + +def test_an_empty_interests_list_says_which_kind_of_empty(tmp_path): + quiet = _interests(tmp_path, [], stamp="2026-08-26") + assert "the last digest ran 2026-08-26" in board._render_md(quiet) + never = _interests(tmp_path, []) + assert "no run recorded yet" in board._render_md(never) + + +def test_the_two_tiers_carry_their_own_stamps(tmp_path): + """One digest can break while the other keeps running; the page must not + report the healthy one's date for the broken one.""" + snap = _snap_with_remote(tmp_path) + snap["inbox_last_digest"] = "2026-08-27" + snap["interests_last_digest"] = "2026-08-20" + html = board._render_html(snap) + assert "data-last-digest='2026-08-27'" in html + assert "data-last-digest='2026-08-20'" in html + diff --git a/tests/test_interests_actions.py b/tests/test_interests_actions.py new file mode 100644 index 0000000..dc7eb93 --- /dev/null +++ b/tests/test_interests_actions.py @@ -0,0 +1,325 @@ +"""tests/test_interests_actions.py β€” the arXiv interests list. + +The list's contract, and specifically the two things that make it NOT the +strong-lensing inbox: it is grouped into day batches with the oldest one +current, and `clear` drops a whole day rather than a line lapsing on a timer. +Everything else β€” line format, dedup, the add/dismiss transitions β€” is the +inbox's contract, tested here because this module owns the `[Topic]` the inbox +lines do not carry. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) + +import interests_actions as ia # noqa: E402 + +LIST = """# arXiv interests + +Prose header, not papers. + +--- +last digest: 2026-08-27 +2026-08-25 β€” [SMBHs] A tidal disruption event β€” 2608.30001 +2026-08-25 β€” [Dark Matter] Ultra-light dark matter from streams β€” 2608.30002 +2026-08-26 β€” [Stats] Simulation-based inference β€” 2608.30004 +""" + +QUEUE = """# Reading queue + +Prose header. + +## SMBHs +A black hole paper already queued β€” 2608.10001 + +## Dark Matter +DONE 2026-08-21 β€” Something already read β€” 2607.99999 + +## Interests +NOTE the fallback section. +""" + +INBOX = """# arXiv inbox + +--- +last digest: 2026-08-27 +2026-08-25 β€” A strong lensing paper β€” 2608.22222 +""" + + +def _entry(title, ref=None, topic=None): + return {"title": title, "ref": ref, "topic": topic} + + +# --- parsing ------------------------------------------------------------------ +def test_parses_date_topic_and_paper(): + got = ia.papers(LIST) + assert [p["ref"] for p in got] == ["2608.30001", "2608.30002", "2608.30004"] + assert got[0]["topic"] == "SMBHs" + assert got[0]["title"] == "A tidal disruption event" + assert got[0]["added"] == "2026-08-25" + + +def test_topic_is_optional(): + got = ia.parse_line("2026-08-25 β€” A paper with no topic β€” 2608.10001") + assert got == ("2026-08-25", None, "A paper with no topic β€” 2608.10001") + + +def test_prose_and_stamp_are_not_papers(): + assert ia.parse_line("# arXiv interests") is None + assert ia.parse_line("last digest: 2026-08-27") is None + assert ia.parse_line("") is None + + +def test_format_line_round_trips(): + line = ia.format_line("2026-08-25", "Stats", "A title β€” 2608.10001") + assert line == "2026-08-25 β€” [Stats] A title β€” 2608.10001" + assert ia.parse_line(line) == ("2026-08-25", "Stats", "A title β€” 2608.10001") + + +def test_format_line_omits_an_empty_topic(): + assert ia.format_line("2026-08-25", None, "A title") == "2026-08-25 β€” A title" + assert ia.format_line("2026-08-25", " ", "A title") == "2026-08-25 β€” A title" + + +def test_a_title_containing_brackets_is_not_read_as_a_topic(): + # The topic marker is anchored at the start; a bracket later in the title + # (or a title that merely opens with one mid-sentence) must survive. + line = "2026-08-25 β€” [Stats] Errors [sic] in inference β€” 2608.10001" + assert ia.parse_line(line)[2] == "Errors [sic] in inference β€” 2608.10001" + + +# --- batches ------------------------------------------------------------------ +def test_batches_group_by_date_oldest_first(): + got = ia.batches(LIST) + assert [d for d, _ in got] == ["2026-08-25", "2026-08-26"] + assert [len(b) for _, b in got] == [2, 1] + + +def test_current_batch_is_the_oldest(): + date, batch = ia.current_batch(LIST) + assert date == "2026-08-25" + assert len(batch) == 2 + + +def test_current_batch_of_an_empty_list_is_none(): + assert ia.current_batch("# arXiv interests\n\n---\n") is None + + +def test_batches_regroup_out_of_order_lines(): + # A hand edit (or a re-run) can leave a date's lines split apart; a batch + # is the date, not a run of adjacent lines. + text = LIST + "2026-08-25 β€” [Stats] A late arrival β€” 2608.30009\n" + assert [len(b) for _, b in ia.batches(text)] == [3, 1] + + +# --- append ------------------------------------------------------------------- +def test_append_writes_the_topic_and_the_ref(): + text, n = ia.append(LIST, [], [_entry("A new paper", "2608.50001", "Stats")], + "2026-08-27") + assert n == 1 + assert text.rstrip().endswith("2026-08-27 β€” [Stats] A new paper β€” 2608.50001") + + +def test_append_dedupes_against_itself(): + text, n = ia.append(LIST, [], + [_entry("Whatever title", "2608.30001", "Stats")], + "2026-08-27") + assert (n, text) == (0, LIST) + + +def test_append_dedupes_against_the_queue_and_the_inbox(): + entries = [_entry("A black hole paper already queued", "2608.10001"), + _entry("Something already read", "2607.99999"), + _entry("A strong lensing paper", "2608.22222"), + _entry("Genuinely new", "2608.77777", "Stats")] + text, n = ia.append(LIST, [QUEUE, INBOX], entries, "2026-08-27") + assert n == 1 + assert "Genuinely new" in text + + +def test_append_caps_the_batch(): + entries = [_entry(f"Paper {i}", f"2608.9{i:04d}", "Stats") for i in range(15)] + text, n = ia.append(LIST, [], entries, "2026-08-27", limit=10) + assert n == 10 + assert len(ia.batches(text)[-1][1]) == 10 + + +def test_append_extends_a_date_already_present(): + text, _ = ia.append(LIST, [], [_entry("Another 25th paper", "2608.50001")], + "2026-08-25") + assert [len(b) for _, b in ia.batches(text)] == [3, 1] + + +def test_append_keeps_the_stamp_above_the_papers(): + text, _ = ia.append(LIST, [], [_entry("A new paper", "2608.50001")], + "2026-08-27") + lines = text.splitlines() + assert lines.index("last digest: 2026-08-27") < lines.index( + "2026-08-27 β€” A new paper β€” 2608.50001") + + +def test_append_into_an_empty_list_lands_below_the_separator(): + empty = "# arXiv interests\n\nProse.\n\n---\nlast digest: 2026-08-27\n" + text, n = ia.append(empty, [], [_entry("First ever", "2608.10001", "Stats")], + "2026-08-27") + assert n == 1 + assert ia.current_batch(text)[1][0]["title"] == "First ever" + + +# --- clear -------------------------------------------------------------------- +def test_clear_drops_exactly_one_day(): + text, n = ia.clear(LIST, "2026-08-25") + assert n == 2 + assert [d for d, _ in ia.batches(text)] == ["2026-08-26"] + + +def test_clear_reveals_the_next_day(): + text, _ = ia.clear(LIST, "2026-08-25") + assert ia.current_batch(text)[0] == "2026-08-26" + + +def test_clear_is_idempotent(): + text, _ = ia.clear(LIST, "2026-08-25") + again, n = ia.clear(text, "2026-08-25") + assert (n, again) == (0, text) + + +def test_clear_keeps_the_prose_and_the_stamp(): + text, _ = ia.clear(LIST, "2026-08-25") + assert "# arXiv interests" in text + assert "last digest: 2026-08-27" in text + + +# --- remove / add_to_queue ---------------------------------------------------- +def test_remove_takes_the_line_as_written(): + line = "2026-08-25 β€” [SMBHs] A tidal disruption event β€” 2608.30001" + text, status = ia.remove(LIST, line) + assert status == "done" + assert line not in text + + +def test_add_to_queue_uses_the_named_section(): + text, status = ia.add_to_queue(QUEUE, "A new SMBH paper β€” 2608.50001", "SMBHs") + assert status == "added" + smbh = text.split("## SMBHs")[1].split("##")[0] + assert "A new SMBH paper" in smbh + + +def test_add_to_queue_falls_back_when_the_topic_names_nothing(): + text, status = ia.add_to_queue(QUEUE, "An exoplanet paper β€” 2608.50001", + "Exoplanets") + assert status == "added" + fallback = text.split("## Interests")[1] + assert "An exoplanet paper" in fallback + + +def test_add_to_queue_reports_no_section_without_a_fallback(): + queue = "# Reading queue\n\n## SMBHs\nA paper\n" + _, status = ia.add_to_queue(queue, "An exoplanet paper", "Exoplanets") + assert status == "no-section" + + +# --- issue bodies ------------------------------------------------------------- +def test_parse_body_reads_a_per_paper_action(): + body = ("file: arxiv-interests.md\nsection: SMBHs\n" + "line: 2026-08-25 β€” [SMBHs] A tidal disruption event β€” 2608.30001\n") + got = ia.parse_body(body) + assert got["file"] == "arxiv-interests.md" + assert got["section"] == "SMBHs" + assert got["line"].startswith("2026-08-25 β€” [SMBHs]") + assert got["date"] is None + + +def test_parse_body_reads_a_clear_action(): + got = ia.parse_body("file: arxiv-interests.md\ndate: 2026-08-25\n") + assert got["date"] == "2026-08-25" + assert got["line"] is None + + +def test_parse_body_never_defaults_the_section_to_strong_lensing(): + # inbox_actions.parse_body does; inheriting that would file every + # topic-less interests paper into the one section it is never for. + got = ia.parse_body("line: 2026-08-25 β€” A paper β€” 2608.10001\n") + assert got["section"] is None + + +def test_parse_body_rejects_a_body_naming_neither(): + assert ia.parse_body("nothing useful here") is None + + +# --- CLI ---------------------------------------------------------------------- +@pytest.fixture +def repo(tmp_path): + (tmp_path / "arxiv-interests.md").write_text(LIST) + (tmp_path / "reading-queue.md").write_text(QUEUE) + (tmp_path / "arxiv-inbox.md").write_text(INBOX) + return tmp_path + + +def _run(repo, *argv): + return ia.main(["--interests", str(repo / "arxiv-interests.md"), + "--queue", str(repo / "reading-queue.md"), + "--inbox", str(repo / "arxiv-inbox.md"), *argv]) + + +def test_cli_append_and_stamp(repo, capsys): + picks = repo / "picks.json" + picks.write_text(json.dumps({"papers": [ + {"title": "A new paper", "id": "2608.50001", "topic": "Stats"}]})) + assert _run(repo, "--today", "2026-08-28", "append", + "--papers-file", str(picks)) == 0 + assert capsys.readouterr().out.strip() == "appended:1" + assert _run(repo, "--today", "2026-08-28", "stamp") == 0 + assert capsys.readouterr().out.strip() == "stamped:2026-08-28" + text = (repo / "arxiv-interests.md").read_text() + assert "last digest: 2026-08-28" in text + assert "2026-08-28 β€” [Stats] A new paper β€” 2608.50001" in text + + +def test_cli_clear_by_date_then_by_body(repo, capsys): + assert _run(repo, "clear", "--date", "2026-08-25") == 0 + assert capsys.readouterr().out.strip() == "cleared:2" + body = repo / "body.txt" + body.write_text("file: arxiv-interests.md\ndate: 2026-08-26\n") + assert _run(repo, "clear", "--body-file", str(body)) == 0 + assert capsys.readouterr().out.strip() == "cleared:1" + assert ia.current_batch((repo / "arxiv-interests.md").read_text()) is None + + +def test_cli_add_routes_by_the_lines_own_topic(repo, capsys): + body = repo / "body.txt" + body.write_text( + "file: arxiv-interests.md\n" + "line: 2026-08-25 β€” [SMBHs] A tidal disruption event β€” 2608.30001\n") + assert _run(repo, "add", "--body-file", str(body)) == 0 + assert capsys.readouterr().out.strip() == "added" + queue = (repo / "reading-queue.md").read_text() + assert "A tidal disruption event β€” 2608.30001" in queue.split( + "## SMBHs")[1].split("##")[0] + assert "2608.30001" not in (repo / "arxiv-interests.md").read_text() + + +def test_cli_dismiss_leaves_the_queue_alone(repo, capsys): + body = repo / "body.txt" + body.write_text( + "file: arxiv-interests.md\n" + "line: 2026-08-26 β€” [Stats] Simulation-based inference β€” 2608.30004\n") + before = (repo / "reading-queue.md").read_text() + assert _run(repo, "dismiss", "--body-file", str(body)) == 0 + assert capsys.readouterr().out.strip() == "dismissed" + assert (repo / "reading-queue.md").read_text() == before + assert "2608.30004" not in (repo / "arxiv-interests.md").read_text() + + +def test_cli_reports_a_body_it_cannot_read(repo, capsys): + body = repo / "body.txt" + body.write_text("nothing useful\n") + assert _run(repo, "add", "--body-file", str(body)) == 2 + assert capsys.readouterr().out.strip() == "bad-body" From 7ef40100082ad6bff3ac32278dd453703812c9d0 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 27 Aug 2026 18:26:55 +0000 Subject: [PATCH 2/2] board: drop a no-op click handler on the clear chip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A link inside a <summary> navigates away, so whether the section also toggles on the way out is not observable β€” unlike the πŸ“‹ buttons beside it, which is what the handler was there for. Say that instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YTKUy8F2CevLpEgSz7xbnJ --- scripts/board.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/scripts/board.py b/scripts/board.py index 8f4c3bd..a0f613a 100644 --- a/scripts/board.py +++ b/scripts/board.py @@ -875,11 +875,7 @@ def _bar(statuses: dict) -> str: freshness(); document.addEventListener("click",function(e){ var b=e.target.closest("button.copy"); - if(b&&b.closest("summary")){e.preventDefault();} - // The 🧹 clear chip lives in a <summary> too: let the link win rather than - // collapsing the batch the human is looking at. - var a=e.target.closest("a.act"); - if(a&&a.closest("summary")){e.stopPropagation();}},true); + if(b&&b.closest("summary")){e.preventDefault();}},true); function flt(q){q=q.toLowerCase(); var secs=document.querySelectorAll('details.qsec'); for(var i=0;i<secs.length;i++){var d=secs[i],any=false,histHit=false; @@ -1078,8 +1074,9 @@ def _fresh_span(fresh: dict, now_text: str, stale_text: str, if left else "") # The one non-per-paper action on the board. It sits in the <summary> # beside the count, because that is what it acts on: the day, not a - # paper. The click handler in _EXTRA_JS lets it through without - # toggling the section open (a link inside a <summary> would). + # paper. No click handling needed, unlike the πŸ“‹ buttons beside it: a + # link navigates away from the board, so whether the section also + # toggles on the way out is not observable. clear_url = _interests_clear_url(snapshot, idate, len(interest_items)) clear = (f"<a class='act clear' href=\"{_html.escape(clear_url, quote=True)}\" " f"title='clear this whole day: opens a prefilled issue; "