🍆

アプリ開発初心者が生成AIを活用して小説翻訳アプリを作成した流れをなるべく丁寧にまとめる

に公開

アプリ開発、生成AIともにほとんど経験のないインフラエンジニアですが、この度各種生成AIサービスを活用して簡単な小説翻訳アプリを作ってみたので、その過程をまとめています。

生成AI流行ってるけど良く分からん、という人のために開発環境の説明からどんなサービスを使ったか、どんなプロンプトを流したか、など細かい部分まで書きだそうと思います。
すでに生成AIを活用している人からすれば取るに足らない内容かもしれませんが、自分が最初AIを触り始めた頃にこんな記事があったらよかったナ、という記事を目指して書きます。

完成品だけが見たいという方はこちらまで飛ばしてください。

はじめに

自身はもともと生成AIにあまり興味もなく、何度か試しに画像生成なんかをやったことがある程度だったのですが、今年のAWS Summitで実際に生成AIでのバイブコーディング[1]に触れる機会があり、これなら自分でもアプリが作れそうだと思ったのが今回アプリ開発を行ったきっかけです。

また、個人的に小説をよく読むのですが、マイナーな言語の小説だと日本語訳がないことが多く、DeepLなどの翻訳ツールを使おうにも一度に翻訳できる文字数が少なく大変なので、AIを使って一括で小説を翻訳できるようなアプリを作ろうと思いました。

利用する生成AIサービス

Copilot In Windows

Windows 11にはデフォルトで導入されているAI。基本的にチャットベースでの質問しかできず、ファイル出力などもできないものの無料で自由に利用できる。
今回、最初のコードのひな形作成や、簡単な質問はこちらで行う。
Windows以外のOSを使ってる場合、Chat GPTなど無料でチャット質問できるAIサービスでも代替可能。

Github Copilot

主にコードの生成や提案に特化したAI。VS Codeなどのエディタに拡張機能をインストールすることで、ローカルのファイルを読み込んだり、ファイルを書き換えたりしてくれる。
個人用のGithubアカウントからFreeプランに登録することで、現状は月2,000回のコード補完、月50回までのチャット質問が無料で可能なもよう。
今回は主にCopilot In Windowsで作成したひな形コードを修正するために利用。

Amazon Bedrock

AWSで提供されている生成AIサービス。多くの生成AIサービスは月々数千円程度のプランに加入して利用することが多いが、Bedrockは使った分だけの従量課金なので固定費がかからない。
主にアプリ内の翻訳処理に利用。

その他

今回、Clineという生成AIを利用して、コードの作成からコマンド実行、動作確認など幅広く実施してくれるツールも導入はしてみたものの、AI側でなんでもかんでもやろうとするため制御が難しく、今回は活用できなかった。
https://zenn.dev/aimasaou/articles/d5dfb5a5382440

GitHub CopilotやCursorなどの従来ツールは、「コードを書くのは人間が主体で、AIが候補を提示する」スタイルが一般的でした。対してClineは、「AIエージェントにタスクを任せると、AI自身がコードを書き、修正し、必要ならコンソールから動作確認まで行う」という進化したスタイルを志向しています。

開発環境

VS Code

エディタはVS Codeを利用。先ほど名前を挙げたGithub CopilotはVS Codeから拡張機能としてインストールすることで利用が可能。
拡張機能のGithub Copilotをインストールすると、VS Codeの画面にチャット欄が表示されるようになり、ここから指示を出すことでエディタのコードを修正してくれるようになる。

Github

Github Copilotの利用にアカウントが必要。
VS CodeにGithub CopilotをインストールしたらGithubとの連携の案内が出てきたのでそこからFreeプランに登録して紐づけを行った。

WSL(Ubuntu)

Windowsと環境を分離できたり、パッケージのインストールが楽だったり、VS Codeから簡単にターミナルを操作できたり、何かと便利なため利用。
今回はnode.jsでアプリを開発したが、アプリ起動もWSLのUbuntu上で行っている。

AWS

Amazon Bedrockを利用するためAWSのアカウントが必要。
今回のアプリでBedrockのAPIを利用するためにはおそらく事前に以下の対応が必要。

  1. モデルアクセス許可申請
    事前にモデルアクセスの申請をしないと利用ができない仕様になっている。
    半年前くらいに申請したときは、会社名や利用目的を聞かれて面倒くさかった記憶がある。
  2. IAMユーザ作成
    Bedrockへのアクセスポリシーを付与したIAMユーザが必要。
    ポリシーは "AmazonBedrockFullAccess" だけ付与されていればとりあえず動くことは確認済み。

アプリ要件

アプリ開発の経験がなく勝手がわかりませんでしたが、大雑把に欲しい機能などを列挙しました。

  • 翻訳元言語と翻訳先言語を入力し、小説のtxtファイルを添付すると翻訳後の文章が返ってくる
  • "対訳"ボタンを押すと、翻訳前の文章と翻訳後の文章を比較できる
  • 訳が気に入らない場合、該当の部分だけ再翻訳ができる
  • 小説全体の要約と、主要な登場人物紹介を行う
  • HTML, Javascript, NodeJSで構築(HTMLとJavascriptは個人的なブログ作成で多少触ったことがあり慣れているのと、UIもHTMLなら簡単にカスタマイズできると思ったため)
  • Webアプリとして作成しているが、基本localhostで動かすことを想定(セキュリティは考慮しない)

開発

Windows In Copilotでひな形作成

以下のプロンプトを流し、コードのひな形を作ってもらいます。
プロンプトの作成に慣れていないので、こなれていない部分があるかもしれません。

プロンプト

次のwebアプリを開発したいです。

・概要
外国語の小説を別言語へ翻訳するアプリ。
Amazon bedrockのapiを利用し、入力された情報をbedrock apiに投げて翻訳し、画面に出力する。
他に、小説全体の要約を140~1000字程度の範囲内で出力する機能と、主要な登場人物を説明紹介する機能も実装する。
基本的にインターネットには公開せず、localhostで利用する。

・使用言語
画面表示にはHTML, CSS, Javascriptを使用。
Amazon bedrockにapiを投げる部分についてはnode.jsで実行。

・入力画面
使用モデルID欄

  • 使用するbedrockのモデルIDを入力する
    リージョン名欄
  • 使用するAWSのリージョンを入力する
    bedrock api key欄
  • 使用するbedrockのapiキーを入力する
    翻訳元言語
  • 翻訳元の言語を入力する
    翻訳先言語
  • 翻訳先の言語を入力する
    作者名欄
  • 作者名を入力する
    タイトル欄
  • タイトルを入力する
    本文ファイル添付欄
  • 小説の本文が入ったtxtファイルを添付する

・出力画面
結果欄

  • 処理の成否を表示する。また処理にかかったbedrockの費用を表示する
    要約欄
  • 小説全体の要約を140字から1000字程度の範囲内で出力する
    登場人物欄
  • 小説の主要な登場人物を2, 3行程度で紹介する
    本文欄
  • 日本語に翻訳した小説の本文を表示する。本文の各行の冒頭には行数を表示する。"対訳"ボタンを押すと、原文と日本語訳の文章を同時に表示し、どこがどの文章の翻訳なのかを分かるようにする

・翻訳機能
入力された情報をもとにbedrock apiを実行する。
小説の本文ファイルを送信する。
140字から1000字程度の範囲内で要約を作成する。
主要な登場人物をまとめ、2, 3行程度の紹介文を作成する。
本文ファイルの内容を翻訳元言語から翻訳先言語に翻訳する。

・出力機能
結果欄に翻訳処理の成否を表示。また、使用したトークン数からかかった費用を計算して表示。
要約、登場人物の紹介文をそれぞれ表示。
本文欄に作者名、作品名、翻訳後の本文を表示。"対訳"ボタンを押すと、翻訳後の文章と翻訳前の本文ファイルの文章をどちらも表示する。

・その他機能
翻訳しなおして欲しい行を指定して"再翻訳ボタン"を押すと、再度その部分だけ改めて翻訳処理を行い、文章を新しいものに入れ替える。

AIの回答

実装ファイル一覧

  • server.js
  • public/index.html
  • public/style.css
  • public/script.js
  • package.json

server.js
const express = require('express');
const multer = require('multer');
const axios = require('axios');
const cors = require('cors');
const bodyParser = require('body-parser');

