LLMのファインチューニング用のデータセット作成に特化したエディタを作ってみました。
0:はじめに
この記事を読んだ後思っても絶対に書いてはいけないコメントがあります。
わざわざ作らなくても既にあるくねです。
なんとなく既にありそうな気がしながら作ってみたら案の定既にありました。
でもfletでのGUI開発の勉強になったのでヨシとします。
1:ほしい機能と心の声
- なるべくマウスを触らずにキーボードだけで基本機能を使えるようにしたい
- ショートカットキーを作って特殊トークンみたいなのを簡単に入力できるようにしたい
- エディタを起動したときにシステムプロンプトを打ち直すのがめんどい
- ファイルを毎回選択するのがめんどい
2:作業の流れ
①内部の処理の確立
②画面の構成
③なぜかうまくいかないエラーの修正
3:作ってみた
↓コード全体
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)
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:完成
完成したプログラムをpyinstallarを用いてexeファイルに書き出し簡単に扱えるようにしました。もしかしたらまだ見つかっていないバグが無数に隠れているかもしれませんが、今のところ特に問題なく使用できているのでこのまま配布しちゃおうと思います。
使い方
ほとんど直感で使えると思います。強いて書いておくことがあるとすればbutton1,2,3は飾りです。そしてsaveとloadも内部で動くようにはなっているものの、入力を確定する際に自動で行われるので使うことはほとんどないと思います。おかしなところがあったらとりあえず再起動してください。大体それで治ります。
5:今後の展望
- PCの画面比率に応じて画面の配置を自動調整する。
- 現状では1920*1080のみに最適化されてしまっています。
- データの形式をsettingで自由に設定できるようにする。
- ユーザーが簡単にこの形式以外の形にも自由に変えられるようにしたい。
とはいえ欲しかった機能はほとんど実装できたので一旦おしまいですね。
ようやくデータセットづくりに着手できるぞ!!!!