読書ブログAI開発記・第3回|動いたGemmaを「自分の感想を記事にするアプリ」へ
掲載コードは、今回のアプリの実装から抜粋したものと、仕組みを説明するために短くしたものです。単独でアプリ全体になるコードではありません。前回までのKotlinプロジェクトと、モデルを初期化済みの
LocalLlmManagerを前提にしています。個人用のパッケージ名、ブログURL、メールアドレス、ID、端末内のパスは掲載していません。入力例には架空の書名・著者名を使っています。
コードの登場人物も整理しておく。LocalLlmManagerは前回のAI実行クラス、AiBookArticleGeneratorは今回の記事用プロンプトと応答を扱う自作クラス、NdlBookMetadataClientは書誌情報を検索する自作クラスである。後ろ二つは、追加ライブラリを導入すると自動で使える名前ではない。
そういえば、作りたかったのは読書ブログアプリだった
前回までで、端末内のAIを動かす部分には目処が付いた。ただし、画面にプロンプトを入力して日本語が返ってくるだけでは、読書記録を残すたびに同じ指示を書かなければならない。
必要なのは、毎回AIへの指示を考える画面ではなく、本の情報と自分の感想を入れれば、記事の形まで整えてくれる画面である。
入力するのは、本の情報と自分の感想
投稿画面では、本の写真、タイトル、著者名、5段階の総合評価、個人的な感想を扱う。今回仕上げた写真付き投稿では、写真とタイトル、評価、感想を確認してから記事作成へ進む。著者名は未入力でも処理できる構成にした。
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生成前に国立国会図書館サーチへ書誌情報を問い合わせる処理を加えた。著者名を入力した場合は、書名と著者名を組み合わせて検索する。
取得できた値は、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へ当てはめる。
総合評価には自分で選んだ星の数を使う。ストーリー、キャラクター、読みやすさの詳細評価はAIが返した値を使うため、こちらは自分が直接入力した採点とは区別して確認したい。
AIの応答を記事データとして読み取れなければ、その時点で止まり、投稿処理は開始しない。文章が返ってきたことと、記事に使えるデータができたことを分けている。
実際にGemmaへ渡しているプロンプト
以下は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.JSONObject、cleanTitle()は外側のかぎ括弧などを整え、空欄なら入力した書名へ戻す自作メソッドである。本文が空なら元の感想を使い、詳細評価が1〜5の範囲外なら入力した総合評価へ戻す処理も入れている。
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への送信には通信を使う。ローカルで行うのは、ここでいう文章生成の部分だ。
第4回は、この写真付き記事をBloggerへ届ける仕組み。最終的には、メール投稿とAPIを組み合わせる構成になった。
この記事は実際の開発内容をもとに、説明の順序を整理し、登場人物の会話として再構成しています。構成・文章・会話の作成にはOpenAIのChatGPTを使用しました。サンプルコードはアプリの実装をもとに抜粋・整理し、個人を特定する設定値は掲載していません。アプリ内の文章生成には、端末内で実行するGemmaを使用しています。

0 件のコメント:
コメントを投稿