対象読者
- Kotlin / Spring Boot でアプリを作っていて、LLM にアプリの状態を渡すプロンプトを組み立てたい個人開発者
- RAG やベクトル DB のような大掛かりな仕組みを導入する前に、まず素朴な実装で動かしたい人
動作環境
- Kotlin 2.1.21 / Spring Boot 3.5.16(Java 21 toolchain)
- 個人開発中のチーム用ワークスペース「Kurari」(リポジトリ: https://github.com/tanishi-z/Kurari )のバックエンドコード(2026-08-23 時点)が根拠
Kurari はボード・ドキュメント・チャット・AI 出力を1画面に統合するツールです。AI ジョブの実行エンジンはユーザーのローカル環境にある copilot / Apple Intelligence / Ollama のいずれかで、backend はどのエンジンが使われるかを意識しません。ボードに付箋を貼ったり、AI に「このボードを要約して」と頼んだりすると、backend が動き出します。ボードの中身を Markdown 風のテキストに組み立てて、これらのエンジンに渡すプロンプトへ載せます。この直列化を担っているのが ContextBuilder というコンポーネントです。
この記事では ContextBuilder(全350行)の実装を、実際のコードを引用しながら解説します。「なぜこの設計にしたか」というトレードオフの話は別記事に譲り、ここでは「どう実装されているか」に絞ります。
全体の構造
ContextBuilder は Spring の @Component で、NodeRepository(ボード・付箋・ドキュメントなどを1つのツリーで持つエンティティのリポジトリ)と EdgeRepository(矢印)に依存しています。
@Component
class ContextBuilder(
private val repo: NodeRepository,
private val edgeRepo: EdgeRepository,
) {
companion object {
const val MAX_CHARS = 8000
private val TEXT_TYPES = setOf(NodeType.sticky, NodeType.text_card, NodeType.shape)
}
公開メソッドは用途ごとに分かれています。
-
buildBoardContext— ボード1件を丸ごと直列化 -
buildOrganizeBoardContext— ボード整理用(要素に番号を振る) -
buildDocumentContext— ドキュメント1件を直列化 -
buildSelectionContext— ユーザーが選択した要素だけを直列化 -
buildChatContext— チャット履歴を直列化 -
buildProjectContext— プロジェクト横断でこれらをまとめる
共通しているのは、どの関数も最後に truncate を通してから返す点です。
truncate: 文字数を切る最後の砦
private fun truncate(text: String, maxChars: Int): String =
if (text.length > maxChars) text.take(maxChars) + "\n…(truncated)" else text
やっていることは text.length > maxChars なら take(maxChars) して "\n…(truncated)" を付けるだけです。文の途中で切れても気にしません。
この素朴さには理由があります。ContextBuilder の各関数はセクションの再帰やコメントの列挙など、内部で文字列を積み上げていく処理が多く、「今何文字使ったか」を逐一計算しながら組み立てるとコードが複雑になります。代わりに、まず組み立てたいだけ組み立てて、最後に truncate で一括りに切る方針にしています。buildBoardContext も buildDocumentContext も buildChatContext も、内部処理は一切文字数を気にせず、return truncate(sb.toString(), maxChars) の1行で締めています。
途中で切れて構文が壊れても、渡す先は人間ではなく LLM です。文末に …(truncated) と付けておけば「ここで打ち切られた」と伝わり、実用上困りません。
appendSectionTree: セクションの再帰とパス表記
ボードにはセクション(付箋や図形をグルーピングする枠)があり、セクションはネストできます。これを見出しの階層として出力するのが appendSectionTree です。
/** セクションを再帰的に出力する。入れ子は「親 > 子」のパス表記 */
private fun appendSectionTree(sb: StringBuilder, section: NodeEntity, path: String) {
val name = if (path.isEmpty()) section.name else "$path > ${section.name}"
sb.appendLine()
sb.appendLine("## Section: $name")
val children = repo.findByParentIdAndDeletedAtIsNull(section.id)
val items = children.filter { it.type in TEXT_TYPES }
val subSections = children.filter { it.type == NodeType.section }
if (items.isEmpty() && subSections.isEmpty()) sb.appendLine("- (空)")
for (item in items) appendItem(sb, item)
for (sub in subSections) appendSectionTree(sb, sub, name)
}
path.isEmpty() の分岐で、トップレベルのセクションはそのまま名前を、ネストしたセクションは "親 > 子" という文字列を見出しにしています。3段ネストなら "親 > 子 > 孫" と伸びていく仕組みです。Markdown の ## を深さに応じて増やす(###, ####...)のではなく、見出しレベルは常に ## のまま名前で階層を表現しています。これは、セクションが何段ネストしても出力側の見出し構造を複雑にしないための実装です。
子要素は TEXT_TYPES(付箋・テキストカード・図形)とサブセクションに分けて集計し、両方空なら "- (空)" と出します。空のセクションを LLM に見せるとき、何も書かないと「取得漏れなのか、本当に空なのか」が曖昧になります。それを防ぐための1行です。
buildBoardContext: ボード1件をMarkdownへ
buildBoardContext はボードを丸ごと直列化するメイン関数です。
fun buildBoardContext(boardId: UUID, maxChars: Int = MAX_CHARS): String {
val board = repo.findById(boardId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "board not found: $boardId")
}
val children = repo.findByParentIdAndDeletedAtIsNull(boardId)
val sections = children.filter { it.type == NodeType.section }
val items = children.filter { it.type in TEXT_TYPES }
// 矢印の端点はセクション内(入れ子含む)の要素・画像・手描きにも付くため、
// 名前解決は全子孫で行う
val all = mutableListOf<NodeEntity>()
fun collect(parentId: UUID) {
for (n in repo.findByParentIdAndDeletedAtIsNull(parentId)) {
all.add(n)
if (n.type == NodeType.section) collect(n.id)
}
}
collect(boardId)
val byId = all.associateBy { it.id }
val totalItems = all.count { it.type in TEXT_TYPES }
...
まずボード直下の子(セクションと通常のアイテム)を分けて取得します。次に collect() というローカル関数で、セクションの中まで含めた全子孫を all に集めます。これが必要な理由は、矢印(Edge)がセクションの中の要素にもつながるからです。矢印の描画名を解決するには「ボード直下」だけでなく「セクション配下」の要素も byId で引けるようにしておく必要があります。
出力部分は次の順で組み立てます。
val sb = StringBuilder()
sb.appendLine("# Board: ${board.name} ($totalItems items)")
sb.appendLine()
sb.appendLine("## Items")
for (item in items) appendItem(sb, item)
// セクション(入れ子はパス表記)は見出しとして構造を伝える
for (section in sections) appendSectionTree(sb, section, "")
タイトルに (N items) と件数を添えているのは、truncate で末尾が切られたときに LLM が「全部でいくつあったか」を見失わないようにするためです。
コメントピンの直列化
val pins = all.filter { it.type == NodeType.comment_pin }
if (pins.isNotEmpty()) {
sb.appendLine()
sb.appendLine("## コメント(ピン)")
for (pin in pins) {
val comments = repo.findByParentIdAndDeletedAtIsNull(pin.id)
.filter { it.type == NodeType.comment }
val thread = comments.joinToString("; ") { comment ->
val author = comment.data["author"] ?: "?"
val body = (comment.data["text"] as? String ?: "").replace("\n", " ")
"$author: $body"
}
sb.appendLine("- [コメントピン] \"${pin.name}\"${if (thread.isBlank()) "" else " ($thread)"}")
}
}
コメントピンはボード上の特定位置に立てるスレッド付きコメントです。1個のピンに複数のコメントがぶら下がるので、joinToString("; ") で "author: text" を ; 区切りに連結してから1行にまとめています。スレッドが空なら括弧ごと省略する三項演算子相当の分岐(if (thread.isBlank()) "" else " ($thread)")も、空の () を出さないための細かい配慮です。
Connections: 矢印の直列化
val edges = edgeRepo.findByBoardIdAndDeletedAtIsNull(boardId)
if (edges.isNotEmpty()) {
sb.appendLine()
sb.appendLine("## Connections(矢印: 要素間の関係。source → target の向き)")
for (e in edges) {
val src = endpointName(e.data["sourceFree"], byId[e.sourceNodeId])
val dst = endpointName(e.data["targetFree"], byId[e.targetNodeId])
val label = if (e.label.isNotBlank()) " [${e.label}]" else ""
sb.append("- \"$src\" →$label \"$dst\"")
// 色分けはユーザーの意図(例: 赤=リスク)を含みうるので、デフォルト以外は添える
val color = e.data["color"] as? String
if (!color.isNullOrBlank() && color != "gray") sb.append("(${color}の矢印)")
sb.appendLine()
}
}
矢印は "A" →[label] "B" という形式で出力します。ラベルがなければ → だけ、あれば →[ラベル] になります。
色の扱いが少し面白いところです。矢印には色を付けられますが、デフォルトの gray はわざわざ言及せず、それ以外の色(赤・青など)のときだけ (${color}の矢印) を末尾に付けます(99〜101行目)。この判断の理由は対になる Zenn 記事に譲りますが、コードとしては color != "gray" の1条件で分岐しているだけのシンプルな実装です。
endpointName は矢印の端点名を解決するヘルパーです。
private fun endpointName(free: Any?, item: NodeEntity?): String = when {
free != null -> "(接続なしの端点)"
item == null -> "?"
item.type == NodeType.image -> "(画像)"
item.type == NodeType.drawing -> "(手描き)"
else -> shortText(item)
}
矢印はどの要素にもつながっていない「フリー端点」を持てます(sourceFree/targetFree)。判定は上から順に次のように行われます。
- フリー端点なら「(接続なしの端点)」
-
byIdに見つからなければ"?" - 画像や手描きならそれぞれ専用の表記
- どれにも当てはまらなければテキストの先頭30文字(
shortText)
buildOrganizeBoardContext: 番号とUUIDを分離する
ボードの「未分類の要素を自動でセクションに振り分けて」という AI 機能で使われるのが buildOrganizeBoardContext です。戻り値の型がここまでの String ではなく OrganizeContext になっています。
data class OrganizeContext(val context: String, val legend: List<String>)
/**
* ボード直下の未分類要素に安定した番号を割り当てる。
* legend はジョブ作成時の対応表として payload に保存し、結果適用時の drift を防ぐ。
*/
fun buildOrganizeBoardContext(boardId: UUID): OrganizeContext {
val board = repo.findById(boardId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "board not found: $boardId")
}
val children = repo.findByParentIdAndDeletedAtIsNull(boardId)
val items = children.filter { it.type in TEXT_TYPES }
val sections = children.filter { it.type == NodeType.section }
val legend = items.map { it.id.toString() }
val sb = StringBuilder()
sb.appendLine("# Board: ${board.name}")
sb.appendLine()
sb.appendLine("## 未分類の要素")
for ((index, item) in items.withIndex()) {
sb.appendLine("- (${index + 1}) ${describe(item)}")
}
for (section in sections) appendSectionTree(sb, section, "")
return OrganizeContext(truncate(sb.toString(), MAX_CHARS), legend)
}
ポイントは legend です。未分類の要素は (1) (2) (3)... という短い番号で LLM に見せます(125〜127行目)。実際の要素は UUID を持っています。UUID をそのままプロンプトに並べると、それだけでトークンを大量に消費するうえ、LLM がそのまま復唱する保証もありません。
そこで legend に items.map { it.id.toString() } という「番号順の UUID リスト」を持たせ、これを context とは別に返します。呼び出し側はこの legend をジョブの payload に保存しておきます。LLM が "(1) を『企画』セクションへ" のように番号で回答してきたら、legend[0] を引いて実際の UUID に解決し直してから DB を更新する、という流れです。
このパターンを採用した理由(driftを防ぐ仕組み)は対になる Zenn 記事で扱います。ここでは実装だけを見ておきます。legend は items.withIndex() の順序でそのまま作られた配列なので、context 側の (${index + 1}) と legend[index] が常に対応する、という単純な仕組みです。
buildDocumentContext / flattenBlock: BlockNoteの再帰変換
Kurari のドキュメント機能はリッチテキストエディタの BlockNote を使っており、本文は data.content にブロックの配列として保存されています。これを Markdown 風のテキストに変換するのが buildDocumentContext と flattenBlock です。
/** ドキュメント本文(data.content = BlockNoteのブロック配列)をMarkdown風テキストに直列化 */
fun buildDocumentContext(docId: UUID, maxChars: Int = 6000): String {
val doc = repo.findById(docId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "document not found: $docId")
}
val sb = StringBuilder()
sb.appendLine("# Document: ${doc.name}")
sb.appendLine()
val content = doc.data["content"] as? List<*>
if (content.isNullOrEmpty()) {
sb.appendLine("(本文なし)")
} else {
for (block in content) flattenBlock(sb, block, depth = 0)
}
return truncate(sb.toString(), maxChars)
}
content は NodeEntity.data という JSONB カラムに保存された値の一部です。型付きの DTO を経由せず as? List<*> で受け取っており、JSONB カラムを Kotlin の型なしコレクションとして扱っている形です。
/** BlockNoteブロック1個をテキスト化。children を再帰 */
private fun flattenBlock(sb: StringBuilder, block: Any?, depth: Int) {
val map = block as? Map<*, *> ?: return
val type = map["type"] as? String ?: ""
val text = (map["content"] as? List<*>).orEmpty()
.mapNotNull { (it as? Map<*, *>)?.get("text") as? String }
.joinToString("")
val indent = " ".repeat(depth)
when (type) {
"heading" -> {
val level = ((map["props"] as? Map<*, *>)?.get("level") as? Number)?.toInt() ?: 1
if (text.isNotBlank()) sb.appendLine("${"#".repeat(level.coerceIn(1, 6))} $text")
}
"bulletListItem", "checkListItem" -> if (text.isNotBlank()) sb.appendLine("$indent- $text")
"numberedListItem" -> if (text.isNotBlank()) sb.appendLine("${indent}1. $text")
else -> if (text.isNotBlank()) sb.appendLine("$indent$text")
}
for (child in (map["children"] as? List<*>).orEmpty()) flattenBlock(sb, child, depth + 1)
}
BlockNote のブロックは { type, content: [{ text }], children: [...] } という形の JSON です。text の取り出しはブロック内の複数インライン要素(太字・リンクなど装飾違いで分かれることがある)を mapNotNull で text フィールドだけ拾い、joinToString("") で1つの文字列に結合しています。装飾情報そのものは捨てて中身のテキストだけを残す、という割り切りです。
type ごとの分岐はシンプルです。heading は props.level(見出しレベル)を取り出し coerceIn(1, 6) で Markdown の # の数に変換します。bulletListItem と checkListItem は区別せず同じ "- $text"(チェック状態は落とす)、numberedListItem は常に "1. $text" にしています。この出力はレンダリングされる画面ではなく、そのまま LLM へのプロンプトに使われます(呼び出し元は AiJobService の buildDocumentContext(...) 呼び出し)。Markdown 記法では 1. を連続させるだけでも番号付きリストとして意図が伝わるため、この関数自体は実際の連番を数えていません。
最後の for (child in ...) が depth + 1 で自分自身を再帰呼び出しし、ネストしたリストや折りたたみブロックの中身までたどります。depth はインデントの半角スペース2個分の繰り返しに使われるだけで、見出しレベルには影響しません。
buildSelectionContext: 選択範囲だけを直列化
ユーザーがボード上で複数要素を選んで「これを要約して」のように頼む場合に使うのが buildSelectionContext です。
fun buildSelectionContext(nodeIds: List<UUID>, maxChars: Int = 4000): String {
val items = nodeIds.mapNotNull { repo.findById(it).orElse(null) }
.filter { it.deletedAt == null }
if (items.isEmpty()) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "no valid nodes in selection")
}
val idSet = items.map { it.id }.toSet()
val sb = StringBuilder()
sb.appendLine("# 選択された要素 (${items.size} items)")
sb.appendLine()
for (item in items) {
when {
item.type in TEXT_TYPES -> appendItem(sb, item)
item.type == NodeType.section -> sb.appendLine("- [セクション] \"${item.name}\"")
else -> sb.appendLine("- ${describe(item)}")
}
}
nodeIds を渡された順に findById で解決し、削除済み(deletedAt != null)は除外します。1件も残らなければ 400 を返して終了です。
矢印の扱いが buildBoardContext と違うのは、「選択されている要素同士をつなぐ矢印だけ」に絞る点です。
// 選択要素同士をつなぐ矢印だけ含める
val boardId = boardAncestorId(items.first())
if (boardId != null) {
val edges = edgeRepo.findByBoardIdAndDeletedAtIsNull(boardId)
.filter { it.sourceNodeId in idSet && it.targetNodeId in idSet }
if (edges.isNotEmpty()) {
val byId = items.associateBy { it.id }
sb.appendLine()
sb.appendLine("## Connections(選択内の矢印)")
for (e in edges) {
val src = byId[e.sourceNodeId]?.let { shortText(it) } ?: "?"
val dst = byId[e.targetNodeId]?.let { shortText(it) } ?: "?"
val label = if (e.label.isNotBlank()) " [${e.label}]" else ""
sb.appendLine("- \"$src\" →$label \"$dst\"")
}
}
}
return truncate(sb.toString(), maxChars)
}
まず先頭の要素からボードの祖先 ID を boardAncestorId で辿ります(要素はセクション配下にあることもあるので、親を遡って NodeType.board に行き着くまでループします)。ボードの全矢印を取ってから、it.sourceNodeId in idSet && it.targetNodeId in idSet で「両端とも選択範囲に含まれる」矢印だけにフィルタします。選択していない要素につながる矢印まで出すと、「選んでいないのに何故これが出てくるのか」と LLM を混乱させるので、選択範囲内で完結する関係だけを見せています。
buildChatContext: チャット履歴の直列化
/** チャット履歴を "user:/ai:" 形式で直列化(直近 maxMessages 件) */
fun buildChatContext(chatRoomId: UUID, maxMessages: Int = 20, maxChars: Int = 3000): String {
val messages = repo.findByParentIdAndDeletedAtIsNull(chatRoomId)
.filter { it.type == NodeType.message }
.sortedBy { it.createdAt }
.takeLast(maxMessages)
val sb = StringBuilder()
for (m in messages) {
val author = m.data["author"] as? String ?: "user"
val text = (m.data["text"] as? String ?: "").replace("\n", " ")
sb.appendLine("$author: $text")
}
return truncate(sb.toString(), maxChars)
}
ここまでの関数に比べると一番シンプルです。チャットルーム配下のメッセージを作成日時でソートし、takeLast(maxMessages) で直近 N 件(デフォルト20件)だけに絞り、"author: text" の1行フォーマットで並べるだけです。改行は空白に潰して1メッセージ1行を保証しています。maxChars のデフォルトは3000文字と、他の関数(ボード8000字、ドキュメント6000字)より小さめに設定されています。
buildProjectContext: 予算をセクションごとに配分する
最も複雑なのが、プロジェクト内のボード・ドキュメント・チャット・決定事項・タスクをまとめて1つのコンテキストにする buildProjectContext です。
/**
* プロジェクト横断コンテキスト。配分: ボード計6000 / ドキュメント計4000 / チャット計1500。
* ai_summary はAI出力の自己参照ループになるため含めない。
*/
fun buildProjectContext(projectId: UUID, maxChars: Int = 12000): String {
val project = repo.findById(projectId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "project not found: $projectId")
}
// project 配下の全子孫から board / document / chat_room と決定事項・タスクを集める
val boards = mutableListOf<NodeEntity>()
val documents = mutableListOf<NodeEntity>()
val chatRooms = mutableListOf<NodeEntity>()
val decisions = mutableListOf<NodeEntity>()
val openQuestions = mutableListOf<NodeEntity>()
val tasks = mutableListOf<NodeEntity>()
fun collect(parentId: UUID) {
for (n in repo.findByParentIdAndDeletedAtIsNull(parentId)) {
when (n.type) {
NodeType.board -> { boards.add(n); collect(n.id) }
NodeType.document -> documents.add(n)
NodeType.chat_room -> chatRooms.add(n)
NodeType.group, NodeType.section -> collect(n.id)
NodeType.decision -> decisions.add(n)
NodeType.open_question -> openQuestions.add(n)
NodeType.task -> tasks.add(n)
else -> {}
}
}
}
collect(projectId)
この collect() は buildBoardContext の中にあったものと似ていますが、対象の型が多い分 when で6種類に振り分けています。NodeType.board に当たった場合は自分自身をリストに加えつつ、さらに中まで潜る(collect(n.id))点に注意が必要です。ボードの子にさらにボードやドキュメントを作る導線は現状のフロントエンドにはありませんが、この関数自体はその制約に依存していません。group や section のようなただの入れ物の型は中身だけを拾って自身はリストに残さず再帰します。
決定事項・タスクを独自予算で先頭に置く
val sb = StringBuilder()
sb.appendLine("# Project: ${project.name}")
// 決定事項・タスクはAIが最初に読むべき前提情報のため先頭に置く(予算は独自に1200字)
if (decisions.isNotEmpty() || openQuestions.isNotEmpty() || tasks.isNotEmpty()) {
val dsb = StringBuilder()
dsb.appendLine("## 決定事項・タスク(プロジェクトの合意状態)")
for (d in decisions.sortedBy { it.createdAt }) dsb.appendLine("- [決定] \"${lineText(d)}\"")
for (q in openQuestions.sortedBy { it.createdAt }) dsb.appendLine("- [未解決] \"${lineText(q)}\"")
for (t in tasks.sortedBy { it.createdAt }) {
val mark = if (t.data["done"] == true) "x" else " "
val due = t.data["dueDate"] as? String
val assignee = (t.data["assignee"] as? Map<*, *>)?.get("name") as? String
val meta = listOfNotNull(due?.let { "期限: $it" }, assignee?.let { "担当: $it" })
val suffix = if (meta.isEmpty()) "" else " (${meta.joinToString(", ")})"
dsb.appendLine("- [$mark] \"${lineText(t)}\"$suffix")
}
sb.appendLine()
sb.appendLine(truncate(dsb.toString().trimEnd(), 1200))
}
ここは sb とは別に dsb という一時的な StringBuilder を用意し、決定事項・未解決事項・タスクをまとめてから、独自に1200字で truncate してから本体の sb に足しています。別枠の予算を持たせている理由(対になる Zenn 記事で扱います)はコメントの通り「AI が最初に読むべき前提情報」だからです。
タスクの1行は listOfNotNull で期限と担当者のうち存在するものだけを拾い、meta が空なら括弧を出さないという、buildBoardContext のコメントスレッドと同じパターンを繰り返しています。
件数で割って下限をつける配分
if (boards.isNotEmpty()) {
val perBoard = (6000 / boards.size).coerceAtLeast(1000)
for (b in boards) {
sb.appendLine()
sb.appendLine(buildBoardContext(b.id, perBoard))
}
}
if (documents.isNotEmpty()) {
val perDoc = (4000 / documents.size).coerceAtLeast(800)
for (d in documents) {
sb.appendLine()
sb.appendLine(buildDocumentContext(d.id, perDoc))
}
}
if (chatRooms.isNotEmpty()) {
val perRoom = (1500 / chatRooms.size).coerceAtLeast(300)
for (r in chatRooms) {
val chat = buildChatContext(r.id, maxChars = perRoom)
if (chat.isNotBlank()) {
sb.appendLine()
sb.appendLine("## Chat: ${r.name}")
sb.append(chat)
}
}
}
return truncate(sb.toString(), maxChars)
ボード計6000字・ドキュメント計4000字・チャット計1500字という総枠を、それぞれの件数で割って1件あたりの予算にしています。ボードが3つあれば perBoard = 2000、6つあれば perBoard = 1000 です。
coerceAtLeast で下限を設けているのがポイントです。ボードが10個あると 6000 / 10 = 600 になりますが、coerceAtLeast(1000) で1000字を下回らないようにしています。この結果、件数が多いプロジェクトでは合計が総枠の6000字を超えることがあります。ここは意図的に割り切っていて、「1件あたりの最低限の情報量を守る」ことを「合計予算を厳密に守る」ことより優先しています。最終的には呼び出し元の buildProjectContext(projectId, maxChars = 12000) 全体に対して最後の return truncate(sb.toString(), maxChars) が効くので、プロンプト全体としての上限は必ず守られます。
なお ai_summary(AI が過去に生成した要約)はここに含まれていません。コメントにある通り、AI の出力を次の AI 呼び出しの入力に混ぜると自己参照のループになるため、意図的に除外されています。
appendItem / describe: 共通の整形ヘルパー
buildBoardContext や buildSelectionContext から呼ばれる appendItem と describe は、要素1件をどう1行にするかを決めている共通処理です。
private fun appendItem(sb: StringBuilder, item: NodeEntity) {
sb.append("- ${describe(item)}")
val comments = repo.findByParentIdAndDeletedAtIsNull(item.id)
.filter { it.type == NodeType.comment }
if (comments.isNotEmpty()) {
val joined = comments.joinToString("; ") { c ->
val author = c.data["author"] ?: "?"
val body = (c.data["text"] as? String ?: "").replace("\n", " ")
"$author: $body"
}
sb.append(" (コメント${comments.size}件: $joined)")
}
sb.appendLine()
}
private fun describe(item: NodeEntity): String {
val text = (item.data["text"] as? String ?: "").replace("\n", " / ")
return when (item.type) {
NodeType.sticky -> "[付箋:${item.data["color"] ?: "yellow"}] \"$text\""
NodeType.text_card -> "[テキスト] \"$text\""
NodeType.shape -> "[図形:${item.data["kind"] ?: "rect"}] \"$text\""
NodeType.comment_pin -> "[コメントピン] \"${item.name}\""
else -> "\"$text\""
}
}
describe は要素の種類ごとに [付箋:color] [テキスト] [図形:kind] のようなタグを前置してからテキストを引用符で囲みます。appendItem はそこにコメント数とコメント本文を付け足す役割です。この2つの関数を buildBoardContext の ## Items にも appendSectionTree の子要素にも buildSelectionContext にも使い回すことで、「要素1件をどう表示するか」のロジックが1箇所にまとまっています。
まとめて見えてくること
ContextBuilder に共通しているのは、どの関数も「文字数を気にせず組み立てて、最後に truncate で切る」「型ごとの when で分岐して1行に整形する」「デフォルト値やノイズになる情報は条件付きで省略する」という同じパターンの繰り返しです。RAG やベクトル DB のような検索ベースの仕組みを使わなくても、アプリの状態をツリー構造で持っているなら、こうした再帰関数の組み合わせだけで十分実用的なプロンプトが組み立てられます。
Kurari の記事一覧
- 対になる Zenn 記事: LLMに渡す文脈を、文字数予算で設計する
- Zenn: Kurariを1ヶ月作って分かったこと
- Qiita: バックエンドがLLMを実行しないAIジョブキューの実装 ― Kurariのジョブキューとローカル実行Agent
- Zenn: ボード・ドキュメント・チャットを1本のツリーで持つ設計
- Qiita: React Flow (@xyflow/react v12) を controlled で使うときの注意点
- Qiita: LAN内WebRTC P2P通話を実装してハマったところ
- Qiita: LAN共有をオーナー承認制にする実装(Kotlin/Spring Boot + React)