筒井康隆の世界(再放送)を見ての感想を

筒井康隆の世界 – NHK https://www.nhk.jp/p/ts/7JPPP2R737/

去年の放送なので、現在は91歳ということになる。訃報は聞いていないので、まだ夫婦で老人ホームにいることになる。

実は『敵』を買って以来(しかも未読)な状態で、最近はあまり追ってはいなかった。現在90歳ということは、30年ほど前に新宿の紀伊国屋に身に行った筒井康隆は60歳ぐらいだったのかと。自分と30ほど違うとは驚きでもある。握手して貰った。

先日、「百年の孤独」を買い直した。ガルシア・マルケスがノーベル賞を取ったときに買っては見たものの暫く放置の状態で、引っ越しの折にBOOK OFF に売ってしまったような覚えがある。引っ越すときに、3,000冊の本を2,000冊位に減らした時にまぎれてしまった訳だが。あまり本は捨てるものではない。とはいえ、最近は電子書籍が便利になったので、Kindleに2,000冊ほど入っている(漫画も含めてだけど)。物理本は1,000冊弱にはなったのじゃないだろうか。で、買い直したのは、先日「百年の孤独」の100分の解説だったかで、大江健三郎や筒井康隆に影響を与えたとことを知ったのである。実は「百年の孤独」は1967年の出版で、私が生まれる前だ。ああ、そんなに古い作品であったのか、というのと大江健三郎(たぶん、万延元年のフットボールか燃え上がる木あたり)に影響を与えたとか、筒井康隆(たぶん、バブリング創成期とか)に影響を与えたとかという話になると、もう一回読んでみないとあかんだろうということになる。そうなると井上ひさしの「吉里吉里人」もそうじゃないだろうか、と思う訳で。で、まだ、「百年の孤独」は読んでいないのだが。

筒井康隆の初期作品に「東海道戦争」(1965年)がある。戦争を眺めるというパターンなのだけど、「銀河鉄道999」(1977年)にも戦争を眺めて食事をするシーンがある。松本零士は筒井康隆の5つぐらい下になるが、ほぼ同世代といってよいだろう。筒井康隆が90歳で存命なわけだが、星新一、小松左京、藤子不二雄、手塚治虫、とこのあたりの SF 世代はみな鬼籍に入っていしまっている。まあ、そういう意味では、夢野久作とか沼正三とか押川春浪なんかは、さらに年上なのだけど。そういう SF の技法に沿った形で描けるのは筒井康隆しか残っていないと言える。

ただし、「朝のガスパール」や「ビアンカ・オーバースタディ」のような書き方、さらに「モナドの領域」が現在の SF 小説(ラノベも含めて)にない訳ではない。むしろ、異世界転生ものだったり、ゲーム世界の拡張であったりするストーリーはそういう「メタ」的な視点が多いに含まれているし、それを知っていることが読者/視聴者の前提知識であったりする。例えば、涼宮ハルヒシリーズであったり、Reゼロであったり、SSSS.GRIDMANであったり、このすばであったり。主人公は意図的にメタ世界を意識している。もちろん、特別な能力があったりなかったりするわけだが、そういう現実的な世界とは別の世界に居ることを前知識として知っているという仮想世界、という読者との申し合わせは済んでいる状態になる。そのあたりは、「朝のガスパール」の連載時点でも、漫画の中に作者が登場したり(Dr.スランプとか、ブラック・ジャックとか)するシーンもあり、駒からはみ出したりするシーンがある。もちろん、そこを小説/漫画内に組み込み始めたは、果たして「朝のガスパール」だったのか、それともそれ以前に漫画があったのかは定かではない。

メタ空間という意味では、松本零士の銀河鉄道999もメタ世界であり、ハーロックやエメラルダスの世界と繋がっている。ラノベの小説でもあっちの主人公がこっちの小説にちょっとだけ出てきたりする。そういう仕掛けも読者にも既におなじみになっていて、みな了解の域に達している。

