Skip to content

Latest commit

 

History

History
executable file
·
202 lines (171 loc) · 12.5 KB

File metadata and controls

executable file
·
202 lines (171 loc) · 12.5 KB

APEX — Architecture

Real-life side quest app. Every user gets 5–8 personalised daily quests generated by a hybrid template + AI pipeline — no quest is ever hand-written per user, and the AI never invents quests from scratch.

┌─────────────────────┐         ┌──────────────────────────────────────────┐
│  Flutter app         │  HTTPS  │  Backend (Node + TypeScript + Fastify)  │
│  iOS / Android /     │ ──────► │                                          │
│  macOS / Win / Linux │  JSON   │  ┌────────────┐  ┌─────────────────────┐ │
│  / Web               │         │  │ Auth (JWT) │  │ Quest Generation    │ │
└─────────────────────┘         │  └────────────┘  │ Service             │ │
                                 │  ┌────────────┐  │  select → fill →    │ │
                                 │  │ REST routes│  │  render → validate  │ │
                                 │  └────────────┘  │  → persist          │ │
                                 │        │         └──────────┬──────────┘ │
                                 │        ▼                    ▼            │
                                 │  ┌──────────┐      ┌────────────────┐    │
                                 │  │ Database │      │ Claude API     │    │
                                 │  │ (SQLite  │      │ (variable fill,│    │
                                 │  │ dev / PG │      │ structured     │    │
                                 │  │ prod)    │      │ output)        │    │
                                 │  └──────────┘      └────────────────┘    │
                                 └──────────────────────────────────────────┘

Why this stack

  • Flutter — one codebase for iOS, Android, macOS, Windows, Linux and the website. Exactly the requirement.
  • Node + TypeScript + Fastify — fast, typed, huge ecosystem; the Anthropic SDK is first-class here.
  • SQLite (dev) → PostgreSQL (prod) — identical schema shipped for both (schema.sql, schema.postgres.sql). SQLite means npm start just works; Postgres is the scale path.

1. Database schema

Four tables (see server/db/schema.postgres.sql for the production version):

Table Purpose
users email + bcrypt password hash, XP, level, and the personalisation profile: interests (JSON array of categories), difficulty, optional location, optional age_range, timezone
quest_templates the reusable catalogue: description pattern with {slots}, typed variables (description + example pool per slot), category, difficulty, est_minutes (5–60), requires_photo, optional age gate
quests finalised per-user quests: rendered title/description, server-computed xp_reward, status (active/completed/skipped/expired), the filled variables (kept for repetition control), optional proof photo_path
generation_runs one row per (user, date) — makes daily generation idempotent and records whether AI or fallback produced the batch

Key design points:

  • Quests are denormalised snapshots. Once generated, a quest's text never changes even if the template is later edited.
  • variables is stored on each quest so the generator can tell the AI exactly which values (and titles) to avoid next time.
  • quest_date is the user's local date (computed from their timezone), so "daily" means their day.

2. Backend service architecture

server/src/
├── index.ts                  Fastify bootstrap, CORS, health
├── config.ts                 all tunables (quest counts, cooldowns, XP table, model)
├── db.ts                     SQLite open + schema + template seeding (upsert)
├── auth.ts                   signup/login (bcrypt), JWT issue/verify middleware
├── routes/
│   ├── auth.routes.ts        POST /auth/signup, /auth/login
│   ├── user.routes.ts        GET/PATCH /me, GET /categories
│   └── quest.routes.ts       GET /quests/today, /quests/history,
│                             POST /quests/:id/complete, /quests/:id/skip
├── services/
│   ├── questGenerator.ts     THE quest generation service (selection → persist)
│   └── aiFiller.ts           Claude structured-output call + deterministic fallback
└── seed/templates.ts         ~35 templates across 12 categories

Generation is lazy + idempotent: the first GET /quests/today of a user's day triggers generation; every later call returns the same rows. At scale you add a pre-generation batch job (see §7) — the service function is the same either way.

3. Quest generation algorithm

Per user, per local day (questGenerator.ts):

  1. Load context — profile + last 14 days of quests (titles and filled variable values).
  2. Select templates (pure code, no AI):
    • exclude templates the user did within the last 7 days (cooldown; auto-relaxes if the pool runs dry),
    • exclude age-gated templates the profile doesn't satisfy,
    • weighted random: interest-matching categories ×3, difficulty matching preference ×2 / adjacent ×1 / opposite ×0.25,
    • diversity cap: max one quest per category per day,
    • count scales with progression: 5 quests at level 1 → 6 at level 4 → 8 at level 10.
  3. Fill variables with AI — one Claude call per user fills all selected templates (cheaper and more coherent than per-quest calls). Falls back deterministically if the API is unavailable.
  4. Render + validate in code — pattern slots substituted; any quest with an unfilled slot is dropped; XP is computed server-side from difficulty + duration and never taken from the AI; duration/free/safe constraints live in the template, not the model.
  5. Persist in one transaction, plus a generation_runs row for idempotency.

