背景や目的
1.生成AIの試験ツールを作ろうとすると、意外と画面作成は面倒だな、と日ごろから感じていました。今回少し時間が出来まして、チャットボット画面を作成してみました。
2.なるべく一般的なシステムとしても通用するようにと、AWSで作成しました。チャットボット画面作成の1つの例として、エンジニアの皆様に、ご参考になれば幸いです。また、より良い方法等、ご意見も頂けると嬉しいです。
アプリのファイル構成
以下の通りです。
| ファイル | 収容機能 | |
|---|---|---|
| クライアント画面 | proto2_init_disp (Lambda関数1) | ・初回アクセス応答用サーバ(python) ・初期画面とAJAX処理(HTML+JS)を返却 |
| サーバ応答部 | proto2_server (Lambda関数2) | ・クライアント画面からのAJAXリクエストに応答。 ・ 利用者限定目的のアクセス元IPによる制御 ・ body部のデコードとパラメータ受理 ・ セッション処理 ・ 回答生成依頼 ・ 回答を利用し、レスポンスデータを作成、返却 ・会話履歴の保存、参照(継続質問時) |
クライアント画面の作成
まずユーザが使う方を作成します。ajaxで実現しました。
クライアント画面:Lambda関数を用意
画面表示の処理は、AWS Lambdaで作成します。
・AWSコンソールでLambdaメニューに遷移し、トップ画面で「関数の作成」を押します。
・画面「関数の作成」では、以下の設定値で、作成します。
| 設定値 | 値 |
|---|---|
| 関数名 | proto2_init_disp |
| ランタイム | python3.9 |
| アーキテクチャ | x86_64(デフォルト) |
| 実行ロール | atd_role_for_lambda ※お手元の環境で任意に設定 |
| 関数 URL を有効化 | チェック |
| 認証タイプ | NONE |
クライアント画面:処理
画面側HTML+JS部と、それを返却するサーバ部があります。以下、処理概要です。
・ 画面側のHTML+JSは、サーバでリクエスト受理時に返却します。
・ 以下、対象のコードです。
##クライアント側:lambda_handler部分
import json
import boto3
def lambda_handler(event, context):
#Output HTML path def
path = './proto2_init_disp.html'
with open(path) as f:
html_body = f.read()
return {
'statusCode': 200,
#add the contenttype for displaying hmtl
'headers': {
'Content-Type': 'text/html',
'charset': "UTF-8",
},
'body': html_body
}・ 画面(HTML+JS)には以下のコンポーネントが存在します。
- 下段には、質問入力欄、質問送信ボタン、話題クリアボタン、が存在します。
- 中央部には、質問&回答の表示部が、存在します。

