Back to Tracker
Phase 3 — Practical Building
Building Real AI Features
Architecture, not theory. Wire AI into a real API — where it fits, how to structure endpoints, stream responses, and test AI features reliably.
FastAPISummarization
ClassificationJSON Output
Sliding WindowMap-Reduce
pytest
AI Feature Architecture — Where AI Fits
CRITICAL
| Pattern | When to Use | Example |
| Sync endpoint | Response < 3s, user waits | Classification, short summaries |
| Streaming endpoint | Long text generation, chat | Chat, code generation, long summaries |
| Async background job | Processing takes minutes | Batch document analysis, indexing |
Rule: If the user is staring at a cursor, stream. If it takes more than 5 seconds, make it async with a job ID. Only use sync for fast (<3s) operations.
FastAPI Endpoint Template
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from openai import OpenAI
from typing import Optional
import os
app = FastAPI()
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
# CORS — required for frontend calls
app.add_middleware(CORSMiddleware,
allow_origins=["http://localhost:3000"], # restrict in production
allow_methods=["*"], allow_headers=["*"])
class SummarizeRequest(BaseModel):
text: str
max_words: Optional[int] = 150
format: Optional[str] = "paragraph" # or "bullets"
class SummarizeResponse(BaseModel):
summary: str
tokens_used: int
@app.post("/summarize", response_model=SummarizeResponse)
async def summarize(req: SummarizeRequest):
if not req.text.strip():
raise HTTPException(400, "Text cannot be empty")
try:
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": f"Summarize in {req.max_words} words or fewer. Format: {req.format}."},
{"role": "user", "content": req.text}
],
max_tokens=500, temperature=0.3
)
return SummarizeResponse(
summary=resp.choices[0].message.content,
tokens_used=resp.usage.total_tokens
)
except Exception as e:
raise HTTPException(500, str(e))
# Start: uvicorn main:app --reload
# Docs: http://localhost:8000/docs
Conversation History — Chatbot Memory
The messages array IS the memory. The API is stateless — you send full history on every request.
# Client-side: maintain history array
messages = [
{"role": "system", "content": "You are a helpful assistant."},
]
def chat(user_input: str) -> str:
messages.append({"role": "user", "content": user_input})
resp = client.chat.completions.create(
model="gpt-4o-mini", messages=messages, max_tokens=500
)
reply = resp.choices[0].message.content
messages.append({"role": "assistant", "content": reply})
return reply
Sliding Window — Prevent Context Overflow
import tiktoken
def sliding_window(messages, max_tokens=3000, model="gpt-4o-mini"):
"""Keep system prompt + most recent messages within token budget."""
enc = tiktoken.encoding_for_model(model)
system = [m for m in messages if m["role"] == "system"]
convo = [m for m in messages if m["role"] != "system"]
def count(msgs):
return sum(len(enc.encode(m["content"])) + 4 for m in msgs) + 3
while count(system + convo) > max_tokens and len(convo) > 2:
convo.pop(0) # drop oldest user/assistant pair
return system + convo
Rule: Keep last 5–8 conversation turns. Drop older ones. Always keep the system prompt. A 128K context window costs money on every call — don't send 50 messages when 8 suffice.
Map-Reduce Summarization — Long Documents
import tiktoken
def chunk_text(text: str, chunk_tokens: int = 2000, model: str = "gpt-4o-mini"):
enc = tiktoken.encoding_for_model(model)
tokens = enc.encode(text)
chunks = []
for i in range(0, len(tokens), chunk_tokens):
chunks.append(enc.decode(tokens[i:i + chunk_tokens]))
return chunks
def summarize_chunk(chunk: str) -> str:
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": "Summarize this section concisely."},
{"role": "user", "content": chunk}
],
max_tokens=300
)
return resp.choices[0].message.content
def map_reduce_summarize(long_text: str) -> str:
chunks = chunk_text(long_text)
# MAP — summarize each chunk
summaries = [summarize_chunk(c) for c in chunks]
# REDUCE — combine summaries
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": "Synthesize these summaries into one coherent summary."},
{"role": "user", "content": "\n\n---\n\n".join(summaries)}
],
max_tokens=500
)
return resp.choices[0].message.content
# Rule of thumb: text < 3000 tokens → single pass. Larger → map-reduce.
Classification with JSON Output
import json
def classify_sentiment(text: str) -> dict:
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{
"role": "system",
"content": """Classify the sentiment of the text.
Respond with ONLY valid JSON in this exact format:
{"label": "positive"|"negative"|"neutral",
"confidence": 0.0-1.0,
"reasoning": "one sentence explanation"}"""
},
{"role": "user", "content": text}
],
response_format={"type": "json_object"}, # ← forces valid JSON
temperature=0.0, # ← deterministic for classification
max_tokens=150
)
result = json.loads(resp.choices[0].message.content)
return {
"label": result["label"], # "positive"
"confidence": result["confidence"], # 0.92
"reasoning": result["reasoning"], # "..."
}
Key pattern: response_format={"type": "json_object"} guarantees parseable JSON. temperature=0.0 ensures consistent classification. Always use both together for classification endpoints.
Testing AI Features with pytest
from unittest.mock import MagicMock, patch
from fastapi.testclient import TestClient
from main import app
test_client = TestClient(app)
def mock_response(content: str, tokens: int = 100):
m = MagicMock()
m.choices[0].message.content = content
m.choices[0].finish_reason = "stop"
m.usage.total_tokens = tokens
m.usage.prompt_tokens = tokens - 30
m.usage.completion_tokens = 30
return m
# Test normal operation
@patch("main.client.chat.completions.create")
def test_summarize_success(mock_create):
mock_create.return_value = mock_response("This is a summary.")
resp = test_client.post("/summarize", json={"text": "Long text here..."})
assert resp.status_code == 200
assert resp.json()["summary"] == "This is a summary."
# Test input validation
def test_summarize_empty_text():
resp = test_client.post("/summarize", json={"text": ""})
assert resp.status_code == 400
# Test cost guard — make sure you're not accidentally using gpt-4o
@patch("main.client.chat.completions.create")
def test_uses_mini_model(mock_create):
mock_create.return_value = mock_response("summary")
test_client.post("/summarize", json={"text": "test"})
call_kwargs = mock_create.call_args[1]
assert call_kwargs["model"] == "gpt-4o-mini" # ← cost guard
Testing strategy for AI:
1. Mock the LLM call — tests should be fast, cheap, and deterministic
2. Test the structure of your code (validation, error handling, model choice)
3. Use real API calls only in integration tests (run rarely, cost money)
4. Always add a model assertion test — one accidental gpt-4o in prod can cost 16× more
Model Selection
| Use Case | Model |
| Most tasks (high volume) | gpt-4o-mini |
| Complex reasoning | gpt-4o |
| Cheapest option | gemini-1.5-flash |
| Long context | claude-3-5-sonnet |
Temperature by Task
| Task | Temperature |
| Classification, extraction | 0.0 |
| Summarization | 0.3 |
| Code generation | 0.2 |
| Chat / conversation | 0.7 |
| Creative writing | 0.8–1.0 |