RAG型チャットボット画面に評価機能を追加。

背景や目的


以前の記事(→AWS Lambdaでチャットボット画面を作成)で紹介したチャット画面の改善版に取り組みました。 

今回は、AIの回答に対してGOOD/BADボタンでユーザー評価を記録する機能を追加し、RAGで生成された回答に対するフィードバックを取得できるようにしました。
良質な応答とそうでない応答を見極め、改善に活かすことが目的です。

本記事では、その実装手順を紹介したいと思います。

実現したい事


AIチャットボットで、

以下のように、フィードバックボタンを設置。

システム構成は以下の通りです。

上の赤枠部を中心に紹介します。

構築手順サマリー


手順のサマリーとしては以下の通りです。

1.【準備用】VPCの構築
2.【準備用】Google認証の準備
3.ユーザー評価機能付きクライアント側Lambdaの作成
4.DynamoDBテーブル作成
5.サーバー側Lambda関数の作成

順番に紹介してゆきます。

構築1)VPCの構築


※個人的な実験でやる場合はここは無しでOKです。構築2へお進み下さい。

セキュリティー対応としてVPCの設定をします。メインVPCとサブネットを定義し、それらにLambda関数が属するようなネットワーク設定をします。

具体的な手順は以前の記事(→RAG型社内チャットボットのVPC構築例)にありますので、必要な場合はそちらを参照してください。

構築2)Google認証の準備


※ユーザー制限が目的ですが、個人的な実験ならIP制限のみや認証なしでもOKです。構築3へお進み下さい。

認証としてGoogle認証(OAuth)を使います。
OAuthクライアントIDとシークレットキーは、Google Cloudの「認証情報」画面からWebアプリ用のOAuthクライアントを作成して取得します。

具体的な手順は以前の記事(→AWS LambdaでGoogle認証)にあります。必要な場合はそちらを参照してください。

構築3)ユーザー評価機能付きクライアント側Lambdaの作成


チャット画面内回答生成後の表示に、ユーザーが評価できるGOOD/BADボタンを追加します。

・まず、Lambda関数を以下の手順で作成します。
  - AWSコンソールでLambdaメニューに遷移し、トップ画面で「関数の作成」を押します。
   (※AWSアカウントの作成など基本設定はここでは割愛します)
  -  画面「関数の作成」では、以下の設定値で、作成します。

設定項目
関数名chat_client ※お好きな名称で
ランタイムpython3.11
アーキテクチャx86_64(デフォルト)
実行ロールlambda_role_for_user ※お手元の環境で任意に設定
関数 URL を有効化チェック
認証タイプNONE

・続いて、Lambda関数の中身となるlambda_function.pyのコードを記述します。
チャット画面のコードで評価入力処理はHTML側で実装し、以下の関数ではCookieによるログイン状態に応じてチャット画面の表示を切り替えるだけの構成です。※個人的な実験ならcookie処理は省略してOKです。

lambda_function.py
import base64
import uuid
import urllib.parse
from http import cookies

COOKIE_KEY = 'cookie-key_ctcrag1' #※お好きな名称で

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:  # Convert array values to strings
        body_dict[key] = body_dict[key][0]
    return body_dict

def lambda_handler(event, context):
    # Initialize cookies_data
    cookies_data = {}
    # Check for cookies in event['cookies'] (list of cookie strings)
    if 'cookies' in event:
        print(f"Raw cookies from event['cookies']: {event['cookies']}")
        # Combine all cookies into a single string (if multiple)
        combined_cookies = "; ".join(event['cookies'])
        C = cookies.SimpleCookie()
        C.load(combined_cookies)
        cookies_data = {k: v.value for k, v in C.items()}
    # Fallback: Check for cookies in event['headers']['cookie'] (single cookie string)
    elif 'headers' in event and 'cookie' in event['headers']:
        cookie_header = event['headers']['cookie']
        #print(f"Raw Cookie header from headers: {cookie_header}")
        C = cookies.SimpleCookie()
        C.load(cookie_header)
        cookies_data = {k: v.value for k, v in C.items()}
    #print(f"Parsed cookies_data: {cookies_data}")
    # Retrieve or generate google_callback_id
    google_callback_id = cookies_data.get(COOKIE_KEY, None)
    if google_callback_id:
        print(f"Existing {COOKIE_KEY} found: {google_callback_id}")
    else:
        google_callback_id = str(uuid.uuid4())  # Generate a new one if not present
        #print(f"Generated new {COOKIE_KEY}: {google_callback_id}")
    # Retrieve `auth_success` from cookies
    auth_success = cookies_data.get('auth_success', '0')  # Default to '0' if not found
    #print(f"Extracted auth_success: {auth_success}")
    # Retrieve `clear` from cookies
    clear = cookies_data.get('clear', '0') 
    # Debugging: Log all cookies
    #print(f"All received cookies: {cookies_data}")
    # Prepare Set-Cookie headers
    set_cookie_header_1 = (
        f"{COOKIE_KEY}={google_callback_id}; Secure; Max-Age=3600; Domain=.lambda-url.ap-northeast-1.on.aws; SameSite=None; Path=/"
    )
    set_cookie_header_2 = (
        f"auth_success={auth_success}; Secure; Max-Age=3600; Domain=.lambda-url.ap-northeast-1.on.aws; SameSite=None; Path=/"
    )
    set_cookie_header_3 = (
        f"clear={clear}; Secure; Domain=.lambda-url.ap-northeast-1.on.aws; SameSite=None; Path=/"
    )
    # Load HTML 
    try:
        with open('./ctcrag_init_disp.html', 'r', encoding='utf-8') as f:
            html_body = f.read()
    except FileNotFoundError:
        print("HTML template not found.")

    # Modify HTML based on authentication state
    if auth_success == '1':
        # Hide login section, show message section
        #print("replacing the function")
        html_body = html_body.replace('<div id="login-section">', '<div id="login-section" style="display:none;">')
        html_body = html_body.replace('id="message-section" style="display:none;"', 'id="message-section" ')
    else:
        # Show login section, hide message section
        html_body = html_body.replace('<div id="login-section">', '<div id="login-section" style="display:block;">')
        html_body = html_body.replace('id="message-section" style="display:block;"', 'id="message-section" style="display:none;"')

    # Prepare response headers
    headers = {
        'Content-Type': 'text/html',
        'charset': "UTF-8",
        'Access-Control-Allow-Origin': '*',
        'Access-Control-Allow-Credentials': 'true',
        'Set-Cookie': f"{set_cookie_header_1}, {set_cookie_header_2},{set_cookie_header_3} "  # Combine cookies into a single header
    }

    # Return the response
    return {
        'statusCode': 200,
        'headers': headers,
        'body': html_body
    }

・上のコードが参照するログイン画面(ctcrag_init_disp.html)のHTMLコードは以下の通りです。

