見積もり依頼作成ツールの試作

ベータ版ではありますが、非IT業種向けの「見積もり作成依頼書ツール」 https://omitt-chan.vercel.app/ を公開しておきます。非IT業種向けということで、いわゆる発注者向けのツールです。

見積もり依頼書とは?

「見積もり依頼書」というのは、発注者がIT業者に見積もり依頼をするときの項目を書き連ねたものです。受注側が作成する要件定義や設計書に先立つものですね。普通は開発コンサルタントが発注会社に居て代筆をしたり、発注会社でのIT部門が作成したりするものです。

大手のIT会社だと開発コンサルタントが居て、営業が居て、という具合になりますが、20人程度の中小のIT会社だったり個人開発だったりすると、そういう風に作業分担ができるわけではありません。営業だったり要件のまとめだったり予算の見積もりだったりをひとりでこなすことが多いです。そんな中で、非IT業種である「見込み客」(まだ受注が決まっていないので、見込みでしかないです)に対して、中小や個人の場合はそんなに時間を掛けることができません。

時間を掛けることはできないのですが、営業活動はしないといけないよね、というジレンマがあります。

20年程前であれば、お客さんもITに詳しくなくてパソコンとインターネットがぎりぎりという方も多くいたのですが、最近であればスマホを使うは当たり前で、仕事でパソコンを使うし、インターネットで検索をするのは普通になってきています。ならば、ちょっとした「見積もり依頼者」ならば、見込み客のほうで書いて貰えるかもしれません。というのが、このツールの発想元です。

中小/個人の非ITとITをつなげる

予算が潤沢であれば、中小のIT業者に依頼をかけて1000万円以上で見積もりを掛けることも可能なのですが、実際はそうはいきません。せいぜい数百万、場合によっては100万以下のシステム開発からスタートするところでしょう。一方で、非ITとITをつなげるマッチングサイトもいくつかあるのですが、価格が安すぎるのが難点です。受注をする IT サイドから言えば、100万以下の案件はなかなか動きづらいところがあります(営業活動や見積もり作業も込みとなるので)。まして、10数万円ていどではITアルバイトのようなものです。実際に、小規模の作業であれば、ITアルバイト感覚で受注することもよいのですが、実はそれだけだと採算が合いません。

  • 発注者(非IT業者)からは、100万円程度で依頼を掛けたい。スケジュールは厳しくないが、それなりに品質を上げて欲しい。
  • 受注者(IT業者)からは、単発の仕事ではなくまとまった仕事で受けたい。タスクをまとめて、マルチプロジェクト/プロセスにして効率化しておきたい。

この2つを同時に実現することを考えます。そうすると、発注者としては、いくつかの不安点を解消する必要がでてきます。

  • 見積もりをして貰うときに、ある程度の予算を知りたい(IT業者に内緒で)
  • むちゃくちゃ安くなくても良い。しかし、1000万円を超える予算は取れない。
  • ITには詳しくないが、スマホのやブラウザの雰囲気は知っておきたい(IT業者には内緒で)

というように、いちいちIT業者に見積もりの問い合わせをしたり開発コンサルにお金を支払ったりしなくても、大まかな予算ぐらいは把握したいところです。いわゆる「市場的な相場はいくらかな?」というところですね。

現在、仲介業者のお陰で契約社員や派遣の給与が低く抑えられてしまっています。もちろん、契約社員や派遣でのよいとこはある(ピンポイントで働く、派遣会社が仕事を斡旋してくれるなどなど)のですが、中小や個人でのIT業者の場合は、あえて仲介を挟まないのであれば、挟みたくないのが本音でしょう。

  • ある程度の開発プロジェクトのマネジメントができるIT業者(中小はそれ)
  • 特化した技術を持ち、なんとか非IT系にリーチさせたい(個人開発者や数名の会社はそれ)

というIT業者と、非IT業者をつなげるのが目的です。

本ツールでできること

基本的に発注者(非IT業者)が使うツールです。

  • 作成した要件を要件入力チャットに入れていきます。まとまってなくて構いません。やりたいことを書いてくれれば ok です。
  • AI がチャットの内容を確認して、要望/機能要件/非機能要件/制約条件などの分類してくれます。
  • ある程度まとまったら「システム構成を生成」ボタンをクリックして見積もり依頼書を作成します。文章は、IT業者が理解しやすいように、システム要件などで分類されています。システムコンポーネントなどIT業者にしか伝わらない専門用語が使われていますが、大丈夫です。相手のIT業者ならわかってくれます。AI が IT業者向けに必要な情報を補います。

「見積もり依頼書」をコピペして、知り合いのIT業者にメールで送るか、クラウドワークスなどのマッチングサイトの要件と部分に貼り付けるか、入札に利用するか、してください。利用は自由です。

真ん中の「構造化要件」の内容は、ファイルに保存ができます。途中まで書いたらダウンロードして保存しておいてください。「読み込み」ボタンをクリックすると、保存したファイルを読み込んで、構造化要件を復元できます。

要件入力チャットの例

例を出しておきますので、コピペして使ってみてください。

https://omitt-chan.vercel.app/

例1 メール配信の自動化


- メールを配布するツールを作りたい
- 宛先はあらじめ Excel に登録しておいて、指定した時刻に配布できるようにする。
- 配布先はログに残しておく。
- 配布先にはチェックボックスがあって、配布するかどうかを選べるようにする。
- 毎日指定時刻に、定型文を送信する。このとき宛名は、Excel に記述した名前を使う。
- 配布する宛先は500件ぐらいある

見積依頼書

見積もり依頼書

【プロジェクト概要】
Webアプリケーションの開発をご依頼いたします。

【システム要件】
■ 機能要件 (6件)
・メール配布ツール: メールを配布するツールを作成する。
・宛先管理: 宛先はあらじめ Excel に登録しておき、指定した時刻に配布できるようにする。
・配布先ログ管理: 配布先はログに残しておく。
・配布先選択機能: 配布先にはチェックボックスがあって、配布するかどうかを選べるようにする。
・定型文送信機能: 毎日指定時刻に、定型文を送信する。このとき宛名は、Excel に記述した名前を使う。
・配布件数管理: 配布する宛先は500件ぐらいある。

■ 非機能要件 (0件)


■ 制約条件 (0件)


【技術仕様】
■ アーキテクチャタイプ: web
■ デプロイ環境: cloud

■ システムコンポーネント (5件)
・メール配布ツールフロントエンド (frontend)
技術: React, Bootstrap
概要: ユーザーがメール配布を管理するためのインターフェース。宛先の選択や定型文の設定が可能。

・メール配布ツールバックエンド (backend)
技術: Node.js, Express, Python
概要: メール配信ロジックを処理し、データベースと連携するAPIを提供。

・宛先管理データベース (database)
技術: PostgreSQL
概要: 宛先情報や配布先ログを管理するためのデータベース。

・クラウドインフラストラクチャ (infrastructure)
技術: AWS, Docker
概要: アプリケーションをホスティングするためのクラウド環境。

・セキュリティ対策 (security)
技術: OAuth2, SSL/TLS
概要: データの保護と不正アクセス防止のための対策。

■ ネットワーク要件
・インターネット接続が必要
・APIレスポンスは1秒以内

■ セキュリティ対策
・ユーザー認証と権限管理の実装
・データ暗号化とバックアップの実施

■ スケーラビリティ考慮事項
・負荷分散を用いたスケーラビリティの確保
・データベースのリードレプリカを利用した読み込み負荷の分散

【その他】
ご質問やご相談がございましたら、お気軽にお声がけください。

例2 炉心計算と制御棒配置の比較研究

- 原子炉の制御棒の配置比較システムを試作する
- 拡散方程式を使い、原子炉内の中性子の分布を計算する
- 制御棒の配置を変更することで、出力の中性子の分布を調整できるようにする
- 制御棒の配置パターンを色々変えた状態で、臨界ができるかチェックする
- 配置パターンによりシミュレーションデータを出力する
- 各実験により、シミュレーションデータを比較できる機能を持つ
- ログイン機能を持ち、各ユーザーが実験データを別々に保存できる

見積依頼書

見積もり依頼書

【プロジェクト概要】
オンプレミスシステムの開発をご依頼いたします。

【システム要件】
■ 機能要件 (7件)
・原子炉制御棒配置比較システム: 原子炉の制御棒の配置を比較するシステムを試作する。
・中性子分布計算: 拡散方程式を使い、原子炉内の中性子の分布を計算する。
・出力調整機能: 制御棒の配置を変更することで、出力の中性子の分布を調整できるようにする。
・臨界チェック機能: 制御棒の配置パターンを色々変えた状態で、臨界ができるかチェックする。
・シミュレーションデータ出力: 配置パターンによりシミュレーションデータを出力する。
・データ比較機能: 各実験により、シミュレーションデータを比較できる機能を持つ。
・ユーザー認証機能: ログイン機能を持ち、各ユーザーが実験データを別々に保存できる。

■ 非機能要件 (0件)


■ 制約条件 (0件)


【技術仕様】
■ アーキテクチャタイプ: on_premise
■ デプロイ環境: on_premise

■ システムコンポーネント (5件)
・Webアプリケーション (frontend)
技術: React, HTML5, CSS3
概要: ユーザーが原子炉制御棒配置を操作・比較するためのブラウザベースのインターフェース。

・シミュレーションエンジン (backend)
技術: Python, Flask, NumPy
概要: 中性子分布計算や臨界チェックを行うロジックを実装したサーバーサイドコンポーネント。

・データベース (database)
技術: PostgreSQL
概要: ユーザー情報やシミュレーションデータを保存するためのリレーショナルデータベース。

・サーバーインフラ (infrastructure)
技術: Linux, Docker
概要: システム全体をホストするための物理サーバーまたは仮想サーバー。

・ユーザー認証システム (security)
技術: OAuth2, JWT
概要: ユーザーのログイン機能を提供し、データのセキュリティを確保する。

■ ネットワーク要件
・低遅延のネットワーク接続が必要
・セキュアなVPN接続を使用して外部からのアクセスを制限

■ セキュリティ対策
・ユーザー認証とアクセス制御の実装
・データ暗号化を使用して機密情報を保護

■ スケーラビリティ考慮事項
・シミュレーション負荷に応じてサーバーリソースをスケールアップ可能
・データベースのシャーディングを考慮し、大量のデータに対応

【その他】
ご質問やご相談がございましたら、お気軽にお声がけください。

今後の拡張予定

見積もり依頼書までできたら、発注側(非IT業者)で、ざっとした要件定義やざっとした概要仕様書まで作っておきたいところです。

依頼書や要件定義、かんたんな概要設計をもとに、AI で見積もりを立てます。それのベースに、スケジュールや相見積を作成するところまで持って行きます。

その後に、発注者(非IT業者)と受注者(中小のITや個人開発者)のマッチングを考えたいところです。

カテゴリー: 開発 | 見積もり依頼作成ツールの試作 はコメントを受け付けていません

個人開発者のマルチプロジェクトのマルチプロセス化の勧め

個人開発者(いわゆるフリーランス)と、中小企業の開発者は、ひとつのプロジェクトに掛かりっきりということがまずありません。いくつかのプロジェクトを掛け持ちすることになります。

前提

実は、大会社の開発者(システムエンジニアとかマネージャではなく)や派遣社員や契約社員の場合には、一定の期間一つのプロジェクトに拘束されるというのが常です。特に、派遣社員や契約社員はそうなのですが、中小企業というか20人ぐらいまでの小規模のIT会社の場合には、一つのプロジェクトに縛られることはまずありません。色々なプロジェクトを平行に動かす必要がでてきます。

というのも、

  • 開発プロジェクトの規模が小さく、ひとつのプロジェクトに貼り付けにするほど予算がない。
  • 請負開発を受注する場合、社員=開発者が少ないので複数プロジェクトに配員せざるを得ない

という現状があります。発注側からみてフルタイムで働いているように見えて、内実は別のプロジェクトも抱えている、というのが現状ですよね。マネージャーや営業レベルだと複数のプロジェクトが動くのは普通なのですが、開発者の場合は複数プロジェクトというのは大手であれば稀だが中小であれば当たり前だったりします。

この認識を前提としていきます。

当然、個人開発者の場合も同じで、派遣や契約社員にならないのであれば、複数のプロジェクトが掛け持ちになるのが普通です。フリーランスなのでハンドリングを自分でやるのが中小の社員とは違いますが、マルチプロジェクト化します。

スクラム方式ではうまくいかない

基本的に、アジャイル開発スクラムの場合には、ひとつのプロジェクトに集中できることが前提となります(実際はどうなのかは別ですが)。自分の時間をコミットするので、2週間なりのスプリントができるし、そのプロジェクトの中で課題(バックログ)のやりくりをします。

ですが、先の中小や個人開発者の場合は、ひとつのプロジェクトに集中できるわけではないので、このスクラムの前提が崩れてしまいます。

と常々思っていたのですが、その理由が解説できそうなので、メモとして残しておきます。

理由はまさしく先に書いた通り、ひとつのプロジェクトに集中できない、つまりはコミット率が低くなってしまうので、スクラムの柔軟さが失われてしまうということです。なので、複数プロジェクトを掛け持ちしているメンバーがスクラムチームにいると失敗しがちですね。巷でいうスクラム開発が失敗したり、うまくいかないと思われている理由はこれでしょう。

マルチプロジェクトをマルチプロセス化する

ひとりの開発者が複数のプロジェクトを掛け持ちしていることを考えてみましょう。ここでは、経費精算とか社内での勉強会とか定型業務は別とします。一般的な開発プロジェクトのようにスタートとエンドが決まっているものを示します。

