Architecture¶
Ekklesia is structured as three independently deployable services connected through well-defined interfaces. The browser talks only to the frontend service; the frontend proxies API calls through nginx; the API orchestrates the agent pipeline against the database.
System diagram¶
graph TB
subgraph Browser
UI[React App - Vite / Tailwind]
end
subgraph Frontend [Frontend Service - nginx]
NGINX[nginx reverse proxy]
end
subgraph API [API Service - FastAPI]
APP[FastAPI app.py]
HEALTH[GET /health]
SERMONS[POST /sermons - SSE]
REVISE[POST /sermons/revise]
LOOKUP[GET /lookup and /passage]
ORCH[Orchestrator]
end
subgraph Agents [Agent Pipeline - Pydantic AI]
PLAN[Planner]
RES[Research]
EXE[Exegesis]
STR[Structure]
CRIT[Critique]
REV[Revision]
end
subgraph Retrieval
HYBRID[Hybrid Search]
EMBED[Embedding]
PASSAGE[Passage Lookup]
LEX[Lexicon Lookup]
XREF[Cross-Reference]
end
subgraph Storage [Storage - PostgreSQL + pgvector]
PG[(PostgreSQL)]
BIBLE[bible_passages / bible_verses]
COMM[commentary_chunks / commentary_parents]
CULT[cultural_entries]
LXDB[lexicon_entries]
XRDB[cross_references]
end
subgraph External
GEMINI[Google Gemini API]
LOGFIRE[Logfire]
end
UI -->|POST /sermons| NGINX
UI -->|POST /sermons/revise| NGINX
UI -->|GET /passage| NGINX
UI -->|GET /lookup| NGINX
NGINX -->|proxy_pass| APP
APP --> HEALTH
APP --> SERMONS
APP --> REVISE
APP --> LOOKUP
SERMONS --> ORCH
REVISE --> REV
ORCH --> PLAN
ORCH --> RES
ORCH --> EXE
ORCH --> STR
ORCH --> CRIT
REV -->|LLM| GEMINI
RES --> HYBRID
EXE --> HYBRID
HYBRID --> EMBED
HYBRID --> PG
EMBED -->|HTTP| GEMINI
PLAN -->|LLM| GEMINI
RES -->|LLM| GEMINI
EXE -->|LLM| GEMINI
STR -->|LLM| GEMINI
CRIT -->|LLM| GEMINI
LOOKUP --> PASSAGE
LOOKUP --> LEX
LOOKUP --> XREF
PASSAGE --> PG
LEX --> PG
XREF --> PG
PG --> BIBLE
PG --> COMM
PG --> CULT
PG --> LXDB
PG --> XRDB
APP -->|spans| LOGFIRE
Components¶
API layer (src/ekklesia/api/)¶
| File | Responsibility |
|---|---|
app.py |
Application factory; Logfire bootstrap; CORS middleware; router registration |
deps.py |
get_session FastAPI dependency — yields an async SQLAlchemy session per request |
routers/health.py |
GET /health — returns {"status": "ok"} |
routers/sermons.py |
POST /sermons — SSE stream via EventSourceResponse; POST /sermons/revise — applies critique revisions via RevisionResult |
routers/lookup.py |
/lookup/strongs/{id} and /passage/{reference:path} — thin HTTP façades over retrieval functions |
Agent pipeline (src/ekklesia/agents/)¶
| File | Responsibility |
|---|---|
orchestrator.py |
Runs all five agents in sequence; emits StageEvent objects; Logfire root span |
planner.py |
Produces a SermonPlan — passage reference, audience, main question, research/exegesis/structure goals |
research.py |
Calls hybrid search tools; produces ResearchFindings with passage summary, themes, cross-refs, commentary highlights |
exegesis.py |
Deep analysis of key scriptures; produces ExegesisNotes with word studies and theological moves |
structure.py |
Produces a SermonOutline — sections, transitions, illustrative content |
critique.py |
Reviews the full draft; produces CritiqueReport with strengths and suggestions |
revision.py |
Applies CritiqueReport.recommended_revisions to the final markdown; produces RevisionResult — invoked on-demand via POST /sermons/revise, not as part of the pipeline |
deps.py |
AgentDeps dataclass — holds SermonDraft state + DB session injected into each agent |
state.py |
SermonDraft — mutable accumulator updated by each stage |
events.py |
StageEvent Pydantic model; StageName and StageStatus literals |
tools/ |
Reusable Pydantic AI tools: lexicon_tool, cross_reference_tool |
Retrieval layer (src/ekklesia/retrieval/)¶
| File | Responsibility |
|---|---|
hybrid.py |
Runs dense + sparse queries per source type in parallel; fuses with RRF |
embedding.py |
embed_query() — async call to Gemini gemini-embedding-001; 768-dim output |
passage.py |
Parses a reference string (e.g. Romans 8:28) and fetches verses from bible_verses |
lexicon.py |
lookup_strongs() — point lookup on lexicon_entries by Strong's number |
cross_reference.py |
Returns related verse IDs from cross_references |
reference.py |
Reference string normalisation utilities |
verse_id.py |
Converts book/chapter/verse tuples to canonical verse IDs |
Data layer (src/ekklesia/)¶
| File | Responsibility |
|---|---|
db.py |
SQLAlchemy Table definitions; HNSW and GIN indexes; create_engine() |
config.py |
Settings (pydantic-settings); reads .env; normalises DATABASE_URL scheme |
models/ |
Shared Pydantic models: RetrievalResult, LexiconEntry, SourceType, primitives |
exceptions.py |
Domain exceptions: InvalidReference, PassageNotFound, LexiconEntryNotFound |
Ingestion scripts (scripts/)¶
| Script | Purpose |
|---|---|
ingest_bible.py |
Loads ESV OSIS XML → bible_passages + bible_verses; calls Gemini batch embedding API |
ingest_commentary.py |
Loads commentary JSON → commentary_chunks + commentary_parents |
ingest_lexicon.py |
Loads Strong's lexicon JSON → lexicon_entries |
ingest_cross_references.py |
Loads TSV cross-reference data → cross_references |
Key design decisions¶
Why SSE instead of WebSockets? SSE is unidirectional (server → client) and maps naturally to a one-way streaming pipeline. It works over plain HTTP/1.1, requires no upgrade handshake, and reconnects automatically in the browser. WebSockets add complexity with no benefit for this use case.
Why Pydantic AI? It enforces structured outputs at the type level — each agent returns a validated Pydantic model, not a raw string. This makes agent outputs composable (the planner's output is the next agent's input) and keeps mypy happy end-to-end.
Why pgvector over a dedicated vector database? The corpus is medium-sized (tens of thousands of passages) and shares a relational schema with lexicon, cross-reference, and verse data. Keeping everything in one PostgreSQL instance simplifies transactions, backup, and deployment. HNSW indexes give sub-millisecond ANN search at this scale.
Why Gemini embeddings over local sentence-transformers?
The gemini-embedding-001 model produces 768-dim embeddings with significantly better semantic coverage for biblical/theological language than all-MiniLM-L6-v2 (384-dim). The latency cost at ingestion time is acceptable; query-time latency is ~100ms per embed call.
Why hybrid (dense + sparse) retrieval?
Dense search finds semantically similar passages even when the exact words differ. Sparse search (PostgreSQL full-text search with websearch_to_tsquery) finds exact word matches and proper nouns (book names, people, places). Neither alone is sufficient for theological research; RRF fusion captures the best of both.
Why Logfire?
Logfire is OpenTelemetry-native and integrates directly with Pydantic AI. A single logfire.instrument_pydantic_ai() call captures every LLM call with token counts, tool use, and latency — without any manual instrumentation inside agent code.