Prompt Random Choice Exの機能を強化し、すべての葉候補を展開してからランダム選択を行うように変更。キャッシュ機能を追加し、展開済み候補リストを管理。

This commit is contained in:
るみなー先生
2026-05-18 03:11:09 +09:00
parent 8b3ac7c3ed
commit d4a9ad4fdd
3 changed files with 207 additions and 83 deletions
+92 -8
View File
@@ -17,6 +17,8 @@
- フラットな候補リストから1つ選ぶノード
- `Prompt Random Choice Ex`
- フラット候補に加えて、`{}` による入れ子の候補展開に対応したノード
- 先にすべての葉候補へ展開し、最後に1回だけランダム選択します
- 展開済み候補リストは `options_text` が変わるまでキャッシュします
どちらもノードしての出力は同じです。
@@ -63,12 +65,21 @@
ここは実例を見てもらった方が話が早そうじゃ。
```text
town|zoo{animals{birds|penguins}|aquarium,{()|fish|jellyfish}}
town|zoo{animals,birds|aquarium,jellyfish}
town|zoo,animals,birds
town
town|zoo{animals{birds|penguins}|aquarium,{fish|jellyfish}}
```
こんな感じで動くぞ。
これは、内部的には次の候補へ展開され、キャッシュされるのじゃ。
```text
town
zoo, animals, birds
zoo, animals, penguins
zoo, aquarium, fish
zoo, aquarium, jellyfish
```
この5候補から1つが選ばれるのじゃ。<br/>
入れ子になった各要素が、すべて等しい確率で出現するように工夫しておるのじゃ。
<br/>
## 導入方法
@@ -165,16 +176,81 @@ day|day|day|sunset|night
- `change_every` が 2 以上なら、その回数ぶん同じ候補を維持する
- 実行時にタイトルへ `Choice: lake` や `Choice: (empty) (2/3)` のように表示する
## Prompt Random Choice
候補リストから1つ選びます。
```text
town|park|lake|coffee shop
```
`()` は明示的な空候補です。
```text
()|(full body:0.9)
```
## Prompt Random Choice Ex
`Prompt Random Choice` と同じフラットな候補リストをそのまま使えます。
```text
town|park|lake|coffee shop
```
さらに、候補の中に `{}` を書くことで、選ばれた候補にだけ追加候補をぶら下げられます。
```text
town|zoo{animals{birds|penguins}|aquarium,{fish|jellyfish}}
```
この入力は、内部的には次の候補へ展開されます。
```text
town
zoo, animals, birds
zoo, animals, penguins
zoo, aquarium, fish
zoo, aquarium, jellyfish
```
この5候補から1つが選ばれます。
### 複数行の Ex 例
```text
zoo{
animals{
birds
penguins
}
aquarium{
fish
jellyfish
}
}
```
出力例:
```text
zoo, animals, birds
zoo, animals, penguins
zoo, aquarium, fish
zoo, aquarium, jellyfish
```
### Ex のルール
- 選択候補の区切り文字は、実際の改行 または `|`
- 空候補は無視
- `()` は明示的な空候補
- `{}` の内部も、実際の改行 または `|` で候補分割
- `{}` は最内側からランダムに展開
- `{}` は最内側からすべての葉候補へ展開
- 展開結果は親要素へ `, ` で接続
- `{}` がなくなるまで繰り返し展開
- 展開回数には安全上限があります
- 最終候補リストから最後に1回だけランダム選択
- 展開済み候補リストは `options_text` が変わるまでキャッシュ
- 展開候補数と展開回数には安全上限があります
- リテラルの `{` / `}` をプロンプト文字として使う用途は非対応
## 出力
@@ -187,6 +263,14 @@ day|day|day|sunset|night
ファイル名向けに安全化した出力です。
`selected_text` が空なら `empty` を返します。
## 推奨構成
`selected_text` を kjnodes の `Join String Multi` などへ接続し、区切り文字は結合ノード側で管理するのがおすすめです。
複数の `Prompt Random Choice` / `Prompt Random Choice Ex` を並べることで、背景・時間帯・天気・構図などを別々にランダム化できます。
ただし、Ex はすべての要素を1つにまとめるためのノードではなく、`zoo` の時だけ動物候補を追加するような、親子関係のある候補を扱うためのノードとして使うのがおすすめです。
<br/>
## 標準選択記法 `{day|night|morning}` との違いについて
+4 -3
View File
@@ -7,8 +7,9 @@
- `Prompt Random Choice Ex`
- `Prompt Random Choice` と同じフラット候補リストに対応
- `{}` による入れ子の候補展開に対応
- 最内側の `{}` からランダムに展開
- すべての葉候補へ展開してから、最後に1回だけランダム選択
- 展開結果を親要素へ `, ` で接続
- 展開済み候補リストを `options_text` 単位でキャッシュ
- `selected_text` / `selected_text_safe` を出力
## 例
@@ -16,8 +17,7 @@
```text
town|zoo{animals{birds|penguins}|aquarium,{fish|jellyfish}}
```
出力例:
これは、内部的には次の候補へ展開されます。
```text
town
@@ -26,6 +26,7 @@ zoo, animals, penguins
zoo, aquarium, fish
zoo, aquarium, jellyfish
```
この5候補から1つが選ばれます。
## 備考
+111 -72
View File
@@ -25,6 +25,7 @@ amusement park{ferris wheel|carousel|balloons}"""
INNER_BLOCK_PATTERN = re.compile(r"\{([^{}]*)\}", re.DOTALL)
MAX_EXPAND_STEPS = 64
MAX_EXPANDED_OPTIONS = 4096
def _split_options(options_text: str):
@@ -33,10 +34,6 @@ def _split_options(options_text: str):
Empty candidates are ignored.
"()" is treated as an explicit empty candidate and becomes "".
This function is shared by PromptRandomChoice and PromptRandomChoiceEx.
For Ex, nested blocks such as zoo{animals|birds} stay together when
splitting the top-level candidate list.
"""
text = str(options_text or "").replace("\r\n", "\n").replace("\r", "\n")
@@ -68,12 +65,9 @@ def _split_options(options_text: str):
for part in parts:
item = str(part).strip(" \t\r\n,")
# Empty parts caused by repeated delimiters are ignored.
if not item:
continue
# Explicit empty choice.
# Example: ()|(full body:0.9)
if item == "()":
options.append("")
continue
@@ -83,72 +77,16 @@ def _split_options(options_text: str):
return options
def _choose_from_options_text(options_text: str):
options = _split_options(options_text)
if not options:
return ""
return secrets.choice(options)
def _normalize_prompt_fragment(text: str):
"""
Normalize comma-connected prompt fragments after nested expansion.
This keeps the syntax forgiving:
zoo{aquarium,{fish|jellyfish}}
can safely become:
zoo, aquarium, fish
even if intermediate expansion produced duplicate commas.
"""
value = str(text or "")
# Normalize line breaks/tabs to spaces.
value = re.sub(r"[\t\r\n]+", " ", value)
# Normalize spaces around commas.
value = re.sub(r"\s*,\s*", ", ", value)
# Collapse duplicate commas.
value = re.sub(r"(,\s*){2,}", ", ", value)
# Collapse spaces.
value = re.sub(r"\s+", " ", value)
# Trim awkward edges.
value = value.strip(" \t\r\n,")
return value
def _expand_nested_choice(text: str):
"""
Expand nested {...} blocks from the innermost blocks outward.
Rule:
- {...} chooses one candidate from inside.
- Inside candidates are split by top-level "|" or actual newline.
- The selected nested result is comma-connected to the parent text.
- "()" is an explicit empty candidate.
- Unmatched braces are left as-is after the expansion limit is reached.
Example:
zoo{animals{birds|penguins}|aquarium,{fish|jellyfish}}
-> zoo, animals, birds
-> zoo, aquarium, jellyfish
"""
value = str(text or "").strip(" \t\r\n,")
for _ in range(MAX_EXPAND_STEPS):
match = INNER_BLOCK_PATTERN.search(value)
if not match:
break
chosen = _choose_from_options_text(match.group(1))
replacement = f", {chosen}" if chosen else ""
value = value[:match.start()] + replacement + value[match.end():]
return _normalize_prompt_fragment(value)
def _safe_text(value: str):
text = str(value or "")
@@ -166,6 +104,95 @@ def _safe_text(value: str):
return text or "empty"
def _find_first_brace_block(text: str):
"""Return (open_index, close_index) for the first balanced {...} block."""
open_index = text.find("{")
if open_index < 0:
return None
depth = 0
for index in range(open_index, len(text)):
char = text[index]
if char == "{":
depth += 1
elif char == "}":
depth -= 1
if depth == 0:
return open_index, index
# Unmatched "{". Treat it as normal text rather than looping forever.
return None
def _expand_one_expression(text: str, depth: int = 0):
"""
Expand one candidate into all final leaf candidates.
This expands the first outermost {...} block, chooses every top-level
branch in that block, and then recursively expands only the selected
branch. This avoids duplicating unrelated branches.
"""
if depth > MAX_EXPAND_STEPS:
raise ValueError(
"PromptRandomChoiceEx expansion exceeded the maximum expansion steps. "
"Please check nested braces."
)
value = str(text or "").strip(" \t\r\n,")
block = _find_first_brace_block(value)
if block is None:
return [_normalize_prompt_fragment(value)]
open_index, close_index = block
prefix = value[:open_index]
body = value[open_index + 1:close_index]
suffix = value[close_index + 1:]
branch_options = _split_options(body)
if not branch_options:
branch_options = [""]
expanded = []
for branch in branch_options:
if branch:
candidate = f"{prefix}, {branch}{suffix}"
else:
candidate = f"{prefix}{suffix}"
for item in _expand_one_expression(candidate, depth + 1):
normalized = _normalize_prompt_fragment(item)
expanded.append(normalized)
if len(expanded) > MAX_EXPANDED_OPTIONS:
raise ValueError(
"PromptRandomChoiceEx expanded too many options "
f"({len(expanded)} > {MAX_EXPANDED_OPTIONS})."
)
return expanded
def _build_expanded_options(options_text: str):
top_options = _split_options(options_text)
expanded_options = []
for option in top_options:
for expanded in _expand_one_expression(option):
# Keep "" when it came from explicit (). It allows Ex to return an
# intentional empty selection just like PromptRandomChoice.
expanded_options.append(expanded)
if len(expanded_options) > MAX_EXPANDED_OPTIONS:
raise ValueError(
"PromptRandomChoiceEx expanded too many options "
f"({len(expanded_options)} > {MAX_EXPANDED_OPTIONS})."
)
return tuple(expanded_options)
class _RandomChoiceStateMixin:
def __init__(self):
self._current_choice = None
@@ -183,16 +210,14 @@ class _RandomChoiceStateMixin:
self._last_options_key = None
self._last_change_every = None
def _pick_candidate(self, options):
return secrets.choice(options)
def _select_new_choice(self, options):
self._current_choice = self._pick_candidate(options)
self._current_choice = secrets.choice(options)
self._repeat_index = 1
def _run_choice(self, options, change_every):
change_every = max(1, int(change_every))
options_key = tuple(options)
options = tuple(options)
options_key = options
if not options:
self._reset_state()
@@ -201,6 +226,7 @@ class _RandomChoiceStateMixin:
state_changed = (
self._last_options_key != options_key
or self._last_change_every != change_every
or self._current_choice not in options
)
if state_changed or self._current_choice is None:
@@ -273,6 +299,11 @@ class PromptRandomChoice(_RandomChoiceStateMixin):
class PromptRandomChoiceEx(_RandomChoiceStateMixin):
def __init__(self):
super().__init__()
self._expanded_options_cache_key = None
self._expanded_options_cache = tuple()
@classmethod
def INPUT_TYPES(cls):
return {
@@ -301,12 +332,20 @@ class PromptRandomChoiceEx(_RandomChoiceStateMixin):
FUNCTION = "pick"
CATEGORY = "utils/prompt"
def _pick_candidate(self, options):
raw_choice = secrets.choice(options)
return _expand_nested_choice(raw_choice)
def _get_expanded_options_cached(self, options_text):
cache_key = str(options_text or "")
if self._expanded_options_cache_key == cache_key:
return self._expanded_options_cache
expanded_options = _build_expanded_options(cache_key)
self._expanded_options_cache_key = cache_key
self._expanded_options_cache = expanded_options
return expanded_options
def pick(self, options_text, change_every):
options = _split_options(options_text)
options = self._get_expanded_options_cached(options_text)
selected_text, selected_text_safe, selected_text_title, repeat_index, change_every = self._run_choice(
options,
change_every,