Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions stacklets/docs/bot/git_mirror.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@

from stack.forgejo import ForgejoClient, ForgejoError

from diary_room import household_now
from vault_entry import (
slug,
capture_hash,
Expand Down Expand Up @@ -529,6 +530,7 @@ async def _fold_action_items_into_list(
author_name: str,
author_email: str,
previous_path: str = "",
today: str = "",
) -> bool:
"""Put a record's action items on its topic's list, `lists/<topic>.md`.

Expand All @@ -541,10 +543,15 @@ async def _fold_action_items_into_list(
have ticked, reworded or removed them since. When a refile renamed
the record (`previous_path`), those items are linked to its new
path instead. An item whose text is already on the list is left
alone too.
alone too. An item due before `today` (the household's date when
not given) stays off the list: an old letter filed late, or a
refile of last year's bill, has nothing left to do. The record's
summary still names it.
"""
source = Link(record_title, record_path) if record_path else None
items = [i for ai in action_items or [] if (i := action_item(ai, source=source))]
today = today or household_now().date().isoformat()
items = [i for ai in action_items or [] if (i := action_item(ai, source=source))
and (not i.due or i.due >= today)]
if not (topic and items):
return False
path = list_path(topic, bucket=self.shared_bucket)
Expand Down Expand Up @@ -589,6 +596,7 @@ async def _fold_document_todos(
record_path: str,
record_title: str,
previous_path: str = "",
today: str = "",
) -> None:
topics = classification.get("topics") or classification.get("topic") or []
if isinstance(topics, str):
Expand All @@ -607,6 +615,7 @@ async def _fold_document_todos(
message=f"chore(lists): archivist added action items to {self._slug(topic)}",
author_name=BOT_USERNAME,
author_email=BOT_EMAIL,
today=today,
)

async def _fold_capture_todos(
Expand All @@ -618,6 +627,7 @@ async def _fold_capture_todos(
author_name: str,
author_email: str,
previous_path: str = "",
today: str = "",
) -> None:
topic = self._topic_of_capture(target_path)
await self._fold_action_items_into_list(
Expand All @@ -631,6 +641,7 @@ async def _fold_capture_todos(
message=f"chore(lists): {author_name} added action items to {topic}",
author_name=author_name,
author_email=author_email,
today=today,
)

# ── Publish ──────────────────────────────────────────────────────────
Expand Down
2 changes: 1 addition & 1 deletion stacklets/docs/bot/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -1155,7 +1155,7 @@ def _build_classify_prompt(*, ocr_text: str, person_names: list[str],
- correspondent_aliases: only when the printed sender name on THIS document differs from your canonical answer. Single-element list is fine.
- correspondent_facts: stable across documents from the same sender. Address and customer numbers belong here; this month's total does not.
- facts: concrete numbers, dates, account numbers, amounts that describe THIS document. When the document itemises purchases or services, each line item is its own fact bullet alongside the top-level totals. Empty list if none.
- action_items: deadlines, payments due, forms to return. Empty list if none.
- action_items: manual family tasks only (pay by transfer, sign/return form, book, cancel by date). NOT: direct debit, SEPA mandate, card on file, standing order, info or confirmation only. [] if none.

Document text:
---
Expand Down
41 changes: 39 additions & 2 deletions tests/unit/stacklets/test_git_mirror.py
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,7 @@ async def test_a_document_starts_the_topics_list(self, mirror):
},
record_path="family/documents/2026/03/2026-03-02-campsite-p12.md",
record_title="Campsite booking",
today="2026-03-02",
)

written = client.files["family/lists/camping.md"]["content"]
Expand Down Expand Up @@ -511,18 +512,54 @@ async def test_a_refiled_record_adds_nothing_and_its_items_follow_a_rename(self,
await mirror._fold_document_todos(
client, classification={"topics": ["Education"],
"action_items": [{"action": "Sign the slip", "due": "2026-10-23"}]},
record_path=old, record_title="Permission slip")
record_path=old, record_title="Permission slip", today="2026-10-09")
await mirror._fold_document_todos(
client, classification={"topics": ["Education"],
"action_items": [{"action": "Sign and return the slip",
"due": "2026-10-23"}]},
record_path=new, record_title="School notice", previous_path=old)
record_path=new, record_title="School notice", previous_path=old, today="2026-10-09")

written = client.files["family/lists/education.md"]["content"]
assert "Sign and return" not in written
assert ("- [ ] Sign the slip [Permission slip]"
"(../documents/2026/10/2026-10-09-school-notice-p72.md) 📅 2026-10-23") in written

@pytest.mark.asyncio
async def test_an_item_already_past_its_due_date_stays_off_the_list(self, mirror):
"""An old letter filed late, or last year's bill refiled, has nothing left to do.
The record's summary still names the item; only the list leaves it out."""
client = _FakeTodoClient()
await mirror._fold_document_todos(
client,
classification={"topics": ["Fahrzeug"], "action_items": [
{"action": "Rechnung überweisen", "due": "2026-08-14"},
{"action": "Unterlagen abholen", "due": "2026-10-13"},
"Reifen einlagern",
]},
record_path="family/documents/2026/08/werkstatt-p40.md",
record_title="Werkstatt Rechnung",
today="2026-10-13",
)

written = client.files["family/lists/fahrzeug.md"]["content"]
assert "Rechnung überweisen" not in written
assert "Unterlagen abholen" in written, "due today is still ahead of the family"
assert "Reifen einlagern" in written, "an item without a date never runs out"

@pytest.mark.asyncio
async def test_a_record_with_only_past_items_starts_no_list(self, mirror):
client = _FakeTodoClient()
await mirror._fold_document_todos(
client,
classification={"topics": ["Fahrzeug"],
"action_items": [{"action": "Rechnung überweisen", "due": "2026-08-14"}]},
record_path="family/documents/2026/08/werkstatt-p40.md",
record_title="Werkstatt Rechnung",
today="2026-10-13",
)

assert client.puts == []

@pytest.mark.asyncio
async def test_a_new_list_is_titled_with_the_topics_name(self, mirror):
client = _FakeTodoClient()
Expand Down
Loading