// トークンあたりの単価(USD)
const COST_PER_TOKEN = 0.00002;

const app = express();
const upload = multer({ storage: multer.memoryStorage() });

app.use(cors());
app.use(bodyParser.json());
app.use(express.static('public'));

// 小説全体の翻訳・要約・登場人物抽出
app.post('/translate', upload.single('novelFile'), async (req, res) => {
  try {
    const {
      modelId, region, apiKey,
      sourceLang, targetLang,
      authorName, bookTitle
    } = req.body;
    const text = req.file.buffer.toString('utf-8');

    // 各タスク用プロンプトを組み立て
    const summaryPrompt = `以下の文章を${sourceLang}から${targetLang}で140字から1000字程度に要約してください:\n\n${text}`;
    const charactersPrompt = `以下の文章の主要な登場人物を抽出し、2-3行程度で紹介してください:\n\n${text}`;
    const translationPrompt = `以下の文章を${sourceLang}から${targetLang}へ翻訳してください:\n\n${text}`;

    // Bedrock API 呼び出し関数
    const invokeBedrock = async (prompt) => {
      const endpoint = `https://bedrock.${region}.amazonaws.com/model/${modelId}/invoke`;
      const response = await axios.post(
        endpoint,
        { inputText: prompt },
        {
          headers: {
            'Content-Type': 'application/json',
            'x-api-key': apiKey
          }
        }
      );
      // レスポンス例: { completion: { content: "..."), totalTokens: { total: 123 } }
      const content = response.data.completion.content;
      const totalTokens = response.data.totalTokens.total;
      return { content, totalTokens };
    };

    // それぞれの呼び出し
    const summaryResp    = await invokeBedrock(summaryPrompt);
    const charactersResp = await invokeBedrock(charactersPrompt);
    const translationResp= await invokeBedrock(translationPrompt);

    // トークン・コスト計算
    const totalTokens = summaryResp.totalTokens + charactersResp.totalTokens + translationResp.totalTokens;
    const cost = totalTokens * COST_PER_TOKEN;

    // 行ごとに分割
    const originalLines   = text.split(/\r?\n/);
    const translatedLines = translationResp.content.split(/\r?\n/);

    res.json({
      success: true,
      usage: {
        tokens: totalTokens,
        cost: cost.toFixed(6)
      },
      summary:    summaryResp.content,
      characters: charactersResp.content,
      originalLines,
      translatedLines
    });
  } catch (error) {
    console.error(error.response?.data || error.message);
    res.status(500).json({ success: false, error: error.message });
  }
});

// 部分再翻訳エンドポイント
app.post('/retranslate', async (req, res) => {
  try {
    const {
      modelId, region, apiKey,
      sourceLang, targetLang,
      textSegment
    } = req.body;

    const prompt = `以下の文章を${sourceLang}から${targetLang}へ翻訳してください:\n\n${textSegment}`;

    const endpoint = `https://bedrock.${region}.amazonaws.com/model/${modelId}/invoke`;
    const response = await axios.post(
      endpoint,
      { inputText: prompt },
      {
        headers: {
          'Content-Type': 'application/json',
          'x-api-key': apiKey
        }
      }
    );
    const newTranslation = response.data.completion.content;
    const tokensUsed     = response.data.totalTokens.total;
    const cost           = tokensUsed * COST_PER_TOKEN;

    res.json({
      success: true,
      newTranslation,
      tokens: tokensUsed,
      cost: cost.toFixed(6)
    });
  } catch (error) {
    console.error(error.response?.data || error.message);
    res.status(500).json({ success: false, error: error.message });
  }
});

const PORT = 3000;
app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
});
public/index.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
  <title>小説翻訳アプリ</title>
  <link rel="stylesheet" href="style.css" />
</head>
<body>
  <h1>小説翻訳アプリ</h1>
  <form id="translateForm">
    <label>使用モデルID:<input type="text" name="modelId" required /></label>
    <label>リージョン名:<input type="text" name="region" required /></label>
    <label>APIキー:<input type="text" name="apiKey" required /></label>
    <label>翻訳元言語:<input type="text" name="sourceLang" required /></label>
    <label>翻訳先言語:<input type="text" name="targetLang" required /></label>
    <label>作者名:<input type="text" name="authorName" /></label>
    <label>タイトル:<input type="text" name="bookTitle" /></label>
    <label>本文ファイル(txt):<input type="file" name="novelFile" accept=".txt" required /></label>
    <button type="submit">翻訳開始</button>
  </form>

  <section id="results" style="display:none;">
    <h2>結果</h2>
    <div id="status"></div>

    <h3>要約</h3>
    <div id="summary"></div>

    <h3>登場人物</h3>
    <div id="characters"></div>

    <h3>本文翻訳</h3>
    <button id="toggleParallel">対訳表示</button>
    <div id="translation"></div>
  </section>

  <script src="script.js"></script>
</body>
</html>
public/style.css
body {
  font-family: Arial, sans-serif;
  margin: 20px;
}

form label {
  display: block;
  margin-bottom: 10px;
}

#translation .line {
  padding: 5px 0;
  border-bottom: 1px solid #eee;
}

.line-number {
  width: 30px;
  display: inline-block;
  font-weight: bold;
}

.retranslate-btn {
  margin-left: 10px;
  font-size: 0.9em;
}
public/script.js
document.addEventListener('DOMContentLoaded', () => {
  const form             = document.getElementById('translateForm');
  const resultsSection   = document.getElementById('results');
  const statusDiv        = document.getElementById('status');
  const summaryDiv       = document.getElementById('summary');
  const charactersDiv    = document.getElementById('characters');
  const translationDiv   = document.getElementById('translation');
  const toggleBtn        = document.getElementById('toggleParallel');

  let originalLines   = [];
  let translatedLines = [];
  let parallel        = false;
  let modelParams     = {};

  // フォーム送信(初回翻訳)
  form.addEventListener('submit', async (e) => {
    e.preventDefault();
    statusDiv.textContent = '処理中...';
    resultsSection.style.display = 'block';

    const formData = new FormData(form);
    try {
      const resp = await fetch('/translate', {
        method: 'POST',
        body: formData
      });
      const data = await resp.json();
      if (!data.success) {
        statusDiv.textContent = 'エラー: ' + data.error;
        return;
      }

      originalLines   = data.originalLines;
      translatedLines = data.translatedLines;
      modelParams = {
        modelId:    formData.get('modelId'),
        region:     formData.get('region'),
        apiKey:     formData.get('apiKey'),
        sourceLang: formData.get('sourceLang'),
        targetLang: formData.get('targetLang')
      };

      statusDiv.textContent = `成功 トークン: ${data.usage.tokens} コスト: $${data.usage.cost}`;
      summaryDiv.textContent    = data.summary;
      charactersDiv.textContent = data.characters;
      renderTranslation();
    } catch (err) {
      statusDiv.textContent = 'エラー: ' + err.message;
    }
  });

  // 対訳トグル
  toggleBtn.addEventListener('click', () => {
    parallel = !parallel;
    toggleBtn.textContent = parallel ? '通常表示' : '対訳表示';
    renderTranslation();
  });

  // 再翻訳ボタンのイベント委譲
  translationDiv.addEventListener('click', async (e) => {
    if (!e.target.matches('.retranslate-btn')) return;
    const idx = e.target.dataset.index;
    const segment = originalLines[idx];
    e.target.textContent = '再翻訳中...';

    try {
      const resp = await fetch('/retranslate', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          ...modelParams,
          textSegment: segment
        })
      });
      const d = await resp.json();
      if (d.success) {
        translatedLines[idx] = d.newTranslation;
        renderTranslation();
      } else {
        alert('再翻訳エラー: ' + d.error);
      }
    } catch (err) {
      alert('再翻訳エラー: ' + err.message);
    } finally {
      e.target.textContent = '再翻訳';
    }
  });

  // 本文を行ごとに描画
  function renderTranslation() {
    translationDiv.innerHTML = '';
    originalLines.forEach((orig, i) => {
      const div = document.createElement('div');
      div.className = 'line';

      const num = document.createElement('span');
      num.className = 'line-number';
      num.textContent = (i + 1) + '.';
      div.appendChild(num);

      if (parallel) {
        const oDiv = document.createElement('div');
        oDiv.textContent = orig;
        oDiv.style.fontStyle = 'italic';
        div.appendChild(oDiv);
      }

      const tDiv = document.createElement('div');
      tDiv.textContent = translatedLines[i] || '';
      div.appendChild(tDiv);

      const btn = document.createElement('button');
      btn.className = 'retranslate-btn';
      btn.dataset.index = i;
      btn.textContent = '再翻訳';
      div.appendChild(btn);

      translationDiv.appendChild(div);
    });
  }
});
package.json
{
  "name": "novel-translator",
  "version": "1.0.0",
  "main": "server.js",
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "axios": "^1.5.0",
    "body-parser": "^1.20.2",
    "cors": "^2.8.5",
    "express": "^4.18.2",
    "multer": "^1.4.5-lts.1"
  }
}

