✍️

AIでテキストファイルを書き換える技術

に公開

はいさい!primeNumberでソフトウェアエンジニアをやっている赤嶺です。

弊社では現在、全社的に「AI Native化」を推進しており、その取り組みの一環として「AI Native Summer Calendar」と題し、社員それぞれが業務を通じて得た知見をリレー形式で記事にして発信しています。

本記事もその中のひとつです。他の記事もぜひあわせてご覧ください!

はじめに

現在、私はAIを活用したテクニカルライティングの効率化プロジェクトに取り組んでいます。その一環として、AIエージェントがやっているように、テキストファイルを自動で更新する機能を実装しました。本記事では、その技術的な背景と具体的な実装方法について詳しく紹介します。

この記事を読むことで、「AIエージェントはどのようにテキストファイルを書き換えているのか?」「どのようにプロンプトを設計し、テキスト処理を行えばよいのか?」といった疑問が解消されるはずです。

差分ベースの編集

従来の素朴なプロンプトによるテキスト生成では、編集対象の全文を入力として渡し、その全文を再生成する手法が一般的でした。しかしこの方法には、以下のような課題があります。

  • トークン使用量が多くなる
  • 元のテキストの良い部分まで書き換えられてしまう
  • どこが変更されたのか分かりにくく、レビューが困難

そこで「差分ベースの編集」を採用することで、人間が文章を編集するように、元のテキストを尊重しながら必要最小限の変更にとどめることができます。

SEARCH/REPLACEブロック形式

今回、オープンソースのAIエージェントとしてAiderとClineのプロンプト実装を参考にしてみました。

Aider: https://github.com/Aider-AI/aider/blob/c23ebfe6884405bffc3e4c0e947e5545cc0dd117/aider/coders/editblock_prompts.py#L50-L55

<<<<<<< SEARCH
from flask import Flask
=======
import math
from flask import Flask
>>>>>>> REPLACE

Cline: https://github.com/cline/cline/blob/main/src/core/prompts/system.ts#L92-L96

------- SEARCH
[exact content to find]
=======
[new content to replace with]
+++++++ REPLACE

Aider や Cline などのツールでは、検索と置換を行うために SEARCH/REPLACE ブロック形式が採用されています。

AI の特性として、与えられたテキスト内の正確な行番号を把握するのが難しいという課題があります。たとえば、一般的な差分表現である unified diff 形式には行番号が含まれていますが、AI がこの行番号を誤って出力してしまうと、せっかく生成した差分が適用できず、無駄になってしまう可能性があります。

この問題を回避するために、SEARCH/REPLACE ブロックでは差分の前後数行の文脈を含めて出力し、そのテキストをもとに置換を行う仕組みになっています。これにより、行番号に依存せず、AI が生成した変更を確実に適用できるようになっているのです。

出力形式のプロンプト

実際に出力形式を指定しているプロンプトを紹介します。

## Output Format
For each change needed, provide a search/replace block in this exact format:

<<<<<<< SEARCH
[exact text to find - include 3-5 lines of context before and after the target text]
=======
[replacement text - maintain exact formatting and structure]
>>>>>>> REPLACE

**Important Requirements:**
- Include sufficient context (3-5 lines before/after) to uniquely identify the location
- Preserve exact indentation, spacing, and formatting
- Make targeted, surgical changes rather than rewriting entire sections
- If no changes are needed, respond with "NO_CHANGES_NEEDED"
- Provide multiple search/replace blocks if multiple sections need updates

翻訳すると下記のことを必須条件として指示しています。

  • 一意に場所を特定できるよう、前後3~5行の十分なコンテキストを含めること
  • インデント、スペース、フォーマットを厳密に保持すること
  • 対象箇所のみを的確に変更し、全体を書き換えないこと
  • 変更が不要な場合は NO_CHANGES_NEEDED と返答すること
  • 複数箇所に変更がある場合は、複数の検索/置換ブロックを提供すること

このように、SEARCH/REPLACEブロック形式ではSEARCHの部分が完全一致することが求められているため、AIには厳密に同じ文章を出力してもらわないといけません。そのための工夫が凝らされていました。

実践

それでは、実際に AI エージェントにテキストファイルを書き換えてもらうためのコード例をご紹介します。

弊社では、Amazon Bedrock を利用して Claude API を呼び出す構成を採用しています。

以下は、ある YAML ファイルを対象に、
「database のポート番号を 3307 に変更して」
というプロンプトを与え、AI がファイルを正しく書き換えられるかを検証する例題です。

