AndroidのローカルAIで読書ブログ投稿アプリを作ることにした 03

2026年9月11日金曜日

Androidアプリ プログラミング

t f B! P L

読書ブログAI開発記・第3回|動いたGemmaを「自分の感想を記事にするアプリ」へ

前回は、Pixel 10 Pro FoldのTensor G5を使い、自作AndroidアプリからGemma 4で日本語を生成するところまで確認した。今回は、そのAIを本来の目的だった読書ブログアプリへ組み込む。モデルを動かすテストから、本の写真と感想を入力する道具へ。ようやく読書ブログの話に戻ってきた。
今回のサンプルについて
掲載コードは、今回のアプリの実装から抜粋したものと、仕組みを説明するために短くしたものです。単独でアプリ全体になるコードではありません。前回までのKotlinプロジェクトと、モデルを初期化済みのLocalLlmManagerを前提にしています。

個人用のパッケージ名、ブログURL、メールアドレス、ID、端末内のパスは掲載していません。入力例には架空の書名・著者名を使っています。

コードの登場人物も整理しておく。LocalLlmManagerは前回のAI実行クラス、AiBookArticleGeneratorは今回の記事用プロンプトと応答を扱う自作クラス、NdlBookMetadataClientは書誌情報を検索する自作クラスである。後ろ二つは、追加ライブラリを導入すると自動で使える名前ではない。

そういえば、作りたかったのは読書ブログアプリだった

前回までで、端末内のAIを動かす部分には目処が付いた。ただし、画面にプロンプトを入力して日本語が返ってくるだけでは、読書記録を残すたびに同じ指示を書かなければならない。

TPUで動いた。ちゃんと日本語も返ってきた。
前回の目標は達成したな。
で、そろそろ本の感想を書きたいんだけど。
そうだった。読書ブログアプリを作ってたんだったな。
そこ忘れないで。AIを動かすのが趣味になりかけてるから。
ここからは、そのAIを普段使う画面につなぐ。

必要なのは、毎回AIへの指示を考える画面ではなく、本の情報と自分の感想を入れれば、記事の形まで整えてくれる画面である。

入力するのは、本の情報と自分の感想

投稿画面では、本の写真、タイトル、著者名、5段階の総合評価、個人的な感想を扱う。今回仕上げた写真付き投稿では、写真とタイトル、評価、感想を確認してから記事作成へ進む。著者名は未入力でも処理できる構成にした。

感想って、最初からちゃんとした文章で書かないとダメ?
そこを整えるのがAIの仕事だ。自分がどう思ったかを書けばいい。
「前半は少し読みにくかった。でも後半は続きが気になった」くらいでも?
そういうメモを、意味を保ったまま文章にしてもらう。
勝手に「最初から最後まで最高でした」にしないでよ。
指示にも、本人の意味と温度感を保つことを入れた。

AIには、感想の意見や評価を入力内容に基づかせ、根拠のない長所や短所を無理に足さないよう指示している。文章が立派になっても、自分が思っていないことを書かれては読書記録として困る。

例えば先ほどのメモなら、「前半は読み進めるのに少し時間がかかったが、後半になると展開が気になり、続きが読みたくなった」といった整え方が欲しい。これは説明用の例だが、目指す方向はこういう補助である。

入力を、AIへ渡すデータにまとめる

まずは実装で使っている入力データ型。AiBookArticleGenerator内に定義している。写真はこのデータに含めず、投稿用の画像として別に扱う。今回、写真をAIへ渡してOCRや内容解析をさせているわけではない。

data class Request(
        val enteredTitle: String,
        val enteredAuthor: String,
        val overallRating: Int,
        val personalImpression: String,
        val verifiedMetadata: NdlBookMetadataClient.BookMetadata? = null
    )

画面の入力値から、このデータを作る部分は次のようになる。画面の部品を結び付ける処理や、必須入力の確認は省略した。

val request = AiBookArticleGenerator.Request(
    enteredTitle = editPostBookTitle.text?.toString().orEmpty().trim(),
    enteredAuthor = editPostAuthorName.text?.toString().orEmpty().trim(),
    overallRating = ratingPostOverall.rating.toInt().coerceIn(1, 5),
    personalImpression =
        editPostOverallImpression.text?.toString().orEmpty().trim()
)

例えば、画面の代わりにテスト値を渡すならこうなる。書名と著者名は説明用の架空の値で、書誌検索が成功するテストデータではない。

val request = AiBookArticleGenerator.Request(
    enteredTitle = "サンプル小説",
    enteredAuthor = "サンプル著者",
    overallRating = 4,
    personalImpression =
        "前半は少し読みにくかった。でも後半は続きが気になった。"
)