以上を配置し、ターミナルで

npm install
npm start

を実行すれば http://localhost:3000 で動作します。

VS Codeでファイル作成

AIのコードを一旦鵜呑みにし、手動でペタペタとコピペしていきます。
ディレクトリ構造だけ気を付けます。

少なくとも張り付けた時点で、コードに構文エラーは出ていないようです。

アプリ起動

とりあえずAIの指示に従って起動していきます。

$ npm install
npm warn deprecated multer@1.4.5-lts.2: Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.

added 98 packages, and audited 99 packages in 3s

17 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities
$ npm start

質素な画面ですが、起動した時点ではエラーなくページが表示できています。

コード確認

翻訳機能をテストしたいところですが、生成AIのAPIキーを扱うこともあり、念のためGithub Copilotに一度コードをレビューしてもらいます。
ついでに申し訳程度に目視でも確認していきます。

VS Codeでコードを配置したディレクトリを右クリックしてチャットへ追加すると、Github Copilotでチャット質問する際に中のファイルを読み込んでくれるようです。

ディレクトリを追加したうえで、以下のようなプロンプトで質問していきます。

プロンプト

ai_appディレクトリ内に、小説を翻訳するアプリを作成しています。

機能としては以下を実装しています。

  • HTML画面で各種必要情報を入力します
  • 入力された情報をもとに、Amazon Bedrock apiを使って翻訳を行います
  • 翻訳処理等で使われたトークン数をもとにかかった費用を計算します
  • HTML画面に結果を出力します
  • 任意の行を選択して、再翻訳を行うことができます

これらのコードについて、セキュリティ上問題がある部分や、不自然なコードがあれば教えてください。

AIの回答

server.js の内容を確認しました。主なセキュリティ上の懸念点や不自然な点は以下の通りです。

セキュリティ上の懸念点

  1. APIキーの取り扱い
    クライアントから apiKey を受け取り、そのままBedrock APIに渡しています。
    → APIキーは本来サーバー側で安全に管理すべきで、クライアントに渡すべきではありません。悪意あるユーザーにAPIキーが漏れるリスクがあります。
  2. ファイルアップロードの検証不足
    multer でファイルを受け取っていますが、ファイルサイズや拡張子などのバリデーションがありません。
    → 大きすぎるファイルや不正なファイル形式のアップロードを防ぐため、制限を設けるべきです。
  3. エラーハンドリング
    エラー時に error.message をそのまま返しています。
    → サーバー内部の情報が漏れる可能性があるため、ユーザー向けには一般的なエラーメッセージのみ返す方が安全です。
  4. CORSの設定
    app.use(cors()) で全てのオリジンを許可しています。
    → 必要なオリジンだけを許可するように制限するのが望ましいです。
  5. リクエストパラメータの検証不足
    modelId, region, sourceLang, targetLang などの値に対してバリデーションがありません。
    → 不正な値が渡された場合、意図しない挙動やAPIエラーにつながる可能性があります。

不自然なコード・改善点

  1. APIレスポンスの構造
    Bedrock APIのレスポンス例コメントで括弧が不自然です({ completion: { content: "..."), totalTokens: { total: 123 } })。
    → 正しくは { completion: { content: "..." }, totalTokens: { total: 123 } } です。
  2. コスト計算の精度
    cost.toFixed(6) で小数点以下6桁にしていますが、必要に応じて桁数を調整してください。
  3. 関数の分割
    1つのエンドポイントで複数のBedrock API呼び出しを行っています。
    → 処理を関数化して分割すると、可読性・保守性が向上します。
  4. ファイルの文字コード
    req.file.buffer.toString('utf-8') でUTF-8固定ですが、他の文字コードの場合の考慮がありません。

まとめ
特に「APIキーの扱い」と「CORSの制限」は早急に見直すべきです。
また、入力値やファイルのバリデーションも追加すると、より安全なアプリになります。

ご希望があれば、具体的な修正案もご提案できます。

質問しておいてなんですが、セキュリティ上の懸念点に関しては今回はlocalhostでしか動かさないため無視します。
コードの部分では「Bedrock APIのレスポンス例コメントで括弧が不自然です」と言われています。
コメント内のコードかつ、あくまでレスポンスの例なので処理には関係ない気もしますが、修正案のほうが正しそうに見えるので一応直しておきます。

翻訳処理実行

翻訳処理を実行していきます。
プロジェクト・グーテンベルクという海外版青空文庫みたいなサイトがあるので、そちらのデータをお借りして検証を進めます。
検証では文量が少なく手元に日本語訳があることから、カフカの『断食芸人』とキャサリン・マンスフィールドの『カナリア』を使用していました。
https://www.gutenberg.org/
断食芸人(Ein Hungerkünstler by Franz Kafka)
カナリア(The Canary by Katherine Mansfield)

AIモデルは無難にClaude Sonnet 4(anthropic.claude-sonnet-4-20250514-v1:0)を利用します。

予想はしていましたが、エラーが発生しました。

コード修正

ここからはGithub Copilotを使ったコード修正を行います。
かなり調査に難航したため、概略だけまとめています。

そもそもAPIのたたき方に問題があり、Bedrock側での翻訳処理ができていない

ここが一番調査に時間がかかりました。
AIの提示したInvokeModel APIを呼び出す方法ではうまく動作せず、AIにいくら聞いてもイマイチ要領を得ない回答しか得られなかったため、Converse APIから呼び出す方式に変更しています。
自分のプロンプトが悪いのか、AI側でBedrockのAPI仕様をうまく把握できていないのかわかりませんが、結局のところ大部分は人力での調査になってしまいました。
https://blog.serverworks.co.jp/bedrock-runtime-invoke-model-and-converse-api

InvokeModel:抽象化レベルが低い API。各種モデルのプロバイダーの仕様に合わせて呼び出す必要がある。
Converse:抽象化レベルが高い API。各種モデルのプロバイダーの仕様の差異を吸収しており、統一された呼び出しが可能。

また、axiosというライブラリでBedrock APIを呼び出すようになっていましたが、ここはAIの提案でAWS SDKを利用するように修正しています。

それから、少なくともClaude Sonnet 4では、モデルIDの指定にクロスリージョン推論の推論プロファイルIDというものを入力しないとエラーになりました。
東京リージョンの場合、基本的にはモデルIDの頭に "apac." と付けるだけでよさそうです。
apac.anthropic.claude-sonnet-4-20250514-v1:0

というわけで、APIまわりを修正したコードは以下の通り。

修正後コード
server.js
const express = require('express');
const multer = require('multer');
const axios = require('axios');
const cors = require('cors');
const bodyParser = require('body-parser');
const { BedrockRuntimeClient, ConverseCommand } = require('@aws-sdk/client-bedrock-runtime');

// トークンあたりの単価(USD)
const COST_PER_TOKEN = 0.00002;

const app = express();
const upload = multer({ storage: multer.memoryStorage() });

app.use(cors());
app.use(bodyParser.json());
app.use(express.static('public'));

