Add chat-first onboarding and intelligent context-based paywall

Replaces the hardcoded message limit with a model-driven paywall and
removes registration before the first question:

- Onboarding: the app opens directly into the chat. Dynamic conversation
  starters are served by GET /suggestions (services/api/suggestions.json),
  updatable with a deploy — no app release needed. Guests chat via
  POST /guest/chat, identified by an app-generated device id; sign-in
  moves to the paywall, where a purchase must attach to an account.
- Free experience: the model runs in discovery mode — follow-up
  questions, pattern identification, visible understanding — building an
  analysis without delivering the full solution.
- Intelligent paywall: the model returns structured output (reply +
  analysis_ready). Only when the problem is described, the information is
  sufficient and an action plan is ready does it write a calm transition
  and pause the conversation. Manufactured urgency, emotional pressure,
  fake readiness and mid-answer stops are explicitly forbidden.
- Premium: on unlock the app resends the transcript and the backend
  immediately delivers the full analysis, strategies and exercises, then
  the dialogue continues without restriction.
- The old FREE_MESSAGE_LIMIT becomes MESSAGE_CAP (default 200/30 days),
  kept purely as an abuse backstop — it is not the paywall.
- WelcomeScreen removed; the app is now two views (Chat, Paywall).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0118DaxZR36RpnY524vRqx3z
This commit is contained in:
Claude
2026-08-03 14:42:44 +00:00
parent 07fbeea09b
commit 6b7f0263e4
11 changed files with 498 additions and 221 deletions
+45 -15
View File
@@ -10,7 +10,7 @@ one dark blue-green accent. No animations, no gradients.
## System overview ## System overview
``` ```
apps/mobile Expo / React Native app (three views: Welcome, Chat, Paywall) apps/mobile Expo / React Native app (two views: Chat, Paywall)
services/api One Lambda backend (chat, usage, subscription verification) services/api One Lambda backend (chat, usage, subscription verification)
infra AWS CDK stack (the entire cloud environment) infra AWS CDK stack (the entire cloud environment)
db/migrations SQL schema (two tables: users, usage) db/migrations SQL schema (two tables: users, usage)
@@ -22,16 +22,42 @@ it is not built.
### Request flow ### Request flow
1. The app signs in via the **Cognito** hosted UI (Apple / Google / email) 1. The user lands directly in the chat — no registration before the first
and receives a JWT. question. Guests are identified by an app-generated device id
2. `POST /chat` goes through **API Gateway** (JWT authorizer) to the single (`POST /guest/chat`); dynamic conversation starters come from
**Lambda**. `GET /suggestions` (both public routes). The suggestions live in
3. The Lambda checks the free-tier quota in **PostgreSQL** (Aurora `services/api/suggestions.json` and can be updated with a deploy — no
Serverless v2). Over the limit and not subscribed → HTTP 402 → the app app release needed.
shows the paywall. 2. Requests go through **API Gateway** to the single **Lambda**; signed-in
4. Otherwise it calls the **OpenAI Responses API** with the Markdown users use `POST /chat` with a **Cognito** JWT (Apple / Google / email via
knowledge base (`services/api/knowledge/`) as system instructions and the hosted UI).
returns the reply. 3. The Lambda calls the **OpenAI Responses API** with the Markdown knowledge
base (`services/api/knowledge/`) as system instructions. The model
returns structured output: a reply plus an `analysis_ready` flag.
4. Sign-in happens at the paywall, since a purchase must attach to an
account. Usage counters live in **PostgreSQL** (Aurora Serverless v2).
### The intelligent paywall
The paywall is not a hardcoded message count. Free conversations run in
_discovery mode_: the model asks relevant follow-up questions, names
patterns, shows understanding, and builds toward an analysis — without
delivering the full solution. When the problem is described, the information
is sufficient, and a concrete action plan is ready, the model signals
`analysis_ready`, writes a calm transition ("…I have a concrete strategy I
would recommend. Continue with Premium to see the analysis and the
recommended steps."), and the conversation pauses.
The instructions explicitly forbid manufactured urgency, emotional pressure,
fake readiness, and stopping mid-answer — Premium should feel like the
natural continuation of an already valuable dialogue.
On unlock, the app resends the transcript; premium mode then delivers the
full analysis, recommended strategies and concrete exercises immediately,
and the dialogue continues without restriction.
A generous `MESSAGE_CAP` (default 200 per 30 days) exists purely as an
abuse backstop for the free tier — it is not the paywall.
Conversations are never stored server-side; the client holds them in memory Conversations are never stored server-side; the client holds them in memory
and sends the running transcript with each request. The database stores the and sends the running transcript with each request. The database stores the
@@ -51,9 +77,6 @@ provider differs per platform behind one `PaymentProvider` interface:
Plans: Monthly and Yearly. Nothing else. Plans: Monthly and Yearly. Nothing else.
Free tier: `FREE_MESSAGE_LIMIT` messages (default 50 ≈ 510 conversations),
resetting every `USAGE_RESET_DAYS` days.
## Getting started ## Getting started
```sh ```sh
@@ -100,12 +123,19 @@ GitHub Actions: `ci.yml` lints, type-checks and tests every PR;
## Security ## Security
- All traffic over HTTPS; every API route requires a Cognito JWT. - All traffic over HTTPS. Account routes require a Cognito JWT; the two
public routes (`/suggestions`, `/guest/chat`) carry no account data and
are bounded by the free-tier message cap.
- Secrets live in AWS Secrets Manager only — no API keys in the client. - Secrets live in AWS Secrets Manager only — no API keys in the client.
- The Lambda runs in private subnets; the database is not publicly reachable. - The Lambda runs in private subnets; the database is not publicly reachable.
## Product philosophy ## Product philosophy
Every free user should leave the app feeling that the system understood
their situation, that a concrete analysis is ready, and that the next step
is available in Premium — never that they were held back by an artificial
interruption.
Every new feature must justify itself. The allowed AWS surface is Every new feature must justify itself. The allowed AWS surface is
API Gateway, Lambda, Cognito, S3, Secrets Manager and CloudWatch — and V1 API Gateway, Lambda, Cognito, S3, Secrets Manager and CloudWatch — and V1
does not even need S3. No Redis, no Kubernetes, no Kafka, no Elasticsearch, does not even need S3. No Redis, no Kubernetes, no Kafka, no Elasticsearch,
+31 -15
View File
@@ -1,29 +1,45 @@
import { useEffect, useState } from 'react'; import { useState } from 'react';
import { StatusBar } from 'expo-status-bar'; import { StatusBar } from 'expo-status-bar';
import { getAccessToken } from './src/auth/session'; import type { ChatMessage } from './src/api/client';
import { ChatScreen } from './src/screens/ChatScreen'; import { ChatScreen } from './src/screens/ChatScreen';
import { PaywallScreen } from './src/screens/PaywallScreen'; import { PaywallScreen } from './src/screens/PaywallScreen';
import { WelcomeScreen } from './src/screens/WelcomeScreen';
type View = 'loading' | 'welcome' | 'chat' | 'paywall';
/** /**
* Three views, no navigation library. The backend decides when the paywall * Two views, no navigation library. The user lands directly in the chat —
* appears (HTTP 402 on /chat). * no registration before the first question. The backend decides the paywall
* moment (the analysis is ready); after unlock the conversation continues
* with the full analysis delivered immediately.
*/ */
export default function App() { export default function App() {
const [view, setView] = useState<View>('loading'); const [view, setView] = useState<'chat' | 'paywall'>('chat');
const [messages, setMessages] = useState<ChatMessage[]>([]);
useEffect(() => { const [paywalled, setPaywalled] = useState(false);
getAccessToken().then((token) => setView(token ? 'chat' : 'welcome')); const [deliverAnalysis, setDeliverAnalysis] = useState(false);
}, []);
return ( return (
<> <>
<StatusBar style="dark" /> <StatusBar style="dark" />
{view === 'welcome' && <WelcomeScreen onSignedIn={() => setView('chat')} />} {view === 'chat' && (
{view === 'chat' && <ChatScreen onLimitReached={() => setView('paywall')} />} <ChatScreen
{view === 'paywall' && <PaywallScreen onSubscribed={() => setView('chat')} />} messages={messages}
setMessages={setMessages}
paywalled={paywalled}
onPaywallTriggered={() => setPaywalled(true)}
onContinueWithPremium={() => setView('paywall')}
deliverAnalysis={deliverAnalysis}
onAnalysisDelivered={() => setDeliverAnalysis(false)}
/>
)}
{view === 'paywall' && (
<PaywallScreen
onSubscribed={() => {
setPaywalled(false);
setDeliverAnalysis(true);
setView('chat');
}}
onDismiss={() => setView('chat')}
/>
)}
</> </>
); );
} }
+53 -18
View File
@@ -1,3 +1,5 @@
import * as Crypto from 'expo-crypto';
import * as SecureStore from 'expo-secure-store';
import { appConfig } from '../config'; import { appConfig } from '../config';
import { getAccessToken } from '../auth/session'; import { getAccessToken } from '../auth/session';
@@ -16,23 +18,38 @@ export interface ChatMessage {
content: string; content: string;
} }
export interface ChatReply {
reply: string;
/** True when the backend judged the analysis ready — the paywall moment. */
paywall: boolean;
}
export interface Me { export interface Me {
subscriptionStatus: 'free' | 'active'; subscriptionStatus: 'free' | 'active';
messagesUsed: number; messagesUsed: number;
freeMessageLimit: number; messageCap: number;
} }
async function request<T>(path: string, init?: RequestInit): Promise<T> { const DEVICE_KEY = 'neurosemantics.device';
const token = await getAccessToken();
if (!token) throw new ApiError(401, 'not_signed_in');
/** Stable anonymous id so guests can chat before signing in. */
async function getDeviceId(): Promise<string> {
let id = await SecureStore.getItemAsync(DEVICE_KEY);
if (!id) {
id = Crypto.randomUUID();
await SecureStore.setItemAsync(DEVICE_KEY, id);
}
return id;
}
async function request<T>(
path: string,
init: RequestInit = {},
headers: Record<string, string> = {},
): Promise<T> {
const response = await fetch(`${appConfig.apiUrl}${path}`, { const response = await fetch(`${appConfig.apiUrl}${path}`, {
...init, ...init,
headers: { headers: { 'Content-Type': 'application/json', ...headers },
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
...init?.headers,
},
}); });
if (!response.ok) { if (!response.ok) {
@@ -47,21 +64,39 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
return (await response.json()) as T; return (await response.json()) as T;
} }
export function fetchMe(): Promise<Me> { async function authHeader(): Promise<Record<string, string>> {
return request<Me>('/me'); const token = await getAccessToken();
if (!token) throw new ApiError(401, 'not_signed_in');
return { Authorization: `Bearer ${token}` };
} }
export function sendChat(messages: ChatMessage[]): Promise<{ reply: string }> { export async function fetchSuggestions(): Promise<string[]> {
return request<{ reply: string }>('/chat', { const data = await request<{ suggestions: string[] }>('/suggestions');
method: 'POST', return data.suggestions;
body: JSON.stringify({ messages }),
});
} }
export function verifyPurchase(body: { /** Signed-in users chat via /chat; guests via /guest/chat with a device id. */
export async function sendChat(messages: ChatMessage[]): Promise<ChatReply> {
const body = { method: 'POST', body: JSON.stringify({ messages }) };
const token = await getAccessToken();
if (token) {
return request<ChatReply>('/chat', body, { Authorization: `Bearer ${token}` });
}
return request<ChatReply>('/guest/chat', body, { 'X-Device-Id': await getDeviceId() });
}
export async function fetchMe(): Promise<Me> {
return request<Me>('/me', {}, await authHeader());
}
export async function verifyPurchase(body: {
platform: 'ios' | 'android'; platform: 'ios' | 'android';
productId: string; productId: string;
receipt: string; receipt: string;
}): Promise<{ subscriptionStatus: 'free' | 'active' }> { }): Promise<{ subscriptionStatus: 'free' | 'active' }> {
return request('/subscription/verify', { method: 'POST', body: JSON.stringify(body) }); return request(
'/subscription/verify',
{ method: 'POST', body: JSON.stringify(body) },
await authHeader(),
);
} }
+95 -19
View File
@@ -1,4 +1,4 @@
import { useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { import {
FlatList, FlatList,
KeyboardAvoidingView, KeyboardAvoidingView,
@@ -9,54 +9,104 @@ import {
TextInput, TextInput,
View, View,
} from 'react-native'; } from 'react-native';
import { ApiError, sendChat, type ChatMessage } from '../api/client'; import { fetchSuggestions, sendChat, type ChatMessage } from '../api/client';
import { colors, spacing, type } from '../theme'; import { colors, spacing, type } from '../theme';
interface Props { interface Props {
onLimitReached: () => void; messages: ChatMessage[];
setMessages: (messages: ChatMessage[]) => void;
/** The analysis is ready behind Premium; input is replaced by the unlock button. */
paywalled: boolean;
onPaywallTriggered: () => void;
onContinueWithPremium: () => void;
/** Set right after unlock: fetch the promised analysis automatically. */
deliverAnalysis: boolean;
onAnalysisDelivered: () => void;
} }
const SUGGESTIONS_SHOWN = 4;
/** /**
* The whole product: one heading, the conversation, a text field and send. * The whole product: one heading, the conversation, a text field and send.
* Conversations live in memory only — nothing is stored. * The user lands here directly — no registration before the first question.
* Conversations live in memory only; nothing is stored.
*/ */
export function ChatScreen({ onLimitReached }: Props) { export function ChatScreen({
const [messages, setMessages] = useState<ChatMessage[]>([]); messages,
setMessages,
paywalled,
onPaywallTriggered,
onContinueWithPremium,
deliverAnalysis,
onAnalysisDelivered,
}: Props) {
const [suggestions, setSuggestions] = useState<string[]>([]);
const [draft, setDraft] = useState(''); const [draft, setDraft] = useState('');
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const listRef = useRef<FlatList<ChatMessage>>(null); const listRef = useRef<FlatList<ChatMessage>>(null);
async function send() { useEffect(() => {
const content = draft.trim(); fetchSuggestions()
if (!content || busy) return; .then((all) =>
const next: ChatMessage[] = [...messages, { role: 'user', content }]; setSuggestions([...all].sort(() => Math.random() - 0.5).slice(0, SUGGESTIONS_SHOWN)),
)
.catch(() => setSuggestions([]));
}, []);
// After unlocking Premium the transcript ends with the assistant's
// transition message; resend it so the backend delivers the full analysis.
useEffect(() => {
if (!deliverAnalysis || busy) return;
setBusy(true);
setError(null);
sendChat(messages)
.then(({ reply }) => {
setMessages([...messages, { role: 'assistant', content: reply }]);
onAnalysisDelivered();
})
.catch(() => setError('Something went wrong. Please try again.'))
.finally(() => setBusy(false));
}, [deliverAnalysis]);
async function send(content: string) {
const trimmed = content.trim();
if (!trimmed || busy || paywalled) return;
const next: ChatMessage[] = [...messages, { role: 'user', content: trimmed }];
setMessages(next); setMessages(next);
setDraft(''); setDraft('');
setBusy(true); setBusy(true);
setError(null); setError(null);
try { try {
const { reply } = await sendChat(next); const { reply, paywall } = await sendChat(next);
setMessages([...next, { role: 'assistant', content: reply }]); setMessages([...next, { role: 'assistant', content: reply }]);
} catch (e) { if (paywall) onPaywallTriggered();
if (e instanceof ApiError && e.status === 402) { } catch {
onLimitReached();
} else {
setError('Something went wrong. Please try again.'); setError('Something went wrong. Please try again.');
setMessages(messages); setMessages(messages);
setDraft(content); setDraft(trimmed);
}
} finally { } finally {
setBusy(false); setBusy(false);
} }
} }
const showSuggestions = messages.length === 0 && suggestions.length > 0;
return ( return (
<KeyboardAvoidingView <KeyboardAvoidingView
style={styles.container} style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : undefined} behavior={Platform.OS === 'ios' ? 'padding' : undefined}
> >
<Text style={styles.heading}>NeuroSemantics AI</Text> <Text style={styles.heading}>NeuroSemantics AI</Text>
{showSuggestions ? (
<View style={styles.suggestions}>
{suggestions.map((s) => (
<Pressable key={s} style={styles.suggestion} onPress={() => send(s)} disabled={busy}>
<Text style={styles.suggestionText}>{s}</Text>
</Pressable>
))}
</View>
) : null}
<FlatList <FlatList
ref={listRef} ref={listRef}
style={styles.list} style={styles.list}
@@ -74,6 +124,13 @@ export function ChatScreen({ onLimitReached }: Props) {
)} )}
/> />
{error ? <Text style={styles.error}>{error}</Text> : null} {error ? <Text style={styles.error}>{error}</Text> : null}
{paywalled ? (
<View style={styles.inputRow}>
<Pressable style={styles.unlock} onPress={onContinueWithPremium}>
<Text style={styles.unlockText}>Continue with Premium</Text>
</Pressable>
</View>
) : (
<View style={styles.inputRow}> <View style={styles.inputRow}>
<TextInput <TextInput
style={styles.input} style={styles.input}
@@ -86,12 +143,13 @@ export function ChatScreen({ onLimitReached }: Props) {
/> />
<Pressable <Pressable
style={[styles.send, (!draft.trim() || busy) && styles.sendDisabled]} style={[styles.send, (!draft.trim() || busy) && styles.sendDisabled]}
onPress={send} onPress={() => send(draft)}
disabled={!draft.trim() || busy} disabled={!draft.trim() || busy}
> >
<Text style={styles.sendText}>{busy ? '…' : 'Send'}</Text> <Text style={styles.sendText}>{busy ? '…' : 'Send'}</Text>
</Pressable> </Pressable>
</View> </View>
)}
</KeyboardAvoidingView> </KeyboardAvoidingView>
); );
} }
@@ -104,9 +162,19 @@ const styles = StyleSheet.create({
paddingTop: spacing.xxl, paddingTop: spacing.xxl,
paddingBottom: spacing.m, paddingBottom: spacing.m,
}, },
suggestions: { paddingHorizontal: spacing.l, gap: spacing.s, marginBottom: spacing.m },
suggestion: {
backgroundColor: colors.surface,
borderColor: colors.border,
borderWidth: 1,
borderRadius: 10,
paddingVertical: spacing.s + 2,
paddingHorizontal: spacing.m,
},
suggestionText: { ...type.body, color: colors.accent },
list: { flex: 1 }, list: { flex: 1 },
listContent: { paddingHorizontal: spacing.l, paddingBottom: spacing.m, gap: spacing.s }, listContent: { paddingHorizontal: spacing.l, paddingBottom: spacing.m, gap: spacing.s },
empty: { ...type.body, color: colors.textMuted, textAlign: 'center', marginTop: spacing.xxl }, empty: { ...type.body, color: colors.textMuted, textAlign: 'center', marginTop: spacing.xl },
message: { maxWidth: '85%', borderRadius: 12, padding: spacing.m }, message: { maxWidth: '85%', borderRadius: 12, padding: spacing.m },
user: { alignSelf: 'flex-end', backgroundColor: colors.accent }, user: { alignSelf: 'flex-end', backgroundColor: colors.accent },
assistant: { assistant: {
@@ -144,4 +212,12 @@ const styles = StyleSheet.create({
}, },
sendDisabled: { opacity: 0.4 }, sendDisabled: { opacity: 0.4 },
sendText: { ...type.heading, color: colors.accentText }, sendText: { ...type.heading, color: colors.accentText },
unlock: {
flex: 1,
backgroundColor: colors.accent,
borderRadius: 12,
paddingVertical: spacing.m,
alignItems: 'center',
},
unlockText: { ...type.heading, color: colors.accentText },
}); });
+73 -7
View File
@@ -1,16 +1,40 @@
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native'; import { Pressable, StyleSheet, Text, View } from 'react-native';
import { getAccessToken, signIn, type Provider } from '../auth/session';
import { purchase, restore, type Plan } from '../purchases'; import { purchase, restore, type Plan } from '../purchases';
import { colors, spacing, type } from '../theme'; import { colors, spacing, type } from '../theme';
interface Props { interface Props {
onSubscribed: () => void; onSubscribed: () => void;
onDismiss: () => void;
} }
export function PaywallScreen({ onSubscribed }: Props) { /**
* Shown when the backend has a complete analysis ready. Guests sign in
* first (purchases must attach to an account), then choose a plan.
*/
export function PaywallScreen({ onSubscribed, onDismiss }: Props) {
const [signedIn, setSignedIn] = useState<boolean | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => {
getAccessToken().then((token) => setSignedIn(token !== null));
}, []);
async function handleSignIn(provider: Provider) {
setBusy(true);
setError(null);
try {
await signIn(provider);
setSignedIn(true);
} catch {
setError('Sign-in did not complete. Please try again.');
} finally {
setBusy(false);
}
}
async function run(action: () => Promise<'free' | 'active'>) { async function run(action: () => Promise<'free' | 'active'>) {
setBusy(true); setBusy(true);
setError(null); setError(null);
@@ -35,20 +59,52 @@ export function PaywallScreen({ onSubscribed }: Props) {
<View style={styles.top}> <View style={styles.top}>
<Text style={styles.title}>Continue your journey</Text> <Text style={styles.title}>Continue your journey</Text>
<Text style={styles.subtitle}> <Text style={styles.subtitle}>
You have used your free conversations. Subscribe to continue. Your analysis and recommended steps are ready. Premium unlocks them and lets the
conversation continue without limits.
</Text> </Text>
</View> </View>
<View style={styles.actions}> <View style={styles.actions}>
{signedIn === false ? (
<>
<Pressable
style={styles.primaryButton}
onPress={() => handleSignIn('apple')}
disabled={busy}
>
<Text style={styles.primaryButtonText}>Continue with Apple</Text>
</Pressable>
<Pressable
style={styles.secondaryButton}
onPress={() => handleSignIn('google')}
disabled={busy}
>
<Text style={styles.secondaryButtonText}>Continue with Google</Text>
</Pressable>
<Pressable
style={styles.secondaryButton}
onPress={() => handleSignIn('email')}
disabled={busy}
>
<Text style={styles.secondaryButtonText}>Continue with Email</Text>
</Pressable>
</>
) : signedIn === true ? (
<>
<Pressable style={styles.primaryButton} onPress={() => buy('monthly')} disabled={busy}> <Pressable style={styles.primaryButton} onPress={() => buy('monthly')} disabled={busy}>
<Text style={styles.primaryButtonText}>Monthly</Text> <Text style={styles.primaryButtonText}>Monthly</Text>
</Pressable> </Pressable>
<Pressable style={styles.primaryButton} onPress={() => buy('yearly')} disabled={busy}> <Pressable style={styles.primaryButton} onPress={() => buy('yearly')} disabled={busy}>
<Text style={styles.primaryButtonText}>Yearly</Text> <Text style={styles.primaryButtonText}>Yearly</Text>
</Pressable> </Pressable>
<Pressable style={styles.restore} onPress={() => run(restore)} disabled={busy}> <Pressable style={styles.textButton} onPress={() => run(restore)} disabled={busy}>
<Text style={styles.restoreText}>Restore Purchase</Text> <Text style={styles.textButtonText}>Restore Purchase</Text>
</Pressable> </Pressable>
</>
) : null}
{error ? <Text style={styles.error}>{error}</Text> : null} {error ? <Text style={styles.error}>{error}</Text> : null}
<Pressable style={styles.textButton} onPress={onDismiss} disabled={busy}>
<Text style={styles.dismissText}>Not now</Text>
</Pressable>
</View> </View>
</View> </View>
); );
@@ -77,7 +133,17 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
}, },
primaryButtonText: { ...type.heading, color: colors.accentText }, primaryButtonText: { ...type.heading, color: colors.accentText },
restore: { alignItems: 'center', paddingVertical: spacing.m }, secondaryButton: {
restoreText: { ...type.body, color: colors.accent }, backgroundColor: colors.surface,
borderColor: colors.border,
borderWidth: 1,
paddingVertical: spacing.m,
borderRadius: 10,
alignItems: 'center',
},
secondaryButtonText: { ...type.heading },
textButton: { alignItems: 'center', paddingVertical: spacing.m },
textButtonText: { ...type.body, color: colors.accent },
dismissText: { ...type.caption },
error: { ...type.caption, color: '#8A3B2E', textAlign: 'center' }, error: { ...type.caption, color: '#8A3B2E', textAlign: 'center' },
}); });
-98
View File
@@ -1,98 +0,0 @@
import { useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { signIn, type Provider } from '../auth/session';
import { colors, spacing, type } from '../theme';
interface Props {
onSignedIn: () => void;
}
export function WelcomeScreen({ onSignedIn }: Props) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSignIn(provider: Provider) {
setBusy(true);
setError(null);
try {
await signIn(provider);
onSignedIn();
} catch {
setError('Sign-in did not complete. Please try again.');
} finally {
setBusy(false);
}
}
return (
<View style={styles.container}>
<View style={styles.top}>
<Text style={styles.title}>NeuroSemantics AI</Text>
<Text style={styles.subtitle}>
A quiet conversation partner for neurosemantics and NLP.
</Text>
</View>
<View style={styles.actions}>
<Pressable
style={styles.primaryButton}
onPress={() => handleSignIn('apple')}
disabled={busy}
>
<Text style={styles.primaryButtonText}>Continue with Apple</Text>
</Pressable>
<Pressable
style={styles.secondaryButton}
onPress={() => handleSignIn('google')}
disabled={busy}
>
<Text style={styles.secondaryButtonText}>Continue with Google</Text>
</Pressable>
<Pressable
style={styles.secondaryButton}
onPress={() => handleSignIn('email')}
disabled={busy}
>
<Text style={styles.secondaryButtonText}>Continue with Email</Text>
</Pressable>
{error ? <Text style={styles.error}>{error}</Text> : null}
<Text style={styles.footnote}>Start free. No card required.</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
paddingHorizontal: spacing.l,
justifyContent: 'space-between',
},
top: { marginTop: spacing.xxl * 2 },
title: { ...type.title, textAlign: 'center' },
subtitle: {
...type.body,
color: colors.textMuted,
textAlign: 'center',
marginTop: spacing.m,
},
actions: { marginBottom: spacing.xxl, gap: spacing.s },
primaryButton: {
backgroundColor: colors.accent,
paddingVertical: spacing.m,
borderRadius: 10,
alignItems: 'center',
},
primaryButtonText: { ...type.heading, color: colors.accentText },
secondaryButton: {
backgroundColor: colors.surface,
borderColor: colors.border,
borderWidth: 1,
paddingVertical: spacing.m,
borderRadius: 10,
alignItems: 'center',
},
secondaryButtonText: { ...type.heading },
error: { ...type.caption, color: '#8A3B2E', textAlign: 'center', marginTop: spacing.s },
footnote: { ...type.caption, textAlign: 'center', marginTop: spacing.m },
});
+17 -2
View File
@@ -1,5 +1,5 @@
import { CfnOutput, Duration, RemovalPolicy, Stack, type StackProps } from 'aws-cdk-lib'; import { CfnOutput, Duration, RemovalPolicy, Stack, type StackProps } from 'aws-cdk-lib';
import { HttpApi, HttpMethod } from 'aws-cdk-lib/aws-apigatewayv2'; import { HttpApi, HttpMethod, HttpNoneAuthorizer } from 'aws-cdk-lib/aws-apigatewayv2';
import { HttpJwtAuthorizer } from 'aws-cdk-lib/aws-apigatewayv2-authorizers'; import { HttpJwtAuthorizer } from 'aws-cdk-lib/aws-apigatewayv2-authorizers';
import { HttpLambdaIntegration } from 'aws-cdk-lib/aws-apigatewayv2-integrations'; import { HttpLambdaIntegration } from 'aws-cdk-lib/aws-apigatewayv2-integrations';
import * as cognito from 'aws-cdk-lib/aws-cognito'; import * as cognito from 'aws-cdk-lib/aws-cognito';
@@ -141,7 +141,7 @@ export class NeuroSemanticsStack extends Stack {
DB_SECRET_ARN: db.secret?.secretArn ?? '', DB_SECRET_ARN: db.secret?.secretArn ?? '',
DB_NAME, DB_NAME,
KNOWLEDGE_DIR: 'knowledge', KNOWLEDGE_DIR: 'knowledge',
FREE_MESSAGE_LIMIT: '50', MESSAGE_CAP: '200',
USAGE_RESET_DAYS: '30', USAGE_RESET_DAYS: '30',
}, },
}); });
@@ -161,6 +161,21 @@ export class NeuroSemanticsStack extends Stack {
api.addRoutes({ path: '/chat', methods: [HttpMethod.POST], integration }); api.addRoutes({ path: '/chat', methods: [HttpMethod.POST], integration });
api.addRoutes({ path: '/subscription/verify', methods: [HttpMethod.POST], integration }); api.addRoutes({ path: '/subscription/verify', methods: [HttpMethod.POST], integration });
// Public routes: the user meets the chat before any registration.
const publicAuthorizer = new HttpNoneAuthorizer();
api.addRoutes({
path: '/suggestions',
methods: [HttpMethod.GET],
integration,
authorizer: publicAuthorizer,
});
api.addRoutes({
path: '/guest/chat',
methods: [HttpMethod.POST],
integration,
authorizer: publicAuthorizer,
});
new CfnOutput(this, 'ApiUrl', { value: api.apiEndpoint }); new CfnOutput(this, 'ApiUrl', { value: api.apiEndpoint });
new CfnOutput(this, 'UserPoolId', { value: userPool.userPoolId }); new CfnOutput(this, 'UserPoolId', { value: userPool.userPoolId });
new CfnOutput(this, 'UserPoolClientId', { value: userPoolClient.userPoolClientId }); new CfnOutput(this, 'UserPoolClientId', { value: userPoolClient.userPoolClientId });
+82 -4
View File
@@ -7,6 +7,14 @@ export interface ChatMessage {
content: string; content: string;
} }
export type ChatMode = 'free' | 'premium';
export interface ChatResult {
reply: string;
/** Free mode only: the model judges a complete analysis is ready to present. */
analysisReady: boolean;
}
const BASE_INSTRUCTIONS = `You are NeuroSemantics AI — a calm, precise conversation partner const BASE_INSTRUCTIONS = `You are NeuroSemantics AI — a calm, precise conversation partner
specialized in neurosemantics and NLP (Neuro-Linguistic Programming). specialized in neurosemantics and NLP (Neuro-Linguistic Programming).
@@ -19,16 +27,74 @@ Principles:
distress or a medical condition, recommend seeking professional help. distress or a medical condition, recommend seeking professional help.
- Answer in the language the user writes in.`; - Answer in the language the user writes in.`;
const FREE_INSTRUCTIONS = `# Conversation mode: free tier (discovery)
Work in discovery mode. In every reply you should:
- ask relevant follow-up questions (one at a time),
- identify and name patterns you notice,
- show genuine understanding of the user's situation,
- build toward a complete analysis.
Do not yet present the full analysis, the recommended strategy, or concrete
exercises.
Set "analysis_ready" to true ONLY when all of the following are genuinely met:
- the user has described their problem,
- you have enough information to give a concrete, personal recommendation,
- a specific action plan is ready to present.
When analysis_ready is true, the reply must be a calm, natural transition —
not an interruption mid-answer. Summarize at a high level what you have
understood and that a concrete strategy is ready. Example of tone:
"I think I'm starting to understand what lies behind this situation, and I
can see some clear communication patterns. I also have a concrete strategy
I would recommend for your specific situation. Continue with Premium to see
the analysis and the recommended steps."
Never:
- manufacture urgency or emotional pressure to drive a purchase,
- claim readiness or insight you do not have,
- stop in the middle of answering a direct question,
- mention Premium in any other situation.
Otherwise, set analysis_ready to false.`;
const PREMIUM_INSTRUCTIONS = `# Conversation mode: premium
The user has full access. Deliver complete value:
- when your analysis is ready, present it in full: the analysis, recommended
strategies, and concrete exercises,
- if the conversation ends with your own message announcing that an analysis
is ready, the user has just unlocked Premium — deliver the full analysis
and the recommended steps now, without being asked again,
- continue the dialogue without restriction.
Always set "analysis_ready" to false; it is not used in this mode.`;
const RESPONSE_SCHEMA = {
type: 'object',
properties: {
reply: { type: 'string' },
analysis_ready: { type: 'boolean' },
},
required: ['reply', 'analysis_ready'],
additionalProperties: false,
} as const;
/** /**
* Calls the OpenAI Responses API with the knowledge base as system * Calls the OpenAI Responses API with the knowledge base as system
* instructions and the conversation as input. Conversations are held by the * instructions and the conversation as input. Conversations are held by the
* client and passed through — nothing is persisted server-side. * client and passed through — nothing is persisted server-side.
*
* The model returns structured output so the backend — not a hardcoded
* message count — decides when the paywall moment has arrived.
*/ */
export async function generateReply(messages: ChatMessage[]): Promise<string> { export async function generateReply(messages: ChatMessage[], mode: ChatMode): Promise<ChatResult> {
const appSecret = await getSecret(config.appSecretArn); const appSecret = await getSecret(config.appSecretArn);
const apiKey = appSecret.OPENAI_API_KEY; const apiKey = appSecret.OPENAI_API_KEY;
if (!apiKey) throw new Error('OPENAI_API_KEY missing from application secret'); if (!apiKey) throw new Error('OPENAI_API_KEY missing from application secret');
const modeInstructions = mode === 'premium' ? PREMIUM_INSTRUCTIONS : FREE_INSTRUCTIONS;
const response = await fetch('https://api.openai.com/v1/responses', { const response = await fetch('https://api.openai.com/v1/responses', {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -37,8 +103,16 @@ export async function generateReply(messages: ChatMessage[]): Promise<string> {
}, },
body: JSON.stringify({ body: JSON.stringify({
model: config.openAiModel, model: config.openAiModel,
instructions: `${BASE_INSTRUCTIONS}\n\n# Knowledge base\n\n${loadKnowledgeBase()}`, instructions: `${BASE_INSTRUCTIONS}\n\n${modeInstructions}\n\n# Knowledge base\n\n${loadKnowledgeBase()}`,
input: messages.map((m) => ({ role: m.role, content: m.content })), input: messages.map((m) => ({ role: m.role, content: m.content })),
text: {
format: {
type: 'json_schema',
name: 'chat_turn',
strict: true,
schema: RESPONSE_SCHEMA,
},
},
}), }),
}); });
@@ -56,7 +130,11 @@ export async function generateReply(messages: ChatMessage[]): Promise<string> {
.filter((part) => part.type === 'output_text') .filter((part) => part.type === 'output_text')
.map((part) => part.text ?? '') .map((part) => part.text ?? '')
.join(''); .join('');
if (!text) throw new Error('OpenAI response contained no output text'); if (!text) throw new Error('OpenAI response contained no output text');
return text;
const parsed = JSON.parse(text) as { reply: string; analysis_ready: boolean };
return {
reply: parsed.reply,
analysisReady: mode === 'free' && parsed.analysis_ready === true,
};
} }
+6 -2
View File
@@ -3,8 +3,12 @@
* variables (set by the CDK stack) — secrets never live here. * variables (set by the CDK stack) — secrets never live here.
*/ */
export const config = { export const config = {
/** Number of free messages before the paywall is shown (~510 conversations). */ /**
freeMessageLimit: Number(process.env.FREE_MESSAGE_LIMIT ?? '50'), * Abuse backstop, NOT the paywall. The paywall is intelligent: the model
* decides when an analysis is ready. This cap only bounds how many free
* messages a single user/device can send per reset window.
*/
messageCap: Number(process.env.MESSAGE_CAP ?? '200'),
/** Free-tier usage resets after this many days. */ /** Free-tier usage resets after this many days. */
usageResetDays: Number(process.env.USAGE_RESET_DAYS ?? '30'), usageResetDays: Number(process.env.USAGE_RESET_DAYS ?? '30'),
openAiModel: process.env.OPENAI_MODEL ?? 'gpt-4.1-mini', openAiModel: process.env.OPENAI_MODEL ?? 'gpt-4.1-mini',
+56 -15
View File
@@ -1,10 +1,11 @@
import type { APIGatewayProxyEventV2WithJWTAuthorizer, APIGatewayProxyResultV2 } from 'aws-lambda'; import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from 'aws-lambda';
import { generateReply, type ChatMessage } from './chat.js'; import { generateReply, type ChatMessage } from './chat.js';
import { config } from './config.js'; import { config } from './config.js';
import { getOrCreateUser, getUsage, incrementUsage, resetUsage, type UserRow } from './db.js'; import { getOrCreateUser, getUsage, incrementUsage, resetUsage, type UserRow } from './db.js';
import { verifyAndApplyPurchase } from './subscription/index.js'; import { verifyAndApplyPurchase } from './subscription/index.js';
import type { VerifyPurchaseRequest } from './subscription/types.js'; import type { VerifyPurchaseRequest } from './subscription/types.js';
import { canSendMessage, shouldResetUsage } from './usage.js'; import { canSendMessage, shouldResetUsage } from './usage.js';
import suggestionsFile from '../suggestions.json';
function json(statusCode: number, body: unknown): APIGatewayProxyResultV2 { function json(statusCode: number, body: unknown): APIGatewayProxyResultV2 {
return { return {
@@ -20,10 +21,17 @@ interface Identity {
provider: string; provider: string;
} }
function identityFromClaims(event: APIGatewayProxyEventV2WithJWTAuthorizer): Identity { /** Reads the Cognito JWT claims on authorized routes; null on public routes. */
const claims = event.requestContext.authorizer.jwt.claims; function identityFromEvent(event: APIGatewayProxyEventV2): Identity | null {
const authorizer = (
event.requestContext as {
authorizer?: { jwt?: { claims?: Record<string, unknown> } };
}
).authorizer;
const claims = authorizer?.jwt?.claims;
if (!claims) return null;
const sub = String(claims.sub ?? ''); const sub = String(claims.sub ?? '');
if (!sub) throw new Error('JWT is missing a sub claim'); if (!sub) return null;
const email = String(claims.email ?? ''); const email = String(claims.email ?? '');
// Cognito sets an `identities` claim for federated sign-ins (Apple/Google). // Cognito sets an `identities` claim for federated sign-ins (Apple/Google).
const identities = String(claims.identities ?? ''); const identities = String(claims.identities ?? '');
@@ -49,29 +57,43 @@ async function handleMe(user: UserRow): Promise<APIGatewayProxyResultV2> {
return json(200, { return json(200, {
subscriptionStatus: user.subscription_status, subscriptionStatus: user.subscription_status,
messagesUsed: usage.messages_used, messagesUsed: usage.messages_used,
freeMessageLimit: config.freeMessageLimit, messageCap: config.messageCap,
}); });
} }
/**
* The paywall is intelligent: no hardcoded message count. In free mode the
* model works in discovery (follow-up questions, patterns, understanding)
* and signals `analysisReady` when a concrete, valuable answer could be
* delivered — that is the paywall moment, returned as `paywall: true`.
* Premium mode delivers the full analysis, including immediately after
* unlock (the transcript then ends with the assistant's transition message).
*/
async function handleChat( async function handleChat(
user: UserRow, user: UserRow,
body: string | undefined, body: string | undefined,
): Promise<APIGatewayProxyResultV2> { ): Promise<APIGatewayProxyResultV2> {
const premium = user.subscription_status === 'active';
const parsed = body ? (JSON.parse(body) as { messages?: ChatMessage[] }) : {}; const parsed = body ? (JSON.parse(body) as { messages?: ChatMessage[] }) : {};
const messages = parsed.messages ?? []; const messages = parsed.messages ?? [];
const last = messages[messages.length - 1]; const last = messages[messages.length - 1];
if (!last || last.role !== 'user' || typeof last.content !== 'string' || !last.content.trim()) { const validLast =
last &&
typeof last.content === 'string' &&
last.content.trim() &&
(last.role === 'user' || (premium && last.role === 'assistant'));
if (!validLast) {
return json(400, { error: 'messages must end with a non-empty user message' }); return json(400, { error: 'messages must end with a non-empty user message' });
} }
const usage = await currentUsage(user); const usage = await currentUsage(user);
if (!canSendMessage(usage.messages_used, user.subscription_status, config.freeMessageLimit)) { if (!canSendMessage(usage.messages_used, user.subscription_status, config.messageCap)) {
return json(402, { error: 'free_limit_reached' }); return json(402, { error: 'message_cap_reached' });
} }
const reply = await generateReply(messages); const result = await generateReply(messages, premium ? 'premium' : 'free');
await incrementUsage(user.id); await incrementUsage(user.id);
return json(200, { reply }); return json(200, { reply: result.reply, paywall: result.analysisReady });
} }
async function handleVerifyPurchase( async function handleVerifyPurchase(
@@ -90,14 +112,33 @@ async function handleVerifyPurchase(
return json(200, { subscriptionStatus: status }); return json(200, { subscriptionStatus: status });
} }
export async function handler( /** Guests chat before signing in, identified by an app-generated device id. */
event: APIGatewayProxyEventV2WithJWTAuthorizer, async function handleGuestChat(event: APIGatewayProxyEventV2): Promise<APIGatewayProxyResultV2> {
): Promise<APIGatewayProxyResultV2> { const deviceId = event.headers?.['x-device-id'] ?? '';
if (!/^[0-9a-fA-F-]{8,64}$/.test(deviceId)) {
return json(400, { error: 'a valid X-Device-Id header is required' });
}
const user = await getOrCreateUser(`guest:${deviceId}`, '', 'guest');
return handleChat(user, event.body);
}
export async function handler(event: APIGatewayProxyEventV2): Promise<APIGatewayProxyResultV2> {
try { try {
const identity = identityFromClaims(event);
const user = await getOrCreateUser(identity.sub, identity.email, identity.provider);
const route = `${event.requestContext.http.method} ${event.rawPath}`; const route = `${event.requestContext.http.method} ${event.rawPath}`;
// Public routes (no sign-in before the first question).
if (route === 'GET /suggestions') {
return json(200, { suggestions: suggestionsFile.suggestions });
}
if (route === 'POST /guest/chat') {
return await handleGuestChat(event);
}
// Authorized routes (Cognito JWT, enforced by API Gateway).
const identity = identityFromEvent(event);
if (!identity) return json(401, { error: 'unauthorized' });
const user = await getOrCreateUser(identity.sub, identity.email, identity.provider);
switch (route) { switch (route) {
case 'GET /me': case 'GET /me':
return await handleMe(user); return await handleMe(user);
+14
View File
@@ -0,0 +1,14 @@
{
"suggestions": [
"I need support before a difficult conversation.",
"Help me prepare for a salary negotiation.",
"I'm going on a first date.",
"Why do I get stuck in the same conflicts?",
"Help me understand my reactions.",
"How do I set clearer boundaries?",
"I want to become a better communicator.",
"Help me prepare for a job interview.",
"How do I build better self-confidence?",
"Help me make a difficult decision."
]
}