
開発に AI を小さく導入するコツをTextlint を Markdown エディタに導入して考えてみた
ブログを丁寧に書くためには文体を整える必要があります。「ら抜き言葉」を使わないとか、弱い表現を避けるとか、気にするところは結構あります。みんな気にしているかもしれないし、私はあんまり気にしてないかもしれません。
さて、ブログの執筆はやればやるほどに慣れていき、文章全体の構造や表現に気を使えるようになりますが、最初のうちはどうしても手探りです。そこでエディター側で文体などについて支援してあげることで少しでも書きやすい環境を提供して障壁を減らそうと考えました。なので、Milkdown に Textlint を組み込んでみました。
さらに、最近受講した AI DLC のワークショップで学んだ考え方を自分なりに解釈して実装してみた内容(失敗談も含めて)もご紹介します。
実装の概略
Vite ベースに動く React のアプリケーションを構築することにしました。理由としては Contentful のアプリとして動かすことを将来的なゴールと考えているので、ルーティングなどの機能が不要で薄い実装にするためです。
アプリケーション概要書
- Milkdown で入力を受け取って、それを Worker で動いている Textlint に渡す
- Worker が Textlint の結果をレスポンスとして返す
- worker.onmessage でデータを受け取って処理する
やってること自体はシンプルです。
失敗とその要因
AI DLC で大事なことは、仕様を AI と決めて、成果物の生成までの仮説検証を素早く行うことです。ですが、仕様をざっくりとしか決めておらず、成果物の生成を急いだので「Textlint の結果を Milkdown に反映させる」のような指令でコーディングをしたのが大きな失敗でした。
そもそもざっくりとした仕様ではブラウザでの動作はだいたい思い通りにいきません。ブラウザでの挙動ベースのフィードバックも文字で行うのが制約として出てしまうので、上手い指令が下せずにすればするほど複雑怪奇なコードになります。
開発の部分に適用するには仕様書を策定する AI DLC の手法は重すぎますが、仮説検証を短くできる AI ベースの開発は魅力的なので指令のフェーズを改善することにしました。
改善案
まず、全体的なアーキテクチャの設計は自分でします。自分でするのですが、AI に ProseMirror の仕様だったりを質問できるので、技術的な要件が満たせるかどうかの判断が早くなります。
そして入力に対して 1 つの操作をするコードのみ生成させる小さな指令を繰り返し出すようにしました。このアプローチのメリットは2つです。まず入力に対する1回の操作が小さくなるので出力の正しさを検証しやすくなります。2つ目は生成結果の良し悪しが 0 か 1 で決まるので捨てやすく取り入れやすい状態になります。大量のコードを書かせると部分的には良いコードができてるけど、悪いコードも混ざってしまい、手戻りするのも勿体無くてその部分だけ取り入れるみたいなことが起きてしまいます。ですが、小さな変更は良いか悪いかで簡単に分けられるのでどんどん仮設検証を回すことができます。
例えば下記のコードは Textlint のエラー結果を受けて、Milkdown の該当位置に下線を入れる実装です。処理の流れ自体は私が考えました。部分的にも大した内容ではなかったら自分で書いたりもしましたが、大方 AI に全て任せました。特に Markdown と Textlint の内容を紐づける部分については実装がどうなっているのかちゃんと読んでないのでわかりません…。ですが何故そういうロジックが必要になるかは説明がつきます。このニュアンスが AI を利用した開発のミソになる部分ではないかと思います。
editor.action((ctx) => {
// 必要となる初期値を用意する
const view = ctx.get(editorViewCtx)
// ...
// decorations を作成して、その中に装飾する位置、装飾内容を入れたい
const decorations: Decoration[] = []
// root から全ての Node を走査して処理を走らせる
doc.descendants((node, position) => {
if (!node.isText || !node.text) return
// 現在の Node が含まれる行を特定する
let currentLine: number = 0;
messages
// Textlint のメッセージで現在の Node と行数が一致するものを見つける
.filter((message) => message.line === currentLine)
// 一致したメッセージとNode を利用して処理を開始する
.forEach((message) => {
/**
* Markdownの位置からテキストノード内の位置を計算する
*/
// Decorations に位置情報とメッセージを入れる
decorations.push(
Decoration.inline(
position + adjustedColumn,
position + adjustedColumn + 1,
{
class: cn('border-b', 'border-red-300'),
title: message.message,
},
),
)
})
})
// View に作成した Decorations でイベントを発火する
const decorationSet = DecorationSet.create(doc, decorations)
view.dispatch(view.state.tr.setMeta(textlintPluginKey, decorationSet))
})
また例えばですが、位置情報の取得で、実際に試してみてズレていました。 **text**
のように太字などの装飾があると、Textlint に渡す Markdown では、記号付きでエラーの開始位置を伝えるため3文字目が text
の開始位置になります。それに対して Milkdown では装飾は別 Node への参照という形になるため、1文字目が開始位置になります。小さく試しているから原因がすぐわかり、入力値を比較して何が起きているかの把握も簡単にできました。なので実装はテストを書いて fail したから修正するみたいな流れに近いです。
つまり、最初にアーキテクチャを AI と組んで、自分の頭でコードを予測します。そこから、初めてコーディングの指令を出すようにします。仕様よりは抽象的ですが、コードとしてはより具体的なレベルに落とし込むことから始めるのです。
今までの開発でもライブラリの内部実装まではわからないものの、使い方はわかるから実装できることはよくありました。その状態が、ロジックはわかるけど実装内容はわからない状態にレイヤーが上がったイメージがあります。ライブラリは API 仕様を公開することでできることを説明してくれますが、AI による開発はそこがプロンプトになるイメージです。コードでやっていることは説明がつくけど、コードの詳細を説明するのは難しいみたいなところがゴールになります。
最終的な成果物
最終的に、Textlint を Worker で動かして、その結果を Milkdown に反映することができました。
Textlint を Worker で動かす
@textlint/script-compile というパッケージがあるので、それを利用することで Worker で動く Textlint を作り上げることができます。Textlint で使いたいルールをインストールして、 .textlintrc
を用意して下記コマンドを実行することで、 /public/textlint-worker.js
にコードが実装されます。
npx textlint-script-compiler \\
--output-dir ./public \\
--metadataName '<editor>' \\
--metadataNamespace '<http://localhost:3000/>' \\
--metadataHomepage '<http://localhost:3000/>'
メッセージを処理する
Worker を立ち上げたら下記のようにメッセージを送ることで、Textlint が実行されます。
const worker = new Worker(textlintWorker)
worker.postMessage({
command: 'lint',
text: serializer(editorView.state.doc),
ext: '.md',
})
あとは、 worker.onmessage
でイベントの処理をします。
worker.onmessage = (event: TextLintMessageEvent) => {
if (event.data.command === 'lint') {
return
}
if (!event.data.result?.messages) {
return
}
// do something ...
}
受け取るイベントの型は下記のようになっています。
event.data.command
の値で何が入るかが決まるので、そこを見て、 lint:result
だった場合は実際の実行結果を受け取れているのでその場合に値を処理します。
import z from 'zod'
export const TextLintMessageEvent = z.union([
z.object({
data: z.object({ command: z.literal('lint'), metadata: z.unknown() }),
}),
z.object({
data: z.object({
command: z.literal('lint:result'),
result: z.object({
filePath: z.string(),
messages: z.array(
z.object({
type: z.enum(['lint']),
ruleId: z.string(),
message: z.string(),
severity: z.number(),
column: z.number(),
index: z.number(),
line: z.number(),
loc: z.object({
end: z.object({ line: z.number(), column: z.number }),
start: z.object({ line: z.number(), column: z.number }),
}),
range: z.array(z.number()),
}),
),
}),
}),
}),
])
export type TextLintMessageEvent = z.infer<typeof TextLintMessageEvent>
Milkdown
まず、Plugin として扱う必要があるので、textlintPlugin を作ります。DecorationSet が装飾に関する内容です。初期化して、渡された内容を適用するだけのシンプルな内容です。
const textlintPluginKey = new PluginKey<DecorationSet>('textlint')
const textlintPlugin = $prose(() => {
return new Plugin({
key: textlintPluginKey,
state: {
init() {
return DecorationSet.empty
},
apply(tr, decorationSet) {
const newDecorationSet = tr.getMeta(textlintPluginKey)
return newDecorationSet || decorationSet.map(tr.mapping, tr.doc)
},
},
props: {
decorations(state) {
return textlintPluginKey.getState(state)
},
},
})
})
Milkdown 側では、エディタを作成する際に、エディタでの変更に合わせて Textlint を呼び出すようにします。また、作成した textlintPlugin を組み込むことで作動するようにもします。
export const Editor = () => {
const { get } = useEditor((root) => {
return MilkdownEditor.make()
.config((ctx) => {
ctx.set(rootCtx, root)
ctx.get(listenerCtx).updated((ctx) => {
const editorView = ctx.get(editorViewCtx)
const serializer = ctx.get(serializerCtx)
worker.postMessage({
command: 'lint',
text: serializer(editorView.state.doc),
ext: '.md',
})
})
})
.use(commonmark)
.use(listener)
.use(textlintPlugin)
})
そして、Web Worker からメッセージが来たら、装飾位置を見つけて施します。
このとき Textlint のエラーメッセージが、メッセージ本文、行数、開始位置と終了位置なのに対して、Milkdown の Node は行の情報を持っておらず、装飾が施されてないテキスト、次の Node、親の Node、そのテキストまでの文字数(正確には doc.descendants の第2引数として)を持っています。なのでそれらの情報を元に装飾を施すべき位置を合致させてから decorationSet
に渡す decorations
を作り上げます。最後に decorationSet
で装飾をかけたら処理は完了です。
この説明がとても大事で、私はこのように設計して説明はできますが、じゃあ実際の処理で具体的に何をしてるかは把握していません。どこまで人がしてどこからAI に任せてブラックボックスにするかの境目をはっきりさせることでうまく開発が進むのです。
export const Editor = () => {
const { get } = useEditor((root) => {})
worker.onmessage = (event: TextLintMessageEvent) => {
const messages = event.data.result.messages
const editor = get()
editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
const serializer = ctx.get(serializerCtx)
const doc = view.state.doc
const markdown = serializer(doc)
const lines = markdown.split('\n')
const decorations: Decoration[] = []
doc.descendants((node, position) => {
if (!node.isText || !node.text) return
// 現在のノードが含まれる行を特定
let currentLine = 0
for (let i = 0; i < lines.length; i++) {
if (lines[i].includes(node.text)) {
currentLine = i + 1
break
}
}
messages
.filter((message) => message.line === currentLine)
.forEach((message) => {
// Markdownの位置からテキストノード内の位置を計算
const markdownLine = lines[currentLine - 1]
const textInLine = node.text ?? ''
const textStart = markdownLine.indexOf(textInLine)
if (textStart !== -1 && message.column > textStart) {
const adjustedColumn = message.column - textStart - 1
if (adjustedColumn >= 0 && adjustedColumn < textInLine.length) {
decorations.push(
Decoration.inline(
position + adjustedColumn,
position + adjustedColumn + 1,
{
class: cn('border-b', 'border-red-300'),
title: message.message,
},
),
)
}
}
})
})
const decorationSet = DecorationSet.create(doc, decorations)
view.dispatch(view.state.tr.setMeta(textlintPluginKey, decorationSet))
})
}
return <Milkdown />
}
やってみて
とりあえず、中間生成物を細かくコミットするのは大事だなぁと思いました。色々作りすぎた時に綺麗に巻き戻すのが難しくなるときがあるので、コミット粒度を小さくセーブポイントをいっぱい設けます。また、コミットも AI にやらせればいい感じにメッセージ書いてくれてなお良いです。
また、設計は AI に質問しながらできるので自分一人で調べながらやるより高速で、実際の実装をしての検証も高速なので全体的に素早くコーディングができました。
最後に、おさらいして終わりにします。
- AI と一緒にどのようなアーキテクチャで進めるか設計して擬似的なコードを思い浮かべる
- 処理に関して小さく小さく分割してそれを実装させる
- コンテキストとタスクを小さくする
- 実装に1つづつ丁寧にテストを繰り返してオールグリーンになることを目指す
- テストケースも AI と考えて書かせることもできそう
- ブラックボックスな領域がライブラリからコード詳細まで上がってきた感覚
参考