// 小説全体の翻訳・要約・登場人物抽出
app.post('/translate', upload.single('novelFile'), async (req, res) => {
  try {
    const {
      modelId, region,
      accessKeyId, secretAccessKey,
      sourceLang, targetLang,
      authorName, bookTitle
    } = req.body;
    const text = req.file.buffer.toString('utf-8');

    // 各タスク用プロンプトを組み立て
    const summaryPrompt = `以下の文章を${sourceLang}から${targetLang}で140字から1000字程度に要約してください:\n\n${text}`;
    const charactersPrompt = `以下の文章の主要な登場人物を抽出し、2-3行程度で紹介してください:\n\n${text}`;
    const translationPrompt = `以下の文章を${sourceLang}から${targetLang}へ翻訳してください:\n\n${text}`;

    // Bedrock Converse API 呼び出し関数
    const converseBedrock = async (prompt) => {
      const client = new BedrockRuntimeClient({
        region,
        credentials: {
          accessKeyId,
          secretAccessKey
        }
      });

      const input = {
        modelId,
        messages: [
          {
            role: 'user',
            content: [ { type: 'text', text: prompt } ]
          }
        ],
        inferenceConfig: [
          {
            maxTokens: 1024,
            temperature: 0.7
          }
        ],
        system: [
          {
            text: "小説の文章を翻訳しているため、表現は文学的にしてください。ただし、原文から飛躍のある意味にしてはいけません。"
          }
        ]
      };

      const command = new ConverseCommand(input);
      const response = await client.send(command);
      console.log('Bedrock Converse response:', response);

      // レスポンス構造に合わせて処理
      if (!response.output || !response.output.message || !response.output.message.content) {
        return {
          error: 'Bedrock Converse APIレスポンスにmessage.contentが含まれていません',
          raw: response
        };
      }
      // contentは配列(type: text, text: ...)
      const textContent = response.output.message.content.find(c => c.type === 'text');
      const content = textContent ? textContent.text : '';
      // トークン数はoutput.usage.tokens.total
      const totalTokens = response.output.usage?.tokens?.total ?? 0;
      return { content, totalTokens };
    };

    // それぞれの呼び出し
    const summaryResp    = await converseBedrock(summaryPrompt);
    if (summaryResp.error) return res.status(500).json({ success: false, error: summaryResp.error, raw: summaryResp.raw });

    const charactersResp = await converseBedrock(charactersPrompt);
    if (charactersResp.error) return res.status(500).json({ success: false, error: charactersResp.error, raw: charactersResp.raw });

    const translationResp= await converseBedrock(translationPrompt);
    if (translationResp.error) return res.status(500).json({ success: false, error: translationResp.error, raw: translationResp.raw });

    // トークン・コスト計算
    const totalTokens = summaryResp.totalTokens + charactersResp.totalTokens + translationResp.totalTokens;
    const cost = totalTokens * COST_PER_TOKEN;

    // 行ごとに分割
    const originalLines   = text.split(/\r?\n/);
    const translatedLines = translationResp.content.split(/\r?\n/);

    res.json({
      success: true,
      usage: {
        tokens: totalTokens,
        cost: cost.toFixed(6)
      },
      summary:    summaryResp.content,
      characters: charactersResp.content,
      originalLines,
      translatedLines
    });
  } catch (error) {
    console.error(error.response?.data || error.message);
    res.status(500).json({ success: false, error: error.message });
  }
});

// 部分再翻訳エンドポイント
app.post('/retranslate', async (req, res) => {
  try {
    const {
      modelId, region, apiKey,
      sourceLang, targetLang,
      textSegment
    } = req.body;

    const prompt = `以下の文章を${sourceLang}から${targetLang}へ翻訳してください:\n\n${textSegment}`;

    const endpoint = `https://bedrock.${region}.amazonaws.com/model/${modelId}/invoke`;
    const response = await axios.post(
      endpoint,
      { inputText: prompt },
      {
        headers: {
          'Content-Type': 'application/json',
          'x-api-key': apiKey
        }
      }
    );
    const newTranslation = response.data.completion.content;
    const tokensUsed     = response.data.totalTokens.total;
    const cost           = tokensUsed * COST_PER_TOKEN;

    res.json({
      success: true,
      newTranslation,
      tokens: tokensUsed,
      cost: cost.toFixed(6)
    });
  } catch (error) {
    console.error(error.response?.data || error.message);
    res.status(500).json({ success: false, error: error.message });
  }
});

const PORT = 3000;
app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
});

データ出力方法の修正

API周りの修正ができたので改めてアプリを実行すると、処理は動いたものの、、、

5分ほど待って表示された結果はすべて空欄。

サーバ側ログは以下の通り

サーバ側ログ
Bedrock Converse response: {
  '$metadata': {
    httpStatusCode: 200,
    requestId: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
    extendedRequestId: undefined,
    cfId: undefined,
    attempts: 1,
    totalRetryDelay: 0
  },
  metrics: { latencyMs: 30816 },
  output: { message: { content: [Array], role: 'assistant' } },
  stopReason: 'end_turn',
  usage: {
    cacheReadInputTokenCount: 0,
    cacheReadInputTokens: 0,
    cacheWriteInputTokenCount: 0,
    cacheWriteInputTokens: 0,
    inputTokens: 8094,
    outputTokens: 1315,
    totalTokens: 9409
  }
}
Bedrock Converse response: {
  '$metadata': {
    httpStatusCode: 200,
    requestId: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
    extendedRequestId: undefined,
    cfId: undefined,
    attempts: 1,
    totalRetryDelay: 0
  },
  metrics: { latencyMs: 9426 },
  output: { message: { content: [Array], role: 'assistant' } },
  stopReason: 'end_turn',
  usage: {
    cacheReadInputTokenCount: 0,
    cacheReadInputTokens: 0,
    cacheWriteInputTokenCount: 0,
    cacheWriteInputTokens: 0,
    inputTokens: 8096,
    outputTokens: 405,
    totalTokens: 8501
  }
}
Bedrock Converse response: {
  '$metadata': {
    httpStatusCode: 200,
    requestId: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
    extendedRequestId: undefined,
    cfId: undefined,
    attempts: 1,
    totalRetryDelay: 0
  },
  metrics: { latencyMs: 214426 },
  output: { message: { content: [Array], role: 'assistant' } },
  stopReason: 'max_tokens',
  usage: {
    cacheReadInputTokenCount: 0,
    cacheReadInputTokens: 0,
    cacheWriteInputTokenCount: 0,
    cacheWriteInputTokens: 0,
    inputTokens: 8088,
    outputTokens: 8192,
    totalTokens: 16280
  }
}

処理自体は行われているものの、今度は出力がうまくいっていないみたいです。
なお、サーバ側のログを見ると、要約、登場人物紹介、本文訳でそれぞれしっかりトークンが消費されています。

一旦Github Copilotに、APIから帰ってくるjsonデータをローカルに保存するようなコードを追加してもらいました。

server.js
      // bedrock.logに追記
      try {
        await fs.appendFile('bedrock.log', JSON.stringify(response, null, 2) + '\n');
      } catch (e) {
        console.error('bedrock.logへの書き込み失敗:', e);
      }

もう一度処理を実行すると、ローカルに "bedrock.log" というファイルが作成され、Bedrockから帰ってきた値が格納されています。
中身を見ると、優等生的といいますか、かなり小奇麗に要約や登場人物紹介がされているように見えます。
ただ、要約が1500文字を超えていたり、maxTokensを一旦1024に制限している影響か全文翻訳は途中で途切れていたりと、まだまだチューニングは必要そうです。

要約(一部抜粋)

"output": {
"message": {
"content": [
{
"text": "この文章は、ある断食芸人の栄光と没落を描いた物語である。\n\nかつて断食芸人は社会の注目を集める存在だった。町全体が彼に関心を寄せ、日を追うごとに見物客は増え続けた。人々は毎日少なくとも一度は彼を見に訪れ、後の日々には定期券を買って小さな格子の檻の前に何日も座り込む者もいた。夜間の見学も松明の光で行われ、晴れた日には檻が屋外に運び出された。特に子供たちは、黒いトリコットを着て肋骨を突き出させた蒼白な彼が、椅子も拒んで撒かれた藁の上に座り、丁寧にうなずきながら質問に答える様子を、驚嘆の眼差しで見つめていた。\n\n見物客の他に、公衆が選んだ常設の監視人たちがいた。奇妙なことに彼らは通常肉屋で、三人一組となって昼夜を問わず断食芸人を監視し、密かに食物を摂取しないよう見張っていた。しかしこれは単なる形式に過ぎなかった。真の芸術家である断食芸人は、断食期間中、いかなる状況下でも決して食べることはなかったからである
(~中略~)
「まだ断食を続けているのか」と問われた断食芸人は、か細い声で答えた。「皆さんに許しを請います。私は自分の断食を讃えてもらいたかった」「讃えているとも」と監督が言うと、断食芸人は言った。「でも讃えてはいけないのです。私は断食せざるを得ないのです。他にはできないのです。なぜなら、私は自分に合う食べ物を見つけることができなかったからです。もしそれを見つけていたら、騒ぎなど起こさず、あなたや皆と同じように腹一杯食べていたでしょう」\n\nこれが彼の最後の言葉だった。断食芸人が埋葬されると、檻には若い豹が入れられた。生命力に満ちた豹の姿は、観客たちを魅了し、彼らは檻の周りから離れようとしなかった。"
}
],
"role": "assistant"
}
},

