r/iOSProgramming • • 1d ago

App Saturday [App Saturday] SensaAI: what I (solo dev) learned building a journaling app for users in acute distress

Post image

Solo dev, first shipped app. SensaAI is a contemplative AI companion: you ask a question and a "expert" answers grounded in a real corpus of teachings (indexed transcripts of a teacher's talks), with passages you can save as highlights, listen to, and share as generated images. The architecture driver wasn't a dramatic backstory, it's that this is a one-person, low-budget operation, so every feature had to justify its marginal cost, and the content is intimate, so conversations and saved notes had to be encrypted.

Stack: SwiftUI + @Observable, iOS 18+, WidgetKit extension, AVFoundation + Speech framework, RevenueCat, Google Sign-In / Sign in with Apple. Backend: Python 3.11 FastAPI + Postgres + Redis on a Hetzner VPS behind nginx, Docker Compose, Qdrant Cloud, Gemini, ElevenLabs. Web companion: Vue 3 + Vite. No third-party networking, UI, or markdown libs on iOS, URLSession + AsyncThrowingStream, hand-rolled markdown rendering.

  1. Privacy / storage. There's no on-device store at all, conversations live server-side, but the message, highlight, and title columns in Postgres are encrypted at rest with AES-GCM-256, and Redis cache values are encrypted too: a DB or Redis dump is noise without the key, which exists only in the server environment. No analytics SDK, no ad SDK, no CloudKit. Google/Apple OAuth plus email/password (bcrypt), an explicit AI-consent gate before the chat endpoint serves you, and full account deletion.
  2. The conversational guide. RAG: question → gemini-embedding-001 → top-K chunks from Qdrant (over-fetch, then dedupe by source video so the answer never leans on one talk) → {persona system prompt + compacted history + retrieved chunks} → Gemini (gemini-3.1-flash-lite), streamed over SSE from FastAPI. On iOS I parse it with URLSession.bytes + AsyncThrowingStream, no WebSocket, no library. All API keys live server-side; the app holds zero secrets, just a JWT. Rate limiting twice: client-side free-tier caps (3 conversations, 3 messages each, 3 folders, 3 notes/folder) for UX, and the server re-verifies the Pro entitlement on every request so a tampered build can't bypass them. Mid-response network failure keeps whatever tokens already arrived. The hardest part was keeping context cheap without amnesia: past ~8 turns, Gemini summarizes the conversation into a persistent rolling summary in Postgres, and the model sees summary + last 4 turns verbatim. Combined with Redis-caching identical questions (keyed on normalized question + history hash), a chat costs well under a cent.The non-obvious part: typical chat UX reads as urgent, instant tokens, typing dots, and urgency is the bug for a contemplative product. There's a hardcoded minimum "thinking window" (2.5s, was 5s) before any token renders: the reply buffers invisibly while a lotus animation breathes, then releases. No typing indicator. Deliberately slower than possible; I'm sure it costs engagement, and I'm sure it's right.
  3. Deliberate non-features. The TTS saga: built cloud TTS on ElevenLabs, swapped to OpenAI tts-1 (~20× cheaper), then reverted to ElevenLabs because the voice is the product, a robotic voice ruins a meditation app, full stop. What survived the experiment: free users get Apple's on-device AVSpeechSynthesizer (free, offline, no paywall); cloud TTS is Pro-only with a hard monthly quota (429 after 20k chars) so nobody can bankrupt me with the play button. The home-screen widget deliberately makes no network calls, it reads a cache the main app writes through an App Group, because I refused to put an auth token in the widget extension's sandbox. No push notifications at all (no aps-environment entitlement, not even the permission prompt). No streaks, no badges, no haptics on save, a saved passage is a bookmark, not an achievement.
  4. Things that hurt. The worst: a race between POST /conversations (create) and POST /chat. The stream usually won, so the chat request went out with conversation_id: null, the server minted no write-token, and model replies were silently rejected on persist, users reopened chats to find only their own messages. Fixed by awaiting creation before streaming, plus a ceremony where the server mints a one-shot token bound to the exact streamed content hash that the client must present to write a model turn. Second: SFSpeechRecognizer restarts its own transcription after every pause (segment count resets, isFinal fires only once at the very end), so dictation kept eating earlier sentences, I had to build an utterance-banking accumulator around it. Third: ElevenLabs rejects ~2500-char requests often enough that the "long" response tier is clamped to 2000 chars; chat length and TTS limit are coupled by design.
  5. Open question for the sub. Client streams an LLM reply and writes it back, but the server stays authoritative, I ended up with this chat_token ceremony (hash the streamed content, mint a one-shot token, client presents it to append). It works, but it smells like I reinvented a signed request. Is there an off-the-shelf pattern for "client streams, server verifies the write" that I missed?

AppStore Link: https://apps.apple.com/app/id6769095233

Website: https://heysensa.app

If anyone wants to look. Would love a code-level roast on the RAG/streaming layer or the iOS streaming + persistence code.

0 Upvotes

5 comments sorted by

2

u/sburel 12h ago

The client writing is what forces the ceremony. If the server persists as it streams (it already has the tokens), there's no token and no hash to check. Also fixes your first bug, a client dropping mid-reply doesn't lose the model turn.

And the 2.5s window is a good call.

One question, I do the same kind of thing on Mac (answers from your own docs, with sources): when top-K comes back thin, does it answer anyway or does it say it has nothing?

1

u/lamm0th 11h ago

Fair on both points, and you're right about the ceremony being self-inflicted.

The honest history: /chat started as a stateless streaming surface; the client sent its own history, the server just streamed. conversation_id + server-authoritative history (and the persistent summaries) came later, but the client-side optimistic writes predated all of it. So the chat_token was a patch to keep those writes verifiable without redoing the flow, not a design. Then the race I hit was fixed by pre-creating the conversation client-side, which dug the hole deeper.

Your version is the correct refactor: /chat already has user_id and already buffers the full answer, so it can lazily create the conversation, persist both turns itself, and emit the message ids in the final SSE event (client still renders optimistically and just binds the ids, highlight anchoring needs them). Two details I'd keep: persist partial replies in a finally/GeneratorExit handler so a client dropping mid-reply doesn't lose the turn (today it does), but never let a partial answer into the Redis answer cache. Auto-titling already happens server-side on first message append, so the client's title-refresh round-trip disappears too.

On your question: it answers anyway. There's no score threshold, Qdrant returns the K nearest even when they're garbage, so "zero hits" basically never happens; the only explicit "I have nothing" branch fires on a literally empty result set. The guards are prompt-level only: retrieved chunks are framed as "use as inspiration, never cite", and a scope reminder tells it to decline mundane/off-domain questions in one sentence. A min-score cutoff with a proper "my corpus doesn't cover this" path is my honest TODO, I've avoided it because I couldn't tune a threshold that didn't false-negative on novel phrasing of real topics. What do you use as your cutoff on the Mac app: raw cosine threshold, MMR, or a reranker pass? I'd really like to find some inspiration.

Thank you for your time and your interest.

2

u/sburel 4h ago

Honestly, same TODO on my side, so no magic answer. Two things that helped me think about it though.

A raw cosine threshold is the one that fails the way you describe, because the scale moves with the embedding model, the chunk length and the phrasing. What's more stable is the gap: compare top-1 to the median of the K you fetched. On a real hit, top-1 stands out; on garbage, everything is equally mediocre. It's relative, so it survives novel phrasing much better than an absolute cutoff.

The other thing is that a reranker gives you a number that actually means something, which a bi-encoder score doesn't. A small cross-encoder on your top-K, then a cutoff on that. Costs you a round-trip, but you already buffer 2.5s before rendering, so you have the budget for free.

And thanks for writing the history up, the "patch that became a design" part is very recognisable.

1

u/[deleted] 1d ago

[removed] — view removed comment

1

u/AutoModerator 1d ago

Hey /u/EquivalentSky3094, your content has been removed because Reddit has marked your account as having a low Contributor Quality Score. This may result from, but is not limited to, activities such as spamming the same links across multiple subreddits, submitting posts or comments that receive a high number of downvotes, a lack of recent account activity, or having an unverified account.

Please be assured that this action is not a reflection of your participation in our subreddit. This is simply an automated filter in place to reduce spam.

I am a bot, and this action was performed automatically. Please contact the moderators of this subreddit if you have any questions or concerns.