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,
};
}