0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

感情分析WebアプリにAIからのコメント機能を追加する

Last updated at Posted at 2026-02-01

これの続きです。

環境イメージ

今まで構築してきた環境にLambdaを1つ追加して、そこからAWS Bedrockに接続します。
BedrockでAWSのAIであるNovaを呼び出してユーザへのコメントを生成してもらいます。
Lambdaを呼び出すために既存のAPI Gatewayに設定を追加します。

aws04-ページ18.drawio (1).png

ハンズオン

Lambda

SentimentLambda-AItalkという名前でLambdaを作成します。
ランタイムはPython3.13とします。
screencapture-ap-northeast-1-console-aws-amazon-lambda-home-2026-02-01-00_48_50.png

作成出来たことを確認します。
このLambdaはAWS Bedrockと連携しますので、Roleの中身を変える必要があります。
変えていきましょう。
image.png

IAMRoleの管理画面に移動して、Roleの新規作成を行います。
AWSのサービスで、ユースケースはLambdaを選択します。
screencapture-us-east-1-console-aws-amazon-iam-home-2026-02-01-00_53_19.png

PolicyはAmazonBedrockFullAccessとCloudWatchLogsFullAccessを付与します。
image.png

SentimentLambda-AItalkRoleという名前でRoleを作成します。
screencapture-us-east-1-console-aws-amazon-iam-home-2026-02-01-00_57_01.png

作成したRoleをLambdaに紐づけておきましょう。
image.png

また、同じく設定タブでタイムアウトを1分にしておきましょう。
Bedrockから回答を待つので、タイムアウトが3秒だとそこまでに回答が間に合わずエラーになる可能性があります。
image.png

Lambdaのコードは以下とします。
コードを更新したらDeployをしておきましょう。今回はAmazon Nova Microを呼び出します。

import json
import boto3
import os

# ★重要点★
# 東京(ap-northeast-1)ではなく、本家(us-east-1)を指定して確実に呼び出します
bedrock = boto3.client(service_name='bedrock-runtime', region_name='us-east-1')

def response(status, body):
    return {
        "statusCode": status,
        "headers": {
            "Content-Type": "application/json",
            "Access-Control-Allow-Origin": "*",
            "Access-Control-Allow-Headers": "Content-Type",
            "Access-Control-Allow-Methods": "OPTIONS,POST"
        },
        "body": json.dumps(body, ensure_ascii=False)
    }

def lambda_handler(event, context):
    if event.get("httpMethod") == "OPTIONS": return response(200, {})

    try:
        body = json.loads(event.get("body")) if isinstance(event.get("body"), str) else event.get("body")
        text = body.get("text", "")
        sentiment = body.get("sentiment", "不明")
    except:
        return response(400, {"message": "Invalid JSON"})

    if not text: return response(400, {"message": "text is required"})

    # --- Amazon Nova Micro 用の設定 ---

    # システムプロンプト
    system_list = [
        { "text": "あなたは親切なAIカウンセラーです。ユーザーの言葉と感情分析結果を元に、50文字以内で優しく励ましやコメントをしてください。" }
    ]

    # ユーザーメッセージ
    user_message = f"ユーザー: {text}\n感情: {sentiment}"
    
    messages_list = [
        {
            "role": "user",
            "content": [
                {"text": user_message}
            ]
        }
    ]

    # リクエストボディ
    body_payload = json.dumps({
        "system": system_list,
        "messages": messages_list,
        "inferenceConfig": {
            "max_new_tokens": 300,
            "temperature": 0.7,
            "top_p": 0.9
        }
    })

    try:
        # モデルID: Amazon Nova Micro
        # USリージョン指定なので、そのままのIDで動きます
        model_id = 'amazon.nova-micro-v1:0' 
        
        res = bedrock.invoke_model(
            body=body_payload,
            modelId=model_id,
            accept='application/json',
            contentType='application/json'
        )
        
        response_body = json.loads(res.get('body').read())
        # 結果の取り出し
        ai_comment = response_body.get('output').get('message').get('content')[0].get('text').strip()

        return response(200, {"comment": ai_comment})

    except Exception as e:
        print(f"Error: {e}")
        return response(500, {"message": str(e)})