ctcrag_init_disp.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <title>チャット画面</title>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <style>
        /* General Body Style */
        body {
            margin: 0;
            padding: 0;
            width: 100%;
            height: 100%;
            display: flex;
            justify-content: center;
            align-items: center;
            background-color: #FFFFFF; 
            font-family: Arial, sans-serif;
        }

        #login-section {
            display: flex; 
            background-color: rgb(255, 180, 13);
            border-radius: 10px;
            box-shadow: 0 6px 12px rgba(0, 0, 0, 0.3);
            margin-top: 5px;
            padding: 20px 20px;
            width: 600px;
            height: 350px;
        }

        #google-login-btn {
            display: flex;
            margin-left: 145px;
            margin-top: 95px;
            background-color: rgb(0, 169, 228);
            color: white;
            border: none;
            padding: 50px 50px;
            border-radius: 5px;
            cursor: pointer;
            font-size: 18px;
            font-weight: bold;
        }

        /* Chat Container */
        #message-section {
            position: relative;
            display: flex;
            flex-direction: column;
            margin-top: 5px;
            width: 600px; /* Fixed width */
            height: 400px; /* Fixed height */
            background-color: rgb(255, 180, 13);
            border-radius: 10px;
            box-shadow: 0 6px 12px rgba(0, 0, 0, 0.3);
            overflow: hidden; /* Prevents scrollbars from affecting alignment */
        }

        /* Chat Body */
        .chat-body {
            flex-grow: 1; /* Occupies available space */
            padding: 15px;
            overflow-y: auto; /* Adds a vertical scrollbar */
            display: flex;
            flex-direction: column;
            gap: 10px; /* Spacing between messages */
        }

        .chat-body::-webkit-scrollbar {
            width: 6px; /* Scrollbar width for Webkit browsers */
        }

        .chat-body::-webkit-scrollbar-thumb {
            background-color: #ccc; /* Scrollbar thumb color */
            border-radius: 10px;
        }

        /* Message Bubbles */
        .user-message-container {
            display: flex;
            justify-content: flex-end; /* Align user messages to the right */
        }

        .bot-message-container {
            display: flex;
            justify-content: flex-start; /* Align bot messages to the left */
        }

        .message {
            /*max-width: 80%;*/
            width: 100%;
            padding: 10px;
            border-radius: 15px;
            word-wrap: break-word;
            box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
        }

        .user-message-container .message {
            background-color: rgb(243, 243, 243);
            align-self: flex-end;
        }

        .bot-message-container .message {
            background-color: white; 
            align-self: flex-start;
        }

        .reviewDiv {
            display: flex;
            justify-content: center; 
            align-self: center;
        }

        /* Timestamp */
        .message-time {
            font-size: 12px;
            color: gray;
            margin-top: 5px;
            text-align: right;
        }

        .bot-message-container .message-time {
            text-align: left;
        }

        /* Chat Input Section */
        .chat-input {
            display: flex;
            align-items: center;
            padding: 10px 15px;
            background-color: #ccc;
            border-top: 1px solid #ccc;
            gap: 10px;
        }

        .chat-input input {
            flex: 1;
            padding: 10px;
            font-size: 10px;
            border: 1px solid #ccc;
            border-radius: 5px;
        }

        .send-button {
            background-color: rgb(40, 54, 103);
            color: white;
            border: none;
            padding: 8px 15px;
            font-size: 14px;
            border-radius: 5px;
            cursor: pointer;
        }

        .send-button:hover {
            background-color: #002855;
        }
    
        /* Chat Header */
        .chat-header {
            display: flex;
            justify-content: space-between;
            align-items: center;
            background-color: rgb(40, 54, 103);
            color: white;
            padding: 10px 15px;
            font-weight: bold;
            font-size: 20px;
            border-radius: 10px 10px 0 0;
        }

        .spacer {
            height: 10px;
            width: 1%;
        }
        
        #bot-img {
            margin-right: 1%;  
        }
        
        #logout-btn {
            background-color: rgb(0, 169, 228);
            color: white;
            border: none;
            padding: 5px 10px;
            border-radius: 5px;
            cursor: pointer;
            font-size: 14px;
        }
    
        #logout-btn:hover {
            background-color: #0056b3;
        }

        #loader {
            display: flex;
            flex-direction: column;
            align-items: center;
            justify-content: center;
            position: fixed;
            z-index: 1000;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            background-color: rgba(255, 215, 0, 0.5); /* Semi-transparent yellow */
        }

        .spinner {
            width: 50px;
            height: 50px;
            border: 5px solid rgb(40, 54, 103); 
            border-top: 5px solid #ffffff; /* White border for contrast */
            border-radius: 50%;
            animation: spin 1s linear infinite;
        }

        @keyframes spin {
            0% {
                transform: rotate(0deg);
            }
            100% {
                transform: rotate(360deg);
            }
        }

        #newChatButton {
            position: absolute;
            right: 5px;
            bottom: 80px; 
            width: 80px;
            height: 80px;
            margin-right: 5px;
            background-color: rgba(255, 180, 13, 0.9);
            border: none;
            border-radius: 50%;
            box-shadow: 2px 4px 6px rgba(0, 0, 0, 0.3);
            cursor: pointer;
            font-size: 14px;
            font-weight: bold;
            display: none; /* Initially hidden */
            justify-content: center;
            align-items: center;
        }
        
        #newChatButton:hover {
            background-color: rgba(255, 180, 13, 0.5);
        }
    </style>        
    
</head>

