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:
@@ -10,7 +10,7 @@ one dark blue-green accent. No animations, no gradients.
|
||||
## 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)
|
||||
infra AWS CDK stack (the entire cloud environment)
|
||||
db/migrations SQL schema (two tables: users, usage)
|
||||
@@ -22,16 +22,42 @@ it is not built.
|
||||
|
||||
### Request flow
|
||||
|
||||
1. The app signs in via the **Cognito** hosted UI (Apple / Google / email)
|
||||
and receives a JWT.
|
||||
2. `POST /chat` goes through **API Gateway** (JWT authorizer) to the single
|
||||
**Lambda**.
|
||||
3. The Lambda checks the free-tier quota in **PostgreSQL** (Aurora
|
||||
Serverless v2). Over the limit and not subscribed → HTTP 402 → the app
|
||||
shows the paywall.
|
||||
4. Otherwise it calls the **OpenAI Responses API** with the Markdown
|
||||
knowledge base (`services/api/knowledge/`) as system instructions and
|
||||
returns the reply.
|
||||
1. The user lands directly in the chat — no registration before the first
|
||||
question. Guests are identified by an app-generated device id
|
||||
(`POST /guest/chat`); dynamic conversation starters come from
|
||||
`GET /suggestions` (both public routes). The suggestions live in
|
||||
`services/api/suggestions.json` and can be updated with a deploy — no
|
||||
app release needed.
|
||||
2. Requests go through **API Gateway** to the single **Lambda**; signed-in
|
||||
users use `POST /chat` with a **Cognito** JWT (Apple / Google / email via
|
||||
the hosted UI).
|
||||
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
|
||||
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.
|
||||
|
||||
Free tier: `FREE_MESSAGE_LIMIT` messages (default 50 ≈ 5–10 conversations),
|
||||
resetting every `USAGE_RESET_DAYS` days.
|
||||
|
||||
## Getting started
|
||||
|
||||
```sh
|
||||
@@ -100,12 +123,19 @@ GitHub Actions: `ci.yml` lints, type-checks and tests every PR;
|
||||
|
||||
## 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.
|
||||
- The Lambda runs in private subnets; the database is not publicly reachable.
|
||||
|
||||
## 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
|
||||
API Gateway, Lambda, Cognito, S3, Secrets Manager and CloudWatch — and V1
|
||||
does not even need S3. No Redis, no Kubernetes, no Kafka, no Elasticsearch,
|
||||
|
||||
+31
-15
@@ -1,29 +1,45 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
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 { 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
|
||||
* appears (HTTP 402 on /chat).
|
||||
* Two views, no navigation library. The user lands directly in the 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() {
|
||||
const [view, setView] = useState<View>('loading');
|
||||
|
||||
useEffect(() => {
|
||||
getAccessToken().then((token) => setView(token ? 'chat' : 'welcome'));
|
||||
}, []);
|
||||
const [view, setView] = useState<'chat' | 'paywall'>('chat');
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [paywalled, setPaywalled] = useState(false);
|
||||
const [deliverAnalysis, setDeliverAnalysis] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<StatusBar style="dark" />
|
||||
{view === 'welcome' && <WelcomeScreen onSignedIn={() => setView('chat')} />}
|
||||
{view === 'chat' && <ChatScreen onLimitReached={() => setView('paywall')} />}
|
||||
{view === 'paywall' && <PaywallScreen onSubscribed={() => setView('chat')} />}
|
||||
{view === 'chat' && (
|
||||
<ChatScreen
|
||||
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')}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import * as Crypto from 'expo-crypto';
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import { appConfig } from '../config';
|
||||
import { getAccessToken } from '../auth/session';
|
||||
|
||||
@@ -16,23 +18,38 @@ export interface ChatMessage {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface ChatReply {
|
||||
reply: string;
|
||||
/** True when the backend judged the analysis ready — the paywall moment. */
|
||||
paywall: boolean;
|
||||
}
|
||||
|
||||
export interface Me {
|
||||
subscriptionStatus: 'free' | 'active';
|
||||
messagesUsed: number;
|
||||
freeMessageLimit: number;
|
||||
messageCap: number;
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const token = await getAccessToken();
|
||||
if (!token) throw new ApiError(401, 'not_signed_in');
|
||||
const DEVICE_KEY = 'neurosemantics.device';
|
||||
|
||||
/** 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}`, {
|
||||
...init,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
...init?.headers,
|
||||
},
|
||||
headers: { 'Content-Type': 'application/json', ...headers },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -47,21 +64,39 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
export function fetchMe(): Promise<Me> {
|
||||
return request<Me>('/me');
|
||||
async function authHeader(): Promise<Record<string, string>> {
|
||||
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 }> {
|
||||
return request<{ reply: string }>('/chat', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ messages }),
|
||||
});
|
||||
export async function fetchSuggestions(): Promise<string[]> {
|
||||
const data = await request<{ suggestions: string[] }>('/suggestions');
|
||||
return data.suggestions;
|
||||
}
|
||||
|
||||
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';
|
||||
productId: string;
|
||||
receipt: string;
|
||||
}): 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(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
FlatList,
|
||||
KeyboardAvoidingView,
|
||||
@@ -9,54 +9,104 @@ import {
|
||||
TextInput,
|
||||
View,
|
||||
} 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';
|
||||
|
||||
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.
|
||||
* 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) {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
export function ChatScreen({
|
||||
messages,
|
||||
setMessages,
|
||||
paywalled,
|
||||
onPaywallTriggered,
|
||||
onContinueWithPremium,
|
||||
deliverAnalysis,
|
||||
onAnalysisDelivered,
|
||||
}: Props) {
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||
const [draft, setDraft] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const listRef = useRef<FlatList<ChatMessage>>(null);
|
||||
|
||||
async function send() {
|
||||
const content = draft.trim();
|
||||
if (!content || busy) return;
|
||||
const next: ChatMessage[] = [...messages, { role: 'user', content }];
|
||||
useEffect(() => {
|
||||
fetchSuggestions()
|
||||
.then((all) =>
|
||||
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);
|
||||
setDraft('');
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { reply } = await sendChat(next);
|
||||
const { reply, paywall } = await sendChat(next);
|
||||
setMessages([...next, { role: 'assistant', content: reply }]);
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 402) {
|
||||
onLimitReached();
|
||||
} else {
|
||||
setError('Something went wrong. Please try again.');
|
||||
setMessages(messages);
|
||||
setDraft(content);
|
||||
}
|
||||
if (paywall) onPaywallTriggered();
|
||||
} catch {
|
||||
setError('Something went wrong. Please try again.');
|
||||
setMessages(messages);
|
||||
setDraft(trimmed);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const showSuggestions = messages.length === 0 && suggestions.length > 0;
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={styles.container}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
>
|
||||
<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
|
||||
ref={listRef}
|
||||
style={styles.list}
|
||||
@@ -74,24 +124,32 @@ export function ChatScreen({ onLimitReached }: Props) {
|
||||
)}
|
||||
/>
|
||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||
<View style={styles.inputRow}>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={draft}
|
||||
onChangeText={setDraft}
|
||||
placeholder="Write a message"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
multiline
|
||||
editable={!busy}
|
||||
/>
|
||||
<Pressable
|
||||
style={[styles.send, (!draft.trim() || busy) && styles.sendDisabled]}
|
||||
onPress={send}
|
||||
disabled={!draft.trim() || busy}
|
||||
>
|
||||
<Text style={styles.sendText}>{busy ? '…' : 'Send'}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
{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}>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={draft}
|
||||
onChangeText={setDraft}
|
||||
placeholder="Write a message"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
multiline
|
||||
editable={!busy}
|
||||
/>
|
||||
<Pressable
|
||||
style={[styles.send, (!draft.trim() || busy) && styles.sendDisabled]}
|
||||
onPress={() => send(draft)}
|
||||
disabled={!draft.trim() || busy}
|
||||
>
|
||||
<Text style={styles.sendText}>{busy ? '…' : 'Send'}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
@@ -104,9 +162,19 @@ const styles = StyleSheet.create({
|
||||
paddingTop: spacing.xxl,
|
||||
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 },
|
||||
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 },
|
||||
user: { alignSelf: 'flex-end', backgroundColor: colors.accent },
|
||||
assistant: {
|
||||
@@ -144,4 +212,12 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
sendDisabled: { opacity: 0.4 },
|
||||
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 },
|
||||
});
|
||||
|
||||
@@ -1,16 +1,40 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { getAccessToken, signIn, type Provider } from '../auth/session';
|
||||
import { purchase, restore, type Plan } from '../purchases';
|
||||
import { colors, spacing, type } from '../theme';
|
||||
|
||||
interface Props {
|
||||
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 [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'>) {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
@@ -35,20 +59,52 @@ export function PaywallScreen({ onSubscribed }: Props) {
|
||||
<View style={styles.top}>
|
||||
<Text style={styles.title}>Continue your journey</Text>
|
||||
<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>
|
||||
</View>
|
||||
<View style={styles.actions}>
|
||||
<Pressable style={styles.primaryButton} onPress={() => buy('monthly')} disabled={busy}>
|
||||
<Text style={styles.primaryButtonText}>Monthly</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.primaryButton} onPress={() => buy('yearly')} disabled={busy}>
|
||||
<Text style={styles.primaryButtonText}>Yearly</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.restore} onPress={() => run(restore)} disabled={busy}>
|
||||
<Text style={styles.restoreText}>Restore Purchase</Text>
|
||||
</Pressable>
|
||||
{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}>
|
||||
<Text style={styles.primaryButtonText}>Monthly</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.primaryButton} onPress={() => buy('yearly')} disabled={busy}>
|
||||
<Text style={styles.primaryButtonText}>Yearly</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.textButton} onPress={() => run(restore)} disabled={busy}>
|
||||
<Text style={styles.textButtonText}>Restore Purchase</Text>
|
||||
</Pressable>
|
||||
</>
|
||||
) : 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>
|
||||
);
|
||||
@@ -77,7 +133,17 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
},
|
||||
primaryButtonText: { ...type.heading, color: colors.accentText },
|
||||
restore: { alignItems: 'center', paddingVertical: spacing.m },
|
||||
restoreText: { ...type.body, color: colors.accent },
|
||||
secondaryButton: {
|
||||
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' },
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { HttpLambdaIntegration } from 'aws-cdk-lib/aws-apigatewayv2-integrations';
|
||||
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_NAME,
|
||||
KNOWLEDGE_DIR: 'knowledge',
|
||||
FREE_MESSAGE_LIMIT: '50',
|
||||
MESSAGE_CAP: '200',
|
||||
USAGE_RESET_DAYS: '30',
|
||||
},
|
||||
});
|
||||
@@ -161,6 +161,21 @@ export class NeuroSemanticsStack extends Stack {
|
||||
api.addRoutes({ path: '/chat', 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, 'UserPoolId', { value: userPool.userPoolId });
|
||||
new CfnOutput(this, 'UserPoolClientId', { value: userPoolClient.userPoolClientId });
|
||||
|
||||
@@ -7,6 +7,14 @@ export interface ChatMessage {
|
||||
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
|
||||
specialized in neurosemantics and NLP (Neuro-Linguistic Programming).
|
||||
|
||||
@@ -19,16 +27,74 @@ Principles:
|
||||
distress or a medical condition, recommend seeking professional help.
|
||||
- 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
|
||||
* instructions and the conversation as input. Conversations are held by the
|
||||
* 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 apiKey = appSecret.OPENAI_API_KEY;
|
||||
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', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -37,8 +103,16 @@ export async function generateReply(messages: ChatMessage[]): Promise<string> {
|
||||
},
|
||||
body: JSON.stringify({
|
||||
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 })),
|
||||
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')
|
||||
.map((part) => part.text ?? '')
|
||||
.join('');
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,8 +3,12 @@
|
||||
* variables (set by the CDK stack) — secrets never live here.
|
||||
*/
|
||||
export const config = {
|
||||
/** Number of free messages before the paywall is shown (~5–10 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. */
|
||||
usageResetDays: Number(process.env.USAGE_RESET_DAYS ?? '30'),
|
||||
openAiModel: process.env.OPENAI_MODEL ?? 'gpt-4.1-mini',
|
||||
|
||||
+56
-15
@@ -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 { config } from './config.js';
|
||||
import { getOrCreateUser, getUsage, incrementUsage, resetUsage, type UserRow } from './db.js';
|
||||
import { verifyAndApplyPurchase } from './subscription/index.js';
|
||||
import type { VerifyPurchaseRequest } from './subscription/types.js';
|
||||
import { canSendMessage, shouldResetUsage } from './usage.js';
|
||||
import suggestionsFile from '../suggestions.json';
|
||||
|
||||
function json(statusCode: number, body: unknown): APIGatewayProxyResultV2 {
|
||||
return {
|
||||
@@ -20,10 +21,17 @@ interface Identity {
|
||||
provider: string;
|
||||
}
|
||||
|
||||
function identityFromClaims(event: APIGatewayProxyEventV2WithJWTAuthorizer): Identity {
|
||||
const claims = event.requestContext.authorizer.jwt.claims;
|
||||
/** Reads the Cognito JWT claims on authorized routes; null on public routes. */
|
||||
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 ?? '');
|
||||
if (!sub) throw new Error('JWT is missing a sub claim');
|
||||
if (!sub) return null;
|
||||
const email = String(claims.email ?? '');
|
||||
// Cognito sets an `identities` claim for federated sign-ins (Apple/Google).
|
||||
const identities = String(claims.identities ?? '');
|
||||
@@ -49,29 +57,43 @@ async function handleMe(user: UserRow): Promise<APIGatewayProxyResultV2> {
|
||||
return json(200, {
|
||||
subscriptionStatus: user.subscription_status,
|
||||
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(
|
||||
user: UserRow,
|
||||
body: string | undefined,
|
||||
): Promise<APIGatewayProxyResultV2> {
|
||||
const premium = user.subscription_status === 'active';
|
||||
const parsed = body ? (JSON.parse(body) as { messages?: ChatMessage[] }) : {};
|
||||
const messages = parsed.messages ?? [];
|
||||
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' });
|
||||
}
|
||||
|
||||
const usage = await currentUsage(user);
|
||||
if (!canSendMessage(usage.messages_used, user.subscription_status, config.freeMessageLimit)) {
|
||||
return json(402, { error: 'free_limit_reached' });
|
||||
if (!canSendMessage(usage.messages_used, user.subscription_status, config.messageCap)) {
|
||||
return json(402, { error: 'message_cap_reached' });
|
||||
}
|
||||
|
||||
const reply = await generateReply(messages);
|
||||
const result = await generateReply(messages, premium ? 'premium' : 'free');
|
||||
await incrementUsage(user.id);
|
||||
return json(200, { reply });
|
||||
return json(200, { reply: result.reply, paywall: result.analysisReady });
|
||||
}
|
||||
|
||||
async function handleVerifyPurchase(
|
||||
@@ -90,14 +112,33 @@ async function handleVerifyPurchase(
|
||||
return json(200, { subscriptionStatus: status });
|
||||
}
|
||||
|
||||
export async function handler(
|
||||
event: APIGatewayProxyEventV2WithJWTAuthorizer,
|
||||
): Promise<APIGatewayProxyResultV2> {
|
||||
/** Guests chat before signing in, identified by an app-generated device id. */
|
||||
async function handleGuestChat(event: APIGatewayProxyEventV2): 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 {
|
||||
const identity = identityFromClaims(event);
|
||||
const user = await getOrCreateUser(identity.sub, identity.email, identity.provider);
|
||||
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) {
|
||||
case 'GET /me':
|
||||
return await handleMe(user);
|
||||
|
||||
@@ -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."
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user