予算が潤沢であれば、中小のIT業者に依頼をかけて1000万円以上で見積もりを掛けることも可能なのですが、実際はそうはいきません。せいぜい数百万、場合によっては100万以下のシステム開発からスタートするところでしょう。一方で、非ITとITをつなげるマッチングサイトもいくつかあるのですが、価格が安すぎるのが難点です。受注をする IT サイドから言えば、100万以下の案件はなかなか動きづらいところがあります(営業活動や見積もり作業も込みとなるので)。まして、10数万円ていどではITアルバイトのようなものです。実際に、小規模の作業であれば、ITアルバイト感覚で受注することもよいのですが、実はそれだけだと採算が合いません。
個人開発者であれば、身がひとつなのですから、Aプロジェクトのタスクをやっている間は、BプロジェクトやCプロジェクトのタスクはお休みなります。一見、マルチタスクで動いているようには見えますが(今後 AI エージェントの活用次第ではそれができるかもしれませんが)、個人の時間軸から言えばシーケンシャルにこなしているだけです。
大抵の場合、作業時間 x 単価ということになるので、AプロジェクトはBプロジェクトの2倍の予算が掛かります、とお客に説明するわけですが、果たしてそうなるでしょうか?当然のことながら、A,B,C のプロジェクトのお客さんは別々で、互いに顔を合わせることはありません。場合によっては、開発者が自分のプロジェクト(Aプロジェクトとか)に専任していると思っているかもしれません。そうです、いわゆる「官庁プロジェクトの開発者は3倍働く」わけですが、それはまた別の話。
- フロントエンド: React - データベース: SQLite - LLM: Local LLM + Next.js - API: OpenAI API + Laravel
こんな風にユースケースを書いておきます。例をいくつか書いておいて、こういうプロンプトを入れたらこういう形で AI が返してくれる。再びチャットを続ける、という形でユーザーインターフェースをデザイン=設計していきます。
以前ならば、このユースケースを設計書に落とし込んで、ルールベースに直して、実装してという手順になり、それを自分でやらなくてはならないのですが、AI エージェントのおかげでこのあたりの手順を AI に任せることができます。漠然としたユースケースなので自動化という訳にはいきませんが、以前よりも実装スピード=プロトタイプが出来るスピードが早いです。
ユースケースを示しながら、設計を AI と相談する
ユースケースが実験できるような、プロトタイプの画面を AI に作成して貰う
設計に従って、AI にコーディングしてもらう
細かい動きを AI に修正してもらう
という形で、ほぼほぼ AI がコーディングを担当していきます。私は、動作確認をしたり、プロトタイプを見て感想を言ったり、テストするポイントを AI に伝えたりという役目です。
AI コーディングには Claude Sonnet を使っていますが、こんな風なイテレーション開発の場合にはこれで十分です。
国語の読書感想文を4チケットに分けて、それぞれの作業内容を書き出してくれます。これは、チケット駆動のチケットの書き出しにあたります。チケット駆動の場合は、プロジェクトが進む中でチケットを書き出すことが多いのですが、夏休みの課題の場合は、全体の総量がわかっているので、このようにチケットを分割してしまったほうが早いです。全体を分割するときに、以前までは付箋を使って計画を立てていたのですが、最近ならば AI を使えば便利です。
ただし、この標準入出力型には難点があって、ストリーム形式なのでローカルな MCP Server を建てる必要が(たぶん)あります。レスポンス&リクエストが一連のストリームになっているので、エラーを返すことができません。適当にログを出力する必要がでてきます。お手軽ではあるのですが、あまり実用的ではないかもしれません。
最初に標準入出力型を実装してみたのですが、あまりうまく拡張できないので HTTP API 型の kkc-mcp-server-api で実装しなおしています。
using System.Text.Json;
using KkcMcpServerApi.Models;
using KkcMcpServerApi.Services;
using Microsoft.OpenApi.Models;
var builder = WebApplication.CreateBuilder(args);
// ロギング設定
builder.Logging.ClearProviders();
builder.Logging.AddConsole();
builder.Logging.SetMinimumLevel(LogLevel.Information);
// サービス登録
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new OpenApiInfo
{
Title = "KKC MCP Server API",
Version = "v1",
Description = "Model Context Protocol (MCP) サーバーAPI - kkc-webapi のプロキシとして動作します"
});
});
// HttpClient設定
builder.Services.AddHttpClient<IWebApiClient, WebApiClient>(client =>
{
client.BaseAddress = new Uri("http://localhost:8000");
client.Timeout = TimeSpan.FromSeconds(30);
});
// MCPツールプロバイダー登録
builder.Services.AddScoped<McpToolProvider>();
// CORS設定
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
{
policy.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader();
});
});
var app = builder.Build();
app.UseCors();
// MCP over HTTP のルートエンドポイント
app.MapPost("/", async (HttpContext context, McpToolProvider toolProvider) =>
{
try
{
using var reader = new StreamReader(context.Request.Body);
var jsonRequest = await reader.ReadToEndAsync();
var requestDoc = JsonDocument.Parse(jsonRequest);
var root = requestDoc.RootElement;
if (!root.TryGetProperty("method", out var methodElement))
{
return Results.BadRequest(new { error = "Missing method property" });
}
var method = methodElement.GetString();
var id = root.TryGetProperty("id", out var idElement) ? idElement.GetString() : null;
object? response = method switch
{
"initialize" => new
{
jsonrpc = "2.0",
id = id,
result = new
{
protocolVersion = "2024-11-05",
capabilities = new
{
tools = new { }
},
serverInfo = new
{
name = "kkc-mcp-server-api",
version = "1.0.0"
}
}
},
"tools/list" => new
{
jsonrpc = "2.0",
id = id,
result = new
{
tools = new object[]
{
new
{
name = "search_reservations",
description = "予約を検索します(ID指定)",
inputSchema = new
{
type = "object",
properties = new
{
storeId = new { type = "integer", description = "店舗ID" },
reservationDate = new { type = "string", description = "予約日 (YYYY-MM-DD)" },
customerName = new { type = "string", description = "顧客名" },
limit = new { type = "integer", description = "取得件数", @default = 10 }
}
}
},
new
{
name = "search_reservations_by_name",
description = "予約を検索します(名前指定)",
inputSchema = new
{
type = "object",
properties = new
{
store = new { type = "string", description = "店舗名" },
areaGroup = new { type = "string", description = "エリアグループ名" },
customerName = new { type = "string", description = "顧客名" },
reservationDate = new { type = "string", description = "予約日 (YYYY-MM-DD)" },
limit = new { type = "integer", description = "取得件数", @default = 10 }
}
}
},
new
{
name = "get_calendar",
description = "カレンダー情報を取得します",
inputSchema = new
{
type = "object",
properties = new
{
date = new { type = "string", description = "日付 (YYYY-MM-DD)" },
areaGroupId = new { type = "integer", description = "エリアグループID" }
},
required = new[] { "date", "areaGroupId" }
}
}
}
}
},
"tools/call" => await HandleToolCall(root, toolProvider, id),
_ => new
{
jsonrpc = "2.0",
id = id,
error = new
{
code = -32601,
message = $"Method not found: {method}"
}
}
};
return Results.Json(response);
}
catch (Exception ex)
{
return Results.Json(new
{
jsonrpc = "2.0",
error = new
{
code = -32603,
message = "Internal error",
data = ex.Message
}
});
}
});
async Task<object> HandleToolCall(JsonElement root, McpToolProvider toolProvider, string? id)
{
try
{
if (!root.TryGetProperty("params", out var paramsElement))
{
return new
{
jsonrpc = "2.0",
id = id,
error = new { code = -32602, message = "Missing params" }
};
}
if (!paramsElement.TryGetProperty("name", out var nameElement))
{
return new
{
jsonrpc = "2.0",
id = id,
error = new { code = -32602, message = "Missing tool name" }
};
}
var toolName = nameElement.GetString();
var arguments = paramsElement.TryGetProperty("arguments", out var argsElement)
? argsElement.GetRawText()
: "{}";
var result = toolName switch
{
"search_reservations" => await toolProvider.CallToolAsync(toolName, JsonSerializer.Deserialize<Dictionary<string, object>>(arguments)),
"search_reservations_by_name" => await toolProvider.CallToolAsync(toolName, JsonSerializer.Deserialize<Dictionary<string, object>>(arguments)),
"get_calendar" => await toolProvider.CallToolAsync(toolName, JsonSerializer.Deserialize<Dictionary<string, object>>(arguments)),
_ => throw new ArgumentException($"Unknown tool: {toolName}")
};
return new
{
jsonrpc = "2.0",
id = id,
result = new
{
content = result.Content.Select(c => new
{
type = c.Type,
text = c.Text
}).ToArray()
}
};
}
catch (Exception ex)
{
return new
{
jsonrpc = "2.0",
id = id,
error = new
{
code = -32603,
message = "Tool execution failed",
data = ex.Message
}
};
}
}
// 既存のエンドポイントも保持(開発・テスト用)
app.MapGet("/health", () => Results.Ok(new { status = "healthy", timestamp = DateTime.UtcNow }));
app.MapGet("/info", () => Results.Ok(new {
name = "KKC MCP Server API",
version = "1.0.0",
description = "ASP.NET Core Minimal API - MCP Server",
endpoints = new[] { "/", "/health", "/info" }
}));
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.Run();
McpToolProvider.cs
using System.Text.Json;
using KkcMcpServerApi.Models;
namespace KkcMcpServerApi.Services;
/// <summary>
/// MCPツールプロバイダー
/// </summary>
public class McpToolProvider
{
private readonly IWebApiClient _webApiClient;
private readonly ILogger<McpToolProvider> _logger;
public McpToolProvider(IWebApiClient webApiClient, ILogger<McpToolProvider> logger)
{
_webApiClient = webApiClient;
_logger = logger;
}
/// <summary>
/// 利用可能なツールの一覧を取得
/// </summary>
public ToolsListResult GetToolsList()
{
var tools = new List<ToolInfo>
{
new ToolInfo
{
Name = "search_reservations",
Description = "予約を検索します。複数の条件で絞り込み検索が可能です。",
InputSchema = new
{
type = "object",
properties = new
{
customerName = new { type = "string", description = "顧客名での検索" },
customerNameKana = new { type = "string", description = "顧客名カナでの検索" },
phoneNumber = new { type = "string", description = "電話番号での検索" },
email = new { type = "string", description = "メールアドレスでの検索" },
receptionDate = new { type = "string", description = "受付日での検索(YYYY-MM-DD形式)" },
areaGroupId = new { type = "integer", description = "エリアグループIDでの検索" },
storeId = new { type = "integer", description = "店舗IDでの検索" },
reservationDate = new { type = "string", description = "予約日での検索(YYYY-MM-DD形式)" },
timeZone = new { type = "string", description = "時間帯での検索" },
status = new { type = "string", description = "予約ステータスでの検索" },
page = new { type = "integer", description = "ページ番号(デフォルト: 1)" },
perPage = new { type = "integer", description = "1ページあたりの件数(デフォルト: 10)" }
}
}
},
new ToolInfo
{
Name = "search_reservations_by_name",
Description = "販売会社名、店舗名、エリアグループ名などの名前を使用して予約を検索します。",
InputSchema = new
{
type = "object",
properties = new
{
sellingCompany = new { type = "string", description = "販売会社名での検索" },
store = new { type = "string", description = "店舗名での検索" },
areaGroup = new { type = "string", description = "エリアグループ名での検索" },
customerName = new { type = "string", description = "顧客名での検索" },
phoneNumber = new { type = "string", description = "電話番号での検索" },
email = new { type = "string", description = "メールアドレスでの検索" },
acceptanceDate = new { type = "string", description = "受付日での検索(YYYY-MM-DD形式)" },
reservationDate = new { type = "string", description = "予約日での検索(YYYY-MM-DD形式)" },
timeZone = new { type = "string", description = "時間帯での検索" },
model = new { type = "string", description = "車種名での検索" },
plateNumber = new { type = "string", description = "登録番号での検索" },
color = new { type = "string", description = "塗色名での検索" },
remarks = new { type = "string", description = "備考での検索" },
page = new { type = "integer", description = "ページ番号(デフォルト: 1)" },
limit = new { type = "integer", description = "1ページあたりの件数(デフォルト: 10)" }
}
}
},
new ToolInfo
{
Name = "get_calendar",
Description = "指定した日付の予約カレンダー情報と空き状況を取得します。",
InputSchema = new
{
type = "object",
properties = new
{
date = new { type = "string", description = "カレンダー表示する日付(YYYY-MM-DD形式)" },
areaGroupId = new { type = "integer", description = "エリアグループID(オプション)" },
storeId = new { type = "integer", description = "店舗ID(オプション)" }
},
required = new[] { "date" }
}
}
};
return new ToolsListResult { Tools = tools };
}
/// <summary>
/// 指定されたツールを実行
/// </summary>
public async Task<ToolCallResult> CallToolAsync(string toolName, Dictionary<string, object>? arguments)
{
try
{
_logger.LogInformation("Calling tool: {ToolName} with arguments: {Arguments}",
toolName, JsonSerializer.Serialize(arguments));
string result = toolName switch
{
"search_reservations" => await CallSearchReservationsAsync(arguments),
"search_reservations_by_name" => await CallSearchReservationsByNameAsync(arguments),
"get_calendar" => await CallGetCalendarAsync(arguments),
_ => throw new ArgumentException($"Unknown tool: {toolName}")
};
return new ToolCallResult
{
Content = new List<ToolContent>
{
new ToolContent { Type = "text", Text = result }
},
IsError = false
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Error calling tool: {ToolName}", toolName);
return new ToolCallResult
{
Content = new List<ToolContent>
{
new ToolContent { Type = "text", Text = $"Error: {ex.Message}" }
},
IsError = true
};
}
}
/// <summary>
/// 予約検索ツール実行
/// </summary>
private async Task<string> CallSearchReservationsAsync(Dictionary<string, object>? arguments)
{
var request = new ReservationSearchRequest();
if (arguments != null)
{
if (arguments.TryGetValue("CustomerName", out var customerName))
request.CustomerName = customerName?.ToString();
if (arguments.TryGetValue("CustomerNameKana", out var customerNameKana))
request.CustomerNameKana = customerNameKana?.ToString();
if (arguments.TryGetValue("PhoneNumber", out var phoneNumber))
request.PhoneNumber = phoneNumber?.ToString();
if (arguments.TryGetValue("Email", out var email))
request.Email = email?.ToString();
if (arguments.TryGetValue("ReceptionDate", out var receptionDate))
request.ReceptionDate = receptionDate?.ToString();
if (arguments.TryGetValue("AreaGroupId", out var areaGroupId) && int.TryParse(areaGroupId?.ToString(), out var areaGroupIdInt))
request.AreaGroupId = areaGroupIdInt;
if (arguments.TryGetValue("StoreId", out var storeId) && int.TryParse(storeId?.ToString(), out var storeIdInt))
request.StoreId = storeIdInt;
if (arguments.TryGetValue("Status", out var status))
request.Status = status?.ToString();
if (arguments.TryGetValue("page", out var page) && int.TryParse(page?.ToString(), out var pageInt))
request.Page = pageInt;
if (arguments.TryGetValue("perPage", out var perPage) && int.TryParse(perPage?.ToString(), out var perPageInt))
request.PerPage = perPageInt;
// 予約日の設定
if (arguments.TryGetValue("ReservationDate", out var reservationDate))
{
var dateStr = reservationDate?.ToString();
if (!string.IsNullOrEmpty(dateStr))
{
request.ReservationDate = new ReservationDateRange
{
Start = dateStr,
End = dateStr
};
}
}
// 時間帯の設定
if (arguments.TryGetValue("TimeZone", out var timeZone))
{
var timeZoneStr = timeZone?.ToString();
if (!string.IsNullOrEmpty(timeZoneStr))
{
request.TimeZone = new[] { timeZoneStr };
}
}
}
return await _webApiClient.SearchReservationsAsync(request);
}
/// <summary>
/// 名前による予約検索ツール実行
/// </summary>
private async Task<string> CallSearchReservationsByNameAsync(Dictionary<string, object>? arguments)
{
var request = new ReservationSearchByNameRequest();
if (arguments != null)
{
if (arguments.TryGetValue("SellingCompany", out var sellingCompany))
request.SellingCompany = sellingCompany?.ToString();
if (arguments.TryGetValue("Store", out var store))
request.Store = store?.ToString();
if (arguments.TryGetValue("AreaGroup", out var areaGroup))
request.AreaGroup = areaGroup?.ToString();
if (arguments.TryGetValue("CustomerName", out var customerName))
request.CustomerName = customerName?.ToString();
if (arguments.TryGetValue("PhoneNumber", out var phoneNumber))
request.PhoneNumber = phoneNumber?.ToString();
if (arguments.TryGetValue("Email", out var email))
request.Email = email?.ToString();
if (arguments.TryGetValue("AcceptanceDate", out var acceptanceDate))
request.AcceptanceDate = acceptanceDate?.ToString();
if (arguments.TryGetValue("Model", out var model))
request.Model = model?.ToString();
if (arguments.TryGetValue("PlateNumber", out var plateNumber))
request.PlateNumber = plateNumber?.ToString();
if (arguments.TryGetValue("Color", out var color))
request.Color = color?.ToString();
if (arguments.TryGetValue("Remarks", out var remarks))
request.Remarks = remarks?.ToString();
if (arguments.TryGetValue("page", out var page) && int.TryParse(page?.ToString(), out var pageInt))
request.Page = pageInt;
if (arguments.TryGetValue("limit", out var limit) && int.TryParse(limit?.ToString(), out var limitInt))
request.Limit = limitInt;
// 予約日の設定
if (arguments.TryGetValue("ReservationDate", out var reservationDate))
{
var dateStr = reservationDate?.ToString();
var storeValue = arguments.TryGetValue("Store", out var storeArg) ? storeArg?.ToString() : null;
if (!string.IsNullOrEmpty(dateStr))
{
request.ReservationDate = new ReservationDateRange
{
Store = storeValue,
Start = dateStr,
End = dateStr
};
}
}
// 時間帯の設定
if (arguments.TryGetValue("TimeZone", out var timeZone))
{
var timeZoneStr = timeZone?.ToString();
if (!string.IsNullOrEmpty(timeZoneStr))
{
request.TimeZone = new[] { timeZoneStr };
}
}
}
return await _webApiClient.SearchReservationsByNameAsync(request);
}
/// <summary>
/// カレンダー取得ツール実行
/// </summary>
private async Task<string> CallGetCalendarAsync(Dictionary<string, object>? arguments)
{
var request = new CalendarRequest();
if (arguments != null)
{
if (arguments.TryGetValue("date", out var date))
request.Date = date?.ToString() ?? string.Empty;
if (arguments.TryGetValue("AreaGroupId", out var areaGroupId) && int.TryParse(areaGroupId?.ToString(), out var areaGroupIdInt))
request.AreaGroupId = areaGroupIdInt;
}
if (string.IsNullOrEmpty(request.Date))
{
throw new ArgumentException("date parameter is required");
}
return await _webApiClient.GetCalendarAsync(request);
}
}
WebApiClient.cs
using System.Text.Json;
using KkcMcpServerApi.Models;
namespace KkcMcpServerApi.Services;
/// <summary>
/// WebAPIクライアントのインターフェース
/// </summary>
public interface IWebApiClient
{
Task<string> SearchReservationsAsync(ReservationSearchRequest request);
Task<string> SearchReservationsByNameAsync(ReservationSearchByNameRequest request);
Task<string> GetCalendarAsync(CalendarRequest request);
}
/// <summary>
/// kkc-webapi を呼び出すクライアント
/// </summary>
public class WebApiClient : IWebApiClient
{
private readonly HttpClient _httpClient;
private readonly ILogger<WebApiClient> _logger;
private readonly JsonSerializerOptions _jsonOptions;
public WebApiClient(HttpClient httpClient, ILogger<WebApiClient> logger)
{
_httpClient = httpClient;
_logger = logger;
_jsonOptions = new JsonSerializerOptions
{
// Laravel APIはPascalCaseを期待しているため、PropertyNamingPolicyを設定しない
WriteIndented = true
};
}
/// <summary>
/// 予約検索API呼び出し
/// </summary>
public async Task<string> SearchReservationsAsync(ReservationSearchRequest request)
{
try
{
var json = JsonSerializer.Serialize(request, _jsonOptions);
var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
_logger.LogInformation("Calling reservations/search API with: {Request}", json);
var response = await _httpClient.PostAsync("/api/v1/reservations/search", content);
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync();
_logger.LogError("API call failed with status {StatusCode}: {Error}",
response.StatusCode, errorContent);
throw new HttpRequestException($"API call failed: {response.StatusCode} - {errorContent}");
}
var result = await response.Content.ReadAsStringAsync();
_logger.LogInformation("API response received: {Response}", result);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error calling reservations search API");
throw;
}
}
/// <summary>
/// 名前による予約検索API呼び出し
/// </summary>
public async Task<string> SearchReservationsByNameAsync(ReservationSearchByNameRequest request)
{
try
{
var json = JsonSerializer.Serialize(request, _jsonOptions);
var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
_logger.LogInformation("Calling reservations/search-by-name API with: {Request}", json);
var response = await _httpClient.PostAsync("/api/v1/reservations/search-by-name", content);
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync();
_logger.LogError("API call failed with status {StatusCode}: {Error}",
response.StatusCode, errorContent);
throw new HttpRequestException($"API call failed: {response.StatusCode} - {errorContent}");
}
var result = await response.Content.ReadAsStringAsync();
_logger.LogInformation("API response received: {Response}", result);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error calling reservations search-by-name API");
throw;
}
}
/// <summary>
/// カレンダーAPI呼び出し
/// </summary>
public async Task<string> GetCalendarAsync(CalendarRequest request)
{
try
{
var json = JsonSerializer.Serialize(request, _jsonOptions);
var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
_logger.LogInformation("Calling reservations/calendar API with: {Request}", json);
var response = await _httpClient.PostAsync("/api/v1/reservations/calendar", content);
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync();
_logger.LogError("API call failed with status {StatusCode}: {Error}",
response.StatusCode, errorContent);
throw new HttpRequestException($"API call failed: {response.StatusCode} - {errorContent}");
}
var result = await response.Content.ReadAsStringAsync();
_logger.LogInformation("API response received: {Response}", result);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error calling calendar API");
throw;
}
}
}
まあ、それでも AI エージェントを使ってバイブコード(Vibe Coding)する分には十分な品質を保っています。About ページならばセキュリティがどうという話もないし特殊なロジックを使っているわけではありません。単にアプリの製品情報とかを並べたいだけです。
従来の方式で言えば Android Studio や Visual Studio が出してくれるテンプレートを使えばいいのですが、 そこすら面倒くさいし、こうやって AI エージェントが作りやすいように画面設計や変数設定をしてしまったほうが後々楽でしょう。うまくいかなければ、もう一度いちから AI エージェントに作って貰えばよいです。
エミュレータで実行するとこんな感じです。
既存の Activity を編集してもらう
About 画面ができたので、いくつか編集をしてみましょう。
UI を作成している AboutScreen の中身を手作業で変更してもよいのですが、AI エージェントを使っても追加や削除ができます。手作業だとレイアウトが崩れたりするので、情報を与えて AI エージェントに修正して貰ったほうが作業が早く済みます。複雑なロジックの場合は面倒なのですが、ちょっとした文言を追加するのはプロンプトだけで結構いけます。
完全にオートメーションという訳にはいかないでしょうが、出だしぐらいはいけるんじゃないでしょうか? Claude Code のように git へのコミットとかはできませんが、この手のプログラムを AI エージェントに一括に任せることは少なく(アイデアを間に差し込むので)、初期のプロトタイプの底上げが目的です。これまでの2回は、自動生成させたコードがバグバグで到底直せない感じになってしまったので、ちょっと慎重に概要設計を書いています。
実は、AI エージェントに対して要件定義や概要設計のレビューを求めることができます。このレビューは要件定義内にある矛盾を叩き出すのに有効です。同時に設計の齟齬も発見してくれます。逆に顧客=自分としてはこの要件定義は外せないと思うならば、要件定義内に「これは契約上重要である」ことを強調すればよいでしょう。そのあたりの整合性を AI が判断しているかどうかは判別できないのですが。
Claude Sonnet の進捗状況
なんとなくできたようです。
ロジックができていて、テストコードをも一応作られています。
まあ、動きは変なんですけどね。。。
どうも落下アニメーションがブラウザ上でうまくいかないのは定番っぽいです。
Expoの開発サーバーが既に起動されているので、ブラウザまたは Expo Go アプリでゲームをプレイできます。ゲームは要件定義と設計仕様に忠実に実装されており、マッチ3ゲームの基本的な遊び方から爆弾システムまで完全に機能します。
とにかく最初のひな型の作成と不明なエラーメッセージを代わりに読んでくれるのは圧倒的に楽です。変なロジックを踏んで、どうにもならなくなったら早々に AI エージェントの動作を取りやめて、方針を変換します。
特定の関数やクラスを切り出すように指示する
おおむね、ペアプロのナビゲータ役か、結合テストのテスター役に徹するとうまくいきます。コードを書くときは、変数名などを AI エージェントが書いているほうに合わせます。このほうが、AI にとって続きが書きやすく、変数名の揺れが少なくなります。
要件定義や概要設計を AI が読んで返して来た用語をそのまま使うようにします。ラショナル統一プロセスの「用語集」やドメイン駆動設計の「ユビキタス言語」を意識するとよいでしょう。どうも、下手に人間側の用語を持ち出すと AI が誤解をし始めるので、いまのところは人間が合わせたほうが無難です。用語については、別途 *.md に書き出してもよいかもしれません(トークンを消費しそうですが)。
テストを繰り返す構造
爆弾セルの動きがおかしかったので指摘をすると、テストコードを追加してテストしてくれます。
ゲームロジックと UI の表示をどうやってテストするのかが難しいところですが、AI エージェントのテストも私のテストも同じことをやってくれます。UI のテストは結構手間なのですが、今回の3マッチゲームの場合はボードに配置する(2次元配列に配置する)ことになるので、爆弾やジェルのマッチの前後を配列そのもので表すことができます。これをちまちま手作業で書いてテストの成功例を書いていきます。実に面倒臭い作業なのですが、これが後々効いてきます。そのあたりの単純作業を AI エージェントが肩代わりしてくれるのでかなり助かります。
手元で試している Claude Sonnet は Claude Code の廉価版みたいなもので、GitHub Copilot Pro(月10ドル) + Claude Sonnet(無料)の組み合わせ開発が可能です。業務や精度をアップしたいときは Claude Code 等を使うといいんでしょうがお金が結構かかるのと同時にリクエスト制限が結構きついです。X を見る限り2,3時間でリミットに達する模様。その点では、GitHub Copilot Pro + Claude Sonnet の組み合わせだとリクエストが無制限になっていて(本家、Claude Sonnet だけだと時間内制限があります、何故w)、それなりに開発ができます。ちなみに、皆さんが Opus 4 を使っているらしく、Sonnet 4 のほうは空いていてレスポンスがよいです。
ちなみに、モデルを GPT40 に変えることができるのですが、コード生成の質が悪くて、最初の課題がクリアできません。素直に Claude Sonnet を使う方がいいです。
SVG のときもそうでしたが、AI エージェントは App.tsx をがんがん書き変えます。これが人の開発者であれば、構造化したり共通化したりするところですが AI の場合は検索できるパターンがあれば機械的に書き変えるの手間がかからないので、全て書き変えてしまう勢いです。
将来的に AI エージェントで出力させたコードの Git 履歴とかが難しいでしょうね。ある程度コード量が多くなってくると、トークン数の制限のためにコードを部分的に読み出すように最適化されてきます。このときに、設計スタイルとしてオブジェクト指向の各種パターンを使うかどうかは定かではないのですが、試した感じでは「1回のトークン数を超えていくところからバグを大量発生」させていきます。おそらく全体の整合性がとれなくなってしまうのでしょう。人の開発者ならば、構造化して眺めるコード量を抑えるのですが、現在の AI エージェントではその視点がありません(上位バージョンの Opus 4 にはあるかもしれません)。なので、AI エージェントが読み込みやすいようにある程度コードを構造化するようにプロンプトで誘導するのがベターかもしれません。
おそらく、条件の組み合わせ爆発のときに駄目っぽい感じがします。業務アプリケーションのように管理画面で CRUD 機能をつけるとかカテゴリから商品をひらくとかいうステートがあまり変化しないものは大丈夫なのですが、3マッチゲームのようにまともにやると遷移表が爆発しそうなものは AI エージェント頼りでは難しそうです。
{
"name": "ASP.NET Core 9.0 Development Container",
"dockerFile": "../Dockerfile.dev",
"context": "..",
"workspaceFolder": "/workspace",
"shutdownAction": "stopContainer",
// Mount the workspace folder
"mounts": [
"source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=cached"
],
// Use vscode user for development
"remoteUser": "vscode",
// Configure tool-specific properties.
"customizations": {
"vscode": {
"extensions": [
"ms-dotnettools.csharp",
"ms-dotnettools.csdevkit",
"ms-vscode.vscode-json",
"ms-azuretools.vscode-docker"
],
"settings": {
"dotnet.defaultSolution": "aspnet-minimal-sample.sln",
"files.exclude": {
"**/bin": true,
"**/obj": true
},
"terminal.integrated.defaultProfile.linux": "bash"
}
}
},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
"forwardPorts": [
8000,
8081
],
// Use 'postCreateCommand' to run commands after the container is created.
"postCreateCommand": "chmod +x .devcontainer/init.sh && .devcontainer/init.sh",
// Configure container features (simplified)
"features": {
"ghcr.io/devcontainers/features/common-utils:2": {
"installZsh": false,
"username": "vscode",
"userUid": "1000",
"userGid": "1000"
}
}
}
init.sh
#!/bin/bash
# Fix permissions for .NET build artifacts
echo "🔧 Fixing permissions for .NET development..."
# Remove existing build artifacts that might have incorrect permissions
echo "🧹 Cleaning build artifacts..."
rm -rf obj bin || true
# Ensure vscode user owns the workspace
echo "👤 Setting ownership..."
sudo chown -R vscode:vscode /workspace
# Set appropriate permissions
echo "🔐 Setting permissions..."
sudo chmod -R 755 /workspace
# Create directories with correct permissions
echo "📁 Creating build directories..."
mkdir -p obj bin
# Clean and restore
echo "🏗️ Cleaning and restoring project..."
dotnet clean
dotnet restore aspnet-minimal-sample.csproj
# Test build
echo "🧪 Testing build..."
dotnet build aspnet-minimal-sample.csproj
echo "✅ Setup complete! You can now run 'dotnet run'"
setup.sh
#!/bin/bash
# DevContainer initialization script
echo "🚀 Starting DevContainer setup..."
# Check if we're in the correct directory
echo "📁 Current directory: $(pwd)"
echo "📄 Files in current directory:"
ls -la
# Check if project file exists
if [ -f "aspnet-minimal-sample.csproj" ]; then
echo "✅ Found aspnet-minimal-sample.csproj"
echo "📦 Restoring NuGet packages..."
dotnet restore aspnet-minimal-sample.csproj
if [ $? -eq 0 ]; then
echo "✅ NuGet packages restored successfully"
else
echo "❌ Failed to restore NuGet packages"
exit 1
fi
else
echo "❌ aspnet-minimal-sample.csproj not found in current directory"
exit 1
fi
# Check if solution file exists
if [ -f "aspnet-minimal-sample.sln" ]; then
echo "✅ Found aspnet-minimal-sample.sln"
else
echo "⚠️ aspnet-minimal-sample.sln not found"
fi
echo "🎉 DevContainer setup completed successfully!"
Dockerfile と docker-compose.yml なんて生成AIのモデルの中では十分に知見がありそうなものですが、何度やっても失敗します。仕方がないので手作業で修正をした後に Claude Sonnet にレビューして貰います。