<body>

    <!-- Login Section -->
    <div id="login-section">
        <button id="google-login-btn">
            Google でログイン
        </button>
    </div>

    <!-- Chat Container -->
    <div id="message-section" style="display:none;">
        <div class="chat-header">
            <span>インフォマン</span>
            <button id="logout-btn">ログアウト</button>
        </div>
        <!-- Chat Body -->
        <div class="chat-body" id="chat-body"></div>

        <!-- New Chat Button (Initially Hidden) -->
        <button id="newChatButton" style="display: none;">新規質問</button>

        <!-- Chat Input -->
        <div class="chat-input">
            <input type="text" id="question" placeholder="質問を入力してください" />
            <button class="send-button" id="send">送信</button>
        </div>
    </div>
    <div id="loader" style="display: none;">
        <div class="spinner"></div>
        <div class="sync-text"><h4>Now Thinking</h4></div>
    </div>
    <script type="text/javascript">

        // Google Login button handler
        document.getElementById('google-login-btn').addEventListener('click', function () {
          const googleAuthUrl = "https://accounts.google.com/o/oauth2/v2/auth"
              + "?client_id=*****.apps.googleusercontent.com"
              + "&redirect_uri=https://*****.lambda-url.ap-northeast-1.on.aws/"
              + "&response_type=code"
              + "&scope=openid%20email%20profile"
              + "&state=random_state_string"
              + "&prompt=select_account";
          window.location.href = googleAuthUrl;
        });
      
        // Logout button handler
        document.getElementById('logout-btn').addEventListener('click', function () {
          createCustomConfirm(
              "全てを終了し、ログアウトします。\n よろしいでしょうか?",
              function () {
                createFeedbackModal(
                        function () {
                            sendEvaluation(1, 1);
                            document.cookie = "auth_success=; Secure; Domain=.lambda-url.ap-northeast-1.on.aws; Path=/; SameSite=None; expires=Thu, 01 Jan 1970 00:00:00 UTC";
                            setTimeout(resetSession, 500);
                        },
                        function () {
                            sendEvaluation(1, 2);
                            document.cookie = "auth_success=; Secure; Domain=.lambda-url.ap-northeast-1.on.aws; Path=/; SameSite=None; expires=Thu, 01 Jan 1970 00:00:00 UTC";
                            setTimeout(resetSession, 500);
                        }
                    );
              },
              function () {
                  // "いいえ": do nothing.
              }
          );
        });

        function resetSession() {
              document.cookie = "cookie-key_ctcrag1=; Secure; Domain=.lambda-url.ap-northeast-1.on.aws; Path=/; SameSite=None; expires=Thu, 01 Jan 1970 00:00:00 UTC";
              location.reload();
              newChatButton.style.display = "none";
          }
      
        // On page load: display the chat UI and add a greeting from the bot.
        window.onload = function () {
          document.getElementById('message-section');
      
          const now = new Date();
          const formattedTime = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} `
              + `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`;
      
          addChat("ご質問をどうぞ。", formattedTime, true);
        };

        // ◆◆◆ユーザ評価メイン部◆◆◆ (used in chat bubble and modal)
        function createFeedbackContent(onLike, onDislike, customTitle) {
        // Outer container
        let feedbackDiv = document.createElement("div");
        feedbackDiv.style.display = "flex";
        feedbackDiv.style.flexDirection = "column";
        feedbackDiv.style.gap = "1px";
        feedbackDiv.style.marginTop = "10px";
        feedbackDiv.style.alignItems = "center";
        feedbackDiv.style.backgroundColor = "#fff9c4";  // Light yellow highlight
        feedbackDiv.style.paddingBottom = "5px";
        feedbackDiv.style.paddingTop = "5px"; 

        // Feedback Title (Top)
        let feedbackTitle = document.createElement("span");
        // Use the custom title if provided; otherwise fall back to the default.
        feedbackTitle.textContent = customTitle || "↓↓質問が終了した時にご評価お願いします。↓↓";
        feedbackTitle.style.textAlign = "center";
        feedbackTitle.style.padding = "0px 5px";
        feedbackTitle.style.borderRadius = "5px";
        feedbackTitle.style.fontSize = "10px";
        feedbackTitle.style.marginBottom = "0px";
        feedbackTitle.style.width = "100%";

        // Label: "解決しましたか?"
        let feedbackLabel = document.createElement("span");
        feedbackLabel.textContent = "解決しましたか?";
        feedbackLabel.style.padding = "0px 5px";
        feedbackLabel.style.borderRadius = "5px";
        feedbackLabel.style.fontSize = "10px";
        feedbackLabel.style.marginTop= "0px";
        feedbackLabel.style.marginBottom = "0px";

        // Row for the buttons
        let feedbackRow = document.createElement("div");
        feedbackRow.style.display = "flex";
        feedbackRow.style.alignItems = "center";
        feedbackRow.style.justifyContent = "center";
        feedbackRow.style.gap = "5px";

        // Good (👍) Button
        let likeButton = document.createElement("button");
        likeButton.innerHTML = "👍";
        likeButton.style.background = "#28a745";
        likeButton.style.color = "white";
        likeButton.style.border = "none";
        likeButton.style.borderRadius = "10px";
        likeButton.style.padding = "5px 10px";
        likeButton.style.fontSize = "12px";
        likeButton.style.cursor = "pointer";
        likeButton.onclick = function () {
            if (typeof onLike === 'function') onLike();
        };

        // Bad (👎) Button
        let dislikeButton = document.createElement("button");
        dislikeButton.innerHTML = "👎";
        dislikeButton.style.background = "#dc3545";
        dislikeButton.style.color = "white";
        dislikeButton.style.border = "none";
        dislikeButton.style.borderRadius = "10px";
        dislikeButton.style.padding = "5px 10px";
        dislikeButton.style.fontSize = "12px";
        dislikeButton.style.cursor = "pointer";
        dislikeButton.onclick = function () {
            if (typeof onDislike === 'function') onDislike();
        };

        // Assemble feedback elements
        feedbackRow.appendChild(likeButton);
        feedbackRow.appendChild(dislikeButton);

        feedbackDiv.appendChild(feedbackTitle);
        feedbackDiv.appendChild(feedbackLabel);
        feedbackDiv.appendChild(feedbackRow);

        return feedbackDiv;
        }
      
        // General-purpose custom confirmation modal.
        // Accepts an optional customButtons array to replace the default "はい" and "いいえ" buttons.
        function createCustomConfirm(message, yesCallback, noCallback, customButtons = null) {
          const messageSection = document.getElementById('message-section');
      
          // Create overlay relative to messageSection
          const overlay = document.createElement('div');
          overlay.style.position = 'absolute';
          overlay.style.top = '0';
          overlay.style.left = '0';
          overlay.style.width = '100%';
          overlay.style.height = '100%';
          overlay.style.backgroundColor = 'rgba(0, 0, 0, 0.5)';
          overlay.style.display = 'flex';
          overlay.style.justifyContent = 'center';
          overlay.style.alignItems = 'center';
          overlay.style.zIndex = '9999';
          overlay.style.borderRadius = '10px';
      
          // Dialog box styling
          const dialogBox = document.createElement('div');
          dialogBox.style.backgroundColor = '#fff';
          dialogBox.style.padding = '20px';
          dialogBox.style.borderRadius = '10px';
          dialogBox.style.boxShadow = '0 4px 8px rgba(0, 0, 0, 0.3)';
          dialogBox.style.textAlign = 'center';
          dialogBox.style.minWidth = '250px';
          dialogBox.style.maxWidth = '90%';
      
          // Confirmation message element
          const messageElem = document.createElement('p');
          messageElem.textContent = message;
          //messageElem.style.fontWeight = 'bold';
          messageElem.style.marginBottom = '20px';
          messageElem.style.fontSize = '14px';
          messageElem.style.whiteSpace = 'pre-line';
      
          // Button container
          const buttonContainer = document.createElement('div');
          buttonContainer.style.display = 'flex';
          buttonContainer.style.justifyContent = 'center';
          buttonContainer.style.gap = '20px';
      
          if (customButtons) {
              // Use custom buttons if provided
              customButtons.forEach(button => buttonContainer.appendChild(button));
          } else {
              // Default buttons: "はい" and "いいえ"
              const yesButton = document.createElement('button');
              yesButton.textContent = 'はい';
              yesButton.style.backgroundColor = '#28a745';
              yesButton.style.color = 'white';
              yesButton.style.padding = '8px 16px';
              yesButton.style.border = 'none';
              yesButton.style.borderRadius = '5px';
              yesButton.style.cursor = 'pointer';
              yesButton.onclick = function () {
                  messageSection.removeChild(overlay);
                  if (typeof yesCallback === 'function') yesCallback();
              };
      
              const noButton = document.createElement('button');
              noButton.textContent = 'いいえ';
              noButton.style.backgroundColor = '#dc3545';
              noButton.style.color = 'white';
              noButton.style.padding = '8px 16px';
              noButton.style.border = 'none';
              noButton.style.borderRadius = '5px';
              noButton.style.cursor = 'pointer';
              noButton.onclick = function () {
                  messageSection.removeChild(overlay);
                  if (typeof noCallback === 'function') noCallback();
              };
      
              buttonContainer.appendChild(yesButton);
              buttonContainer.appendChild(noButton);
          }
      
          // Assemble modal elements
          dialogBox.appendChild(messageElem);
          dialogBox.appendChild(buttonContainer);
          overlay.appendChild(dialogBox);
      
          // Attach overlay to chat container
          messageSection.appendChild(overlay);
        }
      
        // Modularized feedback modal that uses custom like/dislike buttons.
        // Modified createFeedbackModal to pass a custom title
        function createFeedbackModal(likeCallback, dislikeCallback) {
            // Pass your custom title "最後の質問の評価をお願い致します。"
            let feedbackDiv = createFeedbackContent(likeCallback, dislikeCallback, "↓↓終了する質問の評価をお願い致します。↓↓");

            createCustomConfirm(
                "",//★ ご評価お願い致します。★
                null,
                null,
                [feedbackDiv]
            );
        }

      
        // Chat bubble for bot messages
        function addChat(botreply, ajax_time, isGreeting = false) {
            const mainDiv = document.getElementById("chat-body");
            let botMessageContainer = document.createElement("div");
            botMessageContainer.classList.add("bot-message-container");

            let botimgDiv = document.createElement("div");
            botimgDiv.id = "bot-img";
            botimgDiv.innerHTML = '<img src="https://ctcrag-init-disp.s3.ap-northeast-1.amazonaws.com/bot_icon_redesign.png" alt="BOTimg" width="35px" height="35px">';

            let messageDiv = document.createElement("div");
            messageDiv.classList.add("message");
            messageDiv.innerHTML = botreply.replace(/\n/g, "<br>"); //for URL
            messageDiv.style.fontSize = "10px";
            messageDiv.style.paddingBottom = "15px";

            let spacerDiv = document.createElement("div");
            spacerDiv.classList.add("spacer");

            let timeDiv = document.createElement("div");
            timeDiv.classList.add("message-time");
            timeDiv.textContent = ajax_time;

            botMessageContainer.appendChild(botimgDiv);
            botMessageContainer.appendChild(messageDiv);
            botMessageContainer.appendChild(spacerDiv);
            botMessageContainer.appendChild(timeDiv);

            // Only add extra UI for non-greeting messages
            if (!isGreeting) {
                // #v2で追加: 新しい質問の案内とボタン(文中・装飾付き)
                let v2NoticeDiv = document.createElement("div");
                v2NoticeDiv.style.display = "flex";
                v2NoticeDiv.style.flexDirection = "column";
                v2NoticeDiv.style.alignItems = "center";
                v2NoticeDiv.style.justifyContent = "center";
                v2NoticeDiv.style.gap = "8px";
                v2NoticeDiv.style.marginTop = "10px";
                v2NoticeDiv.style.backgroundColor = "#fff9c4";
                v2NoticeDiv.style.paddingTop = "5px"; 

                let line1Wrapper = document.createElement("div");
                line1Wrapper.style.fontSize = "10px";
                line1Wrapper.style.textAlign = "center";
                line1Wrapper.style.display = "flex";
                line1Wrapper.style.flexWrap = "wrap";
                line1Wrapper.style.justifyContent = "center";
                line1Wrapper.style.alignItems = "center";
                line1Wrapper.style.gap = "6px";
                //line1Wrapper.style.fontWeight = "bold";

                let textBeforeBtn = document.createElement("span");
                textBeforeBtn.textContent = "新しい質問をする場合は、";

                let inlineBtn = document.createElement("button");
                inlineBtn.textContent = "新規質問";
                inlineBtn.style.backgroundColor = "rgba(255, 180, 13, 0.9)";
                inlineBtn.style.border = "none";
                inlineBtn.style.width = "50px";
                inlineBtn.style.height = "50px";
                inlineBtn.style.borderRadius = "50%";
                inlineBtn.style.boxShadow = "2px 4px 6px rgba(0, 0, 0, 0.3)";
                inlineBtn.style.cursor = "pointer";
                inlineBtn.style.fontSize = "9px";
                inlineBtn.style.fontWeight = "bold";
                inlineBtn.style.display = "flex";
                inlineBtn.style.alignItems = "center";
                inlineBtn.style.justifyContent = "center";
                inlineBtn.style.transition = "background-color 0.3s";

                inlineBtn.addEventListener("mouseover", function () {
                    inlineBtn.style.backgroundColor = "rgba(255, 180, 13, 0.5)";
                });
                inlineBtn.addEventListener("mouseout", function () {
                    inlineBtn.style.backgroundColor = "rgba(255, 180, 13, 0.9)";
                });

                inlineBtn.addEventListener("click", function () {
                    document.getElementById("newChatButton").click();
                });

                let textAfterBtn = document.createElement("span");
                textAfterBtn.textContent = "を押してください。";

                let line2 = document.createElement("div");
                line2.textContent = "今の質問のまま会話を続けたい場合は、そのまま質問入力欄から入力してください。";
                line2.style.fontSize = "10px";
                line2.style.textAlign = "center";
                //line2.style.fontWeight = "bold";

                line1Wrapper.appendChild(textBeforeBtn);
                line1Wrapper.appendChild(inlineBtn);
                line1Wrapper.appendChild(textAfterBtn);

                v2NoticeDiv.appendChild(line1Wrapper);
                v2NoticeDiv.appendChild(line2);

                messageDiv.appendChild(v2NoticeDiv);

                let feedbackDiv = createFeedbackContent(
                    function () {
                        handleFeedback(1);
                    },
                    function () {
                        handleFeedback(2);
                    }
                );
                messageDiv.appendChild(feedbackDiv);

                let newChatButton = document.getElementById("newChatButton");
                if (newChatButton) {
                    newChatButton.style.display = "flex";
                }
            }

            mainDiv.appendChild(botMessageContainer);
            mainDiv.scrollTop = mainDiv.scrollHeight;
        }
        

        //質問用メッセージ追加    
        function addquestion(question, question_time) {
            const mainDiv = document.getElementById("chat-body");
            let userMessageContainer = document.createElement("div");
            userMessageContainer.classList.add("user-message-container");

            let messageDiv = document.createElement("div");
            messageDiv.classList.add("message");
            messageDiv.style.fontSize = "10px";
            messageDiv.textContent = question;

            let timeDiv = document.createElement("div");
            timeDiv.classList.add("message-time");
            timeDiv.textContent = question_time;

            let spacerDiv = document.createElement("div");
            spacerDiv.classList.add("spacer");

            userMessageContainer.appendChild(timeDiv);
            userMessageContainer.appendChild(spacerDiv);
            userMessageContainer.appendChild(messageDiv);
            mainDiv.appendChild(userMessageContainer);

            mainDiv.scrollTop = mainDiv.scrollHeight;

            if (newChatButton) {
                    newChatButton.style.display = "none"; // Hide till Answer is displayed
                }
        }

      
        // Feedback from Good/Bad Buttons in chat bubble
        function handleFeedback(useful) {
          createCustomConfirm(
              "この質問を終了します。\nよろしいですか?",
              function () {
                  // "はい" clicked: Send Evaluation and End Session
                  sendEvaluation(1, useful);
                  setTimeout(() => {
                      document.cookie = "cookie-key_ctcrag1=; Secure; Domain=.lambda-url.ap-northeast-1.on.aws; Path=/; SameSite=None; expires=Thu, 01 Jan 1970 00:00:00 UTC";
                      location.reload();
                  }, 500);
              },
              function () {
                  // "いいえ" clicked: Do nothing
              }
          );
        }
      
        // ユーザ評価 AJAX
        function sendEvaluation(clear, useful) {
          $.ajax({
              url: 'https://****.lambda-url.ap-northeast-1.on.aws/', //サーバー側URL
              type: 'POST',
              data: {
                  'clear': clear,
                  'useful': useful
              },
              crossDomain: true,
              xhrFields: { withCredentials: true },
          })
          .done(() => {
              // Optionally notify that evaluation was sent.
          })
          .fail(() => {
              //alert("開発者へ連絡してください。");
              //alert("評価の送信に失敗しました。");
          });
        }
      
        // New Chat Button handling
        document.addEventListener("DOMContentLoaded", function () {
          const newChatButton = document.getElementById("newChatButton");
      
          newChatButton.onclick = function () {
              // First confirmation to end the current QA session
              createCustomConfirm("この質問を終了します。\nよろしいですか?",
                  function () {
                      // Second: show feedback modal using our shared feedback styling
                      createFeedbackModal(
                          function () {
                              sendEvaluation(1, 1);
                              setTimeout(resetSession, 500);
                          },
                          function () {
                              sendEvaluation(1, 2);
                              setTimeout(resetSession, 500);
                          }
                      );
                  },
                  function () {
                      // "いいえ": do nothing
                  }
              );
          };
        });
      </script>
    
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js"></script>
<script>

    /* global $*/ // Added as a 対策 to $ is not defined
    $(function () {
        // Prevent default form submit and trigger the send button
        $('form').on('submit', function (e) {
            e.preventDefault(); // Stop the default form submit action.
            $('#send').click(); // Click AJAX button.
        });

        // 「Ajax通信」ボタンをクリックしたら発動
        $('#send').on('click', function () {

            // Get the question value
            var questionValue = $('#question').val();

            // Check if the question value is "0"
            if (questionValue == "") {
                alert("質問を入力してください。");
                return; // Exit the function to prevent further execution
            }
            // Show the loader
            $('#loader').show();

            const now = new Date();
            const formattedTime = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`;

            // Add the user's question to the chat
            addquestion(questionValue, formattedTime);

            // Clear input
            $('#question').val('');

            // AJAX request
            $.ajax({
                url: 'https://***.lambda-url.ap-northeast-1.on.aws/', // Main server URL
                type: 'POST',
                data: {
                    'question': questionValue
                },
                sent: 1,
                crossDomain: true,
                xhrFields: { withCredentials: true },
            })
                // Ajax通信が成功したら発動
                .done((data) => {
                    // Hide the loader
                    $('#loader').hide();
                    // Add bot's response to the chat
                    addChat(data.answer, data.exec_time, false);
                })
                // Ajax通信が失敗したら発動
                .fail((jqXHR, textStatus, errorThrown) => {
                    // Hide the loader
                    $('#loader').hide();
                    //alert("開発者へ連絡してください。");
                    //alert('質問を入力してください');
                    location.reload();
                    //alert('Ajax通信に失敗しました。');
                    console.log("jqXHR          : " + jqXHR.status); // HTTPステータスを表示
                    console.log("textStatus     : " + textStatus); // タイムアウト、パースエラーなどのエラー情報を表示
                    console.log("errorThrown    : " + errorThrown.message); // 例外情報を表示
                })
                // Ajax通信が成功・失敗のどちらでも発動
                .always((data) => {
                    console.log($('#question').val());
                    console.log($('answer').val());
                });
        });
    });
