diff --git a/pyproject.toml b/pyproject.toml index ebac5829..304ef94c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -99,6 +99,7 @@ include = [ "src/agent_core", "src/lightning", "src/self_modify", + "src/scripture", ] [tool.pytest.ini_options] diff --git a/src/config.py b/src/config.py index 765ce586..3f1ff19b 100644 --- a/src/config.py +++ b/src/config.py @@ -90,6 +90,17 @@ class Settings(BaseSettings): work_orders_auto_execute: bool = False # Master switch for auto-execution work_orders_auto_threshold: str = "low" # Max priority that auto-executes: "low" | "medium" | "high" | "none" + # ── Scripture / Biblical Integration ────────────────────────────── + # Enable the sovereign biblical text module. When enabled, Timmy + # loads the local ESV text corpus and runs meditation workflows. + scripture_enabled: bool = True + # Primary translation for retrieval and citation. + scripture_translation: str = "ESV" + # Meditation mode: sequential | thematic | lectionary + scripture_meditation_mode: str = "sequential" + # Background meditation interval in seconds (0 = disabled). + scripture_meditation_interval: int = 0 + model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", diff --git a/src/dashboard/app.py b/src/dashboard/app.py index c696a020..2422cd25 100644 --- a/src/dashboard/app.py +++ b/src/dashboard/app.py @@ -34,6 +34,7 @@ from dashboard.routes.router import router as router_status_router from dashboard.routes.upgrades import router as upgrades_router from dashboard.routes.work_orders import router as work_orders_router from dashboard.routes.tasks import router as tasks_router +from dashboard.routes.scripture import router as scripture_router from router.api import router as cascade_router logging.basicConfig( @@ -197,6 +198,7 @@ app.include_router(router_status_router) app.include_router(upgrades_router) app.include_router(work_orders_router) app.include_router(tasks_router) +app.include_router(scripture_router) app.include_router(cascade_router) diff --git a/src/dashboard/routes/scripture.py b/src/dashboard/routes/scripture.py new file mode 100644 index 00000000..2ab62403 --- /dev/null +++ b/src/dashboard/routes/scripture.py @@ -0,0 +1,274 @@ +"""Scripture dashboard routes. + +GET /scripture — JSON status of the scripture module +GET /scripture/verse — Look up a single verse by reference +GET /scripture/search — Full-text search across verse content +GET /scripture/chapter — Retrieve an entire chapter +GET /scripture/meditate — Get the current meditation verse +POST /scripture/meditate — Advance meditation to the next verse +POST /scripture/meditate/mode — Change meditation mode +GET /scripture/memory — Scripture memory system status +GET /scripture/xref — Cross-references for a verse +GET /scripture/stats — Store statistics +POST /scripture/ingest — Bulk-ingest verses (JSON array) +""" + +from __future__ import annotations + +import json +import logging +from pathlib import Path +from typing import Optional + +from fastapi import APIRouter, Query, Request +from fastapi.responses import JSONResponse + +from scripture.constants import BOOK_BY_ID, book_by_name +from scripture.meditation import meditation_scheduler +from scripture.memory import scripture_memory +from scripture.models import Verse, encode_verse_id +from scripture.parser import extract_references, format_reference, parse_reference +from scripture.store import scripture_store + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/scripture", tags=["scripture"]) + + +@router.get("") +async def scripture_status(): + """Return scripture module status — store stats + memory state.""" + return JSONResponse({ + "store": scripture_store.stats(), + "memory": scripture_memory.status(), + "meditation": meditation_scheduler.status(), + }) + + +@router.get("/verse") +async def get_verse( + ref: str = Query( + ..., + description="Biblical reference, e.g. 'John 3:16' or 'Gen 1:1-3'", + ), +): + """Look up one or more verses by reference string.""" + parsed = parse_reference(ref) + if not parsed: + return JSONResponse( + {"error": f"Could not parse reference: {ref}"}, + status_code=400, + ) + + start = parsed.start + end = parsed.end + + if start.verse_id == end.verse_id: + verse = scripture_store.get_verse(start.book, start.chapter, start.verse) + if not verse: + return JSONResponse({"error": "Verse not found", "ref": ref}, status_code=404) + return JSONResponse(_verse_to_dict(verse)) + + verses = scripture_store.get_range(start.verse_id, end.verse_id) + if not verses: + return JSONResponse({"error": "No verses found in range", "ref": ref}, status_code=404) + return JSONResponse({"verses": [_verse_to_dict(v) for v in verses]}) + + +@router.get("/chapter") +async def get_chapter( + book: str = Query(..., description="Book name or abbreviation"), + chapter: int = Query(..., ge=1, description="Chapter number"), +): + """Retrieve all verses in a chapter.""" + book_info = book_by_name(book) + if not book_info: + return JSONResponse({"error": f"Unknown book: {book}"}, status_code=400) + + verses = scripture_store.get_chapter(book_info.id, chapter) + if not verses: + return JSONResponse( + {"error": f"No verses found for {book_info.name} {chapter}"}, + status_code=404, + ) + return JSONResponse({ + "book": book_info.name, + "chapter": chapter, + "verses": [_verse_to_dict(v) for v in verses], + }) + + +@router.get("/search") +async def search_verses( + q: str = Query(..., min_length=2, description="Search query"), + limit: int = Query(default=20, ge=1, le=100), +): + """Full-text search across verse content.""" + verses = scripture_store.search_text(q, limit=limit) + return JSONResponse({ + "query": q, + "count": len(verses), + "verses": [_verse_to_dict(v) for v in verses], + }) + + +@router.get("/meditate") +async def get_meditation(): + """Return the current meditation focus verse and status.""" + status = meditation_scheduler.status() + current = meditation_scheduler.current_focus() + return JSONResponse({ + "status": status, + "current_verse": _verse_to_dict(current) if current else None, + }) + + +@router.post("/meditate") +async def advance_meditation(): + """Advance to the next verse in the meditation sequence.""" + verse = meditation_scheduler.next_meditation() + if not verse: + return JSONResponse( + {"message": "No more verses available — scripture store may be empty"}, + status_code=404, + ) + return JSONResponse({ + "verse": _verse_to_dict(verse), + "status": meditation_scheduler.status(), + }) + + +@router.post("/meditate/mode") +async def set_meditation_mode( + mode: str = Query(..., description="sequential, thematic, or lectionary"), + theme: Optional[str] = Query(default=None, description="Theme for thematic mode"), +): + """Change the meditation mode.""" + try: + state = meditation_scheduler.set_mode(mode, theme=theme) + except ValueError as exc: + return JSONResponse({"error": str(exc)}, status_code=400) + return JSONResponse({ + "mode": state.mode, + "theme": state.theme, + "message": f"Meditation mode set to {state.mode}", + }) + + +@router.get("/memory") +async def memory_status(): + """Return the scripture memory system status.""" + return JSONResponse(scripture_memory.status()) + + +@router.get("/xref") +async def get_cross_references( + ref: str = Query(..., description="Verse reference, e.g. 'John 3:16'"), +): + """Find cross-references for a verse.""" + parsed = parse_reference(ref) + if not parsed: + return JSONResponse({"error": f"Could not parse: {ref}"}, status_code=400) + + verse = scripture_store.get_verse( + parsed.start.book, parsed.start.chapter, parsed.start.verse + ) + if not verse: + return JSONResponse({"error": "Verse not found"}, status_code=404) + + xrefs = scripture_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 = scripture_store.get_verse_by_id(target_id) + if target: + results.append({ + "reference_type": xref.reference_type, + "confidence": xref.confidence, + "verse": _verse_to_dict(target), + }) + + return JSONResponse({ + "source": _verse_to_dict(verse), + "cross_references": results, + }) + + +@router.get("/stats") +async def store_stats(): + """Return scripture store statistics.""" + return JSONResponse(scripture_store.stats()) + + +@router.post("/ingest") +async def ingest_verses(request: Request): + """Bulk-ingest verses from a JSON array. + + Expects a JSON body with a "verses" key containing an array of objects + with: book, chapter, verse_num, text, and optionally + translation/testament/genre. + """ + try: + body = await request.json() + except Exception: + return JSONResponse({"error": "Invalid JSON body"}, status_code=400) + + raw_verses = body.get("verses", []) + if not raw_verses: + return JSONResponse({"error": "No verses provided"}, status_code=400) + + verses = [] + for rv in raw_verses: + try: + book = int(rv["book"]) + chapter = int(rv["chapter"]) + verse_num = int(rv["verse_num"]) + text = str(rv["text"]) + book_info = BOOK_BY_ID.get(book) + verses.append(Verse( + verse_id=encode_verse_id(book, chapter, verse_num), + book=book, + chapter=chapter, + verse_num=verse_num, + text=text, + translation=rv.get("translation", "ESV"), + testament=book_info.testament if book_info else "OT", + genre=book_info.genre if book_info else "", + )) + except (KeyError, ValueError, TypeError) as exc: + logger.warning("Skipping invalid verse record: %s", exc) + continue + + if verses: + scripture_store.insert_verses(verses) + + return JSONResponse({ + "ingested": len(verses), + "skipped": len(raw_verses) - len(verses), + "total_verses": scripture_store.count_verses(), + }) + + +# ── Helpers ────────────────────────────────────────────────────────────────── + + +def _verse_to_dict(verse: Verse) -> dict: + """Convert a Verse model to a JSON-friendly dict with formatted reference.""" + from scripture.models import VerseRef + + ref = VerseRef(book=verse.book, chapter=verse.chapter, verse=verse.verse_num) + return { + "verse_id": verse.verse_id, + "reference": format_reference(ref), + "book": verse.book, + "chapter": verse.chapter, + "verse_num": verse.verse_num, + "text": verse.text, + "translation": verse.translation, + "testament": verse.testament, + "genre": verse.genre, + } diff --git a/src/scripture/__init__.py b/src/scripture/__init__.py new file mode 100644 index 00000000..8497286c --- /dev/null +++ b/src/scripture/__init__.py @@ -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. +""" diff --git a/src/scripture/constants.py b/src/scripture/constants.py new file mode 100644 index 00000000..36b93ed4 --- /dev/null +++ b/src/scripture/constants.py @@ -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) diff --git a/src/scripture/meditation.py b/src/scripture/meditation.py new file mode 100644 index 00000000..10633770 --- /dev/null +++ b/src/scripture/meditation.py @@ -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() diff --git a/src/scripture/memory.py b/src/scripture/memory.py new file mode 100644 index 00000000..b771d47e --- /dev/null +++ b/src/scripture/memory.py @@ -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() diff --git a/src/scripture/models.py b/src/scripture/models.py new file mode 100644 index 00000000..ebea956b --- /dev/null +++ b/src/scripture/models.py @@ -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 = "" diff --git a/src/scripture/parser.py b/src/scripture/parser.py new file mode 100644 index 00000000..7989e2d8 --- /dev/null +++ b/src/scripture/parser.py @@ -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 + (?:[123]\s*)? # optional ordinal (1, 2, 3) + [A-Za-z]+ # book name + (?:\s+of\s+[A-Za-z]+)? # "Song of Solomon" + ) + \s* + (?P\d{1,3}) # chapter number + (?: + \s*[:\.]\s* # separator (colon or dot) + (?P\d{1,3}) # verse number + (?: + \s*[-–—]\s* # range separator + (?: + (?P\d{1,3}) # optional end chapter + \s*[:\.]\s* + )? + (?P\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}" + ) diff --git a/src/scripture/store.py b/src/scripture/store.py new file mode 100644 index 00000000..78ee1d25 --- /dev/null +++ b/src/scripture/store.py @@ -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() diff --git a/tests/test_scripture.py b/tests/test_scripture.py new file mode 100644 index 00000000..c3fb1f6c --- /dev/null +++ b/tests/test_scripture.py @@ -0,0 +1,901 @@ +"""Tests for the scripture module — sovereign biblical text integration. + +Covers: constants, models, parser, store, memory, meditation, and routes. +All tests use in-memory or temp-file SQLite — no external services needed. +""" + +import json +import sqlite3 +import tempfile +from pathlib import Path +from unittest.mock import patch + +import pytest +from fastapi.testclient import TestClient + + +# ════════════════════════════════════════════════════════════════════════════ +# Constants +# ════════════════════════════════════════════════════════════════════════════ + + +class TestConstants: + def test_total_books(self): + from scripture.constants import BOOKS, TOTAL_BOOKS + assert len(BOOKS) == TOTAL_BOOKS == 66 + + def test_ot_nt_split(self): + from scripture.constants import BOOKS, OT_BOOKS, NT_BOOKS + ot = [b for b in BOOKS if b.testament == "OT"] + nt = [b for b in BOOKS if b.testament == "NT"] + assert len(ot) == OT_BOOKS == 39 + assert len(nt) == NT_BOOKS == 27 + + def test_book_ids_sequential(self): + from scripture.constants import BOOKS + for i, book in enumerate(BOOKS, start=1): + assert book.id == i, f"Book {book.name} has id {book.id}, expected {i}" + + def test_book_by_name_full(self): + from scripture.constants import book_by_name + info = book_by_name("Genesis") + assert info is not None + assert info.id == 1 + assert info.testament == "OT" + + def test_book_by_name_abbreviation(self): + from scripture.constants import book_by_name + info = book_by_name("Rev") + assert info is not None + assert info.id == 66 + assert info.name == "Revelation" + + def test_book_by_name_case_insensitive(self): + from scripture.constants import book_by_name + assert book_by_name("JOHN") is not None + assert book_by_name("john") is not None + assert book_by_name("John") is not None + + def test_book_by_name_alias(self): + from scripture.constants import book_by_name + assert book_by_name("1 Cor").id == 46 + assert book_by_name("Phil").id == 50 + assert book_by_name("Ps").id == 19 + + def test_book_by_name_unknown(self): + from scripture.constants import book_by_name + assert book_by_name("Nonexistent") is None + + def test_book_by_id(self): + from scripture.constants import book_by_id + info = book_by_id(43) + assert info is not None + assert info.name == "John" + + def test_book_by_id_invalid(self): + from scripture.constants import book_by_id + assert book_by_id(0) is None + assert book_by_id(67) is None + + def test_genres_present(self): + from scripture.constants import GENRES + assert "gospel" in GENRES + assert "epistle" in GENRES + assert "wisdom" in GENRES + assert "prophecy" in GENRES + + def test_first_and_last_books(self): + from scripture.constants import BOOKS + assert BOOKS[0].name == "Genesis" + assert BOOKS[-1].name == "Revelation" + + +# ════════════════════════════════════════════════════════════════════════════ +# Models +# ════════════════════════════════════════════════════════════════════════════ + + +class TestModels: + def test_encode_verse_id(self): + from scripture.models import encode_verse_id + assert encode_verse_id(43, 3, 16) == 43003016 + assert encode_verse_id(1, 1, 1) == 1001001 + assert encode_verse_id(66, 22, 21) == 66022021 + + def test_decode_verse_id(self): + from scripture.models import decode_verse_id + assert decode_verse_id(43003016) == (43, 3, 16) + assert decode_verse_id(1001001) == (1, 1, 1) + assert decode_verse_id(66022021) == (66, 22, 21) + + def test_encode_decode_roundtrip(self): + from scripture.models import decode_verse_id, encode_verse_id + for book in (1, 19, 43, 66): + for chapter in (1, 10, 50): + for verse in (1, 5, 31): + vid = encode_verse_id(book, chapter, verse) + assert decode_verse_id(vid) == (book, chapter, verse) + + def test_verse_ref_id(self): + from scripture.models import VerseRef + ref = VerseRef(book=43, chapter=3, verse=16) + assert ref.verse_id == 43003016 + + def test_verse_range_ids(self): + from scripture.models import VerseRange, VerseRef + vr = VerseRange( + start=VerseRef(book=1, chapter=1, verse=1), + end=VerseRef(book=1, chapter=1, verse=3), + ) + ids = vr.verse_ids() + assert 1001001 in ids + assert 1001002 in ids + assert 1001003 in ids + + def test_verse_model(self): + from scripture.models import Verse + v = Verse( + verse_id=43003016, + book=43, + chapter=3, + verse_num=16, + text="For God so loved the world...", + translation="ESV", + testament="NT", + genre="gospel", + ) + assert v.text.startswith("For God") + assert v.testament == "NT" + + def test_meditation_state_advance(self): + from scripture.models import MeditationState + state = MeditationState() + assert state.verses_meditated == 0 + state.advance(1, 1, 2) + assert state.current_verse == 2 + assert state.verses_meditated == 1 + assert state.last_meditation is not None + + def test_scripture_query_defaults(self): + from scripture.models import ScriptureQuery + q = ScriptureQuery(raw_text="test") + assert q.intent == "lookup" + assert q.references == [] + assert q.keywords == [] + + def test_cross_reference_model(self): + from scripture.models import CrossReference + xref = CrossReference( + source_verse_id=43003016, + target_verse_id=45005008, + reference_type="thematic", + confidence=0.9, + ) + assert xref.reference_type == "thematic" + assert xref.confidence == 0.9 + + def test_strongs_entry(self): + from scripture.models import StrongsEntry + entry = StrongsEntry( + strongs_number="H7225", + language="hebrew", + lemma="רֵאשִׁית", + transliteration="reshith", + gloss="beginning", + ) + assert entry.language == "hebrew" + + +# ════════════════════════════════════════════════════════════════════════════ +# Parser +# ════════════════════════════════════════════════════════════════════════════ + + +class TestParser: + def test_parse_single_verse(self): + from scripture.parser import parse_reference + result = parse_reference("John 3:16") + assert result is not None + assert result.start.book == 43 + assert result.start.chapter == 3 + assert result.start.verse == 16 + assert result.end.verse == 16 + + def test_parse_range(self): + from scripture.parser import parse_reference + result = parse_reference("Romans 5:1-11") + assert result is not None + assert result.start.verse == 1 + assert result.end.verse == 11 + + def test_parse_whole_chapter(self): + from scripture.parser import parse_reference + result = parse_reference("Genesis 1") + assert result is not None + assert result.start.verse == 1 + assert result.end.verse == 999 # sentinel for whole chapter + + def test_parse_multi_chapter_range(self): + from scripture.parser import parse_reference + result = parse_reference("Genesis 1:1-2:3") + assert result is not None + assert result.start.chapter == 1 + assert result.start.verse == 1 + assert result.end.chapter == 2 + assert result.end.verse == 3 + + def test_parse_abbreviation(self): + from scripture.parser import parse_reference + result = parse_reference("Rom 8:28") + assert result is not None + assert result.start.book == 45 + + def test_parse_numbered_book(self): + from scripture.parser import parse_reference + result = parse_reference("1 Cor 13:4") + assert result is not None + assert result.start.book == 46 + + def test_parse_invalid(self): + from scripture.parser import parse_reference + assert parse_reference("not a reference") is None + + def test_parse_unknown_book(self): + from scripture.parser import parse_reference + assert parse_reference("Hezekiah 1:1") is None + + def test_extract_multiple_references(self): + from scripture.parser import extract_references + text = "See John 3:16 and Romans 5:8 for the gospel message." + refs = extract_references(text) + assert len(refs) == 2 + assert refs[0].start.book == 43 + assert refs[1].start.book == 45 + + def test_extract_no_references(self): + from scripture.parser import extract_references + assert extract_references("No references here.") == [] + + def test_format_reference(self): + from scripture.models import VerseRef + from scripture.parser import format_reference + ref = VerseRef(book=43, chapter=3, verse=16) + assert format_reference(ref) == "John 3:16" + + def test_format_reference_chapter_only(self): + from scripture.models import VerseRef + from scripture.parser import format_reference + ref = VerseRef(book=1, chapter=1, verse=0) + assert format_reference(ref) == "Genesis 1" + + def test_format_range_single(self): + from scripture.models import VerseRange, VerseRef + from scripture.parser import format_range + vr = VerseRange( + start=VerseRef(book=43, chapter=3, verse=16), + end=VerseRef(book=43, chapter=3, verse=16), + ) + assert format_range(vr) == "John 3:16" + + def test_format_range_same_chapter(self): + from scripture.models import VerseRange, VerseRef + from scripture.parser import format_range + vr = VerseRange( + start=VerseRef(book=45, chapter=5, verse=1), + end=VerseRef(book=45, chapter=5, verse=11), + ) + assert format_range(vr) == "Romans 5:1-11" + + def test_format_range_multi_chapter(self): + from scripture.models import VerseRange, VerseRef + from scripture.parser import format_range + vr = VerseRange( + start=VerseRef(book=1, chapter=1, verse=1), + end=VerseRef(book=1, chapter=2, verse=3), + ) + assert format_range(vr) == "Genesis 1:1-2:3" + + +# ════════════════════════════════════════════════════════════════════════════ +# Store +# ════════════════════════════════════════════════════════════════════════════ + + +@pytest.fixture +def temp_store(): + """Create a ScriptureStore backed by a temp file.""" + from scripture.store import ScriptureStore + with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f: + db_path = f.name + store = ScriptureStore(db_path=db_path) + yield store + store.close() + Path(db_path).unlink(missing_ok=True) + + +def _sample_verse(book=43, chapter=3, verse_num=16, text="For God so loved the world"): + from scripture.models import Verse, encode_verse_id + return Verse( + verse_id=encode_verse_id(book, chapter, verse_num), + book=book, + chapter=chapter, + verse_num=verse_num, + text=text, + translation="ESV", + testament="NT", + genre="gospel", + ) + + +class TestStore: + def test_insert_and_get(self, temp_store): + verse = _sample_verse() + temp_store.insert_verse(verse) + result = temp_store.get_verse(43, 3, 16) + assert result is not None + assert result.text == "For God so loved the world" + + def test_get_nonexistent(self, temp_store): + assert temp_store.get_verse(99, 1, 1) is None + + def test_get_verse_by_id(self, temp_store): + verse = _sample_verse() + temp_store.insert_verse(verse) + result = temp_store.get_verse_by_id(43003016) + assert result is not None + assert result.book == 43 + + def test_bulk_insert(self, temp_store): + verses = [ + _sample_verse(1, 1, 1, "In the beginning God created the heavens and the earth."), + _sample_verse(1, 1, 2, "The earth was without form and void."), + _sample_verse(1, 1, 3, "And God said, Let there be light."), + ] + temp_store.insert_verses(verses) + assert temp_store.count_verses() == 3 + + def test_get_chapter(self, temp_store): + verses = [ + _sample_verse(1, 1, i, f"Verse {i}") + for i in range(1, 6) + ] + temp_store.insert_verses(verses) + chapter = temp_store.get_chapter(1, 1) + assert len(chapter) == 5 + assert chapter[0].verse_num == 1 + + def test_get_range(self, temp_store): + from scripture.models import encode_verse_id + verses = [ + _sample_verse(1, 1, i, f"Verse {i}") + for i in range(1, 11) + ] + temp_store.insert_verses(verses) + result = temp_store.get_range( + encode_verse_id(1, 1, 3), + encode_verse_id(1, 1, 7), + ) + assert len(result) == 5 + + def test_search_text(self, temp_store): + verses = [ + _sample_verse(43, 3, 16, "For God so loved the world"), + _sample_verse(43, 3, 17, "For God did not send his Son"), + _sample_verse(45, 5, 8, "God shows his love for us"), + ] + temp_store.insert_verses(verses) + results = temp_store.search_text("God") + assert len(results) == 3 + results = temp_store.search_text("loved") + assert len(results) == 1 + + def test_count_verses(self, temp_store): + assert temp_store.count_verses() == 0 + temp_store.insert_verse(_sample_verse()) + assert temp_store.count_verses() == 1 + + def test_get_books(self, temp_store): + verses = [ + _sample_verse(1, 1, 1, "Genesis verse"), + _sample_verse(43, 1, 1, "John verse"), + ] + temp_store.insert_verses(verses) + books = temp_store.get_books() + assert len(books) == 2 + assert books[0]["name"] == "Genesis" + assert books[1]["name"] == "John" + + def test_cross_references(self, temp_store): + from scripture.models import CrossReference + xref = CrossReference( + source_verse_id=43003016, + target_verse_id=45005008, + reference_type="thematic", + confidence=0.9, + ) + temp_store.insert_cross_reference(xref) + results = temp_store.get_cross_references(43003016) + assert len(results) == 1 + assert results[0].target_verse_id == 45005008 + + def test_cross_references_bidirectional(self, temp_store): + from scripture.models import CrossReference + xref = CrossReference( + source_verse_id=43003016, + target_verse_id=45005008, + ) + temp_store.insert_cross_reference(xref) + # Query from target side + results = temp_store.get_cross_references(45005008) + assert len(results) == 1 + + def test_topics(self, temp_store): + from scripture.models import Topic + topic = Topic( + topic_id="love", + name="Love", + description="Biblical concept of love", + verse_ids=[43003016, 45005008], + ) + temp_store.insert_topic(topic) + result = temp_store.get_topic("love") + assert result is not None + assert result.name == "Love" + assert len(result.verse_ids) == 2 + + def test_search_topics(self, temp_store): + from scripture.models import Topic + temp_store.insert_topic(Topic(topic_id="love", name="Love")) + temp_store.insert_topic(Topic(topic_id="faith", name="Faith")) + results = temp_store.search_topics("lov") + assert len(results) == 1 + assert results[0].name == "Love" + + def test_get_verses_for_topic(self, temp_store): + from scripture.models import Topic + verse = _sample_verse() + temp_store.insert_verse(verse) + topic = Topic( + topic_id="love", + name="Love", + verse_ids=[verse.verse_id], + ) + temp_store.insert_topic(topic) + verses = temp_store.get_verses_for_topic("love") + assert len(verses) == 1 + assert verses[0].verse_id == verse.verse_id + + def test_strongs(self, temp_store): + from scripture.models import StrongsEntry + entry = StrongsEntry( + strongs_number="G26", + language="greek", + lemma="ἀγάπη", + transliteration="agape", + gloss="love", + ) + temp_store.insert_strongs(entry) + result = temp_store.get_strongs("G26") + assert result is not None + assert result.gloss == "love" + assert temp_store.get_strongs("G9999") is None + + def test_stats(self, temp_store): + stats = temp_store.stats() + assert stats["verses"] == 0 + assert stats["cross_references"] == 0 + assert stats["topics"] == 0 + assert stats["strongs_entries"] == 0 + + +# ════════════════════════════════════════════════════════════════════════════ +# Memory +# ════════════════════════════════════════════════════════════════════════════ + + +@pytest.fixture +def temp_memory(): + """Create a ScriptureMemory backed by a temp file.""" + from scripture.memory import ScriptureMemory + with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f: + db_path = f.name + mem = ScriptureMemory(db_path=db_path) + yield mem + mem.close() + Path(db_path).unlink(missing_ok=True) + + +class TestWorkingMemory: + def test_focus_and_retrieve(self): + from scripture.memory import WorkingMemory + wm = WorkingMemory(capacity=3) + v1 = _sample_verse(1, 1, 1, "Verse 1") + v2 = _sample_verse(1, 1, 2, "Verse 2") + wm.focus(v1) + wm.focus(v2) + assert len(wm) == 2 + focused = wm.get_focused() + assert focused[0].verse_id == v1.verse_id + assert focused[1].verse_id == v2.verse_id + + def test_capacity_eviction(self): + from scripture.memory import WorkingMemory + wm = WorkingMemory(capacity=2) + v1 = _sample_verse(1, 1, 1, "Verse 1") + v2 = _sample_verse(1, 1, 2, "Verse 2") + v3 = _sample_verse(1, 1, 3, "Verse 3") + wm.focus(v1) + wm.focus(v2) + wm.focus(v3) + assert len(wm) == 2 + assert not wm.is_focused(v1.verse_id) + assert wm.is_focused(v2.verse_id) + assert wm.is_focused(v3.verse_id) + + def test_refocus_moves_to_end(self): + from scripture.memory import WorkingMemory + wm = WorkingMemory(capacity=3) + v1 = _sample_verse(1, 1, 1, "Verse 1") + v2 = _sample_verse(1, 1, 2, "Verse 2") + wm.focus(v1) + wm.focus(v2) + wm.focus(v1) # Re-focus v1 + focused = wm.get_focused() + assert focused[-1].verse_id == v1.verse_id + + def test_clear(self): + from scripture.memory import WorkingMemory + wm = WorkingMemory() + wm.focus(_sample_verse()) + wm.clear() + assert len(wm) == 0 + + +class TestAssociativeMemory: + def test_meditation_state_persistence(self, temp_memory): + from scripture.models import MeditationState + state = temp_memory.associative.get_meditation_state() + assert state.current_book == 1 + assert state.mode == "sequential" + + state.advance(43, 3, 16) + state.mode = "thematic" + temp_memory.associative.save_meditation_state(state) + + loaded = temp_memory.associative.get_meditation_state() + assert loaded.current_book == 43 + assert loaded.current_chapter == 3 + assert loaded.current_verse == 16 + assert loaded.mode == "thematic" + assert loaded.verses_meditated == 1 + + def test_meditation_log(self, temp_memory): + temp_memory.associative.log_meditation(43003016, notes="Great verse") + temp_memory.associative.log_meditation(45005008, notes="Also good") + history = temp_memory.associative.get_meditation_history(limit=10) + assert len(history) == 2 + assert history[0]["verse_id"] == 45005008 # most recent first + + def test_meditation_count(self, temp_memory): + assert temp_memory.associative.meditation_count() == 0 + temp_memory.associative.log_meditation(1001001) + assert temp_memory.associative.meditation_count() == 1 + + def test_insights(self, temp_memory): + temp_memory.associative.add_insight( + 43003016, "God's love is unconditional", category="theology" + ) + insights = temp_memory.associative.get_insights(43003016) + assert len(insights) == 1 + assert insights[0]["category"] == "theology" + + def test_recent_insights(self, temp_memory): + temp_memory.associative.add_insight(1001001, "Creation narrative") + temp_memory.associative.add_insight(43003016, "Gospel core") + recent = temp_memory.associative.get_recent_insights(limit=5) + assert len(recent) == 2 + + def test_duplicate_insight_ignored(self, temp_memory): + temp_memory.associative.add_insight(43003016, "Same insight") + temp_memory.associative.add_insight(43003016, "Same insight") + insights = temp_memory.associative.get_insights(43003016) + assert len(insights) == 1 + + +class TestScriptureMemory: + def test_status(self, temp_memory): + status = temp_memory.status() + assert "working_memory_items" in status + assert "meditation_mode" in status + assert status["working_memory_items"] == 0 + + +# ════════════════════════════════════════════════════════════════════════════ +# Meditation Scheduler +# ════════════════════════════════════════════════════════════════════════════ + + +@pytest.fixture +def temp_scheduler(): + """Create a MeditationScheduler backed by temp stores.""" + from scripture.meditation import MeditationScheduler + from scripture.memory import ScriptureMemory + from scripture.store import ScriptureStore + with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f: + store_path = f.name + with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f: + mem_path = f.name + store = ScriptureStore(db_path=store_path) + memory = ScriptureMemory(db_path=mem_path) + scheduler = MeditationScheduler(store=store, memory=memory) + yield scheduler, store, memory + store.close() + memory.close() + Path(store_path).unlink(missing_ok=True) + Path(mem_path).unlink(missing_ok=True) + + +class TestMeditationScheduler: + def test_initial_state(self, temp_scheduler): + scheduler, _, _ = temp_scheduler + state = scheduler.state + assert state.mode == "sequential" + assert state.current_book == 1 + + def test_set_mode(self, temp_scheduler): + scheduler, _, _ = temp_scheduler + state = scheduler.set_mode("thematic", theme="love") + assert state.mode == "thematic" + assert state.theme == "love" + + def test_set_invalid_mode(self, temp_scheduler): + scheduler, _, _ = temp_scheduler + with pytest.raises(ValueError, match="Unknown mode"): + scheduler.set_mode("invalid") + + def test_next_sequential(self, temp_scheduler): + scheduler, store, _ = temp_scheduler + verses = [ + _sample_verse(1, 1, i, f"Genesis 1:{i}") + for i in range(1, 6) + ] + store.insert_verses(verses) + # State starts at 1:1:1, next should be 1:1:2 + result = scheduler.next_meditation() + assert result is not None + assert result.verse_num == 2 + + def test_sequential_chapter_advance(self, temp_scheduler): + scheduler, store, _ = temp_scheduler + # Only two verses in chapter 1, plus verse 1 of chapter 2 + store.insert_verses([ + _sample_verse(1, 1, 1, "Gen 1:1"), + _sample_verse(1, 1, 2, "Gen 1:2"), + _sample_verse(1, 2, 1, "Gen 2:1"), + ]) + # Start at 1:1:1 → next is 1:1:2 + v = scheduler.next_meditation() + assert v.verse_num == 2 + # Next should advance to 1:2:1 + v = scheduler.next_meditation() + assert v is not None + assert v.chapter == 2 + assert v.verse_num == 1 + + def test_current_focus_empty(self, temp_scheduler): + scheduler, _, _ = temp_scheduler + assert scheduler.current_focus() is None + + def test_meditate_on(self, temp_scheduler): + scheduler, store, memory = temp_scheduler + verse = _sample_verse() + store.insert_verse(verse) + scheduler.meditate_on(verse, notes="Reflecting on love") + assert memory.working.is_focused(verse.verse_id) + state = scheduler.state + assert state.verses_meditated == 1 + + def test_status(self, temp_scheduler): + scheduler, _, _ = temp_scheduler + status = scheduler.status() + assert "mode" in status + assert "current_book" in status + assert "verses_meditated" in status + + def test_history(self, temp_scheduler): + scheduler, store, _ = temp_scheduler + verse = _sample_verse() + store.insert_verse(verse) + scheduler.meditate_on(verse) + history = scheduler.history(limit=5) + assert len(history) == 1 + + def test_get_context(self, temp_scheduler): + scheduler, store, _ = temp_scheduler + verses = [_sample_verse(1, 1, i, f"Gen 1:{i}") for i in range(1, 6)] + store.insert_verses(verses) + ctx = scheduler.get_context(verses[2], before=1, after=1) + assert len(ctx) == 3 + + def test_get_cross_references(self, temp_scheduler): + from scripture.models import CrossReference + scheduler, store, _ = temp_scheduler + v1 = _sample_verse(43, 3, 16, "For God so loved") + v2 = _sample_verse(45, 5, 8, "God shows his love") + store.insert_verse(v1) + store.insert_verse(v2) + store.insert_cross_reference(CrossReference( + source_verse_id=v1.verse_id, + target_verse_id=v2.verse_id, + )) + xrefs = scheduler.get_cross_references(v1) + assert len(xrefs) == 1 + assert xrefs[0].verse_id == v2.verse_id + + +# ════════════════════════════════════════════════════════════════════════════ +# Routes +# ════════════════════════════════════════════════════════════════════════════ + + +@pytest.fixture +def scripture_client(tmp_path): + """TestClient with isolated scripture stores.""" + from scripture.meditation import MeditationScheduler + from scripture.memory import ScriptureMemory + from scripture.store import ScriptureStore + + store = ScriptureStore(db_path=tmp_path / "scripture.db") + memory = ScriptureMemory(db_path=tmp_path / "memory.db") + scheduler = MeditationScheduler(store=store, memory=memory) + + # Seed with some verses for route testing + store.insert_verses([ + _sample_verse(43, 3, 16, "For God so loved the world, that he gave his only Son, that whoever believes in him should not perish but have eternal life."), + _sample_verse(43, 3, 17, "For God did not send his Son into the world to condemn the world, but in order that the world might be saved through him."), + _sample_verse(45, 5, 8, "but God shows his love for us in that while we were still sinners, Christ died for us."), + _sample_verse(1, 1, 1, "In the beginning, God created the heavens and the earth."), + _sample_verse(1, 1, 2, "The earth was without form and void, and darkness was over the face of the deep."), + _sample_verse(1, 1, 3, "And God said, Let there be light, and there was light."), + ]) + + with patch("dashboard.routes.scripture.scripture_store", store), \ + patch("dashboard.routes.scripture.scripture_memory", memory), \ + patch("dashboard.routes.scripture.meditation_scheduler", scheduler): + from dashboard.app import app + with TestClient(app) as c: + yield c + + store.close() + memory.close() + + +class TestScriptureRoutes: + def test_scripture_status(self, scripture_client): + resp = scripture_client.get("/scripture") + assert resp.status_code == 200 + data = resp.json() + assert "store" in data + assert "memory" in data + assert "meditation" in data + + def test_get_verse(self, scripture_client): + resp = scripture_client.get("/scripture/verse", params={"ref": "John 3:16"}) + assert resp.status_code == 200 + data = resp.json() + assert data["reference"] == "John 3:16" + assert "loved" in data["text"] + + def test_get_verse_range(self, scripture_client): + resp = scripture_client.get("/scripture/verse", params={"ref": "John 3:16-17"}) + assert resp.status_code == 200 + data = resp.json() + assert "verses" in data + assert len(data["verses"]) == 2 + + def test_get_verse_bad_ref(self, scripture_client): + resp = scripture_client.get("/scripture/verse", params={"ref": "not a ref"}) + assert resp.status_code == 400 + + def test_get_verse_not_found(self, scripture_client): + resp = scripture_client.get("/scripture/verse", params={"ref": "Jude 1:25"}) + assert resp.status_code == 404 + + def test_get_chapter(self, scripture_client): + resp = scripture_client.get( + "/scripture/chapter", params={"book": "Genesis", "chapter": 1} + ) + assert resp.status_code == 200 + data = resp.json() + assert data["book"] == "Genesis" + assert len(data["verses"]) == 3 + + def test_get_chapter_bad_book(self, scripture_client): + resp = scripture_client.get( + "/scripture/chapter", params={"book": "FakeBook", "chapter": 1} + ) + assert resp.status_code == 400 + + def test_search(self, scripture_client): + resp = scripture_client.get("/scripture/search", params={"q": "God"}) + assert resp.status_code == 200 + data = resp.json() + assert data["count"] > 0 + + def test_search_empty(self, scripture_client): + resp = scripture_client.get("/scripture/search", params={"q": "xyznonexistent"}) + assert resp.status_code == 200 + assert resp.json()["count"] == 0 + + def test_meditate_get(self, scripture_client): + resp = scripture_client.get("/scripture/meditate") + assert resp.status_code == 200 + data = resp.json() + assert "status" in data + + def test_meditate_post(self, scripture_client): + resp = scripture_client.post("/scripture/meditate") + assert resp.status_code == 200 + data = resp.json() + assert "verse" in data + assert "status" in data + + def test_set_meditation_mode(self, scripture_client): + resp = scripture_client.post( + "/scripture/meditate/mode", params={"mode": "thematic", "theme": "love"} + ) + assert resp.status_code == 200 + assert resp.json()["mode"] == "thematic" + + def test_set_meditation_mode_invalid(self, scripture_client): + resp = scripture_client.post( + "/scripture/meditate/mode", params={"mode": "invalid"} + ) + assert resp.status_code == 400 + + def test_memory_status(self, scripture_client): + resp = scripture_client.get("/scripture/memory") + assert resp.status_code == 200 + data = resp.json() + assert "working_memory_items" in data + + def test_stats(self, scripture_client): + resp = scripture_client.get("/scripture/stats") + assert resp.status_code == 200 + data = resp.json() + assert "verses" in data + + def test_ingest(self, scripture_client): + payload = { + "verses": [ + {"book": 19, "chapter": 23, "verse_num": 1, "text": "The LORD is my shepherd; I shall not want."}, + {"book": 19, "chapter": 23, "verse_num": 2, "text": "He makes me lie down in green pastures."}, + ] + } + resp = scripture_client.post("/scripture/ingest", json=payload) + assert resp.status_code == 200 + data = resp.json() + assert data["ingested"] == 2 + + def test_ingest_invalid(self, scripture_client): + resp = scripture_client.post("/scripture/ingest", json={"verses": []}) + assert resp.status_code == 400 + + def test_ingest_bad_json(self, scripture_client): + resp = scripture_client.post( + "/scripture/ingest", + content=b"not json", + headers={"Content-Type": "application/json"}, + ) + assert resp.status_code == 400 + + def test_xref(self, scripture_client): + resp = scripture_client.get("/scripture/xref", params={"ref": "John 3:16"}) + assert resp.status_code == 200 + data = resp.json() + assert "source" in data + assert "cross_references" in data + + def test_xref_not_found(self, scripture_client): + resp = scripture_client.get("/scripture/xref", params={"ref": "Jude 1:25"}) + assert resp.status_code == 404