改めて筒井康隆が凄いのは、このメタ世界の小説をいくつも発表しているところだ。シリーズもので一本だけ書くのではなく、思いつく限りたくさん書く。これが凄い。

そうだな。また、短編を書く練習を再開してもいいかもしれない。

カテゴリー: 雑談 | 筒井康隆の世界(再放送)を見ての感想を はコメントを受け付けていません

「図解即戦力 アジャイル開発の基礎知識」のソフトウェア開発のための追記

本書の扱いとして、ソフトウェア開発におけるアジャイル方式をスクラムに限定することなく解説しておく、というのが本筋だったので、ソフトウェア開発そのものではなくマネジメント(スクラムマスターとかプロジェクトリーダーとか)に焦点が当たっています。昨今のアジャイル開発本がスクラムを中心にしていたので、元々の「アジャイル憲章」からスタートして、スクラム以外のアジャイル開発(XP、チケット駆動)を用いるのと、アジャイル開発自体は決して非PMBOKではない、ことを取り入れたかったので、PMBOK 用語を使っているという事情があります。

まあ、端的に言えばスクラム開発については、「ケン・シェーバー著のアジャイルソフトウェア開発スクラム一冊で十分じゃないですかね?」ってところからスタートしたので、こんな感じになっています。

端的に言えば、Software Development 以外を記述した本(教科書的な)なので、ソフトウェア開発については SWBOK あたりを読め、という形になります。これをスタートラインとしているので、本書の範囲としては、右中央の「アジャイル開発型マネジメント」に絞っています。

中央にある、PMBOKや実装ノウハウはソフトウェア開発において基本知識としてあるものとして、実際にプロジェクトを動かす場合に、計画駆動型にするかアジャイル型にするかという判断があります。これはアジャイル憲章にも書いてある通り、ドキュメントを包括するのか動くソフトウェアを優先するのかという “選択” になります。実は、ドキュメントのほうを優先する場合もあります。宇宙開発や原子力開発、航空機関係のシステムなどの人命が掛かるものは「ドキュメント」に基づいて合意を取る等の厳しいルールが課せられます。そういう場合は、到底アジャイル開発という訳にはいきません。もっと厳密な形でプロジェクトを走らせることが求められます。逆に言えば、人命に関わらないものに関しては随時動きを確認するようなアジャイル形式でよいのです。スピードが要求される広告サイトや商品販売、あるいは一時期的なイベントで集客を見込むようなWEBサイトなどはリリースまでの期間は短期であるほうが優れていると言えるでしょう。なんせ、WEBサイトの動きがちょっとおかしかったとしても人命が失われることはありません。更に言えばほぼリアルタイムに修正が可能です。

このように、マネジメント自体を計画駆動にするのか、アジャイル開発にするのかという選択肢は、エコシステムとして(まさしく、「アジャイルソフトウェア開発エコシステム」に書かれていることになりますが)どちらがより金銭的に有利なのかという話になります。

ソフトウェア開発そのものに結び付ける道筋

実際のところ、コードの実装者から見れば、それが計画駆動であるかアジャイル開発であるかの違いはありません。端的に、コードを書く、単体テストをこなす、を繰り返します。コード書く前に紙のドキュメントの有無であったり、詳細設計 “書” に基づくべきなのか、チケット駆動のようにざっとした箇条書きによってコーディングを行うかの違いはありますが、少なくとも現代においては「コードを書く」「単体テストを行う」という XP のノウハウを利用したほうがよいです。さすがに、いまどき、コンパイルが通らない状態でコードを書くことはないでしょう。昔は、コンパイル自体が長かった、あるいは大型計算機の使用時間待ちであったこともあり、コンパイルなしでコードを書く時期もありました。しかし、今、そんなことはしないでしょう。そういう意味で、XP のテストコードを書くといのは実装工程においては必須になってきます(テストコードを先に書くか、後に書くかという違いはありますが)。