</script>
</body>
</html>

・上記のHTMLでは、ユーザーのGood/Bad評価を受け付けることができます。
主な処理流れは以下の通りです:
 1.addChat()(回答表示部)
  - 回答文の表示時に、評価UI(👍👎)を自動で挿入します。
 2.createFeedbackContent()(◆◆◆ユーザ評価メイン部◆◆◆)
  - 評価UIを生成部。上記のaddChat()から呼び出されます。
 3.handleFeedback()
  - 評価ボタン押下後、確認モーダルを表示します。
 4.sendEvaluation()(ユーザ評価AJAX)
  - 上記の確認モーダルで「はい」押下時、評価値(useful)をサーバー側Lambda関数に送信します。 
このようにして、ユーザーからの評価を収集・送信できるようにしました。

構築4)DynamoDBテーブル作成


ユーザー評価(GOOD/BAD)と会話履歴を保存するために、2つのDynamoDBテーブルを作成します。
※評価(GOOD/BAD)のみ記録する場合、会話履歴テーブル作成をスキップしてOKです。

・まずは、ユーザーの評価を記録するための評価記録用テーブルを作成します。
・AWSコンソールでDynamoDBに遷移し、トップ画面で「テーブルの作成」を押します。

・画面「テーブルの作成」にて、以下を設定値としてテーブルを作成します。

