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
20 changes: 19 additions & 1 deletion ideate/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ A command line interface and web application for AI-powered creative idea genera
## Overview

Ideate allows users to generate creative ideas based on optional topic.
The app is built with Typer, Python, Pydantic, and OpenAI.
The app is built with Python, FastAPI, Typer, Pydantic, and OpenAI.

## Features

Expand Down Expand Up @@ -60,6 +60,24 @@ python3 -m ideate.cli.ideate ideate --fallback
python3 -m ideate.cli.ideate ideate -tf <topic>
```

### Run App:

```bash
cd /workspaces/stacked/ideate && source .ideate/bin/activate && cd ..
uvicorn ideate.api.ideate:app --reload
curl 'http://localhost:8000/ideate?fallback=True'
curl 'http://localhost:8000/ideate?fallback=True&topic=<topic>'
curl 'http://localhost:8000/ideate?topic=<topic>'
curl 'http://localhost:8000/ideate'
```

- Browse `https://<codespace>-8000.app.github.dev/ideate?fallback=True`
- Browse `https://<codespace>-8000.app.github.dev/ideate?fallback=True&topic=<topic>`
- Browse `https://<codespace>-8000.app.github.dev/ideate?topic=<topic>`
- Browse `https://<codespace>-8000.app.github.dev/ideate`

- Browse `https://<codespace>-8000.app.github.dev/docs`

### Run Tests

