Back to home
Weirwood.net
AI-powered literary analysis engine for A Song of Ice and Fire prophecies
A full-stack prophecy tracker that catalogs 75 hand-curated prophecies across all five ASOIAF books and uses Claude AI to surface non-obvious thematic connections between them. Users can browse and filter a searchable prophecy database, generate AI-powered connections with confidence scores and textual evidence, analyze how events fulfill prophecies, and explore a D3 force-directed graph visualizing the entire prophecy network. AI responses stream in real-time via Server-Sent Events, and results are cached with SHA-256 content hashing to avoid redundant API calls. Built as a monorepo with a FastAPI async backend, React + TypeScript frontend, and SQLite with FTS5 full-text search — deployable with a single docker compose up command.
🛠️ Tech Stack
🚀 Features
Prophecy Dashboard — Card and table views with full-text search (FTS5), multi-dimensional filtering (book, type, status, character), URL-synced filter state for bookmarkable views, and debounced search inputs
AI Connection Finder — Per-prophecy analysis via Claude that identifies thematic parallels, shared fulfillment candidates, contradictions, and sequential links. Returns structured JSON with connection type, confidence score (0–100), and textual evidence — persisted to the database on generation
Fulfillment Analyzer — Describe any event (freeform text or pre-seeded canonical events) and the AI evaluates which unfulfilled prophecies it could satisfy, with ranked confidence scores and evidence-based reasoning
TWOW Predictions — Per-prophecy and global prediction reports for The Winds of Winter, streamed token-by-token with a real-time typing cursor. Global reports analyze all unfulfilled prophecies in a single comprehensive pass
Graph Explorer — Interactive D3 force-directed network graph where nodes are prophecies (colored by type, sized by connection count) and edges are AI-discovered connections (colored by relationship type). Supports zoom, pan, drag, hover tooltips, click-to-navigate, and filterable controls with a confidence threshold slider
Real-Time SSE Streaming — Long AI analyses stream progressively via
fetch+ReadableStream(notEventSource, which only supports GET). Structured protocol:status→ N ×connection/match→completeeventsContent-Addressed Caching — SHA-256 hash of analysis inputs stored in an
analysis_cachetable. Cached results replay via the same SSE protocol as fresh results — the frontend never knows the differenceMarkdown Export — Download any prophecy's full analysis (connections + predictions) as a formatted Markdown file, via both server-side assembly and client-side Blob generation
🧗♂️ Challenges
Bridging D3's imperative DOM model with React's declarative rendering — D3 wants to own the DOM; React wants to own the DOM. I solved this with a clean ownership boundary: React owns data flow (props, state, query hooks), D3 owns SVG rendering via
useRef+useEffect. D3 scales are extracted into pure functions shared across graph components, andsimulation.stop()is called in the effect cleanup to prevent memory leaks. AResizeObserverfeeds container dimensions as props, keeping the graph responsive without D3 touching layoutDesigning a dual SSE streaming architecture — The AI service needs complete JSON to validate and persist structured data (connections, fulfillment matches), but the frontend needs real-time progress. I used two patterns: for structured output, Claude returns full JSON (non-streaming) which the backend validates, persists, then replays as discrete SSE events. For prose output (predictions), the backend uses
client.messages.stream()and forwards token deltas directly. Same SSE protocol, different backend strategies — the frontend consumes both identicallyAsync SQLAlchemy with SQLite and FTS5 — SQLModel's async engine requires
greenletas a hidden runtime dependency (not installed by default). FTS5 virtual tables don't auto-sync with their content tables, so the seed loader must populate the index explicitly. Correlated subqueries computeconnection_countper prophecy without JOIN/GROUP BY overhead. Bidirectional connections (source ↔ target) requireor_in every query touching the connections tableTaming Chakra UI v3's breaking changes — v3 renamed
ProvidertoChakraProviderbetween minor versions, producing a blank white page with no visible error (only a console module import error). Compound component patterns (DrawerRoot/DrawerContentinstead ofDrawer) changed prop names (openvsisOpen,onOpenChangevsonClose). Most online examples and even official docs still reference v2 syntax — every component had to be verified against the installed version
🧠 Learnings
Prompt engineering is software engineering — Centralized all AI prompts in a dedicated
prompts.pymodule with template functions. Structured prompts with explicit output schemas, ID whitelists of valid prophecy references, and enum constraints. Claude still occasionally wraps JSON in markdown code fences despite instructions — defensive stripping in the service layer handles this gracefullyURL-as-state is underrated for data-heavy UIs — Storing all filter/sort/pagination state in URL search params (via
useSearchParams) gives you bookmarkable views, browser back/forward navigation, and shareable links for free. Combined with TanStack Query'squeryKeyarray caching (where filter params are part of the key), every unique filter combination gets its own cache entry — switching filters feels instant on revisitSSE is the right abstraction for AI streaming, but
EventSourceis not — The browser'sEventSourceAPI only supports GET requests. Our SSE endpoints need POST with JSON bodies, so I built a customuseSSEhook usingfetch+ReadableStream.getReader(). Options must be wrapped inuseMemoto prevent infinite re-renders from reference changes — a subtle React gotcha that's easy to missContent-addressed caching decouples cost from usage — Hashing the analysis input (event text, prophecy IDs, model name) with SHA-256 means identical requests hit the cache regardless of when or how many times they're made. Cached results replay through the same SSE event protocol, so the frontend code path is completely identical for cached vs. fresh responses. This let me add caching as a backend concern without touching a single frontend component