・ 画面側HTML+JS部のHTML部分のコードは以下の通りです。
<!--クライアント側:HTML部分-->
<!DOCTYPE html>
<html lang="ja">
<head>
<title>チャット画面</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
</head>
<body>
<div >
<div id="header">
<h1>生成AIプロト2ボット</h1>
</div>
<div id="message-section">
<!-- If we need a greeting from the bot
<div class="message" id="bot">
<img src="https://proto2-init-disp.s3.ap-northeast-1.amazonaws.com/persona3.png" alt="BOTimg" width="100px" height="100px">
<span id="bot-response">こんにちは!</span></div>-->
</div>
<div id="input-section">
<form method="post">
<input type="text" placeholder="ご質問は何ですか?" name="question" id="question" size="200"/>
</form>
<button class="send" id="ajax">
<div class="circle"><i class="zmdi zmdi-mail-send"></i></div>
</button>
</div>
<!--for clearing cookies.varun added3.4.24-->
<input id="cookieDeletebtn" type="button" value="話題クリア" />
</div>
<div id="loader" style="display: none;">
<div class="spinner"></div>
<div class="sync-text"><h4>Now Thinking</h4></div>
</div>
・・・JS部参照・・・
</body>
</html>・ 画面側HTML+JS部のJS部分のコードは以下の通りです。
<!--クライアント側:JS部分-->
<script type="text/javascript" >
function addquestion(question,question_time){
const mainDiv = document.getElementById("message-section");
// Create bot message container div -- for make Green group
let userMessageContainer = document.createElement("div");
userMessageContainer.classList.add("user-message-container");
// Add to group as 1st item -- Green group1(timestamp)
let timeuserDiv = document.createElement("div");
timeuserDiv.id = "user_time";
timeuserDiv.classList.add("message");
timeuserDiv.innerHTML = `<span id="user-time">${question_time}</span>`;
userMessageContainer.appendChild(timeuserDiv);
// Create user message div and append it -- Green group2(message)
let userDiv = document.createElement("div");
userDiv.id = "user";
userDiv.classList.add("message");
userDiv.innerHTML = `<span id="user-response">${question}</span>`;
userMessageContainer.appendChild(userDiv);
// Append the bot message container after the user message -- Green group fix
mainDiv.appendChild(userMessageContainer);
}
function addChat(botreply,ajax_time) {
const mainDiv = document.getElementById("message-section");
// Clear float partition after the user message -- blank line
let clearDivUser = document.createElement("div");
clearDivUser.style.clear = "both";
mainDiv.appendChild(clearDivUser);
// Create bot message container div -- for make White group
let botMessageContainer = document.createElement("div");
botMessageContainer.classList.add("bot-message-container");
// Create bot image div and append it to the bot message container -- White group1(img)
let botimgDiv = document.createElement("div");
botimgDiv.id = "bot-img";
botimgDiv.innerHTML = `<img src="https://***/persona3.png" alt="BOTimg" width="50px" height="50px">`;
botMessageContainer.appendChild(botimgDiv);
// Create bot text div and append it to the bot message container -- White group2(message)
let botDiv = document.createElement("div");
botDiv.id = "bot";
botDiv.classList.add("message");
botDiv.innerHTML = `<span id="bot-response">${botreply}</span>`;
botMessageContainer.appendChild(botDiv);
// Add to group as 3rd item -- White group3(timestamp)
let timebotDiv = document.createElement("div");
timebotDiv.id = "bot_time";
timebotDiv.classList.add("message");
timebotDiv.innerHTML = `<span id="bot-time">${ajax_time}</span>`;
botMessageContainer.appendChild(timebotDiv);
// Append the bot message container after the user message -- White group fix
mainDiv.appendChild(botMessageContainer);
var scroll = document.getElementById("mchat-container");
// Scroll to the bottom of the message section after a short delay
setTimeout(function() {
mainDiv.scrollTop = mainDiv.scrollHeight;
}, 100);
}
</script>
<!-- 今回はJQueryを用いてAjax通信を実現するため、GoogleのCDN経由でJQueryを読み込む -->
<a href="https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js">https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js</a>
<script>
/* global $*/ //added it as a 対策 to $ is not defined
$(function(){
$('form').on('submit', function(e){
e.preventDefault(); // stop the default form submit action.
$('#ajax').click(); // click AJAX button.
});
// 「Ajax通信」ボタンをクリックしたら発動
$('#ajax').on('click',function(){
// Show the loader
$('#loader').show();
var currentDate = new Date().toISOString().substring(0, 10);//only the first part of the date is required.
var currentDateTime = new Date().toLocaleTimeString("en-GB");//en-gb for 24h format. if not () is AM/PM
var now_client_time = currentDate+' '+currentDateTime;
// Get the question value
var questionValue = $('#question').val();
// Clear input
$('#question').val('');
//need to change it so that subsequent chats are updated later on too.
addquestion(questionValue,now_client_time);
setTimeout(function() {
mainDiv.scrollTop = mainDiv.scrollHeight;
}, 100);
$.ajax({
url:'https://***.on.aws/',//main_server_url
type:'POST',
data:{
'question': questionValue
},
sent:1,
crossDomain:true,
xhrFields: { withCredentials:true },//added for Cookie.varun3/4/24
})
// Ajax通信が成功したら発動
.done( (data) => {
// Hide the loader
$('#loader').hide();
console.log("loader done");
addChat(data.answer,data.exec_time);
})
// Ajax通信が失敗したら発動
.fail( (jqXHR, textStatus, errorThrown) => {
alert('Ajax通信に失敗しました。');
console.log("jqXHR : " + jqXHR.status); // HTTPステータスを表示
console.log("textStatus : " + textStatus); // タイムアウト、パースエラーなどのエラー情報を表示
console.log("errorThrown : " + errorThrown.message); // 例外情報を表示
// Hide the loader
$('#loader').hide();
})
// Ajax通信が成功・失敗のどちらでも発動
.always( (data) => {
console.log($('#question').val());
console.log($('answer').val());
});
});
$("#cookieDeletebtn").on('click',function(){
$.ajax({
url:'https://***.on.aws/',//main_server_url
type: 'POST',
data:{
'clear':'1',//for deleting_cookie
},
xhrFields: {
withCredentials: true // This ensures that cookies are sent along with the request
},
crossDomain: true,
success: function(data) {
addChat(data.answer,data.exec_time);
console.log('Success:', data);
},
error: function(xhr, status, error) {
console.log('Error:', error);
}
});
});
});
</script>Lambda関数の「コード」にセットする時は、.pyファイルとしてコードセルpython1を、.htmlファイルとしてコードセルHTML1+HTML2を、それぞれコピーして下さい。HTMLの方は、実際には、まずHTML1をコピーし、その中の「・・・JS部参照・・・」をHTML2に差し替えて下さいませ。
クライアント画面:質問&回答表示部のアイコンをセット
画面表示で使う画像をS3にアップしておきます。
・AWSコンソールにログイン後、検索窓に”S3″を入力し「S3」を押します。
・Amazon S3の画面に遷移し、メニュー「バケット」で「バケットを作成」を押します。

