Follow AiTechWorlds on LinkedIn for professional AI content!Follow Now →
45 minLesson 34 of 34
Final Projects

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

Get Notes Free →
!