登場人物紹介

"output": {
"message": {
"content": [
{
"text": "この文章の主要な登場人物は以下の通りです:\n\n断食芸人(Hungerkünstler)\n物語の主人公である痩せ衰えた芸術家。檻の中で何日も断食することを芸として披露する。かつては大衆に熱狂的に愛されていたが、時代の変化とともに忘れ去られていく。断食に対する真摯な情熱と、誰にも理解されない孤独を抱えている悲劇的な人物。\n\n興行師(Impresario)\n断食芸人の興行を取り仕切る実務的な人物。断食期間を最大40日に制限し、観客への演出や宣伝を巧みに行う。断食芸人の芸術的な苦悩よりも商業的成功を重視し、時代の変化とともに断食芸人を見捨てることになる。\n\n監視人たち(Wächter)\n観客によって選ばれ、断食芸人が密かに食事をしていないかを監視する役割を担う人々。多くは肉屋で、中には職務を怠る者もいれば、真摯に監視を続ける者もいる。断食芸人との間に複雑な関係を築いている。"
}
],
"role": "assistant"
}
},

本文翻訳(一部抜粋)

"output": {
"message": {
"content": [
{
"text": "ここ数十年来、断食芸人への関心は著しく衰えてきた。以前なら、そのような大がかりな興行を自ら主催することは十分に割の合うものだったが、今日ではそれは全く不可能となっている。時代が変わったのだ。あの頃は、町全体が断食芸人のことで持ちきりとなった。
(中略)
「まだ断食をしているのか?」監督が尋ねた。「いつになったら終わるのだ?」「皆さん、お許しください」と断食芸人はささやいた。格子に耳を当てた監督だけが彼を理解した。「もちろんだ」と監督は言い、指を額に当てて断食芸人の状態を職員に示した。「我々は君を許す。」「いつも皆さんが私の断食を称賛してくださることを望んでいました」と断食芸人は言った。「我々は称賛しているよ」と監督は親切に言った。「しかし皆さんは称賛すべきではないのです」と断食芸人は言った。「それでは、我々は称賛しないことにしよう」と監督は言った。「しかしなぜ称賛してはいけないのだ?」「なぜなら私は断食せざるを得ないのです。他にできないのです」と断食芸人は言った。「これは驚いた」と監督は言った。「なぜ他にできないのだ?」「なぜなら私は」と断食芸人は言い、小さな頭を少し上げ、まるでキスをするように唇を尖らせて監督の耳に直接語りかけた。何も失われないようにするためだった。「なぜなら私は、自分の口に合う食べ物を見つけることができなかったからです。もしそれを見つけていたら"
}
],
"role": "assistant"
}
},

次に、出力した "bedrock.log" の内容も参考に、HTMLに適切に結果を表示するようGithub Copilotにコードを修正してもらいます。

修正後コード
server.js
const express = require('express');
const multer = require('multer');
const axios = require('axios');
const cors = require('cors');
const bodyParser = require('body-parser');
const { BedrockRuntimeClient, ConverseCommand } = require('@aws-sdk/client-bedrock-runtime');
const fs = require('fs-extra');

// トークンあたりの単価(USD)
const COST_PER_TOKEN = 0.00002;

const app = express();
const upload = multer({ storage: multer.memoryStorage() });

app.use(cors());
app.use(bodyParser.json());
app.use(express.static('public'));

// 小説全体の翻訳・要約・登場人物抽出
app.post('/translate', upload.single('novelFile'), async (req, res) => {
  try {
    const {
      modelId, region,
      accessKeyId, secretAccessKey,
      sourceLang, targetLang,
      authorName, bookTitle
    } = req.body;
    const text = req.file.buffer.toString('utf-8');

    // 各タスク用プロンプトを組み立て
    const summaryPrompt = `以下の文章を${sourceLang}から${targetLang}で140字から1000字程度に要約してください:\n\n${text}`;
    const charactersPrompt = `以下の文章の主要な登場人物を抽出し、2-3行程度で紹介してください:\n\n${text}`;
    const translationPrompt = `以下の文章を${sourceLang}から${targetLang}へ翻訳してください:\n\n${text}`;

    // Bedrock Converse API 呼び出し関数
    const converseBedrock = async (prompt) => {
      const client = new BedrockRuntimeClient({
        region,
        credentials: {
          accessKeyId,
          secretAccessKey
        }
      });

      const input = {
        modelId,
        messages: [
          {
            role: 'user',
            content: [ { type: 'text', text: prompt } ]
          }
        ],
        inferenceConfig: [
          {
            maxTokens: 1024,
            temperature: 0.7
          }
        ],
        system: [
          {
            text: "小説の文章を翻訳しているため、表現は文学的にしてください。ただし、原文から飛躍のある意味にしてはいけません。改行は\nを使って表現してください"
          }
        ]
      };

      const command = new ConverseCommand(input);
      const response = await client.send(command);
      // content配列の中身も明示的にログ出力
      if (response.output && response.output.message && Array.isArray(response.output.message.content)) {
        console.log('Bedrock Converse response content array:', response.output.message.content);
      }
      console.log('Bedrock Converse response:', response);
      // bedrock.logに追記
      try {
        await fs.appendFile('bedrock.log', JSON.stringify(response, null, 2) + '\n');
      } catch (e) {
        console.error('bedrock.logへの書き込み失敗:', e);
      }

      // レスポンス構造に合わせて処理
      if (!response.output || !response.output.message || !response.output.message.content) {
        return {
          error: 'Bedrock Converse APIレスポンスにmessage.contentが含まれていません',
          raw: response
        };
      }
      // contentは配列(type: text, text: ...)
      let content = '';
      const arr = response.output.message.content;
      if (Array.isArray(arr)) {
        // type: text優先、なければtextプロパティ
        const textObj = arr.find(c => c.type === 'text' && typeof c.text === 'string');
        if (textObj) {
          content = textObj.text;
        } else {
          // typeがない場合もtextプロパティを探す
          const anyTextObj = arr.find(c => typeof c.text === 'string');
          if (anyTextObj) content = anyTextObj.text;
        }
      }
      // トークン数はoutput.usage.tokens.total
      const totalTokens = response.output.usage?.tokens?.total ?? 0;
      return { content, totalTokens };
    };

    // それぞれの呼び出し
    const summaryResp    = await converseBedrock(summaryPrompt);
    if (summaryResp.error) return res.status(500).json({ success: false, error: summaryResp.error, raw: summaryResp.raw });

    const charactersResp = await converseBedrock(charactersPrompt);
    if (charactersResp.error) return res.status(500).json({ success: false, error: charactersResp.error, raw: charactersResp.raw });

    const translationResp= await converseBedrock(translationPrompt);
    if (translationResp.error) return res.status(500).json({ success: false, error: translationResp.error, raw: translationResp.raw });

    // Bedrockレスポンスのusage, content抽出(必ずtextを抽出)
    function extractText(resp) {
      if (resp.content) return resp.content;
      if (resp.output && resp.output.message && Array.isArray(resp.output.message.content)) {
        const arr = resp.output.message.content;
        // type: text優先、なければtextプロパティ
        const textObj = arr.find(c => c.type === 'text' && typeof c.text === 'string');
        if (textObj) return textObj.text;
        const anyTextObj = arr.find(c => typeof c.text === 'string');
        if (anyTextObj) return anyTextObj.text;
      }
      return '';
    }

    const totalTokens =
      (summaryResp.totalTokens || summaryResp.output?.usage?.tokens?.total || 0) +
      (charactersResp.totalTokens || charactersResp.output?.usage?.tokens?.total || 0) +
      (translationResp.totalTokens || translationResp.output?.usage?.tokens?.total || 0);
    const cost = totalTokens * COST_PER_TOKEN;

    // 行ごとに分割
    const originalLines   = text.split(/\r?\n/);
    let translatedText = extractText(translationResp);
    const translatedLines = translatedText ? translatedText.split(/\r?\n/) : Array(originalLines.length).fill('');

    // summary, charactersも同様に抽出
    let summaryText    = extractText(summaryResp);
    let charactersText = extractText(charactersResp);

    // デバッグ用: レスポンス内容をstatus欄に一時表示
    const debugInfo = {
      summaryResp,
      charactersResp,
      translationResp
    };

    res.json({
      success: true,
      usage: {
        tokens: totalTokens,
        cost: cost.toFixed(6)
      },
      summary:    summaryText,
      characters: charactersText,
      originalLines,
      translatedLines,
      debugInfo // フロントで一時表示可能
    });
  } catch (error) {
    console.error(error.response?.data || error.message);
    res.status(500).json({ success: false, error: error.message });
  }
});

