Add chat-first onboarding and intelligent context-based paywall

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0118DaxZR36RpnY524vRqx3z
This commit is contained in:
Claude
2026-08-03 14:42:44 +00:00
parent 07fbeea09b
commit 6b7f0263e4
11 changed files with 498 additions and 221 deletions
+82 -4
View File
@@ -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,
};
}
+6 -2
View File
@@ -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 (~510 conversations). */
freeMessageLimit: Number(process.env.FREE_MESSAGE_LIMIT ?? '50'),
/**
* Abuse backstop, NOT the paywall. The paywall is intelligent: the model
* decides when an analysis is ready. This cap only bounds how many free
* messages a single user/device can send per reset window.
*/
messageCap: Number(process.env.MESSAGE_CAP ?? '200'),
/** Free-tier usage resets after this many days. */
usageResetDays: Number(process.env.USAGE_RESET_DAYS ?? '30'),
openAiModel: process.env.OPENAI_MODEL ?? 'gpt-4.1-mini',
+56 -15
View File
@@ -1,10 +1,11 @@
import type { APIGatewayProxyEventV2WithJWTAuthorizer, APIGatewayProxyResultV2 } from 'aws-lambda';
import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from 'aws-lambda';
import { generateReply, type ChatMessage } from './chat.js';
import { 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);
+14
View File
@@ -0,0 +1,14 @@
{
"suggestions": [
"I need support before a difficult conversation.",
"Help me prepare for a salary negotiation.",
"I'm going on a first date.",
"Why do I get stuck in the same conflicts?",
"Help me understand my reactions.",
"How do I set clearer boundaries?",
"I want to become a better communicator.",
"Help me prepare for a job interview.",
"How do I build better self-confidence?",
"Help me make a difficult decision."
]
}