開発者が、A,B,C という3つのプロジェクトを掛け持ちしてる場合、複数の時間の使い方が考えられます。

  • Aプロジェクトを終わらせて、Bプロジェクトを終わらせて、Cプロジェクトを終わらせる
  • 1日を3分割してA,B,C のプロジェクトを少しずつやる

理想的なプロジェクトの形式で進めれば、どちらの方法もA,B,Cプロジェクトを終了させることができます。実際のところ、プロジェクトが1週間程度のものであれば、シーケンシャルにA,B,C と進めていったほうが早く終わります。頭の中でプロジェクトを切り替える必要がないのですから、それぞれの専任していると同じになります。

しかし、実情としては後者のようにA,B,Cのプロジェクトをちょっとずつやることになります。これは、各プロジェクトが長期に渡るのも理由なのですが、実際には途中に進捗会議があったり、客の回答待ちになったり、開発環境の購入待ちになったりします。その合間に別のプロジェクトをやったほうが時間効率がよくなる…ように見えるためです。

1日を3分割するのであれば、A,B,C の3つのプロジェクトを廻せそうですが、お客との会議や回答待ちなどを考えると、必ずしも1日を3分割にする必要はありません。むしろ無駄ができそうです。

なので、プロジェクトという単位ではなく、プロジェクトにプロセス/タスクという単位に区切ります。実際のところ単一プロジェクトであっても WBS/バックログ/チケット、という形で作業分割を利用するので、それと同じ状態です。ただし、複数のプロジェクトであり複数のタスク(プロセス)であるということが違います。

タスクを細かく分割すると、A,B,C のプロジェクトの切り替えでセットアップ時間(仕事の切り替え時間)が発生してしまいますが、そこは個人開発者の場合は休憩時間や昼休みなどを利用します。また、タスクに分けたときに、”タスクの途中でも切り上げられる” & “タスクの途中でもうまく再開できる” 仕組みを用意しておきます。理想を言えば、ひとつのタスクが仕上がるまで掛かりっきりになるのがベターではあるのですが、そこまでタスク/チケットを細かくできないことが多く、タスクの中の作業単位(TODOや箇条書き)で切り替えができるとよいです。

複数プロジェクトのタスクを渡り歩く

個人開発者であれば、身がひとつなのですから、Aプロジェクトのタスクをやっている間は、BプロジェクトやCプロジェクトのタスクはお休みなります。一見、マルチタスクで動いているようには見えますが(今後 AI エージェントの活用次第ではそれができるかもしれませんが)、個人の時間軸から言えばシーケンシャルにこなしているだけです。

この場合、A,B,Cの開発見積(予算、金額)はどうなるでしょうか?

プロジェクトの作業見積をする場合、大抵はプロジェクトに専任であることが前提となっています。スクラム方式で言えば、1か月20日8時間勤務ですから、160時間が基準値となります。

これを基準としたとき、A,B,C プロジェクトを掛け持ちする場合を考えてみましょう。

  • Aプロジェクト 80h
  • Bプロジェクト 40h
  • Cプロジェクト 40h

という見積もりを立てることが多いですよね。プロジェクトマネージャーやプロジェクトリーダー役をやったときに、マルチプロジェクトになってしまった場合にはこんな感じに割り振ってしまいます。

大抵の場合、作業時間 x 単価ということになるので、AプロジェクトはBプロジェクトの2倍の予算が掛かります、とお客に説明するわけですが、果たしてそうなるでしょうか?当然のことながら、A,B,C のプロジェクトのお客さんは別々で、互いに顔を合わせることはありません。場合によっては、開発者が自分のプロジェクト(Aプロジェクトとか)に専任していると思っているかもしれません。そうです、いわゆる「官庁プロジェクトの開発者は3倍働く」わけですが、それはまた別の話。

割り振った時間や予算は2:1:1のように分割されますが、実績ではそうはなりません。計画上は2:1:1の比率になるのでしょうが。例えば、Bプロジェクトがちょっと遅れ気味の場合は、Aプロジェクトを少し抑え気味にしてBプロジェクトに力点を変えますよね。Cプロジェクトがさっくりと終わるようであれば、そのままA,Bプロジェクトに作業時間を割り振ってしまうことも稀でありません。そんな形で、A,B,C プロジェクトに費やす時間を開発自身が割り振り直してしまいます。

マルチプロジェクトが安全バッファとなる

過小見積をしてしまうと、A,B,C の3つのプロジェクトが炎上してしまって、どのプロジェクトも立ち行かなくなってしまいます。しかし、それなりに規模見積もりがあっている場合は、A,B,C の実績は平均値を上下する程度で済みます。平均を上下した場合、先のように他のプロジェクトにリソース(この場合は時間)を割り振ることができます。つまりは、CCPMのプロジェクトバッファと同じように、マルチプロジェクト自体が安全バッファとなるのです。マルチプロジェクトの場合は、プロジェクトごとにバッファを持たせる必要がなくて、マルチプロジェクト全体のバッファを持たせればよいということになります。これは個人開発者の場合は、土日祝日だったりするわけです。忙しいときは仕方がないので土曜日に少し足しておくか、という感じです。

制約理論的には、プロセスのバッファをひとつにまとめてプロジェクトバッファとしているのですから、複数プロジェクトのバッファをひとつにまとめて、マルチプロジェクトのバッファつまりは開発者自身のバッファということになります。つまりは、単一プロジェクトに掛かりっきりになるよりも、ある程度マルチプロジェクト化したほうが安全ということです。もちろん、同時に炎上しないように注意する必要はありますが。果樹園農家で、ひとつの果物に集中するのではなく、リスク分散して複数の果物を作るのに似てますね。

カテゴリー: 開発 | 個人開発者のマルチプロジェクトのマルチプロセス化の勧め はコメントを受け付けていません

粗大ごみの金額が検索できるサイトの試作

去年だと思いますが、生成AIがちょっと流行った頃に(今はAI エージェントが流行っている)、東京都のとある区で粗大ごみの分類を検索する入札が行われました。で、どこかの会社が受けたと思うのですが、検索した適合率が90%程度だったので採用にならず、というのがありました。

ちょうど、その頃に Azure OpenAI 本を出版したときもあって、この第10章に Function Calling の活用があって、それを使うともう少し精度ができるなぁ、と思った次第です。

OpenAI の Function Calling は AI のプロンプト上から特定の関数を呼び出すことができる仕組みです。ちょうど JSONP のような形でコールバックを返してくれます。で、いまでは MCP (Model Context Protocol) サーバーを立てるというパターンもあるので、Function Calling を使った RAG (Retrieval-Augmented Generation)にするか MCP サーバーにするかというところですが、このあたりはチャット部分を自前で作成するか、Claude Desktop などを利用した既存のチャット/プロンプトを使うか、という形で分かれます。

板橋区粗大ごみの分類を取得

粗大ごみの分類は各自治体でかなり違ってきます。そのために分類も違ってくるので、なかなか統一できません。

粗大ごみ(申込制/有料)・料金検索(AIチャットボット)|板橋区公式ホームページ https://www.city.itabashi.tokyo.jp/tetsuduki/gomi/sodai/1001868.html

ひと世代前のチャットボットが流行りなのですが、まあ、ひと世代前なのです。

ピンポイントで検索できるものはいいのですが、ちょっと込み入ったものだと微妙です。この例では「ガラス」に引っ張られていますが、「机」のほうがよいですよね。実際に「机」の分類があります。

結局めんどうくさくなって、PDF の一覧表から検索したりします。

実は分類は400弱ぐらいしかないので、そう多くないです。要は利用者が自然言語を使って質問しているところに、サイト側で「キーワードマッチ」しかしていないのが問題です。というか、当時のチャットボットはそういうものだったので、これでも十分なような気がします。

が、生成AIを使っていけば、もう少し利用者側に便利できるのでは?と思ったのが、RAG や MCP サーバーの登場というところです。

粗大ごみの分類DBを作る

粗大ごみの分類が PDF で提供されているので、これを SQLite のような DB で扱えるようにします。この作業を ChatGPT を使おうと苦戦したのですが、あえなく挫折しています。どうも PDF の表面は表形式に見えるのですが、中身がぐちゃぐちゃになっているようで ChatGPT が python で読み込もうとして失敗してしまいます。画像読み込みを試せばよかったのですが、まあ、数も400行位だしということで、手作業で Excel に直しました。

板橋区粗大ごみ.xlsx

試しに分類をやってみたいという奇特な方はこれを使ってみてください。自分の自治体に CSV 形式であればそれを使ってください。

板橋区の場合は「名前」「金額」「特記事項」となっています。まあ、中身をみるとわかるのですが、特記事項に品名っぽいものが書いてあったり、名前にサイズ(机のサイズとか)が書いてあったりしてかなり混乱しています。この手のやつは自治体では仕方ないことで、まあデ庁の標準化は大変だろうな、といいますか、そもそもデ庁主導では標準化できないだろう、などと思ったりししつつ、さてどうしたものかと考えるのです。

面倒な場合は Excel にした後で検索すれば一発なので、うまくキーワードが検索できればそれで十分ですよね。

ユーザーインターフェースを考える

最近は要件定義や設計を readme.md に残すようにしています。

# 粗大ごみ検索システムの試作

粗大ごみの分類を LLM を既存データベースを組み合わせて金額を調べるシステム

## 概要

- 粗大ごみの分類は、既存のデータベース(SQLite)を使用
- フロントエンドの UI は Chat を使う
- フロントエンドは OpenAI API を使うと高くなるので、ローカル LLM を利用する

## ユースケース

例1
- ユーザーが「ソファを捨てたい」と入力
- システムが「ソファは粗大ごみです。料金は500円です。」と応答

例2
- ユーザーが「冷蔵庫と洗濯機を捨てたい」と入力
- システムが「冷蔵庫の料金は1500円です。洗濯機の料金は1000円です。」と応答

例3
- ユーザーが「本棚を捨てたい」と入力
- システムが「本棚の高さは200cm以上ですか?」と応答
- ユーザーが「はい」と入力
- システムが「本棚は粗大ごみです。料金は800円です。」と応答


## 技術スタック

- フロントエンド: React
- データベース: SQLite
- LLM: Local LLM + Next.js
- API: OpenAI API + Laravel

こんな風にユースケースを書いておきます。例をいくつか書いておいて、こういうプロンプトを入れたらこういう形で AI が返してくれる。再びチャットを続ける、という形でユーザーインターフェースをデザイン=設計していきます。

以前ならば、このユースケースを設計書に落とし込んで、ルールベースに直して、実装してという手順になり、それを自分でやらなくてはならないのですが、AI エージェントのおかげでこのあたりの手順を AI に任せることができます。漠然としたユースケースなので自動化という訳にはいきませんが、以前よりも実装スピード=プロトタイプが出来るスピードが早いです。

  • ユースケースを示しながら、設計を AI と相談する
  • ユースケースが実験できるような、プロトタイプの画面を AI に作成して貰う
  • 設計に従って、AI にコーディングしてもらう
  • 細かい動きを AI に修正してもらう

という形で、ほぼほぼ AI がコーディングを担当していきます。私は、動作確認をしたり、プロトタイプを見て感想を言ったり、テストするポイントを AI に伝えたりという役目です。

AI コーディングには Claude Sonnet を使っていますが、こんな風なイテレーション開発の場合にはこれで十分です。

利用者のプロンプトを解釈する

ユースケースから考えると、プロンプトは利用者のブラウザで動かすことになります。

  1. 利用者が、プロンプトを開いて「ソファを捨てたい」と入力する
  2. 「ソファを捨てたい」から「ソファ」の単語を抜き出す。
  3. 「ソファ」にマッチするデータを、粗大ごみ DB から検索する
  4. 「ソファ」の金額が検索できる
  5. プロンプトが「ソファの金額は400円です」と回答する

というユースケース記述ができます。「ユースケース記述」は要件定義やシステム概要設計で使うときの UML のひとつです。

さて、自然言語で「ソファを捨てたい」と利用者が入力するわけですが、単純な単語のマッチングではなくて、AI を使って「ソファ」という単語を取り出します。つまりは、プロンプトのプレ処理に OpenAI API を使う訳です。AI の活用では、こういうように文章から重要な用語を抜き出す用途にも使えます。

が、いちいち OpenAI API を呼び出していると課金されて高いですよね(実は ChatGPT5 の値段が相当安いので、呼び出しても大したことはないのですが)。なので、ここでローカルLLM を使います。ローカルLLMを使えば課金が一切なくなるので、API の呼び出し放題です。

と、いうところまで構想をたてて、まずは laravel の webapi を作成中です。もう少しできあがったら追記します。

ローカル LLM には LM Studio あたりを使う予定。モデルは Google の gemma あたりで十分です。

カテゴリー: 開発 | 粗大ごみの金額が検索できるサイトの試作 はコメントを受け付けていません

夏休みの計画を AI(Copilot) を使って立てよう

毎年、夏休みの宿題についてちょっとだけブログアクセスが増えます。で、今回は、AI(Copilot)を使って計画を立ててみましょう。AI は何でもいいのですが、Windows 11 に付属している無料版の Copilot を使います。

課題一覧を読み込む

ここに中3の夏休みの課題一覧があります。中学3年生の時期は高校受験を控えているので、この手の夏休み課題は少なめにして欲しいのですが果たして…多いですね。塾とか部活とかいかないといけないのに。愚痴を言っても仕方がないので、とっとと計画だけでも立ててみます。

計画を立てる意義としては、

  • 全体のボリュームを把握する
  • 漠然とでもいいので、見通しを立てる
  • 途中で見通しとずれているかを確認する

