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アプリのDynamoDBテーブルをチューニングする

Posted at

これの続きです。

今の状態だと、AIのコメントがDynamoDBに格納されない設計になっています。
また、誰がこの投稿をしたのかもわからない状態です。
これを解消したいと思います。

2026013105.png

環境イメージ

新しいコンポーネントは作成しておりません。
DynamoDBに格納するデータの追加による処理の流れを変えています。

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

処理フロー

こんがらがってきたのでまとめます。

Step 1: 認証と入力 (Frontend)

ユーザーがアプリにログインします(Cognito)。
コメントを入力し、「分析する」ボタンを押すと、React は Cognito から認証トークン(ID Token)を取得し、ヘッダーにセットしてリクエストの準備をします。

Step 2: 感情分析リクエスト (React → Lambda 1)

React が POST/analyze を呼び出します。
API Gateway はトークンを検証し、「誰がアクセスしたか(UserId)」という情報を付与して Lambda (Comprehend側) を起動します。

Step 3: 分析と「枠」の作成 (Lambda 1)

ID取得: Lambda は API Gateway から渡された情報から UserId(Adminなど)を取り出します。
分析: Comprehend にテキストを送り、感情(例: NEGATIVE)を取得します。
時刻生成: 日本時間(JST)で現在時刻(例: 2026-02-01T15:00:00+09:00)を作ります。
保存 (Put): DynamoDB に以下のデータを新規保存します。

UserId: Admin
Timestamp: 2026-02-01T15:00:00+09:00
Text: ...
Sentiment: NEGATIVE
AiComment: (まだ無し)

Step 4: バケツリレー (Frontend)

Lambda1 から React へ、分析結果と一緒に Timestamp (2026-02-01T15:00:00+09:00) が返されます。
React はこれを受け取り、即座に次のリクエストの引数として使用します。

Step 5: AIコメントリクエスト (React → Lambda 2)

React が POST/bedrock を呼び出します。
この時、「さっき保存したデータの時刻 (timestamp)」 を一緒に送信します。

Step 6: 生成と「追記」 (Lambda 2)

ID取得: こちらの Lambda も同様に、トークンから UserId(Admin)を特定します(これで他人によるなりすましを防ぎます)。
生成: Bedrock (Amazon Nova Micro) にテキストと感情を送り、励ましのコメントを生成してもらいます。
更新 (Update): DynamoDB に対し、以下の条件で書き込みに行きます。
「UserId が Admin で、かつ Timestamp が 2026-02-01T15:00:00+09:00 のデータを探して、AiComment 列に今回のコメントを書き込んで!」
これで、先ほど保存したデータにAIコメントが合体します。

Step 7: 完了 (Frontend)

AIコメントが React に返却され、画面に表示されます。

ハンズオン

DynamoDB

プライマリキーを現行の状態から変えるため、いったん既存のものを削除します。
image.png
image.png

削除出来たら同じ名前でDynamoDBのテーブルを作成します。下記の設定以外はデフォルトで作成します。

  • テーブル名: 環境変数に設定したものと同じ名前(例: SentimentResults)
  • パーティションキー: UserId (文字列 / String)
  • ソートキー: Timestamp (文字列 / String)
    screencapture-ap-northeast-1-console-aws-amazon-dynamodbv2-home-2026-02-01-10_21_26.png

作成出来たことを確認します。
image.png

Lambda

Comprehendと連携しているLambda(SentimentLambda)のコードを次のように更新します。

import os
import json
import boto3
from datetime import datetime, timezone, timedelta # ★変更: timedeltaを追加
from decimal import Decimal

# --- クライアント初期化 ---
dynamodb = boto3.resource("dynamodb")
comprehend = boto3.client("comprehend")

# 環境変数 TABLE_NAME (例: SentimentResults)
TABLE_NAME = os.environ.get("TABLE_NAME", "SentimentResults")
table = dynamodb.Table(TABLE_NAME)

