Skip to content
Merged
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ dist/
.mypy_cache/
data/workflows/
data/all_workflows/
.worktrees/
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@

---

![Workflow Patterns Explorer](docs/screenshot-wizard.png)
**[Live Explorer](https://janrummel.github.io/workflow-patterns/)** | [View locally](#view-locally)

[![Workflow Patterns Explorer](docs/screenshot-wizard.png)](https://janrummel.github.io/workflow-patterns/)

## The Idea

Expand Down Expand Up @@ -70,7 +72,7 @@ The site lets you pick a use case and instantly get a complete blueprint:
- **Starter code** in Python with syntax highlighting
- **Claude Code architecture** recommendation

![Blueprint View](docs/screenshot-blueprint.png)
[![Blueprint View](docs/screenshot-blueprint.png)](https://janrummel.github.io/workflow-patterns/)

### View locally

Expand Down Expand Up @@ -159,6 +161,7 @@ Each example implements a real workflow pattern with current tools — not just
| Example | Pattern | What it does |
|---------|---------|-------------|
| [AI Content Creation](examples/ai-content-creation/) | `api -> ai -> transform -> deliver` | Fetches RSS feeds, summarizes with Claude, saves a markdown digest. Interactive selection from 60 curated feeds across 6 categories. |
| [AI Chatbot](examples/ai-chatbot/) | `trigger -> ai -> data -> deliver` | Streaming terminal chat with 5 persona presets and conversation persistence |

```bash
cd examples/ai-content-creation
Expand All @@ -168,12 +171,12 @@ uv run python run.py # interactive feed selection
uv run python run.py --feeds https://hnrss.org/newest?points=100 # or custom
```

More examples coming: AI Chatbot, Data Pipeline, Email Automation.
More examples coming: Data Pipeline, Email Automation.

## Development

```bash
uv run pytest -v # 50 tests across 10 modules
uv run pytest -v # 72 tests across 15 modules
uv run ruff check . # Lint
```

Expand All @@ -188,6 +191,7 @@ workflow-patterns/
│ ├── translator/claude_code.py # Pattern -> Claude Code architecture mapping
│ └── mcp_server/server.py # 4 MCP tools for Claude Code integration
├── examples/
│ ├── ai-chatbot/ # Runnable example (22 tests, streaming API, 5 personas)
│ └── ai-content-creation/ # Runnable example (30 tests, Claude API, 60 curated feeds)
├── scripts/
│ └── generate_site.py # Static site generator with interactive wizard
Expand Down
284 changes: 144 additions & 140 deletions docs/index.html

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions examples/ai-chatbot/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Copy this file to .env and add your key:
# cp .env.example .env
#
# .env is in .gitignore — it will never be committed.
ANTHROPIC_API_KEY=sk-ant-your-key-here
5 changes: 5 additions & 0 deletions examples/ai-chatbot/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.venv/
conversations/
__pycache__/
*.egg-info/
.env
21 changes: 21 additions & 0 deletions examples/ai-chatbot/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
[project]
name = "ai-chatbot"
version = "0.1.0"
description = "AI Chatbot workflow: trigger -> ai -> data -> deliver"
requires-python = ">=3.12"
dependencies = [
"anthropic",
]

[dependency-groups]
dev = ["pytest"]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["src/chatbot"]

[tool.pytest.ini_options]
testpaths = ["tests"]
168 changes: 168 additions & 0 deletions examples/ai-chatbot/run.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
#!/usr/bin/env python3
"""AI Chatbot workflow runner.

Pattern: trigger -> ai -> data -> deliver

Interactive terminal chat with streaming responses,
persona presets, and conversation persistence.

Usage:
uv run python run.py # interactive persona selection
uv run python run.py --persona 1 # select persona by number
"""

import argparse
import os
import sys
from pathlib import Path

from chatbot.chat import create_client, send_message
from chatbot.display import format_header, format_persona_menu, format_welcome
from chatbot.memory import list_conversations, load_conversation, save_conversation
from chatbot.models import Conversation, Message
from chatbot.personas import PERSONAS, get_persona

CONVERSATIONS_DIR = Path(__file__).parent / "conversations"


def _load_dotenv():
"""Load .env file if it exists (no external dependency needed)."""
env_path = Path(__file__).parent / ".env"
if not env_path.exists():
return
for line in env_path.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
key, _, value = line.partition("=")
if key and value:
os.environ.setdefault(key.strip(), value.strip())


def _ensure_api_key():
"""Prompt for API key if not set, optionally save to .env."""
if os.environ.get("ANTHROPIC_API_KEY"):
return

print("No ANTHROPIC_API_KEY found.\n")
print("Get your key at: https://console.anthropic.com/settings/keys\n")

try:
key = input("Enter your ANTHROPIC_API_KEY: ").strip()
except (EOFError, KeyboardInterrupt):
print("\nAborted.")
sys.exit(1)

if not key:
print("No key provided. Exiting.")
sys.exit(1)

os.environ["ANTHROPIC_API_KEY"] = key

try:
save = input("Save to .env for future use? [Y/n] ").strip().lower()
except (EOFError, KeyboardInterrupt):
save = "n"

if save != "n":
env_path = Path(__file__).parent / ".env"
env_path.write_text(f"ANTHROPIC_API_KEY={key}\n")
print(f" Saved to {env_path}\n")


def _select_persona() -> int:
"""Interactive persona selection. Returns 0-based index."""
print(format_header("AI Chatbot — Setup"))
print(format_persona_menu(PERSONAS))
choice = input(f"Persona (1-{len(PERSONAS)}): ").strip()
try:
return int(choice) - 1
except ValueError:
return 0


def _handle_command(command: str, conversation: Conversation) -> bool:
"""Handle a slash command. Returns True if chat should continue."""
if command == "/quit":
return False
if command == "/save":
path = save_conversation(conversation, CONVERSATIONS_DIR)
print(f" Conversation saved to {path}")
return True
if command == "/load":
files = list_conversations(CONVERSATIONS_DIR)
if not files:
print(" No saved conversations found.")
return True
print(" Saved conversations:")
for i, f in enumerate(files[:10], 1):
print(f" {i}. {f.name}")
choice = input(" Load (number): ").strip()
try:
idx = int(choice) - 1
if 0 <= idx < len(files):
loaded = load_conversation(files[idx])
conversation.messages = loaded.messages
conversation.persona = loaded.persona
print(f" Loaded {len(loaded.messages)} messages as {loaded.persona.name}")
except (ValueError, IndexError):
print(" Invalid selection.")
return True
print(f" Unknown command: {command}. Available: /save, /load, /quit")
return True


def main():
_load_dotenv()
_ensure_api_key()

parser = argparse.ArgumentParser(description="AI Chatbot: trigger -> ai -> data -> deliver")
parser.add_argument("--persona", type=int, default=None, help="Persona number (1-5, skips interactive selection)")
args = parser.parse_args()

# Step 1: Trigger — select persona
if args.persona is not None:
persona = get_persona(args.persona - 1)
else:
persona = get_persona(_select_persona())

# Create conversation and client
conversation = Conversation(persona=persona, messages=[])
client = create_client()

print(format_welcome(persona))

# Step 2-4: Chat loop (ai -> data -> deliver)
try:
while True:
try:
user_input = input("You: ").strip()
except EOFError:
break

if not user_input:
continue

if user_input.startswith("/"):
if not _handle_command(user_input, conversation):
break
continue

# AI — send to Claude with streaming
conversation.messages.append(Message(role="user", content=user_input))
print("Assistant: ", end="", flush=True)
response_text = send_message(client, conversation)
conversation.messages.append(Message(role="assistant", content=response_text))
print()

except KeyboardInterrupt:
print("\n")

# Auto-save on exit if there are messages
if conversation.messages:
path = save_conversation(conversation, CONVERSATIONS_DIR)
print(f"Conversation auto-saved to {path}")


if __name__ == "__main__":
main()
Empty file.
39 changes: 39 additions & 0 deletions examples/ai-chatbot/src/chatbot/chat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""Streaming chat logic using Claude API."""

import sys

import anthropic

from chatbot.models import Conversation

MODEL = "claude-sonnet-4-5"


def create_client() -> anthropic.Anthropic:
"""Create an Anthropic client, exiting if API key is missing."""
try:
return anthropic.Anthropic()
except anthropic.AuthenticationError:
print("Error: ANTHROPIC_API_KEY not set or invalid.")
print(" cp .env.example .env # then add your key")
sys.exit(1)


def send_message(client: anthropic.Anthropic, conversation: Conversation) -> str:
"""Send the conversation to Claude and stream the response.

Prints tokens as they arrive. Returns the full response text.
"""
with client.messages.stream(
model=MODEL,
max_tokens=1024,
system=conversation.persona.system_prompt,
messages=conversation.to_api_messages(),
) as stream:
for text in stream.text_stream:
print(text, end="", flush=True)

final = stream.get_final_message()

print() # newline after streaming
return next(b.text for b in final.content if b.type == "text")
36 changes: 36 additions & 0 deletions examples/ai-chatbot/src/chatbot/display.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""Terminal display formatting for the chatbot."""

from chatbot.models import Persona


def format_header(title: str) -> str:
"""Format a boxed header."""
width = 42
lines = [
"",
"╔" + "═" * width + "╗",
"║" + title.center(width) + "║",
"╚" + "═" * width + "╝",
"",
]
return "\n".join(lines)


def format_persona_menu(personas: list[Persona]) -> str:
"""Format the persona selection menu."""
lines = ["Choose a persona:", ""]
for i, p in enumerate(personas, 1):
lines.append(f" {i}. {p.name:<25} {p.description}")
lines.append("")
return "\n".join(lines)


def format_welcome(persona: Persona) -> str:
"""Format the welcome message after persona selection."""
lines = [
"",
f"── {persona.name} ──",
"Type your message. Commands: /save, /load, /quit",
"",
]
return "\n".join(lines)
47 changes: 47 additions & 0 deletions examples/ai-chatbot/src/chatbot/memory.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""Conversation memory: save and load conversations as JSON."""

import json
from datetime import datetime, timezone
from pathlib import Path

from chatbot.models import Conversation, Message, Persona


def save_conversation(conv: Conversation, directory: Path) -> Path:
"""Save a conversation to a JSON file.

Returns the path to the saved file.
"""
directory.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d_%H%M%S_%f")
slug = conv.persona.name.lower().replace(" ", "-")
path = directory / f"{timestamp}_{slug}.json"

data = {
"persona": {
"name": conv.persona.name,
"description": conv.persona.description,
"system_prompt": conv.persona.system_prompt,
},
"messages": [{"role": m.role, "content": m.content} for m in conv.messages],
"started_at": conv.started_at.isoformat(),
}
path.write_text(json.dumps(data, indent=2, ensure_ascii=False))
return path


def load_conversation(path: Path) -> Conversation:
"""Load a conversation from a JSON file."""
data = json.loads(path.read_text())
persona = Persona(**data["persona"])
messages = [Message(**m) for m in data["messages"]]
started_at = datetime.fromisoformat(data["started_at"])
return Conversation(persona=persona, messages=messages, started_at=started_at)


def list_conversations(directory: Path) -> list[Path]:
"""List saved conversation files, newest first."""
if not directory.exists():
return []
files = sorted(directory.glob("*.json"), reverse=True)
return files
Loading