書名と著者名は、AIの知識だけに任せない

本の名前には略称や表記揺れがある。正式なタイトル、著者名、出版社、発売日も記事へ載せたい。しかし、その情報まで生成AIの知識だけで埋めると、見た目だけもっともらしい間違いが入りかねない。

そこで、AI生成前に国立国会図書館サーチへ書誌情報を問い合わせる処理を加えた。著者名を入力した場合は、書名と著者名を組み合わせて検索する。

正式なタイトルって、Gemmaに聞けば分かるんじゃないの?
分かる場合もある。ただ、別の本や別の版と混ざる可能性がある。
そこで図書館のデータを使うのか。
取得できた書誌情報を優先する。文章を整える処理と、本の基本情報を確認する処理を分けた。
見つからなかったら?
その場合はAIの知識を使う。ただし、確信がないときは入力を残し、分からない出版社や発売日を作らないよう指示している。

取得できた値は、AIへの指示に渡すだけでなく、生成結果を読み取る側でも優先して使う。一方で、検索結果の取り違えやAIの補完が完全になくなるわけではない。書誌情報やあらすじも、完成した下書きで確認する対象になる。

書名と著者名を組み合わせて検索する

NdlBookMetadataClient内で使っている検索条件の組み立て部分を抜粋する。著者名があればAND条件へ加える。

private fun buildCqlQuery(title: String, author: String): String {
        val titleCondition = "title=\"${escapeCql(title)}\""
        if (author.isBlank()) return titleCondition
        return "$titleCondition AND creator=\"${escapeCql(author)}\""
    }

    private fun escapeCql(value: String): String =
        value.replace("\\", "\\\\").replace("\"", "\\\"")

検索には国立国会図書館サーチのSRU APIを使い、返されたXMLからタイトル・著者・出版社・日付などを読み取る。実装では候補の一致度も確認し、著者未入力で同名書籍の著者が分かれる場合には、検索結果を採用しない処理を入れた。通信とXML解析の全体は長いため、ここでは呼び出し側を示す。

// Coroutine内で実行する。NdlBookMetadataClientは自作の検索クラス。
val metadata = NdlBookMetadataClient()
    .findMostLikelyBook(
        enteredTitle = request.enteredTitle,
        enteredAuthor = request.enteredAuthor
    )
    .getOrNull()

val groundedRequest = request.copy(verifiedMetadata = metadata)

この実装では、通信失敗や一致する候補がない場合はnullとなり、書誌情報なしでAI生成へ進む。検索で確認した情報があるかどうかは、画面の処理状況にも表示する。

AIには記事の中身を作らせ、HTMLはアプリで組み立てる

生成するのは、正式タイトル、著者名、あらすじ、清書した感想、良かった点、気になった点、総評、おすすめする人など。これらをJSONという項目付きのデータで返してもらい、アプリ側で決まったHTMLへ当てはめる。

記事のHTMLまで、全部AIに書かせるわけじゃないのね。
中身を項目ごとに返してもらって、見た目はアプリ側のテンプレートで揃える。
毎回、見出しや配置が変わらないのはいい。
感想は本ごとに違っていいけど、記事の骨組みまで毎回変える必要はないからな。

総合評価には自分で選んだ星の数を使う。ストーリー、キャラクター、読みやすさの詳細評価はAIが返した値を使うため、こちらは自分が直接入力した採点とは区別して確認したい。

AIの応答を記事データとして読み取れなければ、その時点で止まり、投稿処理は開始しない。文章が返ってきたことと、記事に使えるデータができたことを分けている。

実際にGemmaへ渡しているプロンプト

具体的に、AIには何てお願いしてるの?
「いい感じに記事にして」だけではなく、入力、確認できた書誌情報、文章のルール、返してほしい項目をまとめて渡している。

以下はAiBookArticleGeneratorのプロンプト作成メソッド。Requestと同じオブジェクト内に置いている。