設定項目
テーブル名ctcrag_user_review ※お好きな名称で
パーティションキーsession_id(文字列)
ソートキーexec_time(文字列)
デフォルト設定チェック

・次は、ユーザーとの会話内容を保存する会話履歴用テーブルを作成します。
・もう一度「テーブルの作成」画面を開き、以下の設定でテーブルを作成します。

設定項目
テーブル名ctcrag_chat_history ※お好きな名称で
パーティションキーsession_id(文字列)
ソートキーexec_time(文字列)
デフォルト設定チェック

構築5)サーバー側Lambda関数の作成


サーバー側でユーザーのGOOD/BAD評価を処理・保存するために、1つ関数を追加します。

・サーバー側Lambda関数を以下の手順で作成します。
  - AWSコンソールでLambdaメニューに遷移し、トップ画面で「関数の作成」を押します。
   (※AWSアカウントの作成など基本設定はここでは割愛します)
  -  画面「関数の作成」では、以下の設定値で、作成します。

設定項目
関数名chat_server ※お好きな名称で
ランタイムpython3.9
アーキテクチャx86_64(デフォルト)
実行ロールlambda_role_for_user ※お手元の環境で任意に設定
関数 URL を有効化チェック
認証タイプNONE

・ AWSコンソールでIAM画面に遷移し、メニュー「ロール」を開きます。
・ その画面で、Lambda関数に割り当てたロール名を押し、以下ポリシーをアタッチします。

設定項目
ポリシーAmazonDynamoDBFullAccess

・GOOD/BAD評価を保存するため、lambda_function.py内にhandle_user_rev関数を追加します。処理としては、評価を受け取り、それをDynamoDBに記録する内容です。
・コードは以下の通りです。該当箇所は「ユーザ評価Handler」(行:496~574)です。

lambda_function.py
import json
import boto3
import logging
import base64
import uuid
from http import cookies
import urllib.parse
import urllib.request  # For making HTTP requests
import textwrap
from openai import OpenAI
from datetime import datetime, timedelta

# OpenAI API Key
OPENAI_API_KEY = "******"
openai_client = OpenAI(api_key=OPENAI_API_KEY)

# Pinecone setup
from pinecone import Pinecone, ServerlessSpec
pc = Pinecone(api_key="******")
index = pc.Index(host="https://******.pinecone.io")

# Initialize the logger
logger = logging.getLogger()
logger.setLevel(logging.INFO)

# Constants for Lambda function and VPC Lambda endpoint
VPC_ENDPOINT_DNS = "https://***.vpce.amazonaws.com"
CONSUMER_LAMBDA_NAME = "ctcrag_serve_embedding"

lambda_client = boto3.client("lambda", endpoint_url=VPC_ENDPOINT_DNS)

# Constants for Google OAuth 2.0
GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
CLIENT_ID = "******.apps.googleusercontent.com"
CLIENT_SECRET = "******"
REDIRECT_URI = "https://*****.lambda-url.ap-northeast-1.on.aws/"
COOKIE_KEY = 'cookie-key_ctcrag1'

#DynamoDB関連
dynamodb_client = boto3.client('dynamodb')
CONVO_HISTORY_TABLE_NAME = 'ctcrag_chat_history'
DN_P_KEY = 'session_id'
REVIEW_TABLE_NAME = "ctcrag_user_review"

# Decodes a base64-encoded event body
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:  # Convert array values to strings
        body_dict[key] = body_dict[key][0]
    return body_dict
     
################### QuestionのGluCoSEからベクター値生成    ###################

def create_embeddings(sentence_list):
    try:
        payload = {
            "body": json.dumps({"sentences": sentence_list}, ensure_ascii=False)
        }
        #logger.info(f"Payload prepared: {payload}")

        embed_response = lambda_client.invoke(
            FunctionName=CONSUMER_LAMBDA_NAME,
            InvocationType="RequestResponse",
            Payload=json.dumps(payload).encode("utf-8")
        )

        consumer_body = json.loads(embed_response["Payload"].read().decode("utf-8"))["body"]
        embeddings = json.loads(consumer_body).get("embeddings", [])

        return embeddings[0]

    except Exception as e:
        logger.error(f"Error creating embeddings: {str(e)}", exc_info=True)
        raise