# Configuration Example
server:
  port: 8080
  host: localhost
database:
  type: mysql
  port: 3306
cache:
  enabled: true
  ttl: 3600

サンプルコード

このサンプルコードはClaude Codeに本記事を読み込ませて出力したものをベースにしています。


package main

import (
	"context"
	"encoding/json"
	"fmt"
	"log"
	"strings"

	"github.com/aws/aws-sdk-go-v2/aws"
	"github.com/aws/aws-sdk-go-v2/config"
	"github.com/aws/aws-sdk-go-v2/service/bedrockruntime"
)

// SearchReplaceBlock represents a single search/replace operation
type SearchReplaceBlock struct {
	Search  string
	Replace string
}

// Rewriter rewrites files using AI
type Rewriter struct {
	bedrockClient *bedrockruntime.Client
}

// NewRewriter creates a new rewriter
func NewRewriter() (*Rewriter, error) {
	// AWS Bedrockクライアントの初期化
	cfg, err := config.LoadDefaultConfig(context.Background(), config.WithRegion("ap-northeast-1"))
	if err != nil {
		return nil, fmt.Errorf("failed to load AWS config: %w", err)
	}

	client := bedrockruntime.NewFromConfig(cfg)

	return &Rewriter{
		bedrockClient: client,
	}, nil
}

// RewriteFile rewrites file based on instruction
func (r *Rewriter) RewriteFile(ctx context.Context, originalContent, instruction string) (string, error) {
	// Claude Sonnet 4向けのプロンプトを構築
	prompt := r.buildRewritePrompt(originalContent, instruction)

	// Claude Sonnet 4のリクエストを準備
	modelID := "apac.anthropic.claude-sonnet-4-20250514-v1:0"

	request := &bedrockruntime.InvokeModelInput{
		ModelId:     aws.String(modelID),
		ContentType: aws.String("application/json"),
		Accept:      aws.String("application/json"),
	}

	// リクエストボディを作成
	requestBody := map[string]interface{}{
		"anthropic_version": "bedrock-2023-05-31",
		"max_tokens":        2048,
		"temperature":       0.0, // 決定的な出力のため0.0に設定
		"messages": []map[string]interface{}{
			{
				"role":    "user",
				"content": prompt,
			},
		},
	}

	bodyBytes, err := json.Marshal(requestBody)
	if err != nil {
		return "", fmt.Errorf("failed to marshal request body: %w", err)
	}
	request.Body = bodyBytes

	// モデルを呼び出し
	response, err := r.bedrockClient.InvokeModel(ctx, request)
	if err != nil {
		return "", fmt.Errorf("failed to invoke Bedrock model: %w", err)
	}

	// レスポンスを解析
	var responseBody struct {
		Content []struct {
			Type string `json:"type"`
			Text string `json:"text"`
		} `json:"content"`
	}

	if err := json.Unmarshal(response.Body, &responseBody); err != nil {
		return "", fmt.Errorf("failed to unmarshal response: %w", err)
	}

	if len(responseBody.Content) == 0 {
		return "", fmt.Errorf("empty response from model")
	}

	aiResponse := responseBody.Content[0].Text

	fmt.Println("=== AI Response ===")
	fmt.Println(aiResponse)
	fmt.Println()

	// 変更が不要な場合
	if strings.Contains(aiResponse, "NO_CHANGES_NEEDED") {
		return originalContent, nil
	}

	// SEARCH/REPLACEブロックを適用
	finalContent, err := r.applySearchReplaceBlocks(originalContent, aiResponse)
	if err != nil {
		return "", fmt.Errorf("failed to apply search/replace blocks: %w", err)
	}

	return finalContent, nil
}

// buildRewritePrompt builds the prompt for file rewriting
func (r *Rewriter) buildRewritePrompt(originalContent, instruction string) string {
	prompt := fmt.Sprintf(`Based on the instruction below, modify the file using search/replace blocks.

## Instruction
%s

## Current Content
%s

## Output Format
For each change needed, provide a search/replace block in this exact format:

<<<<<<< SEARCH
[exact text to find - include 3-5 lines of context before and after the target text]
=======
[replacement text - maintain exact formatting and structure]
>>>>>>> REPLACE

**Important Requirements:**
- Include sufficient context (3-5 lines before/after) to uniquely identify the location
- Preserve exact indentation, spacing, and formatting
- Make targeted, surgical changes rather than rewriting entire sections
- If no changes are needed, respond with "NO_CHANGES_NEEDED"
- Provide multiple search/replace blocks if multiple sections need updates`,
		instruction,
		originalContent,
	)

	return prompt
}