def response(status, body):
    def decimal_default(obj):
        if isinstance(obj, Decimal): return float(obj)
        raise TypeError
    return {
        "statusCode": status,
        "headers": {
            "Content-Type": "application/json",
            "Access-Control-Allow-Origin": "*"
        },
        "body": json.dumps(body, default=decimal_default, ensure_ascii=False)
    }

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

    # --- 1. ユーザーID取得 (HTTP API対応) ---
    try:
        auth_data = event.get('requestContext', {}).get('authorizer', {})
        if 'jwt' in auth_data:
            claims = auth_data['jwt']['claims']
        else:
            claims = auth_data.get('claims', {})

        user_id = claims.get('username') or claims.get('cognito:username') or claims.get('sub') or "test_user"
    except Exception as e:
        print(f"Auth extraction error: {e}")
        user_id = "test_user"

    # --- 2. リクエストボディの取得 ---
    try:
        body = json.loads(event.get("body")) if isinstance(event.get("body"), str) else event.get("body")
        text = body.get("text", "").strip()
    except:
        return response(400, {"message": "Invalid JSON"})

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

    # --- 3. Comprehendで感情分析 ---
    try:
        comp_res = comprehend.detect_sentiment(Text=text, LanguageCode="ja")
        sentiment = comp_res["Sentiment"]
        scores = comp_res["SentimentScore"]
    except Exception as e:
        print(f"Comprehend Error: {e}")
        return response(500, {"message": "Failed to analyze sentiment"})

    # --- 4. DynamoDBに保存 (JSTに変更) ---
    
    # ★変更: 日本時間 (UTC+9) のタイムゾーンを定義して時刻生成
    JST = timezone(timedelta(hours=9), 'JST')
    timestamp = datetime.now(JST).isoformat()
    
    item = {
        "UserId": user_id,
        "Timestamp": timestamp, # 例: 2026-02-01T15:00:00+09:00
        "Text": text,
        "Sentiment": sentiment,
        "SentimentScore": {k: Decimal(str(v)) for k, v in scores.items()},
        "Language": "ja"
    }
    
    try:
        table.put_item(Item=item)
    except Exception as e:
        print(f"DynamoDB Put Error: {e}")
        return response(500, {"message": "Failed to save data"})

    # --- 5. 応答 ---
    return response(200, {
        "message": "Diary saved",
        "timestamp": timestamp, # JSTの時間を返す
        "sentiment": sentiment,
        "scores": scores
    })

Bedrockと連携しているLambda(SentimentLambda-AItalk)を以下のように更新します。

import json
import boto3
import os

# Bedrock (us-east-1)
bedrock = boto3.client(service_name='bedrock-runtime', region_name='us-east-1')
# DynamoDB
dynamodb = boto3.resource('dynamodb')

# 環境変数 TABLE_NAME を設定してください (例: SentimentResults)
TABLE_NAME = os.environ.get("TABLE_NAME", "SentimentResults")

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, {})

    # --- 1. ユーザーID取得 (HTTP API / Payload 2.0 対応) ---
    # 保存時と同じIDを使うために、ここでも同じロジックで取得します
    try:
        auth_data = event.get('requestContext', {}).get('authorizer', {})
        
        if 'jwt' in auth_data:
            claims = auth_data['jwt']['claims']
        else:
            claims = auth_data.get('claims', {})

        user_id = claims.get('username') or claims.get('cognito:username') or claims.get('sub') or "test_user"
        
    except Exception as e:
        print(f"Auth extraction error: {e}")
        user_id = "test_user"

    # --- 2. リクエストボディの解析 ---
    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", "不明")
        # ★重要: 前のLambdaから渡されるタイムスタンプを受け取る
        timestamp = body.get("timestamp")
    except:
        return response(400, {"message": "Invalid JSON"})

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

    # --- 3. 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
        }
    })

    ai_comment = ""
    try:
        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()

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

    # --- 4. DynamoDBへの追記処理 ---
    # フロントエンドからタイムスタンプが送られてきた場合のみ実行
    if timestamp:
        try:
            table = dynamodb.Table(TABLE_NAME)
            
            # 該当するデータ(UserIdとTimestampで特定)に、AiCommentを追加
            table.update_item(
                Key={
                    'UserId': user_id,
                    'Timestamp': timestamp
                },
                UpdateExpression="set AiComment = :c",
                ExpressionAttributeValues={
                    ':c': ai_comment
                }
            )
            print(f"DynamoDB updated: {user_id}, {timestamp}")
            
        except Exception as e:
            # エラーログは出すが、ユーザーにはコメントを返してあげる
            print(f"DynamoDB Update Error: {e}")

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