対象読者ですが「コードが一定の品質以上で書ける」ことが前提となっています。マネジメント層(スクラムマスター、プロジェクトマネージャー、オーナーなど)はコード自体はまあいいのですが、開発者が読む場合はこれが必要条件になります。

本書の章をピックアップして具体的なソフトウェア開発(主に実装工程)に結びつけると、こんな感じになります。

第2章 スクラムとXP
先に書いた通り、単体テストが必須です。単体テストサイクルは各 xUnit のツールに従います。継続的なコード改善やコードの共有化においても、単体テストが有効に働きます。これは計画駆動、アジャイル開発に限りません。最近ならば GitHub Action などを利用して、コミット前にビルドやテストコードの実行を強要すればよいでしょう。
ペアプロについては、リモートでのペアプロが可能になっています。VSCode などでコードを共有してもよいし、Teams で通話を繋ぎっぱなしでペアプロをすることも可能です。実際、新人教育などでは2,3時間繋ぎっぱなしで、相手のエディタを表示してもらって操作することもやります。

第3章 チケット駆動、第4章 バックログ、チケット
「チケット駆動」ということになっていますが、実際はチケットの扱い方です。
作業を分割する方法として、WBS/チケット/タスク/プロセスなどの様々な呼び方があります。これらのタスクについては、スケジュールを確定させたい計画駆動と、途中で顧客の要望を取り入れたい=チケットが増加する可能性が大であるアジャイル開発との違いがあります。
また、スクラムではスプリントという期間を決めるのに対して、チケット駆動ではそれほど期間を区切りません。
また、チケットを共有する手段として、Backlog(という製品)や GitHub の issue の利用があります。以前ならば、Excel で課題管理をしていたところですが、昨今では Web アプリを使って複数名で共有するのが当たり前になってきています。
コーディングをする上でのチケットの扱いは、完了を明確にすることです。これは本書でも書いてある通り「単体テストが通った」ことを明確にするとよいです。客観的に完成したことがわかる目安を作っておくことが重要です。

第5章 自動テスト
いわゆる再帰テスト(繰り返しテスト)のやり方です。
モックの作り方などの具体例がでていないのはプログラム言語や動作環境によって異なるためで、割愛しています。
ざっと言えば、Web システムでの単体テストを Controller, Service, Web API 経由という分割した形にする。Servcie 単位が望ましいのですが、外部から curl などを利用した Web API 経由でもよいでしょう。ただし、データベースの中身のチェックがちょっと面倒になります。
デスクトップアプリでは、ユーザーが操作する部分でテストするのは人力となってしまうので、これもロジック部分を切り出して自動テストが可能な状態にします。場合によってはモックが必要になりますが、コツとしては「モックを必要としない単体テスト」と「モックを必要とする結合テスト」に分割するとよいです。
ちなみにモックを必要とする結合テストであっても xUnit のノウハウが使えます。
組み込み系はモックが必須になる(I/Oを叩く、タイミング調節など)ので、これはロジックを分離する形で単体テストができるようにします。これは必須条件ですし、単体テストを利用すれば、少なくとも実装期間が半分になるのでお薦めです。

第6章 スタンドアップミーティング
アジャイル開発で、朝会などが有効となっていますが、新型コロナの影響や海外チームを組むことは “同じ場所に集まることはできない” ので、別な方法をとります、ということです。
最近は、出社が強制する世の中に戻りつつありますが、要は遅延したコミュニケーションをどこまで考慮するのか?ということです。逆に言えば、本当に即対応(命に係わる現象)以外は、遅延を許容したほうがうまくいきます。
アジャイル開発スクラムで「スクラムミーティング」が強調されるため、同じ時間に同じ場所にいるということが重要そうに見えます。しかし、それはスクラムを採用したときの話であって、XPやチケット駆動、あるいは計画駆動の場合には必須条件ではありません。そのあたりを間違えると、無闇な “朝会” を開発者に強要することになってしまいます。というか、最近なっていますが。

