ファインチューニング用データセット作成エディタを作ってみた

LLMのファインチューニング用のデータセット作成に特化したエディタを作ってみました。

## 0:はじめに
この記事を読んだ後思っても絶対に書いてはいけないコメントがあります。
**わざわざ作らなくても既にあるくね**です。
なんとなく既にありそうな気がしながら作ってみたら案の定既にありました。
でもfletでのGUI開発の勉強になったのでヨシとします。

## 1:ほしい機能と心の声
- なるべくマウスを触らずにキーボードだけで基本機能を使えるようにしたい
- ショートカットキーを作って特殊トークンみたいなのを簡単に入力できるようにしたい
- エディタを起動したときにシステムプロンプトを打ち直すのがめんどい
- ファイルを毎回選択するのがめんどい

![完成図.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3921462/95f6eb64-7e37-392c-7b53-ae41077a8a1c.png)
こんな感じになればいいな

## 2:作業の流れ
　①内部の処理の確立
　②画面の構成
　③なぜかうまくいかないエラーの修正
 
## 3:作ってみた
↓コード全体
<details>

~~~
import flet as ft
import json
import os

SETTINGS_FILE = "settings.json"

def load_settings():
    if os.path.exists(SETTINGS_FILE):
        with open(SETTINGS_FILE, 'r', encoding='utf-8') as file:
            return json.load(file)
    return {"file_path": "ファイルが選択されていません", "system_prompt": "初期値", "ctrl1": "初期値", "ctrl2": "初期値", "ctrl3": "初期値", "ctrl4": "初期値", "ctrl5": "初期値"}

def save_settings(settings):
    try:
        with open(SETTINGS_FILE, 'w', encoding='utf-8') as file:
            json.dump(settings, file, ensure_ascii=False)
    except Exception as e:
        print(f"Error saving settings: {e}")

