erin.dev

Back to home

🪾 Weirwood.net

AI-powered literary analysis engine for A Song of Ice and Fire prophecies

Source Code

Live Demo

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

React 18TypeScriptFastAPIPython 3.12SQLModelSQLite + FTS5Anthropic Claude APID3.jsChakra UI v3TanStack QueryFramer MotionSSE (Server-Sent Events)Docker Composeuv

🚀 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 (not EventSource, which only supports GET). Structured protocol: status → N × connection/match → complete events

  • Content-Addressed Caching — SHA-256 hash of analysis inputs stored in an analysis_cache table. Cached results replay via the same SSE protocol as fresh results — the frontend never knows the difference

  • Markdown 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, and simulation.stop() is called in the effect cleanup to prevent memory leaks. A ResizeObserver feeds container dimensions as props, keeping the graph responsive without D3 touching layout

  • Designing 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 identically

  • Async SQLAlchemy with SQLite and FTS5 — SQLModel's async engine requires greenlet as 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 compute connection_count per prophecy without JOIN/GROUP BY overhead. Bidirectional connections (source ↔ target) require or_ in every query touching the connections table

  • Taming Chakra UI v3's breaking changes — v3 renamed Provider to ChakraProvider between minor versions, producing a blank white page with no visible error (only a console module import error). Compound component patterns (DrawerRoot/DrawerContent instead of Drawer) changed prop names (open vs isOpen, onOpenChange vs onClose). 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.py module 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 gracefully

  • URL-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's queryKey array caching (where filter params are part of the key), every unique filter combination gets its own cache entry — switching filters feels instant on revisit

  • SSE is the right abstraction for AI streaming, but EventSource is not — The browser's EventSource API only supports GET requests. Our SSE endpoints need POST with JSON bodies, so I built a custom useSSE hook using fetch + ReadableStream.getReader(). Options must be wrapped in useMemo to prevent infinite re-renders from reference changes — a subtle React gotcha that's easy to miss

  • Content-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