Repetition control happens at two levels: template cooldown (code) and value/title avoidance (the AI is shown recently_used per template and instructed to avoid it; the fallback filter does the same against the example pool).

4. AI prompt design

See aiFiller.ts. The call shape:

  • System prompt (static, cached) — role ("quest writer for APEX"), the hard rules (5–60 min, free, safe/legal, broad audience, no equipment), and style rules for titles and values. It carries a cache_control: {type: "ephemeral"} breakpoint, so after the first request the per-user cost is mostly the small dynamic payload (~90% cheaper on the cached portion).
  • User message (dynamic, JSON)profile (interests, difficulty, level, location, age range), templates (id, category, pattern, title hint, per-variable description + examples), recently_used (per-template values/titles to avoid).
  • Structured outputoutput_config.format with a JSON schema, so the response is guaranteed-parseable: {quests: [{templateId, title, variables: [{name, value}]}]}. Variables are name/value pairs so one static schema covers every template (and a static schema hits the API's 24h schema-compilation cache).
  • Server-side guardrails after the call — unknown template ids dropped, missing variables backfilled by the fallback, titles truncated, XP/duration/photo flags always from the template, never the model.

Model & cost. Default is claude-opus-4-8 (set QUEST_MODEL to change). A generation request is small — roughly 1–2K input tokens (mostly cached) and ~300 output tokens. For thousands of users, run the nightly pre-generation through the Message Batches API (same request shape, 50% price, results well within an overnight window).

What AI is not used for — inventing quests from nothing, setting rewards, deciding which categories a user gets, or anything safety-critical. Those are code.

5. API endpoints

Method Path Auth Description
POST /auth/signup {email, password, displayName?}{id, email, token}
POST /auth/login {email, password}{id, email, token}
GET /me Bearer profile, XP, level
PATCH /me Bearer update interests, difficulty, location, ageRange, timezone, displayName
GET /categories the 12 quest categories
GET /quests/today Bearer today's quests (generates them on first call of the day)
GET /quests/history Bearer completed quests — the memory journal
POST /quests/:id/complete Bearer optional {photoBase64}; returns {xpAwarded, totalXp, level}
POST /quests/:id/skip Bearer mark skipped (no XP)
GET /health liveness + whether AI is enabled

Example quest object (matches the brief's example output):

{
  "title": "Reflection Hunter",
  "description": "Find and photograph three reflections in different places.",
  "category": "Photography",
  "difficulty": "Easy",
  "xpReward": 60,
  "estMinutes": 20,
  "requiresPhoto": true,
  "status": "active"
}

6. Categories & templates

12 categories: the 8 requested (Adventure, Photography, Fitness, Learning, Social, Creativity, Productivity, Food) plus Mindfulness, Nature, Kindness, Music. ~35 seed templates live in templates.ts; each has 2–6 example values per variable, so the catalogue already encodes thousands of distinct quests before the AI adds open-ended variety on top. Adding a template = adding one object to that file (it upserts into the DB on boot).

7. Scalability for thousands of users

What changes as you grow — the service code stays the same:

  1. Database: swap SQLite for Postgres (schema.postgres.sql is ready); the queries are vanilla SQL. Indexes on (user_id, quest_date) and (user_id, status) keep the hot paths O(log n).
  2. Pre-generation instead of lazy: a scheduled job per timezone bucket (cron at each zone's midnight) calls generateDailyQuests for active users, submitted as a Batches API job → 50% AI cost and zero generation latency at 8am peak. Lazy generation remains the safety net for users the job missed.
  3. Idempotency under concurrency: the generation_runs primary key makes double generation impossible even if the cron and a lazy request race (first insert wins; in Postgres wrap with ON CONFLICT DO NOTHING + re-read).
  4. Prompt caching: the static system prompt is shared across all users, so at volume nearly the whole prompt is a cache read (~0.1× input price).
  5. Cost envelope: ~1.5K input (cached after first) + ~300 output tokens per user-day. At 10K users on the Batches API this is a few dollars a day; switch QUEST_MODEL if you want a different cost/quality point.
  6. Stateless API: JWT auth means any number of server replicas behind a load balancer; the only shared state is the DB.
  7. Photos: dev stores uploads on disk; production should write to object storage (S3/GCS/Supabase Storage) and store the URL in photo_path — it's one function to swap in quest.routes.ts.
  8. Degradation path: AI outage ≠ outage. The deterministic fallback keeps daily quests flowing from template example pools; generation_runs.source records which path produced each batch so you can monitor it.

8. Safety & content rules (enforced, not hoped for)

Rule Where enforced
5–60 minutes template est_minutes constraint + prompt rule
No spending money template design + prompt rule
Safe & legal template design + prompt rule + curated example pools
Broad audience prompt rule + optional min_age_range gating in code
Photo proof optional requires_photo is encouragement; completion never requires a photo
XP integrity computed server-side only
No recent repeats template cooldown (code) + recently_used (AI) + example filtering (fallback)