ということが大きいです。なので、従来のウォーターフォール開発のような詳細な計画を立てても仕方がありません。夏休みは長い(とはいえ、今日は8/7なのですが)ので、隙間時間をつかって課題をこなしていきましょう。

最近の AI は OCR 機能がついているものが多く、この手の紙のプリントをスマホで撮って、Copilot のプロンプトに貼り付けます。

大抵の場合はいけるんですが、どうやら「数学」しか読み取れてないので、プロンプトで指定します。

国語、社会、数学、理科、英語、保健体育、技術、家庭の項目を読み取って。

最初から読み取れよ、という気持ちをぐっと抑えて。

念のため、項目をチェックしてください。AI & OCR 読み取りに失敗しているところ(理科の部分)もあるので、これを補足して箇条書きにします。

科目ごとに箇条書きにして。

ほかにも、理科の項目を付け足します。

理科は、「3章の小テスト」「イオン式のテスト」
にして。

Copilot の箇条書きが更新されます。

超概算見積もりを出す

科目ごとにいくつかの項目がでたので、全体で概算を出します。これはIT開発のプロジェクトでも同じです。要件定義の段階でいくつかの機能要件とかを出して概算見積もり(予算、期間)を出します。

夏休みの宿題の場合は納期=2学期の始まりが決まっているので、全体のスケジュール自体はあまり立てなくてもいいです。途中に塾とか部活とか遊びとか花火大会とかカラオケが挟まるわけで、その合間にアジャイル的に夏休みの課題をこなします。

科目ごとに、どれくらいの時間がかかるか見積もって。

子供の実力によってばらつきがあるとは思うのですが、ひとまず概算だけだしてもらいます。

ざっとみると、約23~34時間程度ということなので、寝ずにやれば1日ちょっとで終わりますね。いや、最悪の学生症候群を使ってもいいのですが、まあ、超概算的にはこれで十分でしょう。夏休みの宿題は事務仕事とは違うので、

  • 何かこなせば終わる宿題
  • 何か考えないと前に進まない宿題

という2種類に分けておきます。前者の場合は、夏休みのワークとか英語の宿題とかがそれにあたります。これは逆に時間を掛けないと終わらない項目なので、ちょっと計画立ててやらないと駄目です。逆に、後者の場合は何か考えないと終わりません。漠然と机の前に座っているだけではだめで、能動的に頭を働かせる必要があります。後者の場合は、国語の読書紹介文や社会の税の作文にあたります。これは、方法がわかってしまえば1時間弱で終わります(大人がやれば)、逆に何をやったらわからない場合には1日経っても終わりません。

重たいタスクをピックアップする

いわゆるチケット駆動的に夏休みの宿題をやっていきましょう。科目ごとに概ねかかる時間がわかったので、それぞれをチケットに分けていきます。

チケット駆動の場合は、チケットの仕事量をできるだけ揃えた方がいい(チケットの数だけを勘定したいため)ので、夏休みの宿題の場合は「1チケット=1時間」を目安にしていきます。

例えば、国語の読書感想文のは3-5時間かかるということになっているので、5時間の5チケットということにします。

チケット駆動を利用します。 1チケット=1時間を目安にします。

Copilot がチケット単位にわけてくれます。ここから国語のチケットを書き出してもらいます。

国語の4チケットの内容を書き出して。

国語の読書感想文を4チケットに分けて、それぞれの作業内容を書き出してくれます。これは、チケット駆動のチケットの書き出しにあたります。チケット駆動の場合は、プロジェクトが進む中でチケットを書き出すことが多いのですが、夏休みの課題の場合は、全体の総量がわかっているので、このようにチケットを分割してしまったほうが早いです。全体を分割するときに、以前までは付箋を使って計画を立てていたのですが、最近ならば AI を使えば便利です。

タスクの詳細を掘り込む

ここまでがいわゆる概要設計というやつなので、次は読書感想文という詳細設計に移ってみましょう。これを考えるのが結構面倒だったのですが、AI にテーマを決めてひな形を作ってもらいます。

読書感想文は「山椒魚」にしたいです。 紹介文のテンプレートを書いて。

紹介文のテンプレートを書いて貰います。いわゆる詳細設計ですね。ええ、なんといっても詳細設計です。プログラムの場合はコードを書かないと動かないわけですから、読書感想文は読書感想文自身を書かないと人が書かないといけません。夏休みの宿題は「AI に書かせてはいけない」ことになっているので、ええ、詳細設計にとどめます。

まあ、仕事の場合は「清書の文例」も Copilot に作って貰って、それを手直ししてもいいのですが、中3の課題なので :)

ほかに数学と英語のワークの場合は、全体のページ数を Copilot に伝えて、1チケットで何ページ進めば良いのかを尋ねます。ワークの場合は AI にやって貰うことはできないのですが、英語の訳文はスマホのカメラを使うと便利だったりしますね。げふげふん。

まとめ

読書感想文は寸止めしないと駄目!

カテゴリー: 開発 | 夏休みの計画を AI(Copilot) を使って立てよう はコメントを受け付けていません

MCP Server の試作してみる(C#版)

目的

MCP Server の試作を行い、MCP の機能を確認する。

  • 既存のデータベースを AI エージェントから使えるようにする.
  • たとえば「2025年08月02日の春日部の予約状況は?」とチャットで質問したときに、

  日付「2025年08月02日」、店舗「春日部」を見つけ出して、既存のデータベースから予約状況を取得する。

ちょっと前は RAG(Retrieval-Augmented Generation)と呼ばれていたが、今は MCP(Model Context Protocol)と呼ばれている。vscode の Chat 機能から特定の MCP Server にアクセスして、既存のシステムを参照できるようになる。

構成

  • – kkc-webapi: 既存システムを Laravel で実装した Web API
      OpenAPI 仕様が定義されていて、これを使う
  • – kkc-mcp-server: MCP Server の実装(標準入出力形式)
      Chat からの入力を受け取り、必要な情報を kkc-webapi から取得して、Chat に返す
  • – kkc-mcp-server-api: MCP Server の実装(HTTP API 形式)
      Chat からの入力を受け取り、必要な情報を kkc-webapi から取得して、Chat に返す
  • – kkc-mcp-client: MCP Server にアクセスするクライアント
      vscode の Chat 機能ではなく、独自の GUI アプリを提供する

kkc-webapi の作成

既存システムが独自 MVC と ChakPHP のハイブリッドになっているので、これを Laravel の Web API 形式に書き直します。手作業で書き直すのは大変なので、

  • ddl.md の作成
  • openapi.yaml の作成

ということで、既存データベースを CRUD できる Web API を自動生成してしまいます。このあたりはまめに php artisan make コマンドを使うことになるのですが、Claude Sonnet を使って作成してもらいます。

プロンプトでは、以下のように指示するだけで十分です。

ddl.md に従って CRUD できる openapi 仕様を作成して。
openapi.yaml に従って Laravel の Controller を作成して。

実際のところは、途中でトークンを忘れてしまうらしく、なんどか修正を指示しないとけないのですが、それでも手作業でつくるよりはるかに早いです。openapi.yaml についても、すべてを一遍に作成するのではなく、一定のカテゴリごとに分けてプロンプトで指示をするとうまくいく率が高いです。

このあたり、うまくやらば Claude Code を使って夜間バッチ的に作ることもできるのでしょうが、まあ、そこまでしなくても Claude Sonnet で十分でしょう。今回の場合はテーブル数が20弱ぐらいなのでプロンプトで指示をしていますが、100を超えるような場合は、ddl.md から openapi.yaml を作成して Laravel の Controller を作成する、ツールを作ったほうがよいでしょう。これを「治具」と呼ぶのですが、この手の使い捨てのツールは良く作ります。環境変数などはコードに埋め込みにしてしまうので他のプロジェクトで使うことはできないのでが、あえてそのような汎用性を捨ててしまって、スピードと精度を優先させます。

まあ、そんなこんなで kkc-webapi を作成しておきます。

ModelContextProtocol の活用

MCP Server の実装にあたっては、ModelContextProtocol を活用します。これは、Chat からの入力を受け取り、必要な情報を kkc-webapi から取得して、Chat に返すためのプロトコルです。以前は、JSON 形式のリクエストを作ってちまちまやっていたのですが、ModelContextProtocol を使うことで、より効率的にデータを取得できるようになります。現在はプレビュー版 0.3.0-preview.3 なのが難点ではあるのですが。

現状で MCP Server は標準入出力型と HTTP API 型の2つの実装があります。

  • 標準入出力型: コマンドラインから実行し、標準入力と標準出力を使ってやり取りする。
  • HTTP API 型: HTTP リクエストを受け取り、レスポンスを返す形式。

ということになっていて、ModelContextProtocol を使った標準入出力型の型の例があります。

Build a Model Context Protocol (MCP) server in C# – .NET Blog
https://devblogs.microsoft.com/dotnet/build-a-model-context-protocol-mcp-server-in-csharp/

ただし、この標準入出力型には難点があって、ストリーム形式なのでローカルな MCP Server を建てる必要が(たぶん)あります。レスポンス&リクエストが一連のストリームになっているので、エラーを返すことができません。適当にログを出力する必要がでてきます。お手軽ではあるのですが、あまり実用的ではないかもしれません。

最初に標準入出力型を実装してみたのですが、あまりうまく拡張できないので HTTP API 型の kkc-mcp-server-api で実装しなおしています。

実は実装自体はあまり手間ではなくて、

dotnet new console

でプロジェクトを作成した後に、以下のプロンプトで作成して貰っています。

内部で kkc-webapi を呼び出す MCP Server を作成して。

kkc-mcp-server は、vscode のチャットと OpenAPI の kkc-webapi を繋ぐ役目になっています。

動作状況

作成したコードはさておき、vscode の Chat からどういう風に呼び出されるのかを見ておきましょう。

なぜか、powershell のコマンドを実行しようとするのですが、これが HTTP API 型のためなのかよくわかりません。

ひとまず、自前の MCP Server を呼び出すことはできているようです。

実際に利用するときは、vscode の Chat 機能ではなくて、適当なブラウザアプリかデスクトップアプリを作ることになるので、このあたりはもうちょっと作り込みが必要そうです。

コマンドパレットから、MCP Server を指定

コマンドパレットで「MCP サーバーの追加」を見つけ出して、「コマンド(stdio)」か「HTTP」を選択します。

標準入出力型の stdio のほうは、デバッグがしづらいので、ある程度 HTTP API 型のようで調節をしていから、stdio のほうに戻したほうがよいかもしれません。

ちなみに、私の現状の環境では、まだ HTTP API 型の MCP Server から kkc-webapi のほうにパラメーターが正確にわたっていなくて、完成していません。これは近いうちに。

MCP Server の呼び出し

$request = @{
    jsonrpc = "2.0"
    id = "1"
    method = "tools/call"
    params = @{
        name = "search_reservations_by_name"
        arguments = @{
            Store = "春日部"
            ReservationDate = "2025-08-02"
        }
    }
} | ConvertTo-Json -Depth 10

$response = Invoke-RestMethod -Uri "http://localhost:5000" -Method Post -Body $request -ContentType "application/json"
Write-Host "=== 2025年08月02日 春日部店の予約状況 ===" -ForegroundColor Green
$response | ConvertTo-Json -Depth 10

Chat が MCP Server を呼び出すときのコマンドを見ると解るのですが、jsonrpc 形式でリクエストを送信しています。vscode の Chat 部分で「2025年08月02日の「春日部」の予約状況を示して。」と入力すると、うまく JSON RPC 形式のリクエストに変換してくれることがわかります。このために、ユーザーは search_reservations_by_name などの関数名を意識することなく、自然言語を使って指示を与えるあるいは質問をすることができるのです。

このあたりで Chat 形式のユーザ―インターフェースが変わってくるという話です。

まあ、もっとも、予約のチェックのように画面で何かを選択するほうが早い場合もあるので、このチャット形式も善しあしでああるのですが。JR の忘れ物の問い合わせのような検索システムの場合は、入力する項目をうまく誘導するためにこの手のチャット形式が有効でしょう。

kkc-mcp-server-api

Program.cs

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;
        }
    }
}
カテゴリー: 開発 | MCP Server の試作してみる(C#版) はコメントを受け付けていません

Claude Sonnet で Android アプリに Compose UI のページを追加する。

下準備として、Github Pro の課金が必要です。月10ドルなので、本家 Claude Code よりも安め(月20ドルだったか?)でいけます。

GitHub のプラン – GitHub Docs https://docs.github.com/ja/get-started/learning-about-github/githubs-plans

Android Studio から直接 Claude Sonnet は使えない(と思う)ので、vscode 上でコードの変更を行います。Github Pro を入れておくと、Copilot のアイコンが使えるので、いつものチャット(Ask)からエージェント(Agent)に切り替えます。

  • Ask の場合は、チャット上でコードの提案をしてくれます
  • Agent の場合は、要望や質問を Copilot 自身がファイルを修正してくれます

Agent のほうがファイルを直接操作してくれるので手間は少ないです。が、あらぬところを変えることがあるので、そういうときは Ask モードに切り替えるか、git でブランチを作っておいて元のファイルに戻します。元のファイルに戻したときは

元のファイルに戻しました

と Copilot に知らせてあげると話がスムーズになります。Claude Sonnet があれこれと調べ始めるので。

コード生成のスピードは直接 Claude Code に課金したほうが早いのですが、Claude Code だと結構なお金が掛かる(Maxだと特に)のと、API を使うときの制限が厳しいです。仕事的に 2,3 時間使ってしまうと制限に達してしまいます。なぜかわかりませんが、Copilot + Claude Sonnet の組み合わせだと、この API 呼び出しが無制限になっています。なので、あまり生成スピードを気にせずにだらだらやる場合は Sonnet のほうがお得です。多分、Claude Code のほうが色々と便利なコマンドがあるんでしょうが、いまのところ私は Claude Sonnet で十分な感じです。

新しい Compose ページを追加する

Android の Jetpack Compose は、React の JSX 形式のようにコード内で UI を記述することができます。まあ、機能的にはそれだけではないのですが、この「コードに記述する」というスタイルが、AI エージェントと相性がいいです。

従来の Android UI の場合は Android Stdio を立ち上げてデザイナを使って画面を構成することが多く、その設定は *.xml ファイルに記述されます。直接 *.xml ファイルを編集することもあるのですが、結構面倒で、さらに言えば AI エージェントのこれをやって貰うと大変なことになります。どういう大変なことになるかというと、それは各自に試してみてください。端的に言えば、完成しません、ってだけなんですが。

バージョンを示すページを作ってみましょう。

バージョンを表示する AboutActivity を作成して。

いろいろ項目を入れる必要があると感じますが、いいえ、そんなことはありません。最初のテンプレートだけを作りたいので、一行だけプロンプトで指示すれば ok です。

完全に余計なお世話的な感じで作ってくれますが、これを使います。

画面を表示するに AndroidManifest.xml に追加しておきます。これは最初に指示をすると追加してくれたり、追加してくれなかったりします。

AboutActivity を AndroidManifest.xml に追加して。

設定した後に Android Studio でビルドをして確認しておきます。

Compose UI で作成したときに、プレビューが出ないのが残念なところなのですが、これは放置します。手書きで書くとプレビューが表示されるので、おそらく Claude Sonnet の生成するコードのせいです。

package jp.mamori_i.app.screen.ui

import android.content.pm.PackageManager
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Info
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import jp.mamori_i.app.ui.theme.AppTheme
import java.text.SimpleDateFormat
import java.util.*

class AboutActivity : ComponentActivity() {
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        
        enableEdgeToEdge()
        setContent {
            AppTheme {
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background
                ) {
                    AboutScreen(
                        onNavigateBack = {
                            finish()
                        },
                        context = this@AboutActivity
                    )
                }
            }
        }
    }
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AboutScreen(
    onNavigateBack: () -> Unit = {},
    context: ComponentActivity? = null
) {
    // バージョン情報を取得
    val versionInfo = remember {
        context?.let { ctx ->
            try {
                val packageInfo = ctx.packageManager.getPackageInfo(ctx.packageName, 0)
                mapOf(
                    "appName" to (ctx.applicationInfo.loadLabel(ctx.packageManager).toString()),
                    "versionName" to packageInfo.versionName,
                    "versionCode" to packageInfo.longVersionCode.toString(),
                    "packageName" to packageInfo.packageName,
                    "buildTime" to SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date(packageInfo.lastUpdateTime))
                )
            } catch (e: PackageManager.NameNotFoundException) {
                mapOf(
                    "appName" to "FolkBears",
                    "versionName" to "1.0.0",
                    "versionCode" to "1",
                    "packageName" to "jp.mamori_i.app",
                    "buildTime" to "Unknown"
                )
            }
        } ?: mapOf(
            "appName" to "FolkBears",
            "versionName" to "1.0.0",
            "versionCode" to "1",
            "packageName" to "jp.mamori_i.app",
            "buildTime" to "Unknown"
        )
    }

    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text("アプリについて") },
                navigationIcon = {
                    IconButton(onClick = onNavigateBack) {
                        Icon(
                            imageVector = Icons.AutoMirrored.Filled.ArrowBack,
                            contentDescription = "戻る"
                        )
                    }
                }
            )
        }
    ) { paddingValues ->
        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(paddingValues)
                .padding(16.dp)
                .verticalScroll(rememberScrollState()),
            verticalArrangement = Arrangement.spacedBy(16.dp),
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            Spacer(modifier = Modifier.height(32.dp))
            
            // アプリアイコン(代替として情報アイコンを使用)
            Card(
                modifier = Modifier.size(80.dp),
                colors = CardDefaults.cardColors(
                    containerColor = MaterialTheme.colorScheme.primary
                )
            ) {
                Box(
                    modifier = Modifier.fillMaxSize(),
                    contentAlignment = Alignment.Center
                ) {
                    Icon(
                        imageVector = Icons.Default.Info,
                        contentDescription = null,
                        modifier = Modifier.size(40.dp),
                        tint = MaterialTheme.colorScheme.onPrimary
                    )
                }
            }
            
            Spacer(modifier = Modifier.height(16.dp))
            
            // アプリ名
            Text(
                text = versionInfo["appName"] ?: "FolkBears",
                style = MaterialTheme.typography.headlineMedium,
                fontWeight = FontWeight.Bold,
                textAlign = TextAlign.Center
            )
            
            // バージョン情報
            Text(
                text = "バージョン ${versionInfo["versionName"]} (${versionInfo["versionCode"]})",
                style = MaterialTheme.typography.titleMedium,
                color = MaterialTheme.colorScheme.onSurfaceVariant,
                textAlign = TextAlign.Center
            )
            
            Spacer(modifier = Modifier.height(24.dp))
            
            // 詳細情報カード
            Card(
                modifier = Modifier.fillMaxWidth(),
                elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
            ) {
                Column(
                    modifier = Modifier.padding(16.dp),
                    verticalArrangement = Arrangement.spacedBy(12.dp)
                ) {
                    Text(
                        text = "アプリケーション情報",
                        style = MaterialTheme.typography.titleMedium,
                        fontWeight = FontWeight.Bold,
                        color = MaterialTheme.colorScheme.primary
                    )
                    
                    InfoRow("アプリ名", versionInfo["appName"] ?: "Unknown")
                    InfoRow("バージョン", versionInfo["versionName"] ?: "Unknown")
                    InfoRow("ビルド番号", versionInfo["versionCode"] ?: "Unknown")
                    InfoRow("パッケージ名", versionInfo["packageName"] ?: "Unknown")
                    InfoRow("ビルド日時", versionInfo["buildTime"] ?: "Unknown")
                }
            }
            
            // システム情報カード
            Card(
                modifier = Modifier.fillMaxWidth(),
                elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
            ) {
                Column(
                    modifier = Modifier.padding(16.dp),
                    verticalArrangement = Arrangement.spacedBy(12.dp)
                ) {
                    Text(
                        text = "システム情報",
                        style = MaterialTheme.typography.titleMedium,
                        fontWeight = FontWeight.Bold,
                        color = MaterialTheme.colorScheme.primary
                    )
                    
                    InfoRow("Android バージョン", android.os.Build.VERSION.RELEASE)
                    InfoRow("API レベル", android.os.Build.VERSION.SDK_INT.toString())
                    InfoRow("デバイス", "${android.os.Build.MANUFACTURER} ${android.os.Build.MODEL}")
                    InfoRow("CPU アーキテクチャ", System.getProperty("os.arch") ?: "Unknown")
                }
            }
            
            // 著作権情報カード
            Card(
                modifier = Modifier.fillMaxWidth(),
                elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
            ) {
                Column(
                    modifier = Modifier.padding(16.dp),
                    verticalArrangement = Arrangement.spacedBy(12.dp)
                ) {
                    Text(
                        text = "著作権情報",
                        style = MaterialTheme.typography.titleMedium,
                        fontWeight = FontWeight.Bold,
                        color = MaterialTheme.colorScheme.primary
                    )
                    
                    Text(
                        text = "© 2024 FolkBears Group\nAll rights reserved.",
                        style = MaterialTheme.typography.bodyMedium,
                        textAlign = TextAlign.Center,
                        modifier = Modifier.fillMaxWidth()
                    )
                    
                    Text(
                        text = "このアプリケーションは接触確認システムとして開発されました。",
                        style = MaterialTheme.typography.bodySmall,
                        color = MaterialTheme.colorScheme.onSurfaceVariant,
                        textAlign = TextAlign.Center,
                        modifier = Modifier.fillMaxWidth()
                    )
                }
            }
            
            Spacer(modifier = Modifier.height(32.dp))
        }
    }
}