第7章 タイムボックス
第6章のコミュニケーションに関わるものとして、締め切りを明確にした「タイムボックス」制を使います。つまり、いつまで作ればよいか?を明確にして、その間にあれこれと言わないということです。皆さん、プログラマはプロなんですから、途中でサボったりしませんよね、ということとサボっても締め切りには間に合わせてね、ってことです。途中経過は問わないのです。結果が重要です、という話ですね。
逆に言えば、未知なる要素が多いとこきはタイムボックスが有効に働きません。経験上、とあるコードが何時間、何日で作れるのかというのは「勘」を働かせることもありますが、過去の経験との比較をするのがベターです。あと、自分の実力を知るために PSP をしておくとよいです。うまくいかなそうなときは、いったんタイムボックスを外して、調査時間を決めて(時間は無限ではないので)、どのくらいで調べられそうかをチェックします。これは AI エージェントを使ったコーディングでも同じことが言えます。途中で迷路に入ってしまった AI エージェントを “時間” を区切って引き戻します。前回のプロンプトを無しにして、もう一度やり直しをすれば ok です。

第8,9章は割愛。主にマネジメントよりなので。

第10章 継続的リリース
DevOps が入れたかったのと、ソフトウェア開発者はどのように休日過ごすべきだろうか?というのを書くために入れています。
いまとなっては Google がやめてしまった 20% ルールですが、日々の仕事の中でなんらかの学習や実験をしておくとは非常に有効です。それも、最新情報を常に追うのではなく、仕事をしながら、つまりは仕事が8割で、未来への投資が2割ということです。未来の投資がなければ、成長がありませんよね。自分への2割の投資をしてください。
執筆時、「プログラマは休日、どうやて過ごせばいいだろうか?最新技術の勉強でしょ」という雰囲気があったので、それの反動です。最新技術を追う時間は業務時間にやったほうがいいです。休日はもっと自由な技術の追い方(休憩も含めて)をしてみてください。仕事とはいえ楽しくやりたいし、ましてプログラマという仕事は好奇心を突き詰めることもできるので “楽しい” 仕事です。そのための、ちょっとばかし辛い勉強や作業があっても全然大丈夫ですよね。ってことです。

このあたりの開発者寄りの部分は、また機会を見つけて書いていきましょう。なお、実際にどうすればよいのかというのを、https://openccpm.com で準備中です。

カテゴリー: 開発 | 「図解即戦力 アジャイル開発の基礎知識」のソフトウェア開発のための追記 はコメントを受け付けていません

AI エージェントでプログラマ向けタイピング練習アプリの試作とアイデアマラソン

ひさびさに “アイデアマラソン” の一環です。最近は起き抜けにスマホで Copilot を開いて、何かアイデアをまとめておきます。まとめるというか、アイデアを少し具体的に AI に尋ねて思考実験(壁打ちとも言う)をしておきます。Copilot の場合、あれこれと具体的なツールを作ろうとするのですが、そこは完全に無視をして、ちょっと抽象的で要件定義的な話に留めておきます。あと、実際に作るとなったらどんな機能があるのかを箇条書にメモをしておきます。

要件と機能が数行程度で固まったら、readme.md に書き込んで、vscode + GitHub Copilot でコードを書いて貰います。

# プログラマ向けタイピングゲーム

- コーディングのための英単語をが出てくる
- コンピュータの専門用語が省略形だけじゃなく、フルスペルで出てくる
- プログラミングで出てくるコードの一部がでてくる

## 機能

- 単語単位ではなく文字単位でキーのチェック判定をする
- 制限時間内のタイピングを競う

あらかじめ、Next.js を使うことは決めておいたので、React のプロジェクトだけは作成しておきます。