Bedrockは少し前まで、AIを使う時はそのモデルアクセスを有効化する必要がありましたが、今はそれが必要ありません。
image.png

Model access page has been retired
Serverless foundation models are now automatically enabled across all AWS commercial regions when first invoked in your account, so you can start using them instantly. You no longer need to manually activate model access through this page. Note that for Anthropic models, first-time users may need to submit use case details before they can access the model. For models served from AWS Marketplace, a user with AWS Marketplace permissions must invoke the model once to enable it account-wide for all users.

Account administrators retain full control over model access through IAM policies and Service Control Policies to restrict access as needed. Learn more

To get started, simply select a model from the Model catalog and open it in the playground or invoke the model using the InvokeModel or Converse API operations. Review our documentation for the complete list of available models.

Lambdaのテストを実施してみます。
以下の内容を使って、Lambdaのテストを作成します。

{
  "body": {
    "text": "今日は仕事で大きなミスをしてしまって、本当に落ち込んでいます...もう立ち直れないかもしれません。",
    "sentiment": "NEGATIVE"
  }
}

作成出来たら保存ボタンを押下します。
image.png

Testボタンを押下します。
image.png

結果が返ってきたことがわかります。
image.png

Status: Succeeded
Test Event Name: call-Bedrock

Response:
{
  "statusCode": 200,
  "headers": {
    "Content-Type": "application/json",
    "Access-Control-Allow-Origin": "*",
    "Access-Control-Allow-Headers": "Content-Type",
    "Access-Control-Allow-Methods": "OPTIONS,POST"
  },
  "body": "{\"comment\": \"ミスは誰にでも起きます。明日は新たなスタート。頑張ろうね!\"}"
}

The area below shows the last 4 KB of the execution log.

Function Logs:
START RequestId: 30a24990-af8f-4517-84a7-422aef0b0c93 Version: $LATEST
END RequestId: 30a24990-af8f-4517-84a7-422aef0b0c93
REPORT RequestId: 30a24990-af8f-4517-84a7-422aef0b0c93	Duration: 998.75 ms	Billed Duration: 1462 ms	Memory Size: 128 MB	Max Memory Used: 86 MB	Init Duration: 462.52 ms

Request ID: 30a24990-af8f-4517-84a7-422aef0b0c93

API Gateway

既存で使っているAPI Gatewayを流用します。
SentimentAPIのルートの右側の作成を押下。
image.png

Post/bedrockとします。
この状態で作成を押下します。
image.png

作成出来たことを確認します。
統合をアタッチするを押下します。
image.png

統合を作成してアタッチを押下します。
image.png

統合タイプをLambda関数として、Lambdaは先ほど作成したSentimentLambda-AItalkを指定します。
アクセス許可を呼び出すは有効化します。この状態で作成します。
image.png

統合が作成されたことを確認します。
続いて認可を設定していきます。認証をアタッチを押下します。
image.png

オーソライザを作成してアタッチを押下します。
image.png

タイプ:JWT
名前:CognitoAuth-AITalk
IDソース:$request.header.Authorization(デフォルト)
URL:https://cognito-idp.ap-northeast-1.amazonaws.com/CognitoのユーザプールID
対象者:CognitoのアプリケーションクライアントID
これで作成します。設定内容は前作ったものと同様です。
2026013103.png

認可に設定が入ったことを確認します。こうすることによりAPI Gateway経由でLambdaを実行する際にCognitoで認証を受けたユーザ(ログインに成功したユーザ)のみがLambdaをキックできるようになりました。
image.png

Reactコードの修正

src/components/AiFeedback.jsx

import React from 'react';