// 部分再翻訳エンドポイント
app.post('/retranslate', async (req, res) => {
  try {
    const {
      modelId, region, apiKey,
      sourceLang, targetLang,
      textSegment
    } = req.body;

    const prompt = `以下の文章を${sourceLang}から${targetLang}へ翻訳してください:\n\n${textSegment}`;

    const endpoint = `https://bedrock.${region}.amazonaws.com/model/${modelId}/invoke`;
    const response = await axios.post(
      endpoint,
      { inputText: prompt },
      {
        headers: {
          'Content-Type': 'application/json',
          'x-api-key': apiKey
        }
      }
    );
    const newTranslation = response.data.completion.content;
    const tokensUsed     = response.data.totalTokens.total;
    const cost           = tokensUsed * COST_PER_TOKEN;

    res.json({
      success: true,
      newTranslation,
      tokens: tokensUsed,
      cost: cost.toFixed(6)
    });
  } catch (error) {
    console.error(error.response?.data || error.message);
    res.status(500).json({ success: false, error: error.message });
  }
});

const PORT = 3000;
app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
});

相変わらずコスト計算が出来ていなかったり、出力もところどころおかしかったり、まだまだ課題は多いですが最低限の形は出来上がってきました。

そのほか各種調整

翻訳と結果表示という最低限の機能はそろったため、引き続き気になる部分を調整していきます。
基本的にはAIに任せていますが、Bedrockに投げるプロンプトの内容など細かいパラメータは手動で調整しています。
主な修正箇所は以下です。

  • HTML上でコストが正常に表示されていなかったため修正
  • 要約と登場人物紹介の出力フォーマットが崩れているのを修正
  • 再翻訳機能は修正が面倒になったので廃止
  • 当初は原文の改行をもとにして機械的に段落分けを行っていたが、Bedrockに段落分けをしてもらうよう修正
    (当初の実装だとどんなに長い文章でも改行がなければ一段落として扱われてしまったり、事前にある程度原文を成形しないといけない場合があったりしたため)
  • 翻訳処理中の画面に処理の進捗を表示するよう修正
  • アプリ画面がさすがに地味だったので適当に修正
    (「HTMLの画面が殺風景なので、モダンな見た目に変えてください」というプロンプトで修正)
  • HTML画面にクリップボードへのコピーボタンを追加
  • 小説本文を毎回送信するのは非効率なので、プロンプトキャッシュを利用するように修正(したもののうまくキャッシュがかからない、、、)
    ⇒追記:初歩的な記載ミスでキャッシュ設定を書く位置を間違えていただけでした、、、
  • temperatureをHTMLから変更できるよう修正。ただし、プロンプトで「原文から飛躍のある意味にしてはいけません」と指定しているためか、温度0と1の場合でもあまり違いは感じられない、、、

以上のような調整を行って、一通り完成の形に持っていきました。

完成品

コード

完成品はGithubにアップロードしています。
※zennブランチに記事執筆時点のバージョンが、mainブランチは改修後のバージョンを配置しています
https://github.com/pesipesi/novel-translator

なお、もし本アプリを利用したことにより何かしらの不利益を被ったとしても一切の責任は負いかねます。
また、生成AIを利用して作成したコードのライセンスの扱いが不明なため、ライセンスは未記載としています。

実際の動作

package.jsonのあるディレクトリで npm installnpm start を実行することで動作。

$ npm install

up to date, audited 191 packages in 905ms
20 packages are looking for funding
  run `npm fund` for details
found 0 vulnerabilities

$ npm start

> novel-translator@1.0.0 start
> node server.js
Server running on http://localhost:3000

ブラウザからlocalhost:3000でアクセス可能。

各種パラメータを入力。

AWSアクセスキーとシークレットキーは、Bedrockへのアクセス権限があるIAMユーザのものを入力する。
翻訳の精度と温度(出力結果が保守的かランダムか)を調整可能。
高精度翻訳を無効にすると、トークン消費が少なくなる代わりに前後の段落の文脈を無視する仕様になっています。

処理中の画面、長さにもよるが短い小説でも数分は待たされる。


サーバ側(今回はWSLのUbuntu)にログが出力される

サーバログ
Bedrock Converse response content array: [
  {
    text: '一人暮らしの女性が愛していたカナリアについて語る物語。彼女は玄関ドアの右側にある大きな釘を見るたびに、そこにかかっていた鳥かごを思い出す。カナリアは他の鳥とは違う美しい歌声で人々を魅了し、彼女にとって完璧な伴侶だった。<br><br>毎日の世話を通じて深い絆を築き、孤独な生活の中で唯一の慰めとなっていた。下宿人たちに「かかし」と呼ばれても、カナリアがいれば寂しくなかった。ある夜、悪夢で目覚めた時も、カナリアの優しい鳴き声に慰められた。<br><br>しかし愛するカナリアは死んでしまい、彼女の心には空虚感が残った。人生には説明しがたい悲しみがあると感じながらも、カナリアの美しい歌声の中にもその同じ悲しみを聞いていたのだった。'
  }
]
Bedrock Converse response: {
  '$metadata': {
    httpStatusCode: 200,
    requestId: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
    extendedRequestId: undefined,
    cfId: undefined,
    attempts: 1,
    totalRetryDelay: 0
  },
  metrics: { latencyMs: 8493 },
  output: { message: { content: [Array], role: 'assistant' } },
  stopReason: 'end_turn',
  usage: {
    cacheReadInputTokenCount: 0,
    cacheReadInputTokens: 0,
    cacheWriteInputTokenCount: 0,
    cacheWriteInputTokens: 0,
    inputTokens: 2018,
    outputTokens: 331,
    totalTokens: 2349
  }
}

処理結果

上部には利用したトークン数、おおよそのコストが表示。
それから要約欄と登場人物紹介欄を表示。

下部の本文翻訳欄に全文翻訳が表示。

"対訳表示" ボタンを押すと段落ごとに対訳(左:原文 右:訳文)を表示する。

翻訳文

細かいところまではみていませんが、おおむね問題はないように見受けられます。
ただ、原文ではところどころ行頭が「...You see that ~」というような形で始まっているのですが、AIが気を利かせて冒頭の点を省いてしまっていますね。

原文(キャサリン・マンスフィールド『カナリア』)

※文章はプロジェクト・グーテンベルクのものを利用しています。
You see that big nail to the right of the front door? I can scarcely look at it even now and yet I could not bear to take it out. I should like to think it was there always even after my time. I sometimes hear the next people saying, "There must have been a cage hanging from there." And it comforts me; I feel he is not quite forgotten.

You cannot imagine how wonderfully he sang. It was not like the singing of other canaries. And that isn't just my fancy. Often, from the window, I used to see people stop at the gate to listen, or they would lean over the fence by the mock-orange for quite a long time—carried away. I suppose it sounds absurd to you—it wouldn't if you had heard him—but it really seemed to me that he sang whole songs with a beginning and an end to them.

For instance, when I'd finished the house in the afternoon, and changed my blouse and brought my sewing on to the veranda here, he used to hop, hop, hop from one perch to another, tap against the bars as if to attract my attention, sip a little water just as a professional singer might, and then break into a song so exquisite that I had to put my needle down to listen to him. I can't describe it; I wish I could. But it was always the same, every afternoon, and I felt that I understood every note of it.

