Prompt Random Choice Exの機能を強化し、すべての葉候補を展開してからランダム選択を行うように変更。キャッシュ機能を追加し、展開済み候補リストを管理。
This commit is contained in:
@@ -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
@@ -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つが選ばれます。
|
||||
|
||||
## 備考
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user