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?

サーバレスでここまでできる!CloudFront + S3 + Lambda でWeb公開

Posted at

はじめに

記事の目的

サーバレスで外部設計書をレビューできるWebサイトをホスティングした構成を作成したので紹介と自分のメモがてら記事を作成しました。
サービスとしてCloudFront + S3 + Lambda + APIGatewayを使用したので4つのサービスの役割と連携を整理し、作成手順を記載しています。

構成図

アーキテクチャ図

画像1.png

構成説明

・S3にWebサイトとなるHTML、css、jsスクリプトを格納してCloudfrontにてWebサイトをキャッシュすることで公開します
・フロント側から直接レビュー関数を動かすのではなく、プロンプトの保存をトリガーとして起動するようになっています。(苦戦した点にて解説)
・フロントからプロンプトを保存するための関数を動かすにはAPIGatewayを使用しました。

使用サービスと役割

Cloudfront

・Webサイトのホスティングのため
・オリジンにHTML等が入っているバケットを指定することでホスティング可能

S3

・HTML、css、js、プロンプトの保存先、レビュー結果(json形式)の保存先として利用

ai-document-review-s3-bucket:レビュー結果以外の保存先

画像2.png

・prompt/:Lambdaからユーザが入力したプロンプト、ドキュメント内容が記載されたjson形式が保存される
・reviewPoints.json:レビュー観点がjson形式で記載されたもの

