Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .cursor/rules/ai-engineering.mdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
description: AI engineering project rules
alwaysApply: true
---

This project is for an AI engineering challenge.

Prefer clean, production-style code.

Use environment variables for secrets.

Never hardcode API keys.

Use clear separation between:
- UI components
- API routes
- service logic
- model/provider logic
- prompt templates

Add helpful comments only where the logic is non-obvious.

When generating code, include basic error handling.
7 changes: 7 additions & 0 deletions .cursor/rules/nextjs.mdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
description: Next.js project rules
alwaysApply: true
---

Before implementing unfamiliar Next.js APIs, check the official docs at:
https://nextjs.org/docs
6 changes: 6 additions & 0 deletions .cursor/rules/typescript.mdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
description: TypeScript rules
alwaysApply: true
---

Use strict TypeScript.
35 changes: 35 additions & 0 deletions .cursor/rules/vercel.mdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
description: Vercel platform and AI SDK
alwaysApply: true
---

This project is deployed on Vercel.

When writing code:

- Follow Vercel best practices.
- Prefer the App Router.
- Use Edge Runtime only when appropriate.
- Use Route Handlers for APIs.
- Use environment variables for secrets.
- Optimize for serverless execution.
- Prefer streaming responses where applicable.
- Follow official Vercel AI SDK patterns when building AI features.
- When uncertain, consult the official Vercel documentation before implementing.

Official documentation:

General:
https://vercel.com/docs

Next.js:
https://nextjs.org/docs

AI SDK:
https://ai-sdk.dev/docs

AI SDK Examples:
https://github.com/vercel/ai

Templates:
https://vercel.com/templates
4 changes: 2 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ dist/
downloads/
eggs/
.eggs/
lib/
lib64/
/lib/
/lib64/
parts/
sdist/
var/
Expand Down
862 changes: 774 additions & 88 deletions README.md

Large diffs are not rendered by default.

