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:
@@ -7,6 +7,14 @@ export interface ChatMessage {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export type ChatMode = 'free' | 'premium';
|
||||
|
||||
export interface ChatResult {
|
||||
reply: string;
|
||||
/** Free mode only: the model judges a complete analysis is ready to present. */
|
||||
analysisReady: boolean;
|
||||
}
|
||||
|
||||
const BASE_INSTRUCTIONS = `You are NeuroSemantics AI — a calm, precise conversation partner
|
||||
specialized in neurosemantics and NLP (Neuro-Linguistic Programming).
|
||||
|
||||
@@ -19,16 +27,74 @@ Principles:
|
||||
distress or a medical condition, recommend seeking professional help.
|
||||
- Answer in the language the user writes in.`;
|
||||
|
||||
const FREE_INSTRUCTIONS = `# Conversation mode: free tier (discovery)
|
||||
|
||||
Work in discovery mode. In every reply you should:
|
||||
- ask relevant follow-up questions (one at a time),
|
||||
- identify and name patterns you notice,
|
||||
- show genuine understanding of the user's situation,
|
||||
- build toward a complete analysis.
|
||||
|
||||
Do not yet present the full analysis, the recommended strategy, or concrete
|
||||
exercises.
|
||||
|
||||
Set "analysis_ready" to true ONLY when all of the following are genuinely met:
|
||||
- the user has described their problem,
|
||||
- you have enough information to give a concrete, personal recommendation,
|
||||
- a specific action plan is ready to present.
|
||||
|
||||
When analysis_ready is true, the reply must be a calm, natural transition —
|
||||
not an interruption mid-answer. Summarize at a high level what you have
|
||||
understood and that a concrete strategy is ready. Example of tone:
|
||||
"I think I'm starting to understand what lies behind this situation, and I
|
||||
can see some clear communication patterns. I also have a concrete strategy
|
||||
I would recommend for your specific situation. Continue with Premium to see
|
||||
the analysis and the recommended steps."
|
||||
|
||||
Never:
|
||||
- manufacture urgency or emotional pressure to drive a purchase,
|
||||
- claim readiness or insight you do not have,
|
||||
- stop in the middle of answering a direct question,
|
||||
- mention Premium in any other situation.
|
||||
|
||||
Otherwise, set analysis_ready to false.`;
|
||||
|
||||
const PREMIUM_INSTRUCTIONS = `# Conversation mode: premium
|
||||
|
||||
The user has full access. Deliver complete value:
|
||||
- when your analysis is ready, present it in full: the analysis, recommended
|
||||
strategies, and concrete exercises,
|
||||
- if the conversation ends with your own message announcing that an analysis
|
||||
is ready, the user has just unlocked Premium — deliver the full analysis
|
||||
and the recommended steps now, without being asked again,
|
||||
- continue the dialogue without restriction.
|
||||
|
||||
Always set "analysis_ready" to false; it is not used in this mode.`;
|
||||
|
||||
const RESPONSE_SCHEMA = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
reply: { type: 'string' },
|
||||
analysis_ready: { type: 'boolean' },
|
||||
},
|
||||
required: ['reply', 'analysis_ready'],
|
||||
additionalProperties: false,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Calls the OpenAI Responses API with the knowledge base as system
|
||||
* instructions and the conversation as input. Conversations are held by the
|
||||
* client and passed through — nothing is persisted server-side.
|
||||
*
|
||||
* The model returns structured output so the backend — not a hardcoded
|
||||
* message count — decides when the paywall moment has arrived.
|
||||
*/
|
||||
export async function generateReply(messages: ChatMessage[]): Promise<string> {
|
||||
export async function generateReply(messages: ChatMessage[], mode: ChatMode): Promise<ChatResult> {
|
||||
const appSecret = await getSecret(config.appSecretArn);
|
||||
const apiKey = appSecret.OPENAI_API_KEY;
|
||||
if (!apiKey) throw new Error('OPENAI_API_KEY missing from application secret');
|
||||
|
||||
const modeInstructions = mode === 'premium' ? PREMIUM_INSTRUCTIONS : FREE_INSTRUCTIONS;
|
||||
const response = await fetch('https://api.openai.com/v1/responses', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -37,8 +103,16 @@ export async function generateReply(messages: ChatMessage[]): Promise<string> {
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: config.openAiModel,
|
||||
instructions: `${BASE_INSTRUCTIONS}\n\n# Knowledge base\n\n${loadKnowledgeBase()}`,
|
||||
instructions: `${BASE_INSTRUCTIONS}\n\n${modeInstructions}\n\n# Knowledge base\n\n${loadKnowledgeBase()}`,
|
||||
input: messages.map((m) => ({ role: m.role, content: m.content })),
|
||||
text: {
|
||||
format: {
|
||||
type: 'json_schema',
|
||||
name: 'chat_turn',
|
||||
strict: true,
|
||||
schema: RESPONSE_SCHEMA,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -56,7 +130,11 @@ export async function generateReply(messages: ChatMessage[]): Promise<string> {
|
||||
.filter((part) => part.type === 'output_text')
|
||||
.map((part) => part.text ?? '')
|
||||
.join('');
|
||||
|
||||
if (!text) throw new Error('OpenAI response contained no output text');
|
||||
return text;
|
||||
|
||||
const parsed = JSON.parse(text) as { reply: string; analysis_ready: boolean };
|
||||
return {
|
||||
reply: parsed.reply,
|
||||
analysisReady: mode === 'free' && parsed.analysis_ready === true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,8 +3,12 @@
|
||||
* variables (set by the CDK stack) — secrets never live here.
|
||||
*/
|
||||
export const config = {
|
||||
/** Number of free messages before the paywall is shown (~5–10 conversations). */
|
||||
freeMessageLimit: Number(process.env.FREE_MESSAGE_LIMIT ?? '50'),
|
||||
/**
|
||||
* Abuse backstop, NOT the paywall. The paywall is intelligent: the model
|
||||
* decides when an analysis is ready. This cap only bounds how many free
|
||||
* messages a single user/device can send per reset window.
|
||||
*/
|
||||
messageCap: Number(process.env.MESSAGE_CAP ?? '200'),
|
||||
/** Free-tier usage resets after this many days. */
|
||||
usageResetDays: Number(process.env.USAGE_RESET_DAYS ?? '30'),
|
||||
openAiModel: process.env.OPENAI_MODEL ?? 'gpt-4.1-mini',
|
||||
|
||||
+56
-15
@@ -1,10 +1,11 @@
|
||||
import type { APIGatewayProxyEventV2WithJWTAuthorizer, APIGatewayProxyResultV2 } from 'aws-lambda';
|
||||
import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from 'aws-lambda';
|
||||
import { generateReply, type ChatMessage } from './chat.js';
|
||||
import { config } from './config.js';
|
||||
import { getOrCreateUser, getUsage, incrementUsage, resetUsage, type UserRow } from './db.js';
|
||||
import { verifyAndApplyPurchase } from './subscription/index.js';
|
||||
import type { VerifyPurchaseRequest } from './subscription/types.js';
|
||||
import { canSendMessage, shouldResetUsage } from './usage.js';
|
||||
import suggestionsFile from '../suggestions.json';
|
||||
|
||||
function json(statusCode: number, body: unknown): APIGatewayProxyResultV2 {
|
||||
return {
|
||||
@@ -20,10 +21,17 @@ interface Identity {
|
||||
provider: string;
|
||||
}
|
||||
|
||||
function identityFromClaims(event: APIGatewayProxyEventV2WithJWTAuthorizer): Identity {
|
||||
const claims = event.requestContext.authorizer.jwt.claims;
|
||||
/** Reads the Cognito JWT claims on authorized routes; null on public routes. */
|
||||
function identityFromEvent(event: APIGatewayProxyEventV2): Identity | null {
|
||||
const authorizer = (
|
||||
event.requestContext as {
|
||||
authorizer?: { jwt?: { claims?: Record<string, unknown> } };
|
||||
}
|
||||
).authorizer;
|
||||
const claims = authorizer?.jwt?.claims;
|
||||
if (!claims) return null;
|
||||
const sub = String(claims.sub ?? '');
|
||||
if (!sub) throw new Error('JWT is missing a sub claim');
|
||||
if (!sub) return null;
|
||||
const email = String(claims.email ?? '');
|
||||
// Cognito sets an `identities` claim for federated sign-ins (Apple/Google).
|
||||
const identities = String(claims.identities ?? '');
|
||||
@@ -49,29 +57,43 @@ async function handleMe(user: UserRow): Promise<APIGatewayProxyResultV2> {
|
||||
return json(200, {
|
||||
subscriptionStatus: user.subscription_status,
|
||||
messagesUsed: usage.messages_used,
|
||||
freeMessageLimit: config.freeMessageLimit,
|
||||
messageCap: config.messageCap,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The paywall is intelligent: no hardcoded message count. In free mode the
|
||||
* model works in discovery (follow-up questions, patterns, understanding)
|
||||
* and signals `analysisReady` when a concrete, valuable answer could be
|
||||
* delivered — that is the paywall moment, returned as `paywall: true`.
|
||||
* Premium mode delivers the full analysis, including immediately after
|
||||
* unlock (the transcript then ends with the assistant's transition message).
|
||||
*/
|
||||
async function handleChat(
|
||||
user: UserRow,
|
||||
body: string | undefined,
|
||||
): Promise<APIGatewayProxyResultV2> {
|
||||
const premium = user.subscription_status === 'active';
|
||||
const parsed = body ? (JSON.parse(body) as { messages?: ChatMessage[] }) : {};
|
||||
const messages = parsed.messages ?? [];
|
||||
const last = messages[messages.length - 1];
|
||||
if (!last || last.role !== 'user' || typeof last.content !== 'string' || !last.content.trim()) {
|
||||
const validLast =
|
||||
last &&
|
||||
typeof last.content === 'string' &&
|
||||
last.content.trim() &&
|
||||
(last.role === 'user' || (premium && last.role === 'assistant'));
|
||||
if (!validLast) {
|
||||
return json(400, { error: 'messages must end with a non-empty user message' });
|
||||
}
|
||||
|
||||
const usage = await currentUsage(user);
|
||||
if (!canSendMessage(usage.messages_used, user.subscription_status, config.freeMessageLimit)) {
|
||||
return json(402, { error: 'free_limit_reached' });
|
||||
if (!canSendMessage(usage.messages_used, user.subscription_status, config.messageCap)) {
|
||||
return json(402, { error: 'message_cap_reached' });
|
||||
}
|
||||
|
||||
const reply = await generateReply(messages);
|
||||
const result = await generateReply(messages, premium ? 'premium' : 'free');
|
||||
await incrementUsage(user.id);
|
||||
return json(200, { reply });
|
||||
return json(200, { reply: result.reply, paywall: result.analysisReady });
|
||||
}
|
||||
|
||||
async function handleVerifyPurchase(
|
||||
@@ -90,14 +112,33 @@ async function handleVerifyPurchase(
|
||||
return json(200, { subscriptionStatus: status });
|
||||
}
|
||||
|
||||
export async function handler(
|
||||
event: APIGatewayProxyEventV2WithJWTAuthorizer,
|
||||
): Promise<APIGatewayProxyResultV2> {
|
||||
/** Guests chat before signing in, identified by an app-generated device id. */
|
||||
async function handleGuestChat(event: APIGatewayProxyEventV2): Promise<APIGatewayProxyResultV2> {
|
||||
const deviceId = event.headers?.['x-device-id'] ?? '';
|
||||
if (!/^[0-9a-fA-F-]{8,64}$/.test(deviceId)) {
|
||||
return json(400, { error: 'a valid X-Device-Id header is required' });
|
||||
}
|
||||
const user = await getOrCreateUser(`guest:${deviceId}`, '', 'guest');
|
||||
return handleChat(user, event.body);
|
||||
}
|
||||
|
||||
export async function handler(event: APIGatewayProxyEventV2): Promise<APIGatewayProxyResultV2> {
|
||||
try {
|
||||
const identity = identityFromClaims(event);
|
||||
const user = await getOrCreateUser(identity.sub, identity.email, identity.provider);
|
||||
const route = `${event.requestContext.http.method} ${event.rawPath}`;
|
||||
|
||||
// Public routes (no sign-in before the first question).
|
||||
if (route === 'GET /suggestions') {
|
||||
return json(200, { suggestions: suggestionsFile.suggestions });
|
||||
}
|
||||
if (route === 'POST /guest/chat') {
|
||||
return await handleGuestChat(event);
|
||||
}
|
||||
|
||||
// Authorized routes (Cognito JWT, enforced by API Gateway).
|
||||
const identity = identityFromEvent(event);
|
||||
if (!identity) return json(401, { error: 'unauthorized' });
|
||||
const user = await getOrCreateUser(identity.sub, identity.email, identity.provider);
|
||||
|
||||
switch (route) {
|
||||
case 'GET /me':
|
||||
return await handleMe(user);
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"suggestions": [
|
||||
"I need support before a difficult conversation.",
|
||||
"Help me prepare for a salary negotiation.",
|
||||
"I'm going on a first date.",
|
||||
"Why do I get stuck in the same conflicts?",
|
||||
"Help me understand my reactions.",
|
||||
"How do I set clearer boundaries?",
|
||||
"I want to become a better communicator.",
|
||||
"Help me prepare for a job interview.",
|
||||
"How do I build better self-confidence?",
|
||||
"Help me make a difficult decision."
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user