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