18 changes: 17 additions & 1 deletion api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,22 @@ This runs the app with `uvicorn` on `http://localhost:8000` with auto-reload ena
export OPENAI_API_KEY=sk-your-key-here
```

### Optional usage limits (recommended for public deployments)

| Variable | Default | Purpose |
|----------|---------|---------|
| `OPENAI_MODEL` | `gpt-5` | Model id — set server-side only (e.g. `gpt-4.1-mini` in production) |
| `OPENAI_MAX_TOKENS` | `800` | Max tokens per completion |
| `OPENAI_MAX_MESSAGE_CHARS` | `2000` | Max characters per user message |

Example for a cost-conscious public demo on Vercel:

```bash
OPENAI_MODEL=gpt-4.1-mini
OPENAI_MAX_TOKENS=500
OPENAI_MAX_MESSAGE_CHARS=2000
```

If you encounter an "Address already in use" error, you may need to kill existing processes on port 8000:

```bash
Expand All @@ -66,7 +82,7 @@ lsof -ti:8000 | xargs kill -9
}
```

The chat endpoint uses OpenAI's GPT-5 model with a supportive mental coach system prompt to provide helpful responses.
The chat endpoint uses an OpenAI model configured via `OPENAI_MODEL` (default `gpt-5`) with a supportive mental coach system prompt. Output length and input size are capped via `OPENAI_MAX_TOKENS` and `OPENAI_MAX_MESSAGE_CHARS`.

### Root Endpoint
- **URL**: `/`
Expand Down
Empty file added api/__init__.py
Empty file.
42 changes: 42 additions & 0 deletions api/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""Server-side limits for OpenAI usage — all configurable via environment variables."""

import os

# Defaults preserve current dev behavior; tighten in production via Vercel env vars.
DEFAULT_OPENAI_MODEL = "gpt-5"
DEFAULT_MAX_TOKENS = 800
DEFAULT_MAX_MESSAGE_CHARS = 2000


def openai_model() -> str:
"""OpenAI model id — never exposed to or controlled by the client."""
return os.getenv("OPENAI_MODEL", DEFAULT_OPENAI_MODEL)


def openai_max_tokens() -> int:
"""Cap completion length to limit cost per request."""
raw = os.getenv("OPENAI_MAX_TOKENS", str(DEFAULT_MAX_TOKENS))
try:
value = int(raw)
except ValueError:
return DEFAULT_MAX_TOKENS
return max(1, min(value, 16_384))


def openai_completion_limit_kwargs() -> dict[str, int]:
"""
Token limit kwargs for chat.completions.create.
gpt-5+ requires max_completion_tokens; older models accept both.
"""
limit = openai_max_tokens()
return {"max_completion_tokens": limit}


def max_message_chars() -> int:
"""Max characters accepted in a user message."""
raw = os.getenv("OPENAI_MAX_MESSAGE_CHARS", str(DEFAULT_MAX_MESSAGE_CHARS))
try:
value = int(raw)
except ValueError:
return DEFAULT_MAX_MESSAGE_CHARS
return max(1, min(value, 32_000))
49 changes: 49 additions & 0 deletions api/errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""Maps internal failures to safe, user-facing error messages."""

import logging

logger = logging.getLogger(__name__)

# Messages shown to end users — never expose raw provider or stack traces.
MSG_SERVICE_UNAVAILABLE = (
"The coach is temporarily unavailable. Please try again in a few minutes."
)
MSG_TRY_AGAIN = (
"Something went wrong while generating a reply. Please try again."
)
MSG_RATE_LIMIT = (
"We're receiving a lot of messages right now. Please wait a moment and try again."
)
MSG_TIMEOUT = (
"That took longer than expected. Please try again."
)
MSG_MISSING_KEY = MSG_SERVICE_UNAVAILABLE


def user_message_for_openai_error(error: Exception) -> tuple[int, str]:
"""
Convert an OpenAI/client exception into an HTTP status and a safe user message.
Logs the original error for debugging.
"""
logger.exception("OpenAI chat request failed: %s", error)
message = str(error).lower()

if any(
token in message
for token in ("rate limit", "rate_limit", "429", "too many requests")
):
return 429, MSG_RATE_LIMIT

if any(
token in message
for token in ("timeout", "timed out", "deadline")
):
return 504, MSG_TIMEOUT

if any(
token in message
for token in ("api key", "authentication", "incorrect api key", "unauthorized")
):
return 503, MSG_SERVICE_UNAVAILABLE

return 500, MSG_TRY_AGAIN
74 changes: 66 additions & 8 deletions api/index.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,21 @@
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from pydantic import BaseModel, Field
from openai import OpenAI
from typing import Literal
import os
import time
from dotenv import load_dotenv

from .config import (
max_message_chars,
openai_completion_limit_kwargs,
openai_model,
)
from .errors import MSG_MISSING_KEY, user_message_for_openai_error
from .prompts.characters import DEFAULT_CHARACTER, VALID_CHARACTERS
from .prompts.system import build_system_prompt

load_dotenv()

app = FastAPI()
Expand All @@ -19,8 +30,27 @@

client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))

DEFAULT_CREATIVITY = 50

CharacterId = Literal["professional", "warm_friend", "mindful_guide", "motivational_coach"]

class ChatRequest(BaseModel):
message: str
message: str = Field(
...,
min_length=1,
max_length=max_message_chars(),
description="User message to the coach.",
)
creativity: int = Field(
default=DEFAULT_CREATIVITY,
ge=0,
le=100,
description="Controls response style from focused (0) to creative (100).",
)
character: CharacterId = Field(
default=DEFAULT_CHARACTER,
description="Coach persona that shapes tone and coaching style.",
)

@app.get("/")
def root():
Expand All @@ -29,17 +59,45 @@ def root():
@app.post("/api/chat")
def chat(request: ChatRequest):
if not os.getenv("OPENAI_API_KEY"):
raise HTTPException(status_code=500, detail="OPENAI_API_KEY not configured")
raise HTTPException(status_code=503, detail=MSG_MISSING_KEY)

if request.character not in VALID_CHARACTERS:
raise HTTPException(
status_code=400,
detail="That coach style isn't available. Please choose another one.",
)

try:
user_message = request.message
system_prompt = build_system_prompt(
character=request.character,
creativity=request.creativity,
)
start = time.perf_counter()
response = client.chat.completions.create(
model="gpt-5",
model=openai_model(),
messages=[
{"role": "system", "content": "You are a supportive mental coach."},
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_message}
]
],
**openai_completion_limit_kwargs(),
)
return {"reply": response.choices[0].message.content}
response_time_ms = round((time.perf_counter() - start) * 1000)
content = response.choices[0].message.content
if not content:
raise HTTPException(
status_code=500,
detail="The coach couldn't generate a reply. Please try again.",
)
return {"reply": content, "response_time_ms": response_time_ms}
except HTTPException:
raise
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error calling OpenAI API: {str(e)}")
status_code, detail = user_message_for_openai_error(e)
raise HTTPException(status_code=status_code, detail=detail)

@app.get("/api/health")
def health():
return {"status": "ok"}
Empty file added api/prompts/__init__.py
Empty file.
43 changes: 43 additions & 0 deletions api/prompts/characters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""Coach persona templates — tone and style only; guardrails always apply separately."""

from typing import Final

CharacterId = str

CHARACTERS: Final[dict[CharacterId, str]] = {
"professional": (
"Persona: Professional coach. "
"Speak in a clear, structured, and composed tone. "
"Use organized responses with practical steps when helpful. "
"Be warm but measured — like a skilled workplace or life coach."
),
"warm_friend": (
"Persona: Warm friend. "
"Speak in a conversational, empathetic, and validating tone. "
"Acknowledge feelings first, use everyday language, and make the user feel heard. "
"Be supportive without being overly clinical."
),
"mindful_guide": (
"Persona: Mindful guide. "
"Speak in a calm, reflective, and grounding tone. "
"Encourage present-moment awareness, gentle breathing, and self-compassion. "
"Use unhurried language and thoughtful pauses in your phrasing."
),
"motivational_coach": (
"Persona: Motivational coach. "
"Speak in an encouraging, energizing, and action-oriented tone. "
"Highlight strengths, celebrate small wins, and help the user identify concrete next steps. "
"Stay optimistic and empowering without being dismissive of difficult feelings."
),
}

DEFAULT_CHARACTER: CharacterId = "mindful_guide"

VALID_CHARACTERS: frozenset[CharacterId] = frozenset(CHARACTERS.keys())


def get_character_prompt(character: CharacterId) -> str:
"""Return the persona prompt for a valid character id."""
if character not in CHARACTERS:
raise ValueError(f"Unknown character: {character}")
return CHARACTERS[character]
16 changes: 16 additions & 0 deletions api/prompts/guardrails.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
"""Safety guardrails applied to every coach persona."""

GUARDRAILS = """
You are an AI mental wellness coach, not a licensed therapist, psychiatrist, or medical professional.

Always follow these rules regardless of persona:
- Never diagnose conditions or prescribe medication.
- Do not claim to replace professional mental health care.
- Be respectful, non-judgmental, and supportive at all times.
- Do not minimize serious emotional distress or trauma.
- If the user mentions self-harm, suicide, abuse, or being in immediate danger, respond with empathy,
encourage them to contact local emergency services or a crisis helpline, and suggest speaking with a
qualified mental health professional. Do not attempt to handle the crisis solely through coaching.
- Avoid harmful, discriminatory, or stigmatizing language.
- Keep advice within coaching scope: emotional support, reflection, motivation, habits, stress, and confidence.
""".strip()
Loading