```bash
Expand Down
Empty file added ideate/api/__init__.py
Empty file.
115 changes: 115 additions & 0 deletions ideate/api/ideate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
"""
FastAPI app for ideate web API, matching CLI logic and supporting fallback.
"""
from fastapi import FastAPI, Response, Query
from fastapi.responses import HTMLResponse

from ideate.ai.ai_provider import get_ai_idea
from ideate.fallback.fallback_provider import get_fallback_idea
from ideate.option.option_provider import normalize_topic


app = FastAPI()

@app.get("/ideate", response_class=HTMLResponse)
async def ideate(
topic: str = Query(default=None, description="Optional topic"),
fallback: bool = Query(default=False, description="Force fallback idea"),
) -> Response:
"""
Serves a basic HTML page with a creative idea, using AI provider with fallback.
"""
canonical_topic = normalize_topic(topic) if topic else None

if fallback:
idea = get_fallback_idea()
else:
idea, _, status = get_ai_idea(canonical_topic)
if status != 200 or not idea:
idea = get_fallback_idea()
if canonical_topic:
idea = f"{canonical_topic} Idea: {idea}"

html = f"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>ideate - Creative Idea</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
html, body {{
min-height: 100vh;
margin: 0;
padding: 0;
font-family: 'Inter', 'Segoe UI', 'Arial', sans-serif;
background: #000000;
color: #fafafa;
}}
.container {{
display: grid;
grid-template-rows: 20px 1fr 20px;
align-items: center;
justify-items: center;
min-height: 100vh;
padding: 32px 32px 80px 32px;
gap: 64px;
}}
@media (min-width: 640px) {{
.container {{
padding: 80px 80px 80px 80px;
}}
.main {{
align-items: flex-start;
}}
}}
.main {{
display: flex;
flex-direction: column;
gap: 32px;
grid-row: 2;
align-items: center;
}}
.idea {{
background: rgba(255,255,255,1);
color: rgba(0,0,0,1);
font-family: 'Fira Mono', 'Menlo', 'Monaco', monospace;
font-weight: 300;
padding: 4px 8px;
border-radius: 6px;
margin-bottom: 16px;
font-size: 1rem;
max-width: 600px;
text-align: center;
box-shadow: 0 2px 8px #0002;
}}
.footer {{
background: rgba(0,0,0,0.05);
color: rgba(255,255,255,0.25);
font-size: 9px;
font-family: 'Fira Mono', 'Menlo', 'Monaco', monospace;
font-weight: 300;
grid-row: 3;
display: flex;
gap: 24px;
flex-wrap: wrap;
align-items: center;
justify-content: center;
padding: 2px 0;
}}
</style>
</head>
<body>
<div class="container">
<main class="main">
<div class="idea">{idea}</div>
</main>
<footer class="footer">
<p>unvalidated responses inferred at individual risk</p>
</footer>
</div>
</body>
</html>
"""

return HTMLResponse(content=html, status_code=200)
24 changes: 3 additions & 21 deletions ideate/cli/ideate.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@
import os
import random
import time
from typing import List, Optional
from typing import Optional

import typer

from ideate.ai.ai_provider import get_ai_idea
from ideate.fallback.fallback_provider import get_fallback_idea
from ideate.option.option_provider import autocomplete_topics, get_topics
from ideate.option.option_provider import autocomplete_topics, get_topics, normalize_topic


app = typer.Typer(help="ideate Tool - Generates a creative idea.")
Expand All @@ -34,8 +34,7 @@ def ideate(
"""
Generates a creative idea, by topic optionally, with a simulated thinking delay.
"""
topics: List[str] = get_topics()
canonical_topic = _normalize_topic(topic, topics) if topic else None
canonical_topic = normalize_topic(topic) if topic else None

if topic and not canonical_topic:
suggestions = autocomplete_topics(topic)
Expand Down Expand Up @@ -68,23 +67,6 @@ def echo_topics() -> None:
for t in get_topics():
typer.echo(f"{t}")

def _normalize_topic(topic: Optional[str], topics: List[str]) -> Optional[str]:
"""
Returns canonical topic from list if case-insensitive match, else None.

:return: Optional[str]
"""
if not topic:

return None

for t in topics:
if t.lower() == topic.lower():

return t

return None

def _should_skip_delay() -> bool:
"""
Returns True if IDEATE_TEST_MODE=1 is set in the environment.
Expand Down
19 changes: 18 additions & 1 deletion ideate/option/option_provider.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""
Provides options for ideate tool.
"""
from typing import List
from typing import List, Optional

from ideate.option.topics import topics

Expand All @@ -21,3 +21,20 @@ def autocomplete_topics(incomplete: str) -> List[str]:
"""

return [t for t in get_topics() if t.lower().startswith(incomplete.lower())]

def normalize_topic(topic: Optional[str]) -> Optional[str]:
"""
Returns canonical topic from topics if case-insensitive match, else None.

:return: Optional[str]
"""
if not topic:

return None

for t in get_topics():
if t.lower() == topic.lower():

return t

return None
Empty file added ideate/tests/api/__init__.py
Empty file.
102 changes: 102 additions & 0 deletions ideate/tests/api/ideate_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
"""
Tests FastAPI ideate web API root endpoint with AI provider and fallback.
"""
from unittest.mock import patch

from fastapi.testclient import TestClient

from ideate.api.ideate import app
from ideate.fallback.fallback_responses import responses


client = TestClient(app)

def test_ideate_api_returns_disclaimer() -> None:
"""
Tests GET /ideate returns 200 and includes disclaimer in the HTML.
"""
with patch(
"ideate.api.ideate.get_ai_idea",
return_value=("A sketchbook that encourages practice", None, 200),
):
response = client.get("/ideate")

assert response.status_code == 200
assert "unvalidated responses inferred at individual risk" in response.text
assert "<html" in response.text

def test_ideate_api_returns_ai_idea() -> None:
"""
Tests GET /ideate returns 200 and includes AI idea in the HTML.
"""
with patch(
"ideate.api.ideate.get_ai_idea",
return_value=("A sketchbook that encourages practice", None, 200),
):
response = client.get("/ideate")

assert response.status_code == 200
assert "A sketchbook that encourages practice" in response.text
assert "<html" in response.text

def test_ideate_api_returns_ai_idea_with_topic() -> None:
"""
Tests GET /ideate returns 200 and includes topical AI idea in the HTML.
"""
with patch(
"ideate.api.ideate.get_ai_idea",
return_value=("Art Idea: A sketchbook that encourages practice", None, 200),
):
response = client.get("/ideate?topic=art")

assert response.status_code == 200
assert "Art Idea: A sketchbook that encourages practice" in response.text
assert "<html" in response.text

def test_ideate_api_returns_fallback_on_ai_error() -> None:
"""
Tests GET /ideate returns fallback idea if AI provider fails.
"""
with patch(
"ideate.api.ideate.get_ai_idea",
return_value=(None, "server misconfigured", 500),
):
response = client.get("/ideate")

assert response.status_code == 200
assert any(idea in response.text for idea in responses)
assert "<html" in response.text

def test_ideate_api_returns_fallback_on_ai_error_with_topic() -> None:
"""
Tests GET /ideate returns topical fallback idea if AI provider fails.
"""
with patch(
"ideate.api.ideate.get_ai_idea",
return_value=(None, "server misconfigured", 500),
):
response = client.get("/ideate?topic=art")

assert response.status_code == 200
assert any(idea in response.text for idea in responses)
assert "<html" in response.text

def test_ideate_api_fallback_query_param() -> None:
"""
Tests GET /ideate?fallback=true returns fallback idea.
"""
response = client.get("/ideate?fallback=true")

assert response.status_code == 200
assert any(idea in response.text for idea in responses)
assert "<html" in response.text

def test_ideate_api_fallback_query_param_with_topic() -> None:
"""
Tests GET /ideate?fallback=true returns topical fallback idea.
"""
response = client.get("/ideate?fallback=true&topic=art")

assert response.status_code == 200
assert any(idea in response.text for idea in responses)
assert "<html" in response.text
12 changes: 11 additions & 1 deletion ideate/tests/option/option_provider_test.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""
Tests for topic provider.
"""
from ideate.option.option_provider import autocomplete_topics, get_topics
from ideate.option.option_provider import autocomplete_topics, get_topics, normalize_topic


def test_get_topics() -> None:
Expand All @@ -14,6 +14,16 @@ def test_get_topics() -> None:
assert all(isinstance(t, str) for t in topics)
assert len(topics) == 36

def test_normalize_topics() -> None:
"""
Tests normalize_topics returns a non-empty list of strings.
"""
topic = "Art"
normal_topic = normalize_topic(topic=topic)

assert isinstance(normal_topic, str)
assert all(isinstance(t, str) for t in normal_topic)

def test_autocomplete_topics() -> None:
"""
Tests autocomplete_topics returns a non-empty list of strings.
Expand Down