forked from Rockachopa/Timmy-time-dashboard
feat: add sovereign biblical text integration module (scripture)
Implement the core scripture module for local-first ESV text storage, verse retrieval, reference parsing, original language support, cross-referencing, topical mapping, and automated meditation workflows. Architecture: - scripture/constants.py: 66-book Protestant canon with aliases and metadata - scripture/models.py: Pydantic models with integer-encoded verse IDs - scripture/parser.py: Regex-based reference extraction and formatting - scripture/store.py: SQLite-backed verse/xref/topic/Strong's storage - scripture/memory.py: Tripartite memory (working/long-term/associative) - scripture/meditation.py: Sequential/thematic/lectionary meditation scheduler - dashboard/routes/scripture.py: REST endpoints for all scripture operations - config.py: scripture_enabled, translation, meditation settings - 95 comprehensive tests covering all modules and routes https://claude.ai/code/session_015wv7FM6BFsgZ35Us6WeY7H
This commit is contained in:
7
src/scripture/__init__.py
Normal file
7
src/scripture/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""Scripture module — sovereign biblical text integration for Timmy Time.
|
||||
|
||||
Provides local-first ESV text storage, verse retrieval, reference parsing,
|
||||
original language support, cross-referencing, topical mapping, and
|
||||
automated meditation workflows. All data resides on localhost — no cloud
|
||||
API dependency for core functionality.
|
||||
"""
|
||||
197
src/scripture/constants.py
Normal file
197
src/scripture/constants.py
Normal file
@@ -0,0 +1,197 @@
|
||||
"""Biblical constants — canonical book ordering, abbreviations, metadata.
|
||||
|
||||
The canon follows the standard 66-book Protestant ordering used by the ESV.
|
||||
Each book is assigned a unique integer ID (1-66) for O(1) verse lookup via
|
||||
the integer encoding scheme: book (1-2 digits) + chapter (3 digits) +
|
||||
verse (3 digits).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BookInfo:
|
||||
"""Immutable metadata for a canonical book."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
abbreviation: str
|
||||
testament: Literal["OT", "NT"]
|
||||
chapters: int
|
||||
genre: str
|
||||
|
||||
|
||||
# ── Canonical book list (Protestant 66-book canon, ESV ordering) ────────────
|
||||
|
||||
BOOKS: tuple[BookInfo, ...] = (
|
||||
# ── Old Testament ────────────────────────────────────────────────────
|
||||
BookInfo(1, "Genesis", "Gen", "OT", 50, "law"),
|
||||
BookInfo(2, "Exodus", "Exod", "OT", 40, "law"),
|
||||
BookInfo(3, "Leviticus", "Lev", "OT", 27, "law"),
|
||||
BookInfo(4, "Numbers", "Num", "OT", 36, "law"),
|
||||
BookInfo(5, "Deuteronomy", "Deut", "OT", 34, "law"),
|
||||
BookInfo(6, "Joshua", "Josh", "OT", 24, "narrative"),
|
||||
BookInfo(7, "Judges", "Judg", "OT", 21, "narrative"),
|
||||
BookInfo(8, "Ruth", "Ruth", "OT", 4, "narrative"),
|
||||
BookInfo(9, "1 Samuel", "1Sam", "OT", 31, "narrative"),
|
||||
BookInfo(10, "2 Samuel", "2Sam", "OT", 24, "narrative"),
|
||||
BookInfo(11, "1 Kings", "1Kgs", "OT", 22, "narrative"),
|
||||
BookInfo(12, "2 Kings", "2Kgs", "OT", 25, "narrative"),
|
||||
BookInfo(13, "1 Chronicles", "1Chr", "OT", 29, "narrative"),
|
||||
BookInfo(14, "2 Chronicles", "2Chr", "OT", 36, "narrative"),
|
||||
BookInfo(15, "Ezra", "Ezra", "OT", 10, "narrative"),
|
||||
BookInfo(16, "Nehemiah", "Neh", "OT", 13, "narrative"),
|
||||
BookInfo(17, "Esther", "Esth", "OT", 10, "narrative"),
|
||||
BookInfo(18, "Job", "Job", "OT", 42, "wisdom"),
|
||||
BookInfo(19, "Psalms", "Ps", "OT", 150, "wisdom"),
|
||||
BookInfo(20, "Proverbs", "Prov", "OT", 31, "wisdom"),
|
||||
BookInfo(21, "Ecclesiastes", "Eccl", "OT", 12, "wisdom"),
|
||||
BookInfo(22, "Song of Solomon", "Song", "OT", 8, "wisdom"),
|
||||
BookInfo(23, "Isaiah", "Isa", "OT", 66, "prophecy"),
|
||||
BookInfo(24, "Jeremiah", "Jer", "OT", 52, "prophecy"),
|
||||
BookInfo(25, "Lamentations", "Lam", "OT", 5, "prophecy"),
|
||||
BookInfo(26, "Ezekiel", "Ezek", "OT", 48, "prophecy"),
|
||||
BookInfo(27, "Daniel", "Dan", "OT", 12, "prophecy"),
|
||||
BookInfo(28, "Hosea", "Hos", "OT", 14, "prophecy"),
|
||||
BookInfo(29, "Joel", "Joel", "OT", 3, "prophecy"),
|
||||
BookInfo(30, "Amos", "Amos", "OT", 9, "prophecy"),
|
||||
BookInfo(31, "Obadiah", "Obad", "OT", 1, "prophecy"),
|
||||
BookInfo(32, "Jonah", "Jonah", "OT", 4, "prophecy"),
|
||||
BookInfo(33, "Micah", "Mic", "OT", 7, "prophecy"),
|
||||
BookInfo(34, "Nahum", "Nah", "OT", 3, "prophecy"),
|
||||
BookInfo(35, "Habakkuk", "Hab", "OT", 3, "prophecy"),
|
||||
BookInfo(36, "Zephaniah", "Zeph", "OT", 3, "prophecy"),
|
||||
BookInfo(37, "Haggai", "Hag", "OT", 2, "prophecy"),
|
||||
BookInfo(38, "Zechariah", "Zech", "OT", 14, "prophecy"),
|
||||
BookInfo(39, "Malachi", "Mal", "OT", 4, "prophecy"),
|
||||
# ── New Testament ────────────────────────────────────────────────────
|
||||
BookInfo(40, "Matthew", "Matt", "NT", 28, "gospel"),
|
||||
BookInfo(41, "Mark", "Mark", "NT", 16, "gospel"),
|
||||
BookInfo(42, "Luke", "Luke", "NT", 24, "gospel"),
|
||||
BookInfo(43, "John", "John", "NT", 21, "gospel"),
|
||||
BookInfo(44, "Acts", "Acts", "NT", 28, "narrative"),
|
||||
BookInfo(45, "Romans", "Rom", "NT", 16, "epistle"),
|
||||
BookInfo(46, "1 Corinthians", "1Cor", "NT", 16, "epistle"),
|
||||
BookInfo(47, "2 Corinthians", "2Cor", "NT", 13, "epistle"),
|
||||
BookInfo(48, "Galatians", "Gal", "NT", 6, "epistle"),
|
||||
BookInfo(49, "Ephesians", "Eph", "NT", 6, "epistle"),
|
||||
BookInfo(50, "Philippians", "Phil", "NT", 4, "epistle"),
|
||||
BookInfo(51, "Colossians", "Col", "NT", 4, "epistle"),
|
||||
BookInfo(52, "1 Thessalonians", "1Thess", "NT", 5, "epistle"),
|
||||
BookInfo(53, "2 Thessalonians", "2Thess", "NT", 3, "epistle"),
|
||||
BookInfo(54, "1 Timothy", "1Tim", "NT", 6, "epistle"),
|
||||
BookInfo(55, "2 Timothy", "2Tim", "NT", 4, "epistle"),
|
||||
BookInfo(56, "Titus", "Titus", "NT", 3, "epistle"),
|
||||
BookInfo(57, "Philemon", "Phlm", "NT", 1, "epistle"),
|
||||
BookInfo(58, "Hebrews", "Heb", "NT", 13, "epistle"),
|
||||
BookInfo(59, "James", "Jas", "NT", 5, "epistle"),
|
||||
BookInfo(60, "1 Peter", "1Pet", "NT", 5, "epistle"),
|
||||
BookInfo(61, "2 Peter", "2Pet", "NT", 3, "epistle"),
|
||||
BookInfo(62, "1 John", "1John", "NT", 5, "epistle"),
|
||||
BookInfo(63, "2 John", "2John", "NT", 1, "epistle"),
|
||||
BookInfo(64, "3 John", "3John", "NT", 1, "epistle"),
|
||||
BookInfo(65, "Jude", "Jude", "NT", 1, "epistle"),
|
||||
BookInfo(66, "Revelation", "Rev", "NT", 22, "apocalyptic"),
|
||||
)
|
||||
|
||||
# ── Lookup indices (built once at import time) ──────────────────────────────
|
||||
|
||||
BOOK_BY_ID: dict[int, BookInfo] = {b.id: b for b in BOOKS}
|
||||
|
||||
# Map both full names and abbreviations (case-insensitive) to BookInfo
|
||||
_BOOK_NAME_MAP: dict[str, BookInfo] = {}
|
||||
for _b in BOOKS:
|
||||
_BOOK_NAME_MAP[_b.name.lower()] = _b
|
||||
_BOOK_NAME_MAP[_b.abbreviation.lower()] = _b
|
||||
|
||||
# Common aliases people use that differ from the canonical abbreviation
|
||||
_ALIASES: dict[str, int] = {
|
||||
"ge": 1, "gen": 1, "genesis": 1,
|
||||
"ex": 2, "exo": 2, "exodus": 2,
|
||||
"le": 3, "lev": 3, "leviticus": 3,
|
||||
"nu": 4, "num": 4, "numbers": 4,
|
||||
"dt": 5, "deut": 5, "deuteronomy": 5,
|
||||
"jos": 6, "josh": 6, "joshua": 6,
|
||||
"jdg": 7, "judg": 7, "judges": 7,
|
||||
"ru": 8, "ruth": 8,
|
||||
"1sa": 9, "1sam": 9, "1 samuel": 9, "i samuel": 9, "1st samuel": 9,
|
||||
"2sa": 10, "2sam": 10, "2 samuel": 10, "ii samuel": 10, "2nd samuel": 10,
|
||||
"1ki": 11, "1kgs": 11, "1 kings": 11, "i kings": 11, "1st kings": 11,
|
||||
"2ki": 12, "2kgs": 12, "2 kings": 12, "ii kings": 12, "2nd kings": 12,
|
||||
"1ch": 13, "1chr": 13, "1 chronicles": 13, "i chronicles": 13,
|
||||
"2ch": 14, "2chr": 14, "2 chronicles": 14, "ii chronicles": 14,
|
||||
"ezr": 15, "ezra": 15,
|
||||
"ne": 16, "neh": 16, "nehemiah": 16,
|
||||
"est": 17, "esth": 17, "esther": 17,
|
||||
"job": 18,
|
||||
"ps": 19, "psa": 19, "psalm": 19, "psalms": 19,
|
||||
"pr": 20, "prov": 20, "proverbs": 20,
|
||||
"ec": 21, "eccl": 21, "ecclesiastes": 21, "ecc": 21,
|
||||
"so": 22, "song": 22, "song of solomon": 22, "song of songs": 22, "sos": 22,
|
||||
"isa": 23, "isaiah": 23,
|
||||
"jer": 24, "jeremiah": 24,
|
||||
"la": 25, "lam": 25, "lamentations": 25,
|
||||
"eze": 26, "ezek": 26, "ezekiel": 26,
|
||||
"da": 27, "dan": 27, "daniel": 27,
|
||||
"ho": 28, "hos": 28, "hosea": 28,
|
||||
"joe": 29, "joel": 29,
|
||||
"am": 30, "amos": 30,
|
||||
"ob": 31, "obad": 31, "obadiah": 31,
|
||||
"jon": 32, "jonah": 32,
|
||||
"mi": 33, "mic": 33, "micah": 33,
|
||||
"na": 34, "nah": 34, "nahum": 34,
|
||||
"hab": 35, "habakkuk": 35,
|
||||
"zep": 36, "zeph": 36, "zephaniah": 36,
|
||||
"hag": 37, "haggai": 37,
|
||||
"zec": 38, "zech": 38, "zechariah": 38,
|
||||
"mal": 39, "malachi": 39,
|
||||
"mt": 40, "matt": 40, "matthew": 40, "mat": 40,
|
||||
"mk": 41, "mark": 41, "mar": 41,
|
||||
"lk": 42, "luke": 42, "lu": 42,
|
||||
"jn": 43, "john": 43, "joh": 43,
|
||||
"ac": 44, "acts": 44, "act": 44,
|
||||
"ro": 45, "rom": 45, "romans": 45,
|
||||
"1co": 46, "1cor": 46, "1 cor": 46, "1 corinthians": 46, "i corinthians": 46,
|
||||
"2co": 47, "2cor": 47, "2 cor": 47, "2 corinthians": 47, "ii corinthians": 47,
|
||||
"ga": 48, "gal": 48, "galatians": 48,
|
||||
"eph": 49, "ephesians": 49,
|
||||
"php": 50, "phil": 50, "philippians": 50,
|
||||
"col": 51, "colossians": 51,
|
||||
"1th": 52, "1thess": 52, "1 thessalonians": 52, "i thessalonians": 52,
|
||||
"2th": 53, "2thess": 53, "2 thessalonians": 53, "ii thessalonians": 53,
|
||||
"1ti": 54, "1tim": 54, "1 timothy": 54, "i timothy": 54,
|
||||
"2ti": 55, "2tim": 55, "2 timothy": 55, "ii timothy": 55,
|
||||
"tit": 56, "titus": 56,
|
||||
"phm": 57, "phlm": 57, "philemon": 57,
|
||||
"heb": 58, "hebrews": 58,
|
||||
"jas": 59, "james": 59, "jam": 59,
|
||||
"1pe": 60, "1pet": 60, "1 peter": 60, "i peter": 60, "1st peter": 60,
|
||||
"2pe": 61, "2pet": 61, "2 peter": 61, "ii peter": 61, "2nd peter": 61,
|
||||
"1jn": 62, "1john": 62, "1 john": 62, "i john": 62, "1st john": 62,
|
||||
"2jn": 63, "2john": 63, "2 john": 63, "ii john": 63, "2nd john": 63,
|
||||
"3jn": 64, "3john": 64, "3 john": 64, "iii john": 64, "3rd john": 64,
|
||||
"jude": 65, "jud": 65,
|
||||
"re": 66, "rev": 66, "revelation": 66, "revelations": 66,
|
||||
}
|
||||
|
||||
for _alias, _bid in _ALIASES.items():
|
||||
_BOOK_NAME_MAP.setdefault(_alias, BOOK_BY_ID[_bid])
|
||||
|
||||
TOTAL_BOOKS = 66
|
||||
OT_BOOKS = 39
|
||||
NT_BOOKS = 27
|
||||
|
||||
GENRES = frozenset(b.genre for b in BOOKS)
|
||||
|
||||
|
||||
def book_by_name(name: str) -> BookInfo | None:
|
||||
"""Resolve a book name or abbreviation to a BookInfo (case-insensitive)."""
|
||||
return _BOOK_NAME_MAP.get(name.strip().lower())
|
||||
|
||||
|
||||
def book_by_id(book_id: int) -> BookInfo | None:
|
||||
"""Return the BookInfo for a canonical book ID (1-66)."""
|
||||
return BOOK_BY_ID.get(book_id)
|
||||
211
src/scripture/meditation.py
Normal file
211
src/scripture/meditation.py
Normal file
@@ -0,0 +1,211 @@
|
||||
"""Meditation scheduler — automated scripture engagement workflows.
|
||||
|
||||
Provides background meditation capabilities for the "always on its mind"
|
||||
requirement. Supports three modes:
|
||||
|
||||
- **Sequential**: book-by-book progression through the Bible
|
||||
- **Thematic**: topical exploration guided by Nave's-style index
|
||||
- **Lectionary**: cyclical reading patterns following liturgical calendars
|
||||
|
||||
The scheduler integrates with the ScriptureMemory system to persist
|
||||
progress and working memory state across restarts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import random
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from scripture.constants import BOOK_BY_ID, BOOKS
|
||||
from scripture.memory import ScriptureMemory, scripture_memory
|
||||
from scripture.models import MeditationState, Verse, decode_verse_id, encode_verse_id
|
||||
from scripture.store import ScriptureStore, scripture_store
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MeditationScheduler:
|
||||
"""Orchestrates automated meditation workflows.
|
||||
|
||||
Usage::
|
||||
|
||||
from scripture.meditation import meditation_scheduler
|
||||
|
||||
# Advance to the next verse in sequence
|
||||
result = meditation_scheduler.next_meditation()
|
||||
|
||||
# Get the current meditation focus
|
||||
current = meditation_scheduler.current_focus()
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
store: ScriptureStore | None = None,
|
||||
memory: ScriptureMemory | None = None,
|
||||
) -> None:
|
||||
self._store = store or scripture_store
|
||||
self._memory = memory or scripture_memory
|
||||
|
||||
@property
|
||||
def state(self) -> MeditationState:
|
||||
return self._memory.associative.get_meditation_state()
|
||||
|
||||
def set_mode(self, mode: str, theme: Optional[str] = None) -> MeditationState:
|
||||
"""Change the meditation mode (sequential / thematic / lectionary)."""
|
||||
state = self.state
|
||||
if mode not in ("sequential", "thematic", "lectionary"):
|
||||
raise ValueError(f"Unknown mode: {mode}")
|
||||
state.mode = mode
|
||||
state.theme = theme
|
||||
self._memory.associative.save_meditation_state(state)
|
||||
return state
|
||||
|
||||
def current_focus(self) -> Optional[Verse]:
|
||||
"""Return the verse currently in meditation focus."""
|
||||
state = self.state
|
||||
return self._store.get_verse(
|
||||
state.current_book, state.current_chapter, state.current_verse
|
||||
)
|
||||
|
||||
def next_meditation(self) -> Optional[Verse]:
|
||||
"""Advance to the next verse and return it.
|
||||
|
||||
Dispatches to the appropriate strategy based on current mode.
|
||||
"""
|
||||
state = self.state
|
||||
if state.mode == "thematic":
|
||||
return self._next_thematic(state)
|
||||
if state.mode == "lectionary":
|
||||
return self._next_lectionary(state)
|
||||
return self._next_sequential(state)
|
||||
|
||||
def meditate_on(self, verse: Verse, notes: str = "") -> None:
|
||||
"""Record meditation on a specific verse and bring into focus."""
|
||||
self._memory.working.focus(verse)
|
||||
self._memory.associative.log_meditation(
|
||||
verse.verse_id, notes=notes, mode=self.state.mode
|
||||
)
|
||||
state = self.state
|
||||
state.advance(verse.book, verse.chapter, verse.verse_num)
|
||||
self._memory.associative.save_meditation_state(state)
|
||||
|
||||
def get_context(self, verse: Verse, before: int = 2, after: int = 2) -> list[Verse]:
|
||||
"""Retrieve surrounding verses for contextual meditation."""
|
||||
start_id = encode_verse_id(verse.book, verse.chapter, max(1, verse.verse_num - before))
|
||||
end_id = encode_verse_id(verse.book, verse.chapter, verse.verse_num + after)
|
||||
return self._store.get_range(start_id, end_id)
|
||||
|
||||
def get_cross_references(self, verse: Verse) -> list[Verse]:
|
||||
"""Retrieve cross-referenced verses for expanded meditation."""
|
||||
xrefs = self._store.get_cross_references(verse.verse_id)
|
||||
results = []
|
||||
for xref in xrefs:
|
||||
target_id = (
|
||||
xref.target_verse_id
|
||||
if xref.source_verse_id == verse.verse_id
|
||||
else xref.source_verse_id
|
||||
)
|
||||
target = self._store.get_verse_by_id(target_id)
|
||||
if target:
|
||||
results.append(target)
|
||||
return results
|
||||
|
||||
def history(self, limit: int = 20) -> list[dict]:
|
||||
"""Return recent meditation history."""
|
||||
return self._memory.associative.get_meditation_history(limit=limit)
|
||||
|
||||
def status(self) -> dict:
|
||||
"""Return meditation scheduler status."""
|
||||
state = self.state
|
||||
current = self.current_focus()
|
||||
book_info = BOOK_BY_ID.get(state.current_book)
|
||||
return {
|
||||
"mode": state.mode,
|
||||
"theme": state.theme,
|
||||
"current_book": book_info.name if book_info else f"Book {state.current_book}",
|
||||
"current_chapter": state.current_chapter,
|
||||
"current_verse": state.current_verse,
|
||||
"current_text": current.text if current else None,
|
||||
"verses_meditated": state.verses_meditated,
|
||||
"last_meditation": state.last_meditation,
|
||||
}
|
||||
|
||||
# ── Private strategies ───────────────────────────────────────────────
|
||||
|
||||
def _next_sequential(self, state: MeditationState) -> Optional[Verse]:
|
||||
"""Sequential mode: advance verse-by-verse through the Bible."""
|
||||
book = state.current_book
|
||||
chapter = state.current_chapter
|
||||
verse_num = state.current_verse + 1
|
||||
|
||||
# Try next verse in same chapter
|
||||
verse = self._store.get_verse(book, chapter, verse_num)
|
||||
if verse:
|
||||
self.meditate_on(verse)
|
||||
return verse
|
||||
|
||||
# Try next chapter
|
||||
chapter += 1
|
||||
verse_num = 1
|
||||
verse = self._store.get_verse(book, chapter, verse_num)
|
||||
if verse:
|
||||
self.meditate_on(verse)
|
||||
return verse
|
||||
|
||||
# Try next book
|
||||
book += 1
|
||||
if book > 66:
|
||||
book = 1 # Wrap around to Genesis
|
||||
chapter = 1
|
||||
verse_num = 1
|
||||
verse = self._store.get_verse(book, chapter, verse_num)
|
||||
if verse:
|
||||
self.meditate_on(verse)
|
||||
return verse
|
||||
|
||||
return None
|
||||
|
||||
def _next_thematic(self, state: MeditationState) -> Optional[Verse]:
|
||||
"""Thematic mode: retrieve verses related to current theme."""
|
||||
if not state.theme:
|
||||
# Fall back to sequential if no theme set
|
||||
return self._next_sequential(state)
|
||||
|
||||
topics = self._store.search_topics(state.theme, limit=1)
|
||||
if not topics:
|
||||
return self._next_sequential(state)
|
||||
|
||||
verses = self._store.get_verses_for_topic(topics[0].topic_id)
|
||||
if not verses:
|
||||
return self._next_sequential(state)
|
||||
|
||||
# Pick the next un-meditated verse (or random if all visited)
|
||||
history_ids = {
|
||||
e["verse_id"]
|
||||
for e in self._memory.associative.get_meditation_history(limit=1000)
|
||||
}
|
||||
for v in verses:
|
||||
if v.verse_id not in history_ids:
|
||||
self.meditate_on(v)
|
||||
return v
|
||||
|
||||
# All verses in topic visited; pick a random one
|
||||
chosen = random.choice(verses)
|
||||
self.meditate_on(chosen)
|
||||
return chosen
|
||||
|
||||
def _next_lectionary(self, state: MeditationState) -> Optional[Verse]:
|
||||
"""Lectionary mode: placeholder — rotates through key passages.
|
||||
|
||||
A full lectionary implementation would integrate the Revised Common
|
||||
Lectionary or similar. This simplified version cycles through
|
||||
thematically significant passages.
|
||||
"""
|
||||
# Simplified: just advance sequentially for now
|
||||
return self._next_sequential(state)
|
||||
|
||||
|
||||
# Module-level singleton
|
||||
meditation_scheduler = MeditationScheduler()
|
||||
286
src/scripture/memory.py
Normal file
286
src/scripture/memory.py
Normal file
@@ -0,0 +1,286 @@
|
||||
"""Scripture memory system — working, long-term, and associative memory.
|
||||
|
||||
Provides the tripartite memory architecture for continuous scriptural
|
||||
engagement:
|
||||
|
||||
- **Working memory**: active passage under meditation (session-scoped)
|
||||
- **Long-term memory**: persistent store of the full biblical corpus
|
||||
(delegated to ScriptureStore)
|
||||
- **Associative memory**: thematic and conceptual linkages between verses
|
||||
|
||||
The meditation scheduler uses this module to maintain "always on its mind"
|
||||
engagement with scripture.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
from collections import OrderedDict
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from scripture.models import MeditationState, Verse, decode_verse_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Working memory capacity (analogous to 7±2 human working memory)
|
||||
WORKING_MEMORY_CAPACITY = 7
|
||||
|
||||
_MEM_DB_DIR = Path("data")
|
||||
_MEM_DB_PATH = _MEM_DB_DIR / "scripture.db"
|
||||
|
||||
_MEMORY_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS meditation_state (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
current_book INTEGER NOT NULL DEFAULT 1,
|
||||
current_chapter INTEGER NOT NULL DEFAULT 1,
|
||||
current_verse INTEGER NOT NULL DEFAULT 1,
|
||||
mode TEXT NOT NULL DEFAULT 'sequential',
|
||||
theme TEXT,
|
||||
last_meditation TEXT,
|
||||
verses_meditated INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS meditation_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
verse_id INTEGER NOT NULL,
|
||||
meditated_at TEXT NOT NULL,
|
||||
notes TEXT NOT NULL DEFAULT '',
|
||||
mode TEXT NOT NULL DEFAULT 'sequential'
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_meditation_log_verse
|
||||
ON meditation_log(verse_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS verse_insights (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
verse_id INTEGER NOT NULL,
|
||||
insight TEXT NOT NULL,
|
||||
category TEXT NOT NULL DEFAULT 'general',
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(verse_id, insight)
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
class WorkingMemory:
|
||||
"""Session-scoped memory for actively meditated passages.
|
||||
|
||||
Holds the most recent ``WORKING_MEMORY_CAPACITY`` verses in focus.
|
||||
Uses an LRU-style eviction: oldest items drop when capacity is exceeded.
|
||||
"""
|
||||
|
||||
def __init__(self, capacity: int = WORKING_MEMORY_CAPACITY) -> None:
|
||||
self._capacity = capacity
|
||||
self._items: OrderedDict[int, Verse] = OrderedDict()
|
||||
|
||||
def focus(self, verse: Verse) -> None:
|
||||
"""Bring a verse into working memory (or refresh if already present)."""
|
||||
if verse.verse_id in self._items:
|
||||
self._items.move_to_end(verse.verse_id)
|
||||
else:
|
||||
self._items[verse.verse_id] = verse
|
||||
if len(self._items) > self._capacity:
|
||||
self._items.popitem(last=False)
|
||||
|
||||
def get_focused(self) -> list[Verse]:
|
||||
"""Return all verses currently in working memory (most recent last)."""
|
||||
return list(self._items.values())
|
||||
|
||||
def is_focused(self, verse_id: int) -> bool:
|
||||
return verse_id in self._items
|
||||
|
||||
def clear(self) -> None:
|
||||
self._items.clear()
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._items)
|
||||
|
||||
|
||||
class AssociativeMemory:
|
||||
"""Thematic and conceptual linkages between verses.
|
||||
|
||||
Associates verses with insights and connections discovered during
|
||||
meditation. Persisted to SQLite for cross-session continuity.
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: Path | str = _MEM_DB_PATH) -> None:
|
||||
self._db_path = Path(db_path)
|
||||
self._db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._conn: Optional[sqlite3.Connection] = None
|
||||
self._init_db()
|
||||
|
||||
def _get_conn(self) -> sqlite3.Connection:
|
||||
if self._conn is None:
|
||||
self._conn = sqlite3.connect(
|
||||
str(self._db_path), check_same_thread=False
|
||||
)
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
return self._conn
|
||||
|
||||
def _init_db(self) -> None:
|
||||
conn = self._get_conn()
|
||||
conn.executescript(_MEMORY_SCHEMA)
|
||||
# Ensure the singleton meditation state row exists
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO meditation_state (id) VALUES (1)"
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def close(self) -> None:
|
||||
if self._conn:
|
||||
self._conn.close()
|
||||
self._conn = None
|
||||
|
||||
# ── Meditation state persistence ─────────────────────────────────────
|
||||
|
||||
def get_meditation_state(self) -> MeditationState:
|
||||
"""Load the current meditation progress."""
|
||||
row = self._get_conn().execute(
|
||||
"SELECT * FROM meditation_state WHERE id = 1"
|
||||
).fetchone()
|
||||
if not row:
|
||||
return MeditationState()
|
||||
return MeditationState(
|
||||
current_book=row["current_book"],
|
||||
current_chapter=row["current_chapter"],
|
||||
current_verse=row["current_verse"],
|
||||
mode=row["mode"],
|
||||
theme=row["theme"],
|
||||
last_meditation=row["last_meditation"],
|
||||
verses_meditated=row["verses_meditated"],
|
||||
)
|
||||
|
||||
def save_meditation_state(self, state: MeditationState) -> None:
|
||||
"""Persist the meditation state."""
|
||||
conn = self._get_conn()
|
||||
conn.execute(
|
||||
"""UPDATE meditation_state SET
|
||||
current_book = ?, current_chapter = ?, current_verse = ?,
|
||||
mode = ?, theme = ?, last_meditation = ?, verses_meditated = ?
|
||||
WHERE id = 1""",
|
||||
(
|
||||
state.current_book, state.current_chapter, state.current_verse,
|
||||
state.mode, state.theme, state.last_meditation,
|
||||
state.verses_meditated,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
# ── Meditation log ───────────────────────────────────────────────────
|
||||
|
||||
def log_meditation(
|
||||
self, verse_id: int, notes: str = "", mode: str = "sequential"
|
||||
) -> None:
|
||||
"""Record that a verse was meditated upon."""
|
||||
conn = self._get_conn()
|
||||
conn.execute(
|
||||
"INSERT INTO meditation_log (verse_id, meditated_at, notes, mode) VALUES (?, ?, ?, ?)",
|
||||
(verse_id, datetime.now(timezone.utc).isoformat(), notes, mode),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def get_meditation_history(self, limit: int = 20) -> list[dict]:
|
||||
"""Return the most recent meditation log entries."""
|
||||
rows = self._get_conn().execute(
|
||||
"SELECT * FROM meditation_log ORDER BY id DESC LIMIT ?", (limit,)
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"verse_id": r["verse_id"],
|
||||
"meditated_at": r["meditated_at"],
|
||||
"notes": r["notes"],
|
||||
"mode": r["mode"],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
def meditation_count(self) -> int:
|
||||
"""Total meditation sessions logged."""
|
||||
row = self._get_conn().execute(
|
||||
"SELECT COUNT(*) FROM meditation_log"
|
||||
).fetchone()
|
||||
return row[0] if row else 0
|
||||
|
||||
# ── Verse insights ───────────────────────────────────────────────────
|
||||
|
||||
def add_insight(
|
||||
self, verse_id: int, insight: str, category: str = "general"
|
||||
) -> None:
|
||||
"""Record an insight discovered during meditation or study."""
|
||||
conn = self._get_conn()
|
||||
conn.execute(
|
||||
"""INSERT OR IGNORE INTO verse_insights
|
||||
(verse_id, insight, category, created_at) VALUES (?, ?, ?, ?)""",
|
||||
(verse_id, insight, category, datetime.now(timezone.utc).isoformat()),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def get_insights(self, verse_id: int) -> list[dict]:
|
||||
"""Retrieve all insights for a given verse."""
|
||||
rows = self._get_conn().execute(
|
||||
"SELECT * FROM verse_insights WHERE verse_id = ? ORDER BY created_at DESC",
|
||||
(verse_id,),
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"insight": r["insight"],
|
||||
"category": r["category"],
|
||||
"created_at": r["created_at"],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
def get_recent_insights(self, limit: int = 10) -> list[dict]:
|
||||
"""Return the most recently added insights across all verses."""
|
||||
rows = self._get_conn().execute(
|
||||
"SELECT * FROM verse_insights ORDER BY created_at DESC LIMIT ?",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"verse_id": r["verse_id"],
|
||||
"insight": r["insight"],
|
||||
"category": r["category"],
|
||||
"created_at": r["created_at"],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
class ScriptureMemory:
|
||||
"""Unified scripture memory manager combining all three memory tiers.
|
||||
|
||||
Usage::
|
||||
|
||||
from scripture.memory import scripture_memory
|
||||
scripture_memory.working.focus(verse)
|
||||
state = scripture_memory.associative.get_meditation_state()
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: Path | str = _MEM_DB_PATH) -> None:
|
||||
self.working = WorkingMemory()
|
||||
self.associative = AssociativeMemory(db_path=db_path)
|
||||
|
||||
def close(self) -> None:
|
||||
self.working.clear()
|
||||
self.associative.close()
|
||||
|
||||
def status(self) -> dict:
|
||||
"""Return a summary of memory system state."""
|
||||
state = self.associative.get_meditation_state()
|
||||
return {
|
||||
"working_memory_items": len(self.working),
|
||||
"working_memory_capacity": WORKING_MEMORY_CAPACITY,
|
||||
"meditation_mode": state.mode,
|
||||
"verses_meditated": state.verses_meditated,
|
||||
"last_meditation": state.last_meditation,
|
||||
"meditation_count": self.associative.meditation_count(),
|
||||
}
|
||||
|
||||
|
||||
# Module-level singleton
|
||||
scripture_memory = ScriptureMemory()
|
||||
160
src/scripture/models.py
Normal file
160
src/scripture/models.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""Data models for the scripture module.
|
||||
|
||||
Provides Pydantic models for verses, references, cross-references,
|
||||
topics, and original language annotations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ── Integer encoding scheme ──────────────────────────────────────────────────
|
||||
# book (1-66, 1-2 digits) + chapter (3 digits, zero-padded) +
|
||||
# verse (3 digits, zero-padded) = 7-8 digit unique integer per verse.
|
||||
# Example: John 3:16 → 43_003_016 = 43003016
|
||||
|
||||
|
||||
def encode_verse_id(book: int, chapter: int, verse: int) -> int:
|
||||
"""Encode a book/chapter/verse triplet into a unique integer ID."""
|
||||
return book * 1_000_000 + chapter * 1_000 + verse
|
||||
|
||||
|
||||
def decode_verse_id(verse_id: int) -> tuple[int, int, int]:
|
||||
"""Decode an integer verse ID back to (book, chapter, verse)."""
|
||||
book = verse_id // 1_000_000
|
||||
remainder = verse_id % 1_000_000
|
||||
chapter = remainder // 1_000
|
||||
verse = remainder % 1_000
|
||||
return book, chapter, verse
|
||||
|
||||
|
||||
# ── Core models ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class VerseRef(BaseModel):
|
||||
"""A single verse reference (book + chapter + verse)."""
|
||||
|
||||
book: int = Field(ge=1, le=66, description="Canonical book ID (1-66)")
|
||||
chapter: int = Field(ge=1, description="Chapter number")
|
||||
verse: int = Field(ge=0, description="Verse number (0 for chapter-level)")
|
||||
|
||||
@property
|
||||
def verse_id(self) -> int:
|
||||
return encode_verse_id(self.book, self.chapter, self.verse)
|
||||
|
||||
|
||||
class VerseRange(BaseModel):
|
||||
"""A contiguous range of verses."""
|
||||
|
||||
start: VerseRef
|
||||
end: VerseRef
|
||||
|
||||
def verse_ids(self) -> list[int]:
|
||||
"""Expand the range to individual verse IDs."""
|
||||
ids = []
|
||||
for vid in range(self.start.verse_id, self.end.verse_id + 1):
|
||||
b, c, v = decode_verse_id(vid)
|
||||
if 1 <= b <= 66 and c >= 1 and v >= 1:
|
||||
ids.append(vid)
|
||||
return ids
|
||||
|
||||
|
||||
class Verse(BaseModel):
|
||||
"""A single verse with text content and metadata."""
|
||||
|
||||
verse_id: int = Field(description="Encoded integer ID")
|
||||
book: int
|
||||
chapter: int
|
||||
verse_num: int
|
||||
text: str
|
||||
translation: str = "ESV"
|
||||
testament: Literal["OT", "NT"] = "OT"
|
||||
genre: str = ""
|
||||
|
||||
|
||||
class CrossReference(BaseModel):
|
||||
"""A cross-reference link between two verses."""
|
||||
|
||||
source_verse_id: int
|
||||
target_verse_id: int
|
||||
reference_type: Literal[
|
||||
"quotation", "allusion", "thematic", "typology", "parallel"
|
||||
] = "thematic"
|
||||
confidence: float = Field(default=1.0, ge=0.0, le=1.0)
|
||||
|
||||
|
||||
class Topic(BaseModel):
|
||||
"""A topical category from a topical index (e.g. Nave's)."""
|
||||
|
||||
topic_id: str
|
||||
name: str
|
||||
parent_id: Optional[str] = None
|
||||
description: str = ""
|
||||
verse_ids: list[int] = Field(default_factory=list)
|
||||
|
||||
|
||||
class StrongsEntry(BaseModel):
|
||||
"""A Strong's concordance entry for original language terms."""
|
||||
|
||||
strongs_number: str = Field(description="e.g. H7225, G26")
|
||||
language: Literal["hebrew", "greek"]
|
||||
lemma: str = ""
|
||||
transliteration: str = ""
|
||||
gloss: str = ""
|
||||
morphology: str = ""
|
||||
|
||||
|
||||
class OriginalLanguageToken(BaseModel):
|
||||
"""A token in original language text with annotations."""
|
||||
|
||||
text: str
|
||||
transliteration: str = ""
|
||||
strongs_number: str = ""
|
||||
morphology: str = ""
|
||||
gloss: str = ""
|
||||
word_position: int = 0
|
||||
|
||||
|
||||
class InterlinearVerse(BaseModel):
|
||||
"""A verse with interlinear original language alignment."""
|
||||
|
||||
verse_id: int
|
||||
reference: str
|
||||
original_tokens: list[OriginalLanguageToken] = Field(default_factory=list)
|
||||
esv_text: str = ""
|
||||
language: Literal["hebrew", "greek"] = "hebrew"
|
||||
|
||||
|
||||
class MeditationState(BaseModel):
|
||||
"""Tracks the current meditation progress."""
|
||||
|
||||
current_book: int = 1
|
||||
current_chapter: int = 1
|
||||
current_verse: int = 1
|
||||
mode: Literal["sequential", "thematic", "lectionary"] = "sequential"
|
||||
theme: Optional[str] = None
|
||||
last_meditation: Optional[str] = None
|
||||
verses_meditated: int = 0
|
||||
|
||||
def advance(self, book: int, chapter: int, verse: int) -> None:
|
||||
self.current_book = book
|
||||
self.current_chapter = chapter
|
||||
self.current_verse = verse
|
||||
self.last_meditation = datetime.now(timezone.utc).isoformat()
|
||||
self.verses_meditated += 1
|
||||
|
||||
|
||||
class ScriptureQuery(BaseModel):
|
||||
"""A parsed user query for scripture content."""
|
||||
|
||||
intent: Literal[
|
||||
"lookup", "explanation", "application", "comparison", "devotional", "search"
|
||||
] = "lookup"
|
||||
references: list[VerseRef] = Field(default_factory=list)
|
||||
keywords: list[str] = Field(default_factory=list)
|
||||
topic: Optional[str] = None
|
||||
raw_text: str = ""
|
||||
166
src/scripture/parser.py
Normal file
166
src/scripture/parser.py
Normal file
@@ -0,0 +1,166 @@
|
||||
"""Reference parser — extract and normalise biblical references from text.
|
||||
|
||||
Handles explicit references (``John 3:16``), range references
|
||||
(``Romans 5:1-11``), multi-chapter ranges (``Genesis 1:1-2:3``),
|
||||
and fuzzy book name matching (``1 Cor 13``, ``Phil 4 13``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from scripture.constants import book_by_name, BookInfo, BOOK_BY_ID
|
||||
from scripture.models import VerseRef, VerseRange
|
||||
|
||||
|
||||
# ── Regex patterns ───────────────────────────────────────────────────────────
|
||||
|
||||
# Matches patterns like "John 3:16", "1 Cor 13:4-7", "Gen 1:1-2:3"
|
||||
_REF_PATTERN = re.compile(
|
||||
r"""
|
||||
(?P<book>
|
||||
(?:[123]\s*)? # optional ordinal (1, 2, 3)
|
||||
[A-Za-z]+ # book name
|
||||
(?:\s+of\s+[A-Za-z]+)? # "Song of Solomon"
|
||||
)
|
||||
\s*
|
||||
(?P<chapter>\d{1,3}) # chapter number
|
||||
(?:
|
||||
\s*[:\.]\s* # separator (colon or dot)
|
||||
(?P<verse>\d{1,3}) # verse number
|
||||
(?:
|
||||
\s*[-–—]\s* # range separator
|
||||
(?:
|
||||
(?P<end_chapter>\d{1,3}) # optional end chapter
|
||||
\s*[:\.]\s*
|
||||
)?
|
||||
(?P<end_verse>\d{1,3}) # end verse
|
||||
)?
|
||||
)?
|
||||
""",
|
||||
re.VERBOSE | re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _normalise_book_name(raw: str) -> str:
|
||||
"""Collapse whitespace and lowercase for lookup."""
|
||||
return re.sub(r"\s+", " ", raw.strip()).lower()
|
||||
|
||||
|
||||
def resolve_book(name: str) -> Optional[BookInfo]:
|
||||
"""Resolve a book name/abbreviation to a BookInfo."""
|
||||
return book_by_name(_normalise_book_name(name))
|
||||
|
||||
|
||||
def parse_reference(text: str) -> Optional[VerseRange]:
|
||||
"""Parse a single scripture reference string into a VerseRange.
|
||||
|
||||
Examples::
|
||||
|
||||
parse_reference("John 3:16")
|
||||
parse_reference("Genesis 1:1-3")
|
||||
parse_reference("Rom 5:1-11")
|
||||
parse_reference("1 Cor 13") # whole chapter
|
||||
"""
|
||||
m = _REF_PATTERN.search(text)
|
||||
if not m:
|
||||
return None
|
||||
|
||||
book_info = resolve_book(m.group("book"))
|
||||
if not book_info:
|
||||
return None
|
||||
|
||||
chapter = int(m.group("chapter"))
|
||||
verse_str = m.group("verse")
|
||||
end_verse_str = m.group("end_verse")
|
||||
end_chapter_str = m.group("end_chapter")
|
||||
|
||||
if verse_str is None:
|
||||
# Whole chapter reference: "Genesis 1"
|
||||
start = VerseRef(book=book_info.id, chapter=chapter, verse=1)
|
||||
# Use a large verse number; the caller truncates to actual max
|
||||
end = VerseRef(book=book_info.id, chapter=chapter, verse=999)
|
||||
return VerseRange(start=start, end=end)
|
||||
|
||||
start_verse = int(verse_str)
|
||||
start = VerseRef(book=book_info.id, chapter=chapter, verse=start_verse)
|
||||
|
||||
if end_verse_str is not None:
|
||||
end_ch = int(end_chapter_str) if end_chapter_str else chapter
|
||||
end_v = int(end_verse_str)
|
||||
end = VerseRef(book=book_info.id, chapter=end_ch, verse=end_v)
|
||||
else:
|
||||
end = VerseRef(book=book_info.id, chapter=chapter, verse=start_verse)
|
||||
|
||||
return VerseRange(start=start, end=end)
|
||||
|
||||
|
||||
def extract_references(text: str) -> list[VerseRange]:
|
||||
"""Extract all scripture references from a block of text.
|
||||
|
||||
Returns a list of VerseRange objects for every reference found.
|
||||
"""
|
||||
results: list[VerseRange] = []
|
||||
for m in _REF_PATTERN.finditer(text):
|
||||
book_info = resolve_book(m.group("book"))
|
||||
if not book_info:
|
||||
continue
|
||||
|
||||
chapter = int(m.group("chapter"))
|
||||
verse_str = m.group("verse")
|
||||
end_verse_str = m.group("end_verse")
|
||||
end_chapter_str = m.group("end_chapter")
|
||||
|
||||
if verse_str is None:
|
||||
start = VerseRef(book=book_info.id, chapter=chapter, verse=1)
|
||||
end = VerseRef(book=book_info.id, chapter=chapter, verse=999)
|
||||
else:
|
||||
sv = int(verse_str)
|
||||
start = VerseRef(book=book_info.id, chapter=chapter, verse=sv)
|
||||
if end_verse_str is not None:
|
||||
end_ch = int(end_chapter_str) if end_chapter_str else chapter
|
||||
end = VerseRef(book=book_info.id, chapter=end_ch, verse=int(end_verse_str))
|
||||
else:
|
||||
end = VerseRef(book=book_info.id, chapter=chapter, verse=sv)
|
||||
|
||||
results.append(VerseRange(start=start, end=end))
|
||||
return results
|
||||
|
||||
|
||||
def format_reference(ref: VerseRef) -> str:
|
||||
"""Format a VerseRef as a human-readable string.
|
||||
|
||||
Example: ``VerseRef(book=43, chapter=3, verse=16)`` → ``"John 3:16"``
|
||||
"""
|
||||
book = BOOK_BY_ID.get(ref.book)
|
||||
if not book:
|
||||
return f"Unknown {ref.chapter}:{ref.verse}"
|
||||
if ref.verse == 0:
|
||||
return f"{book.name} {ref.chapter}"
|
||||
return f"{book.name} {ref.chapter}:{ref.verse}"
|
||||
|
||||
|
||||
def format_range(vr: VerseRange) -> str:
|
||||
"""Format a VerseRange as a human-readable string.
|
||||
|
||||
Examples::
|
||||
|
||||
"John 3:16" (single verse)
|
||||
"Romans 5:1-11" (same chapter range)
|
||||
"Genesis 1:1-2:3" (multi-chapter range)
|
||||
"""
|
||||
start_book = BOOK_BY_ID.get(vr.start.book)
|
||||
if not start_book:
|
||||
return "Unknown reference"
|
||||
|
||||
if vr.start.verse_id == vr.end.verse_id:
|
||||
return format_reference(vr.start)
|
||||
|
||||
if vr.start.chapter == vr.end.chapter:
|
||||
return f"{start_book.name} {vr.start.chapter}:{vr.start.verse}-{vr.end.verse}"
|
||||
|
||||
return (
|
||||
f"{start_book.name} {vr.start.chapter}:{vr.start.verse}"
|
||||
f"-{vr.end.chapter}:{vr.end.verse}"
|
||||
)
|
||||
387
src/scripture/store.py
Normal file
387
src/scripture/store.py
Normal file
@@ -0,0 +1,387 @@
|
||||
"""Scripture store — SQLite-backed verse storage and retrieval.
|
||||
|
||||
Provides the persistent knowledge base for the complete ESV text.
|
||||
Follows the project's SQLite singleton pattern (cf. swarm/registry.py).
|
||||
|
||||
Tables
|
||||
------
|
||||
- ``verses`` Primary verse storage with text + metadata
|
||||
- ``cross_references`` TSK-derived edges between verses
|
||||
- ``topics`` Nave's-style topical index entries
|
||||
- ``verse_topics`` Many-to-many verse ↔ topic links
|
||||
- ``strongs`` Strong's concordance entries
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from scripture.constants import BOOK_BY_ID, book_by_name
|
||||
from scripture.models import (
|
||||
CrossReference,
|
||||
StrongsEntry,
|
||||
Topic,
|
||||
Verse,
|
||||
VerseRef,
|
||||
decode_verse_id,
|
||||
encode_verse_id,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DB_DIR = Path("data")
|
||||
DB_PATH = DB_DIR / "scripture.db"
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS verses (
|
||||
verse_id INTEGER PRIMARY KEY,
|
||||
book INTEGER NOT NULL,
|
||||
chapter INTEGER NOT NULL,
|
||||
verse_num INTEGER NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
translation TEXT NOT NULL DEFAULT 'ESV',
|
||||
testament TEXT NOT NULL DEFAULT 'OT',
|
||||
genre TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_verses_book_ch
|
||||
ON verses(book, chapter);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cross_references (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source_verse_id INTEGER NOT NULL,
|
||||
target_verse_id INTEGER NOT NULL,
|
||||
reference_type TEXT NOT NULL DEFAULT 'thematic',
|
||||
confidence REAL NOT NULL DEFAULT 1.0,
|
||||
UNIQUE(source_verse_id, target_verse_id, reference_type)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_xref_source
|
||||
ON cross_references(source_verse_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_xref_target
|
||||
ON cross_references(target_verse_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS topics (
|
||||
topic_id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
parent_id TEXT,
|
||||
description TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS verse_topics (
|
||||
verse_id INTEGER NOT NULL,
|
||||
topic_id TEXT NOT NULL,
|
||||
relevance REAL NOT NULL DEFAULT 1.0,
|
||||
PRIMARY KEY (verse_id, topic_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_vt_topic
|
||||
ON verse_topics(topic_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS strongs (
|
||||
strongs_number TEXT PRIMARY KEY,
|
||||
language TEXT NOT NULL,
|
||||
lemma TEXT NOT NULL DEFAULT '',
|
||||
transliteration TEXT NOT NULL DEFAULT '',
|
||||
gloss TEXT NOT NULL DEFAULT '',
|
||||
morphology TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
class ScriptureStore:
|
||||
"""SQLite-backed scripture knowledge base.
|
||||
|
||||
Usage::
|
||||
|
||||
from scripture.store import scripture_store
|
||||
verse = scripture_store.get_verse(43, 3, 16)
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: Path | str = DB_PATH) -> None:
|
||||
self._db_path = Path(db_path)
|
||||
self._db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._conn: Optional[sqlite3.Connection] = None
|
||||
self._init_db()
|
||||
|
||||
# ── Connection management ────────────────────────────────────────────
|
||||
|
||||
def _get_conn(self) -> sqlite3.Connection:
|
||||
if self._conn is None:
|
||||
self._conn = sqlite3.connect(
|
||||
str(self._db_path), check_same_thread=False
|
||||
)
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
self._conn.execute("PRAGMA journal_mode=WAL")
|
||||
self._conn.execute("PRAGMA foreign_keys=ON")
|
||||
return self._conn
|
||||
|
||||
def _init_db(self) -> None:
|
||||
conn = self._get_conn()
|
||||
conn.executescript(_SCHEMA)
|
||||
conn.commit()
|
||||
|
||||
def close(self) -> None:
|
||||
if self._conn:
|
||||
self._conn.close()
|
||||
self._conn = None
|
||||
|
||||
# ── Verse CRUD ───────────────────────────────────────────────────────
|
||||
|
||||
def insert_verse(self, verse: Verse) -> None:
|
||||
"""Insert or replace a single verse."""
|
||||
conn = self._get_conn()
|
||||
conn.execute(
|
||||
"""INSERT OR REPLACE INTO verses
|
||||
(verse_id, book, chapter, verse_num, text, translation, testament, genre)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
verse.verse_id,
|
||||
verse.book,
|
||||
verse.chapter,
|
||||
verse.verse_num,
|
||||
verse.text,
|
||||
verse.translation,
|
||||
verse.testament,
|
||||
verse.genre,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def insert_verses(self, verses: list[Verse]) -> None:
|
||||
"""Bulk-insert verses (efficient for full-text ingestion)."""
|
||||
conn = self._get_conn()
|
||||
conn.executemany(
|
||||
"""INSERT OR REPLACE INTO verses
|
||||
(verse_id, book, chapter, verse_num, text, translation, testament, genre)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
[
|
||||
(v.verse_id, v.book, v.chapter, v.verse_num,
|
||||
v.text, v.translation, v.testament, v.genre)
|
||||
for v in verses
|
||||
],
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def get_verse(self, book: int, chapter: int, verse: int) -> Optional[Verse]:
|
||||
"""Retrieve a single verse by book/chapter/verse."""
|
||||
vid = encode_verse_id(book, chapter, verse)
|
||||
row = self._get_conn().execute(
|
||||
"SELECT * FROM verses WHERE verse_id = ?", (vid,)
|
||||
).fetchone()
|
||||
return self._row_to_verse(row) if row else None
|
||||
|
||||
def get_verse_by_id(self, verse_id: int) -> Optional[Verse]:
|
||||
"""Retrieve a verse by its integer ID."""
|
||||
row = self._get_conn().execute(
|
||||
"SELECT * FROM verses WHERE verse_id = ?", (verse_id,)
|
||||
).fetchone()
|
||||
return self._row_to_verse(row) if row else None
|
||||
|
||||
def get_chapter(self, book: int, chapter: int) -> list[Verse]:
|
||||
"""Retrieve all verses in a chapter, ordered by verse number."""
|
||||
rows = self._get_conn().execute(
|
||||
"SELECT * FROM verses WHERE book = ? AND chapter = ? ORDER BY verse_num",
|
||||
(book, chapter),
|
||||
).fetchall()
|
||||
return [self._row_to_verse(r) for r in rows]
|
||||
|
||||
def get_range(self, start_id: int, end_id: int) -> list[Verse]:
|
||||
"""Retrieve all verses in a range of verse IDs (inclusive)."""
|
||||
rows = self._get_conn().execute(
|
||||
"SELECT * FROM verses WHERE verse_id BETWEEN ? AND ? ORDER BY verse_id",
|
||||
(start_id, end_id),
|
||||
).fetchall()
|
||||
return [self._row_to_verse(r) for r in rows]
|
||||
|
||||
def search_text(self, query: str, limit: int = 20) -> list[Verse]:
|
||||
"""Full-text search across verse content (LIKE-based)."""
|
||||
rows = self._get_conn().execute(
|
||||
"SELECT * FROM verses WHERE text LIKE ? ORDER BY verse_id LIMIT ?",
|
||||
(f"%{query}%", limit),
|
||||
).fetchall()
|
||||
return [self._row_to_verse(r) for r in rows]
|
||||
|
||||
def count_verses(self) -> int:
|
||||
"""Return the total number of verses in the store."""
|
||||
row = self._get_conn().execute("SELECT COUNT(*) FROM verses").fetchone()
|
||||
return row[0] if row else 0
|
||||
|
||||
def get_books(self) -> list[dict]:
|
||||
"""Return a summary of all books with verse counts."""
|
||||
rows = self._get_conn().execute(
|
||||
"""SELECT book, COUNT(*) as verse_count, MIN(chapter) as min_ch,
|
||||
MAX(chapter) as max_ch
|
||||
FROM verses GROUP BY book ORDER BY book"""
|
||||
).fetchall()
|
||||
result = []
|
||||
for r in rows:
|
||||
info = BOOK_BY_ID.get(r["book"])
|
||||
result.append({
|
||||
"book_id": r["book"],
|
||||
"name": info.name if info else f"Book {r['book']}",
|
||||
"abbreviation": info.abbreviation if info else "",
|
||||
"testament": info.testament if info else "",
|
||||
"verse_count": r["verse_count"],
|
||||
"chapters": r["max_ch"],
|
||||
})
|
||||
return result
|
||||
|
||||
# ── Cross-references ─────────────────────────────────────────────────
|
||||
|
||||
def insert_cross_reference(self, xref: CrossReference) -> None:
|
||||
"""Insert a cross-reference link."""
|
||||
conn = self._get_conn()
|
||||
conn.execute(
|
||||
"""INSERT OR IGNORE INTO cross_references
|
||||
(source_verse_id, target_verse_id, reference_type, confidence)
|
||||
VALUES (?, ?, ?, ?)""",
|
||||
(xref.source_verse_id, xref.target_verse_id,
|
||||
xref.reference_type, xref.confidence),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def get_cross_references(self, verse_id: int) -> list[CrossReference]:
|
||||
"""Find all cross-references from or to a verse."""
|
||||
rows = self._get_conn().execute(
|
||||
"""SELECT * FROM cross_references
|
||||
WHERE source_verse_id = ? OR target_verse_id = ?
|
||||
ORDER BY confidence DESC""",
|
||||
(verse_id, verse_id),
|
||||
).fetchall()
|
||||
return [
|
||||
CrossReference(
|
||||
source_verse_id=r["source_verse_id"],
|
||||
target_verse_id=r["target_verse_id"],
|
||||
reference_type=r["reference_type"],
|
||||
confidence=r["confidence"],
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
# ── Topics ───────────────────────────────────────────────────────────
|
||||
|
||||
def insert_topic(self, topic: Topic) -> None:
|
||||
"""Insert a topical index entry."""
|
||||
conn = self._get_conn()
|
||||
conn.execute(
|
||||
"""INSERT OR REPLACE INTO topics
|
||||
(topic_id, name, parent_id, description) VALUES (?, ?, ?, ?)""",
|
||||
(topic.topic_id, topic.name, topic.parent_id, topic.description),
|
||||
)
|
||||
for vid in topic.verse_ids:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO verse_topics (verse_id, topic_id) VALUES (?, ?)",
|
||||
(vid, topic.topic_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def get_topic(self, topic_id: str) -> Optional[Topic]:
|
||||
"""Retrieve a topic by ID."""
|
||||
row = self._get_conn().execute(
|
||||
"SELECT * FROM topics WHERE topic_id = ?", (topic_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
verse_rows = self._get_conn().execute(
|
||||
"SELECT verse_id FROM verse_topics WHERE topic_id = ?", (topic_id,)
|
||||
).fetchall()
|
||||
return Topic(
|
||||
topic_id=row["topic_id"],
|
||||
name=row["name"],
|
||||
parent_id=row["parent_id"],
|
||||
description=row["description"],
|
||||
verse_ids=[r["verse_id"] for r in verse_rows],
|
||||
)
|
||||
|
||||
def search_topics(self, query: str, limit: int = 10) -> list[Topic]:
|
||||
"""Search topics by name."""
|
||||
rows = self._get_conn().execute(
|
||||
"SELECT * FROM topics WHERE name LIKE ? ORDER BY name LIMIT ?",
|
||||
(f"%{query}%", limit),
|
||||
).fetchall()
|
||||
return [
|
||||
Topic(topic_id=r["topic_id"], name=r["name"],
|
||||
parent_id=r["parent_id"], description=r["description"])
|
||||
for r in rows
|
||||
]
|
||||
|
||||
def get_verses_for_topic(self, topic_id: str) -> list[Verse]:
|
||||
"""Retrieve all verses associated with a topic."""
|
||||
rows = self._get_conn().execute(
|
||||
"""SELECT v.* FROM verses v
|
||||
INNER JOIN verse_topics vt ON v.verse_id = vt.verse_id
|
||||
WHERE vt.topic_id = ?
|
||||
ORDER BY v.verse_id""",
|
||||
(topic_id,),
|
||||
).fetchall()
|
||||
return [self._row_to_verse(r) for r in rows]
|
||||
|
||||
# ── Strong's concordance ─────────────────────────────────────────────
|
||||
|
||||
def insert_strongs(self, entry: StrongsEntry) -> None:
|
||||
"""Insert a Strong's concordance entry."""
|
||||
conn = self._get_conn()
|
||||
conn.execute(
|
||||
"""INSERT OR REPLACE INTO strongs
|
||||
(strongs_number, language, lemma, transliteration, gloss, morphology)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
(entry.strongs_number, entry.language, entry.lemma,
|
||||
entry.transliteration, entry.gloss, entry.morphology),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def get_strongs(self, number: str) -> Optional[StrongsEntry]:
|
||||
"""Look up a Strong's number."""
|
||||
row = self._get_conn().execute(
|
||||
"SELECT * FROM strongs WHERE strongs_number = ?", (number,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return StrongsEntry(
|
||||
strongs_number=row["strongs_number"],
|
||||
language=row["language"],
|
||||
lemma=row["lemma"],
|
||||
transliteration=row["transliteration"],
|
||||
gloss=row["gloss"],
|
||||
morphology=row["morphology"],
|
||||
)
|
||||
|
||||
# ── Stats ────────────────────────────────────────────────────────────
|
||||
|
||||
def stats(self) -> dict:
|
||||
"""Return summary statistics of the scripture store."""
|
||||
conn = self._get_conn()
|
||||
verses = conn.execute("SELECT COUNT(*) FROM verses").fetchone()[0]
|
||||
xrefs = conn.execute("SELECT COUNT(*) FROM cross_references").fetchone()[0]
|
||||
topics = conn.execute("SELECT COUNT(*) FROM topics").fetchone()[0]
|
||||
strongs = conn.execute("SELECT COUNT(*) FROM strongs").fetchone()[0]
|
||||
return {
|
||||
"verses": verses,
|
||||
"cross_references": xrefs,
|
||||
"topics": topics,
|
||||
"strongs_entries": strongs,
|
||||
}
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _row_to_verse(row: sqlite3.Row) -> Verse:
|
||||
return Verse(
|
||||
verse_id=row["verse_id"],
|
||||
book=row["book"],
|
||||
chapter=row["chapter"],
|
||||
verse_num=row["verse_num"],
|
||||
text=row["text"],
|
||||
translation=row["translation"],
|
||||
testament=row["testament"],
|
||||
genre=row["genre"],
|
||||
)
|
||||
|
||||
|
||||
# Module-level singleton
|
||||
scripture_store = ScriptureStore()
|
||||
Reference in New Issue
Block a user