@Composable
fun InfoRow(label: String, value: String) {
    Row(
        modifier = Modifier.fillMaxWidth(),
        horizontalArrangement = Arrangement.SpaceBetween
    ) {
        Text(
            text = "$label:",
            style = MaterialTheme.typography.bodyMedium,
            fontWeight = FontWeight.Medium,
            modifier = Modifier.weight(1f)
        )
        Text(
            text = value,
            style = MaterialTheme.typography.bodyMedium,
            fontFamily = FontFamily.Monospace,
            color = MaterialTheme.colorScheme.primary,
            modifier = Modifier.weight(1f),
            textAlign = TextAlign.End
        )
    }
}

@Preview(showBackground = true)
@Composable
fun AboutScreenPreview() {
    AppTheme {
        AboutScreen()
    }
}

まあ、それでも AI エージェントを使ってバイブコード(Vibe Coding)する分には十分な品質を保っています。About ページならばセキュリティがどうという話もないし特殊なロジックを使っているわけではありません。単にアプリの製品情報とかを並べたいだけです。

従来の方式で言えば Android Studio や Visual Studio が出してくれるテンプレートを使えばいいのですが、 そこすら面倒くさいし、こうやって AI エージェントが作りやすいように画面設計や変数設定をしてしまったほうが後々楽でしょう。うまくいかなければ、もう一度いちから AI エージェントに作って貰えばよいです。

エミュレータで実行するとこんな感じです。

既存の Activity を編集してもらう

About 画面ができたので、いくつか編集をしてみましょう。

UI を作成している AboutScreen の中身を手作業で変更してもよいのですが、AI エージェントを使っても追加や削除ができます。手作業だとレイアウトが崩れたりするので、情報を与えて AI エージェントに修正して貰ったほうが作業が早く済みます。複雑なロジックの場合は面倒なのですが、ちょっとした文言を追加するのはプロンプトだけで結構いけます。

バージョン情報の前に

- アプリの目的:これは接触確認アプリを更に進化させて、実験的に接触記録をトレースできるものです。
- アプリの外報 : 複数の端末で GATT/iBeacon 方式で送受信することにより、相互の距離を記録していきます。接触回数や時刻などを研究データとして役立てるために、指定のサーバーに送信します。

の文言を追加して。

コードをざっと眺めて、うまくいかないようであれば git で元に戻せば ok です。あるいは、コードの変更がしめされたときに「取り消し」します。

このあたりの修正サイクルを Claude Code の設計書(claude.md)に最初に練り込んでいくのか、それとも後からプロンプトを使って修正していくのかと議論の分かれるところでしょうが、私としては後者である「ペアプロ」方式のほうが絶対に楽です。最初の設計書の場合は、まあ、環境づくりとかひな形の作成に使うとよいでしょう。夜間バッチ的に動かせば人手がいれなくなるので結構いいと思います。が、その場合は、Claude Code にバッチファイルを作って貰ったほうがいいんじゃないでしょうか…とは思いますが、開発プロジェクトのスタイルによってそれぞれ。要は、開発者(=自分)が疲弊しない方法を選んでください。あと、やってみて楽しいほうがいいです。

カテゴリー: 開発 | Claude Sonnet で Android アプリに Compose UI のページを追加する。 はコメントを受け付けていません

Claude Sonnet で簡単な Android/iOS アプリを作る

AI エージェントである Claude Code を本格的な業務システムに使うのもいいのですが、治具的なツールを作るのも便利です、という話。

モバイルアプリの場合 React Native Expo を使うことが多いでしょう。コードが TypeScript で書けるし、アップロード等の手間も必要ありません。Web API を呼び出す程度の社内ツールならばこれで十分です。審査が面倒なので、社内配布や自分だけが使うのでれば、これで十分です。

Expo の場合、Android/iOS 共通のコードを使えるので2つの環境を別々に作らなくてよいというメリットが大きいです。一方で、スマホネイティブのコードを弄るときにはちょっと手間がかかります。手としては、それぞれのライブラリを作っておいて、Expo や Flutter から呼び出せ場イイのですが、少し複雑なことをやろうとすると、やっぱりネイティブ環境での動作確認が必要になります。そこで、初手の実験コードとしては、Kotlin, Swift で書かざるを得ません。

ネイティブコードとして Kotlin/Swift で書くときに結構面倒になるのが UI です。Android の場合は、*.xml を Android Studio で開いてデザインしないといけないし、Swift の場合は古来の *.storyboard を編集しないといけません。それぞれコツがあって結構面倒。なので、最近は Compose UI か Swift UI が流行りだし、そっちのほうがテストツールのような標準的な画面は作りやすいのです。

Compose UI / Swift UI を使う

AI エージェントツールである Claude Sonnet がどの程度まで UI を書けるかという、この程度までいけます。

最近は、仕様書駆動(spec駆動)が流行っているし、それらの指示を Claude Sonnet も読み取ってくれるので、プロンプトに直接書くのではなく、ちょっとした readme.md を書いて AI エージェントに渡します。

readme.md

# Folkbers.mini の作成

GATT/iBeacon の接続テストを行う

## iBeacon サービス
- BeaconScan : iBeacon のスキャン
- BeaconTransmitter : iBeacon の発信
- BeaconTraceService : iBeacon の発信とスキャンを制御する

## GATT サービス
- GattAdvertise : GATT アドバタイズ
- GattClient : GATT クライアント
- GattServer : GATT サーバー
- GattTraceService : GATT サービスの発信とスキャンを制御する

## ContentView.swift

- Trace の開始と停止ボタン
- GATT/iBeacon の切り替えスイッチ