def main(page: ft.Page):
    page.title = "jsonl-Note"
    page.window_maximized = True  # ウィンドウを最大化

    # 設定の読み込み
    
    settings = load_settings()
    file_path = settings["file_path"]
    mid_text_box_value = settings["system_prompt"]
    ctrl1 = settings["ctrl1"]
    ctrl2 = settings["ctrl2"]
    ctrl3 = settings["ctrl3"]
    ctrl4 = settings["ctrl4"]
    ctrl5 = settings["ctrl5"]
    
    # 関数定義
    focused_element = None
    def on_focus(e):
        nonlocal focused_element
        focused_element = e.control  # フォーカスされたテキストボックスを記録

    # UI要素の初期化
    mid_text_box = ft.TextField(value=mid_text_box_value, label="system prompt", multiline=True, min_lines=3, max_lines=3, on_focus=on_focus)
    left_text_box_1 = ft.TextField(label="user", multiline=True, min_lines=14, max_lines=14, expand=True, on_focus=on_focus)
    left_text_box_2 = ft.TextField(label="assistant", multiline=True, min_lines=14, max_lines=14, expand=True, on_focus=on_focus)
    right_text_box = ft.TextField(expand=True, label="view", multiline=True, min_lines=30, max_lines=30, on_focus=on_focus)

    # ファイルパス表示用テキスト
    file_path_text = ft.Text(
    file_path,
    size=16,
    weight="bold",
    color="#606060" # 濃い目のグレーを指定
    )
    
    # デバッグ用
    key_info_text = ft.Text()

    # データ関連の関数
    def load_jsonl_file(file_path):
        if not os.path.exists(file_path):  # ファイルが存在しない場合
            page.snack_bar = ft.SnackBar(ft.Text("指定されたファイルが見つかりません。"))
            page.snack_bar.open = True
            page.update()
            return []  # 空のリストを返す
        data_list = []
        with open(file_path, 'r', encoding='utf-8') as file:
            for line in file:
                data_list.append(json.loads(line))
        return data_list

    def save_jsonl_file(file_path, data_list):
        with open(file_path, 'w', encoding='utf-8') as file:
            for item in data_list:
                file.write(json.dumps(item, ensure_ascii=False) + '\n')

    # 保存ボタンを押したときの処理
    def save_data(e):
        try:
            updated_json_data = [json.loads(line) for line in right_text_box.value.splitlines() if line.strip()]
            save_jsonl_file(file_path, updated_json_data)
        except json.JSONDecodeError:
            page.snack_bar = ft.SnackBar(ft.Text("無効なJSONフォーマットです！"))
            page.snack_bar.open = True
        page.update()

    # JSONLデータの初期設定
    def load_data(e):
        if file_path == "ファイルが選択されていません":
            page.snack_bar = ft.SnackBar(ft.Text("ファイルが選択されていません。ファイルを選択してください。"))
            page.snack_bar.open = True
            return
        json_data = load_jsonl_file(file_path)
        json_text = "\n".join([json.dumps(item, ensure_ascii=False) for item in json_data])
        right_text_box.value = json_text  # right_text_boxを更新
        page.update()  # ページを更新して変更を表示

    def write_to_jsonl():
        system_content = mid_text_box.value
        user_content = left_text_box_1.value
        assistant_content = left_text_box_2.value

        data = {
            "messages": [
                {"role": "system", "content": system_content}, 
                {"role": "user", "content": user_content},
                {"role": "assistant", "content": assistant_content}
            ]
        }

        with open(file_path, 'a', encoding='utf-8') as f:
            f.write(json.dumps(data, ensure_ascii=False) + '\n')
        left_text_box_1.value = ''
        left_text_box_2.value = ''

    def allRstrip():
        left_text_box_1.value = left_text_box_1.value.rstrip("\n")
        left_text_box_2.value = left_text_box_2.value.rstrip("\n")
        mid_text_box.value = mid_text_box.value.rstrip("\n")
        right_text_box.value = right_text_box.value.rstrip("\n")

    def on_keyboard(e: ft.KeyboardEvent):
        key_info_text.value = f"Key: {e.key}, Shift: {e.shift}, Control: {e.ctrl}, Alt: {e.alt}"
        if e.shift and e.key == "Enter":
            if focused_element == left_text_box_1:
                left_text_box_1.value += "\n"
            elif focused_element == left_text_box_2:
                left_text_box_2.value += "\n"
            elif focused_element == mid_text_box:
                mid_text_box.value += "\n"
            elif focused_element == right_text_box:
                right_text_box.value += "\n"
        elif not e.shift and e.key == "Enter":
            e.prevent_default_action = True
            if focused_element == left_text_box_1:
                left_text_box_2.focus()
            elif focused_element == left_text_box_2:
                allRstrip()
                write_to_jsonl()
                load_data(None)
                left_text_box_1.focus()
            elif focused_element == mid_text_box:
                left_text_box_1.focus()
        #ショートカットキーの設定
        if e.ctrl and e.key == "1":
            focused_element.value += ctrl1
        if e.ctrl and e.key == "2":
            focused_element.value += ctrl2
        if e.ctrl and e.key == "3":
            focused_element.value += ctrl3
        if e.ctrl and e.key == "4":
            focused_element.value += ctrl4
        if e.ctrl and e.key == "5":
            focused_element.value += ctrl5
        
        page.update()

    def on_file_picked(e):
        nonlocal file_path
        if e.files:
            file_path = e.files[0].path
            file_path_text.value = file_path  # ファイルパス表示用テキストを更新
            load_data(None)  # データを読み込む
        else:
            page.snack_bar = ft.SnackBar(ft.Text("ファイルが選択されませんでした。"))
            page.snack_bar.open = True
        page.update()

    def open_settings_overlay(e):
        margin = 15
        list_view = ft.ListView(
            controls=[
                ft.Container(
                    content=ft.Text("default setting",size=22, weight=ft.FontWeight.BOLD),
                    margin=ft.Margin(left=0, top=0, right=0, bottom=margin)  # 下にマージンを追加
                ),
                ft.Container(
                    content=ft.TextField(label="Default File Path", value=file_path, multiline=False),
                    margin=ft.Margin(left=0, top=0, right=0, bottom=margin)  # 下にマージンを追加
                ),
                ft.Container(
                    content=ft.TextField(label="Default System Prompt", value=mid_text_box.value, multiline=True, min_lines=5, max_lines=5),
                    margin=ft.Margin(left=0, top=0, right=0, bottom=margin)  # 下にマージンを追加
                ),
                ft.Container(
                    content=ft.Text("shortcutKeys",size=22, weight=ft.FontWeight.BOLD),
                    margin=ft.Margin(left=0, top=0, right=0, bottom=margin)  # 下にマージンを追加
                ),
                ft.Container(
                    content=ft.TextField(label="ctrl + 1", value=ctrl1, multiline=True, min_lines=1, max_lines=1),
                    margin=ft.Margin(left=0, top=0, right=0, bottom=margin)  # 下にマージンを追加
                ),
                ft.Container(
                    content=ft.TextField(label="ctrl + 2", value=ctrl2, multiline=True, min_lines=1, max_lines=1),
                    margin=ft.Margin(left=0, top=0, right=0, bottom=margin)  # 下にマージンを追加
                ),
                ft.Container(
                    content=ft.TextField(label="ctrl + 3", value=ctrl3, multiline=True, min_lines=1, max_lines=1),
                    margin=ft.Margin(left=0, top=0, right=0, bottom=margin)  # 下にマージンを追加
                ),
                ft.Container(
                    content=ft.TextField(label="ctrl + 4", value=ctrl4, multiline=True, min_lines=1, max_lines=1),
                    margin=ft.Margin(left=0, top=0, right=0, bottom=margin)  # 下にマージンを追加
                ),
                ft.Container(
                    content=ft.TextField(label="ctrl + 5", value=ctrl5, multiline=True, min_lines=1, max_lines=1)
                ),  
            ],
            width=600,
            height=400,
            padding=10,
        )
        
        # 設定用のダイアログを作成
        settings_dialog = ft.AlertDialog(
            modal=False,
            title=ft.Text("Settings", weight=ft.FontWeight.BOLD),
            content=list_view,  
            actions=[
                ft.TextButton("Save", on_click=lambda e: save_settings_and_close(settings_dialog))
            ],
            actions_alignment=ft.MainAxisAlignment.END
        )
        
        page.dialog = settings_dialog
        settings_dialog.open = True
        page.update()


    def save_settings_and_close(dialog):
        column_controls = dialog.content.controls

        new_file_path = column_controls[1].content.value 
        new_system_prompt = column_controls[2].content.value 
        new_ctrl1 = column_controls[4].content.value  
        new_ctrl2 = column_controls[5].content.value  
        new_ctrl3 = column_controls[6].content.value 
        new_ctrl4 = column_controls[7].content.value  
        new_ctrl5 = column_controls[8].content.value  

        new_settings = {"file_path": new_file_path, "system_prompt": new_system_prompt, "ctrl1": new_ctrl1, "ctrl2": new_ctrl2, "ctrl3": new_ctrl3, "ctrl4": new_ctrl4, "ctrl5": new_ctrl5}
        save_settings(new_settings)  
            # UIコンポーネントに新しい設定を反映
        file_path_text.value = new_file_path
        mid_text_box.value = new_system_prompt

        # ショートカットキーも更新
        nonlocal ctrl1, ctrl2, ctrl3, ctrl4, ctrl5
        ctrl1, ctrl2, ctrl3, ctrl4, ctrl5 = new_ctrl1, new_ctrl2, new_ctrl3, new_ctrl4, new_ctrl5
        
        dialog.open = False 

        page.update()  

    # FilePickerウィジェットのインスタンス作成
    file_picker = ft.FilePicker(on_result=on_file_picked)
    page.overlay.append(file_picker)
    page.on_keyboard_event = on_keyboard

    # UIの設定
    incontainer = ft.Container(
        content=ft.Row(
        controls=[
            ft.ElevatedButton("file", on_click=lambda e: file_picker.pick_files(), width=100, height=35),
            ft.ElevatedButton("setting", on_click=open_settings_overlay, width=100, height=35),  
            ft.ElevatedButton("button1", width=100, height=35),
            ft.ElevatedButton("button2", width=100, height=35),
            ft.ElevatedButton("button3", width=100, height=35),
            file_path_text
            ],
    alignment=ft.MainAxisAlignment.START
    ),
    padding=10,  
    margin=ft.Margin(left=100, top=0, right=0, bottom=0),
    )
    buttons_row = ft.Container(
        content = incontainer,
        bgcolor=ft.colors.BLACK12,
        margin=ft.Margin(top=0, right=0, bottom=0, left=0) 
    )

    left_column = ft.Column([left_text_box_1, left_text_box_2], spacing=20, expand=True)
    prompt_box = ft.Column([mid_text_box, ft.Row(controls=[left_column, right_text_box])], expand=True)
    bottom_buttons = ft.Row(controls=[ft.ElevatedButton("load", on_click=load_data, width=100, height=30),
                                      ft.ElevatedButton("Save", on_click=save_data, width=100, height=30)],
                                        alignment=ft.MainAxisAlignment.END)

    layout = ft.Container(
    content=ft.Column(
        controls=[ prompt_box, bottom_buttons],
        expand=True
    ),
    margin=ft.Margin(top=0, right=100, bottom=50, left=100)  # 上側の余白をなし、他の辺に20ピクセルの余白を設定
   # すべての辺に20ピクセルの余白を設定
    )

    layout2 = ft.Column(
        [
            buttons_row,
            ft.Container(height=5),
            layout
        ]
    )
    load_data(None)  # 最初にデータを読み込む
    page.add(layout2)

