ピースミール・テクノロジー 進地 です。
新しい連載を始めます。テーマは、チームで育てたユビキタス言語をAIに渡せるか、です。前の連載「GoでDDD」では、申請と承認のドメインをGoで実装しながらDDDを学んできました。そこで整理してきた語彙を、今度はAIに渡してみます。
想定読者はGoを書く人です。DDDやオントロジーの知識は前提にしません。前連載を読んでいなくても、この記事は読めるようにしました。
私自身、オントロジーもMCPも学びながら書いています。答えの出ていないところは、そのまま書きます。
前連載: GoでDDD 第1回
コード: shinchi-pmtech/ringi(本記事時点は ai-01 タグ。前連載との差分は article05...ai-01 の比較で確認できます。)
現場の用語は揺れている
稟議、決裁、承認、申請。同じ会議の中で、これらが混ざって飛び交うのを何度も見てきました。「あの稟議まだ決裁下りてない?」「承認依頼は出しました」。話している当人たちは困っていません。
DDDでは、こうした揺れをチームで揃えます。ユビキタス言語です。私たちも前連載で、申請、承認、差戻しという語を決めて、コードの中でも同じ語を使ってきました。
今回はその中の一語に注目します。「差戻し」です。
差戻しされた申請は、もう一度出せるのか。現場の人に聞けば即答です。出せます、直して出し直すためのものだから、と。ですが、この答えは「差戻し」という語のどこにも書かれていません。
AIに実装させてみる
ユビキタス言語として合意していた内容を、そのままAI(Claude)に渡しました。渡した文章がこれです。
// チームで合意していたドメインの説明。
// これをそのままAIに渡す。
const domainDoc = `
申請には次の状態がある。
- 下書き
- 申請中
- 承認済み
- 差戻し
申請者は下書きの申請を提出できる。
承認者は申請中の申請を承認、または差戻しできる。
承認者は申請者と同一人物であってはならない。
`
const instruction = `
上記のドメインをGoで実装してください。
状態を表す型と、状態遷移を行う関数を定義してください。
不正な遷移はエラーにしてください。
コードだけを返してください。
`
一回きりだと偶然かどうか分からないので、同じ内容を5回流して、それぞれ別ファイルに保存しました。呼び出し部分は net/http でMessages APIを叩くだけなので、リポジトリを見てください。
すべて同じ場所で間違えた
返ってきたコードは、細かいところは毎回違いました。ですが、状態の定義はほぼ同じで、5回すべてがこう書いています。
type Status int
const (
Draft Status = iota
Pending
Approved
Rejected
)
func (s Status) String() string {
switch s {
case Draft:
return "下書き"
case Pending:
return "申請中"
case Approved:
return "承認済み"
case Rejected:
return "差戻し"
default:
return "不明"
}
}
遷移のほうも見てみます。これは5回のうちの一つですが、他の回もほぼ同じです。
func (a *Application) Submit(applicantID string) error {
if a.ApplicantID != applicantID {
return errors.New("申請者本人のみ提出できます")
}
if a.Status != Draft {
return errors.New("下書き状態の申請のみ提出できます")
}
a.Status = Pending
return nil
}
func (a *Application) Reject(approverID string) error {
if a.ApplicantID == approverID {
return errors.New("差戻し者は申請者と同一人物であってはなりません")
}
if a.Status != Pending {
return errors.New("申請中状態の申請のみ差戻しできます")
}
a.Status = Rejected
return nil
}
Submit は Draft からしか通りません。Rejected になった申請は、どこにも進めなくなります。
ポイントを3つに絞ります。
1. 差戻しが終端になっている
5回すべてで、この構造でした。
一方、私たちがDDDの過程で合意していたのは次の構造です。
差戻しから戻る線が、丸ごとありません。5回とも同じです。
2. 訳した時点で決まっていた
面白かったのはここです。5回すべてが、差戻しを Rejected と名付けています。
Rejected は却下や拒否の語です。差し戻して直させる、という意味は入っていません。つまりAIは、状態遷移を実装するときに間違えたのではなく、日本語をGoの識別子に置き換えた時点で行き先を決めていました。Rejected と名付けてしまえば、そこから戻る線を引く理由はどこにもありません。
ユビキタス言語を日本語で揃えても、コードになる過程で別の語彙を通ります。意味が確定していたのは、日本語ではなくコード上の言語のほうでした。
3. それでも仕様には違反していない
渡した文章を読み返しました。「差戻された申請は再提出できる」とは、どこにも書いていません。書いていないことを実装しなかっただけで、AIは指示に忠実です。
私が「差戻し」という語に込めていた意味のうち、文字になっていた部分はごく一部でした。残りの意味は私の頭の中だけにあったのです。
揺れたのは、別のところだった
5回流したのは、結果が揺れるところを見たかったからです。実際に揺れました。ただし、予想とは違う場所でした。
申請者IDの型は毎回違います。string の named type、int、User 構造体、素の string。関数で書いた回とメソッドで書いた回もあります。この程度なら、どれでも動きます。
問題は Submit でした。「申請者は下書きの申請を提出できる」という一文を、5回のうち3回は申請者本人だけが提出できるという制約として読み、残り2回は下書き状態なら提出できるとだけ読んでいます。後者はこうなっていました。
func Submit(app *Application, applicantID int) error {
if app.Status != Draft {
return fmt.Errorf("%w: %s から 申請中 への遷移はできません",
ErrInvalidTransition, app.Status)
}
app.ApplicantID = applicantID // 提出した人が申請者になる
app.Status = Pending
return nil
}
提出した人がそのまま申請者になります。本人確認はありません。他人が勝手に提出できるかどうかという大きな違いです。
並べてみると、こうなります。
| 差戻しの扱い | Submit の解釈 | |
|---|---|---|
| 5回の結果 | 5回とも終端ステータス | 本人限定が3回、誰でも可が2回 |
私が揺れると思っていたほうは全く結果が揺れず、揺れないと思っていたほうが揺れました。
書き足せば直る。ただし名前は直らない
「差戻された申請は再提出できる」と書き足せば済む話ではないか?
確かめました。
条件を3つ用意して、それぞれ5回ずつ、計15回流しています。
- 条件A: 最初と同じ文章(対照)
- 条件B: 末尾に「差戻された申請は、修正して再提出できる。」を足す
- 条件C: 末尾に「差戻しの状態から、申請中の状態に戻ることがある。」を足す
条件Cを入れたのは、語には触れずに遷移だけを足したかったからです。名前が変わるかどうかを見たいので、「再提出」という語をあえて使いませんでした。
まず条件A。5回とも終端でした。最初の5回と合わせて10回、一度も例外が出ていません。
条件BとC。10回すべてで、差戻しから申請中に戻れるようになりました。プロンプトに書けば直ることがわかります。
問題はここからです。15回とも、状態の名前は Rejected のままでした。
つまり、こういうコードが10回続けて出てきました。
func Resubmit(app *Application, applicantID int) error {
if app.Status != StatusRejected {
return fmt.Errorf("%w: %s -> %s", ErrInvalidTransition, app.Status, StatusPending)
}
app.Status = StatusPending
return nil
}
Rejected から Pending に戻る関数です。却下されたものが申請中に戻ります。読むと引っかかるはずですが、AIは名前を直しませんでした。構造は指示すれば変わるのに、語彙は変わりませんでした。
一語でデータモデルが変わった
条件BとCの差も出ました。
| 遷移の実装 | Content(=申請内容)フィールド | |
|---|---|---|
| B(修正して再提出できる) | 専用関数が3回、Submit を Draft と Rejected の両方から通す形が2回 |
3回で追加 |
| C(申請中の状態に戻ることがある) | 5回とも専用の Resubmit
|
追加なし |
Bに入れた「修正して」の一語で、3回はデータモデルが変わりました。申請内容を持つ Content フィールドが足され、再提出のときに差し替える実装になっています。Cにはその語がないので、フィールドは増えませんでした。
実装の形も分かれました。差戻しから申請中へ戻す方法には二通りあります。Resubmit のような専用の関数を足すか、提出のための既存の関数の条件を広げるか、です。後者はこうなります。
func Submit(app *Application, applicantID int) error {
// 下書きだけでなく、差戻しからも通すようにした
if app.Status != Draft && app.Status != Rejected {
return fmt.Errorf("%w: %s から 申請中 への遷移はできません",
ErrInvalidTransition, app.Status)
}
app.ApplicantID = applicantID
app.Status = Pending
return nil
}
条件Bではこの形が2回出ました。「再提出できる」と書かれたとき、再提出を新しい操作と見るか、提出をもう一度行うことと見るかで割れたわけです。条件Cは「申請中の状態に戻る」と遷移の言葉で書いたので、5回とも専用関数になりました。
書き足す一文は、遷移だけを足しているつもりでも、モデルの形にまで影響していました。
ユビキタス言語は何でできていたのか
ここで考え込みました。
ユビキタス言語がうまく機能していたのは、語を揃えたからだと思っていました。ですが、そうではありませんでした。うまくいっていたのは、同じ現場を見ている人同士で揃えたからです。
「差戻し」と聞いて全員が再提出を思い浮かべたのは、差戻された申請が実際に手元に戻ってきて、直して出し直すのを見ていたからです。語が意味を運んでいたのではなく、共通の経験が意味を支えていて、語はその目印にすぎませんでした。
目印だけをAIに渡しても、指している先がありません。だから Rejected に着地します。
そして、遷移をいくら書き足しても、目印は Rejected のままです。構造は文章で指示できますが、語彙の選び方は指示の外にありました。
言い換えると、語彙の裏にはいつも構造がありました。何と何が区別されているのか、何が禁止されていて、何が許されているのか。この構造は、チームの中では暗黙のまま共有できます。AIはその場にいないので、明示的に渡すしかありません。
そこでこの連載の問いになります。暗黙のまま共有していた構造を、どうやって書き出してAIに渡せばいいのか?
やってみてハマったこと・迷っていること
一行足せば直る、というのは半分だけ本当でした。遷移は直りますが、名前は直りません。しかも、書き足すべき一行を、私は差戻しについては知っていました。知らない項目については書き足せません。
Submit の解釈が割れたことには、正直ひやりとしました。私はこの一文を読み直すまで、曖昧だと思っていませんでした。曖昧さは、指摘されるまで見えなかった。
一番引っかかっているのは、これは結局「仕様を漏れなく書く」という古い問題ではないのか、ということです。相手が人間でも、書いていないことは伝わりません。AI相手だから新しく起きた問題ではなく、昔からあった問題が、聞き返してくれない相手のせいで見えやすくなっただけかもしれません。
いまのところ、私はこう考えています。人間相手なら、漏れは質問として返ってきます。だから漏れたまま進むことは少ない。AIは質問しないので、漏れが埋められた形で返ってきます。そして人間は埋められたことになかなか気づけません。
とはいえ、この整理が正しいかどうかは、まだ自信がありません。次回以降で確かめていきます。
まとめ
- 語彙をそのまま渡すと、AIは書かれていない部分をそれらしく埋める。10回とも差戻しを終端にした
- 遷移は書き足せば直る。ただし名前は15回とも
Rejectedのままで、名前と構造が食い違ったコードが生成される - 書き足す一文は、遷移だけでなくモデルの形まで決めている。「修正して」の一語でフィールドが増えた
- 揺れたのは予想と別の箇所だった。複数回流して見比べる方法は、常に同じ方向に間違える誤りには効かない
次回
差戻しのあとに何が起きるか。これを文章ではなく、Goの型として書けないか試します。文章で足すと名前が置き去りになりましたが、型なら名前と構造を一緒に決められるかもしれません。
コードと15回ぶんの出力は shinchi-pmtech/ringi の ai-01 タグにあります。記事中のコードは説明のために簡略化しています。実際に動かすうえで必要な部分はリポジトリを確認してください。