{
  "review_type": "screen_design_external_review",
  "sections": [
    {
      "id_large": "1",
      "title_large": "画面項目、レイアウトの妥当性",
      "categories_large": [
        {
          "id_medium": "1",
          "title_medium": "画面構成、配置",
          "categories_medium": [
            { "id_small": "1-1-1", "title_small": "画面ID/画面名が一意に定義されているか" },
            { "id_small": "1-1-2", "title_small": "画面の目的、用途が明確に記載されているか" },
            { "id_small": "1-1-3", "title_small": "項目の配置、並び順が業務フローに沿っているか" },
            { "id_small": "1-1-4", "title_small": "項目のグルーピングが論理的か" },
            { "id_small": "1-1-5", "title_small": "必須/任意項目が視覚的に判別できるか" },
            { "id_small": "1-1-6", "title_small": "スクロール有無、範囲が明確か" },
            { "id_small": "1-1-7", "title_small": "重要情報が初期表示領域に収まっているか" },
            { "id_small": "1-1-8", "title_small": "モーダル/別画面表示の使い分けが適切か" }
          ]
        },
        {
          "id_medium": "2",
          "title_medium": "各UI部品の仕様記載",
          "categories_medium": [
            { "id_small": "1-2-1", "title_small": "項目ID(論理名、物理名)が定義されているか" },
            { "id_small": "1-2-2", "title_small": "UI部品種別(テキスト、チェックボックス等)が明確か" },
            { "id_small": "1-2-3", "title_small": "表示/非表示条件が定義されているか" },
            { "id_small": "1-2-4", "title_small": "活性/非活性条件が定義されているか" },
            { "id_small": "1-2-5", "title_small": "表示形式(全角/半角、日付形式等)が明確か" },
            { "id_small": "1-2-6", "title_small": "単位表示(円、%等)の有無が定義されているか" },
            { "id_small": "1-2-7", "title_small": "ツールチップ、補足説明の有無が整理されているか" }
          ]
        },
        {
          "id_medium": "3",
          "title_medium": "テキストボックス、入力項目の詳細",
          "categories_medium": [
            { "id_small": "1-3-1", "title_small": "表示サイズ(見た目の桁数)が定義されているか" },
            { "id_small": "1-3-2", "title_small": "最大文字数、最小文字数が定義されているか" },
            { "id_small": "1-3-3", "title_small": "初期値(固定値/空白/引継ぎ)が明記されているか" },
            { "id_small": "1-3-4", "title_small": "入力可能文字種が定義されているか" },
            { "id_small": "1-3-5", "title_small": "IME制御(ON/OFF/英数)が必要に応じて定義されているか" },
            { "id_small": "1-3-6", "title_small": "禁止文字、トリム有無が明確か" }
          ]
        },
        {
          "id_medium": "4",
          "title_medium": "表示文言、ラベル設計",
          "categories_medium": [
            { "id_small": "1-4-1", "title_small": "項目名、ラベルが業務用語として適切か" },
            { "id_small": "1-4-2", "title_small": "画面間で用語、表記が統一されているか" },
            { "id_small": "1-4-3", "title_small": "略語、専門用語に補足が必要か" },
            { "id_small": "1-4-4", "title_small": "文字数超過時の表示方法が定義されているか" },
            { "id_small": "1-4-5", "title_small": "多言語対応有無(将来含む)が考慮されているか" }
          ]
        },
        {
          "id_medium": "5",
          "title_medium": "一覧、表(グリッド)表示の仕様",
          "categories_medium": [
            { "id_small": "1-5-1", "title_small": "列名、表示順、表示幅が定義されているか" },
            { "id_small": "1-5-2", "title_small": "ソート可否、初期ソート条件が明確か" },
            { "id_small": "1-5-3", "title_small": "ページング有無、件数が定義されているか" },
            { "id_small": "1-5-4", "title_small": "行選択時の挙動が定義されているか" },
            { "id_small": "1-5-5", "title_small": "行強調表示、状態別表示ルールがあるか" },
            { "id_small": "1-5-6", "title_small": "データなし時の表示文言が定義されているか" }
          ]
        },
        {
          "id_medium": "6",
          "title_medium": "表示制御、デフォルト表示",
          "categories_medium": [
            { "id_small": "1-6-1", "title_small": "初期表示状態が明確か" },
            { "id_small": "1-6-2", "title_small": "検索条件未指定時の挙動が定義されているか" },
            { "id_small": "1-6-3", "title_small": "表示件数上限、制限時の扱いが定義されているか" },
            { "id_small": "1-6-4", "title_small": "前回条件保持などのユーザー設定保存有無" }
          ]
        },
        {
          "id_medium": "7",
          "title_medium": "レスポンシブ、表示環境差異への対応",
          "categories_medium": [
            { "id_small": "1-7-1", "title_small": "画面サイズ、解像度差による表示崩れの考慮" },
            { "id_small": "1-7-2", "title_small": "ブラウザ差異による影響の考慮" },
            { "id_small": "1-7-3", "title_small": "拡大縮小時の可読性" }
          ]
        }
      ]
    },
    {
      "id_large": "2",
      "title_large": "入力制御、業務ルールの妥当性",
      "categories_large": [
        {
          "id_medium": "1",
          "title_medium": "入力チェック仕様",
          "categories_medium": [
            { "id_small": "2-1-1", "title_small": "必須チェック条件が明確か" },
            { "id_small": "2-1-2", "title_small": "桁数、形式チェックが定義されているか" },
            { "id_small": "2-1-3", "title_small": "相関チェックが整理されているか" },
            { "id_small": "2-1-4", "title_small": "チェックタイミングが明確か" },
            { "id_small": "2-1-5", "title_small": "チェック順序が整理されているか" },
            { "id_small": "2-1-6", "title_small": "警告とエラーの区別があるか" }
          ]
        },
        {
          "id_medium": "2",
          "title_medium": "エラーメッセージ設計",
          "categories_medium": [
            { "id_small": "2-2-1", "title_small": "エラー条件とメッセージが対応付けられているか" },
            { "id_small": "2-2-2", "title_small": "利用者に理解しやすい文言か" },
            { "id_small": "2-2-3", "title_small": "表示位置が定義されているか" },
            { "id_small": "2-2-4", "title_small": "複数エラー時の表示ルールが明確か" },
            { "id_small": "2-2-5", "title_small": "エラーコード、問い合わせ用情報があるか" }
          ]
        },
        {
          "id_medium": "3",
          "title_medium": "業務ルール、制約条件",
          "categories_medium": [
            { "id_small": "2-3-1", "title_small": "業務上の入力制約が明示されているか" },
            { "id_small": "2-3-2", "title_small": "権限、ロールによる制御が整理されているか" },
            { "id_small": "2-3-3", "title_small": "状態遷移による制御が定義されているか" },
            { "id_small": "2-3-4", "title_small": "例外業務、特例処理が明記されているか" },
            { "id_small": "2-3-5", "title_small": "法令、社内規程に基づく制約が考慮されているか" }
          ]
        },
        {
          "id_medium": "4",
          "title_medium": "マスタ、外部データ依存項目",
          "categories_medium": [
            { "id_small": "2-4-1", "title_small": "参照マスタ、データ取得元が明確か" },
            { "id_small": "2-4-2", "title_small": "表示順、表示内容が定義されているか" },
            { "id_small": "2-4-3", "title_small": "無効データ、未存在時の扱いが定義されているか" },
            { "id_small": "2-4-4", "title_small": "マスタ変更時の反映タイミングが明確か" },
            { "id_small": "2-4-5", "title_small": "参照条件が明記されているか(完全一致、部分一致、前方一致など)" }
          ]
        },
        {
          "id_medium": "5",
          "title_medium": "自動補完、自動計算項目",
          "categories_medium": [
            { "id_small": "2-5-1", "title_small": "自動設定、計算条件が明確か" },
            { "id_small": "2-5-2", "title_small": "手入力可否が定義されているか" },
            { "id_small": "2-5-3", "title_small": "再計算トリガーが明確か" },
            { "id_small": "2-5-4", "title_small": "丸め処理、計算精度が定義されているか" }
          ]
        },
        {
          "id_medium": "6",
          "title_medium": "データ整合性、排他制御",
          "categories_medium": [
            { "id_small": "2-6-1", "title_small": "排他制御方式が定義されているか" },
            { "id_small": "2-6-2", "title_small": "更新競合時の挙動、メッセージが明確か" },
            { "id_small": "2-6-3", "title_small": "排他発生後の再編集可否が定義されているか" }
          ]
        },
        {
          "id_medium": "7",
          "title_medium": "監査、証跡(オーディット)観点",
          "categories_medium": [
            { "id_small": "7-1", "title_small": "変更前後の値の保持要否" },
            { "id_small": "7-2", "title_small": "操作ログ取得対象の整理" },
            { "id_small": "7-3", "title_small": "監査要件との整合性" }
          ]
        }
      ]
    },
    {
      "id_large": "3",
      "title_large": "操作性、遷移、非機能観点の妥当性",
      "categories_large": [
        {
          "id_medium": "1",
          "title_medium": "ボタン、操作仕様",
          "categories_medium": [
            { "id_small": "3-1-1", "title_small": "各ボタンの役割、処理内容が明確か" },
            { "id_small": "3-1-2", "title_small": "二重送信防止が考慮されているか" },
            { "id_small": "3-1-3", "title_small": "確認ダイアログ有無、文言が定義されているか" },
            { "id_small": "3-1-4", "title_small": "権限による表示/非表示制御があるか" },
            { "id_small": "3-1-5", "title_small": "危険操作の配置、強調が適切か" }
          ]
        },
        {
          "id_medium": "2",
          "title_medium": "画面遷移、連携",
          "categories_medium": [
            { "id_small": "3-2-1", "title_small": "遷移元、遷移先が整理されているか" },
            { "id_small": "3-2-2", "title_small": "遷移時の引継ぎ項目が明確か" },
            { "id_small": "3-2-3", "title_small": "戻る操作時の状態保持が定義されているか" },
            { "id_small": "3-2-4", "title_small": "URL直接アクセス時の挙動が定義されているか" },
            { "id_small": "3-2-5", "title_small": "セッション切れ時の遷移が考慮されているか" }
          ]
        },
        {
          "id_medium": "3",
          "title_medium": "非機能、共通観点",
          "categories_medium": [
            { "id_small": "3-3-1", "title_small": "想定レスポンス時間が考慮されているか" },
            { "id_small": "3-3-2", "title_small": "ローディング表示等のユーザー通知があるか" },
            { "id_small": "3-3-3", "title_small": "利用ブラウザ、端末条件が明記されているか" },
            { "id_small": "3-3-4", "title_small": "ログ出力対象操作が整理されているか" }
          ]
        },
        {
          "id_medium": "4",
          "title_medium": "キーボード操作、フォーカス制御",
          "categories_medium": [
            { "id_small": "3-4-1", "title_small": "初期フォーカス位置が定義されているか" },
            { "id_small": "3-4-2", "title_small": "Tab順が業務フローに沿っているか" },
            { "id_small": "3-4-3", "title_small": "Enterキー動作が定義されているか" },
            { "id_small": "3-4-4", "title_small": "ショートカットキー有無が整理されているか" }
          ]
        },
        {
          "id_medium": "5",
          "title_medium": "再利用、共通化観点",
          "categories_medium": [
            { "id_small": "3-5-1", "title_small": "共通部品が他画面と統一されているか" },
            { "id_small": "3-5-2", "title_small": "既存画面との差分が明確か" },
            { "id_small": "3-5-3", "title_small": "将来的な拡張性が考慮されているか" }
          ]
        },
        {
          "id_medium": "6",
          "title_medium": "運用、保守観点",
          "categories_medium": [
            { "id_small": "3-6-1", "title_small": "誤操作、誤登録時のリカバリ手段があるか" },
            { "id_small": "3-6-2", "title_small": "運用調査に必要な情報が取得可能か" },
            { "id_small": "3-6-3", "title_small": "問い合わせ対応に必要な画面識別情報があるか" }
          ]
        },
        {
          "id_medium": "7",
          "title_medium": "例外、異常系UX/セキュリティ観点",
          "categories_medium": [
            { "id_small": "3-7-1", "title_small": "通信エラー、タイムアウト時の画面挙動" },
            { "id_small": "3-7-2", "title_small": "入力内容保持、再試行可否" },
            { "id_small": "3-7-3", "title_small": "画面単位の認可制御" },
            { "id_small": "3-7-4", "title_small": "URL直叩き、不正操作時の制御" }
          ]
        }
      ]
    }
  ]
}