この位の readme.md を書いておいて、プロンプトで

readme.md に従って画面作成して。

と指示するだけで ok です。iOS の場合は BLE 通信の中身まで作ってくれました。まあ、この中身は標準的なものでしかないので、私の場合は修正が必須なのですが。

Android の設定画面っぽいものも、readme.md とか別の *.md ファイルに書いておいてプロンプトで渡せば ok です。

治具ツールを作るときは、いちから Claude Sonnet に任せるのは難しいので、新規プロジェクトだけ Android Studio や Xcode で作成しておきます。

/save コマンド

プロンプトで /save と打つと今までのプロンプトと AI エージェントの回答が表示されます。

---
tools: ['listDirectory', 'readFile', 'createFile', 'replaceString', 'runInTerminal', 'textSearch', 'fileSearch']
---

readme.md を参考にして

- BeaconScan : iBeacon のスキャン
- BeaconTransmitter : iBeacon の発信
- BeaconTraceService : iBeacon の発信とスキャンを制御する

を作成して。

> Copilot: readme.mdを参考にして、iBeacon関連のサービスクラスを作成いたします。まず、現在のプロジェクト構造を確認してから、必要なファイルを作成していきます。 それでは、iBeacon関連のサービスクラスを作成します。まず、必要なインポートとCore Locationフレームワークを使用して、以下の3つのクラスを作成します:
>
> 1. **BeaconScan** - iBeaconのスキャン機能
> 2. **BeaconTransmitter** - iBeaconの発信機能
> 3. **BeaconTraceService** - 発信とスキャンを制御するサービス 次に、これらのサービスを使用するためにContentView.swiftを更新します:
> ```
>
> ```
> 最後に、プロジェクトでCore LocationとCore Bluetoothフレームワークを使用するため、Info.plistにプライバシー設定を追加する必要があります。Info.plistファイルを作成します: ## 作成完了
>
> readme.mdの仕様に基づいて、以下の3つのiBeacon関連サービスクラスを作成しました:
>
> ### 📡 BeaconScan.swift
> - **機能**: iBeaconのスキャン機能を提供
> - **主要メソッド**:
> - `startScanning()` - スキャン開始
> - `stopScanning()` - スキャン停止
> - **プロパティ**: 検出されたビーコン一覧、スキャン状態
>
> ### 📤 BeaconTransmitter.swift
> - **機能**: iBeaconの発信機能を提供
> - **主要メソッド**:
> - `startTransmitting()` - 発信開始
> - `stopTransmitting()` - 発信停止
> - `updateBeaconParameters()` - MajorとMinor値の更新
> - **プロパティ**: 発信状態、Bluetooth状態
>
> ### 🎛️ BeaconTraceService.swift
> - **機能**: 発信とスキャンを統合制御するサービス
> - **動作モード**:
> - スキャンのみ
> - 発信のみ
> - スキャン&発信の同時実行
> - **主要メソッド**:
> - `startTrace()` - トレース開始
> - `stopTrace()` - トレース停止
> - `changeTraceMode()` - モード切り替え
>
> ### 🖥️ ContentView.swift
> readme.mdの要件に従って以下の機能を実装:
> - ✅ Traceの開始と停止ボタン
> - ✅ iBeaconのモード切り替え(スキャン/発信/両方)
> - ✅ リアルタイムステータス表示
> - ✅ 検出されたビーコンの一覧表示
>
> ### 📋 Info.plist
> Core LocationとCore Bluetooth使用のためのプライバシー権限設定を追加しました。
>
> これで、iBeaconのスキャンと発信機能を持つアプリケーションが完成しました。アプリを実行すると、モードを選択してiBeaconの動作をテストできます。

readme.md を参照して、

- GattAdvertise : GATT アドバタイズ
- GattClient : GATT クライアント
- GattServer : GATT サーバー
- GattTraceService : GATT サービスの発信とスキャンを制御する

を作って。


> Copilot: readme.mdを参考にして、GATT関連のサービスクラスを作成いたします。GATT(Generic Attribute Profile)のアドバタイズ、クライアント、サーバー、そしてそれらを制御するサービスを作成します。 次に、ContentView.swiftを更新して、GATTとiBeaconの両方の機能を切り替えできるようにします:
> ```
>
> ```
> ## 作成完了 ✅
>
> readme.mdの仕様に基づいて、GATT関連の4つのサービスクラスを作成し、ContentView.swiftも更新しました:
>
> ### 📡 GattAdvertise.swift
> - **機能**: GATTアドバタイズ機能を提供
> - **主要メソッド**:
> - `startAdvertising()` - アドバタイズ開始
> - `stopAdvertising()` - アドバタイズ停止
> - `updateCharacteristicValue()` - キャラクタリスティック値の更新
> - **特徴**: カスタムサービスとキャラクタリスティックを設定し、クライアントからの読み書き要求に対応
>
...

カテゴリー: 開発 | Claude Sonnet で簡単な Android/iOS アプリを作る はコメントを受け付けていません

要件定義と概要設計を作成して、3マッチゲームを作る

3度目の正直ということで、要件定義(requiremnet.md)と概要設計(desing.md)を作成して、Claude Sonnet で3マッチゲームを作ってみます。

完全にオートメーションという訳にはいかないでしょうが、出だしぐらいはいけるんじゃないでしょうか? Claude Code のように git へのコミットとかはできませんが、この手のプログラムを AI エージェントに一括に任せることは少なく(アイデアを間に差し込むので)、初期のプロトタイプの底上げが目的です。これまでの2回は、自動生成させたコードがバグバグで到底直せない感じになってしまったので、ちょっと慎重に概要設計を書いています。

要件定義 requiremnet.md

# 要件定義

マッチ3ゲームの要件定義を以下に示す

## ゲームの目的

- 同じ色のジェルを3つ以上揃えて消す
- ブロックを消しときに、爆弾、中爆弾などのアイテムが出現する。アイテムを消すと、ジェルの消え方が様々に変わる
- ジェルを動かしたり、アイテムをクリックすることで、残り動作のカウントが減る。ゲームスタート時のカウントが0になるとゲームオーバーになる。
- すべてのブロックの裏にパネルが貼り付けてあり、すべてのパネルを消すことがゲームの目的となる

## ゲームのルール

- プレイヤーは隣接するブロックを入れ替えて、3つ以上の同じ色のブロックを揃える
- ブロックが消えると、上のブロックが下に落ちてくる
- 新しいブロックが上から追加される
- 消せない岩ブロックがある
- パネルをすべてのブロックの裏に配置させて、すべてのパネルを消すようにジェルや爆弾を操作する

## ユーザーインターフェース

- ゲームボードは8x8のグリッド
- 各ブロックは異なる色と形を持つ
- スコア、残り動作のカウント、レベルを表示するUI要素

## 機能要件

- Webブラウザ上で動作する
- モバイルデバイスに対応したレスポンシブデザイン
- 高速な描画とアニメーションでユーザー体験を向上させる。SVG形式を採用すること。
- 爆弾が消えるときには派手なアニメーションをつける


## 非機能要件

- ゲームのパフォーマンスは、低スペックのデバイスでも快適に動作すること
- アクセシビリティを考慮し、キーボード操作ができること
- ジェルが消えるロジックは関数化されて、テスト可能となっていること
- ジェルの色は3色から5色まで変更可能とすること

概要設計 desing.md

# 概要設計

このドキュメントは、システムの概要設計を記述します。以下のセクションでは、システムのアーキテクチャ、主要なコンポーネント、および相互作用について説明します。

## システムアーキテクチャ

- マッチ3ゲームは、ブラウザ上で動作する
- 画面をカラフルにするための SVG 形式を使用する
- ゲームのロジックは TypeScript で実装される
- ゲームのロジックは、単体テストが可能にする。ロジックは UI の ファイルを別にする

## 主要コンポーネント

### ゲームボード

- 8x8のグリッドで構成される
- 各セルには異なる色と形のブロックが配置される
- ブロックは、ジェルと呼ばれる

### ユーザーインターフェース

- スコア、残り動作のカウント、レベル、パネルの数を表示するためのUI要素が含まれる
- ゲームの開始、リセット、終了などの操作を行うためのボタンが含まれる
- ゲームの進行状況を表示するためのアニメーションが含まれる

### ゲームロジック

- ブロックの入れ替え、消去、スコア計算などのゲームロジックを処理する
- ジェルの消去ロジックは関数化され、テスト可能な形で実装される
- 爆弾や中爆弾の動作を制御するロジックが含まれる
- ゲームの進行状況を管理し、ゲームオーバーやレベルアップの判定を行う

### アニメーション

- ブロックの消去や爆弾の動作に対するアニメーションを制御する
- ジェルが上から落下するときになめらかになるようにアニメーションする
- ジェルが消えるときのアニメーションは派手にする
- ゲームの進行に合わせて、アニメーションがスムーズに動作するように最適化される
- アニメーションは、SVG形式で実装される

要件定義と概要設計の違い

ひとりプロジェクトであったり、顧客=開発者であればとくに要件定義と概要設計を分離しなくてもよいのですが、実際の仕事としては発注側=顧客が要件定義を行い、受注側=IT屋が概要設計を書きます。実際のところは発注側はITに詳しくないのでコンサルタントや場合によっては受注側が要件定義を代筆することが多いのですが、これらの二つは契約と言う点で発注/受注という大きな違いがあります。

簡単にいえば、要件定義が目的であり、概要設計が手段です。目的を達成するためには手段を変えても構いません。しかし目的を変える場合には顧客の合意が必要です。そういう意味では、

  • AI エージェントは要件定義の内容を変更できない。
  • AI エージェントは、場合によっては概要設計の内容を変更する提案ができる。

という違いがあります。

実は、AI エージェントに対して要件定義や概要設計のレビューを求めることができます。このレビューは要件定義内にある矛盾を叩き出すのに有効です。同時に設計の齟齬も発見してくれます。逆に顧客=自分としてはこの要件定義は外せないと思うならば、要件定義内に「これは契約上重要である」ことを強調すればよいでしょう。そのあたりの整合性を AI が判断しているかどうかは判別できないのですが。

Claude Sonnet の進捗状況

なんとなくできたようです。

ロジックができていて、テストコードをも一応作られています。

まあ、動きは変なんですけどね。。。

どうも落下アニメーションがブラウザ上でうまくいかないのは定番っぽいです。

Expoの開発サーバーが既に起動されているので、ブラウザまたは Expo Go アプリでゲームをプレイできます。ゲームは要件定義と設計仕様に忠実に実装されており、マッチ3ゲームの基本的な遊び方から爆弾システムまで完全に機能します。

と豪語していますが、到底完璧とは言えません。が、以前よりも随分マシになりました。

npm test 

テストコードを動かすと失敗しますがw

ところどこ動かないのは仕方がないのところなので、このあたりは人間が手を入れるかというところでしょう。いちおう、何回かプロンプトで指定をすると、直りました。えらい!

開発はスピードアップするのか?

私の場合、問題なくスピードアップしています。と言いますか、スピードアップするところだけ使っています。私自身の使い方としては、

  • React Native のような自分の未知なプロジェクトの雛形を作成する
  • Kotlin/Swift の最新版のコードをコメント込みで入れ込む
  • Kotlin/Swift からデッドコードとなったクラスを指定して削除する
  • 組み込みC言語のコンパイルエラーから、コードを修正する目途をつける
  • Laravel の不明な実行エラーから、コードを修正する目途を付ける

とにかく最初のひな型の作成と不明なエラーメッセージを代わりに読んでくれるのは圧倒的に楽です。変なロジックを踏んで、どうにもならなくなったら早々に AI エージェントの動作を取りやめて、方針を変換します。

  • 特定の関数やクラスを切り出すように指示する

おおむね、ペアプロのナビゲータ役か、結合テストのテスター役に徹するとうまくいきます。コードを書くときは、変数名などを AI エージェントが書いているほうに合わせます。このほうが、AI にとって続きが書きやすく、変数名の揺れが少なくなります。

要件定義や概要設計を AI が読んで返して来た用語をそのまま使うようにします。ラショナル統一プロセスの「用語集」やドメイン駆動設計の「ユビキタス言語」を意識するとよいでしょう。どうも、下手に人間側の用語を持ち出すと AI が誤解をし始めるので、いまのところは人間が合わせたほうが無難です。用語については、別途 *.md に書き出してもよいかもしれません(トークンを消費しそうですが)。

テストを繰り返す構造

爆弾セルの動きがおかしかったので指摘をすると、テストコードを追加してテストしてくれます。

ゲームロジックと UI の表示をどうやってテストするのかが難しいところですが、AI エージェントのテストも私のテストも同じことをやってくれます。UI のテストは結構手間なのですが、今回の3マッチゲームの場合はボードに配置する(2次元配列に配置する)ことになるので、爆弾やジェルのマッチの前後を配列そのもので表すことができます。これをちまちま手作業で書いてテストの成功例を書いていきます。実に面倒臭い作業なのですが、これが後々効いてきます。そのあたりの単純作業を AI エージェントが肩代わりしてくれるのでかなり助かります。

落下アニメーションは、ジェルが消えた場所だけにして。
全体のジェルが落ちてくるアニメーションになっています。

なんというか、お茶目な感じにバグを頻発させますが、まあ以前よりはましな気がします。

続く…

カテゴリー: 開発 | 要件定義と概要設計を作成して、3マッチゲームを作る はコメントを受け付けていません

Claude Sonnet で 3マッチゲームが作れるのか?

Amazon の Kiro で spec 駆動(仕様駆動)が流行りですが、AI エージェントを使うと Kiro とかじゃなくても結構いけます、というお話です…と同時に、それ以上いけません、というのも記録しておきます。

