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
PatternWhen to UseExample
Sync endpointResponse < 3s, user waitsClassification, short summaries
Streaming endpointLong text generation, chatChat, code generation, long summaries
Async background jobProcessing takes minutesBatch 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
Quick Reference
Model Selection
Use CaseModel
Most tasks (high volume)gpt-4o-mini
Complex reasoninggpt-4o
Cheapest optiongemini-1.5-flash
Long contextclaude-3-5-sonnet
Temperature by Task
TaskTemperature
Classification, extraction0.0
Summarization0.3
Code generation0.2
Chat / conversation0.7
Creative writing0.8–1.0