・index.html:HTML

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8" />
  <title>ドキュメントレビュー</title>
  <link rel="stylesheet" href="style.css" />
</head>
<body>

  <h1>📄 ドキュメントレビュー</h1>

<!-- ファイルアップロード -->
<label>ローカルファイルをアップロード</label>

<div id="dropZone" class="drop-zone">
  ここにファイルをドラッグ<br>
  またはクリックして選択
</div>

<input
  type="file"
  id="fileInput"
  style="display:none"
  accept=".pdf,.docx,.txt,.md"
/>

<p id="selectedFile"></p>



  <!-- プロンプト入力 -->
  <label for="promptInput">レビュー指示(プロンプト)</label>
  <textarea
    id="promptInput"
    rows="5"
    placeholder="例:設計の妥当性、抜け漏れ、改善点をレビューしてください"
  ></textarea>

  <button id="reviewBtn">レビュー実行</button>

  <h2>レビュー結果</h2>
  <button id="copyBtn">📋 結果をコピー</button>
  <pre id="result"></pre>

  <script src="script.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
</body>
</html>

・style.css:css

body {
  font-family: Arial, sans-serif;
  margin: 40px;
}

label {
  display: block;
  margin-top: 15px;
  font-weight: bold;
}

select,
textarea {
  width: 100%;
  margin-top: 5px;
  padding: 8px;
}