手元で試している 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 を使う方がいいです。

React Native Expo で3マッチゲームを作る

3マッチゲームは結構古くからあって、ルール自体は隣のブロックを入れ替えて3つに並んだら消えるという簡単なものです。samegame もそうですよね。ルールが簡単なので、プログラムで作るのも比較的簡単なのですが、最初の頃からバリエーションが増えてきて、最近ではいろいろなブロックのアイテムがあります。

Fishdom
https://play.google.com/store/apps/details?id=com.playrix.fishdomdd.gplay&hl=ja

単純に上から下というわけでもなく、課金うんぬんはさておき、ディズニーキャラクター版とかもあったります。単にぽちぽち消すのではなく、ぷよぷよみたいに連鎖しないと意味がありません。

というわけで expo でプロジェクトを作ります

npx create-expo-app swap-game-expo0 --template expo-template-blank-typescript

実は Kiro 風に readme.md を作って、プロジェクトを作成するところからスタートしても構いません。が、プロジェクト構造を最初に決めておいて、どんなフレームワークを使うのかを AI エージェントに伝えたほうが完成の角度が高いです。

Expo プロジェクトができたら、プロンプトに入力します。

3マッチゲームを作って。

設計上いろいろ決めてもいいのですが、ひな形を先に作って貰った方が早いです。

おそらく、この手のゲームのテンプレートがモデル内にある感じがしています。Transformer だけではちょっと無理かなと思うのですが、ここでは問いません。多分、どの AI エージェントを使っても3マッチゲームとかオセロとか定番なものは揃っているでしょう。

スマホで動かす前に PC の web ブラウザ上で動かしてみましょう。

npm run web

で起動できるのですが、web のライブラリが入っていないのでこれを入れます。

npx expo install react-dom react-native-web @expo/metro-runtime

この手の問題は「web ブラウザでも動くように」とプロンプトで指示するといけるような気もするのですが、ペアプロ的に作りながら指示したほうが楽です。

ちなみにプロンプトで「3マッチゲームを作って」と指示をしても同じコードが生成されるとは限りません。AI エージェントは生成 AI と同じように内部ではランダムな回答を出すようになっている(ことが多い)ので、生成されるコードもある程度ランダムになります。つまり、再現性が低いのです。

spec 駆動の場合は、これを要件/設計(claude.md)で詰めることになっていますが、夜間バッチで動かすためのものなので、正確に狙った形の出力を得るのは難しいでしょうね。一案としては、設計を書いたあとに、5~10個ぐらい同時に動かしてコード生成をした後に、よいものをピックアップするよいでしょう。いわゆる A/B テストか、遺伝子プログラミングのように目標を絞っている形になるのですが、まあ、課金は結構かかりそうです。そのあたりは、タイムパフォーマンスをどのあたりで手を打つかというところでしょう。

私の場合は、ペアプロ風に readme.md を書いていくほうが開発スタイルに合っています。

結論から言えば「3マッチゲームを作って」というプロンプトだけで、ここまでいけます。ブロックをマウスで選択して交換する、ブロックが消える、というルールは満たしてあります。UI としてはしょぼいですが、最初のプロトタイプとしてはこれで十分ですよね。去年あたりに所謂「AI 驚き屋」さんがやっていたのもこんな感じです。ブロック崩しとかオセロとかちょっとしゅたシューティングゲームとかの定番は、モデル自身が持っているテンプレート(たぶん)から引っ張ってくるだけで ok です。これは TypeScript で書かれていますが、他の言語に直すこともできます。こういう使い方は便利ですね。

ジェルを SVG で描画する

遊べるの少し動かしてみるのですが、かなりしょぼいです。

小学生の夏休みの宿題ならばなんとかななりそうだけど、仕事?では無理そうです。コードを見ると単純なHTMLを使っているだけなので、消えるときのアニメーションとから落下のアニメーションがありません。このあたり「落下のアニメーションを付けて」とか試行錯誤することもできるのですが、そもそも SVG 形式で描画しないとカラフルにはなりませんよね。

SVG を使ってジェルなどをカラフルにして。

プロンプトで「SVG」を指定するのですから、spec駆動の場合には設計時に「SVG形式を使うどうか」というのが問題になりますよね。そのあたりspec駆動の場合は狙うことができるのかは不明です。ただし、SwiftUI とか Compose UI とかの標準的なコンポーネントだけ使う場合はうまくいくかもしれません。そのあたり、デザイナー視点をどう含めるかが問題ですが。私の場合は、ロジックを先に作って UI で装飾、という手順のほうがよいと思っています。

SVG 形式を使ったのでかなりカラフルになっています。「カラフル」という用語を使ったのですが、これもモデルに内で最適化されたものでしょう。これあこれで遊べるのですが、落下アニメーションとか爆弾のアイテムとかがないので、かなりしょぼいです。

落下アニメーションを付ける

落下アニメーションを付けて。
ジェルが3つ消えるときに爆弾アイテムを出現させて。

SVG のときもそうでしたが、AI エージェントは App.tsx をがんがん書き変えます。これが人の開発者であれば、構造化したり共通化したりするところですが AI の場合は検索できるパターンがあれば機械的に書き変えるの手間がかからないので、全て書き変えてしまう勢いです。

将来的に AI エージェントで出力させたコードの Git 履歴とかが難しいでしょうね。ある程度コード量が多くなってくると、トークン数の制限のためにコードを部分的に読み出すように最適化されてきます。このときに、設計スタイルとしてオブジェクト指向の各種パターンを使うかどうかは定かではないのですが、試した感じでは「1回のトークン数を超えていくところからバグを大量発生」させていきます。おそらく全体の整合性がとれなくなってしまうのでしょう。人の開発者ならば、構造化して眺めるコード量を抑えるのですが、現在の AI エージェントではその視点がありません(上位バージョンの Opus 4 にはあるかもしれません)。なので、AI エージェントが読み込みやすいようにある程度コードを構造化するようにプロンプトで誘導するのがベターかもしれません。

落下アニメーションが、ジェルが消える場所を埋めるように落下させて。
爆弾をクリックしたら、上下左右の4個のジェルを消して。爆発アニメーションを付けて。

あと、ブラウザでアニメーションがうまく表示されていないので、書き変えて貰います。

落下アニメーションがブラウザででていません。

このあたり、一発で思った通りのものが出力されません。それどころか、何かの指示を出すたびにどこかのバグを含めてしまいます。それをいちいち UI を動かして動作を確認していきます。このあたりは、ちょうどテスターの役割を人間が担います。

ゲームを動かして、ちょっとずつ完成させていくイメージですね。自分でコーディングしたほうが早いのではないか?って場合もあるでしょうが。実際、ある程度の複雑なコードの場合は人間がやったほうがよさそうです。ただし、AI エージェントのコードは可読性がよくないので、人が読むのは一苦労です。可読性が良くなるようにリファクタリングした貰うといいかもしれません。

落下するときのアニメーションをなめらかにしたり、爆弾アイテムをつけたりした状態がこの状態になります。動きがおかしいですよね。一見すると複雑な条件で設計ができそうなのですが、3マッチゲームはなかなか完成しません。

おそらく、条件の組み合わせ爆発のときに駄目っぽい感じがします。業務アプリケーションのように管理画面で CRUD 機能をつけるとかカテゴリから商品をひらくとかいうステートがあまり変化しないものは大丈夫なのですが、3マッチゲームのようにまともにやると遷移表が爆発しそうなものは AI エージェント頼りでは難しそうです。

テスト可能なように構造化する

まあ、ゲームロジックの場合は、いろいろな条件が重なるので View と Logic を分離させておくのがよいでしょう。

ジェルの消去や爆弾の出現条件をテストできるようにコードを構造化して。

果たして、テスト可能なように構造化してくれるでしょうか?

デバッグ画面っぽいものは出るのですが、なんか違うんですよね…これは、Claude の中の人が悪いのか、Sonnet が悪いのか。

続きは後日。

ちなみに、現在のルールとかは次のプロンプトを使うとよいです。

設計書を design.md に書き出して。

ファイル名とか文言とかは変更しても大丈夫です。まあ、致命的なのは、この design.md を最初に渡して3マッチゲームが完成されないことなんですよね。これも再現性がないので、何回か繰り返すと猿がシェイクスピアを生み出す確率でうまく出力されるかもしれないですが。

# 3マッチゲーム 設計書

## 概要
React Native/Expoを使用したモバイル向け3マッチパズルゲーム。SVGベースのカラフルなジェルと爆弾アイテム、落下アニメーション、爆発エフェクトを特徴とする。

## 技術仕様

### フレームワーク・ライブラリ
- **React Native**: 18.x
- **Expo**: SDK対応
- **react-native-svg**: SVGグラフィックス
- **TypeScript**: 型安全性

### 対応プラットフォーム
- iOS
- Android
- Web (Expo Web)

## ゲーム仕様

### 基本ルール
1. 8x8のグリッドボード
2. 5種類のジェル(赤、緑、青、黄、紫)
3. 隣接するタイル同士の交換
4. 3個以上の同色マッチで消去
5. 重力による落下システム
6. 空いたスペースに新しいタイルが補充

### 特殊アイテム
- **爆弾**: 4個以上のマッチで生成
- **爆弾効果**: 上下左右4方向のタイルを消去
- **爆発アニメーション**: スケール・透明度変化

### 勝利条件・ゲーム終了
- エンドレス仕様(現在)
- 将来的にスコアシステム、レベル制を追加予定

## アーキテクチャ設計

### コンポーネント構成

```
App
├── ExplosionEffect (爆発エフェクト)
├── BombTile (爆弾タイル)
├── AnimatedGelTile (ジェルタイル)
└── DebugPanel (デバッグUI)
```

### 状態管理

```typescript
// メインゲーム状態
const [board, setBoard] = useState<TileType[][]>() // ボード状態
const [selected, setSelected] = useState<Position>() // 選択中タイル
const [isAnimating, setIsAnimating] = useState<boolean>() // アニメーション中フラグ
const [explosions, setExplosions] = useState<Position[]>() // 爆発エフェクト
const [debugMode, setDebugMode] = useState<boolean>() // デバッグモード

// アニメーション管理
const animationValues = useRef<Animated.Value[][]>() // 落下アニメーション値
```

### データ型定義

```typescript
// 基本型
const TILE_TYPES = ['red', 'green', 'blue', 'yellow', 'purple'] as const;
const SPECIAL_ITEMS = ['bomb'] as const;
type TileType = typeof TILE_TYPES[number] | typeof SPECIAL_ITEMS[number] | null;

// 位置情報
interface Position {
y: number;
x: number;
}

// アニメーション情報
interface AnimationInfo {
fromY: number;
toY: number;
tile: TileType;
}
```

## ゲームロジック設計

### GameLogicオブジェクト

```typescript
const GameLogic = {
// ボード操作
createBoard(): TileType[][] // ランダムボード生成
createTestBoard(pattern): TileType[][] // テスト用ボード生成
cloneBoard(board): TileType[][] // ボードクローン

// マッチング処理
findMatches(board): boolean[][] // マッチ検出
removeMatches(board, matched): result // マッチ消去

// 爆弾処理
handleBombExplosion(board, y, x): count // 爆弾爆発処理
shouldGenerateBomb(count): boolean // 爆弾生成判定

// タイル操作
dropTiles(board): void // 落下・補充処理

// テスト・デバッグ用
countTotalMatches(board): number // マッチ数カウント
countTilesByColor(board, color): number // 特定色カウント
boardToString(board): string // ボード文字列化
}
```

### 処理フロー

#### 1. タイル交換処理
```
handleTileClick(y, x)
├── 爆弾チェック
│ ├── 爆発アニメーション開始
│ ├── handleBombExplosion()
│ ├── dropTiles()
│ ├── 落下アニメーション
│ └── processMatches()
└── 通常タイル
├── 選択状態更新
├── 隣接チェック
├── タイル交換
└── processMatches()
```

#### 2. マッチ処理
```
processMatches(board, shouldGenerateBombs)
├── findMatches()
├── removeMatches()
├── 爆弾生成判定 (4個以上)
├── dropTiles()
├── 落下アニメーション
└── 再帰処理(連鎖)
```

#### 3. 落下アニメーション
```
calculateDropAnimations(oldBoard, newBoard)
├── 各列の移動追跡
├── animationMapの生成
└── 距離計算

animateDrop(animationData)
├── アニメーション設定
├── 並列実行
└── 完了コールバック
```

## UI/UX設計

