Merge pull request #53 from AlexanderWhitestone/claude/sovereign-biblical-ai-design-0nuHW

Add scripture module: ESV text storage, parsing, and meditation
This commit is contained in:
Alexander Whitestone
2026-02-26 12:16:56 -05:00
committed by GitHub
17 changed files with 2730 additions and 0 deletions

View File

@@ -0,0 +1,19 @@
# Self-Modify Report: 20260226_170416
**Instruction:** Add docstring
**Target files:** src/foo.py
**Dry run:** True
**Backend:** ollama
**Branch:** N/A
**Result:** SUCCESS
**Error:** none
**Commit:** none
**Attempts:** 1
**Autonomous cycles:** 0
## Attempt 1 -- dry_run
### LLM Response
```
llm raw
```

View File

@@ -0,0 +1,31 @@
# Self-Modify Report: 20260226_170416
**Instruction:** Break it
**Target files:** src/foo.py
**Dry run:** False
**Backend:** ollama
**Branch:** N/A
**Result:** FAILED
**Error:** Tests failed after 1 attempt(s).
**Commit:** none
**Attempts:** 1
**Autonomous cycles:** 0
## Attempt 1 -- complete
### LLM Response
```
llm raw
```
### Edits Written
#### src/foo.py
```python
x = 1
```
### Test Result: FAILED
```
1 failed
```

View File

@@ -0,0 +1,12 @@
# Self-Modify Report: 20260226_170416
**Instruction:** do something vague
**Target files:** (auto-detected)
**Dry run:** False
**Backend:** ollama
**Branch:** N/A
**Result:** FAILED
**Error:** No target files identified. Specify target_files or use more specific language.
**Commit:** none
**Attempts:** 0
**Autonomous cycles:** 0

View File

@@ -0,0 +1,31 @@
# Self-Modify Report: 20260226_170416
**Instruction:** Fix foo
**Target files:** src/foo.py
**Dry run:** False
**Backend:** ollama
**Branch:** N/A
**Result:** FAILED
**Error:** Tests failed after 1 attempt(s).
**Commit:** none
**Attempts:** 1
**Autonomous cycles:** 0
## Attempt 1 -- complete
### LLM Response
```
llm raw
```
### Edits Written
#### src/foo.py
```python
x = 2
```
### Test Result: FAILED
```
FAILED
```

View File

@@ -0,0 +1,34 @@
# Self-Modify Report: 20260226_170416
**Instruction:** Fix foo
IMPORTANT CORRECTION from previous failure:
Fix: do X instead of Y
**Target files:** src/foo.py
**Dry run:** False
**Backend:** ollama
**Branch:** N/A
**Result:** SUCCESS
**Error:** none
**Commit:** abc123
**Attempts:** 1
**Autonomous cycles:** 0
## Attempt 1 -- complete
### LLM Response
```
llm raw
```
### Edits Written
#### src/foo.py
```python
x = 2
```
### Test Result: PASSED
```
PASSED
```

View File

@@ -99,6 +99,7 @@ include = [
"src/agent_core",
"src/lightning",
"src/self_modify",
"src/scripture",
]
[tool.pytest.ini_options]

View File

@@ -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",

View File

@@ -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)

View File

@@ -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,
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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()

901
tests/test_scripture.py Normal file
View File

@@ -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