button {
  margin-top: 20px;
  padding: 8px 16px;
  font-size: 14px;
  cursor: pointer;
}

pre {
  margin-top: 20px;
  background: #f5f5f5;
  padding: 15px;
  white-space: pre-wrap;
}

#result {
  max-height: 60vh;           /* 画面の6割まで表示 */
  overflow-y: auto;           /* 縦スクロール */
  padding: 16px;
  background-color: #f7f7f7;
  border: 1px solid #ccc;
  border-radius: 6px;

  white-space: pre-wrap;      /* ← 改行保持+自動折り返し */
  word-break: break-word;     /* 長い文字列でも折り返す */

  font-family: Consolas, Menlo, Monaco, monospace;
  font-size: 14px;
  line-height: 1.6;

  user-select: text;          /* ← 確実に選択可能 */
}

.drop-zone {
  border: 2px dashed #999;
  border-radius: 8px;
  padding: 24px;
  text-align: center;
  cursor: pointer;
  background: #fafafa;
}

.drop-zone.dragover {
  background: #eef;
  border-color: #3366ff;
}

#selectedFile {
  margin-top: 8px;
  font-size: 0.9em;
}

#result h3,
#result p,
#result ul,
#result li,
#result blockquote {
  margin: 0.2em 0;
  padding: 0;
}

#result ul {
  padding-left: 1.2em;
}

#result blockquote {
  border-left: 3px solid #ddd;
  padding-left: 0.6em;
}

・script.js:フロント側での動きのみ記載したjsスクリプト

const API_BASE = "ai-document-reviewのAPI";

const dropZone = document.getElementById("dropZone");
const fileInput = document.getElementById("fileInput");
const selectedFileEl = document.getElementById("selectedFile");

let uploadedFile = null;

// クリックで file input
dropZone.addEventListener("click", () => {
  fileInput.click();
});

// ファイル選択
fileInput.addEventListener("change", (e) => {
  setFile(e.target.files[0]);
});

// drag & drop
dropZone.addEventListener("dragover", (e) => {
  e.preventDefault();
  dropZone.classList.add("dragover");
});

dropZone.addEventListener("dragleave", () => {
  dropZone.classList.remove("dragover");
});

dropZone.addEventListener("drop", (e) => {
  e.preventDefault();
  dropZone.classList.remove("dragover");
  setFile(e.dataTransfer.files[0]);
});

function setFile(file) {
  if (!file) return;

  uploadedFile = file;
  selectedFileEl.textContent = `選択中: ${file.name}`;

  // S3選択を無効化(どちらか一方)
  //document.getElementById("docSelect").disabled = true;
  const reader = new FileReader();
  reader.onload = () => {
    documentPath = reader.result; // ← Markdownそのまま  //uploadedFileContent
  };

  reader.readAsText(file);
}

document.getElementById("reviewBtn").addEventListener("click", async () => {
  //const documentPath = document.getElementById("fileInput").files[0];
  const userPrompt = document.getElementById("promptInput").value;
  const result = document.getElementById("result");

  const requestId = generateRequestId();
  result.textContent = "レビュー依頼を送信しました...";

  try {
    // =========================
    // ① LambdaA を呼び出す
    // =========================
    const submitRes = await fetch(`${API_BASE}/`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        requestId,
        documentPath,
        userPrompt
      })
    });

    if (!submitRes.ok) {
      result.textContent = "レビュー依頼の送信に失敗しました";
      return;
    }

    // =========================
    // ② ポーリング制御用
    // =========================
    let retryCount = 0;
    const MAX_RETRY = 15;      // 最大15回
    const INTERVAL = 25_000;      // 25秒間隔

    const pollResult = async () => {
      if (retryCount >= MAX_RETRY) {
        result.textContent = "レビューが時間内に完了しませんでした(タイムアウト)";
        return;
      }

      retryCount++;

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

        if (res.status === 200) {
          // ✅ 結果あり
          const json = await res.json();
          const { signedUrl } = json;
          
          if (!signedUrl) {
            throw new Error("signedUrl is missing in response");
          }

          const reviewRes = await fetch(signedUrl);
          const data = await reviewRes.json();
          const { review } = data;

          if (typeof review === "string") {
            // ```json``` を含む文字列
            const blocks = extractJsonBlocks(review);

            for (const block of blocks) {
              const json = JSON.parse(block);
              const reviewMD = convertReviewJsonToMarkdown(json);
              const reviewHTML = marked.parse(reviewMD);
              result.innerHTML += "<hr>" + reviewHTML;
              //result.textContent += "\n\n---\n\n" + reviewMD;
            }
          } else {
            // 純粋な JSON オブジェクト
            const reviewMD = convertReviewJsonToMarkdown(review);
            const reviewHTML = marked.parse(reviewMD);
            result.innerHTML = reviewHTML;
            //result.textContent = reviewMD;
          }



        } else if (res.status === 404) {
          // 🔄 まだ生成中
          setTimeout(pollResult, INTERVAL);

        } else {
          result.textContent = "レビュー取得に失敗しました";
        }

      } catch (err) {
        console.error(err);
        // 通信エラー時も再試行
        setTimeout(pollResult, INTERVAL);
      }
    };

    // ポーリング開始
    pollResult();

  } catch (e) {
    console.error(e);
    result.textContent = "エラーが発生しました";
  }
});