export function AiFeedback({ comment, loading, isVisible }) {
  // 分析結果が出ていない、かつロード中でもなければ何も表示しない
  if (!isVisible && !loading) return null;

  return (
    <div style={{ 
      marginTop: "20px", 
      padding: "16px", 
      backgroundColor: "#f3f4f6", 
      borderRadius: "8px", 
      borderLeft: "4px solid #10b981" 
    }}>
      <h3 style={{ 
        margin: "0 0 8px 0", 
        fontSize: "1.1em", 
        display: "flex", 
        alignItems: "center", 
        gap: "8px" 
      }}>
        🤖 AIカウンセラー
        {loading && <span style={{ fontSize: "0.8em", color: "#666" }}>考え中...</span>}
      </h3>
      
      {!loading && comment && (
        <p style={{ margin: 0, lineHeight: "1.6", whiteSpace: "pre-wrap" }}>
          {comment}
        </p>
      )}
      
      {!loading && !comment && isVisible && (
          <p style={{ margin: 0, color: "#9ca3af", fontSize: "0.9em" }}>
            (コメントはありません)
          </p>
      )}
    </div>
  );
}

src/components/MainApp.jsx

import { useMemo, useState } from "react";
import { fetchAuthSession } from "aws-amplify/auth";
import { useTranscribe } from "../hooks/useTranscribe";
import { ResultCard } from "./ResultCard";
import { AiFeedback } from "./AiFeedback"; // ★追加: 作成したコンポーネントを読み込み

// 環境設定
const API_BASE = (import.meta.env.VITE_API_BASE_URL ?? "").replace(/\/$/, "");

async function safeReadText(res) {
  try { return await res.text(); } catch { return ""; }
}