fun buildPrompt(request: Request): String {
        val authorCondition = if (request.enteredAuthor.isBlank()) {
            "著者名は未入力です。タイトルから最も可能性が高い著者名を補ってください。"
        } else {
            "タイトルと著者名をAND条件として同じ書籍を特定し、両方を正式表記へ直してください。"
        }
        val verifiedMetadata = request.verifiedMetadata?.let { metadata ->
            """
                【国立国会図書館サーチで確認した書誌情報】
                正式タイトル: ${metadata.title}
                著者名: ${metadata.author.ifEmpty { "(記録なし)" }}
                出版社: ${metadata.publisher.ifEmpty { "(記録なし)" }}
                発売日: ${metadata.publicationDate.ifEmpty { "(記録なし)" }}
                この欄に値がある項目は信頼できる書誌情報です。表記を変更せず使用してください。
            """.trimIndent()
        }.orEmpty()

        return """
            あなたは日本語の書籍情報を整理し、個人の読書感想をブログ記事へ清書する編集者です。

            【入力】
            タイトル: ${request.enteredTitle.trim()}
            著者名: ${request.enteredAuthor.trim().ifEmpty { "(未入力)" }}
            総合評価: ${request.overallRating.coerceIn(1, 5)} / 5
            個人的な感想:
            ${request.personalImpression.trim()}

            $verifiedMetadata

            【書籍の特定】
            - $authorCondition
            - 入力には略称、表記揺れ、軽い誤字が含まれる可能性があります。
            - モデルが知っている書誌情報に基づいて、correctedTitleとcorrectedAuthorを正式表記にしてください。
            - 確信がない場合は推測で別の本にせず、入力値をそのまま使ってください。
            - 出版社や発売日が分からない場合は空文字にしてください。架空の情報を作らないでください。

            【記事作成】
            - 感想の評価や意見は、入力された「個人的な感想」だけを根拠にしてください。
            - polishedReviewは本人の意味と温度感を保ち、自然な日本語へ清書・校正してください。
            - reviewHeadingは感想内容を表す短い見出しにしてください。
            - synopsisは作品の核心的なネタバレを避け、2~4文で簡潔にしてください。
            - goodPoints、concernPoints、recommendedForは各0~3件の短い文章にしてください。
            - 感想に根拠がない長所・短所を無理に追加しないでください。
            - storyRating、characterRating、readabilityRatingは1~5の整数にしてください。
            - 引用文は生成しないでください。

            次のキーを持つJSONオブジェクトだけを出力してください。
            Markdown、コードフェンス、前置き、後書きは出力しないでください。

            {
              "correctedTitle": "正式な書名",
              "correctedAuthor": "正式な著者名",
              "publisher": "出版社または空文字",
              "publicationDate": "YYYY年MM月DD日、分かる範囲、または空文字",
              "synopsis": "簡潔なあらすじ",
              "reviewHeading": "感想の見出し",
              "polishedReview": "清書した感想本文",
              "goodPoints": ["良かった点"],
              "concernPoints": ["気になった点"],
              "verdict": "総評",
              "recommendedFor": ["おすすめする人"],
              "storyRating": 1,
              "characterRating": 1,
              "readabilityRating": 1
            }
        """.trimIndent()
    }

JSONの例に書かれた星の数は、返答形式を示すための値である。本人が入力した総合評価は別に保持する。詳細評価やあらすじはAI生成なので、内容の確認は必要になる。プロンプトに禁止事項を書けば必ず守られる、という保証ではない。

前回動いたAIへ渡し、応答を記事データとして読む

記事生成の中心部分を、送信処理と切り離して短くすると次のようになる。ActivityのCoroutine内で呼び出す例であり、モデルは事前に読み込んでおく。二重実行を防ぐボタン制御などは省略している。

// 必要なimport: kotlinx.coroutines.launch
// mainScopeはActivity側で管理するCoroutineScope。
// localLlmManagerは初期化・モデル読み込み済みの自作クラス。
mainScope.launch {
    val metadata = NdlBookMetadataClient()
        .findMostLikelyBook(request.enteredTitle, request.enteredAuthor)
        .getOrNull()
    val groundedRequest = request.copy(verifiedMetadata = metadata)

    val rawResponse = localLlmManager.generate(
        AiBookArticleGenerator.buildPrompt(groundedRequest)
    ).getOrElse { error ->
        textPostFormStatus.text = "AI生成に失敗しました: ${error.message}"
        return@launch
    }

    val article = runCatching {
        AiBookArticleGenerator.parse(rawResponse, groundedRequest)
    }.getOrElse { error ->
        textPostFormStatus.text = "記事データを読み取れません: ${error.message}"
        return@launch
    }

    editPostBookTitle.setText(article.correctedTitle)
    editPostAuthorName.setText(article.correctedAuthor)
    textPostFormStatus.text = "記事データを作成しました。"
    // 完成版では、このあとHTMLを組み立て、Blogger連携へ進む。
}

generate()は前回作った自作メソッドで、Result<String>を返す。ここでクラウドのChatGPT APIを呼んでいるわけではない。既存のローカルAI実行処理へ、記事用に組み立てたプロンプトを渡している。

生成された値を、そのまま無条件に採用しない

parse()ではJSONを読み、書誌情報が取れている項目はそちらを優先する。実装中のタイトル決定部分は次の通り。これはGeneratedArticle(...)へ渡す名前付き引数の抜粋である。