################### Lambda Handler    ###################

def lambda_handler(event, context):
    query_params = event.get('queryStringParameters', {})
    #logging.info(f" Lambda Handler Query Params: {query_params}")
    request_body = decode_body(event['body']) if event.get('body') else {}
    if event.get('body'):
        useful,question = request_body.get('useful'),request_body.get('question')  
    #print(f"Lambda Handler Request body : {request_body}")

    if 'code' in query_params:
        return handle_google_callback(event, context) #Google認証
    elif 'question' in request_body:
        return handle_chat(event, context) #チャット応答
    else:
        return handle_user_rev(event,context) #評価記録

################### Google認証 Handler   ###################

def handle_google_callback(event, context):
    #print("Handling Google callback...")
    #print("jsonevent:", event)

    # Initialize cookies for COOKIE_KEY and auth_success only
    google_callback_id = None
    auth_success = "1"

    # Check for cookies in event['cookies'] (list of cookie strings)
    if 'cookies' in event:
        #print(f"Raw cookies from event['cookies']: {event['cookies']}")
        combined_cookies = "; ".join(event['cookies'])
        C = cookies.SimpleCookie()
        C.load(combined_cookies)
        google_callback_id = C.get(COOKIE_KEY).value if C.get(COOKIE_KEY) else None
        auth_success = C.get("auth_success").value if C.get("auth_success") else "0"

    # Fallback: Check for cookies in event['headers']['cookie'] (single cookie string)
    elif 'headers' in event and 'cookie' in event['headers']:
        cookie_header = event['headers']['cookie']
        #print(f"Raw Cookie header from headers: {cookie_header}")
        C = cookies.SimpleCookie()
        C.load(cookie_header)
        google_callback_id = C.get(COOKIE_KEY).value if C.get(COOKIE_KEY) else None
        auth_success = C.get("auth_success").value if C.get("auth_success") else "0"

    #print(f"Extracted COOKIE_KEY during auth: {google_callback_id}")
    #print(f"Extracted auth_success during auth: {auth_success}")

    # Generate a new google_callback_id if not present
    if not google_callback_id:
        google_callback_id = str(uuid.uuid4())
        #print(f"Generated new {COOKIE_KEY}: {google_callback_id}")

    # Validate the authorization code
    code = event.get('queryStringParameters', {}).get('code')
    if not code:
        print("Authorization code missing from query parameters.")
        return {'statusCode': 400, 'body': 'Invalid Request'}

    #print(f"Authorization code received: {code}")

    # Simulate token exchange and validation
    try:
        token_data = get_google_token(code)
        id_token = token_data.get('id_token')
        auth_success = "1"
        if not id_token:
            print("ID token verification failed.")
            return {'statusCode': 401, 'body': 'Unauthorized'}

        # Decode user info (optional step for debugging)
        user_info = decode_jwt(id_token)
        #print(f"User info decoded: {user_info}")
        user_email = user_info.get("email", "Unknown")
        #print(f"User email decoded: {user_email}")

        set_cookie_header_email = (
            f"user_email={user_email}; Secure; Domain=.lambda-url.ap-northeast-1.on.aws; SameSite=None; Path=/"
        )

         # Redirect back to 関数3
        return {
            'statusCode': 302,
            'headers': {
                'Location': "https://rc26qs5sgsdkrn4pfsxion6gme0cgoss.lambda-url.ap-northeast-1.on.aws/",
                'Set-Cookie': f"auth_success={auth_success}; Secure; Domain=.lambda-url.ap-northeast-1.on.aws; SameSite=None; Path=/, {set_cookie_header_email}",
                'Access-Control-Allow-Credentials': 'true',
            },
            'body': 'Redirecting...'
        }

    except Exception as e:
        #print(f"Error during token exchange or verification: {e}")
        return {'statusCode': 500, 'body': 'Internal Server Error'}

def get_google_token(code):
    payload = {
        'code': code,
        'client_id': CLIENT_ID,
        'client_secret': CLIENT_SECRET,
        'redirect_uri': REDIRECT_URI,
        'grant_type': 'authorization_code'
    }
    headers = {'Content-Type': 'application/x-www-form-urlencoded'}
    data = urllib.parse.urlencode(payload).encode('utf-8')

    #print(f"Sending request to Google Token API: {payload}")
    req = urllib.request.Request(GOOGLE_TOKEN_URL, data=data, headers=headers, method='POST')

    try:
        with urllib.request.urlopen(req) as response:
            response_body = response.read().decode('utf-8')
            #print(f"Google Token API Response: {response_body}")
            return json.loads(response_body)
    except urllib.error.HTTPError as e:
        error_body = e.read().decode('utf-8')
        #print(f"Error fetching token: {error_body}")
        raise

def decode_jwt(jwt_token):
    try:
        base64_url = jwt_token.split('.')[1]
        base64_bytes = base64_url + '=' * (4 - len(base64_url) % 4)
        decoded_bytes = base64.b64decode(base64_bytes)
        decoded_str = decoded_bytes.decode('utf-8')
        return json.loads(decoded_str)
    except Exception as e:
        #print(f"Error decoding JWT: {e}")
        return {}

################### チャット処理 Handler   ###################