// applySearchReplaceBlocks applies search/replace blocks to the original content
func (r *Rewriter) applySearchReplaceBlocks(originalContent, aiResponse string) (string, error) {
	content := originalContent

	// AIレスポンスからSEARCH/REPLACEブロックを解析
	blocks := r.parseSearchReplaceBlocks(aiResponse)

	// 各ブロックを適用
	for i, block := range blocks {
		newContent := strings.Replace(content, block.Search, block.Replace, 1)
		if newContent == content {
			return "", fmt.Errorf("search/replace block %d failed: search text not found", i+1)
		}
		content = newContent
	}

	return content, nil
}

// parseSearchReplaceBlocks extracts search/replace blocks from AI response
func (cr *Rewriter) parseSearchReplaceBlocks(aiResponse string) []SearchReplaceBlock {
	var blocks []SearchReplaceBlock

	lines := strings.Split(aiResponse, "\n")
	var currentBlock *SearchReplaceBlock
	var isInSearch bool
	var isInReplace bool
	var searchLines []string
	var replaceLines []string

	for _, line := range lines {
		if strings.Contains(line, "<<<<<<< SEARCH") {
			currentBlock = &SearchReplaceBlock{}
			isInSearch = true
			isInReplace = false
			searchLines = []string{}
			replaceLines = []string{}
		} else if strings.Contains(line, "=======") && currentBlock != nil {
			isInSearch = false
			isInReplace = true
		} else if strings.Contains(line, ">>>>>>> REPLACE") && currentBlock != nil {
			// ブロックを完成させる
			currentBlock.Search = strings.Join(searchLines, "\n")
			currentBlock.Replace = strings.Join(replaceLines, "\n")
			blocks = append(blocks, *currentBlock)

			currentBlock = nil
			isInSearch = false
			isInReplace = false
		} else if isInSearch {
			searchLines = append(searchLines, line)
		} else if isInReplace {
			replaceLines = append(replaceLines, line)
		}
	}

	return blocks
}

func main() {
	// サンプルの設定ファイル内容
	originalConfig := `# Configuration Example
server:
  port: 8080
  host: localhost
database:
  type: mysql
  port: 3306
cache:
  enabled: true
  ttl: 3600`

	// 変更指示
	instruction := "databaseのポート番号を3307に変更して"

	fmt.Println("=== 元の設定ファイル ===")
	fmt.Println(originalConfig)
	fmt.Println()

	// Rewriterを初期化
	rewriter, err := NewRewriter()
	if err != nil {
		log.Fatalf("Failed to create rewriter: %v", err)
	}

	// 設定ファイルを書き換え
	ctx := context.Background()
	rewrittenConfig, err := rewriter.RewriteFile(ctx, originalConfig, instruction)
	if err != nil {
		log.Fatalf("Failed to rewrite config: %v", err)
	}

	fmt.Println("=== 変更後の設定ファイル ===")
	fmt.Println(rewrittenConfig)
	fmt.Println()
}

実行結果

databaseのポート番号が3307に変わっており、AIを使ったファイルの書き換え作業が無事成功しました。

$ AWS_PROFILE=xxx go run .
=== 元の設定ファイル ===
# Configuration Example
server:
  port: 8080
  host: localhost
database:
  type: mysql
  port: 3306
cache:
  enabled: true
  ttl: 3600

=== AI Response ===
<<<<<<< SEARCH
database:
  type: mysql
  port: 3306
cache:
  enabled: true
=======
database:
  type: mysql
  port: 3307
cache:
  enabled: true
>>>>>>> REPLACE

=== 変更後の設定ファイル ===
# Configuration Example
server:
  port: 8080
  host: localhost
database:
  type: mysql
  port: 3307
cache:
  enabled: true
  ttl: 3600

まとめ

今回は、AIエージェントが内部でどのようにテキストファイルを書き換えているのか、その具体的な仕組みをご紹介しました。

既存のAIエージェントにルーティンワークを任せるのも一つの手ですが、シンプルなカスタムスクリプトを自作することで、より安定して課題を解決できる場合もあります。

AIエージェントを“使う”だけでなく、“どう実装されているか”を理解し参考にすることで、自分だけのエージェントを作成したり、既存のサービスへ柔軟に組み込んだりと、AIの新たな応用の可能性が広がっていくかもしれません。

Discussion