このぐらいのアプリだと、まあ UI は程よくできてくるのですが、動きが変なところが出てきます。今回のアプリで言えば、

  • キー入力が効かない
  • キーを入力している間にタイマーが正常に動いていない
  • 間違ったキーを押したときに、正しいキーを入れるまでカーソルを止めて欲しい

のように、動かせば一発でわかるようなバグが出てくるので、テスター役としてアプリを動かします。

バグの修正の場合、元の readme.md を書き変えてもよいのですが、所詮使い捨てのプロトタイプになるので、プロンプトで指示を出しています。

そういう意味では、自動化された vibe coding ではないのですが、私の場合はこのような AI ペアプログラミングのスタイルで十分です。要件定義や機能を厳密にまとめる必要はありません。そもそも、厳密に記述する必要がないし、厳密に記述することは不可能です。未来を見通せないので、プロトタイプ開発をしているわけですから。

半日ほど面倒を見て(片手間に)から、ほどよく動いたので、Vercel に公開しておきます。

https://for-programmers-typing.vercel.app

Vercel の場合、React プロジェクトが手軽に公開できるのが便利です。本格的に使うといろいろとあるのかもしれませんが、GitHub にアップロード → Vercel で自動的に公開、という手順が用意されているので、雑に公開してみるのもよいでしょう。

コードのほうは、GitHub に public にしておきます。private な状態でも Vercel に公開ができます。

https://github.com/moonmile/for-programmers-typing

typingData.ts に、出題するランダムな単語が書いてあります。このランダムな単語や文字列自体も Claude Sonnet が作ったもので、適当に問題が追加できるようになっています。 ひとまず、実際に動かしてアプリの雰囲気を確認したいときに、この方法が便利じゃないでしょうか?

参考先

アイデアマラソン研究所
https://www.idea-marathon.com/

アイデアマラソンというのは樋口氏が提案した、毎日アイデアを出してノートにつづっておくという習慣です。2000年頃の会社時代でもやっていました。営業とかでないので、プログラマだとアイデアを出しても仕事に直結する訳ではないのですが、日々の検索や最新情報を追うだけではなくて、「自分で作ったらどうなるだろうか」という形でメモっていきます。以前だと、実際に動かすことはできなくて思考実験で終わっていたのですが、AI エージェントと組み合わせれば、プロトタイプ程度であれば結構できあがりますよ、ってことです。出来上がったものを実際に仕事とかに使うかどうかは別ですね。

紙のノートとか使い場合はこんな風になります。

アイデアマラソンをやってみる – sowdatabase
https://sowrealyou.hatenablog.com/entry/2024/06/17/%E3%82%A2%E3%82%A4%E3%83%87%E3%82%A2%E3%83%9E%E3%83%A9%E3%82%BD%E3%83%B3%E3%82%92%E3%82%84%E3%81%A3%E3%81%A6%E3%81%BF%E3%82%8B

カテゴリー: 開発 | AI エージェントでプログラマ向けタイピング練習アプリの試作とアイデアマラソン はコメントを受け付けていません

.NET開発者じゃなくても利用できるタスクランナー fake の勧め

発端はこのポストから。

いわゆる、ビルドからデプロイ、テスト実行などを含めたタスクランナーですね。昔話をすると、いわゆる Makefile を使ったビルドが Java 環境で辛くなり始めたとき Ant が出てきて流行りました。中味は XML 形式で書かれていて、条件分岐や依存関係などが Makefile よりも書きやすいという触れ込みです。それを受けて、MSBuild が出来たわけですが、実は今となっては XML 形式というのは書きづらく、JSON 形式か YAML 形式(Docker など)が流行ったりしています。

一方で、Makefile を C 言語風に書くということで C++ 界隈では CMake が使われます。CMake は OpenCV などをビルドするときに外せない要素で結構複雑怪奇なのですが、関数形式であることと複数の Makefile をひとつにまとめられるという依存関係で重宝されています。