# アプリを起動
ft.app(target=main)

~~~

</details>

**globalとnonlocalは完全に別物！**
settingの保存がなぜか反映されないバグに見舞われましたが原因は何も考えずに使われたglobalでした。浅い理解ですがローカルにはないがグローバルでもない場合はglobalではなくnonlocalを使うといいそうです。

~~~
def save_settings_and_close(dialog):
        column_controls = dialog.content.controls

        new_file_path = column_controls[1].content.value 
        new_system_prompt = column_controls[2].content.value 
        new_ctrl1 = column_controls[4].content.value  
        new_ctrl2 = column_controls[5].content.value  
        new_ctrl3 = column_controls[6].content.value 
        new_ctrl4 = column_controls[7].content.value  
        new_ctrl5 = column_controls[8].content.value  

        new_settings = {"file_path": new_file_path, "system_prompt": new_system_prompt, "ctrl1": new_ctrl1, "ctrl2": new_ctrl2, "ctrl3": new_ctrl3, "ctrl4": new_ctrl4, "ctrl5": new_ctrl5}
        save_settings(new_settings)  
            # UIコンポーネントに新しい設定を反映
        file_path_text.value = new_file_path
        mid_text_box.value = new_system_prompt

        # ショートカットキーも更新(ここでミスってglobal使ってた)
        nonlocal ctrl1, ctrl2, ctrl3, ctrl4, ctrl5
        ctrl1, ctrl2, ctrl3, ctrl4, ctrl5 = new_ctrl1, new_ctrl2, new_ctrl3, new_ctrl4, new_ctrl5
        
        dialog.open = False 

        page.update()  
