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
|
## 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 ≈ 5–10 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
@@ -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')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
setError('Something went wrong. Please try again.');
|
||||||
} else {
|
setMessages(messages);
|
||||||
setError('Something went wrong. Please try again.');
|
setDraft(trimmed);
|
||||||
setMessages(messages);
|
|
||||||
setDraft(content);
|
|
||||||
}
|
|
||||||
} 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,24 +124,32 @@ export function ChatScreen({ onLimitReached }: Props) {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||||
<View style={styles.inputRow}>
|
{paywalled ? (
|
||||||
<TextInput
|
<View style={styles.inputRow}>
|
||||||
style={styles.input}
|
<Pressable style={styles.unlock} onPress={onContinueWithPremium}>
|
||||||
value={draft}
|
<Text style={styles.unlockText}>Continue with Premium</Text>
|
||||||
onChangeText={setDraft}
|
</Pressable>
|
||||||
placeholder="Write a message"
|
</View>
|
||||||
placeholderTextColor={colors.textMuted}
|
) : (
|
||||||
multiline
|
<View style={styles.inputRow}>
|
||||||
editable={!busy}
|
<TextInput
|
||||||
/>
|
style={styles.input}
|
||||||
<Pressable
|
value={draft}
|
||||||
style={[styles.send, (!draft.trim() || busy) && styles.sendDisabled]}
|
onChangeText={setDraft}
|
||||||
onPress={send}
|
placeholder="Write a message"
|
||||||
disabled={!draft.trim() || busy}
|
placeholderTextColor={colors.textMuted}
|
||||||
>
|
multiline
|
||||||
<Text style={styles.sendText}>{busy ? '…' : 'Send'}</Text>
|
editable={!busy}
|
||||||
</Pressable>
|
/>
|
||||||
</View>
|
<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>
|
</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 },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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}>
|
||||||
<Pressable style={styles.primaryButton} onPress={() => buy('monthly')} disabled={busy}>
|
{signedIn === false ? (
|
||||||
<Text style={styles.primaryButtonText}>Monthly</Text>
|
<>
|
||||||
</Pressable>
|
<Pressable
|
||||||
<Pressable style={styles.primaryButton} onPress={() => buy('yearly')} disabled={busy}>
|
style={styles.primaryButton}
|
||||||
<Text style={styles.primaryButtonText}>Yearly</Text>
|
onPress={() => handleSignIn('apple')}
|
||||||
</Pressable>
|
disabled={busy}
|
||||||
<Pressable style={styles.restore} onPress={() => run(restore)} disabled={busy}>
|
>
|
||||||
<Text style={styles.restoreText}>Restore Purchase</Text>
|
<Text style={styles.primaryButtonText}>Continue with Apple</Text>
|
||||||
</Pressable>
|
</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}
|
{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' },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 { 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 });
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (~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. */
|
/** 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
@@ -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);
|
||||||
|
|||||||
@@ -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