def handle_chat(event, context):

    # Initialize cookies for COOKIE_KEY and auth_success only
    google_callback_id = None
    auth_success = "0"
    clear = "0"

    # Check for cookies in event['cookies'] (list of cookie strings)
    if 'cookies' in event:
        #print(f"Raw cookies from event['cookies']: {event['cookies']}")
        combined_cookies = "; ".join(event['cookies'])
        C = cookies.SimpleCookie()
        C.load(combined_cookies)
        google_callback_id = C.get(COOKIE_KEY).value if C.get(COOKIE_KEY) else None
        auth_success = C.get("auth_success").value if C.get("auth_success") else "0"
        clear = C.get("clear").value if C.get("clear") else "0"

    # Fallback: Check for cookies in event['headers']['cookie'] (single cookie string)
    elif 'headers' in event and 'cookie' in event['headers']:
        cookie_header = event['headers']['cookie']
        #print(f"Raw Cookie header from headers: {cookie_header}")
        C = cookies.SimpleCookie()
        C.load(cookie_header)
        google_callback_id = C.get(COOKIE_KEY).value if C.get(COOKIE_KEY) else None
        auth_success = C.get("auth_success").value if C.get("auth_success") else "0"
        clear = C.get("clear").value if C.get("clear") else "0"

    #print(f"Extracted COOKIE_KEY during chat: {google_callback_id}")
    #print(f"Extracted auth_success during chat: {auth_success}")
    
    # Check if the user is authenticated
    if auth_success != "1":
        print("User not authenticated. Redirecting to login.")
        google_auth_url = (
            f"https://accounts.google.com/o/oauth2/v2/auth"
            f"?client_id={CLIENT_ID}"
            f"&redirect_uri={REDIRECT_URI}"
            "&response_type=code"
            "&scope=openid%20email%20profile"
            "&state=random_state_string"
        )
        #print(f"Redirecting to Google Auth: {google_auth_url}")  # for debug
        return {
            'statusCode': 302,
            'headers': {'Location': google_auth_url,'Set-Cookie': 'auth_success=0; Domain=.lambda-url.ap-northeast-1.on.aws; Path=/; HttpOnly; SameSite=Strict',},
            'body': json.dumps({"message": "User not authenticated. Please log in."}, ensure_ascii=False),
        }

    #print("Authentication successful, proceeding to handle chat.")

    session_id,continuation = '',False
    print("going ahead with handling chat")
    #body部のデコード
    if event.get('body'):
        request_body = decode_body(event['body'])
        print("decoded_body",request_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')  
    print("question:",question)  
    print("Processing Answer: Creating GluCoSE embeddings + PineconeQuery + OpenAI")
    cookie_dict = {k: v.value for k, v in C.items()}
    if COOKIE_KEY not in cookie_dict:
        session_id = str(uuid.uuid4())
        print('IS_cookies:NO!! -> New Question?')
    else:
        session_id = cookie_dict[COOKIE_KEY]
        print('COOKIE_KEY:Exist!!',session_id)
        if is_conversation_log(session_id):
            continuation=True
            print('Continue Conversation')
        else:
            continuation=False
            print('New Question!!')
    
    print(f"Extracted clear during chat: {clear}")

    if clear == '1':
        session_id,continuation = str(uuid.uuid4()),False
        print('conversation_clear!!')
    #回答生成要求部
    if clear == '1':
        answer,system_prompt,exec_time,question = '新しい質問をどうぞ。','','','' #生成しない。
    else:
        ("No need to clear. go ahead and save to DB")
        answer,system_prompt,exec_time = make_answer(question,session_id,continuation) #引数3は継続mode(True:継続/False:初回)

    # Prepare Set-Cookie headers
    set_cookie_header_1 = (
        f"{COOKIE_KEY}={session_id}; Secure; Domain=.lambda-url.ap-northeast-1.on.aws; SameSite=None; Path=/"
    )
    set_cookie_header_2 = (
        f"auth_success={auth_success}; Secure; Domain=.lambda-url.ap-northeast-1.on.aws; SameSite=None; Path=/"
    )
    set_cookie_header_3 = (
        f"clear={clear}; Secure; Domain=.lambda-url.ap-northeast-1.on.aws; SameSite=None; Path=/"
    )

    #レスポンスデータ作成
    message = {
        "question":question,
        "exec_time":exec_time,
        "answer":answer 
    }

    return {
        'statusCode': 200,
        'isBase64Encoded': False,
        'headers': {
            'Content-Type': 'application/json',
            'Set-Cookie':  f"{set_cookie_header_1}, {set_cookie_header_2},{set_cookie_header_3} "
        },
        'body': json.dumps(message, ensure_ascii=False),
    }

def is_conversation_log(session_id): #会話履歴内存在確認の関数
    dynamo_response = dynamodb_client.query(TableName=CONVO_HISTORY_TABLE_NAME,KeyConditionExpression= DN_P_KEY + '= :val',ExpressionAttributeValues={":val": {"S": session_id}},)
    if dynamo_response['Count'] > 0 :
        return True
    else:
        return False

def record_conversations(session_id,question,answer,system_prompt): #会話履歴保存関数
    exec_time = str((datetime.utcnow() + timedelta(hours=9)).strftime("%Y-%m-%d %H:%M:%S"))
    dynamodb_client.put_item(TableName=CONVO_HISTORY_TABLE_NAME,Item={
        DN_P_KEY: {"S": session_id},
        "question": {"S": question},
        "answer": {"S": answer},
        "system_prompt": {"S": system_prompt},
        "exec_time": {"S": exec_time},
    })
    dynamo_response = dynamodb_client.query(TableName=CONVO_HISTORY_TABLE_NAME,KeyConditionExpression= DN_P_KEY + '= :val',ExpressionAttributeValues={":val": {"S": session_id}},)
    print("dynamoDB output:",dynamo_response)

    return exec_time

def make_answer(question,session_id,continuation): #回答作成関数(最上位関数) question:質問文,session_id:セッションID,continuation:True継続/False初回
    if not continuation :
        answer,system_prompt = exec_rag(question,similarity=0.65)
    else:
        answer,system_prompt = continuous_genarate(question,session_id)
    #exec_time = '2025-02-5 11:11:11' #for non openai debug
    exec_time = record_conversations(session_id,question,answer,system_prompt)
    

    if exec_time :
        return answer,system_prompt,exec_time
    else:
        return '**異常終了1**','**異常終了1**',''

def exec_rag(question,similarity):#question:質問(str),similarity:類似度(-1~1)
    try:
        # Create embeddings for the question
        question_list = [f"query: {question}"] #GLuCoSEに送る前にquery:を追加しました。
        print("Creating embeddings for the question")
        q_embeddings = create_embeddings(question_list)
        print("Question embeddings created")

        # Query Pinecone for similar vectors
        print("Querying Pinecone for similarity search")
        #search_results = index.query(vector=q_embeddings, top_k=10, include_values=False, include_metadata=True)
        # For File IDs (is_f=True)
        search_results_f = index.query(vector=q_embeddings, top_k=5,filter={"is_f": True}, include_values=False, include_metadata=True)
        # For Slack IDs (is_f not True OR missing)
        search_results_s = index.query(
            vector=q_embeddings,
            top_k=5,
            filter={
                "$or": [
                    {"is_f": {"$ne": True}},  # is_f exists but is not True
                    {"is_f": {"$exists": False}}  # is_f not set (old Slack vectors)
                ]
            },
            include_values=False,
            include_metadata=True
        )
        # Filter search results based on similarity
        context_candidates = []
        work_list = []

        filtered_f = [
            [val['id'], val['score'], val['metadata']['doc']]
            for val in search_results_f['matches']
            if val['score'] >= similarity
        ]
        filtered_s = [
            [val['id'], val['score'], val['metadata']['doc']]
            for val in search_results_s['matches']
            if val['score'] >= similarity
        ]

        # Sort only by ID (descending)
        filtered_f.sort(key=lambda x: int(x[0][1:]), reverse=True)
        filtered_s.sort(key=lambda x: int(x[0][1:]), reverse=True)

        # Merge with F first, then S
        context_candidates = filtered_f + filtered_s

        # Build LLM context
        llm_context = ''
        view_use_id = '[PineconeID:'
        for candidate in context_candidates:
            llm_context += candidate[2] + '\n-----------------\n'
            view_use_id += candidate[0] + ','

        view_use_id += ' ]'

        # Prepare the system prompt
        system_prompt = textwrap.dedent("""
            あなたは質問に回答するテクニカルセンターのチャットbotです。
            以下のコンテクストを参考にして質問に回答して下さい。
            コンテクストの中に質問に対する答えがない場合や、わからない場合、不確かな情報で回答しないでください。
            わからない場合は正直に「わかりませんでした。CTCまでお問い合わせください。」と答えてください。
            なお、回答文で”コンテクスト”という表現は使わずに、別の言い方に変えて下さい。”検索結果”が良いと思います。
            回答文に「https://」や「http://」で始まるリンク箇所があればその部分はそのwebページを別ウィンドウで開くようにHTMLタグを追記してください。
            回答文に「G:」で始まるGoogleドライブへのリンク箇所があればそのままにして変更しないでください。
            ## コンテクスト(開始) ##
            {}
            ## コンテクスト(終了) ##
        """).format(llm_context)

        # Generate the response using OpenAI
        print("Generating response using OpenAI GPT model")
        response_gpt = openai_client.chat.completions.create(
            model="gpt-4o",
            messages=[
                {"role": "system", "content": system_prompt},
                {"role": "user", "content": question}
            ]
        )

        # Extract the answer from the response
        answer = response_gpt.choices[0].message.content + view_use_id #Varun edit20250220
        return answer, system_prompt

    except Exception as e:
        print(f"Error in exec_rag: {e}")
        raise

def continuous_genarate(question,session_id): #継続的な生成をする関数。question:質問(str),session_id:セッションID
    llm_context = ''
    dynamo_response = dynamodb_client.query(TableName=CONVO_HISTORY_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['exec_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)と利用者の最近の会話内容です。
        これを参考にして質問に回答して下さい。
        会話内容の中に存在するコンテクストに質問に対する答えが無い場合は、一般論として、ヒントとなる情報を回答して下さい。
        その際は、憶測の情報である事を述べる言葉を必ず使って下さい。
        わからない場合は正直に「わかりませんでした。CTCまでお問い合わせください。」と答えてください。
        回答文に「https://」や「http://」で始まるリンク箇所があればその部分はそのwebページを別ウィンドウで開くようにHTMLタグを追記してください。
        回答文に「G:」で始まるGoogleドライブへのリンク箇所があればそのままにして変更しないでください。
        ## 会話履歴(開始) ############################################################
        {}
        ## 会話履歴(終了) ############################################################
    """).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

    #answer ="継続会話部 trial" #for non openai debug

    return answer,system_prompt

################### ユーザ評価 Handler   ###################

# ◇◇◇ユーザ評価保存部◇◇◇
def handle_user_rev(event,context):
    print("processing user review")
    #body部のデコード
    request_body = decode_body(event['body']) if event.get('body') else {}
    print("user rev decoded_body",request_body)
    session_id = ''
    #パラメータの受理(clear,useful) 
    useful,clear = '0','0'
    if event.get('queryStringParameters'):
        useful,clear = event.get('queryStringParameters').get('useful'),event.get('queryStringParameters').get('clear')
    elif event.get('body'):
        useful,clear = request_body.get('useful'),request_body.get('clear')  
    print("Processing User review")
    if 'cookies' in event:
        cookie_header = event['headers']['cookie']
        print(f"Processing User review Cookie header from headers: {cookie_header}")
        C = cookies.SimpleCookie()
        C.load(cookie_header)
        cookie_dict = {k: v.value for k, v in C.items()}
        if COOKIE_KEY not in cookie_dict:
            session_id = str(uuid.uuid4())
            print('During user rev IS_cookies:NO!! ')
        else:
            session_id = cookie_dict[COOKIE_KEY]
            print('During user rev COOKIE_KEY:Exist!!',session_id)

    #print(f"Received Feedback: Useful={useful}")
    if session_id:
        #fq = get_first_question(session_id) #v1code
        fq, f_ans = get_first_question_and_answer(session_id)

        print(f"First question from 会話履歴DB: {fq}")
        print(f"First answer from 会話履歴DB: {f_ans}")
        print(f"Feedback recorded for session: {session_id}")

        record_reviews(session_id,fq,f_ans,useful)

    set_cookie_header_1 = (
        f"{COOKIE_KEY}={session_id}; Secure; Domain=.lambda-url.ap-northeast-1.on.aws; SameSite=None; Path=/"
    )
    set_cookie_header_2 = (
        f"clear={clear}; Secure; Domain=.lambda-url.ap-northeast-1.on.aws; SameSite=None; Path=/"
    )

    return {
        "statusCode": 200,
        "body": json.dumps({"message": "Feedback received", "session_id": session_id}),
        "headers": {
            "Content-Type": "application/json",
            'Set-Cookie':  f"{set_cookie_header_1},{set_cookie_header_2}"
        }
    }

def get_first_question_and_answer(session_id):
    """Fetches the first question and first answer for the session."""
    response = dynamodb_client.query(
        TableName=CONVO_HISTORY_TABLE_NAME,
        KeyConditionExpression=f"{DN_P_KEY} = :val",
        ExpressionAttributeValues={":val": {"S": session_id}},
        ScanIndexForward=True,
        Limit=1
    )
    item = response.get("Items", [{}])[0]  # Default to empty dict if no items found
    return item.get("question", {}).get("S", "N/A"), item.get("answer", {}).get("S", "N/A")

def record_reviews(session_id,fq,f_ans,useful): #評価記録保存関数
    exec_time = str((datetime.utcnow() + timedelta(hours=9)).strftime("%Y-%m-%d %H:%M:%S"))
    dynamodb_client.put_item(TableName=REVIEW_TABLE_NAME,Item={
        DN_P_KEY: {"S": session_id},
        "question": {"S": fq},
        "helpful": {"N": str(useful)},
        "exec_time": {"S": exec_time},
        "answer": {"S": f_ans}  #v2で追加
    })
    dynamo_response = dynamodb_client.query(TableName=REVIEW_TABLE_NAME,KeyConditionExpression= DN_P_KEY + '= :val',ExpressionAttributeValues={":val": {"S": session_id}},)
    print("dynamoDB output:",dynamo_response)

    return exec_time

・handle_user_rev()関数(行:496~548)の主な処理は以下の通りです:
 - クライアントから送信された評価(useful)を受け取れます。
 - 最初の質問と回答は会話履歴DB(ctcrag_chat_history)に保存済みのため、そこから取得します。
 - 取得した内容と評価を、record_reviews()で評価記録用DB(ctcrag_user_review)に保存します。
 - 保存後、画面を初期状態に戻します。

・上記の補足情報として、record_reviews関数(行:562~574)では、取得した内容に基づいて以下の情報が評価記録用DB(ctcrag_user_review)に保存されます:
 -exec_time:評価が登録された日時
 -session_id:評価対象となる会話の識別子
 -question:ユーザーが投稿した最初の質問
 -answer:その質問に対するAIの回答
 -helpful:ユーザー評価(1=GOOD、2=BAD)

試用してみましょう!


画面は出来たので早速テストをしてみます。

・チャット画面にログイン後、質問を入力して送信ボタンを押します。

・数秒後に回答が表示され、下に評価ボタン部も確認できました。

・正しい回答だったので「👍」を押してみます。

・確認モーダルで「はい」を押すと、ユーザー評価は送信されました!その後初期状態にリセットされました。
・最後に、評価情報が評価記録用のテーブルに保存されていることを確認しました。

「helpful=1」で情報1件は入っていますね!完成!

まとめ


今回、チャット画面の改善版を作成し、ユーザーからの評価(GOOD/BAD)を収集・保存することができました。

上記ではあまり触れませんでしたが、今回の実装を通じて、セッションIDをCookieに保持し、その値をDynamoDBにも保存することで、ユーザーの質問とその評価を正確に紐づけることができました。

また、サーバー側Lambdaでは、Google認証・チャット応答・評価記録などの処理をlambda_handler内で用途ごとに分離することで、構成が整理され、エラー対応や修正がしやすくなることを実感しました。

そして、次はこの画面で蓄積された情報を確認できるように、管理者用の確認画面を作成します。詳しい内容は、別の記事でご紹介したいと思います。

ಮುಂದಿನ ಲೇಖನದಲ್ಲಿ ನಿಮ್ಮನ್ನು ಭೇಟಿಯಾಗೋಣ
(次の記事でお会いしましょう)

ご連絡フォーム


フィードバックを是非お願いします。
本記事の方法での問題点や、よりよい方法のアイデアを頂けると大変助かります。

この記事に関して

その他のご連絡

DevAIsをもっと見る

今すぐ購読し、続きを読んで、すべてのアーカイブにアクセスしましょう。

続きを読む