~~~

**なぜかオフにできないEnterキー**
Shift+Enterで改行、Enterだけで入力を確定し次のテキストボックスへ移動といった動作をするようにしたかったのですが **Enterだけを入力した場合も改行されてしまう!!**
色々と試してみましたがどうしてもうまくいかなかったので保存する直前で入力の最後の改行コード部分は消去するようにしました。
~~~
def allRstrip():
        left_text_box_1.value = left_text_box_1.value.rstrip("\n")
        left_text_box_2.value = left_text_box_2.value.rstrip("\n")
        mid_text_box.value = mid_text_box.value.rstrip("\n")
        right_text_box.value = right_text_box.value.rstrip("\n")
~~~

GPT先生に手伝ってもらいながら書き進めていきましたが、学生の私には有料版なんて使えません。書き始めは適当に投げてもどうにかしてくれましたが、コードが長くなってきた終盤はろくな答えが返ってこなくなってきたので（多分私のプロンプトが悪いだけ）公式ドキュメントとにらめっこです。
https://flet.dev/docs/


## 4:完成
![スクリーンショット 2024-10-30 011030.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3921462/6db2a961-d295-97d4-e917-7d76407dc8aa.png)
完成したプログラムをpyinstallarを用いてexeファイルに書き出し簡単に扱えるようにしました。もしかしたらまだ見つかっていないバグが無数に隠れているかもしれませんが、今のところ特に問題なく使用できているのでこのまま配布しちゃおうと思います。

https://github.com/if-milktea/jsonl-Note
### 使い方
ほとんど直感で使えると思います。強いて書いておくことがあるとすればbutton1,2,3は飾りです。そしてsaveとloadも内部で動くようにはなっているものの、入力を確定する際に自動で行われるので使うことはほとんどないと思います。おかしなところがあったらとりあえず再起動してください。大体それで治ります。

## 5:今後の展望
- PCの画面比率に応じて画面の配置を自動調整する。
    - 現状では1920*1080のみに最適化されてしまっています。
- データの形式をsettingで自由に設定できるようにする。
    - ユーザーが簡単にこの形式以外の形にも自由に変えられるようにしたい。

とはいえ欲しかった機能はほとんど実装できたので一旦おしまいですね。
ようやくデータセットづくりに着手できるぞ！！！！