stubborn-otter/otter-boards

Otter Boards, a team task board you can run from the web or from Claude and ChatGPT. FastAPI + DataK3 + appid + Models + MCP.

A remix template · 1 remixes · Remix stubborn-otter/otter-boards

app/boards/application/use_cases.py

"""Every action a person can take. The web routes and the MCP tools both call these and nothing
else, so a rule cannot be skipped by using the other door.

Add a use case here, then expose it in doors/web.py and doors/mcp.py (one line each).

References: wherever a use case takes `board`, `card` or `list_name` it accepts what people
actually say. A board is an id or a name, a list is an id or a name, a card is an id, a number
("12", "#12", "card 12") or its title. Resolving only ever searches boards the person is on.
"""
import re
import uuid
from dataclasses import dataclass
from datetime import date, datetime, timedelta, timezone
from typing import Callable, Optional

from boards.application.ports import Assistant, AssistantUnavailable, BoardRepository
from boards.domain.model import (DEFAULT_LISTS, MAX_NAME, MAX_TITLE, Board, BoardList, Card, Change,
                                 Event, Invalid, Person, Role, blocker_chain, clean_text, match_one,
                                 position_between, would_cycle)
from boards.domain.similarity import card_text, cosine, keyword_score, text_hash


class NotFound(Exception):
    pass


def utc_now() -> datetime:
    return datetime.now(timezone.utc).replace(tzinfo=None)


def _new_id() -> str:
    return uuid.uuid4().hex[:12]


def _card_number(ref: str) -> Optional[int]:
    found = re.fullmatch(r"(?:card\s*)?#?\s*(\d+)", ref.strip(), re.IGNORECASE)
    return int(found.group(1)) if found else None


@dataclass
class Snapshot:
    """One board as it stands now, and how to show its cards."""
    board: Board
    lists: list
    cards: list
    today: date

    def __post_init__(self):
        self.list_by_id = {x.id: x for x in self.lists}
        self.by_number = {c.number: c for c in self.cards}

    def finished(self, card: Card) -> bool:
        board_list = self.list_by_id.get(card.list_id)
        return board_list is not None and board_list.is_done

    def open_blockers(self, card: Card) -> list:
        found = (self.by_number.get(n) for n in card.blocked_by)
        return [c for c in found if c is not None and not self.finished(c)]

    def overdue(self) -> list:
        return [c for c in self.cards if c.is_overdue(self.today, self.finished(c))]

    def show(self, card: Card) -> dict:
        board_list = self.list_by_id.get(card.list_id)
        return {
            "id": card.id, "number": card.number,
            "board_id": self.board.id, "board": self.board.title,
            "title": card.title, "description": card.description,
            "list_id": card.list_id, "list": board_list.name if board_list else None,
            "assignee": card.assignee,
            "due_date": card.due_date.isoformat() if card.due_date else None,
            "labels": list(card.labels),
            "checklist": [{"text": i.text, "done": i.done} for i in card.checklist],
            "blocked_by": list(card.blocked_by),
            "blocked": bool(self.open_blockers(card)),
            "overdue": card.is_overdue(self.today, self.finished(card)),
        }


def _numbered(numbers: list) -> str:
    return ", ".join(f"#{n}" for n in numbers)


def _by_due_date(cards: list) -> list:
    return sorted(cards, key=lambda c: (c["due_date"] or "9999", c["board"], c["number"]))


def _event_view(e: Event) -> dict:
    return {"at": e.at.isoformat(timespec="seconds") + "Z", "who": e.actor, "action": e.action,
            "field": e.field, "old": e.old, "new": e.new}