・画面「バケットを作成」では以下を登録します。
| 設定値 | 値 |
|---|---|
| バケット名 | proto2-init-disp |
| オブジェクト所有者 | ACL 無効 (推奨) |
| ブロックパブリックアクセス設定 | パブリックアクセスをすべて ブロック |
| バケットのバージョニング | 無効 |
| 暗号化タイプ | Amazon S3マネージドキーを・・・暗号化 (SSE-S3) |
| バケットキー | 有効 |
| アップロードするファイル | persona3.jpg (※ボット顔アイコン) |

サーバ応答部
サーバ応答部:Lambda関数を用意
クライアントから依頼される処理は、同様にLambdaで実行させます。
・ AWS Lambdaメニューに遷移し、Lambdaトップで「関数の作成」押下します。
・ 画面「関数の作成」にて、以下の設定値で、作成します。
設定値 値 関数名 proto2_server ランタイム python3.9 アーキテクチャ x86_64(デフォルト) 実行ロール atd_role_for_lambda
※お手元の環境で任意に設定関数 URL を有効化 チェック 認証タイプ NONE VPCを有効化 チェック
・ ajaxを使う関係でCORS対策が必要です。以下設定をしておきます。
– 関数proto2_serverの「設定」、「関数URL」、「編集」を順に押します。
- その画面で以下を設定し、保存を押します。
設定値 値 オリジン間リソース共有 (CORS) を設定 チェック 許可オリジン ※クライアント(proto2_init_disp)のURL 許可メソッド POST
・ LLMの処理等、時間がかかるのでタイムアウト値を変更しておきます。
– 関数proto2_serverの「設定」、「一般設定」、「編集」を順に押します。
- その画面のタイムアウト欄を1分に変更し、保存を押します。
・ OpenAIとPineconeをimport出来るように、レイヤーを追加しておきます。
– 関数proto2_server画面内の「レイヤーの追加」を押します。
- その画面で以下を入力し、追加を完了させます。(※レイヤー自体は作成済の前提)
設定値 値 レイヤーソース カスタムレイヤー カスタムレイヤー pinecone 設定値 値 レイヤーソース カスタムレイヤー カスタムレイヤー openai
※RAGツールの一部として、この画面を作成しています。
サーバ応答部:基本の応答処理
クライアントからXMLHttpRequestに応答します。以下、基本部の処理概要です。
・ 利用者を限定するためのアクセス元IPによる制御
・ body部のデコードとパラメータ受理
・ セッション処理
・ 回答生成依頼
・ 回答を利用し、レスポンスデータを作成、返却
・ 以下、対象のコードです。
##サーバー側:lambda_handler部分
IP_RANGE =['']#必要であれば設定してください。
def check_ip(ip_address, IP_RANGE):#アクセス元ipチェック関数
print('using_IP:',ip_address)
if ip_address in IP_RANGE:
print('Match!!')
return True
else:
print('Miss!!')
return False
def decode_body(event_body):
body_query = base64.b64decode(event_body).decode()
body_dict = urllib.parse.parse_qs(body_query)
for key in body_dict: # valueが配列に入っているので出す
body_dict[key] = body_dict[key][0]
return body_dict
def lambda_handler(event, context):
#IPアドレスチェック
ip_address = ''
if event.get('requestContext'):
http_info = event.get('requestContext').get('http')
ip_address = http_info['sourceIp']
valid_ip = check_ip(ip_address, IP_RANGE)
if not valid_ip:
view_str = '500 Unauthorized '+ip_address
return {'statusCode': 500,'body': json.dumps(view_str)}
#body部のデコード
if event.get('body'):
request_body = decode_body(event['body'])
#パラメータの受理(question,clear)
question,clear = '','0'
if event.get('queryStringParameters'):
question,clear = event.get('queryStringParameters').get('question'),event.get('queryStringParameters').get('clear')
elif event.get('body'):
question,clear = request_body.get('question'),request_body.get('clear')
#session_id処理
COOKIE_KEY = 'cookie-key_proto2'
session_id,continuation = '',False
if not 'cookies' in event:
session_id = str(uuid.uuid4())
else:
C = cookies.SimpleCookie()
C.load('; '.join([cookie for cookie in event['cookies']]))
cookie_dict = {k: v.value for k, v in C.items()}
if not COOKIE_KEY in cookie_dict:
session_id = str(uuid.uuid4())
else:
session_id = cookie_dict[COOKIE_KEY]
if is_conversation_log(session_id):
continuation=True
else:
continuation=False
if clear == '1':
session_id,continuation = str(uuid.uuid4()),False
#cookie用response作成
set_cookie = '{cookie_key}={session_id}'.format(cookie_key=COOKIE_KEY,session_id=session_id)
#回答生成要求部
if clear == '1':
answer,system_prompt,now_time,question = '新しい質問をどうぞ。','','','' #生成しない。
else:
answer,now_time = make_answer_vol4(question,session_id,continuation)
#引数3は継続mode(True:継続/False:初回)
#レスポンスデータ作成
message = {
"question":question,
"exec_time":now_time, #record_conversationsで作成したタイムスタンプ。
"answer":answer #"試験中です。"
}
response = {
'statusCode': 200,
'isBase64Encoded': False,
'headers': {
'Access-Control-Allow-Credentials' : True, #for cookie.varun edit3/4/24
'Content-Type': 'application/json',
'Set-Cookie': set_cookie,
},
'body': json.dumps(message, ensure_ascii=False),
}
return responseサーバ応答部:質問文処理
RAGやその他質問文評価処理を入れたい場合は、ここで入れる事ができます。ここでは単純にGPTに生成依頼をするコードにしておきます。(継続会話処理はあり)
・ 以下、そのコードです。
##サーバー側:質問文処理コード
import json
import base64
import urllib.parse
import textwrap
from openai import OpenAI
import boto3
from pinecone import Pinecone
from http import cookies
import uuid
from datetime import datetime, timedelta
import collections
OPENAI_API_KEY = "sk-******"
dynamodb_client = boto3.client('dynamodb')
DN_TABLE_NAME = '******'
DN_P_KEY = 'session_id'
def record_conversations_vol4(session_id,question,answer,system_prompt): #会話履歴保存関数
now_time = str((datetime.utcnow() + timedelta(hours=9)).strftime("%Y-%m-%d %H:%M:%S"))
dynamodb_client.put_item(TableName=DN_TABLE_NAME,Item={
DN_P_KEY: {"S": session_id},
"question": {"S": question},
"answer": {"S": answer},
"system_prompt": {"S": system_prompt},
"now_time": {"S": now_time},
})
dynamo_response = dynamodb_client.query(TableName=DN_TABLE_NAME,KeyConditionExpression= DN_P_KEY + '= :val',ExpressionAttributeValues={":val": {"S": session_id}},)
print(dynamo_response)
return now_time
def continuous_genarate_vol4(question,session_id): #継続的な生成をする関数。question:質問(str),session_id:セッションID
llm_context = ''
openai_client = OpenAI(api_key = OPENAI_API_KEY)
dynamo_response = dynamodb_client.query(TableName=DN_TABLE_NAME,KeyConditionExpression= DN_P_KEY + '= :val',ExpressionAttributeValues={":val": {"S": session_id}},)
for idx,item in enumerate(dynamo_response['Items']):
llm_context = llm_context +'[時刻]'+item['now_time']['S']+'----------\n'
llm_context = llm_context +'[利用者]\n'
if idx == 0 : #system_promptの前処理:1回目は会話履歴が無いので、そのまま利用。
system_prompt_val = item['system_prompt']['S']
else : #system_promptの前処理:前回以前の会話履歴は冗長かつ促進文も今回与える意味は無さそう。以下固定文を残す、とした。
system_prompt_val = 'あなたは質問に回答するチャットbotです。'
llm_context = llm_context +' <プロンプト>\n'+system_prompt_val+'\n'
llm_context = llm_context +' <質問>\n'+item['question']['S']+'\n'
llm_context = llm_context +'[生成AI]\n'+item['answer']['S']+'\n'
system_prompt = textwrap.dedent("""\
あなたは質問に回答するチャットbotです。
以下の会話履歴は、あなた(生成AI)と利用者の最近の会話内容です。
これを参考にして質問に回答して下さい。
会話内容の中に存在するコンテクストに質問に対する答えが無い場合は、一般論として、ヒントとなる情報を回答して下さい。
その際は、憶測の情報である事を述べる言葉を必ず使って下さい。
## 会話履歴(開始) ############################################################
{}
## 会話履歴(終了) ############################################################
""").format(llm_context)
response_gpt = openai_client.chat.completions.create(
model="gpt-4o",
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": question}
],
#temperature=1
)
answer = response_gpt.choices[0].message.content
return answer,system_prompt
def make_answer_vol4(question,session_id,continuation): #回答作成関数(最上位関数) question:質問文,session_id:セッションID,continuation:True継続/False初回
openai_client = OpenAI(api_key = OPENAI_API_KEY)
system_prompt="あなたは質問に回答するチャットbotです。"
if not continuation :
response_gpt = openai_client.chat.completions.create(
model="gpt-4o",
messages=[
{"role": "system", "content": system_prompt},#初回
{"role": "user", "content": question}
],
)
answer = response_gpt.choices[0].message.content
else:
answer,system_prompt = continuous_genarate_vol4(question,session_id)
now_time = record_conversations_vol4(session_id,question,answer,system_prompt)
return answer,now_timeLambda関数の「コード」にセットする時は、.pyファイルとして、コードセルpython3をコピーし、その下にpython2をコピーすればOKです。
コードセルpython3内の「OPENAI_API_KEY」はOpenAIから取得したAPIキーをセットします。OpenAIキーについては、こちらを参照してください(株式会社ユリーカ様の記事を参照させて頂きました)。また、同様に、「DN_TABLE_NAME」はDynamoDBで作成した名前をセットして下さい。
会話履歴のDB(dynamodb)
AWSのDynamoDBはsession_id(パーティションキー)、now_time(ソートキー)、question、answer、system_prompt、を属性とし、テーブルを作成しておきます。
作成方法は、Serverworks日高様の情報を参考にさせて頂きました。
もし、dynamodbが面倒で、会話継続時の履歴参照を割愛したい場合は、コードセルpython3の関数make_answer_vol4内のelseを実行しないように、上手くカットすれば良いと思います。
まとめ
1.2つのLambda関数を使い、AJAXのサーバー側とクライアント側を作成する事が出来ました。
2.AWSのLambda関数でのXMLHttpRequestの処理と付随する事(CORS、セッションcookie等)を、理解する事ができました。
3.AJAX失敗エラーの対応で苦労しました。原因はデコード部の書き忘れとCORS未設定でした。パラメータの前処理を良く理解できていなかった。皆様もそれらを見落とさないように注意してください。
4.フロント画面の作成方法と必要な設定は勉強になって嬉しいです。次回の記事でこのフロント画面を使い、本番データでRAGシステムを構築します。
