こんにちは! スタジオ・アルカナに新卒で入社し、エンジニアとして日々学ばせていただいている兼松です。
最近業務でAmazon Bedrockを使用した開発を行いました。 学んだことを定着させるために、アウトプットとして何か形に残しておきたい…。 というわけで今回は、業務の知見を活かしつつ、「ユーザーの性格や気分に合わせてジャンルを選定し、ショートショートを書いてくれるAI」を開発してみたいと思います!
私自身、まだまだAWSもAIも勉強の真っ最中で、ツッコミどころもあるかと思いますが、成長の記録として温かく見守っていただければ幸いです🙇♀️ お気づきの点があれば、ぜひフィードバックをいただけますと幸いです!
ショートショートとは?
「ショートショート」とは、一般的には「短くて、不思議で、気のきいた意外な結末(オチ)がある超短編小説」のことを指します。
と、改めて調べて書いてみましたが、気の利いた意外なオチか〜。 ハードルが上がってしまいました😣 AIに面白いオチを作っていただきましょう!(笑)
実装の全体像
今回は以下の構成で作ります。
- Backend: AWS Lambda (Python) + LangChain + Amazon Bedrock (Claude 3.5 Sonnet)
- API: Amazon API Gateway
- Frontend: Next.js
1. Lambdaの作成と設定
まずはバックエンドとなるLambda関数を作成します。
上記はLambdaの基本設定画面です。 AIの生成には少し時間がかかる場合があるので、タイムアウトは余裕を持って「3分」に設定変更しておきます。デフォルトの3秒だとエラーになります泣
また、画像には写っていませんが、設定は以下のようにしています。
- ランタイム: Python 3.13
- アーキテクチャ: x86_64
- ライブラリ: LangChainなど必要なライブラリを含んだ Lambda Layer を作成して追加します。Pythonのバージョンもお気をつけください!
また、LambdaがBedrockを呼び出せるように、権限周りの設定も行います。
設定 → アクセス権限 → ロール名をクリック → 許可を追加 → ポリシーをアタッチ と進み、「AmazonBedrockFullAccess」 を追加してください。本当は必要なポリシーだけ追加するのをおすすめします🙇♀️
2. Lambdaコードの実装(Python)
ユーザーから送られてきた「気分」や「好み」をプロンプトに埋め込み、Bedrock (Claude) に物語を生成してもらいます。 生成AIにJSONをフォーマットを伝えて、出力結果をAPIのレスポンスに使います。
Python
import logging
import json
import re
from langchain_aws import ChatBedrock
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
from langchain_community.callbacks.manager import get_bedrock_anthropic_callback
logger = logging.getLogger()
logger.setLevel(logging.INFO)
ACCESS_CONTROL_ALLOW_ORIGIN = '<http://localhost:3000>'
def lambda_handler(event, context):
if event.get("requestContext", {}).get("http", {}).get("method") == "OPTIONS":
return {
'statusCode': 200,
'headers': {
'Access-Control-Allow-Headers': 'Content-Type',
'Access-Control-Allow-Origin': ACCESS_CONTROL_ALLOW_ORIGIN,
'Access-Control-Allow-Methods': 'OPTIONS,POST'
},
'body': json.dumps('ok')
}
try:
logger.info(f'Request body: {event["body"]}')
body = json.loads(event["body"])
name = body.get("name", "あなた")
feeling = body.get("feeling", "")
likes = body.get("likes", "")
dislikes = body.get("dislikes", "")
keyword = body.get("keyword", "")
user_prompt = f"""
名前: {name}
今日の気分: {feeling}
好きなもの: {likes}
苦手なもの: {dislikes}
キーワード: {keyword}
"""
# ChatBedrockの設定
chat = ChatBedrock(
model_id='anthropic.claude-3-5-sonnet-20240620-v1:0',
provider='anthropic',
model_kwargs={
"max_tokens": 2000,
"temperature": 0.7,
},
region='ap-northeast-1'
)
messages = [
SystemMessage(content=system_prompt()),
HumanMessage(content=user_prompt)
]
logger.info("Invoking Bedrock...")
with get_bedrock_anthropic_callback() as cb:
response: AIMessage = chat.invoke(messages, config={'callbacks':[cb]})
content = response.content
logger.info(f'Raw model response: {content}')
response_dict = {}
# 1. Markdownのコードブロック記号を除去
cleaned_content = content.replace('```json', '').replace('```', '').strip()
# 2. 最初の中括弧 { から 最後の中括弧 } までを抽出
match = re.search(r'\\{.*\\}', cleaned_content, re.DOTALL)
if match:
json_str = match.group(0)
try:
response_dict = json.loads(json_str, strict=False)
except json.JSONDecodeError as e:
logger.warning(f"First parse attempt failed: {e}. Trying fallback...")
try:
escaped_str = json_str.replace('\\n', '\\\\n').replace('\\r', '')
response_dict = json.loads(escaped_str, strict=False)
except Exception as e2:
logger.error(f"All JSON parse attempts failed: {e2}\\nOriginal: {json_str}")
return error_response("AIの生成結果を読み取れませんでした。もう一度試してください。")
else:
logger.error(f'No JSON found in response: {cleaned_content}')
return error_response("AIからの応答にJSON形式が見当たりませんでした。")
return {
'statusCode': 200,
'headers': {
"Access-Control-Allow-Origin": ACCESS_CONTROL_ALLOW_ORIGIN,
"Access-Control-Allow-Methods": "POST,OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
'Content-Type': 'application/json; charset=utf-8',
},
'body': json.dumps({'response': response_dict}, ensure_ascii=False),
'isBase64Encoded': False,
}
except Exception as e:
logger.error(f"Unhandled exception: {e}", exc_info=True)
return error_response(f"Unhandled exception: {str(e)}")
def error_response(message):
return {
'statusCode': 500,
'headers': {
"Access-Control-Allow-Origin": ACCESS_CONTROL_ALLOW_ORIGIN,
"Access-Control-Allow-Methods": "POST,OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
'Content-Type': 'application/json; charset=utf-8',
},
'body': json.dumps({"error": message}, ensure_ascii=False),
'isBase64Encoded': False,
}
def system_prompt():
return """
あなたはショートショート作家です。
ユーザーの感情や好みから「最適なジャンル」を診断し、そのジャンルにふさわしい短編小説を作成します。
### ジャンル判定ルール(例)
- 怖さ/不安/寒さ/暗い → ホラー
- 好き/甘い/恋/照れ → ラブコメ
- 面倒/失敗/忙しい/ドジ/日常感 → コメディ
- 不思議/考えた/空/未来/科学 → SF
- 哀しい/しみじみ/静か/思い出 → ヒューマンドラマ
### 物語ルール
1. 主人公はユーザーの "名前" を必ず含める
2. ユーザーのキーワードを必ず物語内で使う
3. **700〜900文字(約800字)**で、最後に必ず「オチ」をつける
4. 読みやすく一気に読めるテンポにする
5. 設定の説明に偏らず、必ず物語として展開させる
### 出力JSON(厳守)
回答は**必ずJSON形式のみ**で出力してください。Markdownのコードブロックは不要です。
JSONの値の中で改行する場合は、必ず `\\\\n` を使用してください。
{
"genre": "選ばれたジャンル名",
"story": "生成されたショートショート本文"
}
"""
import logging
import json
import re
from langchain_aws import ChatBedrock
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
from langchain_community.callbacks.manager import get_bedrock_anthropic_callback
logger = logging.getLogger()
logger.setLevel(logging.INFO)
ACCESS_CONTROL_ALLOW_ORIGIN = '<http://localhost:3000>'
def lambda_handler(event, context):
if event.get("requestContext", {}).get("http", {}).get("method") == "OPTIONS":
return {
'statusCode': 200,
'headers': {
'Access-Control-Allow-Headers': 'Content-Type',
'Access-Control-Allow-Origin': ACCESS_CONTROL_ALLOW_ORIGIN,
'Access-Control-Allow-Methods': 'OPTIONS,POST'
},
'body': json.dumps('ok')
}
try:
logger.info(f'Request body: {event["body"]}')
body = json.loads(event["body"])
name = body.get("name", "あなた")
feeling = body.get("feeling", "")
likes = body.get("likes", "")
dislikes = body.get("dislikes", "")
keyword = body.get("keyword", "")
user_prompt = f"""
名前: {name}
今日の気分: {feeling}
好きなもの: {likes}
苦手なもの: {dislikes}
キーワード: {keyword}
"""
# ChatBedrockの設定
chat = ChatBedrock(
model_id='anthropic.claude-3-5-sonnet-20240620-v1:0',
provider='anthropic',
model_kwargs={
"max_tokens": 2000,
"temperature": 0.7,
},
region='ap-northeast-1'
)
messages = [
SystemMessage(content=system_prompt()),
HumanMessage(content=user_prompt)
]
logger.info("Invoking Bedrock...")
with get_bedrock_anthropic_callback() as cb:
response: AIMessage = chat.invoke(messages, config={'callbacks':[cb]})
content = response.content
logger.info(f'Raw model response: {content}')
response_dict = {}
# 1. Markdownのコードブロック記号を除去
cleaned_content = content.replace('```json', '').replace('```', '').strip()
# 2. 最初の中括弧 { から 最後の中括弧 } までを抽出
match = re.search(r'\\{.*\\}', cleaned_content, re.DOTALL)
if match:
json_str = match.group(0)
try:
response_dict = json.loads(json_str, strict=False)
except json.JSONDecodeError as e:
logger.warning(f"First parse attempt failed: {e}. Trying fallback...")
try:
escaped_str = json_str.replace('\\n', '\\\\n').replace('\\r', '')
response_dict = json.loads(escaped_str, strict=False)
except Exception as e2:
logger.error(f"All JSON parse attempts failed: {e2}\\nOriginal: {json_str}")
return error_response("AIの生成結果を読み取れませんでした。もう一度試してください。")
else:
logger.error(f'No JSON found in response: {cleaned_content}')
return error_response("AIからの応答にJSON形式が見当たりませんでした。")
return {
'statusCode': 200,
'headers': {
"Access-Control-Allow-Origin": ACCESS_CONTROL_ALLOW_ORIGIN,
"Access-Control-Allow-Methods": "POST,OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
'Content-Type': 'application/json; charset=utf-8',
},
'body': json.dumps({'response': response_dict}, ensure_ascii=False),
'isBase64Encoded': False,
}
except Exception as e:
logger.error(f"Unhandled exception: {e}", exc_info=True)
return error_response(f"Unhandled exception: {str(e)}")
def error_response(message):
return {
'statusCode': 500,
'headers': {
"Access-Control-Allow-Origin": ACCESS_CONTROL_ALLOW_ORIGIN,
"Access-Control-Allow-Methods": "POST,OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
'Content-Type': 'application/json; charset=utf-8',
},
'body': json.dumps({"error": message}, ensure_ascii=False),
'isBase64Encoded': False,
}
def system_prompt():
return """
あなたはショートショート作家です。
ユーザーの感情や好みから「最適なジャンル」を診断し、そのジャンルにふさわしい短編小説を作成します。
### ジャンル判定ルール(例)
- 怖さ/不安/寒さ/暗い → ホラー
- 好き/甘い/恋/照れ → ラブコメ
- 面倒/失敗/忙しい/ドジ/日常感 → コメディ
- 不思議/考えた/空/未来/科学 → SF
- 哀しい/しみじみ/静か/思い出 → ヒューマンドラマ
### 物語ルール
1. 主人公はユーザーの "名前" を必ず含める
2. ユーザーのキーワードを必ず物語内で使う
3. **700〜900文字(約800字)**で、最後に必ず「オチ」をつける
4. 読みやすく一気に読めるテンポにする
5. 設定の説明に偏らず、必ず物語として展開させる
### 出力JSON(厳守)
回答は**必ずJSON形式のみ**で出力してください。Markdownのコードブロックは不要です。
JSONの値の中で改行する場合は、必ず `\\\\n` を使用してください。
{
"genre": "選ばれたジャンル名",
"story": "生成されたショートショート本文"
}
"""
苦労したのが、AIからのレスポンスを綺麗なJSONで受け取るという点です。AIはたまに余計な前置きを入れたりするので、正規表現を使ってJSON部分だけを抽出する処理を入れています。
3. API Gatewayの設定
Lambdaができたら、外部から叩けるように HTTP API を作成します。
- API名を設定(例:
gen_ss) - ルートに
POSTとOPTIONSを追加し、先ほどのLambda関数を統合します。 - このままだとブラウザから叩けないので、CORS設定をします。
- Access-Control-Allow-Origin:
http://localhost:3000(開発環境用) - Access-Control-Allow-Headers:
content-type - Access-Control-Allow-Methods:
POST, OPTIONS
- Access-Control-Allow-Origin:
4. フロントエンドの実装(Next.js)
最後に、ユーザーが情報を入力する画面をNext.jsで作ります。 Tailwind CSSを使用しています。
JavaScript
"use client";
import { useState } from "react";
export default function Page() {
const [name, setName] = useState("");
const [feeling, setFeeling] = useState("");
const [likes, setLikes] = useState("");
const [dislikes, setDislikes] = useState("");
const [keyword, setKeyword] = useState("");
const [story, setStory] = useState("");
const [genre, setGenre] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError("");
setStory("");
setGenre("");
try {
const lambdaUrl =
"<https://0jbqrqikq8.execute-api.ap-northeast-1.amazonaws.com/>";
const res = await fetch(lambdaUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, feeling, likes, dislikes, keyword }),
});
if (!res.ok) {
const errText = await res.text();
throw new Error(`API error ${res.status}: ${errText}`);
}
const data = await res.json();
setGenre(data.response?.genre || "取得できませんでした");
setStory(data.response?.story || "取得できませんでした");
} catch (err: any) {
console.error(err);
setError(err.message || "Something went wrong");
} finally {
setLoading(false);
}
};
return (
<div className="max-w-2xl mx-auto p-4">
<h1 className="text-2xl font-bold mb-4">ショートショート生成器</h1>
<form onSubmit={handleSubmit} className="space-y-3">
<input
type="text"
placeholder="名前"
value={name}
onChange={(e) => setName(e.target.value)}
className="border p-2 w-full"
required
/>
<input
type="text"
placeholder="今日の気分"
value={feeling}
onChange={(e) => setFeeling(e.target.value)}
className="border p-2 w-full"
/>
<input
type="text"
placeholder="好きなもの"
value={likes}
onChange={(e) => setLikes(e.target.value)}
className="border p-2 w-full"
/>
<input
type="text"
placeholder="苦手なもの"
value={dislikes}
onChange={(e) => setDislikes(e.target.value)}
className="border p-2 w-full"
/>
<input
type="text"
placeholder="キーワード"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
className="border p-2 w-full"
/>
<button
type="submit"
disabled={loading}
className="bg-blue-500 text-white p-2 rounded w-full"
>
{loading ? "生成中..." : "ショートショートを生成"}
</button>
</form>
{error && <p className="text-red-500 mt-3">{error}</p>}
{story && (
<div className="mt-6 p-4 border rounded bg-black-50">
<h2 className="text-xl font-semibold mb-2">ジャンル: {genre}</h2>
<p>{story}</p>
</div>
)}
</div>
);
}
"use client";
import { useState } from "react";
export default function Page() {
const [name, setName] = useState("");
const [feeling, setFeeling] = useState("");
const [likes, setLikes] = useState("");
const [dislikes, setDislikes] = useState("");
const [keyword, setKeyword] = useState("");
const [story, setStory] = useState("");
const [genre, setGenre] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError("");
setStory("");
setGenre("");
try {
const lambdaUrl =
"<https://0jbqrqikq8.execute-api.ap-northeast-1.amazonaws.com/>";
const res = await fetch(lambdaUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, feeling, likes, dislikes, keyword }),
});
if (!res.ok) {
const errText = await res.text();
throw new Error(`API error ${res.status}: ${errText}`);
}
const data = await res.json();
setGenre(data.response?.genre || "取得できませんでした");
setStory(data.response?.story || "取得できませんでした");
} catch (err: any) {
console.error(err);
setError(err.message || "Something went wrong");
} finally {
setLoading(false);
}
};
return (
<div className="max-w-2xl mx-auto p-4">
<h1 className="text-2xl font-bold mb-4">ショートショート生成器</h1>
<form onSubmit={handleSubmit} className="space-y-3">
<input
type="text"
placeholder="名前"
value={name}
onChange={(e) => setName(e.target.value)}
className="border p-2 w-full"
required
/>
<input
type="text"
placeholder="今日の気分"
value={feeling}
onChange={(e) => setFeeling(e.target.value)}
className="border p-2 w-full"
/>
<input
type="text"
placeholder="好きなもの"
value={likes}
onChange={(e) => setLikes(e.target.value)}
className="border p-2 w-full"
/>
<input
type="text"
placeholder="苦手なもの"
value={dislikes}
onChange={(e) => setDislikes(e.target.value)}
className="border p-2 w-full"
/>
<input
type="text"
placeholder="キーワード"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
className="border p-2 w-full"
/>
<button
type="submit"
disabled={loading}
className="bg-blue-500 text-white p-2 rounded w-full"
>
{loading ? "生成中..." : "ショートショートを生成"}
</button>
</form>
{error && <p className="text-red-500 mt-3">{error}</p>}
{story && (
<div className="mt-6 p-4 border rounded bg-black-50">
<h2 className="text-xl font-semibold mb-2">ジャンル: {genre}</h2>
<p>{story}</p>
</div>
)}
</div>
);
}
完成!
パースエラーとの戦いにはなりましたが、無事に動かすことができました!
実際に実行してみた結果がこちらです。
…???(困惑)
宙吊りで激辛カレー食べてるのがコメディなのでしょうか…(笑) 体感ホラー要素も感じました(笑)
AIが一生懸命書いてくれた文章ですが、ちょっと物語としては色々欠落していますね…💧 もう少し工夫する余地がありそうです。 これはこれで人間には書けない独特の味があって面白いなと感じました。今回はあえて修正せず、このまま一つのコンテンツとして楽しむことにします。 自分自身が物語の主人公になれるのも、個人的に新鮮でした!友達の名前でも面白かった!!
Bedrockを使用し、こうした「性格診断 × コンテンツ生成」のようなアプリも手軽に作れることがわかりました。画像生成モデルを使えば、簡単な情報を入力するだけでマイアイコンを作る、なんてこともできそうです。(実はそちらもやってみたい)
皆さんもぜひBedrockを触って、気軽に遊んでみてください!
PS: 別の物語を生成してもらった際に、きっと「兼松は寝た」と言いたかったのだと思うのですが、「兼松は安らかな眠りについた」と書かれていて、え!しんじゃった!!! と思わず動揺してしまいました。 これはさすがに睡眠の表現については学習させてあげねば…と思った出来事でした😇