export default function MainApp({ signOut, user }) {
  const [text, setText] = useState("");
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState("");
  const [result, setResult] = useState(null);
  const [apiStatus, setApiStatus] = useState("unchecked");
  
  // AI用ステート
  const [aiComment, setAiComment] = useState("");
  const [aiLoading, setAiLoading] = useState(false);

  const { isRecording, startRecording, stopRecording } = useTranscribe(setText);

  const canSubmit = useMemo(() => {
    const len = text.trim().length;
    return !loading && !aiLoading && len > 0 && len <= 1000;
  }, [text, loading, aiLoading]);

  const apiStatusLabel = !API_BASE ? "未設定" : apiStatus === "ok" ? "接続OK" : apiStatus === "ng" ? "接続NG" : "未確認";

  // --- ロジック部分 ---
  async function onSubmit(e) {
    e.preventDefault();
    setError("");
    setResult(null);
    setAiComment("");

    if (!API_BASE) {
      setError("API の設定がありません。");
      return;
    }

    setLoading(true);
    try {
      const session = await fetchAuthSession();
      const token = session.tokens?.idToken?.toString();
      if (!token) throw new Error("認証トークンエラー");

      // 1. 感情分析
      const res = await fetch(`${API_BASE}/analyze`, {
        method: "POST",
        headers: { "Content-Type": "application/json", Authorization: token },
        body: JSON.stringify({ text: text.trim() }),
      });

      if (!res.ok) {
        const bodyText = await safeReadText(res);
        throw new Error(bodyText || `HTTP ${res.status}`);
      }

      const data = await res.json();
      setResult(data);
      setApiStatus("ok");

      // 2. AIコメント取得
      await fetchAiComment(text.trim(), data.sentiment, token);

    } catch (err) {
      setApiStatus("ng");
      setError(err instanceof Error ? err.message : String(err));
    } finally {
      setLoading(false);
    }
  }

  async function fetchAiComment(inputText, sentimentResult, token) {
    setAiLoading(true);
    try {
      const res = await fetch(`${API_BASE}/bedrock`, {
        method: "POST",
        headers: { "Content-Type": "application/json", Authorization: token },
        body: JSON.stringify({ text: inputText, sentiment: sentimentResult }),
      });
      if (!res.ok) throw new Error("AI Error");
      const data = await res.json();
      setAiComment(data.comment);
    } catch (err) {
      console.error(err);
      setAiComment(""); // エラー時は空にしておくか、エラーメッセージを入れる
    } finally {
      setAiLoading(false);
    }
  }

  // --- 表示部分 (JSX) ---
  return (
    <div className="page">
      <main className="card">
        <div className="header">
          <div>
            <h1 className="title">感情分析デモ</h1>
            <p className="subtitle">ユーザー: {user?.signInDetails?.loginId || user?.username}</p>
            <p className="subtitle" style={{ marginTop: "4px" }}>API接続: {apiStatusLabel}</p>
          </div>
          <div>
            <button onClick={signOut} className="btn" style={{ backgroundColor: "#4b5563", fontSize: "12px", padding: "8px 12px" }}>ログアウト</button>
          </div>
        </div>

        <form onSubmit={onSubmit} className="form">
          <textarea
                className="textarea"
            value={text}
            onChange={(e) => setText(e.target.value)}
            placeholder="ここに文章を入力してください(最大1000文字)"
          />
          <div className="row" style={{ alignItems: 'center' }}>
            <button 
                type="button" 
                className="btn"
                onClick={isRecording ? stopRecording : startRecording}
                style={{ 
                    backgroundColor: isRecording ? '#dc2626' : '#2563eb',
                    marginRight: 'auto', display: 'flex', alignItems: 'center', gap: '6px', cursor: 'pointer'
                }}
            >
                <span style={{ fontSize: '1.2em' }}>{isRecording ? "" : "🎤"}</span> 
                {isRecording ? "停止する" : "音声入力"}
            </button>
            <small className="counter" style={{ marginRight: '10px' }}>{text.length} / 1000</small>
            <button type="submit" className="btn" disabled={!canSubmit}>
              {loading ? "分析中..." : "分析する"}
            </button>
          </div>
        </form>

        {error && <div className="alert alert--error">{error}</div>}

        {/* 結果表示 */}
        <ResultCard result={result} />

        {/* AiFeedbackコンポ―ネントの配置 */}
        <AiFeedback 
          comment={aiComment} 
          loading={aiLoading} 
          isVisible={!!result || aiLoading} 
        />
        
      </main>
    </div>
  );
}

動作確認

npm run devを行いアプリを稼働させます。
適当に文章内容を入力します。
image.png

Comprehendを使って感情分析をすると共に、AIがコメントをくれるようになりました。
問題なさそうです。
image.png

本番環境にデプロイ

問題なさそうですので、今の状態でAmplifyに反映していきたいと思います。

C:\Users\ohtsu\Documents\AWS\SentimentRepo>git add .
warning: in the working copy of 'src/components/AiFeedback.jsx', LF will be replaced by CRLF the next time Git touches it

C:\Users\ohtsu\Documents\AWS\SentimentRepo>git commit -m "ver1.3" 
[main cb8c32e] ver1.3
 2 files changed, 121 insertions(+), 58 deletions(-)
 create mode 100644 src/components/AiFeedback.jsx

C:\Users\ohtsu\Documents\AWS\SentimentRepo>git push origin main
Enumerating objects: 10, done.
Counting objects: 100% (10/10), done.
Delta compression using up to 20 threads
Compressing objects: 100% (6/6), done.
Writing objects: 100% (6/6), 2.57 KiB | 2.57 MiB/s, done.
Total 6 (delta 3), reused 0 (delta 0), pack-reused 0
remote: Resolving deltas: 100% (3/3), completed with 3 local objects.
To https://github.com/ohtsuka-shota/SentimentRepo.git
   90667ee..cb8c32e  main -> main

デプロイが開始されました。正常終了することを確認します。
image.png
image.png

本番環境でも動作に問題なさそうです。
image.png

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?