I loved him. How I loved him! Perhaps it does not matter so very much what it is one loves in this world. But love something one must. Of course there was always my little house and the garden, but for some reason they were never enough. Flowers respond wonderfully, but they don't sympathize. Then I loved the evening star. Does that sound foolish? I used to go into the backyard, after sunset, and wait for it until it shone above the dark gum tree. I used to whisper "There you are, my darling." And just in that first moment it seemed to be shining for me alone. It seemed to understand this ... something which is like longing, and yet it is not longing. Or regret—it is more like regret. And yet regret for what? I have much to be thankful for.

But after he came into my life I forgot the evening star; I did not need it any more. But it was strange. When the Chinaman who came to the door with birds to sell held him up in his tiny cage, and instead of fluttering, fluttering, like the poor little goldfinches, he gave a faint, small chirp, I found myself saying, just as I had said to the star over the gum tree, "There you are, my darling." From that moment he was mine.

It surprises me even now to remember how he and I shared each other's lives. The moment I came down in the morning and took the cloth off his cage he greeted me with a drowsy little note. I knew it meant "Missus! Missus!" Then I hung him on the nail outside while I got my three young men their breakfasts, and I never brought him in until we had the house to ourselves again. Then, when the washing-up was done, it was quite a little entertainment. I spread a newspaper over a corner of the table and when I put the cage on it he used to beat with his wings despairingly, as if he didn't know what was coming. "You're a regular little actor," I used to scold him. I scraped the tray, dusted it with fresh sand, filled his seed and water tins, tucked a piece of chickweed and half a chili between the bars. And I am perfectly certain he understood and appreciated every item of this little performance. You see by nature he was exquisitely neat. There was never a speck on his perch. And you'd only to see him enjoy his bath to realize he had a real small passion for cleanliness. His bath was put in last. And the moment it was in he positively leapt into it. First he fluttered one wing, then the other, then he ducked his head and dabbled his breast feathers. Drops of water were scattered all over the kitchen, but still he would not get out. I used to say to him, "Now that's quite enough. You're only showing off." And at last out he hopped and, standing on one leg, he began to peck himself dry. Finally he gave a shake, a flick, a twitter and he lifted his throat—Oh, I can hardly bear to recall it. I was always cleaning the knives at the time. And it almost seemed to me the knives sang too, as I rubbed them bright on the board.

Company, you see—that was what he was. Perfect company. If you have lived alone you will realize how precious that is. Of course there were my three young men who came in to supper every evening, and sometimes stayed in the dining-room afterwards reading the paper. But I could not expect them to be interested in the little things that made my day. Why should they be? I was nothing to them. In fact, I overheard them one evening talking about me on the stairs as "the Scarecrow." No matter. It doesn't matter. Not in the least. I quite understand. They are young. Why should I mind? But I remember feeling so especially thankful that I was not quite alone that evening. I told him, after they had gone out. I said "Do you know what they call Missus?" And he put his head on one side and looked at me with his little bright eye until I could not help laughing. It seemed to amuse him.

Have you kept birds? If you haven't all this must sound, perhaps, exaggerated. People have the idea that birds are heartless, cold little creatures, not like dogs or cats. My washerwoman used to say on Mondays when she wondered why I didn't keep "a nice fox terrier," "There's no comfort, Miss, in a canary." Untrue. Dreadfully untrue. I remember one night. I had had a very awful dream—dreams can be dreadfully cruel—even after I had woken up I could not get over it. So I put on my dressing-gown and went down to the kitchen for a glass of water. It was a winter night and raining hard. I suppose I was still half asleep, but through the kitchen window, that hadn't a blind, it seemed to me the dark was staring in, spying. And suddenly I felt it was unbearable that I had no one to whom I could say "I've had such a dreadful dream," or—or "Hide me from the dark." I even covered my face for a minute. And then there came a little "Sweet! Sweet!" His cage was on the table, and the cloth had slipped so that a chink of light shone through. "Sweet! Sweet!" said the darling little fellow again, softly, as much as to say, "I'm here, Missus! I'm here!" That was so beautifully comforting that I nearly cried.

And now he's gone. I shall never have another bird, another pet of any kind. How could I? When I found him, lying on his back, with his eye dim and his claws wrung, when I realised that never again should I hear my darling sing, something seemed to die in me. My heart felt hollow, as if it was his cage. I shall get over it. Of course. I must. One can get over anything in time. And people always say I have a cheerful disposition. They are quite right. I thank my God I have.

All the same, without being morbid, and giving way to—to memories and so on, I must confess that there does seem to me something sad in life. It is hard to say what it is. I don't mean the sorrow that we all know, like illness and poverty and death. No, it is something different. It is there, deep down, deep down, part of one, like one's breathing. However hard I work and tire myself I have only to stop to know it is there, waiting. I often wonder if everybody feels the same. One can never know. But isn't it extraordinary that under his sweet, joyful little singing it was just this sadness—ah, what is it?—that I heard?

日本語訳(キャサリン・マンスフィールド『カナリア』)

※高精度翻訳を無効にしているので精度は低めです
玄関の扉の右側にある大きな釘が見えるでしょう?今でもそれを見るのがつらくて、それでいて取り外すことなどとてもできません。私がいなくなった後も、あの釘がずっとそこにあってほしいと思うのです。時々、次に住む人たちが「ここには鳥籠が下がっていたに違いない」と言っているのが聞こえるような気がします。それが私を慰めてくれるのです。あの子のことが完全に忘れ去られてしまったわけではないのだと感じられるから。

あの子がどんなに素晴らしく歌ったか、あなたには想像もつかないでしょう。他のカナリアの歌声とは全く違っていました。これは私の思い込みではありません。窓からよく見かけたのですが、人々が門のところで立ち止まって聞き入ったり、バイカウツギの垣根に長い間もたれかかって——すっかり心を奪われて——いるのです。あなたには馬鹿げて聞こえるかもしれませんが——あの子の歌声を聞いたことがあれば、そうは思わないでしょうが——本当に私には、あの子が始まりと終わりのある完全な歌を歌っているように思えたのです。

たとえば、午後に家事を済ませて、ブラウスを着替えてから、こちらのベランダに針仕事を持ち出すと、あの子は止まり木から止まり木へと、ぴょん、ぴょん、ぴょんと飛び移って、私の気を引こうとするように籠の格子を嘴で叩き、まるでプロの歌手のように少し水を飲んでから、あまりに美しい歌声を響かせるので、私は針を置いて聞き入らずにはいられませんでした。言葉では表現できません。できればよいのですが。けれど毎日午後になると必ず同じように歌ってくれて、私にはその一つ一つの音符が理解できるような気がしていました。

私はあの子を愛していました。なんと深く愛していたことでしょう!この世で何を愛するかということは、それほど重要なことではないのかもしれません。けれど人は何かを愛さずにはいられないものです。もちろん私には小さな家と庭がいつもありましたが、なぜかそれだけでは十分ではありませんでした。花々は素晴らしく応えてくれますが、共感はしてくれません。それで私は宵の明星を愛するようになったのです。馬鹿げて聞こえるでしょうか?日没後、裏庭に出て行き、暗いユーカリの木の上に輝くまでその星を待ったものでした。「そこにいるのね、愛しい人」と囁いたものです。そしてまさにその最初の瞬間、その星は私だけのために輝いているように思えました。この……憧れのようでいて憧れではない何かを、理解してくれているように思えたのです。あるいは悔恨——悔恨により近いものでしょうか。けれど一体何への悔恨なのでしょう?私には感謝すべきことがたくさんあるというのに。

でも、あの子が私の人生に入ってきてからは、宵の明星のことを忘れてしまいました。もうそれは必要なくなったのです。でも不思議なことでした。鳥を売りに戸口にやってきた中国人が、小さな籠の中であの子を持ち上げた時、哀れな小さなゴシキヒワたちのようにばたばたと羽ばたくのではなく、かすかで小さなさえずりを一声発したのです。その時私は、ユーカリの木の上の星に向かって言ったのとまったく同じように、「そこにいたのね、愛しい子」と言っている自分に気づきました。その瞬間から、あの子は私のものになったのです。