correctedTitle = request.verifiedMetadata?.title
    ?.takeIf(String::isNotBlank)
    ?: cleanTitle(
        json.optString("correctedTitle"),
        request.enteredTitle
    ),

jsonは応答を読み取ったorg.json.JSONObjectcleanTitle()は外側のかぎ括弧などを整え、空欄なら入力した書名へ戻す自作メソッドである。本文が空なら元の感想を使い、詳細評価が1〜5の範囲外なら入力した総合評価へ戻す処理も入れている。

HTMLを作るときは、文字をエスケープする

感想の中に、記号やHTMLみたいな文字があったら?
文章はHTMLの命令にせず、文字として表示する。そのためにエスケープしてからテンプレートへ入れる。

実際のHTMLは、書誌情報、星、あらすじ、良かった点、総評などを含む。ここでは仕組みが見えるように、タイトルと感想を表示する短い関数にした。

import android.text.TextUtils

fun buildReviewHtml(title: String, review: String): String {
    val safeTitle = TextUtils.htmlEncode(title.trim())
    val safeReview = TextUtils.htmlEncode(review.trim())
        .replace("\n", "<br>")

    return """
        <article class="book-review">
          <h1>『$safeTitle』</h1>
          <section class="review">
            <h2>読んで感じたこと</h2>
            <p>$safeReview</p>
          </section>
        </article>
    """.trimIndent()
}

先に文字をエスケープし、そのあと改行を<br>へ変えている。AIには文章の中身を作らせ、HTMLの構造はコードで固定する。これで、本が変わっても記事の基本的な並びを揃えられる。

Amazonへのリンクも記事へ組み込む

Amazonへのリンクは、書名と著者名を検索語として組み立てる。商品を一意に確定するリンクではなく、本を探すための検索リンクである。

設定画面にアソシエイトのトラッキングIDを登録してあればタグを付け、未設定なら通常のAmazonリンクにする。アフィリエイトリンクを使う記事には、その旨の表示も加えた。

毎回リンクを貼り直す必要は?
設定しておけば、記事を組み立てるときに付く。
感想を書いたあとに、細かい作業を繰り返さなくて済むわけね。
そのための専用アプリだ。

検索リンクをコードで作る

実装で使っているUri.Builderによるリンク生成を、独立した関数に整理した。書名と著者名を検索語にし、トラッキングIDがある場合だけtagを付ける。

import android.net.Uri

fun buildAmazonSearchUrl(
    title: String,
    author: String,
    trackingId: String = ""
): String {
    val keywords = listOf(title.trim(), author.trim())
        .filter(String::isNotBlank)
        .joinToString(" ")

    val builder = Uri.Builder()
        .scheme("https")
        .authority("www.amazon.co.jp")
        .path("s")
        .appendQueryParameter("k", keywords)

    if (trackingId.isNotBlank()) {
        builder.appendQueryParameter("tag", trackingId.trim())
    }
    return builder.build().toString()
}

// 空欄なら通常リンク。個人のアソシエイトIDは掲載しない。
val amazonUrl = buildAmazonSearchUrl(
    title = "サンプル小説",
    author = "サンプル著者",
    trackingId = ""
)

完成版では、設定画面に保存したトラッキングIDを渡す。生成したURLをHTMLの属性へ埋め込む際もエスケープし、アソシエイトリンクの場合はrel="sponsored noopener noreferrer"と記事内の案内文を加える。

文章は端末内で作る。次はBloggerへ届ける

今回の文章生成は、前回動作を確認した端末内のGemmaを使う。開発相談やこのブログ記事の作成に使っているChatGPTと、アプリに組み込んだAIは別である。

ただし、アプリ全体がオフラインで完結するわけではない。書誌情報の検索やBloggerへの送信には通信を使う。ローカルで行うのは、ここでいう文章生成の部分だ。

これで本の写真と感想から、記事の中身はできる。
次はBloggerへの投稿だな。
HTMLを送れば終わりでしょ?
写真がある。
……また何かあるの?

第4回は、この写真付き記事をBloggerへ届ける仕組み。最終的には、メール投稿とAPIを組み合わせる構成になった。

この記事について

この記事は実際の開発内容をもとに、説明の順序を整理し、登場人物の会話として再構成しています。構成・文章・会話の作成にはOpenAIのChatGPTを使用しました。サンプルコードはアプリの実装をもとに抜粋・整理し、個人を特定する設定値は掲載していません。アプリ内の文章生成には、端末内で実行するGemmaを使用しています。

検索

年月ごとアーカイブ

フレンドブログ

Translate

自己紹介

自分の写真
映画が好き! 音楽好き! 演奏も好き! ミキシングが一番好き!

QooQ