1_AIで下駄を履いてなんかすごそうなゲームやらツールを作るのではなく、まずは再学習も兼ねて身の丈に合った開発から始めた話
発端:
AIを使って、SNSでバズるような
なんだかすごそうなゲームやツールを一気に作る。
それも今ならできるのかもしれない。
ただ、自分が理解・把握していないものを実装し、
それを世に出そうという気にはなれなかった。
AIという下駄を履けば履くほど、体の重心が上がり、
ほんのちょっとしたことで崩壊してしまう恐怖に苛まれたからだった。
これに関しては色々な意見があるとは思っている。
AIの進歩によってそれすらも不要になるとか、逆に技術負債がたまって...とか。
少なくとも私個人としては、
この恐怖を飼い慣らすためには保守的に行くしかないと感じた。
だからそれをやる前に一度、
自分の手で小さなものを作るところからやり直すのが賢明と判断するに至った。
「自分が何を書いているのか理解しながら、小さな機能を一つずつ積み上げる」
ところから始めることにしたのだ。
0. まずやったこと:
まず初めにやったのは計画立案でもなんでもなく、
ChatGPTの契約プランをProからPlusへ落とすことだった。
主な理由は、今の自分にはProを十分使いこなせないと感じたから。
そんな状態で高額なプランを契約していても豚に真珠である。
ここは謙虚に行くことにした。
...あと今月誕生日だったので 誕プレ欲しさに...というのもある。
1. 今回形にしたかったもの
手始めに作ろうとしているのは、
Zenn向けの記事自動整形ツール。
私が普段note等で文章を書くときは、
比較的広めに行間を取るようにしている。
ギチギチに詰まった文章を前にして、
読者さんが読み疲れてしまいにくくするために、
せめてもの配慮をしたかったからだ。
ただ、はっきり言って手間がかかるのも事実だった。
noteだけなら見た目を調整しながら書けないこともないが、
媒体が増えると管理や整形の手間も増える。
だいいち、ZennではMarkdownの仕様も表示も異なる。
そこで、
自分が書きやすいMarkdownを、自動的にZenn向けへ変換する
小さなツールを作ってみることを題材に、学習を進めていくことにした。
いきなり記事全体を整形するものを作るのではなく、最初の機能は一つだけに絞った。
今回の対象はH2(大見出し)。
## タイトル
を検出したら、zenn向けに
<br>
<br>
## タイトル
<br>
<br>
のように前後へ<br>を2つ入れる。
まずはこれだけにした。
2. 昨日:何を作るかを決めた
直前まではpytestそのものを勉強していた。
ここまでで、
-
fixture
-
parametrize
-
tmp_path
-
基本的なassert
-
mockが必要になる理由
などはある程度見えてきていた。
過去に作った簡易スクリプトを教材にテストを書いていた。
...のだが。
...pytestの演習そのものには少し飽き始めていた。
そこで、
pytestを勉強するためにテストを書くのではなく、作りたいものをテストする
方向へ切り替えることにした。
題材として選んだのが、
先ほど話題に上げた記事整形ツール。
最初の目標は、
H2を検知する
↓
前後へ<br>を追加する
↓
出力する
↓
pytestを緑にする
...の、ここまで。
この段階では、
-
FastAPI
-
Docker
-
GitHub Actions
-
複数媒体対応
-
AI連携
などは全部後回し。
最初の一塊だけを作ることにした。
3. 今日:H2変換を実装する
ここからは、実際に書いたコードも載せて話を進めていく。
h2_candidates = ("## ", "## ")
def format_all(line: str) -> str:
if line.startswith(h2_candidates):
return format_h2(line)
else:
return line
まず、H2候補を、
h2_candidates = ("## ", "## ")
として定義した。
一応半角スペースだけでなく、
全角スペースも対象にした。
各行を読み、
if line.startswith(h2_candidates):
でH2かどうかを判定。
H2なら専用のformat_h2()へ渡し、それ以外はそのまま返す構造にした。
def format_h2(line: str) -> str:
h2 = line.replace(" ", " ", 1)
line_formatted = f"""
<br>
<br>
{h2}
<br>
<br>
"""
return line_formatted
全角スペースだった場合は、最初の一つだけ半角スペースへ置き換える。
これで、
<br>
<br>
## タイトル(半角スペース)
<br>
<br>
と、
<br>
<br>
## タイトル(全角スペース)
<br>
<br>
の両方を同じ出力へ正規化できるようにした。
4. 合わせて、テストを書く
@pytest.mark.parametrize(
"input,expected",
[
pytest.param(
"## title",
"""
<br>
<br>
## title
<br>
<br>
""",
id="h2_half_blank"
),
pytest.param("title", "title", id="no_h2"),
pytest.param("title ## ", "title ## ", id="h2_inside"),
pytest.param("### title", "### title", id="h3"),
]
)
def test_format_all(input, expected):
assert format_all(input) == expected
テストでは、
単にH2が変換されることだけを見るのではなく、
変換してはいけないものも固定する
ことにした。
parametrizeを使って、
-
半角スペースのH2
-
全角スペースのH2
-
通常の文章
-
文中にある
## -
H3
をまとめてテストした。
つまり、
# H1
→ 触らない
<br>
<br>
## H2
<br>
<br>
→ 整形する
### H3
→ 触らない
文章中の ##
→ 触らない
という仕様をテストとして固定した。
ここで以前学んだparametrizeが、
ようやく演習問題ではなく自分のコードに繋がってきた。
5. 実装後の試験:実ファイルを通したら、改行が怪しい
実際に走らせてみたところ、
単体の文字列テストだけなら問題なかった。
ただ、実際のMarkdownファイルを読み込む処理を見ると、一つ問題があった。
Pythonでファイルを一行ずつ読むと、各行の末尾には\nがついてくる。
その状態で、
-
format_h2()側でも改行を追加する -
最後に
"\n".join()でも各要素を結合する
という処理をすると、
意図した以上に改行が増える可能性があった。
最初に思いついたのは、
line = raw_line.strip("\n")
あるいはreplace()で\nを消す方法だった。
ただ、今回欲しいのは、
文字列全体を整形することではなく、ファイル読み込みによって末尾についた改行だけを消すこと。
そこで最終的には、
line = raw_line.removesuffix("\n")
を採用した。
このように。
with open(input_path, mode="r", encoding="utf-8") as text:
for raw_line in text:
line = raw_line.removesuffix("\n")
formatted_text.append(format_all(line))
これなら、
「末尾に
\nがあれば一つだけ除去する」
という意図がコードそのものに出るはず。
Markdownではインデントやスペースにも意味がある場合があるので、
余計な空白まで消さないという意味でも扱いやすい...はず。
6. 関数単体のテストだけでなく、実際の処理全体をテストした
ここで、
関数単体が正しいだけでは足りない
ことに気づいた。
実際に使うときは、
Markdownファイル
↓
読み込み
↓
各行を変換
↓
保存
↓
完成したMarkdown
という流れになる。
そこでtmp_pathを使って、
最終出力ファイルそのものを確認するテストを追加した。
def test_all_pipeline(tmp_path):
input_path = return_path("input/sample_01.md")
output_path = tmp_path / "sample_01.md"
expected_output_path = return_path("test/samples/expected_01.md")
formatted_text = process_all_lines(input_path)
save_formatted_text(output_path, formatted_text)
assert (
output_path.read_text(encoding="utf-8", newline="")
== expected_output_path.read_text(encoding="utf-8", newline="")
)
処理としては、
formatted_text = process_all_lines(input_path)
save_formatted_text(output_path, formatted_text)
を実際に通し、
最後に出力ファイルと期待値ファイルの内容を比較する。
テスト用入力には、
-
H1
-
H2
-
H3
-
通常文章
を混在させた。
期待出力では、H2だけに<br>が追加される形にした。
これによって、
「H2変換関数が正しい」
だけでなく、
「実際のMarkdownファイルを入れたら、期待しているMarkdownファイルが出てくる」
ところまで確認できるようになった。
7. ぶつかった壁と試行錯誤
今回一番大きかったのは、
H2検出そのものではなかった。
そこは比較的単純だった。
むしろ、
文字列としてテストしたときには見えなかった改行の扱い
の方が問題になった。
流れとしては、
H2変換が動く
↓
単体テストも通る
↓
実ファイルを考える
↓
「あれ、元の\nも残っているのでは?」
↓
改行の責務を整理
↓
読み込み時に末尾の\nを落とす
↓
実ファイル全体のテストを追加
となった。
もちろん最初から全部分かっていたわけではない。
作って、テストして、実際の入力に近づけたところで次の問題が見つかった。
その問題をまたテスト対象へ変えた。
8. 今日得た理解
今回一番大きかったのは、
pytestを勉強している感覚がかなり薄くなったこと。
だった。
代わりに、
作りたいものがある
↓
壊れてほしくない挙動がある
↓
それをpytestで固定する
という順番になった。
parametrizeもtmp_pathも、
覚える対象ではなく、
必要になったから使う道具
になり始めた。
また、
1行を見るだけで完結する処理
と、
ファイル全体を通して初めて確認できる処理
は別物だということも分かった。
単体テストだけでは見えない問題がある。
逆に、全部を大きなテストだけで確認する必要もない。
いわば、「マクロ」と「ミクロ」の視点。
小さいテストと、処理全体を見るテストの両方があることで安心して変更できる。
9. 今日できたもの
今日の終了時点で残ったものは、
-
H2判定
-
半角・全角スペース対応
-
H2前後への
<br>追加 -
通常文章を変更しない処理
-
H3を変更しない処理
-
ファイル読み込み
-
改行文字の除去
-
ファイル保存
-
parametrizeを使った複数パターンのテスト -
tmp_pathを使ったファイルテスト -
実入力→実出力までを見るパイプラインテスト
-
期待出力Markdown
となった。
「pytestを勉強した」だけではなく、
実際に使える小さな文章変換処理が一つ残った。
これが今回作りたかった最初の「とっかかり」になる。
10. 次に考えていること
次は、文章全体の余白を扱いたい。
候補は二つ。
連続空行の変換
自分用の記法として、
空行2個
→ <br> 1個
空行4個
→ <br> 2個
のように変換することを考えた。
咄嗟に思いついたのは、
「偶数ならそのまま、奇数なら1個除去(奇数個だった場合は切り捨て)して後続に回す」だった。
つまり、
2 → 1
3 → 1
4 → 2
5 → 2
という対応にする。
実装上は、
br_count = blank_count // 2
のように考えられそう。
必要な引用ブロック処理
やや難しそうだが、引用ブロックについてはこう実装することを考えている。
入力が、
> 引用の最初
引用の続き
さらに続く
通常文章
なら、
> 引用の最初
> 引用の続き
> さらに続く
通常文章
へ変換する。
最初の>を検知したら引用状態へ入り、
> 検知
↓
引用フラグON
↓
以後の行へ > を付ける
↓
空行を検知
↓
引用フラグOFF
という処理を考えている。
これまでのH2処理は、
一行を見れば判断できる処理
だった。
次の引用処理では、
前の行から状態を引き継ぐ処理
が必要になる。
次の一歩としてはちょうどよさそう。
11. 終わりに
AIに大きなものを作らせるところから始めるのではなく、
自分が理解できる小さな問題を、自分で切り分け、実装し、テストし、実ファイルまで通す。
最初の二日でやったのは、たったそれだけだった。
ただ、その「それだけ」を一度最後まで通せたことで、
pytest、ファイル処理、文章整形が初めて一つの成果物としてつながった。
次は、この小さな核に一つずつ機能を足しつつ、
学習を進めていくことにしようと思う。
次の記事↓
Discussion