Files
2026-01-23 15:20:16 +01:00

286 lines
10 KiB
Python

"""
A1111 Prompt Scheduler
Handles step-based scheduling and alternation logic.
Generates the correct prompt text for each step based on A1111 scheduling syntax.
"""
import re
from typing import List, Tuple
import lark
from .grammar import get_parser
def get_prompt_schedule(prompt: str, steps: int) -> List[Tuple[int, str]]:
"""
A1111-style prompt schedule generation.
Returns list of (end_at_step, prompt_text) tuples representing
when each prompt variant should be used.
This properly handles nested alternation and scheduling.
"""
# Split by BREAK first, process each chunk, then rejoin
break_chunks = re.split(r"\s*\bBREAK\b\s*", prompt)
try:
parser = get_parser()
trees = [parser.parse(chunk) for chunk in break_chunks]
except lark.exceptions.LarkError:
# Parse error - return original prompt for all steps
return [(steps, prompt)]
# Collect all relevant steps from all chunks
all_steps = _collect_steps(steps, trees)
# Generate prompt text for each step
schedule = []
for step in all_steps:
chunk_texts = []
for tree in trees:
text = _at_step(step, steps, tree)
if text.strip():
chunk_texts.append(text)
full_prompt = " BREAK ".join(chunk_texts)
schedule.append((step, full_prompt))
return schedule
def _collect_steps(steps: int, trees) -> List[int]:
"""Collect all steps where the prompt changes.
A1111 behavior: modifies tree.children[-2] in place to be the computed step number.
This is critical for at_step to work correctly.
"""
result = {steps} # Always include final step
class CollectSteps(lark.Visitor):
def scheduled(self, tree):
# A1111 uses tree.children[-2] directly (the NUMBER token)
s = tree.children[-2]
if isinstance(s, lark.Token):
s_str = str(s)
v = float(s_str)
if "." in s_str:
step_num = int(v * steps)
else:
step_num = int(v)
else:
# Already converted by outer scheduling
step_num = int(s)
step_num = max(1, min(steps, step_num))
# A1111 CRITICAL: modify tree in place so at_step gets integer
tree.children[-2] = step_num
if step_num >= 1:
result.add(step_num)
def alternate(self, tree):
# Alternation changes every step
result.update(range(1, steps + 1))
def alternate_scheduled(self, tree):
# Scheduled alternation: [a|b:when] or [a|b::when]
# Format: options..., maybe DOUBLE_COLON, maybe None, NUMBER, maybe None
# The DOUBLE_COLON token indicates :: syntax
when_token = None
has_double_colon = False
# Find the NUMBER token and check for DOUBLE_COLON
for child in tree.children:
if isinstance(child, lark.Token):
if child.type == "NUMBER":
when_token = child
elif child.type == "DOUBLE_COLON":
has_double_colon = True
if when_token is not None:
s_str = str(when_token)
v = float(s_str)
if "." in s_str:
step_num = int(v * steps)
else:
step_num = int(v)
step_num = max(1, min(steps, step_num))
# Store the computed step and whether it's double-colon
tree.children.append(("_schedule_info", step_num, has_double_colon))
result.add(step_num)
# Still need all steps for alternation
result.update(range(1, steps + 1))
for tree in trees:
CollectSteps().visit(tree)
return sorted(result)
def _at_step(step: int, total_steps: int, tree) -> str:
"""
Generate the prompt text for a specific step.
This follows A1111's approach of walking the tree and picking
the correct option at each node based on the current step.
"""
class AtStep(lark.Transformer):
def scheduled(self, args):
# A1111 uses strict 5-positional unpacking:
# before, after, _, when, _ = args
# where 'when' is already an integer (modified by collect_steps)
# Pad args to 5 elements for consistent unpacking
padded = list(args) + [None] * max(0, 5 - len(args))
before, after, _, when, _ = padded[:5]
# 'when' should be an integer now (set by collect_steps)
# If it's still a token, convert it
if isinstance(when, lark.Token):
when_str = str(when)
if "." in when_str:
when = int(float(when_str) * total_steps)
else:
when = int(float(when_str))
elif when is None:
# No valid when found, yield everything
for a in args:
if a is not None:
yield a
return
# A1111: yield before if step <= when, else yield after
# Use 'or ()' pattern like A1111 for empty handling
if step <= when:
yield before or ()
else:
if after is not None:
yield after
def alternate(self, args):
# A1111: Convert falsy to "" but KEEP them (affects cycle count)
# Don't filter out empty - "[A|]" should alternate A, "", A, ""...
options = ["" if not a else a for a in args]
if not options:
yield ""
return
idx = (step - 1) % len(options)
yield options[idx]
def alternate_scheduled(self, args):
# Scheduled alternation: [a|b:when] or [a|b::when]
# Extract options and schedule info
options = []
schedule_step = None
is_remove = False # True for ::when (remove), False for :when (add)
for arg in args:
if (
isinstance(arg, tuple)
and len(arg) == 3
and arg[0] == "_schedule_info"
):
# This is our schedule info from collect_steps
schedule_step, is_remove = arg[1], arg[2]
elif isinstance(arg, lark.Token):
# Skip DOUBLE_COLON, colon, and number tokens
if arg.type in ("NUMBER", "DOUBLE_COLON") or str(arg) == ":":
continue
options.append(str(arg) if arg else "")
elif arg is not None:
# Keep the arg as-is (could be string or generator)
options.append("" if not arg else arg)
# Fallback: if no schedule info, look for NUMBER token
if schedule_step is None:
for arg in args:
if isinstance(arg, lark.Token) and arg.type == "NUMBER":
s_str = str(arg)
v = float(s_str)
if "." in s_str:
schedule_step = int(v * total_steps)
else:
schedule_step = int(v)
schedule_step = max(1, min(total_steps, schedule_step))
# Check for double colon token
is_remove = any(
isinstance(a, lark.Token) and a.type == "DOUBLE_COLON"
for a in args
)
break
if schedule_step is None:
# No schedule found, fall back to normal alternation
idx = (step - 1) % len(options) if options else 0
yield options[idx] if options else ""
return
# Apply scheduling logic
# [a|b:when] = first tag before when, alternate after (add at step when)
# [a|b::when] = alternate before when, first tag after (remove at step when)
if is_remove:
# ::when - alternate before, first tag after
if step <= schedule_step:
# Alternate
if options:
idx = (step - 1) % len(options)
yield options[idx]
else:
yield ""
else:
# After removal, yield first tag
yield options[0] if options else ""
else:
# :when - first tag before, alternate after
if step <= schedule_step:
# Before the schedule point, yield first tag
yield options[0] if options else ""
else:
# After the schedule point, alternate
if options:
idx = (step - 1) % len(options)
yield options[idx]
else:
yield ""
def emphasized(self, args):
# Grammar uses !emphasized so literal parens/brackets are already in args
# We just yield them as-is without adding extra parens
for a in args:
if isinstance(a, str):
yield a
elif isinstance(a, lark.Token):
yield str(a)
elif hasattr(a, "__iter__"):
for item in a:
yield item
elif a is not None:
yield str(a)
def plain(self, args):
yield args[0].value if args else ""
def start(self, args):
def flatten(x):
if isinstance(x, str):
yield x
elif isinstance(x, lark.Token):
yield str(x)
elif x is None or x == ():
pass
elif hasattr(x, "__iter__"):
for item in x:
yield from flatten(item)
else:
yield str(x)
return "".join(flatten(args))
def __default__(self, data, children, meta):
for child in children:
yield child
return AtStep().transform(tree)