### ビジュアルデザイン
- **テーマ**: ダークモード(背景: #1a1a2e)
- **ジェル**: SVGグラデーション、立体感
- **爆弾**: メタリック質感、導火線付き
- **エフェクト**: 爆発は放射グラデーション + 火花

### アニメーション仕様
```typescript
// 落下アニメーション
duration: Math.abs(fromY - toY) * 80 // 距離比例
easing: Linear // 等速度

// 爆発エフェクト
scale: 0 → 3 // 拡大
opacity: 1 → 0 // 透明化
duration: 400ms // 固定時間
```

### レスポンシブ対応
- タイルサイズ: 38px固定
- ボードサイズ: 8 * 42px (パディング含む)
- 画面サイズに応じた中央配置

## テスト設計

### テスト機能
```typescript
// デバッグモード機能
testThreeMatch() // 3マッチテスト
testBombGeneration() // 4+マッチ→爆弾生成テスト
testBombExplosion() // 爆弾爆発テスト
logBoardState() // ボード状態ログ出力
forceMatchAt() // 強制マッチ作成
```

### テストパターン
1. **3マッチテスト**: 爆弾生成されない
2. **4+マッチテスト**: 爆弾生成される
3. **爆弾テスト**: 十字方向消去確認
4. **連鎖テスト**: 連続マッチ処理
5. **落下テスト**: アニメーション確認

## パフォーマンス設計

### 最適化手法
- `useNativeDriver: false`: Web互換性重視
- アニメーション並列実行: `Animated.parallel()`
- 状態更新最小化: 必要時のみsetBoard()
- メモリ管理: アニメーション完了時リセット

### レンダリング最適化
- `key`属性適切設定: `tile-${y}-${x}`
- SVGグラデーションID重複回避: `${color}-${position}`
- 条件付きレンダリング: デバッグパネル、エフェクト

## セキュリティ・品質

### エラーハンドリング
- 境界値チェック: 配列インデックス
- null/undefined対策: オプショナルチェーン
- アニメーション中操作制限: isAnimatingフラグ

### コード品質
- TypeScript型安全性
- ESLintルール準拠
- コメント・ドキュメント完備
- 関数型プログラミング重視

## 今後の拡張予定

### 機能追加
- [ ] スコアシステム
- [ ] レベル制・難易度調整
- [ ] 音効・BGM
- [ ] 特殊アイテム追加(レインボー、ラインクリア等)
- [ ] アチーブメントシステム

### 技術改善
- [ ] Redux/Zustand状態管理
- [ ] React.memo最適化
- [ ] PWA対応
- [ ] オフライン機能
- [ ] マルチプレイヤー

### テスト強化
- [ ] Jest単体テスト
- [ ] E2Eテスト (Detox)
- [ ] パフォーマンステスト
- [ ] アクセシビリティテスト

## 開発環境

### 必要ツール
```bash
npm install -g expo-cli
npm install react-native-svg
```

### 実行方法
```bash
npx expo start # 開発サーバー起動
npx expo start --web # Web版実行
```

### ビルド
```bash
npx expo build:ios # iOS向けビルド
npx expo build:android # Android向けビルド
```

---

**作成日**: 2025年7月22日
**バージョン**: 1.0.0
**更新者**: AI Development Assistant
カテゴリー: 開発 | Claude Sonnet で 3マッチゲームが作れるのか? はコメントを受け付けていません

asp.net minimal を Docker 対応して、azure に up するまで…いかず

asp.net minimal をローカル環境でなく Azure で動作できるようにします。ひとまず、Docker 化しておきましょう。

Dockerfile を作る

aspnet-minimal-sample の Dockerfile を書いて。

毎度、Docker ファイルを作るのが面倒なので、作ってくれるのはありがたい。

mysql も使うので docker-compose.yml も次いでに作ってくれます。

Dockerfile

# ASP.NET Minimal API Dockerfile

# Build stage
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src

# Copy csproj and restore dependencies
COPY *.csproj ./
RUN dotnet restore

# Copy everything else and build
COPY . ./
RUN dotnet publish -c Release -o /app/publish

# Runtime stage
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime
WORKDIR /app

# Copy published files
COPY --from=build /app/publish .

# Copy static files (wwwroot)
COPY --from=build /src/wwwroot ./wwwroot

# Create a non-root user
RUN adduser --disabled-password --gecos '' appuser && chown -R appuser /app
USER appuser

# Expose port
EXPOSE 8000

# Set environment variables
ENV ASPNETCORE_URLS=http://+:8000
ENV ASPNETCORE_ENVIRONMENT=Production

# Entry point
ENTRYPOINT ["dotnet", "aspnet-minimal-sample.dll"]

docker-compose.yml

version: '3.8'

services:
  # MySQL Database
  mysql:
    image: mysql:8.0
    container_name: mos-mysql
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: mos
      MYSQL_USER: mos
      MYSQL_PASSWORD: mos
    ports:
      - "3306:3306"
    volumes:
      - mysql_data:/var/lib/mysql
      - ./database/init:/docker-entrypoint-initdb.d
    networks:
      - mos-network
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      timeout: 20s
      retries: 10

  # ASP.NET Web API
  webapi:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: mos-webapi
    ports:
      - "8000:8000"
    environment:
      - ASPNETCORE_ENVIRONMENT=Production
      - ConnectionStrings__DefaultConnection=Server=mysql;Database=mos;User=mos;Password=mos;
    depends_on:
      mysql:
        condition: service_healthy
    networks:
      - mos-network
    volumes:
      - ./wwwroot/images:/app/wwwroot/images

volumes:
  mysql_data:

networks:
  mos-network:
    driver: bridge

いまどき、Dockerfile や docker-compose.yml を一から作ることはなくて、大体が似たようなプロジェクトからコピーして少し修正するぐらいなので、その元ネタが Claude 由来ってところでしょうか。

マイグレーションファイルを作る

mysql に categories テーブル等を作成するマイグレーション用のコードを作成して。

データベースを Docker 内に構築するので、テーブル定義からマイグレーション用のコードを生成します。以前ならば、何らかのツールか手作業で作成するところですが、これも一気に作ってくれます。

Seeder のサンプルも作ってくれて、丁寧にも readme.md にツールのドキュメントが生成されます。

README.md

# ASP.NET Core Minimal API with MySQL - マイグレーション管理

このプロジェクトでは、Entity Framework Coreを使用してMySQLデータベースとの連携を行います。

## 前提条件

- .NET 9.0
- MySQL 8.0以降
- Entity Framework Core Tools

## データベース設定

### 1. 接続文字列の設定

`appsettings.json`または`appsettings.Development.json`で接続文字列を設定してください:

```json
{
  "ConnectionStrings": {
    "DefaultConnection": "Server=localhost;Database=mos;User=root;Password=your_password;Port=3306;"
  }
}
```

### 2. マイグレーションの管理

#### PowerShell(Windows)

```powershell
# 初期マイグレーションを作成
.\migrate.ps1 init

# 新しいマイグレーションを作成
.\migrate.ps1 add "AddNewTable"

# データベースを最新のマイグレーションに更新
.\migrate.ps1 update

# マイグレーションの状態を確認
.\migrate.ps1 status

# 初期データを投入
.\migrate.ps1 seed

# データベースをリセット(危険)
.\migrate.ps1 reset
```

#### Bash(Linux/Mac)

```bash
# 初期マイグレーションを作成
./migrate.sh init

# 新しいマイグレーションを作成
./migrate.sh add "AddNewTable"

# データベースを最新のマイグレーションに更新
./migrate.sh update

# マイグレーションの状態を確認
./migrate.sh status

# 初期データを投入
./migrate.sh seed

# データベースをリセット(危険)
./migrate.sh reset
```

#### 手動でのEntity Framework Coreコマンド

```bash
# 初期マイグレーションを作成
dotnet ef migrations add InitialCreate

# データベースを更新
dotnet ef database update

# 初期データを投入
dotnet run -- --seed

# マイグレーションの状態を確認
dotnet ef migrations list

# 特定のマイグレーションに移行
dotnet ef database update TargetMigration

# データベースを削除
dotnet ef database drop
```

## マイグレーションファイルの構成

### 自動生成されるマイグレーション

Entity Framework Coreは、モデルの変更を検出して自動的にマイグレーションを作成します:

- `Migrations/[timestamp]_InitialCreate.cs`: マイグレーションの実行内容
- `Migrations/[timestamp]_InitialCreate.Designer.cs`: EF Coreのメタデータ
- `Migrations/AppDbContextModelSnapshot.cs`: 現在のモデルのスナップショット

### 手動でのSQL実行

手動でSQLを実行する場合は、`database/init/01_create_tables.sql`を使用できます:

```sql
-- MySQLに直接接続して実行
mysql -u root -p mos < database/init/01_create_tables.sql
```

## 初期データについて

### 自動投入される初期データ

`Services/DatabaseSeeder.cs`で以下のデータが自動投入されます:

- **カテゴリ**: 4件(コーヒー、紅茶・ティー、フード、季節限定)
- **商品**: 12件(各カテゴリの代表的な商品)
- **注文**: 2件(サンプル注文データ)
- **注文商品**: 関連する注文商品データ

### 初期データの投入

```bash
# 初期データを投入
dotnet run -- --seed

# または PowerShell
.\migrate.ps1 seed

# または Bash
./migrate.sh seed
```

## Docker環境での使用

Docker環境では、データベースの初期化が自動で行われます:

```bash
# 開発環境の起動
docker-compose -f docker-compose.dev.yml up

# 本番環境の起動
docker-compose up
```

## トラブルシューティング

### マイグレーションエラー

```bash
# マイグレーションをリセット
dotnet ef migrations remove

# データベースを削除して再作成
dotnet ef database drop
dotnet ef database update
```

### 接続エラー

1. MySQL サーバーが起動していることを確認
2. 接続文字列が正しいことを確認
3. データベース `mos` が存在することを確認

### パッケージエラー

```bash
# パッケージを復元
dotnet restore

# キャッシュをクリア
dotnet nuget locals all --clear
```

## API エンドポイント

マイグレーション後、以下のAPIエンドポイントが利用可能になります:

- `GET /mos/api/categories` - カテゴリ一覧
- `GET /mos/api/products` - 商品一覧
- `GET /mos/api/orders` - 注文一覧
- `POST /mos/api/orders` - 注文作成

詳細な仕様については、`webapi/mos-api.yaml`を参照してください。

DatabaseSeeder.cs を書き変える

データベースに投入する初期値は DatabaseSeeder.cs に書かれています。

using aspnet_minimal_sample.Data;
using aspnet_minimal_sample.Models;
using Microsoft.EntityFrameworkCore;

namespace aspnet_minimal_sample.Services;

public class DatabaseSeeder
{
    private readonly AppDbContext _context;

    public DatabaseSeeder(AppDbContext context)
    {
        _context = context;
    }

    public async Task SeedAsync()
    {
        // 既存のデータがある場合はスキップ
        if (await _context.Categories.AnyAsync())
        {
            Console.WriteLine("データベースには既にデータが存在します。初期データの投入をスキップします。");
            return;
        }

        Console.WriteLine("初期データを投入中...");

        // カテゴリの初期データ
        var categories = new List<Category>
        {
            new Category
            {
                Slug = "coffee",
                Title = "コーヒー",
                Description = "厳選されたコーヒー豆を使用したドリンク",
                Image = "/images/categories/coffee.jpg",
                SortId = 1,
                Display = 1
            },
            new Category
            {
                Slug = "tea",
                Title = "紅茶・ティー",
                Description = "世界各地から厳選された紅茶とハーブティー",
                Image = "/images/categories/tea.jpg",
                SortId = 2,
                Display = 1
            },
            new Category
            {
                Slug = "food",
                Title = "フード",
                Description = "軽食やデザートなど、お飲み物と一緒にお楽しみください",
                Image = "/images/categories/food.jpg",
                SortId = 3,
                Display = 1
            },

ちまちま書き変えてもいいのですが、既に laravel-webapi-sample で CategorySeeder.php と ProductSeeder.php が書かれているので、これを流用しましょう。

laravel-webapi-sample の ProductSeeder と CategorySeeder があるので、
これを使って aspnet-minimal-sample の DatabaseSeeder のデータを書き変えて。

PHP のコードから C# のコードに書き変えるだけなので、Copilot + Claude Sonnet の得意とするところでしょう。

コンバートが出来て一見できているように見えますが、CategoryIdの値が固定になっています。

        // 商品の初期データ
        var products = new List<Product>
        {
            // ハンバーガー
            new Product
            {
                CategoryId = categories[4].Id, // ハンバーガー
                Slug = "burger1",
                Name = "モスバーガー",
                Description = "",
                Image = "burger1.jpg",
                Price = 440,
                SortId = 1,
                Display = 1
            },
            new Product
            {
                CategoryId = categories[4].Id, // ハンバーガー
                Slug = "burger2",
                Name = "モスチーズバーガー",
                Description = "",
                Image = "burger2.jpg",
                Price = 480,
                SortId = 2,
                Display = 1
            },

実は、カテゴリはあちこちに割り振られるようにランダム値にしています。これを C# のコードにもいれておきます。

        // カテゴリの最大値からランダムidを取得
        function fake_category_id() {
            $maxId = Category::max('id');
            return rand(1, $maxId);
        }

この部分は手作業と Copiot で変更

        // カテゴリの最大値からランダムidを取得
        int fake_category_id()
        {
            var maxId = _context.Categories.Max(c => c.Id);
            return Random.Shared.Next(maxId) + 1; // 1からmaxIdまでのランダムな値を生成
        }


        // 商品の初期データ
        var products = new List<Product>
        {
            // ハンバーガー
            new Product
            {
                CategoryId = fake_category_id(),
                Slug = "burger1",
                Name = "モスバーガー",
                Description = "",
                Image = "burger1.jpg",
                Price = 440,
                SortId = 1,
                Display = 1
            },

.devcontainer コンテナを作成する

vscode では開発環境をコンテナ化できるのでこれを作成します。

.devcontainer を作成して。

できあがったっぽいので、いったん vscode を閉じて開き直します。

右下に「コンテナ―で再度開く」ボタンが出てきたら ok です。これをクリックすると Docker コンテナを作り始めます。

コマンドパレットで「開発コンテナ:コンテナ―で再度開く」を選択しても ok です。

で、一発でうまくいけばいいのですが、大抵はうまくいきません。素直に AI に聞いてみましょう。AI が書いたんだし。

リモートコンテナを開いたときにエラーがでます。

ubuntu の dotnet 9.0 のイメージが無いようです。

豪快に 8.0 に以降しようとしていますが、9.0 で使いたいんですよね。。。

.NET(Core)で利用可能なDockerイメージとタグ #Docker – Qiita https://qiita.com/karuakun/items/8d98f0430bf2dbc6af59

これを見る限り、Ubuntu 22.04 ならば 9.0-jammy、Ubuntu 24.04 ならば 9.0-noble となるはずです。

FROM mcr.microsoft.com/dotnet/sdk:8.0
の代わりに
FROM mcr.microsoft.com/dotnet/sdk:9.0-noble
を使ってみて。

執拗に Pomelo.EntityFrameworkCore.MySql を薦めてくるのですが、現時点では Oracle 提供の MySql.EntityFrameworkCore を使った方がいいです。

MySql.EntityFrameworkCore を使って。

この手の話は、実際に開発経験がないとわからないところでもあるし、Claude Sonnet にしても内部での学習済みモデルを作成したときの収集データ時期の問題もあり、そもそも、MySql.EntityFrameworkCore を使った例があまりブログなどでないという現状もあり。このあたり、「正解」なのか「多数決」なのかが問われるところです。

Docker コンテナを作る

さて、ビルドが通ったところで Docker の開発コンテナを作ります。今度はうまくイメージファイルをダウンロードできそうです。

ちなみに、先の 9.0 から 8.0 のダウングレード騒ぎのときに、Claude Sonnet が既存のコンテナを全削除してしまいました。この手のやらかしは結構あるかもしれません。

一見うまくいきそうだったのですが、何故か https://download.docker.com/linux/debian のところで失敗しています。

------
> [dev_container_auto_added_stage_label 4/9] RUN curl -fsSL https://download.do
cker.com/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-
keyring.gpg && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/s
hare/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debi
an $(lsb_release -cs) stable" | tee /etc/apt/sources.list.d/docker.list > /dev/n
ull && apt-get update && apt-get install -y docker-ce-cli && apt-get
clean && rm -rf /var/lib/apt/lists/*:
0.917 Hit:1 https://deb.nodesource.com/node_20.x nodistro InRelease
1.171 Hit:2 http://archive.ubuntu.com/ubuntu noble InRelease
1.221 Hit:3 http://security.ubuntu.com/ubuntu noble-security InRelease
1.324 Ign:4 https://download.docker.com/linux/debian noble InRelease
1.391 Hit:5 http://archive.ubuntu.com/ubuntu noble-updates InRelease
1.614 Hit:6 http://archive.ubuntu.com/ubuntu noble-backports InRelease
1.656 Err:7 https://download.docker.com/linux/debian noble Release
1.656 404 Not Found [IP: 18.172.31.22 443]
1.728 Reading package lists...
3.740 E: The repository 'https://download.docker.com/linux/debian noble Release'
does not have a Release file.
------
failed to solve: process "/bin/sh -c curl -fsSL https://download.docker.com/linu
x/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg
&& echo \"deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyring
s/docker-archive-keyring.gpg] https://download.docker.com/linux/debian $(lsb_rel
ease -cs) stable\" | tee /etc/apt/sources.list.d/docker.list > /dev/null &&
apt-get update && apt-get install -y docker-ce-cli && apt-get clean
&& rm -rf /var/lib/apt/lists/*" did not complete successfully: exit code: 100

View build details: docker-desktop://dashboard/build/desktop-linux/desktop-linux
/jadc0jrt185aqxmaw15etxq1i
[2025-07-06T12:53:13.110Z] Stop (136147 ms): Run: docker compose --project-name aspnet-minimal-sample_devcontainer -f h:\ai-sample\src\webapi\aspnet-minimal-sample\.devcontainer\docker-compose.yml -f c:\Users\masuda\AppData\Roaming\Code\User\globalStorage\ms-vscode-remote.remote-containers\data\docker-compose\docker-compose.devcontainer.build-1751806256960.yml build
[2025-07-06T12:53:13.129Z] Error: Command failed: docker compose --project-name aspnet-minimal-sample_devcontainer -f h:\ai-sample\src\webapi\aspnet-minimal-sample\.devcontainer\docker-compose.yml -f c:\Users\masuda\AppData\Roaming\Code\User\globalStorage\ms-vscode-remote.remote-containers\data\docker-compose\docker-compose.devcontainer.build-1751806256960.yml build
[2025-07-06T12:53:13.129Z] at lw (c:\Users\masuda\.vscode\extensions\ms-vscode-remote.remote-containers-0.417.0\dist\spec-node\devContainersSpecCLI.js:432:525)
[2025-07-06T12:53:13.130Z] at async c6 (c:\Users\masuda\.vscode\extensions\ms-vscode-remote.remote-containers-0.417.0\dist\spec-node\devContainersSpecCLI.js:432:2475)
[2025-07-06T12:53:13.130Z] at async u6 (c:\Users\masuda\.vscode\extensions\ms-vscode-remote.remote-containers-0.417.0\dist\spec-node\devContainersSpecCLI.js:412:3489)
[2025-07-06T12:53:13.130Z] at async H6 (c:\Users\masuda\.vscode\extensions\ms-vscode-remote.remote-containers-0.417.0\dist\spec-node\devContainersSpecCLI.js:484:4015)
[2025-07-06T12:53:13.130Z] at async BC (c:\Users\masuda\.vscode\extensions\ms-vscode-remote.remote-containers-0.417.0\dist\spec-node\devContainersSpecCLI.js:484:4957)
[2025-07-06T12:53:13.131Z] at async d7 (c:\Users\masuda\.vscode\extensions\ms-vscode-remote.remote-containers-0.417.0\dist\spec-node\devContainersSpecCLI.js:665:202)
[2025-07-06T12:53:13.131Z] at async f7 (c:\Users\masuda\.vscode\extensions\ms-vscode-remote.remote-containers-0.417.0\dist\spec-node\devContainersSpecCLI.js:664:14804)
[2025-07-06T12:53:13.131Z] at async c:\Users\masuda\.vscode\extensions\ms-vscode-remote.remote-containers-0.417.0\dist\spec-node\devContainersSpecCLI.js:484:1188
[2025-07-06T12:53:13.219Z] Stop (143625 ms): Run: d:\tools\Microsoft VS Code\Code.exe c:\Users\masuda\.vscode\extensions\ms-vscode-remote.remote-containers-0.417.0\dist\spec-node\devContainersSpecCLI.js up --user-data-folder c:\Users\masuda\AppData\Roaming\Code\User\globalStorage\ms-vscode-remote.remote-containers\data --container-session-data-folder /tmp/devcontainers-434ce8f4-209d-49f9-b58e-f43a1a52828f1751806242331 --workspace-folder h:\ai-sample\src\webapi\aspnet-minimal-sample --workspace-mount-consistency cached --gpu-availability detect --id-label devcontainer.local_folder=h:\ai-sample\src\webapi\aspnet-minimal-sample --id-label devcontainer.config_file=h:\ai-sample\src\webapi\aspnet-minimal-sample\.devcontainer\devcontainer.json --log-level debug --log-format json --config h:\ai-sample\src\webapi\aspnet-minimal-sample\.devcontainer\devcontainer.json --default-user-env-probe loginInteractiveShell --mount type=volume,source=vscode,target=/vscode,external=true --mount type=bind,source=\\wsl.localhost\Ubuntu\mnt\wslg\runtime-dir\wayland-0,target=/tmp/vscode-wayland-97456619-a854-41e6-aa3d-2cca4ba71657.sock --skip-post-create --update-remote-user-uid-default on --mount-workspace-git-root --include-configuration --include-merged-configuration
[2025-07-06T12:53:13.220Z] Exit code 1
[2025-07-06T12:53:13.238Z] Command failed: d:\tools\Microsoft VS Code\Code.exe c:\Users\masuda\.vscode\extensions\ms-vscode-remote.remote-containers-0.417.0\dist\spec-node\devContainersSpecCLI.js up --user-data-folder c:\Users\masuda\AppData\Roaming\Code\User\globalStorage\ms-vscode-remote.remote-containers\data --container-session-data-folder /tmp/devcontainers-434ce8f4-209d-49f9-b58e-f43a1a52828f1751806242331 --workspace-folder h:\ai-sample\src\webapi\aspnet-minimal-sample --workspace-mount-consistency cached --gpu-availability detect --id-label devcontainer.local_folder=h:\ai-sample\src\webapi\aspnet-minimal-sample --id-label devcontainer.config_file=h:\ai-sample\src\webapi\aspnet-minimal-sample\.devcontainer\devcontainer.json --log-level debug --log-format json --config h:\ai-sample\src\webapi\aspnet-minimal-sample\.devcontainer\devcontainer.json --default-user-env-probe loginInteractiveShell --mount type=volume,source=vscode,target=/vscode,external=true --mount type=bind,source=\\wsl.localhost\Ubuntu\mnt\wslg\runtime-dir\wayland-0,target=/tmp/vscode-wayland-97456619-a854-41e6-aa3d-2cca4ba71657.sock --skip-post-create --update-remote-user-uid-default on --mount-workspace-git-root --include-configuration --include-merged-configuration
[2025-07-06T12:53:13.238Z] Exit code 1

で、

本当かどうかわからないのですが、Docker は 24.04 はサポートしていないらしく、22.04 を使うそうです。ほんとかな?

で、うまくいかないのでやり直し

2日間苦戦したのですが、何をやってもポートエラー等が取れなくなってしまったので、別途 minimal のみな asp.net web api を作って、Dockerfile を整理しました。

Dockerfile.dev

# Development Dockerfile for devcontainer
FROM mcr.microsoft.com/dotnet/sdk:9.0

# Set environment variables
ENV DEBIAN_FRONTEND=noninteractive
ENV TZ=Asia/Tokyo

# Install additional tools
RUN apt-get update && apt-get install -y \
    git \
    curl \
    wget \
    unzip \
    vim \
    nano \
    zsh \
    sudo \
    ca-certificates \
    && rm -rf /var/lib/apt/lists/* \
    && apt-get clean

# Create vscode user with sudo access
RUN groupadd --gid 1000 vscode \
    && useradd --uid 1000 --gid vscode --shell /bin/bash --create-home vscode \
    && echo vscode ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/vscode \
    && chmod 0440 /etc/sudoers.d/vscode

# Set up workspace directory with proper permissions
WORKDIR /workspace
RUN chown vscode:vscode /workspace

# Switch to vscode user for the rest of the setup
USER vscode

# Set umask to ensure proper permissions for created files
RUN echo "umask 022" >> /home/vscode/.bashrc \
    && echo "umask 022" >> /home/vscode/.zshrc || true

# Install Oh My Zsh with retry logic and error handling
RUN curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh -o install-ohmyzsh.sh \
    && chmod +x install-ohmyzsh.sh \
    && bash install-ohmyzsh.sh --unattended \
    && rm -f install-ohmyzsh.sh \
    || echo "Oh My Zsh installation failed, continuing..."

# Set the default shell to zsh (switch back to root temporarily)
USER root
RUN chsh -s /bin/zsh vscode || echo "Failed to change shell, continuing..."

# Switch back to vscode user
USER vscode

# Install dotnet tools with error handling
RUN dotnet tool install --global dotnet-ef || echo "dotnet-ef installation failed" \
    && dotnet tool install --global dotnet-aspnet-codegenerator || echo "dotnet-aspnet-codegenerator installation failed" \
    && dotnet tool install --global dotnet-watch || echo "dotnet-watch installation failed"

# Add dotnet tools to PATH
ENV PATH="${PATH}:/home/vscode/.dotnet/tools"

# Set up git configuration
RUN git config --global --add safe.directory /workspace \
    && git config --global init.defaultBranch main

# Create necessary directories
RUN mkdir -p /home/vscode/.vscode-server/extensions \
    && mkdir -p /home/vscode/.nuget/packages

# Default command
CMD ["sleep", "infinity"]

docker-compose.yml

services:
  aspnet-sample:
    build:
      context: .
      dockerfile: Dockerfile.dev
    ports:
      - "8000:8000"
      - "8001:8001"
    environment:
      - ASPNETCORE_ENVIRONMENT=Development
      - ASPNETCORE_URLS=http://+:8000
    networks:
      - aspnet-network
  mysql:
    image: mysql:8.0
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: mos
      MYSQL_USER: mos
      MYSQL_PASSWORD: mos
    ports:
      - "3306:3306"
    volumes:
      - mysql-data:/var/lib/mysql
      - ./database/init:/docker-entrypoint-initdb.d
    networks:
      - aspnet-network
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      timeout: 20s
      retries: 10
      interval: 10s

networks:
  aspnet-network:
    driver: bridge

volumes:
    mysql-data:
        driver: local

.devcontainer/devcontainer.json

{
  "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 にレビューして貰います。

おそらく

  • ホストのポート番号とダブることを想定していない。
  • dotnet build で書き込みされるフォルダーのパーミッション変更が必須
  • ホストが windows まわりなのでが問題か?

なところで引っ掛かります。laravel や next.js のほうはビルドする必要がないのでコード変更をした後にブラウザ等でリロードすればいいのですが、dotnet の場合はいちいちビルドしないといけないのが面倒なところです。hot reload もできたような気がするのですが、これは後で調べるとして。

そんな訳で、無事「開発コンテナ」の中での実行まで完了

私の場合、xamp での mysql も動いているので mysql-1 の 3306:3306 も変えないと駄目なんですが、ひとまず、これは終了。あとで、azure にデプロイを試していきます。

参照

webapi/aspnet-minimal-sample : ASP.NET Core Minimal による web api サンプル & Docker 動作

https://github.com/moonmile/mos-ai-sample/tree/master/src/webapi/aspnet-minimal-sample

カテゴリー: 開発 | asp.net minimal を Docker 対応して、azure に up するまで…いかず はコメントを受け付けていません