さらに傍流のところに Fake があります。Fake は F# 専用の(という訳でもないのですが)の Make ファイルで、ちょうど Ant/MSBuild の集約の仕方と CMake の依存関係をうまく吸収しています。とはいえ、F# 界隈でしか使われいないし、F# を使っていても実際のところは *.fsproj を使うので Fake を使うことは非常にまれです。と言いますか、今の今まで忘れていました。

https://fake.build/

fake コマンドをインストールする

いまひとつ、インストールの仕方が解り辛いのですが、以下のように dotnet コマンドで tool-manifest を作ってから fake-cli をグローバルにインストールします。npm でツールをインストールときと同じです。本来ならば、ローカルに入れたいところなのですが、パスを作るのが powershell では面倒なので -g でグローバルに入れたほうが楽です。

fake.build/guide/getting-started.html

dotnet new tool-manifest
dotnet tool install fake-cli -g

.dotnet/tools の中に fake.exe があります。fake.exe 自体は .NET 6 なんですが、まあ無視します。

PS H:\kitami\FolkBearsGroup\folkbears-android\FolkBears> gcm fake                                                                                            

CommandType Name Version Source
----------- ---- ------- ------
Application fake.exe 6.1.0.0 C:\Users\masuda\.dotnet\tools\fake.exe

バージョンはこんな感じです。

PS H:\kitami\FolkBearsGroup\folkbears-android\FolkBears> fake --version 
FAKE 6 - F# Make (6.1.3.0) (running on .NETCoreApp,Version=v6.0) (this line is written to standard error, see https://github.com/fsharp/FAKE/issues/2066)
FakePath: C:\Users\masuda\.dotnet\tools\.store\fake-cli\6.1.3\fake-cli\6.1.3\tools\net6.0\any\Fake.Runtime.dll
Paket.Core: 8.1.0

build.fsx を作成する

Makefile にあたるものを作成します。コマンドとしては dontet new fake でひな形が作成できるのですが、https://fake.build/guide/getting-started.html にあるコードをコピペしても構いません。

#r "paket:
nuget Fake.Core.Target //"
// include Fake modules, see Fake modules section

open Fake.Core

// *** Define Targets ***
Target.create "Clean" (fun _ ->
  Trace.log " --- Cleaning stuff --- "
)

Target.create "Build" (fun _ ->
  Trace.log " --- Building the app --- "
)

Target.create "Deploy" (fun _ ->
  Trace.log " --- Deploying app --- "
)

open Fake.Core.TargetOperators

// *** Define Dependencies ***
"Clean"
  ==> "Build"
  ==> "Deploy"

// *** Start Build ***
Target.runOrDefault "Deploy"

Target.create で、ターゲット名に従ってコマンドを書いてきます。大抵はShell.Exec でコマンドを実行します。依存関係が “Clean” ==> “Build” ==> “Deploy” のところで、ここではデフォルトで “Deploy” が実行されます。

実行するときは fake build のようにします。build は build.fsx のことなので、deploy.fsx とか setup.fsx とかも作れるはずです。

fake build

Android のビルドに使ってみる

では、手元の Android プロジェクトで使ってみましょう。手元の FolkBears プロジェクトは Kotlin で書かれているので、IDE エディタは Android Studio を使っています。コマンドラインからは gradlew コマンドを使いビルドができます。実機へのデプロイは adb コマンドを使います。

Gradle を利用してデプロイなどの機能を修正してもよいのですが、これを fake で作ります。

build.fsx

#r "paket:
nuget Fake.Core.Target
nuget Fake.Tools.Shell //"

#load "./.fake/build.fsx/intellisense.fsx"

open Fake.Core

Target.create "Clean" (fun _ ->
    printfn "Cleaning..."
)

Target.create "Build" (fun _ ->
    printfn "Building..."
    // gradlew でビルド
    Shell.Exec("./gradlew.bat", "assembleDebug") |> ignore
    // Shell.Exec("gradlew", "build")
)