class Boards:
    def __init__(self, repo: BoardRepository, assistant: Optional[Assistant] = None,
                 now: Callable[[], datetime] = utc_now):
        self.repo, self.assistant, self.now = repo, assistant, now

    # ---- finding things, always as this person ------------------------------------------------

    def _board(self, ref: str, person: Person) -> Board:
        """A board by id or name. A board you can't see doesn't exist for you."""
        ref = str(ref).strip()
        board = self.repo.get(ref)
        if board is not None and board.role_of(person) is not None:
            return board
        found = match_one(self.repo.boards_for(person.email), ref, lambda b: b.title, "board")
        if found is None:
            raise NotFound("no such board")
        return found

    def _find_card(self, ref, person: Person, board_ref: Optional[str] = None) -> tuple:
        ref = str(ref).strip()
        scope = self._board(board_ref, person) if board_ref else None
        card = self.repo.get_card(ref)
        if card is not None and not card.archived and (scope is None or scope.id == card.board_id):
            board = self.repo.get(card.board_id)
            if board is not None and board.role_of(person) is not None:
                return board, card
        boards = [scope] if scope else self.repo.boards_for(person.email)
        pairs = [(b, c) for b in boards for c in self.repo.cards_on(b.id)]
        number = _card_number(ref)
        if number is None:
            found = match_one(pairs, ref, lambda p: p[1].title, "card")
            if found is None:
                raise NotFound("no such card")
            return found
        hits = [p for p in pairs if p[1].number == number]
        if not hits:
            raise NotFound("no such card")
        if len(hits) > 1:
            names = ", ".join(sorted(repr(b.title) for b, _ in hits))
            raise Invalid(f"card {number} is on more than one board: {names}. Which board?")
        return hits[0]

    def _find_list(self, snap: Snapshot, ref: str) -> BoardList:
        ref = str(ref).strip()
        found = snap.list_by_id.get(ref) or match_one(snap.lists, ref, lambda x: x.name, "list")
        if found is None:
            names = ", ".join(x.name for x in snap.lists)
            raise Invalid(f"no list {ref!r} on this board; its lists are {names}")
        return found

    def _snapshot(self, board: Board) -> Snapshot:
        return Snapshot(board, self.repo.lists_on(board.id), self.repo.cards_on(board.id),
                        self.now().date())

    def _record(self, board: Board, card: Optional[Card], person: Person, action: str,
                change: Optional[Change] = None) -> None:
        at = self.now()
        self.repo.log(Event(
            id=f"{at:%Y%m%d%H%M%S%f}-{uuid.uuid4().hex[:8]}",  # sorts by time
            board_id=board.id, card_id=card.id if card else None, actor=person.email,
            action=action, at=at, field=change.field if change else None,
            old=change.old if change else None, new=change.new if change else None))

    def _save(self, board: Board, card: Card, person: Person, changes: list) -> None:
        """Every change to a card comes through here, so none can miss the history."""
        if changes:
            self.repo.save_card(card)
        for change in changes:
            self._record(board, card, person, change.sentence(), change)

    def _shown(self, board: Board, card: Card) -> dict:
        """The card as we just wrote it. Not re-read, so the answer doesn't depend on how soon
        the database shows a write to the next read."""
        return self._snapshot(board).show(card)

    # ---- boards and people ---------------------------------------------------------------------

    def whoami(self, person: Person) -> dict:
        return {"email": person.email}

    def create_board(self, person: Person, title: str) -> dict:
        title = clean_text(title, "board title", MAX_TITLE)
        board = Board(id=_new_id(), title=title, owner_email=person.email)
        for place, (name, is_done) in enumerate(DEFAULT_LISTS, start=1):
            self.repo.save_list(BoardList(_new_id(), board.id, name, float(place), is_done))
        self.repo.save_board(board)
        self._record(board, None, person, f"created board {board.title!r}")
        return {"id": board.id, "title": board.title, "role": Role.OWNER.value}

    def my_boards(self, person: Person) -> list:
        boards = sorted(self.repo.boards_for(person.email), key=lambda b: b.title.casefold())
        return [{"id": b.id, "title": b.title, "role": b.role_of(person).value} for b in boards]

    def show_board(self, person: Person, board: str) -> dict:
        found = self._board(board, person)
        snap = self._snapshot(found)
        role = found.role_of(person)
        return {
            "id": found.id, "title": found.title, "role": role.value,
            "can_edit": role is not Role.VIEWER, "is_owner": role is Role.OWNER,
            "today": snap.today.isoformat(), "overdue_count": len(snap.overdue()),
            "members": [{"email": e, "role": r.value} for e, r in found.people()],
            "lists": [{"id": x.id, "name": x.name, "is_done": x.is_done,
                       "cards": [snap.show(c) for c in snap.cards if c.list_id == x.id]}
                      for x in snap.lists],
        }

    def rename_board(self, person: Person, board: str, title: str) -> dict:
        found = self._board(board, person)
        found.require_owner(person)
        old, found.title = found.title, clean_text(title, "board title", MAX_TITLE)
        if found.title != old:
            self.repo.save_board(found)
            self._record(found, None, person, f"renamed the board from {old!r} to {found.title!r}")
        return {"id": found.id, "title": found.title}

    def list_members(self, person: Person, board: str) -> list:
        found = self._board(board, person)
        return [{"email": e, "role": r.value} for e, r in found.people()]

    def add_member(self, person: Person, board: str, email: str, role: str = "member") -> dict:
        """Invite someone, or change their role. Only the owner may do either."""
        found = self._board(board, person)
        found.require_owner(person)
        try:
            wanted = Role(str(role).strip().lower())
        except ValueError:
            raise Invalid(f"{role!r} is not a role; use member or viewer")
        known = (email or "").strip().lower() in found.members
        email = found.add_member(person, email, wanted)
        self.repo.save_board(found)
        verb = "changed" if known else "added"
        self._record(found, None, person, f"{verb} {email} as {wanted.value}")
        if wanted is Role.VIEWER:
            self._take_cards_from(found, email, person)
        return {"board_id": found.id, "email": email, "role": wanted.value}

    def remove_member(self, person: Person, board: str, email: str) -> dict:
        found = self._board(board, person)
        found.require_owner(person)
        email = found.remove_member(person, email)
        self.repo.remove_member(found.id, email)
        self._record(found, None, person, f"removed {email} from the board")
        self._take_cards_from(found, email, person)
        return {"board_id": found.id, "email": email, "removed": True}

    def _take_cards_from(self, board: Board, email: str, person: Person) -> None:
        """Someone who can no longer act on a card shouldn't still hold it."""
        for card in self.repo.cards_on(board.id):
            if card.assignee == email:
                self._save(board, card, person, card.edit({"assignee": None}))

    # ---- lists ---------------------------------------------------------------------------------

    def _editable_snapshot(self, person: Person, board: str) -> Snapshot:
        found = self._board(board, person)
        found.require_edit(person)
        return self._snapshot(found)

    @staticmethod
    def _require_unique_name(snap: Snapshot, name: str, keep: Optional[BoardList] = None) -> None:
        for other in snap.lists:
            if other is not keep and other.name.casefold() == name.casefold():
                raise Invalid(f"this board already has a list called {other.name!r}")

    @staticmethod
    def _list_view(x: BoardList) -> dict:
        return {"id": x.id, "board_id": x.board_id, "name": x.name, "is_done": x.is_done}

    def add_list(self, person: Person, board: str, name: str, is_done: bool = False) -> dict:
        snap = self._editable_snapshot(person, board)
        name = clean_text(name, "list name", MAX_NAME)
        self._require_unique_name(snap, name)
        position = position_between(snap.lists[-1].position if snap.lists else None, None)
        created = BoardList(_new_id(), snap.board.id, name, position, bool(is_done))
        self.repo.save_list(created)
        self._record(snap.board, None, person, f"added list {name!r}")
        return self._list_view(created)

    def update_list(self, person: Person, board: str, list_name: str,
                    name: Optional[str] = None, is_done: Optional[bool] = None) -> dict:
        """Rename a list, or say whether cards on it count as finished."""
        snap = self._editable_snapshot(person, board)
        target = self._find_list(snap, list_name)
        actions = []
        if name is not None:
            name = clean_text(name, "list name", MAX_NAME)
            self._require_unique_name(snap, name, keep=target)
            if name != target.name:
                actions.append(f"renamed list {target.name!r} to {name!r}")
                target.name = name
        if is_done is not None and bool(is_done) != target.is_done:
            target.is_done = bool(is_done)
            kind = "finished work" if target.is_done else "open work"
            actions.append(f"marked list {target.name!r} as {kind}")
        if actions:
            self.repo.save_list(target)
        for action in actions:
            self._record(snap.board, None, person, action)
        return self._list_view(target)

    def move_list(self, person: Person, board: str, list_name: str,
                  before: Optional[str] = None) -> dict:
        """Put a list just before another one, or last if no other is named."""
        snap = self._editable_snapshot(person, board)
        target = self._find_list(snap, list_name)
        others = [x for x in snap.lists if x.id != target.id]
        place = len(others)
        if before:
            anchor = self._find_list(snap, before)
            if anchor.id == target.id:
                return self._list_view(target)
            place = next(i for i, x in enumerate(others) if x.id == anchor.id)
        target.position = position_between(others[place - 1].position if place > 0 else None,
                                           others[place].position if place < len(others) else None)
        self.repo.save_list(target)
        self._record(snap.board, None, person, f"moved list {target.name!r}")
        return self._list_view(target)

    def archive_list(self, person: Person, board: str, list_name: str) -> dict:
        """Lists leave the board only once they're empty, so no card is ever stranded."""
        snap = self._editable_snapshot(person, board)
        target = self._find_list(snap, list_name)
        waiting = sum(c.list_id == target.id for c in snap.cards)
        if waiting:
            raise Invalid(f"{target.name!r} still has {waiting} card(s); move them first")
        if len(snap.lists) == 1:
            raise Invalid("a board needs at least one list")
        target.archived = True
        self.repo.save_list(target)
        self._record(snap.board, None, person, f"archived list {target.name!r}")
        return {"id": target.id, "archived": True}

    # ---- cards ---------------------------------------------------------------------------------

    def add_card(self, person: Person, board: str, title: str, list_name: Optional[str] = None,
                 description: str = "", assignee: Optional[str] = None,
                 due_date: Optional[str] = None, labels: Optional[list] = None) -> dict:
        snap = self._editable_snapshot(person, board)
        if not snap.lists:
            raise Invalid("this board has no lists yet")
        target = self._find_list(snap, list_name) if list_name else snap.lists[0]
        in_list = [c.position for c in snap.cards if c.list_id == target.id]
        card = Card(id="", board_id=snap.board.id, number=0, title="", list_id=target.id,
                    position=position_between(max(in_list, default=None), None))
        card.edit({"title": title, "description": description, "due_date": due_date,
                   "labels": labels, "assignee": snap.board.find_assignee(assignee, person)})
        self._claim_number(card)
        self._record(snap.board, card, person, f"added the card to {target.name}",
                     Change("created", "", card.title))
        return self._shown(snap.board, card)

    def _claim_number(self, card: Card) -> None:
        """No sequences in DataK3: the primary key contains the number, so of two people adding a
        card at the same moment one wins it and the other takes the next."""
        for _ in range(20):
            card.number = self.repo.last_card_number(card.board_id) + 1
            card.id = f"{card.board_id}-{card.number}"
            if self.repo.insert_card(card):
                return
        raise Invalid("the board is busy; try that again")

    def show_card(self, person: Person, card: str, board: Optional[str] = None) -> dict:
        """A card with everything about it: its details, what it holds up, and its history."""
        found, target = self._find_card(card, person, board)
        snap = self._snapshot(found)
        return {**snap.show(target),
                "blocking": [{"number": c.number, "title": c.title}
                             for c in snap.cards if target.number in c.blocked_by],
                "history": self.card_history(person, target.id)}

    def card_history(self, person: Person, card: str, board: Optional[str] = None) -> list:
        _, target = self._find_card(card, person, board)
        return [_event_view(e) for e in self.repo.history(target.id)]

    def board_activity(self, person: Person, board: str, limit: int = 50) -> list:
        found = self._board(board, person)
        return [_event_view(e) for e in self.repo.activity(found.id, max(1, min(limit, 200)))]

    def update_card(self, person: Person, card: str, changes: dict,
                    board: Optional[str] = None) -> dict:
        """Change any of title, description, assignee, due_date, labels.
        An empty assignee or due date clears it."""
        found, target = self._find_card(card, person, board)
        found.require_edit(person)
        changes = dict(changes)
        if "assignee" in changes:
            changes["assignee"] = found.find_assignee(changes["assignee"], person)
        self._save(found, target, person, target.edit(changes))
        return self._shown(found, target)

    def assign_card(self, person: Person, card: str, assignee: Optional[str],
                    board: Optional[str] = None) -> dict:
        return self.update_card(person, card, {"assignee": assignee}, board)

    def move_card(self, person: Person, card: str, list_name: str, board: Optional[str] = None,
                  before: Optional[str] = None) -> dict:
        """Move a card to a list, just above the card named in `before`, or else to the bottom."""
        found, target = self._find_card(card, person, board)
        found.require_edit(person)
        snap = self._snapshot(found)
        destination = self._find_list(snap, list_name)
        lane = [c for c in snap.cards if c.list_id == destination.id]
        order = [c for c in lane if c.id != target.id]
        place = len(order)
        if before:
            _, anchor = self._find_card(before, person, found.id)
            if anchor.list_id != destination.id:
                raise Invalid("`before` must name a card on the list you are moving to")
            place = next((i for i, c in enumerate(order) if c.id == anchor.id), place)
        if len(lane) > len(order) and lane[place].id == target.id:
            return snap.show(target)  # already there: no change, so no history
        position = position_between(order[place - 1].position if place > 0 else None,
                                    order[place].position if place < len(order) else None)
        origin = snap.list_by_id.get(target.list_id)
        changes = target.move_to(destination, position, origin.name if origin else "?")
        self._save(found, target, person, changes)
        return self._shown(found, target)

    def archive_card(self, person: Person, card: str, board: Optional[str] = None) -> dict:
        """Take a card off the board. Its number and history stay, so nothing is ever reused."""
        found, target = self._find_card(card, person, board)
        found.require_edit(person)
        self._save(found, target, person, target.archive())
        return {"id": target.id, "number": target.number, "archived": True}

    # ---- the questions people ask --------------------------------------------------------------

    def my_cards(self, person: Person, days: int = 7) -> dict:
        """What's on your plate: your unfinished cards, split by when they're due."""
        days = max(0, min(int(days), 366))
        today = self.now().date()
        last = (today + timedelta(days=days)).isoformat()
        overdue, soon, later, undated = [], [], [], []
        for board in self.repo.boards_for(person.email):
            snap = self._snapshot(board)
            for card in snap.cards:
                if card.assignee != person.email or snap.finished(card):
                    continue
                view = snap.show(card)
                if view["overdue"]:
                    overdue.append(view)
                elif view["due_date"] is None:
                    undated.append(view)
                elif view["due_date"] <= last:
                    soon.append(view)
                else:
                    later.append(view)
        return {"today": today.isoformat(), "through": last,
                "overdue": _by_due_date(overdue), "due_soon": _by_due_date(soon),
                "no_due_date": _by_due_date(undated), "due_later": _by_due_date(later)}

    def overdue_cards(self, person: Person, board: Optional[str] = None) -> dict:
        """Every overdue card on one board, or on all the boards you're on."""
        boards = ([self._board(board, person)] if board
                  else self.repo.boards_for(person.email))
        today = self.now().date()
        cards = []
        for found in boards:
            snap = self._snapshot(found)
            cards.extend(snap.show(c) for c in snap.overdue())
        return {"today": today.isoformat(), "count": len(cards), "cards": _by_due_date(cards)}

    # ---- blocked by ----------------------------------------------------------------------------

    def _blocker_change(self, found: Board, target: Card, person: Person, numbers: list) -> None:
        old, target.blocked_by = target.blocked_by, numbers
        change = Change("blocked_by", _numbered(old), _numbered(numbers))
        self._save(found, target, person, [change])

    def add_blocker(self, person: Person, card: str, blocked_by: str,
                    board: Optional[str] = None) -> dict:
        """Say that `card` can't move until `blocked_by` is finished."""
        found, target = self._find_card(card, person, board)
        found.require_edit(person)
        _, blocker = self._find_card(blocked_by, person, found.id)
        snap = self._snapshot(found)
        if blocker.number in target.blocked_by:
            return snap.show(target)
        if would_cycle(target, blocker, snap.by_number):
            raise Invalid(f"#{blocker.number} already waits on #{target.number}, directly or "
                          "through other cards; a card can't wait on itself")
        self._blocker_change(found, target, person, sorted({*target.blocked_by, blocker.number}))
        return self._shown(found, target)

    def remove_blocker(self, person: Person, card: str, blocked_by: str,
                       board: Optional[str] = None) -> dict:
        found, target = self._find_card(card, person, board)
        found.require_edit(person)
        _, blocker = self._find_card(blocked_by, person, found.id)
        if blocker.number in target.blocked_by:
            self._blocker_change(found, target, person,
                                 [n for n in target.blocked_by if n != blocker.number])
        return self._shown(found, target)

    def what_is_blocking(self, person: Person, card: str, board: Optional[str] = None) -> dict:
        """What stands in a card's way, including what its blockers are themselves waiting on."""
        found, target = self._find_card(card, person, board)
        snap = self._snapshot(found)
        chain = [{**snap.show(c), "steps_away": depth}
                 for c, depth in blocker_chain(target, snap.by_number) if not snap.finished(c)]
        return {"card": snap.show(target), "blocked": bool(snap.open_blockers(target)),
                "open_blockers": chain,
                "holding_up": [{"number": c.number, "title": c.title}
                               for c in snap.cards if target.number in c.blocked_by]}

    # ---- checklist -----------------------------------------------------------------------------

    def suggest_checklist(self, person: Person, card: str, board: Optional[str] = None) -> dict:
        """An AI suggestion only: it changes nothing. A person decides what to keep, then saves
        it with save_checklist. Only people who could save it can ask for it."""
        found, target = self._find_card(card, person, board)
        found.require_edit(person)
        if self.assistant is None:
            raise AssistantUnavailable("AI isn't set up for this board")
        return {"card_id": target.id, "number": target.number,
                "suggested_checklist": self.assistant.checklist(target.title, target.description)}

    def save_checklist(self, person: Person, card: str, items: list,
                       board: Optional[str] = None) -> dict:
        found, target = self._find_card(card, person, board)
        found.require_edit(person)
        self._save(found, target, person, target.set_checklist(items))
        return self._shown(found, target)

    def tick_checklist_item(self, person: Person, card: str, item: str, done: bool = True,
                            board: Optional[str] = None) -> dict:
        found, target = self._find_card(card, person, board)
        found.require_edit(person)
        self._save(found, target, person, target.tick(item, done))
        return self._shown(found, target)

    # ---- AI reads ------------------------------------------------------------------------------

    def _vectors(self, board: Board, cards: list) -> dict:
        """Embeddings for these cards. Only new or changed text goes to Dodil Models."""
        if self.assistant is None:
            raise AssistantUnavailable("AI isn't set up for this board")
        stored = self.repo.embeddings(board.id)
        hashes = {c.id: text_hash(card_text(c)) for c in cards}
        stale = [c for c in cards if stored.get(c.id, (None,))[0] != hashes[c.id]]
        if stale:
            vectors = self.assistant.embed([card_text(c) for c in stale])
            if len(vectors) != len(stale):
                raise AssistantUnavailable("Dodil Models returned the wrong number of embeddings")
            for card, vector in zip(stale, vectors):
                self.repo.save_embedding(board.id, card.id, hashes[card.id], vector)
                stored[card.id] = (hashes[card.id], vector)
        return {c.id: stored[c.id][1] for c in cards}

    def find_similar(self, person: Person, card: str, board: Optional[str] = None,
                     limit: int = 5) -> dict:
        """Cards like this one on the same board: by meaning, or by shared words if AI is down."""
        found, target = self._find_card(card, person, board)
        snap = self._snapshot(found)
        others = [c for c in snap.cards if c.id != target.id]
        try:
            vectors = self._vectors(found, [target, *others]) if others else {}
            scored = [(cosine(vectors[target.id], vectors[c.id]), c) for c in others]
            method = "meaning"
        except AssistantUnavailable:
            words = card_text(target)
            scored = [(keyword_score(words, card_text(c)), c) for c in others]
            method = "keywords"
        scored = sorted((p for p in scored if p[0] > 0), key=lambda p: (-p[0], p[1].number))
        return {"card": target.number, "method": method,
                "matches": [{"id": c.id, "number": c.number, "title": c.title,
                             "list": snap.show(c)["list"],
                             "score": round(s, 3)}
                            for s, c in scored[:max(1, min(limit, 20))]]}

    def _facts(self, snap: Snapshot) -> dict:
        since = self.now() - timedelta(days=1)
        recent = [e for e in self.repo.activity(snap.board.id, 200) if e.at >= since]
        soon = snap.today + timedelta(days=2)
        open_cards = [c for c in snap.cards if not snap.finished(c)]
        return {
            "lists": {x.name: sum(c.list_id == x.id for c in snap.cards) for x in snap.lists},
            "overdue": [f"#{c.number} {c.title}" for c in snap.overdue()],
            "due_soon": [f"#{c.number} {c.title}" for c in open_cards
                         if c.due_date and snap.today <= c.due_date <= soon],
            "blocked": [f"#{c.number} {c.title}" for c in open_cards if snap.open_blockers(c)],
            "unassigned": sum(c.assignee is None for c in open_cards),
            "changes_last_24h": len(recent),
            "recent": [f"{e.actor} {e.action}" for e in recent[:12]],
        }

    def board_summary(self, person: Person, board: str) -> dict:
        """Today's short summary. The facts come from the board; AI only words them, and if AI
        is down the facts are the summary."""
        found = self._board(board, person)
        snap = self._snapshot(found)
        facts = self._facts(snap)
        plain = _facts_text(found.title, snap.today, facts)
        words, source = plain, "facts"
        if self.assistant is not None:
            try:
                written = self.assistant.summary(plain).strip()
                if written:
                    words, source = written, "ai"
            except AssistantUnavailable:
                pass
        return {"board": found.title, "date": snap.today.isoformat(), "summary": words,
                "source": source, "facts": facts}


def _facts_text(title: str, today: date, facts: dict) -> str:
    counts = ", ".join(f"{name}: {n}" for name, n in facts["lists"].items())
    lines = [f"Board {title!r} on {today.isoformat()}.", f"Cards by list: {counts}.",
             f"{facts['changes_last_24h']} change(s) in the last 24 hours."]
    for label, name in (("Overdue", "overdue"), ("Due in the next two days", "due_soon"),
                        ("Blocked", "blocked")):
        if facts[name]:
            lines.append(f"{label}: {'; '.join(facts[name])}.")
    if facts["unassigned"]:
        lines.append(f"{facts['unassigned']} open card(s) have nobody assigned.")
    return "\n".join(lines)