766 lines
45 KiB
Markdown
766 lines
45 KiB
Markdown
# ComfyUI-ScriptFlow(日本語版)
|
||
[[en](README.md) | ja]
|
||
|
||
---
|
||
|
||
**Version:** 1.3.5
|
||
**License:** GPL-3.0
|
||
|
||
System One の判断モデルを使える、安全な Python 風スクリプトノードです。
|
||
TypeSafe AI の Jev API(フォールバックとしてローカルの GGUF LLM)に Yes/No・選択・スコアの質問をして、その答えを `if` 文の中でそのまま使えます。
|
||
|
||

|
||
|
||
デモ:`MultiOutputScript (Jev)` が TypeSafe API を使ってテキストプロンプトに合うアスペクト比を選び、各比率の確率を一覧にして、幅と高さを画像ノードに渡しています。
|
||
|
||
デモを簡略化した例です。テキストプロンプトを `in_text_1` につなぐと、その答えで幅と高さが決まります。
|
||
|
||
```python
|
||
if jev.yes(it1, "Does a portrait orientation suit this prompt?"):
|
||
ov1, ov2 = 832, 1280
|
||
else:
|
||
ov1, ov2 = 1280, 832
|
||
```
|
||
|
||
System One モデルはテキストを生成せず、1 回の高速な処理で確率として答えを返します。そのため、答えをそのままワークフローの判断に使えます。
|
||
|
||
複数の入出力により、計算・論理判断・テキスト編集・条件分岐・解像度の選択・ファイル名の生成などを 1 つのノードで扱えます。これまで複数の基本ノードを組み合わせていた処理をまとめられます。スクリプトは制限付きのインタープリタで実行され、ファイルや OS にはアクセスしません。
|
||
|
||
## インストール
|
||
|
||
1. `ComfyUI/custom_nodes/` ディレクトリに移動します。
|
||
2. このリポジトリをクローンします。
|
||
```bash
|
||
git clone https://github.com/kantan-kanto/ComfyUI-ScriptFlow
|
||
```
|
||
3. ComfyUI を再起動します。
|
||
|
||
`MultiOutputScript` と `centi` ノードに追加のパッケージは不要です。`MultiOutputScript (Jev)` は、使うバックエンドに応じて以下のセットアップが必要です。
|
||
|
||
### `MultiOutputScript (Jev)` のセットアップ
|
||
|
||
#### TypeSafe APIを使う場合
|
||
|
||
1. ComfyUI を実行している Python 環境に SDK をインストールします。
|
||
```bash
|
||
pip install typesafe-sdk
|
||
```
|
||
2. このカスタムノードの実際のインストール先ディレクトリに `api_key.txt` を作成し、TypeSafe の API キーを記入します(1 行、引用符なし)。
|
||
```
|
||
ComfyUI/custom_nodes/<your-installation-folder>/api_key.txt
|
||
```
|
||
キーは実行のたびにこのファイルから読み込まれます。`api_key.txt` は `.gitignore` に登録されています。コミットしないでください。
|
||
|
||
#### ローカルモデル(フォールバック)を使う場合
|
||
|
||
- ComfyUI を実行している Python 環境に [llama-cpp-python](https://github.com/JamePeng/llama-cpp-python) をインストールします。JamePeng 版と、本家の [abetlen/llama-cpp-python](https://github.com/abetlen/llama-cpp-python) 0.3.35 で動作を確認しています。
|
||
- 指示追従型(instruction-tuned)の GGUF モデルを `ComfyUI/models/LLM`(または `models/text_encoders`)に置きます。GGUF にはチャットテンプレート(`tokenizer.chat_template` メタデータ)が含まれている必要があります。ほとんどの指示追従型 GGUF には含まれています。Qwen3.5-9B Q8_0 と Gemma-4-E4B Q8 で動作を確認しています。
|
||
- Cloudflare のオープンウェイトの判断モデル [Clef](https://huggingface.co/Cloudflare/clef-flash) を使う場合は、llama.cpp ネイティブ形式の Clef の GGUF([ggml-org/Clef-Flash-GGUF](https://huggingface.co/ggml-org/Clef-Flash-GGUF) など)を同じフォルダに置きます。JamePeng 版の llama-cpp-python 0.4.2 以降が必要です。通常のチャットモデルとして変換された Clef の GGUF は汎用モデルとして扱われ、Clef の判断ヘッドは使われません。Windows(Intel Arc、SYCL ビルド)の Clef-Flash Q8_0 で動作を確認しています。Linux と macOS では未確認です。
|
||
- Liquid AI のオープンウェイトの判断モデル [d1](https://huggingface.co/LiquidAI/d1-3B) を使う場合は、d1 の GGUF([LiquidAI/d1-3B-GGUF](https://huggingface.co/LiquidAI/d1-3B-GGUF) など)を同じフォルダに置きます。LFM2 モデルを読み込める llama-cpp-python が必要です。`mmproj` ファイルは不要です。Windows(Intel Arc、SYCL ビルド)の d1-3B Q8_0 と JamePeng 版の llama-cpp-python 0.4.2 で動作を確認しています。Linux と macOS では未確認です。
|
||
|
||
## 主な機能
|
||
- 複数の入力:数値とテキストの入力を自由に組み合わせられます。
|
||
- 計算と論理:四則演算、比較、簡単な条件分岐に対応します。
|
||
- 複数の出力:結果を別々の出力ポートから下流のノードに渡せます。
|
||
- AI による判断:`MultiOutputScript (Jev)` で `jev.yes` / `jev.choice` / `jev.score` を確率付きで使えます。TypeSafe AI の Jev API、またはフォールバックとしてローカルの GGUF モデルを使います。
|
||
- 安全な実行:スクリプトは構文を制限した AST インタープリタで実行され、ファイルや OS にはアクセスしません。モデルや TypeSafe API にアクセスするのは、Jev ノードの `jev` 関数だけです。
|
||
|
||
## 使用例
|
||
- Wan2.2 の I2V ワークフローで、入力画像が縦長か横長かに応じて推奨解像度
|
||
(512×384 または 384×512)を自動で選ぶ。
|
||
- ファイル名をまとめて生成する(例:ローカル LLM のプロンプト履歴、出力ログ)。
|
||
- 条件分岐でワークフローの動作を制御する(例:入力に応じてパラメータや経路を切り替える)。
|
||
- LLM の出力を解析・変換し、下流のノード向けの構造化された値にする。
|
||
- シード、しきい値、倍率などの数値パラメータを、独自の計算で動的に生成する。
|
||
- 生成前に、テキストプロンプトに合うアスペクト比とサイズを、各比率の確率付きで選ぶ(Jev ノードの初期スクリプト)。
|
||
- テキストの意味でワークフローを振り分ける(例:プロンプトが夜の情景かを判定する、主題を分類する)。
|
||
|
||
## ノード
|
||
|
||
### `MultiOutputScript`
|
||
**Category:** `utils`
|
||
|
||
入力(任意、接続可能):
|
||
- `in_text_1`(任意の入力を受け付け、実行時に `str` か検証)
|
||
- `in_text_2`(任意の入力を受け付け、実行時に `str` か検証)
|
||
- `in_text_3`(任意の入力を受け付け、実行時に `str` か検証)
|
||
- `in_value_1`(任意の入力を受け付け、実行時に `int` または `float` か検証)
|
||
- `in_value_2`(任意の入力を受け付け、実行時に `int` または `float` か検証)
|
||
- `in_value_3`(任意の入力を受け付け、実行時に `int` または `float` か検証)
|
||
- `code`(複数行の文字列)
|
||
|
||
出力:
|
||
- `out_text_1`(STRING)
|
||
- `out_text_2`(STRING)
|
||
- `out_text_3`(STRING)
|
||
- `out_value_1`(INT)
|
||
- `out_value_2`(INT)
|
||
- `out_value_3`(INT)
|
||
|
||
数値出力についての注意:
|
||
- 出力は `INT` です。
|
||
- 整数入力から `FLOAT` の値が必要な場合は、`centi` ノードを使ってください。
|
||
- 値が小数の場合、出力時に小数部分は切り捨てられます。
|
||
- 四捨五入や切り上げをしたい場合は、スクリプト内で処理してください(例:`round(...)`、`math.ceil(...)`)。
|
||
|
||
### スクリプト変数
|
||
`code` の中では次の変数を使います。
|
||
- 入力:`it1`、`it2`、`it3`、`iv1`、`iv2`、`iv3`
|
||
- 出力:`ot1`、`ot2`、`ot3`、`ov1`、`ov2`、`ov3`
|
||
|
||
代入しなかった出力は `None` になります。
|
||
|
||
### スクリプト例
|
||
```python
|
||
# Example: keep landscape at 512x384, portrait at 384x512
|
||
# Connect GetImageSize width/height to in_value_1/in_value_2
|
||
# iv1: image width, iv2: image height
|
||
w, h = iv1, iv2
|
||
ov1, ov2 = (512, 384) if w >= h else (384, 512)
|
||
```
|
||
|
||
### `MultiOutputScript (Jev)`
|
||
**Category:** `utils`
|
||
|
||
`MultiOutputScript` と同じ入力・出力・スクリプトの規則に加えて、`jev` 名前空間を使えます。TypeSafe AI の判断モデルである [Jev](https://typesafe.ai) に、テキストについての型付きの質問をし、その答えでスクリプトを分岐できます。
|
||
|
||
- **TypeSafe API**(基本):質問をインターネット経由で Jev に送ります。
|
||
- **ローカル GGUF モデル**(フォールバック):Jev API を使えない場合や、テキストを手元のマシンから出したくない場合に使います。汎用の指示追従型モデルで Jev 風の判断を近似するもので、[SemIf (OpenJev)](https://github.com/TheoLeeCJ/SemIf-OpenJev) の方式に従い、文字を割り当てた選択肢の次トークン確率を 1 回の順伝播で読み取ります。テキストは生成せず、ネットワークにも何も送りません。答えや確率は Jev とは異なります。
|
||
- **ローカルの Clef モデル**:[Clef](https://huggingface.co/Cloudflare/clef-flash) は、Jev と同じ種類の質問に答えるオープンウェイトの判断モデルです。GGUF のメタデータ `general.architecture` が `clef` のモデルを、Clef として扱います。Clef の GGUF を選ぶと、上の近似の代わりにモデルの判断ヘッドを使い、同じ `state` についての質問すべてに 1 回の順伝播で答えます。`state` と質問を Clef の入力書式に並べ、判断ヘッドが出す選択肢ごとのスコアを、質問ごとに softmax で確率にします。文章は生成せず、ネットワークにも何も送信しません。答えと確率は Jev とは異なります。
|
||
- **ローカルの d1 モデル**:[d1](https://huggingface.co/LiquidAI/d1-3B) は、Jev と同じ種類の質問に答えるオープンウェイトの判断モデルです。GGUF のメタデータ `lfm2.decision.type` が `lfm2-d1` のモデルを、d1 として扱います。d1 の GGUF を選ぶと、上の文字を割り当てた選択肢の代わりに、d1 が学習したプロンプト形式で質問します。答えは、d1 が学習した返答の次トークン確率から読み取ります。`jev.noul` では yes か no、`jev.choice` では選択肢のコード、`jev.score` では数字です。文章は生成せず、ネットワークにも何も送信しません。答えと確率は Jev とは異なります。
|
||
|
||
スクリプトはどれでも同じです。各バックエンドのセットアップは[インストール](#インストール)を参照してください。
|
||
|
||
追加の入力:
|
||
- `model`:`TypeSafe API`、またはローカルの GGUF モデル(`mmproj-*` ファイルは表示されません)。
|
||
- `max_jev_calls`(INT、初期値 8):1 回の実行での Jev リクエストの上限です。同じ `state` についての質問は 1 回のリクエストにまとめて送るため([質問のまとめ送り](#質問のまとめ送り)を参照)、質問数ではなくリクエスト数を制限します。上限を超えるリクエストは送る前にエラーで止まります。同じ実行の中で同じ質問は 1 回しか問い合わせません。
|
||
|
||
関数(`state` は文字列・辞書・リスト。テキストのみ):
|
||
- `jev.yes(state, question[, threshold])` → `bool`(Yes の確率が `threshold` 以上か。初期値 `0.5`)
|
||
- `jev.noul(state, question)` → Yes の確率(`0.0`〜`1.0`)。しきい値と比較して使ってください。そのまま条件に使うとエラーになります。
|
||
- `jev.choice(state, question, options)` → 最も確からしい選択肢(`str`)。`options` はラベルのリスト、またはラベルから説明への辞書です(汎用のローカルモデルでは 2〜16 個)。
|
||
- `jev.probabilities(state, question, options)` → 各選択肢とその確率の `dict`(合計 `1.0`)。`options` は `jev.choice` と同じです。同じ引数で両方を呼んでも、問い合わせは 1 回です。
|
||
- `jev.score(state, question, levels)` → 順序付きの段階の説明に対する期待値(`float`、`0`〜`len(levels) - 1`)(汎用のローカルモデルでは 2〜16 段階)
|
||
|
||
```python
|
||
# it1: a prompt text
|
||
if jev.yes(it1, "Does this prompt describe a night scene?", 0.7):
|
||
ov1 = 30
|
||
else:
|
||
ov1 = 20
|
||
|
||
kind = jev.choice(it1, "Which orientation suits this prompt?", ["portrait", "landscape"])
|
||
ov2, ov3 = (384, 512) if kind == "portrait" else (512, 384)
|
||
|
||
probs = jev.probabilities(it1, "Which orientation suits this prompt?", ["portrait", "landscape"])
|
||
ot1 = f"{kind} (portrait: {probs['portrait']:.3f}, landscape: {probs['landscape']:.3f})"
|
||
```
|
||
|
||
ノードの初期スクリプトは、`it1` のプロンプトに合う 8 種類のアスペクト比から 1 つを選び、`in_value_1` のメガピクセル数(未接続時は `1.0`。幅と高さは 64 の倍数に丸め)に合わせてサイズを決めます。幅と高さを `out_value_1` / `out_value_2` に、各比率の確率を `out_text_1` に出力します。
|
||
|
||
各関数は 1 つの質問をします。[TypeSafe Python SDK](https://pypi.org/project/typesafe-sdk/) で書いた場合のコードとの対応を示します。
|
||
|
||
| ScriptFlow | TypeSafe SDK |
|
||
| --- | --- |
|
||
| `jev.noul(state, q)` | `client.system_one(state=state, questions={"q": Noul(instructions=q)}).answers["q"].noul` |
|
||
| `jev.yes(state, q, t)` | `... .answers["q"].noul >= t` |
|
||
| `jev.choice(state, q, options)` | `client.system_one(state=state, questions={"q": Choice(instructions=q, criteria=options)}).answers["q"].choice` |
|
||
| `jev.probabilities(state, q, options)` | `... .answers["q"].probabilities`(`jev.choice` と同じリクエスト) |
|
||
| `jev.score(state, q, levels)` | `client.system_one(state=state, questions={"q": Score(instructions=q, criteria=levels)}).answers["q"].score` |
|
||
|
||
リストで渡した `options` は `criteria={label: None, ...}` として送られます。`confidence`、Score の段階ごとの確率、Noul の `criteria` は利用できません。
|
||
|
||
#### 質問のまとめ送り
|
||
|
||
質問は可能な範囲でまとめて送られます。
|
||
|
||
1. スクリプトの実行中、`jev` の質問はその場では送らず、順に溜めておきます。
|
||
2. 答えを変数やリストに入れる、自分で定義した関数に渡す、`float()` を通す、といった使い方なら実行を続けます。`if` の条件、比較、計算、f 文字列、`sorted`、`join`、出力などで答えの値を初めて使ったところで、実行を止めます。
|
||
3. 溜めた質問を `state` ごとに 1 回のリクエストで送り、スクリプトを最初から実行し直します。答えが分かった質問はその値を返し、新しい質問は同じように溜めます。
|
||
|
||
`state` が違うリクエストは、TypeSafe API では同時に送り、ローカルモデルでは順に処理します。質問を 1 つずつ問い合わせる場合と同じ質問をし、同じ結果を出力します。1 回の実行の中では、実行し直すたびに `random` の状態と `datetime.datetime.now()` の時刻が同じところから始まるので、実行し直しても質問は変わりません。
|
||
|
||
次の例では、`a` は `if` まで使われないので、`q1` と `q2` は 1 回のリクエストで送られます。`q3` は `a` が分かってから 2 回目のリクエストで送られます。
|
||
|
||
```python
|
||
a = jev.noul(it1, "q1")
|
||
b = jev.noul(it1, "q2")
|
||
if a > 0.5:
|
||
c = jev.noul(it1, "q3")
|
||
```
|
||
|
||
多くの質問を 1 回のリクエストで送るには、`state` を同じにして、質問ごとに違う内容は質問文に入れてください。
|
||
|
||
TypeSafe API では、1 回のリクエストが [TypeSafe Python SDK](https://pypi.org/project/typesafe-sdk/) の `system_one` 呼び出し 1 回にあたります。上の例の 1 回目のリクエストは次のとおりです。
|
||
|
||
```python
|
||
client.system_one(state=it1, questions={"q0": Noul(instructions="q1"), "q1": Noul(instructions="q2")})
|
||
```
|
||
|
||
注意:
|
||
- TypeSafe API は `state` と質問をインターネット経由で送信します。外部に出せないテキストには使わないでください。
|
||
- 汎用のローカルモデルは、質問 1 つにつき 1 回の順伝播で処理します(GPU 上の 4B〜9B モデルで約 1〜1.5 秒)。Clef モデルは、1 回のリクエストに含まれる質問すべてを 1 回の順伝播で処理します(Intel Arc の内蔵 GPU 上の Clef-Flash Q8_0 で、300 トークンのリクエストが約 3 秒)。d1 モデルは、質問 1 つにつき 1 回の順伝播で処理します(同じ GPU 上の d1-3B Q8_0 で約 0.5 秒)。モデルは実行ごとに最初の質問で読み込まれ(数秒)、実行が終わるとアンロードされて、ワークフローの残りのために VRAM を解放します。
|
||
- ローカルモデルの確率は較正されておらず、モデルによって変わります。しきい値は実際の入力で試して決めてください。
|
||
- Qwen3.5-9B での試験では、汎用のローカルモデルは、テキストから答えが決まらない質問に「No」と答えました。`jev.noul` の低い値は、「起こりにくい」ではなく「真だとは言えない」を表すことがあります。この2つを区別したい場合は、主張とその否定形の両方について聞くか、`jev.score` でパーセントの段階を選ばせてください。測定の結果とスクリプトの例は、[汎用のローカルモデルは、不確かな問いにどう答えるか](docs/local-model-uncertainty/README.ja.md) にあります。
|
||
- ローカルモデルのプロンプトは 8,192 トークンまでです。汎用モデルでは `state` と質問 1 つ、Clef では `state` と 1 回のリクエストの質問すべてがこの範囲に収まる必要があります。Clef はテキストのみに対応しています。
|
||
- d1 モデルでは、`jev.choice` と `jev.probabilities` の選択肢は 2〜26 個、`jev.score` の段階は 2〜10 個です。画像には対応していません。
|
||
- d1-3B での試験では、似た選択肢に確率が分かれ、1 つだけ性質の違う選択肢が最多になることがありました。アスペクト比の例では、縦長の 3 つの比率の合計が 0.49 だったのに対し、0.35 の `1:1` が選ばれました。選択肢がグループに分かれる場合は、`jev.probabilities` をグループごとに合計してから選んでください。
|
||
- 実行ごとにコンソールへ `[ComfyUI-ScriptFlow] Jev requests: N, states: S, questions: Q, responses: M` を出力します。送ったリクエスト数、異なる `state` の数、質問数、返信があったリクエスト数です。返信数がリクエスト数より少なければ、途中のリクエストが失敗しています。同じ実行のキャッシュから答えた質問は数えません。
|
||
- `typesafe-sdk` がインストールされていると、`httpx2`(SDK が使う HTTP クライアント)と `typesafe_sdk` のロガーを WARNING に設定するため、リクエストごとの INFO ログは表示されません。
|
||
- `MultiOutputScript` はモデルを読み込まず、ネットワークにもアクセスしません。`jev` は使えません。
|
||
- このプロジェクトは独立したもので、TypeSafe AI とは関係ありません。
|
||
|
||
### `centi`
|
||
**Category:** `utils`
|
||
|
||
入力(任意、接続可能):
|
||
- `int_1`(INT)
|
||
- `int_2`(INT)
|
||
- `int_3`(INT)
|
||
|
||
出力:
|
||
- `float_1`(FLOAT)= `int_1 / 100`
|
||
- `float_2`(FLOAT)= `int_2 / 100`
|
||
- `float_3`(FLOAT)= `int_3 / 100`
|
||
|
||
注意:
|
||
- `int_n` が接続されていない場合、対応する `float_n` の出力は `None` です。
|
||
- 意図的に最小限の、接続のみのノードにしています。
|
||
|
||
---
|
||
|
||
## 実行環境
|
||
スクリプトは Python の AST で解析され、ScriptFlow の安全なインタープリタで評価されます。代入、計算、分岐、ループ、ユーザー定義のヘルパー関数、辞書、リスト、文字列、一部のメソッドなど、ワークフローでよく使う処理に対応しています。
|
||
|
||
### 使える関数
|
||
- `len`、`min`、`max`、`sum`、`abs`、`round`
|
||
- `int`、`float`、`str`、`bool`
|
||
- `sorted`、`reversed`
|
||
- `enumerate`、`range`、`zip`
|
||
- `any`、`all`、`pow`、`divmod`
|
||
- `list`、`dict`、`tuple`
|
||
- `jev.yes`、`jev.noul`、`jev.choice`、`jev.probabilities`、`jev.score`(`MultiOutputScript (Jev)` のみ。[該当の節](#multioutputscript-jev)を参照)
|
||
|
||
### 使える名前空間
|
||
- `random`:`random`、`randint`、`uniform`、`choice`
|
||
- `datetime`:`datetime.now`、`date.today`、`year`・`month`・`day`・`hour`・`minute`・`second` などの日時の項目、`isoformat` と `strftime`(`strftime` は v1.2.0 以降)
|
||
- `math`:`ceil`、`floor`、`sqrt`、`sin`、`cos`、`tan`、`asin`、`acos`、`atan`、`atan2`、`log`、`log10`、`exp`、`pow`、`fabs`、`isfinite`、`pi`、`e`、`tau`
|
||
|
||
### 使えるメソッド
|
||
- `str`:`replace`、`find`、`strip`、`split`、`splitlines`、`startswith`、`endswith`、`join`、`lower`、`upper`
|
||
- `list`:`append`
|
||
|
||
### 使えないもの
|
||
- `import` 文
|
||
- ファイルへのアクセス(`open` など)
|
||
- OS の操作(`os`、`sys`)
|
||
- Python の動的な実行
|
||
- セーフモードで禁止している構文:`Import`、`ImportFrom`、`Global`、`Nonlocal`、`ClassDef`、`Try`、`With`、`AsyncWith`、`Lambda`、`Delete`、`Yield`、`Await`
|
||
- セーフモードで禁止している呼び出し:動的な実行、ファイルや入力へのアクセス、実行時の名前空間の参照、属性の動的な変更
|
||
|
||
### 実行時の注意
|
||
- `random` や `datetime` を使うと、出力は毎回変わる可能性があります。
|
||
- v1.3.2 以降では、`datetime.datetime.now()` と `datetime.date.today()` は実行を開始した時刻を返すため、1 つのスクリプトの中で何度呼んでも同じ値になります。
|
||
- v1.2.0 以降では、`datetime.strftime(...)` でタイムスタンプを簡潔に整形できます。
|
||
```python
|
||
now = datetime.datetime.now()
|
||
ot1 = now.strftime("%Y%m%d%H%M%S")
|
||
```
|
||
- v1.2.0 より前のバージョンでは、同じタイムスタンプを手作業で整形します。
|
||
```python
|
||
now = datetime.datetime.now()
|
||
ot1 = f'{now.year:04d}{now.month:02d}{now.day:02d}{now.hour:02d}{now.minute:02d}{now.second:02d}'
|
||
```
|
||
- 型が合わない場合はエラーになり、実行が止まります。
|
||
- セーフモードでは評価の前に AST を検証し、ステップ数やループ回数の上限を超えたスクリプトを止めます。
|
||
- 信頼できるスクリプトやワークフローだけを使ってください。出所の分からないコードは実行しないでください。
|
||
|
||
## 例
|
||
|
||
### ワークフローの例
|
||
|
||
```
|
||
examples/
|
||
├─ example_workflow.json
|
||
```
|
||
|
||
## 応用レシピ
|
||
|
||
### `LLM Dialogue Cycle` の会話ログを `dialogue_segments_json` に変換する
|
||
|
||
<details>
|
||
<summary>ComfyUI-LLM-Session の会話ログ変換レシピを表示</summary>
|
||
|
||
`ComfyUI-LLM-Session` は、`LLM Dialogue Cycle` の結果全体を `transcript_text` として出力します。
|
||
そのテキストを `MultiOutputScript.in_text_1` につなぎ、下のスクリプトを `code` 欄に貼り付けると、
|
||
後段の会話用 TTS ノード向けに、話者タグ付きの JSON テキストを生成できます。
|
||
|
||
推奨するワークフロー:
|
||
|
||
```text
|
||
LLM Dialogue Cycle
|
||
-> transcript_text
|
||
-> MultiOutputScript.in_text_1
|
||
-> MultiOutputScript.out_text_1
|
||
-> dialogue_segments_json input of a TTS node
|
||
```
|
||
|
||
次のコードを `MultiOutputScript.code` に貼り付けます。
|
||
|
||
```python
|
||
# Convert ComfyUI-LLM-Session "LLM Dialogue Cycle" transcript_text
|
||
# into dialogue_segments_json for TTS.
|
||
# Only text inside Japanese corner quotes 「...」 is used as spoken dialogue.
|
||
#
|
||
# Input:
|
||
# it1: transcript_text
|
||
#
|
||
# Output:
|
||
# ot1: JSON text with schema "kantan.dialogue.v1"
|
||
#
|
||
# Expected transcript lines:
|
||
# [2026-05-16T12:00:00] USER -> A: initial prompt
|
||
# [2026-05-16T12:00:01] A: 彼女は少し考えた。「こんにちは。」
|
||
# [2026-05-16T12:00:02] B: 彼は笑った。「やあ。」「調子はどう?」
|
||
#
|
||
# The parser also accepts the unicode arrow form:
|
||
# USER → A:
|
||
|
||
def json_escape(value):
|
||
s = str(value)
|
||
s = s.replace("\\", "\\\\")
|
||
s = s.replace('"', '\\"')
|
||
s = s.replace("\n", "\\n")
|
||
s = s.replace("\r", "\\r")
|
||
s = s.replace("\t", "\\t")
|
||
return s
|
||
|
||
def extract_spoken_text(value):
|
||
source = str(value)
|
||
parts = []
|
||
position = 0
|
||
|
||
while position < len(source):
|
||
start = source.find("「", position)
|
||
if start < 0:
|
||
position = len(source)
|
||
else:
|
||
end = source.find("」", start + 1)
|
||
if end < 0:
|
||
position = len(source)
|
||
else:
|
||
spoken = source[start + 1:end].strip()
|
||
if spoken:
|
||
parts.append(spoken)
|
||
position = end + 1
|
||
|
||
return "\n".join(parts)
|
||
|
||
transcript = str(it1 or "")
|
||
lines = transcript.splitlines()
|
||
utterances = []
|
||
current = None
|
||
|
||
for raw_line in lines:
|
||
line = str(raw_line)
|
||
speaker = None
|
||
text = None
|
||
timestamp = ""
|
||
|
||
if line.startswith("[") and "] " in line:
|
||
close_index = line.find("] ")
|
||
timestamp = line[1:close_index]
|
||
rest = line[close_index + 2:]
|
||
|
||
if rest.startswith("A:"):
|
||
speaker = "A"
|
||
text = rest[2:].strip()
|
||
elif rest.startswith("B:"):
|
||
speaker = "B"
|
||
text = rest[2:].strip()
|
||
elif rest.startswith("USER -> A:"):
|
||
speaker = "USER"
|
||
text = rest[len("USER -> A:"):].strip()
|
||
elif rest.startswith("USER → A:"):
|
||
speaker = "USER"
|
||
text = rest[len("USER → A:"):].strip()
|
||
|
||
if speaker == "A" or speaker == "B":
|
||
if current is not None:
|
||
utterances.append(current)
|
||
current = {
|
||
"speaker": speaker,
|
||
"text": text,
|
||
"timestamp": timestamp,
|
||
}
|
||
else:
|
||
if current is not None:
|
||
if current["text"]:
|
||
current["text"] = current["text"] + "\n" + line
|
||
else:
|
||
current["text"] = line
|
||
|
||
if current is not None:
|
||
utterances.append(current)
|
||
|
||
items = []
|
||
index = 1
|
||
for u in utterances:
|
||
spoken_text = extract_spoken_text(u["text"])
|
||
if not spoken_text:
|
||
continue
|
||
|
||
item = (
|
||
'{"index":'
|
||
+ str(index)
|
||
+ ',"speaker":"'
|
||
+ json_escape(u["speaker"])
|
||
+ '","text":"'
|
||
+ json_escape(spoken_text)
|
||
+ '","timestamp":"'
|
||
+ json_escape(u["timestamp"])
|
||
+ '"}'
|
||
)
|
||
items.append(item)
|
||
index = index + 1
|
||
|
||
ot1 = (
|
||
'{"schema":"kantan.dialogue.v1",'
|
||
+ '"source":"ComfyUI-LLM-Session LLM Dialogue Cycle",'
|
||
+ '"utterances":['
|
||
+ ",".join(items)
|
||
+ "]}"
|
||
)
|
||
```
|
||
|
||
出力される `out_text_1` は、次のような JSON 文字列です。
|
||
|
||
```json
|
||
{
|
||
"schema": "kantan.dialogue.v1",
|
||
"source": "ComfyUI-LLM-Session LLM Dialogue Cycle",
|
||
"utterances": [
|
||
{
|
||
"index": 1,
|
||
"speaker": "A",
|
||
"text": "こんにちは。",
|
||
"timestamp": "2026-05-16T12:00:01"
|
||
},
|
||
{
|
||
"index": 2,
|
||
"speaker": "B",
|
||
"text": "やあ。\n調子はどう?",
|
||
"timestamp": "2026-05-16T12:00:02"
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
注意:
|
||
|
||
- TTS がキャラクターのセリフだけを読むよう、`USER -> A` / `USER → A` は意図的に除外しています。
|
||
- モデルの出力が複数行にわたる場合は、直前の `A` または `B` の発話に含めます。
|
||
- `utterances[].text` に出力するのは `「...」` の中のテキストだけです。かぎかっこの外の地の文や心の声は除外します。
|
||
- 1 つの発話に複数のかぎかっこがある場合は、改行でつなぎます。
|
||
- `「...」` を含まない発話は、TTS 用のスクリプトから除外します。
|
||
- このレシピでは、`out_text_2`、`out_text_3`、数値出力は使いません。
|
||
|
||
</details>
|
||
|
||
### プロンプトエンハンサーの出力をシステムプロンプトと照らし合わせる
|
||
|
||
<details>
|
||
<summary>プロンプトエンハンサーのルール確認レシピを表示</summary>
|
||
|
||
プロンプトエンハンサーは、短い依頼文を、システムプロンプトのルールに従って画像・動画モデル用のプロンプトに書き直す LLM です。
|
||
このレシピは、そのシステムプロンプトをルールに分け、エンハンサーの出力が各ルールを破っていないかを Jev に尋ねて、
|
||
スコアと、破っている疑いのあるルールを出力します。ルールは接続したシステムプロンプトから読み取るので、特定のエンハンサーに依存しません。
|
||
下のスクリプトを `MultiOutputScript (Jev)` の `code` 欄に貼り付けてください。
|
||
|
||
推奨するワークフロー:
|
||
|
||
```text
|
||
enhancer output -> MultiOutputScript (Jev).in_text_1
|
||
system prompt -> MultiOutputScript (Jev).in_text_2
|
||
user request (optional) -> MultiOutputScript (Jev).in_text_3
|
||
MultiOutputScript (Jev)
|
||
-> out_text_1 report
|
||
-> out_text_2 likely violations only
|
||
-> out_text_3 rules extracted from the system prompt
|
||
-> out_value_1 adherence score (0-100)
|
||
-> out_value_2 number of likely violations
|
||
```
|
||
|
||
次のコードを `MultiOutputScript (Jev).code` に貼り付けます。
|
||
|
||
```python
|
||
# エンハンサー出力がシステムプロンプトにどれだけ従っているかを評価する(モデル非依存)
|
||
# it1: エンハンサーの出力
|
||
# it2: システムプロンプト(テキストそのもの、または "system_prompt" を含む JSON 設定の中身)
|
||
# it3: ユーザー文(任意。ルールがユーザー文を参照する場合の判定材料)
|
||
# ot1: レポート(違反の疑いが強い順) / ot2: 違反の疑いがあるルールのみ / ot3: 抽出したルール一覧
|
||
# ov1: 準拠スコア (0-100) / ov2: 違反の疑いがあるルール数
|
||
# Jev リクエストは 2 回(全ルールで 1 回、総合評価で 1 回。OVERALL = False なら 1 回)。max_jev_calls は 2 以上
|
||
|
||
MAX_RULES = 40 # これを超えるルールは評価しない
|
||
THRESHOLD = 0.5 # 違反確率がこれ以上なら違反とみなす
|
||
SKIP_HEADINGS = ["例", "example", "sample", "思考", "手順", "thinking", "reasoning"] # この語を含む見出しの節は評価しない
|
||
OVERALL = True # システムプロンプト全体に対する総合評価も行う
|
||
|
||
def has_any(s, words):
|
||
for w in words:
|
||
if w in s:
|
||
return True
|
||
return False
|
||
|
||
output = str(it1 or "").strip()
|
||
sp = str(it2 or "")
|
||
user_text = str(it3 or "").strip()
|
||
|
||
if sp.strip().startswith("{"):
|
||
k = sp.find('"system_prompt"')
|
||
if k < 0:
|
||
raise ValueError("JSON に system_prompt がありません")
|
||
tail = sp[sp.find('"', sp.find(":", k) + 1) + 1:]
|
||
tail = tail.replace("\\\\", "\x00").replace('\\"', "\x01")
|
||
sp = tail[:tail.find('"')].replace("\\n", "\n").replace("\\t", "\t").replace("\x01", '"').replace("\x00", "\\")
|
||
|
||
# 見出しごとに、行頭の項目または段落を1ルールとし、字下げされた行は直前のルールに含める
|
||
rules = []
|
||
heading = ""
|
||
skip = False
|
||
for line in sp.splitlines():
|
||
s = line.strip()
|
||
if not s:
|
||
continue
|
||
if s[0] in "【#[-":
|
||
if s[:3] == "---":
|
||
continue
|
||
if s[0] != "-" and (s[0] != "[" or s[-1] == "]"):
|
||
heading = s
|
||
skip = has_any(s.lower(), SKIP_HEADINGS)
|
||
continue
|
||
if skip:
|
||
continue
|
||
if rules and line[:1] in " \t ":
|
||
rules[-1][1] = rules[-1][1] + "\n" + s
|
||
else:
|
||
rules.append([heading, s])
|
||
|
||
truncated = len(rules) > MAX_RULES
|
||
rules = rules[:MAX_RULES]
|
||
|
||
# 全ルールで state を共通にし、ルールは質問文に入れる(1 リクエストにまとまる)
|
||
state = {"output": output}
|
||
if user_text:
|
||
state["user_input"] = user_text
|
||
results = []
|
||
for r in rules:
|
||
q = "Does the output clearly break the instruction below? Answer no if the instruction does not concern the output, or if judging it needs a reference image or anything else not given here."
|
||
if r[0]:
|
||
q += "\nSection: " + r[0]
|
||
p = jev.noul(state, q + "\nInstruction: " + r[1])
|
||
results.append([float(p), r[0], r[1]])
|
||
|
||
# 総合評価はシステムプロンプト全体を state にするので別リクエスト。並べ替えの前に聞いてルールと同時に送る
|
||
if OVERALL:
|
||
overall_state = {"system_prompt": sp, "output": output}
|
||
if user_text:
|
||
overall_state["user_input"] = user_text
|
||
level = jev.score(overall_state, "How faithfully does the output follow the system prompt as a whole?", ["ignores most instructions", "follows some instructions", "follows about half", "follows most instructions", "follows every instruction"])
|
||
|
||
ranked = list(reversed(sorted(results)))
|
||
lines = []
|
||
issues = []
|
||
total = 0.0
|
||
for r in ranked:
|
||
total += r[0]
|
||
bad = r[0] >= THRESHOLD
|
||
line = ("[違反?] " if bad else "[OK] ") + f"p={r[0]:.2f} {r[1]} " + r[2].splitlines()[0][:70]
|
||
lines.append(line)
|
||
if bad:
|
||
issues.append(line)
|
||
|
||
score = round(100 * (1 - total / len(ranked))) if ranked else 0
|
||
header = f"準拠スコア: {score}/100 (ルール {len(ranked)} 件中、違反の疑い {len(issues)} 件)"
|
||
if truncated:
|
||
header += f"\n※ルールが {MAX_RULES} 件を超えたため、以降は未評価"
|
||
if OVERALL:
|
||
header += f"\n総合評価: {level:.2f} / 4"
|
||
|
||
ot1 = header + "\n" + "\n".join(lines)
|
||
ot2 = header + "\n" + "\n".join(issues)
|
||
rule_lines = []
|
||
for i, r in enumerate(rules):
|
||
rule_lines.append(f"{i + 1}. {r[0]} {r[1]}")
|
||
ot3 = "\n\n".join(rule_lines)
|
||
ov1 = score
|
||
ov2 = len(issues)
|
||
```
|
||
|
||
出力される `out_text_1` は、次のようなレポートです。
|
||
|
||
```text
|
||
準拠スコア: 71/100 (ルール 4 件中、違反の疑い 1 件)
|
||
総合評価: 3.10 / 4
|
||
[違反?] p=0.74 ## 出力形式 - 文章は書かない。
|
||
[OK] p=0.21 あなたは SDXL 用のプロンプトエンハンサーです。
|
||
[OK] p=0.12 ## 出力形式 - 先頭は「masterpiece, best quality」にする。
|
||
[OK] p=0.09 ## 出力形式 - カンマ区切りのタグを 1 行で出力する。
|
||
```
|
||
|
||
注意:
|
||
|
||
- v1.3.2 以降が必要です。v1.3.2 以降では、同じ `state` についての質問を 1 回のリクエストにまとめて送ります。このスクリプトのリクエストは 2 回なので、`max_jev_calls` は 2 以上にしてください。
|
||
- `p` は、出力がそのルールを破っていると Jev が判断した確率です。確実な検査ではありません。テストでは、守られているルールを違反と判定することも、破られているルールを見逃すこともあり、`THRESHOLD` 付近の値は実行のたびに変わりました。違反と判定されたルールは、確認すべき候補として扱ってください。
|
||
- このレシピは、TypeSafe API の Jev で作成しました。テストでは、ローカルの Clef-Flash モデルは、出力が守っているルールも含めて大半のルールを違反と判定したため、このレシピには向きません。
|
||
- 参照画像のように、スクリプトに渡していない情報が必要なルールは判定できません。ユーザーの依頼文に関するルールを判定するには、依頼文を `in_text_3` につないでください。
|
||
- `in_text_2` には、システムプロンプトのテキストそのもの、または `"system_prompt"` キーを持つ JSON 設定のテキストを渡せます。JSON の `\uXXXX` エスケープは復元しません。
|
||
- `#`、`【`、`[...]` で始まる行は見出しとして扱います。見出しの下では、行頭から始まる行を 1 つのルールとし、字下げされた行は直前のルールに含めます。システムプロンプトがどう分けられたかは `out_text_3` で確認できます。
|
||
- 見出しに `SKIP_HEADINGS` の語を含む節は評価しません。例や思考の手順がルールとして判定されるのを防ぐためです。
|
||
- 評価するのは先頭から `MAX_RULES` 件までです。システムプロンプトが非常に長いと、スクリプトのステップ上限を超えます。その場合は、下の[長いシステムプロンプトの場合](#長いシステムプロンプトの場合)を参照してください。
|
||
- TypeSafe API を使う場合、システムプロンプト、エンハンサーの出力、ユーザーの依頼文はインターネット経由で送信されます。
|
||
- `out_text_3` と数値出力は必要に応じて使ってください。`out_value_3` は使いません。
|
||
|
||
#### 長いシステムプロンプトの場合
|
||
|
||
上のスクリプトはシステムプロンプトを 1 行ずつ評価するため、数百行あるシステムプロンプトでは `Script exceeded step limit` で止まります。
|
||
その場合は、下のスクリプトを使ってください。システムプロンプトを空行の位置で `PART_CHARS` 字程度の部分に区切り、
|
||
出力がその部分の指示を破っていないかを、部分ごとに Jev に尋ねます。入力と出力の接続は同じです。
|
||
|
||
```python
|
||
# 長いシステムプロンプト向け:エンハンサー出力がシステムプロンプトにどれだけ従っているかを、部分ごとに評価する
|
||
# システムプロンプトを空行の位置で PART_CHARS 字程度の部分に区切り、部分ごとに違反の有無を Jev に聞く
|
||
# (1 行ずつ評価する prompt_rule_check.py がステップ上限を超える長さのシステムプロンプト用)
|
||
# it1: エンハンサーの出力
|
||
# it2: システムプロンプト(テキストそのもの、または "system_prompt" を含む JSON 設定の中身)
|
||
# it3: ユーザー文(任意。指示がユーザー文を参照する場合の判定材料)
|
||
# ot1: レポート(違反の疑いが強い順) / ot2: 違反の疑いがある部分のみ / ot3: 区切った部分の一覧
|
||
# ov1: 準拠スコア (0-100) / ov2: 違反の疑いがある部分の数
|
||
# Jev リクエストは 2 回(全部分で 1 回、総合評価で 1 回。OVERALL = False なら 1 回)。max_jev_calls は 2 以上
|
||
|
||
PART_CHARS = 1000 # 1 つの部分のおおよその文字数。小さくすると細かく評価する(質問数が増える)
|
||
MAX_PARTS = 60 # これを超える部分は評価しない
|
||
THRESHOLD = 0.5 # 違反確率がこれ以上なら違反とみなす
|
||
OVERALL = True # システムプロンプト全体に対する総合評価も行う
|
||
|
||
output = str(it1 or "").strip()
|
||
sp = str(it2 or "")
|
||
user_text = str(it3 or "").strip()
|
||
|
||
if sp.strip().startswith("{"):
|
||
k = sp.find('"system_prompt"')
|
||
if k < 0:
|
||
raise ValueError("JSON に system_prompt がありません")
|
||
tail = sp[sp.find('"', sp.find(":", k) + 1) + 1:]
|
||
tail = tail.replace("\\\\", "\x00").replace('\\"', "\x01")
|
||
sp = tail[:tail.find('"')].replace("\\n", "\n").replace("\\t", "\t").replace("\x01", '"').replace("\x00", "\\")
|
||
sp = sp.replace("\r\n", "\n").strip()
|
||
|
||
# PART_CHARS 字を超えた後の最初の空行で区切る(段落の途中では切らない)
|
||
parts = []
|
||
pos = 0
|
||
while pos < len(sp) and len(parts) < MAX_PARTS:
|
||
end = sp.find("\n\n", pos + PART_CHARS)
|
||
if end < 0:
|
||
end = len(sp)
|
||
parts.append(sp[pos:end].strip())
|
||
pos = end
|
||
truncated = pos < len(sp)
|
||
|
||
# 全部分で state を共通にし、部分は質問文に入れる(1 リクエストにまとまる)
|
||
state = {"output": output}
|
||
if user_text:
|
||
state["user_input"] = user_text
|
||
results = []
|
||
for i, part in enumerate(parts):
|
||
p = jev.noul(state, "Does the output clearly break any instruction in the part of the system prompt below? Answer no if the instructions do not concern the output, or if judging them needs a reference image or anything else not given here.\nPart of the system prompt:\n" + part)
|
||
results.append([float(p), i + 1])
|
||
|
||
# 総合評価はシステムプロンプト全体を state にするので別リクエスト。並べ替えの前に聞いて部分と同時に送る
|
||
if OVERALL:
|
||
overall_state = {"system_prompt": sp, "output": output}
|
||
if user_text:
|
||
overall_state["user_input"] = user_text
|
||
level = jev.score(overall_state, "How faithfully does the output follow the system prompt as a whole?", ["ignores most instructions", "follows some instructions", "follows about half", "follows most instructions", "follows every instruction"])
|
||
|
||
lines = []
|
||
issues = []
|
||
total = 0.0
|
||
for r in reversed(sorted(results)):
|
||
total += r[0]
|
||
bad = r[0] >= THRESHOLD
|
||
# 部分の先頭行を見出し代わりに表示する(区切り線だけの行は飛ばす)
|
||
title = parts[r[1] - 1].strip("=-_*# \n\t").splitlines()[0][:60]
|
||
line = ("[違反?] " if bad else "[OK] ") + f"p={r[0]:.2f} 部分{r[1]}: {title}"
|
||
lines.append(line)
|
||
if bad:
|
||
issues.append(line)
|
||
|
||
score = round(100 * (1 - total / len(parts))) if parts else 0
|
||
header = f"準拠スコア: {score}/100 (部分 {len(parts)} 件中、違反の疑い {len(issues)} 件)"
|
||
if truncated:
|
||
header += f"\n※部分が {MAX_PARTS} 件を超えたため、以降は未評価"
|
||
if OVERALL:
|
||
header += f"\n総合評価: {level:.2f} / 4"
|
||
|
||
ot1 = header + "\n" + "\n".join(lines)
|
||
ot2 = header + "\n" + "\n".join(issues)
|
||
part_texts = []
|
||
for i, part in enumerate(parts):
|
||
part_texts.append(f"--- 部分{i + 1} ---\n{part}")
|
||
ot3 = "\n\n".join(part_texts)
|
||
ov1 = score
|
||
ov2 = len(issues)
|
||
```
|
||
|
||
出力される `out_text_1` は、次のようなレポートです。
|
||
|
||
```text
|
||
準拠スコア: 74/100 (部分 19 件中、違反の疑い 2 件)
|
||
総合評価: 2.90 / 4
|
||
[違反?] p=0.78 部分7: 出来事はユーザーが指定した順序のままにする。
|
||
[違反?] p=0.61 部分12: カメラの動きは、ユーザーが求めたときだけ書く。
|
||
[OK] p=0.34 部分1: あなたは動画モデル用のプロンプトエンハンサーです。
|
||
[OK] p=0.22 部分3: 長さはユーザーが指定した秒数にする。
|
||
```
|
||
|
||
注意:
|
||
|
||
- `max_jev_calls`、`p`、`in_text_2`、TypeSafe API についての上の注意は、このスクリプトにも当てはまります。
|
||
- 部分は、`PART_CHARS` 字を超えた後の最初の空行で区切るので、段落の途中では切れません。見出しや箇条書きは解釈しないので、どんな書き方のシステムプロンプトでも使えますが、例の節は飛ばしません。
|
||
- レポートは、各部分をその先頭の行で示します。違反と判定された部分の全文は `out_text_3` で読み、どの指示が問題なのかを確認してください。
|
||
- 1 つの質問に複数の指示が含まれるので、1 行ずつ評価するスクリプトより結果は粗くなります。`PART_CHARS` を小さくすると、部分が細かくなり、質問数が増えます。
|
||
- テストでは、約 20,000 字のシステムプロンプトが 19 個の部分に区切られ、約 2,200 ステップで実行できました。ステップ上限には十分収まります。
|
||
|
||
</details>
|
||
|
||
## ライセンス
|
||
|
||
このプロジェクトは **GNU General Public License v3.0** の下でライセンスされています。
|
||
|
||
**Copyright (C) 2026 kantan-kanto**
|
||
GitHub: https://github.com/kantan-kanto
|
||
|
||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
||
|
||
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||
|
||
You should have received a copy of the GNU General Public License along with this program. If not, see https://www.gnu.org/licenses/.
|
||
|
||
## サポート
|
||
|
||
- **Issues**:不具合の報告や機能の要望は GitHub Issues へお願いします
|
||
- **Documentation**:バージョン履歴は [CHANGELOG.md](CHANGELOG.md) を参照してください
|
||
- **Examples**:ワークフローのテンプレートは [examples/](examples/) を参照してください
|
||
|
||
---
|
||
|
||
## 変更履歴
|
||
|
||
詳しいバージョン履歴は [CHANGELOG.md](CHANGELOG.md) を参照してください。
|
||
|
||
### 現在のバージョン:1.3.5
|
||
- `MultiOutputScript (Jev)` が、ローカルの d1 の GGUF モデル(Liquid AI のオープンウェイトの判断モデル)を動かせるように。d1 が学習したプロンプト形式で質問する
|
||
- Jev、Clef-Flash、汎用のローカルモデルが、答えを知りようのない問いにどう答えるかを調べた技術レポートを追加
|