Target.create "Default" (fun _ ->
    printfn "Default target実行中..."
    printfn "gradlewでAndroidアプリをビルドします"
    // gradlew でビルド
    let result = Shell.Exec("./gradlew.bat", "assembleDebug")
    if result = 0 then
        printfn "ビルド成功"
    else
        printfn "ビルド失敗"
        failwith "gradlewビルドが失敗しました"
)

// Android 実機にデプロイする
Target.create "Deploy" (fun _ ->
    printfn "Deploying to Android device..."
    Shell.Exec("./gradlew.bat", "installDevDebug") |> ignore
)

// 特定のデバイス(25041JEGR04165)にデプロイする
Target.create "DeployToDevice" (fun _ ->
    printfn "Deploying to device 25041JEGR04165..."
    // 環境変数でデバイスを指定
    System.Environment.SetEnvironmentVariable("ANDROID_SERIAL", "25041JEGR04165")
    let result = Shell.Exec("./gradlew.bat", "installDevDebug")
    if result = 0 then
        printfn "デプロイ成功 (25041JEGR04165)"
        printfn "アプリを起動しています..."
        // アプリを起動
        let launchResult = Shell.Exec("adb", "-s 25041JEGR04165 shell am start -n jp.mamori_i.app.dev/jp.mamori_i.app.screen.start.SplashActivity")
        if launchResult = 0 then
            printfn "アプリ起動成功"
        else
            printfn "アプリ起動失敗"
    else
        printfn "デプロイ失敗"
        failwith "デプロイが失敗しました"
)

// アプリを起動する(デプロイ済みの場合)
Target.create "LaunchApp" (fun _ ->
    printfn "Starting app on device 25041JEGR04165..."
    let result = Shell.Exec("adb", "-s 25041JEGR04165 shell am start -n jp.mamori_i.app.dev/jp.mamori_i.app.screen.start.SplashActivity")
    if result = 0 then
        printfn "アプリ起動成功"
    else
        printfn "アプリ起動失敗"
        failwith "アプリ起動が失敗しました"
)

open Fake.Core.TargetOperators
// 依存関係を削除して、Defaultが単独で実行されるようにする

Target.runOrDefault "Default"

色々と書いてありますが、これは vscode 上で GitHub Copilot + Claude Sonnet を使って書き足したものです。文法はは F# なので、既存の生成 AI のモデルに学習結果が含まれています。このために、Fake の書き方も的確に書いてくれます。

vscode の拡張には、Ionide.Ionide-fsharp を入れておくと便利です。

ビルド&実機へのデプロイを試してみましょう。

fake build -t DeployToDevice

無事、ビルドとデプロイが終わると次のところまで表示がでます。

警告が出ていますが、無視して大丈夫です。

Warning: Paket resolved a FSharp.Core with version '9.0.0.0', but fake runs with a version of '8.0.0.0'. This is not supported.
Please either lock the version via 'nuget FSharp.Core <nuget-version>' or upgrade fake.
Read https://github.com/fsharp/FAKE/issues/2001 for details.

どうやら現在の環境が .NET9 なんですが、fake が .NET8 のままっぽいです。バージョンを 8 のほうに揃えれば出なくなると思いますが、これは後で。

Shell.Exec でコマンドを羅列して戻り値を見るだけなので、結構使えるはずです。難点は文法が F# なところですが、Powershell や sh をちまちま書くよりはいいかもしれません。

これは Android 版なのですが、次は Xcode で Swift 版のほうのビルドをしたいんですよね。Swift 版のほうは macOS で動かさないといけないので、VNC 経由でやるか、mac mini 用のマウスとキーボードを使うとかという処で結構面倒なのです。ビルドとテスト実行ぐらいならば windows から ssh 経由でやりたいところなので、ここはもう少し試してみる予定。

カテゴリー: 開発 | .NET開発者じゃなくても利用できるタスクランナー fake の勧め はコメントを受け付けていません

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

ベータ版ではありますが、非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 のページを追加する。 はコメントを受け付けていません