function generateRequestId() {
  const now = new Date();

  const pad = (n) => n.toString().padStart(2, "0");

  const year = now.getFullYear();
  const month = pad(now.getMonth() + 1); // 0始まりなので +1
  const day = pad(now.getDate());
  const hour = pad(now.getHours());
  const minute = pad(now.getMinutes());
  const second = pad(now.getSeconds());

  return `${year}-${month}-${day}-${hour}${minute}${second}`;
}

function convertReviewJsonToMarkdown(data) {
  let md = "";

  // ===== 大分類 =====
  md += `# ${data.title_large}\n\n`;

  data.categories_large.forEach((large) => {
    // ===== 中分類 =====
    md += `## ${large.title_medium}\n\n`;

    large.categories_medium.forEach((item) => {
      md += `### ${item.id_small} ${item.title_small}\n`;
      md += `- **結果**: ${item.status}\n`;
      md += `- **該当箇所**: ${item.location}\n`;

      if (item.quote && item.quote !== "該当する記載がありません") {
        md += `> ${item.quote.replace(/\n/g, "\n> ")}\n`;
      } else {
        md += `> 該当する記載がありません\n`;
      }

      md += `**レビューコメント**  \n`;
      md += `${item.comment}\n\n`;
    });
  });

  return md;
}

function extractJsonBlocks(text) {
  if (!text) return [];

  const regex = /```json\s*([\s\S]*?)\s*```/g;
  const results = [];

  let match;
  while ((match = regex.exec(text)) !== null) {
    results.push(match[1]);
  }

  return results;
}

ai-document-review-record:レビュー結果の保存先

画像3.png

・results/:json形式で記載されたレビュー結果が保存される

Lambda

ai-document-prompt-put:プロンプト保存用の関数

import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import crypto from "crypto";

const BUCKET = "ai-document-review-s3-bucket";
const PREFIX = "prompt/";

const s3 = new S3Client({ region: "us-east-1"});


export const promptHandler = async (event) => {
  console.log("event:", JSON.stringify(event));

  // ===== OPTIONS (CORS preflight) =====
  if (event.httpMethod === "OPTIONS") {
    return {
      statusCode: 200,
      headers: corsHeaders(),
      body: ""
    };
  }

  try {
    if (!event.body) {
      return response(400, { error: "body is missing" });
    }

    const body = typeof event.body === "string" ? JSON.parse(event.body) : event.body;

    const { requestId, documentPath, userPrompt } = body;

    if (!requestId) {
      return response(400, { error: "requestId is required" });
    }

    if (!documentPath) {
      return response(400, { error: "documentPath is required" });
    }

    const s3Key = `${PREFIX}${requestId}.json`;

    await s3.send(
      new PutObjectCommand({
        Bucket: BUCKET,
        Key: s3Key,
        Body: JSON.stringify({
          requestId,
          documentPath,
          userPrompt,
          requestedAt: new Date().toISOString()
        }),
        ContentType: "application/json"
      })
    );
    

    return response(202, {
      requestId,
      message: "レビュー受付完了"
    });

  } catch (err) {
    console.error("Lambda Error:", err);

    return response(500, {
      error: "Internal Server Error",
      detail: err.message
    });
  }
};

// ===== helpers =====

const response = (statusCode, body) => ({
  statusCode,
  headers: corsHeaders(),
  body: JSON.stringify(body)
});

const corsHeaders = () => ({
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Headers": "Content-Type",
  "Access-Control-Allow-Methods": "POST,GET,OPTIONS"
});

ai-document-review:レビュー用の関数