今でも思い出すと驚くのですが、あの子と私がどれほどお互いの生活を分かち合っていたことでしょう。朝、私が階下に降りて鳥籠の布を取ると、あの子は眠そうな小さな鳴き声で私を迎えてくれました。それが「奥さん!奥さん!」という意味だということが分かっていました。それから三人の下宿人の朝食を用意する間、あの子を外の釘に掛けておき、再び家に私たちだけになるまで中には入れませんでした。そして食器洗いが済むと、それはちょっとした見世物でした。テーブルの隅に新聞紙を敷いて、その上に鳥籠を置くと、あの子は何が始まるのか分からないとでもいうように、絶望的に羽をばたつかせるのです。「まったく小さな役者さんね」と私はよく叱ったものです。私が受け皿を掻き出し、新しい砂を撒き、餌と水の容器を満たし、ハコベの一片と唐辛子を半分、格子の間に挟んでやる。そしてこの小さな儀式の一つ一つを、あの子が理解し感謝していることを私は確信していました。というのも、あの子は生来極めて清潔好きだったのです。止まり木に一点の汚れもありませんでした。そしてあの子が水浴びを楽しむ様子を見れば、清潔さに対する真の小さな情熱を持っていることが分かりました。水浴び用の器は最後に入れられました。そして器が入れられた瞬間、あの子は文字通り飛び込んだのです。まず片方の翼をばたつかせ、次にもう片方を、それから頭を突っ込んで胸の羽毛を濡らしました。水滴が台所中に飛び散りましたが、それでもあの子は出ようとしませんでした。私はよく「もうそれで十分よ。見せびらかしているだけじゃない」と言ったものです。そしてついに飛び出すと、片足で立って、自分の体を嘴で乾かし始めるのです。最後に身震いをし、羽をひらめかせ、さえずって、喉を持ち上げて――ああ、思い出すのも辛いほどです。私はいつもその時にナイフを磨いていました。そして板の上でナイフを光るまで擦っていると、ナイフまでもが歌っているように思えたのです。

仲間、そう——それが彼の存在だったのです。完璧な仲間でした。一人暮らしをしたことがあるなら、それがどれほど貴重なものかお分かりでしょう。もちろん毎晩夕食を取りに来る三人の若い男性たちがいて、時には食後に食堂で新聞を読んでいることもありました。けれど私の一日を彩る小さなことがらに、彼らが関心を示してくれるなど期待できるはずもありません。なぜそうする必要があるでしょう?私は彼らにとって何でもない存在なのです。実際、ある晩階段で彼らが私のことを「案山子」と呼んで話しているのを耳にしてしまいました。構いません。構わないのです。少しも。よく理解しています。彼らは若いのですから。なぜ私が気に病む必要があるでしょう?けれどその晩、自分が完全に一人ではないということに、特別な感謝の気持ちを抱いたことを覚えています。彼らが出かけた後、彼に話しかけました。「あの人たちが奥さんのことを何て呼んでいるか知ってる?」と言うと、彼は首をかしげて、その小さな輝く瞳で私を見つめるのです。私は思わず笑ってしまいました。彼も面白がっているようでした。

あなたは鳥を飼ったことがおありですか?もしなければ、これまでのお話はきっと大げさに聞こえることでしょう。人々は鳥というものを、犬や猫とは違って、心のない冷たい小さな生き物だと思っているのです。洗濯女は月曜日になると、なぜ私が「素敵なフォックステリア」を飼わないのかと不思議がって、「カナリアなんて、お嬢さん、慰めにもなりませんよ」と言ったものでした。間違いです。ひどく間違っています。ある夜のことを覚えています。私はとても恐ろしい夢を見ました——夢というものは残酷なほど恐ろしいことがあるのです——目が覚めてからも、その夢から立ち直ることができませんでした。それで私はガウンを羽織って台所へ水を飲みに降りて行きました。冬の夜で、激しい雨が降っていました。まだ半分眠っていたのでしょうが、ブラインドのない台所の窓から、暗闇がじっと覗き込んで、監視しているように思えたのです。そして突然、「恐ろしい夢を見ました」とか——あるいは「暗闇から私を隠してください」と言える相手が誰もいないということが、耐え難く感じられました。一瞬、顔を覆ってしまったほどでした。するとそのとき、小さく「スイート!スイート!」という声が聞こえてきたのです。彼の籠はテーブルの上にあり、覆いの布がずれて光の筋が差し込んでいました。「スイート!スイート!」と、愛らしい小さな子がもう一度、やさしく鳴きました。まるで「ここにいますよ、奥さん!ここにいますよ!」と言っているかのように。それはあまりにも美しい慰めで、私は泣きそうになりました。

そして今、あの子はもういない。私はもう二度と鳥を飼うことはないでしょう、どんなペットも。どうしてそんなことができましょう?あの子が仰向けに倒れ、目は曇り、爪は縮こまっているのを見つけた時、もう二度と愛しいあの子の歌声を聞くことはないのだと悟った時、私の中で何かが死んでしまったようでした。私の心は空っぽに感じられました、まるであの子の鳥籠のように。いつかは立ち直るでしょう。もちろんです。そうしなければなりません。時が経てば何でも乗り越えられるものです。そして人々はいつも私が明るい性格だと言います。その通りです。そんな性格に恵まれたことを神に感謝しています。

それでも、病的になったり、思い出などに——思い出などに身を委ねたりするわけではないのですが、人生には何か悲しいものがあるように思えると告白せざるを得ません。それが何なのかを言葉にするのは難しいのです。病気や貧困や死といった、私たちが皆知っている悲しみのことを言っているのではありません。いえ、それとは違うものです。それは心の奥深く、奥深くに存在し、呼吸のように自分の一部となっているのです。どんなに一生懸命働いて疲れ果てても、立ち止まりさえすれば、それがそこにあって待っているのがわかるのです。誰もが同じように感じているのかしらと、よく思うのです。でも知ることはできません。けれど不思議ではありませんか、あの子の甘く喜びに満ちた小さな歌声の下に、私が聞いていたのはまさにこの悲しみだったのです——ああ、それは一体何なのでしょう?

かかったコスト

  • 工数:2人日程度
    アプリ開発ほぼ未経験でも生成AIを使えば、裏でアニメを見ながら土日で簡単なアプリ開発はできる
  • コード作成:無料
    コード作成は無料のCopilot In WindowsとGithub Copilotのフリープランでまかなえた。
    Github Copilotはフリープランの月50チャットのうち82%を使用したもよう。
    月にアプリ一つまでならフリープランでも十分かもしれない。
  • 翻訳処理:『断食芸人』で$1程度
    今回は翻訳処理にはBedrockを使っているため、翻訳の度に課金される。
    『断食芸人』を高精度翻訳した場合で、入力トークンが253510、出力トークンが17211。計算すると大体$1.018695になるはず。
    『断食芸人』であればブックオフで探せば100円でも買えそうだけれども、一から翻訳してくれると考えると破格の金額。
    現状プロンプトキャッシュをうまく使えないために長い文章は費用が怖くて翻訳できていないが、個人的には『トニオ・クレーゲル』ぐらいの分量を安価に翻訳できると嬉しい。
    ⇒追記:コード修正しプロンプトキャッシュがかかるようになりました。キャッシュがかかると高精度翻訳の場合『断食芸人』で$0.521124程度です。

課題

  • 現状はBedrockのapiにしか対応していない(Google Geminiなどを使えるようにすれば無料でも翻訳可能になるはず)
  • デバッグなどは全然できていない。途中でAIが生成した不要なコード達は邪魔なので整理したい。

所感

  • 最初から機能をたくさん盛り込もうとすると大変
    当初考えなしに再翻訳機能を実装しようとしたが、あとから追加したほうがたぶん簡単に実装ができた。スモールスタートで始めたほうがいいのかもしれない。
  • 深く考えず見切り発車で進めたものの、案外形になった
    思いつきで始めたものの、案外すんなりと形になったのはうれしい
  • サクサク行くときは行くけれども、一度詰まるとプロンプトを工夫しても結構沼る
    一度沼にはまると、プロンプトを工夫してもなかなか前に進んでくれない感触があった。
    AIに指示をする前に一度コミットしておいて、AIの修正の路線が怪しいときはすぐ切り戻して仕切り直すようにしたら結構円滑に開発が進むようになった
  • BedrockのAPI周りはかなりデタラメな出力が多い印象だった
    いわゆるハルシネーションというやつなのか、存在しないパラメータやコマンドを出力しまくり結局人力で公式ドキュメントを読む羽目になった。ドキュメントなんか読みたくないからAIに聞いているのに!
脚注
  1. 人間は基本的にチャット等でAIに指示を出したりするだけで、コードの作成はほとんどAIに任せるコーディング手法 ↩︎

Discussion