diff --git a/README.md b/README.md index 6874276..f9441ce 100644 --- a/README.md +++ b/README.md @@ -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, diff --git a/apps/mobile/App.tsx b/apps/mobile/App.tsx index a36cf65..9e2dbcc 100644 --- a/apps/mobile/App.tsx +++ b/apps/mobile/App.tsx @@ -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('loading'); - - useEffect(() => { - getAccessToken().then((token) => setView(token ? 'chat' : 'welcome')); - }, []); + const [view, setView] = useState<'chat' | 'paywall'>('chat'); + const [messages, setMessages] = useState([]); + const [paywalled, setPaywalled] = useState(false); + const [deliverAnalysis, setDeliverAnalysis] = useState(false); return ( <> - {view === 'welcome' && setView('chat')} />} - {view === 'chat' && setView('paywall')} />} - {view === 'paywall' && setView('chat')} />} + {view === 'chat' && ( + setPaywalled(true)} + onContinueWithPremium={() => setView('paywall')} + deliverAnalysis={deliverAnalysis} + onAnalysisDelivered={() => setDeliverAnalysis(false)} + /> + )} + {view === 'paywall' && ( + { + setPaywalled(false); + setDeliverAnalysis(true); + setView('chat'); + }} + onDismiss={() => setView('chat')} + /> + )} ); } diff --git a/apps/mobile/src/api/client.ts b/apps/mobile/src/api/client.ts index 308d894..6b63615 100644 --- a/apps/mobile/src/api/client.ts +++ b/apps/mobile/src/api/client.ts @@ -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(path: string, init?: RequestInit): Promise { - 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 { + let id = await SecureStore.getItemAsync(DEVICE_KEY); + if (!id) { + id = Crypto.randomUUID(); + await SecureStore.setItemAsync(DEVICE_KEY, id); + } + return id; +} + +async function request( + path: string, + init: RequestInit = {}, + headers: Record = {}, +): Promise { 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(path: string, init?: RequestInit): Promise { return (await response.json()) as T; } -export function fetchMe(): Promise { - return request('/me'); +async function authHeader(): Promise> { + 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 { + 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 { + const body = { method: 'POST', body: JSON.stringify({ messages }) }; + const token = await getAccessToken(); + if (token) { + return request('/chat', body, { Authorization: `Bearer ${token}` }); + } + return request('/guest/chat', body, { 'X-Device-Id': await getDeviceId() }); +} + +export async function fetchMe(): Promise { + return request('/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(), + ); } diff --git a/apps/mobile/src/screens/ChatScreen.tsx b/apps/mobile/src/screens/ChatScreen.tsx index 714c4ec..f48a224 100644 --- a/apps/mobile/src/screens/ChatScreen.tsx +++ b/apps/mobile/src/screens/ChatScreen.tsx @@ -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([]); +export function ChatScreen({ + messages, + setMessages, + paywalled, + onPaywallTriggered, + onContinueWithPremium, + deliverAnalysis, + onAnalysisDelivered, +}: Props) { + const [suggestions, setSuggestions] = useState([]); const [draft, setDraft] = useState(''); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const listRef = useRef>(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 ( NeuroSemantics AI + {showSuggestions ? ( + + {suggestions.map((s) => ( + send(s)} disabled={busy}> + {s} + + ))} + + ) : null} {error ? {error} : null} - - - - {busy ? '…' : 'Send'} - - + {paywalled ? ( + + + Continue with Premium + + + ) : ( + + + send(draft)} + disabled={!draft.trim() || busy} + > + {busy ? '…' : 'Send'} + + + )} ); } @@ -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 }, }); diff --git a/apps/mobile/src/screens/PaywallScreen.tsx b/apps/mobile/src/screens/PaywallScreen.tsx index 6f9b420..b39d719 100644 --- a/apps/mobile/src/screens/PaywallScreen.tsx +++ b/apps/mobile/src/screens/PaywallScreen.tsx @@ -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(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(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) { Continue your journey - 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. - buy('monthly')} disabled={busy}> - Monthly - - buy('yearly')} disabled={busy}> - Yearly - - run(restore)} disabled={busy}> - Restore Purchase - + {signedIn === false ? ( + <> + handleSignIn('apple')} + disabled={busy} + > + Continue with Apple + + handleSignIn('google')} + disabled={busy} + > + Continue with Google + + handleSignIn('email')} + disabled={busy} + > + Continue with Email + + + ) : signedIn === true ? ( + <> + buy('monthly')} disabled={busy}> + Monthly + + buy('yearly')} disabled={busy}> + Yearly + + run(restore)} disabled={busy}> + Restore Purchase + + + ) : null} {error ? {error} : null} + + Not now + ); @@ -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' }, }); diff --git a/apps/mobile/src/screens/WelcomeScreen.tsx b/apps/mobile/src/screens/WelcomeScreen.tsx deleted file mode 100644 index 5ad6289..0000000 --- a/apps/mobile/src/screens/WelcomeScreen.tsx +++ /dev/null @@ -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(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 ( - - - NeuroSemantics AI - - A quiet conversation partner for neurosemantics and NLP. - - - - handleSignIn('apple')} - disabled={busy} - > - Continue with Apple - - handleSignIn('google')} - disabled={busy} - > - Continue with Google - - handleSignIn('email')} - disabled={busy} - > - Continue with Email - - {error ? {error} : null} - Start free. No card required. - - - ); -} - -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 }, -}); diff --git a/infra/lib/neurosemantics-stack.ts b/infra/lib/neurosemantics-stack.ts index 3cb368e..7f50ad1 100644 --- a/infra/lib/neurosemantics-stack.ts +++ b/infra/lib/neurosemantics-stack.ts @@ -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 }); diff --git a/services/api/src/chat.ts b/services/api/src/chat.ts index 9404bc8..78be1f7 100644 --- a/services/api/src/chat.ts +++ b/services/api/src/chat.ts @@ -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 { +export async function generateReply(messages: ChatMessage[], mode: ChatMode): Promise { 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 { }, 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 { .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, + }; } diff --git a/services/api/src/config.ts b/services/api/src/config.ts index d400135..9a8b489 100644 --- a/services/api/src/config.ts +++ b/services/api/src/config.ts @@ -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', diff --git a/services/api/src/handler.ts b/services/api/src/handler.ts index ea0d664..b6fa1bf 100644 --- a/services/api/src/handler.ts +++ b/services/api/src/handler.ts @@ -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 } }; + } + ).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 { 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 { + 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 { +/** Guests chat before signing in, identified by an app-generated device id. */ +async function handleGuestChat(event: APIGatewayProxyEventV2): Promise { + 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 { 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); diff --git a/services/api/suggestions.json b/services/api/suggestions.json new file mode 100644 index 0000000..f3ff6ca --- /dev/null +++ b/services/api/suggestions.json @@ -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." + ] +}