Project: AI Writing Assistant
Project: AI Writing Assistant
This capstone project brings together everything from the course: async Python, FastAPI, streaming, AI APIs, file I/O, and a clean CLI. You'll build a multi-mode AI writing assistant that can draft, edit, summarize, and translate text — using both OpenAI and Claude, with a streaming interface that feels instant.
What You'll Build
$ python assistant.py
AI Writing Assistant
====================
Mode: [1] Draft [2] Edit [3] Summarize [4] Translate [5] Brainstorm
Model: [a] GPT-4o [b] Claude Sonnet
Select mode: 2
Select model: b
Paste your text (enter . on a blank line to finish):
This report is bad and needs improvement. The data shows things.
.
Editing with Claude... ▓▓▓▓▓▓▓▓░░░░
EDITED VERSION:
This report requires significant revision to communicate its findings
effectively. The data reveals several key patterns worth examining...
[s]ave [c]opy [r]retry [q]quit: s
Saved to: edits/edit_20260526_143022.md ✓
Project Structure
ai_writing_assistant/
├── assistant.py # Main CLI application
├── ai_client.py # Unified OpenAI + Claude client
├── modes.py # Writing mode prompts
├── history.py # Session history management
├── output.py # File saving + formatting
├── requirements.txt
└── .env
Step 1: Unified AI Client
The key design decision here: one interface that works with both APIs, with streaming support.
# ai_client.py
import os
from typing import Iterator
from openai import OpenAI
import anthropic
from dotenv import load_dotenv
load_dotenv()
class AIClient:
def __init__(self):
self.openai = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
self.anthropic = anthropic.Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))
def complete(
self,
system: str,
user: str,
model: str = "gpt-4o",
temperature: float = 0.7,
max_tokens: int = 2048,
stream: bool = True
) -> Iterator[str]:
"""Unified completion — yields text chunks for streaming."""
if model.startswith("gpt") or model.startswith("o"):
yield from self._openai_stream(system, user, model, temperature, max_tokens, stream)
else:
yield from self._claude_stream(system, user, model, max_tokens, stream)
def _openai_stream(self, system, user, model, temperature, max_tokens, stream):
if not stream:
response = self.openai.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": user}
],
temperature=temperature,
max_tokens=max_tokens
)
yield response.choices[0].message.content
return
with self.openai.chat.completions.stream(
model=model,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": user}
],
temperature=temperature,
max_tokens=max_tokens
) as stream:
for chunk in stream:
if chunk.choices[0].delta.content:
yield chunk.choices[0].delta.content
def _claude_stream(self, system, user, model, max_tokens, stream):
if not stream:
response = self.anthropic.messages.create(
model=model,
max_tokens=max_tokens,
system=system,
messages=[{"role": "user", "content": user}]
)
yield response.content[0].text
return
with self.anthropic.messages.stream(
model=model,
max_tokens=max_tokens,
system=system,
messages=[{"role": "user", "content": user}]
) as stream:
for text in stream.text_stream:
yield text
MODELS = {
"a": ("GPT-4o", "gpt-4o"),
"b": ("Claude Sonnet", "claude-sonnet-4-6"),
"c": ("Claude Opus", "claude-opus-4-7"),
"d": ("GPT-4o mini", "gpt-4o-mini"),
}
Step 2: Writing Mode Prompts
# modes.py
MODES = {
"1": {
"name": "Draft",
"description": "Write a first draft from your notes or outline",
"system": """You are an expert writer. When given notes, bullet points, or an outline,
produce a well-structured, engaging first draft.
- Match the tone to the content type (professional for reports, conversational for blogs)
- Use clear paragraph structure with strong topic sentences
- Write in active voice whenever possible
- Do not pad with filler phrases"""
},
"2": {
"name": "Edit",
"description": "Improve clarity, grammar, and flow",
"system": """You are a professional editor. Improve the given text for:
1. Clarity — cut ambiguity and vague language
2. Concision — remove unnecessary words without losing meaning
3. Flow — ensure smooth transitions between ideas
4. Grammar — fix errors without changing the author's voice
Return ONLY the edited text, no commentary."""
},
"3": {
"name": "Summarize",
"description": "Condense long text to key points",
"system": """You are an expert at distilling information. Create a summary that:
- Captures the most important points in order of importance
- Uses bullet points for scannable structure
- Includes key numbers and specifics (don't round them away)
- Fits in under 20% of the original length
Start directly with the summary."""
},
"4": {
"name": "Translate",
"description": "Translate to another language naturally",
"system": """You are a professional translator. Translate the given text naturally —
not word-for-word, but conveying the full meaning and tone of the original.
The user will specify the target language in their message."""
},
"5": {
"name": "Brainstorm",
"description": "Generate ideas, angles, and variations",
"system": """You are a creative thinking partner. Generate diverse, practical ideas.
- Provide at least 10 distinct ideas
- Range from conventional to creative
- For each idea, include a 1-sentence explanation of why it works
- Organize by theme or category when helpful
Do not repeat similar ideas with different wording."""
},
"6": {
"name": "Tone Shift",
"description": "Rewrite in a different tone (formal/casual/persuasive)",
"system": """You are a versatile writer who can match any tone.
The user will specify the target tone. Rewrite the text to fully match that tone
while preserving the core message. Only output the rewritten text."""
}
}
Step 3: Session History
# history.py
from dataclasses import dataclass, field
from datetime import datetime
import json
from pathlib import Path
@dataclass
class HistoryEntry:
mode: str
model: str
input_text: str
output_text: str
timestamp: str = field(default_factory=lambda: datetime.now().isoformat())
def to_dict(self):
return {
"mode": self.mode,
"model": self.model,
"input": self.input_text,
"output": self.output_text,
"timestamp": self.timestamp
}
class SessionHistory:
def __init__(self, session_dir: str = "sessions"):
self.entries: list[HistoryEntry] = []
self.session_dir = Path(session_dir)
self.session_dir.mkdir(exist_ok=True)
self.session_file = self.session_dir / f"session_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
def add(self, entry: HistoryEntry):
self.entries.append(entry)
self._save()
def _save(self):
data = [e.to_dict() for e in self.entries]
self.session_file.write_text(json.dumps(data, indent=2))
def get_last(self) -> HistoryEntry | None:
return self.entries[-1] if self.entries else None
def summary(self) -> str:
if not self.entries:
return "No entries this session."
lines = [f"Session: {len(self.entries)} interactions"]
for e in self.entries[-3:]:
lines.append(f" [{e.mode}] {e.input_text[:40]}...")
return "\n".join(lines)
Step 4: Output Handler
# output.py
import pyperclip # pip install pyperclip
from pathlib import Path
from datetime import datetime
def save_output(text: str, mode: str, output_dir: str = "outputs") -> str:
"""Save output to a timestamped file."""
path = Path(output_dir) / mode.lower()
path.mkdir(parents=True, exist_ok=True)
filename = f"{mode.lower()}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.md"
filepath = path / filename
content = f"# {mode} — {datetime.now().strftime('%Y-%m-%d %H:%M')}\n\n{text}\n"
filepath.write_text(content, encoding='utf-8')
return str(filepath)
def copy_to_clipboard(text: str) -> bool:
"""Copy text to system clipboard."""
try:
pyperclip.copy(text)
return True
except Exception:
return False
def print_streaming(chunks) -> str:
"""Print streaming chunks as they arrive. Returns full text."""
full_text = ""
print() # New line before output
for chunk in chunks:
print(chunk, end='', flush=True)
full_text += chunk
print("\n") # Clean end
return full_text
Step 5: The Main CLI
# assistant.py
import sys
from ai_client import AIClient, MODELS
from modes import MODES
from history import SessionHistory, HistoryEntry
from output import save_output, copy_to_clipboard, print_streaming
def get_multiline_input(prompt: str = "Your text (enter . on a blank line to finish):") -> str:
"""Collect multi-line input until user types a lone period."""
print(f"\n{prompt}")
lines = []
while True:
try:
line = input()
if line.strip() == '.':
break
lines.append(line)
except (KeyboardInterrupt, EOFError):
break
return "\n".join(lines).strip()
def select_mode() -> tuple[str, dict]:
print("\nWriting Modes:")
for key, mode in MODES.items():
print(f" [{key}] {mode['name']:12s} — {mode['description']}")
while True:
choice = input("\nSelect mode: ").strip()
if choice in MODES:
return choice, MODES[choice]
print("Invalid choice. Try again.")
def select_model() -> tuple[str, str]:
print("\nModels:")
for key, (name, model_id) in MODELS.items():
print(f" [{key}] {name}")
while True:
choice = input("\nSelect model (default: a): ").strip() or "a"
if choice in MODELS:
name, model_id = MODELS[choice]
return name, model_id
print("Invalid choice. Try again.")
def handle_post_output(text: str, mode_name: str, history: SessionHistory, entry: HistoryEntry):
"""Handle save/copy/retry actions after output."""
while True:
action = input("[s]ave [c]opy to clipboard [q]uit [enter] continue: ").strip().lower()
if action == 's':
path = save_output(text, mode_name)
print(f"Saved → {path} ✓")
elif action == 'c':
if copy_to_clipboard(text):
print("Copied to clipboard ✓")
else:
print("Clipboard not available — install pyperclip")
elif action == 'q':
print("\nSession summary:")
print(history.summary())
sys.exit(0)
else:
break
def main():
print("\n" + "=" * 50)
print(" AI Writing Assistant")
print("=" * 50)
client = AIClient()
history = SessionHistory()
while True:
try:
# Mode selection
mode_key, mode_config = select_mode()
mode_name = mode_config['name']
# Model selection
model_name, model_id = select_model()
print(f"\nUsing: {mode_name} mode with {model_name}")
# Build prompt based on mode
if mode_key == "4": # Translate
lang = input("Translate to: ").strip() or "Spanish"
user_text = get_multiline_input()
user_prompt = f"Translate this to {lang}:\n\n{user_text}"
elif mode_key == "6": # Tone shift
tone = input("Target tone (e.g., formal, casual, persuasive): ").strip()
user_text = get_multiline_input()
user_prompt = f"Rewrite in a {tone} tone:\n\n{user_text}"
elif mode_key == "5": # Brainstorm
topic = input("Topic or question to brainstorm: ").strip()
user_prompt = topic
user_text = topic
else:
user_text = get_multiline_input()
user_prompt = user_text
if not user_prompt:
print("No input provided. Try again.")
continue
# Stream the response
print(f"\n{mode_name} with {model_name}...\n" + "─" * 40)
chunks = client.complete(
system=mode_config['system'],
user=user_prompt,
model=model_id,
stream=True
)
output_text = print_streaming(chunks)
# Save to history
entry = HistoryEntry(
mode=mode_name,
model=model_name,
input_text=user_text,
output_text=output_text
)
history.add(entry)
handle_post_output(output_text, mode_name, history, entry)
except KeyboardInterrupt:
print("\n\nSession ended.")
print(history.summary())
break
if __name__ == '__main__':
main()
Bonus: Batch Processing Mode
# batch_process.py — process multiple files at once
import json
from pathlib import Path
from ai_client import AIClient
from modes import MODES
def batch_summarize(input_dir: str, output_dir: str, model: str = "claude-sonnet-4-6"):
"""Summarize all .txt files in a directory."""
client = AIClient()
mode = MODES["3"] # Summarize
input_path = Path(input_dir)
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
files = list(input_path.glob("*.txt")) + list(input_path.glob("*.md"))
print(f"Processing {len(files)} files...")
results = []
for i, filepath in enumerate(files, 1):
print(f"[{i}/{len(files)}] {filepath.name}...", end=' ')
text = filepath.read_text(encoding='utf-8')
chunks = client.complete(
system=mode['system'],
user=text,
model=model,
stream=False
)
summary = "".join(chunks)
out_file = output_path / f"{filepath.stem}_summary.md"
out_file.write_text(summary)
results.append({"file": filepath.name, "summary_file": out_file.name})
print("✓")
# Write index
index = output_path / "index.json"
index.write_text(json.dumps(results, indent=2))
print(f"\nDone. {len(files)} summaries saved to {output_dir}/")
if __name__ == '__main__':
batch_summarize("documents/", "summaries/")
Running the Project
# Install dependencies
pip install openai anthropic python-dotenv pyperclip
# Set up your .env
echo "OPENAI_API_KEY=sk-..." > .env
echo "ANTHROPIC_API_KEY=sk-ant-..." >> .env
# Run the assistant
python assistant.py
# Run batch processing
python batch_process.py
Congratulations — you've completed the Python Complete Course 2026. You've gone from Python fundamentals all the way to building production-quality AI applications. The patterns in this project — unified API clients, streaming interfaces, session management, and batch processing — are exactly what you'll find in real AI-powered products.
Get this course's notes on Telegram!
Free cheat sheets, summaries & practice exercises