import { S3Client, GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
import { BedrockRuntimeClient, InvokeModelCommand } from "@aws-sdk/client-bedrock-runtime";
import {BedrockAgentRuntimeClient, RetrieveCommand } from "@aws-sdk/client-bedrock-agent-runtime";

const s3 = new S3Client({ region: "us-east-1" });
const bedrock = new BedrockRuntimeClient({ region: "us-east-1" });
const kbClient = new BedrockAgentRuntimeClient({ region: "us-east-1" });

const BUCKET_DOCUMENT = "ai-document-review-s3-bucket";
const BUCKET_REVIEW = "ai-document-review-record";
const PREFIX = "prompt/";


export const reviewHandler = async (event) => {
  try {
    // S3 イベントからバケットとキーを取得
    const record = event.Records[0];
    const bucket = record.s3.bucket.name;
    const key = decodeURIComponent(record.s3.object.key.replace(/\+/g, " "));
    const key_reviewPoints = "reviewPoints.json";

    // requestId をキーから生成(例: prompts/YYYY-MM-dd-HHMMSS.json → YYYY-MM-dd-HHMMSS)
    const fileName = key.split("/").pop();
    const requestId = fileName.replace(".json", "");

    // ① プロンプトファイルを取得
    const obj = await s3.send(new GetObjectCommand({
      Bucket: bucket,
      Key: key
    }));
    const promptData = await streamToString(obj.Body);
    const { documentPath, userPrompt } = JSON.parse(promptData);

    //レビュー観点を取得
    const obj_reviewPoints = await s3.send(new GetObjectCommand({
      Bucket: BUCKET_DOCUMENT,
      Key: key_reviewPoints
    }));
    const reviewPointsJson = await streamToString(obj_reviewPoints.Body);

    // ② ドキュメント取得
    //const docObj = await s3.send(new GetObjectCommand({
    //  Bucket: BUCKET_DOCUMENT,
    //  Key: documentPath
    //}));
    //const documentText = await streamToString(docObj.Body);

    // ③ Bedrock 用プロンプト
    const prompt = `
以下はレビュー対象のドキュメントと、レビュー観点です。
レビュー観点は「大項目 → 中項目 → 小項目」の3階層構造で構成されています。
レビュー観点で用いられている変数の説明は次の通りです。
- id_ladge:大項目のレビュー観点ID
- title_large:大項目のレビュー観点内容
- categories_large:大項目が包括している中項目の一覧
- id_middle:中項目のレビュー観点ID
- title_middle:中項目のレビュー観点内容
- categories_medium:中項目が包括している小項目の一覧
- id_small:小項目のレビュー観点ID
- title_small:小項目のレビュー観点内容

【レビュー観点】
${reviewPointsJson || "設計全体をレビューしてください"}

【レビュー対象ドキュメント】
${documentPath}
`;

    const system = `
    あなたは業務システム開発における
    「外部設計書レビューの実施担当者」です。
    
    本プロンプトはテンプレートとして使用されます。
    そのため、何度実行しても以下を満たしてください。
    - 出力される項目
    - 出力順序
    - 出力構成
    は常に完全に同一でなければなりません。
    
    以下のルールを厳守してください。
    
    - レビュー観点はユーザーが提示したもののみを使用する
    - 観点の追加・削除・名称変更は禁止
    - 観点の順序変更は禁止
    - 一般的観点・業界標準観点・暗黙知の使用は禁止
    - 入力に存在しない情報を用いた推測レビューは禁止
    - 指定された出力形式以外での出力は禁止
    - 思考過程・理由説明の出力は禁止
    - ユーザーの指示に含まれない内容の出力は禁止
    `;

    const reset= `
    これは外部設計書である新しいドキュメントのレビューリクエストです。
    過去の会話履歴や以前にレビューしたドキュメント内容は一切参照せず、以下に提示される新しいドキュメントのみを対象としてレビューを実行してください。 
    【コンテキストリセット指示】 
    - 以前の会話やドキュメント内容は完全に無視してください。
    - このリクエストで提供されるドキュメント内容のみを分析対象としてください ・過去のレビュー結果や分析内容を引用や参照しないでください。
    - このドキュメント固有の内容のみに基づいて新しいレビューを実行してください。
    `;

    const carefulPoint=`
    該当箇所は、元のドキュメントから実際の文章を正確に引用してください。短い引用ではなく、問題が明確にわかる十分な長さの文章を引用してください。 
    - 該当箇所には、可能な限り位置情報(章番号、節番号、段落番号、行番号など)を含めてください。 
    - 改善案は設計書レベルの修正案に留め、実装コードの提案は行わないでください。 
    - 必要に応じて、設計書に追記すべき記載例(文章例)は記載しても構いません。 
    - 観点毎に見出しを区切り、指摘事項を観点毎に記載してください。 
    - 文章はです・ます調で記載してください。 
    - 提示した観点以外でレビューを行わないでください。
    - 一つの小項目の観点につき、指摘件数は0件から最大20件までとする。この際、無理に最大件数まで指摘を行う必要はない。 
    - 出力は提示した観点の順序を厳守してください。 
    - 指摘が存在しない観点は出力しないでください。
    `;
    
    const outputForm=`
                {
  "output_schema": {
    "id_ladge": "string",
    "title_large": "string",
    "categories_large": [
      {
        "id_middle": "string",
        "title_middle": "string",
        "categories_medium": [
          {
            "id_small": "string",
            "title_small": "string",
            "status": "OK | NG | 記載なし",
            "location": "string",
            "quote": "string",
            "comment": "string"
          }
        ]
      }
    ]
  }
}

output_schema はレビュー結果の出力形式を定義したものです。
- レビューは「大項目 → 中項目 → 小項目」の3階層構造で出力してください
- 大項目・中項目は構造を維持し、内容は変更しないでください
- 実際のレビュー判断は categories_medium(小項目)に対してのみ行ってください
- status は必ず「OK」「NG」「記載なし」のいずれかを設定してください
- location / quote / comment は、status の根拠が分かるよう具体的に記載してください
- 出力は output_schema と同一構造の JSON のみとしてください
output_schemで用いられている変数の説明は次の通りです。
- status:レビューの状態、「記載なし」はレビュー観点に沿う内容が存在しない場合に使用してください。
- location:設計書からのレビュー該当箇所、章や項の番号および名前で記載してください。
- quote:設計書からのレビュー箇所の引用部分、元のドキュメントから正確にコピーしてください。
- comment:指摘内容
`;
    
    // ④ Bedrock 呼び出し
    const command = new InvokeModelCommand({
      modelId: "arn:aws:bedrock:us-east-1:779940174379:inference-profile/global.anthropic.claude-haiku-4-5-20251001-v1:0",
      contentType: "application/json",
      accept: "application/json",
      body: JSON.stringify({
        anthropic_version: "bedrock-2023-05-31",
    
        system: `${system}`,
    
        max_tokens: 30000,
        temperature: 0.05,
    
        messages: [
          {
            role: "user",
            content: [
              { type: "text",
                text: `${reset}`
              }
            ]      
          },
          {
            role: "user",
            content: [
              { type: "text", 
                text: `${carefulPoint}`
              }
            ]
          },
          {
            role: "user",
            content: [
              { type: "text", 
                text: `${outputForm}`
              }
            ]
          },
          {
            role: "user",
            content: [
              { type: "text", text: prompt }
            ]
          }
        ]
      })
    });


    const response = await bedrock.send(command);
    const resultText = JSON.parse(new TextDecoder().decode(response.body))?.content?.[0]?.text
      ?? "レビュー結果が取得できませんでした";

    // ⑤ レビュー結果を S3 に保存
    const reviewRecord = {
      timestamp: new Date().toISOString(),
      requestId,
      documentPath,
      userPrompt:userPrompt,
      reset:reset,
      carefulPoint:carefulPoint,
      outputForm:outputForm,
      prompt:prompt,
      model: "claude-haiku-4-5",
      parameters: { max_tokens: 32000, temperature: 0.05 },
      review: resultText
    };

    const reviewKey = `results/${requestId}.json`;
    await s3.send(new PutObjectCommand({
      Bucket: BUCKET_REVIEW,
      Key: reviewKey,
      Body: JSON.stringify(reviewRecord, null, 2),
      ContentType: "application/json; charset=utf-8"
    }));

    console.log(`レビュー完了: ${reviewKey}`);

    return {
      status: "success",
      requestId,
      reviewS3Key: reviewKey
    };

  } catch (err) {
    console.error("LambdaB error:", err);
    // S3トリガーの場合は HTTP レスポンス不要なので throw で Lambda が失敗扱いになる
    throw err;
  }
};

// S3ストリーム → 文字列
const streamToString = async (stream) =>
  new Promise((resolve, reject) => {
    const chunks = [];
    stream.on("data", chunk => chunks.push(chunk));
    stream.on("error", reject);
    stream.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
  });

s3-URL-issuance:s3の署名付きURL発行用の関数

import { S3Client, HeadObjectCommand, GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

const s3 = new S3Client({ region: "us-east-1" });
const BUCKET_REVIEW = "ai-document-review-record";

export const handler = async (event) => {
  console.log("event:", JSON.stringify(event, null, 2));

  // ===== CORS preflight =====
  if (event.httpMethod === "OPTIONS") {
    return {
      statusCode: 200,
      headers: corsHeaders(),
      body: ""
    };
  }

  // ===== body 防御 =====
  if (!event.body) {
    return response(400, { error: "body is missing" });
  }

  let body;
  try {
    body = typeof event.body === "string"
      ? JSON.parse(event.body)
      : event.body;
  } catch (e) {
    return response(400, { error: "invalid JSON body" });
  }

  const { requestId } = body;

  if (!requestId) {
    return response(400, { error: "requestId is required" });
  }

  const key = `results/${requestId}.json`;

  try {
    // ✅ ファイル存在確認
    await s3.send(new HeadObjectCommand({
      Bucket: BUCKET_REVIEW,
      Key: key
    }));

    // ✅ 存在する → 署名付きURL発行
    const signedUrl = await getSignedUrl(
      s3,
      new GetObjectCommand({
        Bucket: BUCKET_REVIEW,
        Key: key
      }),
      { expiresIn: 60 } // 60秒
    );

    return {
      statusCode: 200,
      headers: corsHeaders(),
      body: JSON.stringify({ signedUrl })
    };

  } catch (err) {
    if (err.name === "NotFound") {
      // ❗まだ存在しない → ポーリング継続
      return { statusCode: 404 };
    }

    console.error(err);
    return { statusCode: 500 };
  }
};

const response = (statusCode, body) => ({
  statusCode,
  headers: corsHeaders(),
  body: JSON.stringify(body)
});

const corsHeaders = () => ({
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Headers": "Content-Type",
  "Access-Control-Allow-Methods": "POST,OPTIONS"
});

作成手順

1.Lambda関数の作成

1-1 Lambdaのコンソール画面(関数)から関数の作成を選択

画像4.png

1-2 下記の画像の通りに3つのLambda関数を作成(それぞれの関数名を入力し、関数を作成を選択)

画像5.png

1-3 Lambdaのコンソール(関数)からそれぞれの関数名をクリックし、コードを入力しDeployを選択

画像7.png

1-4 それぞれの関数のIAMロールにポリシーを追加

設定>アクセス権限>ロール名をクリック
画像8.png
関数それぞれに以下のポリシーをアタッチ
・ai-document-prompt-put:AmazonS3FullAccess
・ai-document-review:AmazonBedrockFullAccess、AmazonS3FullAccess、AmazonS3ReadOnlyAccess
・s3-URL-issuance:AmazonS3FullAccess

2.S3の作成とファイル配置

2-1 S3のコンソール画面からバケットを作成を選択

画像9.png

2-2 S3バケットを作成する

以下の設定の状態で、バケット名にそれぞれ名前(ai-document-review-s3-bucket 、ai-document-review-record)を入力し、バケットを作成を選択
画像10.png
画像11.png

2-3 S3バケット内にフォルダを作成

・ai-document-review-s3-bucket:prompt
・ai-document-review-record:results

2-4 ファイルをアップロード

ai-document-review-s3-bucketにファイル4つ(index.html、style.css、script.js、reviewPoints.json)をアップロード

3.Cloudfrontを作成

3-1 Cloudfronのコンソール画面からディストリビューションを作成を選択

画像12.png

3-2 Freeプランを選択

Freeプランを選択し、Nextを選択
画像13.png

3-3 ディストリビューションの名前を決定

Distribution nameにai-document-review-distributionと入力し、Nextを選択
画像14.png

3-4 オリジンを設定

Origin typeでAmazonS3を選択し、BrowseS3からai-document-review-s3-bucket を選択。それ以外は初期設定のまま、Nextを選択
画像16.png

3-5 ディストリビューション作成

Step4は初期設定のままNextを選択。
Step5に関しても初期設定のままCreate distributionを選択
画像17.png

4.APIGatewayを作成

4-1 APIコンソールに移動

APIコンソールからAPIを作成を選択
画像18.png

4-2 RestAPIを作成

RestAPIの構築を選択
画像19.png

4-3 API名を決定

API名にai-document-review-restapiを入力し、他は初期設定のままAPIを作成を選択
画像20.png

4-4 リソースを作成

作成下APIを選択し、リソースを作成を選択
リソースパスに/を、リソース名にresultを入力しリソースを作成を選択
画像21.png
画像22.png

4-5 メソッドの作成

/resultを選択し、メソッドを作成を選択
メソッドタイプにPOST、Lambda関数にai-document-prompt-putを選択し、メソッドを作成を選択(ほかは初期設定のまま)
画像23.png
画像24.png

4-6 CORSの有効化とデプロイ

CORSを有効にするを選択する。OPTIONとPOSTにチェックを入れて保存を選択する。
CORSを有効にできたらAPIをデプロイを選択する。
新しいステージを選択し、ステージ名にai-document-reviewを入力し、デプロイを選択
image.png
画像25.png
画像26.png
画像27.png

5.S3コンソールに移動

5-1 S3イベント通知を作成

ai-document-review-s3-bucketのコンソール画面からプロパティを選択する。
下にスクロールすると、イベント通知という項目があるのでイベント通知を作成を選択
画像28.png
画像29.png

5-2 S3イベントを作成

イベント名にai-document-review-trigger、プレフィックスにpromptを入力する。
イベントタイプ>オブジェクトの作成のところで、PUTとPOSTにチェックを入れる。
下までスクロールし、Lambda関数にai-document-reviewを選択する。
他は初期設定のまま、変更の保存を選択する。
画像30.png
画像31.png

6.Webサイト操作

6-1 ディストリビューションドメイン名をコピー

Cloudfront>自身が作成したディストリビューション名>ディストリビューションドメイン名をコピー
画像32.png

6-2 Webブラウザでの読込み

先ほどコピーしたものをWebブラウザにペーストすると、Webサイトが見れる
image.png

苦戦した点

・今回フロント側から直接プロンプトの保存、レビュー実行、結果をフロントに返すまでを行おうとするとLambdaの処理が3分以上かかっていまい、APIGatewayのタイムアウト(29秒)が発生してしまう。
そこで今回はプロンプトの保存、レビュー実行、結果をフロントに返す関数を分けて、一番処理の重いレビュー実行をS3をトリガーとして起動させ、APIGatewayを通らないようなアーキテクチャにしました。このままだとフロント側に返せないので、結果を別のS3に保存し、結果がある場合のみ署名付きURLを発行し結果の中身をフロント側で表示するといった処理(ポーリング処理)を行っている。

まとめ

意外と簡単にWebサイトを公開することができたので、この記事を見ていただいた方にも是非実践してほしいです!
今回Webサイト自体の説明を行えていないので、次回のQiitaではこのWebサイトで何が行えるかを説明できたらいいなと思っています!

今後の改善点

・ポーリング処理からストリーミング処理に変更してフロント側に直接結果を渡せるようにしたい。
・Lambdaのロールにアタッチしているポリシーが過剰なので、最低限のポリシーに変更したい。
・レビューの評価項目を変更できるようにしたい。

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?