環境変数を以下のように設定を追加します。
これを追加してあげないとどのDynamoDBのテーブルに追記をしていけばLambdaは判断できません。
2026013104.png

また、Lambdaに紐づいているIAM Roleに対してAmazonDynamoDBFullAccess_v2のpolicyも追加します。
これが無いとDynamoDBにデータを書き込みできません。
image.png

React修正

**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. 感情分析 API 呼び出し
      // ここでバックエンドはデータを保存し、timestampを発行して返します
      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コメント取得 API 呼び出し
      // ★修正: Analyzeの結果に含まれる timestamp を引数に追加して渡します
      // これにより、Bedrock側が「どのデータに追記すればよいか」を理解できます
      if (data.timestamp) {
        await fetchAiComment(text.trim(), data.sentiment, data.timestamp, token);
      } else {
        // 万が一タイムスタンプがない場合も、コメント生成自体は動くようにそのまま呼ぶ
        await fetchAiComment(text.trim(), data.sentiment, null, token);
      }

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

  // ★修正: 引数に timestamp を追加
  async function fetchAiComment(inputText, sentimentResult, timestamp, token) {
    setAiLoading(true);
    try {
      const bodyParams = { 
        text: inputText, 
        sentiment: sentimentResult 
      };

      // timestampがある場合のみbodyに追加
      if (timestamp) {
        bodyParams.timestamp = timestamp;
      }

      const res = await fetch(`${API_BASE}/bedrock`, {
        method: "POST",
        headers: { "Content-Type": "application/json", Authorization: token },
        body: JSON.stringify(bodyParams),
      });

      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でアプリケーションを稼働。
Adminユーザでコメントを追加。ComprehendとBedrock連携は問題なさそうです。
image.png

DynamoDBを確認するとユーザと、タイムスタンプ、AIのコメントが格納されていることがわかります。
image.png

ユーザを変えてテストをしてみます。こちらは音声入力を試してみます。
Transcribeとの連携も壊れていないようです。(AIが「何言ってんだコイツは?」みたいな反応を示しています)
image.png

DynamoDBにもデータが格納されています。
image.png

本番へのデプロイ

C:\Users\ohtsu\Documents\AWS\SentimentRepo>git add .

C:\Users\ohtsu\Documents\AWS\SentimentRepo>git commit -m "ver1.4" 
[main 0ce9ebd] ver1.4
 1 file changed, 27 insertions(+), 7 deletions(-)

C:\Users\ohtsu\Documents\AWS\SentimentRepo>git push origin main   
Enumerating objects: 9, done.
Counting objects: 100% (9/9), done.
Delta compression using up to 20 threads
Compressing objects: 100% (5/5), done.
Writing objects: 100% (5/5), 962 bytes | 962.00 KiB/s, done.
Total 5 (delta 4), reused 0 (delta 0), pack-reused 0
remote: Resolving deltas: 100% (4/4), completed with 4 local objects.
To https://github.com/ohtsuka-shota/SentimentRepo.git
   cb8c32e..0ce9ebd  main -> main

デプロイも問題なさそうです。
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?