Ctrl K
ComunidadGuías…

backend/songbird/seed/sermon_notes.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Pure transform for the one-time sermon-notes seed import (Slice 14).

Turns soap-journal backup entries into ``SermonNoteRow`` values ready to insert into the
``sermon_notes`` table. Deliberately copyright-free and Scripture-text-free: ``SermonNoteRow`` has
**no text field**, so the backup's ``scripture_text`` (copyrighted ESV/NKJV) is never carried into
a row (CLAUDE.md invariant 5). The book map is derived from Concord's ``canonical_order`` — never
hand-typed (invariant 4 lives or dies on this map). All I/O (reading the backup, fetching Concord,
writing the DB) lives in the CLI loader; this module is pure and unit-tested.
"""

from __future__ import annotations

import re
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from datetime import date
from typing import Any

_URL_RE = re.compile(r"https?://\S+")


def normalize_tags(names: Sequence[str]) -> list[str]:
    """Trim + lowercase + de-dupe (order-preserving) — the SAME normalization annotations use
    (mirrors ``songbird.api.annotations._normalize_tags``), so the seed reuses the existing tag
    vocabulary rather than creating a parallel one."""
    return list(dict.fromkeys(n.strip().lower() for n in names if n.strip()))


# Known canonical_order → USFM anchors; the book map must agree or it's wrong (wrong map lands
# notes on the wrong book silently).
_MAP_TRIPWIRE = {1: "GEN", 44: "ACT", 66: "REV"}


class SeedError(Exception):
    """A fatal problem in the seed source/transform — abort before writing any row."""


@dataclass(frozen=True)
class SermonNoteRow:
    """One ``sermon_notes`` row to insert. NO Scripture text by design."""

    title: str
    sermon_url: str
    reference: str  # the entry's scripture_ref verbatim — the full true span
    book_usfm: str
    book_order_index: int
    start_chapter: int
    start_verse: int
    end_chapter: int
    end_verse: int
    event_date: date | None
    tags: list[str] = field(default_factory=list)


def build_book_map(books: Sequence[Mapping[str, Any]]) -> dict[int, str]:
    """Concord ``/v1/books`` → ``{canonical_order: usfm_id}``. Raises if the known anchors disagree
    (a sanity tripwire that a swapped/garbled book list can't slip past)."""
    book_map: dict[int, str] = {int(b["canonical_order"]): str(b["id"]) for b in books}
    for order, usfm in _MAP_TRIPWIRE.items():
        if book_map.get(order) != usfm:
            raise SeedError(
                f"book-map tripwire failed: canonical_order {order} -> {book_map.get(order)!r}, "
                f"expected {usfm!r}. Refusing to seed against a suspect book map."
            )
    return book_map


def missing_book_indices(
    entries: Sequence[Mapping[str, Any]], book_map: Mapping[int, str]
) -> list[int]:
    """Every distinct ``book_order_index`` across ALL verses that the map can't resolve. The loader
    aborts (listing these) before writing a single row — never anchor a note to an unknown book."""
    seen: set[int] = set()
    for entry in entries:
        verses: Sequence[Mapping[str, Any]] = entry.get("verses") or []
        for verse in verses:
            seen.add(int(verse["book_order_index"]))
    return sorted(i for i in seen if i not in book_map)


def count_urls(observation: str | None) -> int:
    """How many ``http(s)`` URLs the observation text contains (for the loader's bad-URL report)."""
    return len(_URL_RE.findall(observation or ""))


def extract_url(observation: str | None) -> str:
    """The sermon URL is embedded in ``observation`` (often ``"<title>\\n<url>"``). Require exactly
    one ``http(s)`` URL; 0 or >1 is a data problem the loader surfaces rather than guessing."""
    urls = _URL_RE.findall(observation or "")
    if len(urls) != 1:
        raise SeedError(f"expected exactly 1 URL in observation, found {len(urls)}")
    return urls[0]


def parse_event_date(value: str | None) -> date | None:
    """Parse ``YYYY-MM-DD`` to a ``date``; return ``None`` for missing/unparseable (never crash)."""
    if not value:
        return None
    try:
        return date.fromisoformat(str(value)[:10])
    except ValueError:
        return None


def split_runs(verses: Sequence[Mapping[str, Any]]) -> list[list[tuple[int, int, int]]]:
    """Split canonical ``verses[]`` into maximal runs of consecutive verses WITHIN A SINGLE
    CHAPTER. A run breaks at a verse gap, any chapter change, or any book change — so every run is
    single-book, single-chapter (needs no Concord verse-counts and cannot over-mark)."""
    tuples = sorted(
        (int(v["book_order_index"]), int(v["chapter"]), int(v["verse"])) for v in verses
    )
    runs: list[list[tuple[int, int, int]]] = []
    for t in tuples:
        if runs:
            pb, pc, pv = runs[-1][-1]
            if t[0] == pb and t[1] == pc and t[2] == pv + 1:
                runs[-1].append(t)
                continue
        runs.append([t])
    return runs


def transform_entry(entry: Mapping[str, Any], book_map: Mapping[int, str]) -> list[SermonNoteRow]:
    """One source entry → one ``SermonNoteRow`` per per-chapter run. All rows share the entry's
    title / sermon_url / event_date / tags, and the verbatim ``scripture_ref`` as ``reference``
    (so the popover shows the full true span even when an entry is split or multi-book)."""
    title = str(entry.get("title") or "").strip()
    sermon_url = extract_url(entry.get("observation"))
    reference = str(entry.get("scripture_ref") or "").strip()
    event_date = parse_event_date(entry.get("entry_date"))
    tags = normalize_tags(list(entry.get("tags") or []))

    rows: list[SermonNoteRow] = []
    for run in split_runs(entry.get("verses") or []):
        book_order_index = run[0][0]
        usfm = book_map.get(book_order_index)
        if usfm is None:
            raise SeedError(f"unmapped book_order_index {book_order_index} in {reference!r}")
        rows.append(
            SermonNoteRow(
                title=title,
                sermon_url=sermon_url,
                reference=reference,
                book_usfm=usfm,
                book_order_index=book_order_index,
                start_chapter=run[0][1],
                start_verse=run[0][2],
                end_chapter=run[-1][1],
                end_verse=run[-1][2],
                event_date=event_date,
                tags=tags,
            )
        )
    return rows


def find_true_duplicate_entries(
    entries: Sequence[Mapping[str, Any]],
) -> list[tuple[str, str]]:
    """Likely double-imports: (title, extracted-url) pairs that appear more than once. Reported for
    a human eyeball — NEVER auto-deleted (same passage by different preachers is legitimately
    distinct, and the chooser handles verse overlap)."""
    counts: dict[tuple[str, str], int] = {}
    for entry in entries:
        try:
            url = extract_url(entry.get("observation"))
        except SeedError:
            url = ""
        key = (str(entry.get("title") or "").strip(), url)
        counts[key] = counts.get(key, 0) + 1
    return [k for k, c in counts.items() if c > 1]