Files

802 lines
37 KiB
Python

"""Fixed values, combines and prompt templates from plain language.
Three small answers, each forced by a JSON schema, each checked here and by
its engine handler, shared by the editor panels, the chat and the pack
builder:
fixed values {"values": [{"name", "value", "negative"}]}
combine {"template", "negative"}
template {"template", "negative"}
What each may hold follows the engine (see `refs.compile_text`): a fixed
value is text with optional ``{a|b}`` picks; a combine reads ``$vars`` and
may pick; the Assembler's template reads ``$vars`` only (it has no seed, so a
pick would stay as written) and its negative has a ``$negatives`` slot where
the negatives of the variables the prompt used go.
"""
from __future__ import annotations
import re
import secrets
from typing import Any
from engine.ai import about, adult
from engine.ai import refs as ai_refs
from engine.ai import scripts as ai_scripts
from engine.modules.combine_handler import CombineHandler
from engine.modules.fixed_values_handler import FixedValuesHandler
from engine.negatives import with_slot
from engine.syntax.tokenize import tokenize_text
from engine.syntax.types import TokenKind
MAX_VALUES = 40
MAX_TEXT = 2000
MAX_TEMPLATE = 4000
_IDENT = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]{0,63}$")
_VAR_READ = re.compile(r"\$([a-zA-Z_][a-zA-Z0-9_]*)")
_STR = {"type": "string"}
def _obj(props: dict[str, Any]) -> dict[str, Any]:
return {"type": "object", "additionalProperties": False,
"required": list(props), "properties": props}
def _cap(n: int) -> dict[str, Any]:
return {"type": "string", "maxLength": n}
#: Longest strings the answer schemas let a model write. Unbounded, a 9B
#: wrote one on until the output limit cut the answer off (the Template
#: panel on an empty library, 2026-10-24; the pack builder's slot
#: negatives the same day); a real template or value is far shorter.
SCHEMA_TEMPLATE_CHARS = 1200
SCHEMA_VALUE_CHARS = 400
SCHEMA_NEGATIVE_CHARS = 400
FIXED_SCHEMA = _obj({"values": {"type": "array", "maxItems": MAX_VALUES, "items": _obj({
"name": _cap(64), "value": _cap(SCHEMA_VALUE_CHARS),
"negative": _cap(SCHEMA_NEGATIVE_CHARS),
})}})
COMBINE_SCHEMA = _obj({"template": _cap(SCHEMA_TEMPLATE_CHARS),
"negative": _cap(SCHEMA_NEGATIVE_CHARS)})
TEMPLATE_SCHEMA = _obj({"template": _cap(SCHEMA_TEMPLATE_CHARS),
"negative": _cap(SCHEMA_NEGATIVE_CHARS)})
#: Fixed values can't read a variable, so their panel needs the library
#: only for its style and the names already taken: a few variables with a
#: few values each, the rest by name. With the full narrowed catalog the
#: panel's prompt was 5.6k tokens on Dumi's library (9B, 2026-10-24).
FIXED_CATALOG_VARS = 6
FIXED_CATALOG_VALUES = 6
_INTRO = """\
You write for Wildcard Pipeline, a ComfyUI extension that builds \
image-generation prompts from variables ($name) that modules set in order. \
Write text exactly as it should appear in the prompt, in the style of the \
library (usually comma-separated tags; sentences for models like Flux). Don't \
add commentary; the output is data for the user's own library.\
"""
FIXED_SYSTEM = _INTRO + """
You write a fixed values module: variables set to the same text every run, \
like a style preset or quality tags ("$quality = masterpiece, best quality"). \
Each value is {name, value, negative}:
- name: the variable it sets, letters, digits and _ (no $).
- value: the text. It can't read $variables. {a|b} picks one of a or b each \
run when a little variation helps.
- negative: words this value puts in the negative prompt when the prompt uses \
it (a style preset's "3d render, photo" for an anime style), or "".\
"""
COMBINE_SYSTEM = _INTRO + """
You write a combine: a template that joins variables into a new one ("$hair \
$eyes" → $look). Read variables as $name ($name.0 for the first of a \
multi-pick, $name.AXIS for an accepts tag group's rolled tag, $name.neg for \
a variable's negative words). {a|b} picks one of a or b each run. You can't \
point at wildcards here (@{...} stays as written); read their variables. \
In the tags style, put ", " between the variables it joins ("$hair, $eyes"), \
since each holds its own tags. \
negative: words this combine adds to the negative prompt when the prompt \
uses it, or "". It also carries the negatives of the variables it reads.\
"""
TEMPLATE_SYSTEM = _INTRO + """
You write the final prompt template for the WP Prompt Assembler. It reads \
variables as $name and nothing else: the Assembler has no seed, so {a|b} \
and @{...} would stay as written. Put every choice in a wildcard or combine \
and read its variable here. Order it the way the model reads best (subject \
first, then details, then style and quality).
negative: the standing negative prompt. Write $negatives where the negative \
words of the variables the prompt used should go (e.g. "$negatives, lowres, \
bad hands, watermark", or just "$negatives"); without $negatives they are \
left out.\
"""
def prompt_catalog(kind: str, catalog: dict[str, Any], request: str) -> dict[str, Any]:
"""The catalog a panel's prompt lists. A combine or template doesn't
see the loop and model variables unless the request names them: they
are for rules, and listed as ordinary variables the 9B printed them
(bench T3: "$model_family $model_name $iteration $iteration_total")."""
if kind == "fixed_values":
return catalog
from engine.ai.library_vars import RUNTIME_VARS
named = set(_ASKED_VAR.findall(request or ""))
return {k: v for k, v in catalog.items() if k not in RUNTIME_VARS or k in named
or any(s.get("kind") not in ("loop", "model") for s in v.get("sources") or [])}
def build_prompt(
*, kind: str, instruction: str, name: str, catalog_text: str,
description: str = "", labels: list[str] | None = None, current: str = "",
out_var: str = "",
others: dict[str, str] | None = None,
) -> str:
what = {"fixed_values": "Fixed values", "combine": "Combine",
"template": "Prompt template"}[kind]
lines = [f"{what}: {name or '(unnamed)'}"]
lines.extend(about.lines(description, labels or []))
if out_var:
lines.append(f"It sets ${out_var}." if kind != "fixed_values" else
f"Its main value is ${out_var}: always set it.")
if others:
lines.append("Other items set these; don't set them here: " + ", ".join(
f"${v} ({n})" for v, n in list(others.items())[:40]) + ".")
if current:
lines.append("It holds now:")
lines.append(current)
lines.append("")
lines.append("Variables in the library (name, where it comes from, its options):")
lines.append(catalog_text or "(none yet)")
from engine.ai import guide
fmt = guide.format_line(instruction)
if fmt and kind != "combine":
lines.append("")
lines.append(f"How that model reads a prompt: {fmt}")
lines.append("")
lines.append(f"Request: {instruction.strip()}")
return "\n".join(lines)
def _flat(raw: Any, limit: int) -> str:
s = re.sub(r"\s+", " ", raw).strip() if isinstance(raw, str) else ""
return s[:limit]
def reads(text: str) -> list[str]:
return list(dict.fromkeys(_VAR_READ.findall(text or "")))
def _unknown(text: str, catalog: dict[str, Any] | None, own: str = "") -> list[str]:
if catalog is None:
return []
return [v for v in reads(text) if (v not in catalog or v == own) and v != "negatives"]
def compile_fixed(
raw: Any, *, existing: dict[str, Any] | None = None, request: str = "",
) -> dict[str, Any]:
"""New (or replaced) values. A value whose name is already in
``existing`` replaces that row's text and keeps its id, so a rewrite
stays one row. Returns ``{payload, rows, preview, warnings, skipped,
changed}``; ``rows`` are the new and changed values, each
``{id, name, value, negative, status: "new"|"changed", before?}``, so a
panel can offer them one by one. Raises ValueError when nothing is
usable."""
if not isinstance(raw, dict) or not isinstance(raw.get("values"), list):
raise ValueError("the model's answer wasn't a list of values")
rows = [dict(v) for v in (existing or {}).get("values") or [] if isinstance(v, dict)]
by_name = {v.get("name"): v for v in rows if isinstance(v.get("name"), str)}
taken = {v.get("id") for v in rows}
reference = " ".join([request, *(f"{v.get('value') or ''}" for v in rows)])
preview: list[str] = []
skipped: list[dict[str, str]] = []
out_rows: list[dict[str, Any]] = []
notes: list[str] = []
changed = 0
for item in raw["values"][:MAX_VALUES]:
if not isinstance(item, dict):
continue
name = _flat(item.get("name"), 70).lstrip("$")
if not _IDENT.match(name) or name.startswith("__"):
skipped.append({"text": name or "a value", "reason": "not a variable name"})
continue
try:
value = ai_refs.compile_text(_flat(item.get("value"), MAX_TEXT), surface="fixed")
negative = ai_refs.compile_text(_flat(item.get("negative"), MAX_TEXT),
surface="fixed")
except ValueError as e:
skipped.append({"text": name, "reason": str(e)})
continue
value, dropped = ai_scripts.strip_foreign(dedupe_list(value), reference)
negative, dropped_neg = ai_scripts.strip_foreign(negative, reference)
if dropped or dropped_neg:
notes.append(f"Took {', '.join(dropped + dropped_neg)[:60]} out of ${name}: "
"it's in another script than your library")
row = by_name.get(name)
# On a value that exists, a left-out text keeps it and a left-out
# negative keeps that ("negative": "" clears it), so the model can
# change just one of the two.
if row is not None:
value = value or str(row.get("value") or "")
if "negative" not in item:
negative = str(row.get("negative") or "")
# A value that only keeps words out ("bad anatomy, extra fingers" as
# its negative, nothing in the prompt) is a value too (9B bench Z9,
# 2026-10-26: a Pony preset's anatomy negatives were dropped).
negative = clean_negative(negative, value)
if not value and not negative:
skipped.append({"text": name, "reason": "empty"})
continue
why = adult.blocked(value)
if why:
skipped.append({"text": name, "reason": why})
continue
twin = next((r for r in rows if r is not row and value
and _list_key(str(r.get("value") or "")) == _list_key(value)), None)
if twin is not None:
# Bench F1: $quality and $style came back word for word the same.
skipped.append({"text": name, "reason": f"same text as ${twin.get('name')}"})
continue
before = None if row is None else {"value": str(row.get("value") or ""),
"negative": str(row.get("negative") or "")}
if row is None:
row = {"id": _new_id(taken), "name": name}
rows.append(row)
by_name[name] = row
elif row.get("value") == value and (row.get("negative") or "") == negative:
continue
row["value"] = value
if negative:
row["negative"] = negative
else:
row.pop("negative", None)
if not value:
# Bench Z9: a value that only adds negatives was dropped as empty.
notes.append(f"${name} only adds negative words; they reach the negative when "
f"the template reads ${name}")
changed += 1
out_rows.append({"id": row["id"], "name": name, "value": value, "negative": negative,
"status": "new" if before is None else "changed",
**({"before": before} if before else {})})
preview.append(f"${name} = {value}" + (f" (negative: {negative})" if negative else ""))
if not changed:
raise ValueError("no usable values" + "".join(
f"; {s['text'][:40]}: {s['reason']}" for s in skipped[:3]))
payload = dict(existing or {})
payload["values"] = rows
FixedValuesHandler.validate_payload(payload)
return {"payload": payload, "rows": out_rows, "preview": preview, "skipped": skipped,
"changed": changed,
"warnings": [f"Left out ${s['text'][:40]}: {s['reason']}" for s in skipped[:6]]
+ notes}
def _list_key(text: str) -> str:
return ",".join(sorted(p.strip().lower() for p in text.split(",") if p.strip()))
def dedupe_list(text: str) -> str:
"""A comma list with repeated pieces taken out, first spelling kept
(bench F1 run 2: "marker, pen, pencil, ink…" over and over until the
output limit). Text without commas is left as it is."""
if "," not in (text or ""):
return text
parts = [p.strip() for p in text.split(",")]
seen: set[str] = set()
out: list[str] = []
for p in parts:
key = re.sub(r"\s+", " ", p.lower())
if p and key not in seen:
seen.add(key)
out.append(p)
return ", ".join(out)
def clean_negative(negative: str, value: str) -> str:
"""A negative without repeats and without words its own value prints
(bench F1: "watercolor" in a watercolor preset's negative; #35: a
value's negative repeating the value)."""
negative = dedupe_list(negative)
if not negative or not value:
return negative
low = value.lower()
kept = [p.strip() for p in negative.split(",") if p.strip() and not (
"$" not in p and re.search(rf"(?<![\w-]){re.escape(p.strip().lower())}(?![\w-])", low))]
return ", ".join(kept)
def _new_id(taken: set[Any]) -> str:
while True:
vid = f"v{secrets.token_hex(4)}"
if vid not in taken:
taken.add(vid)
return vid
_AXIS_READ = re.compile(r"\$([a-zA-Z_][a-zA-Z0-9_]*)\.([a-zA-Z_][a-zA-Z0-9_]*)")
def _fix_axes(text: str, catalog: dict[str, Any] | None,
warnings: list[str]) -> str:
"""``$x.WORD`` reads x's accepts tag group WORD (or ``.neg``). A word that
isn't one of x's groups ("$race.tags", "$outerwear.AXIS") renders as
nothing at run time, so it becomes a plain ``$x``."""
def sub(m: re.Match[str]) -> str:
var, word = m.group(1), m.group(2)
entry = (catalog or {}).get(var)
if word == "neg" or not entry or not entry.get("sources") \
or word in (entry.get("axes") or []):
return m.group(0)
warnings.append(f"Read ${var} instead of ${var}.{word}: ${var} has no {word} tag group")
return f"${var}"
return _AXIS_READ.sub(sub, text)
_ASKED_VAR = re.compile(r"\$([a-zA-Z_][a-zA-Z0-9_]*)")
#: "{portrait}" or "{ }": braces around plain words, not a pick.
_STRAY_BRACES = re.compile(r"(?<![@$])\{([^{}|$]*)\}")
#: A ref or read with nothing inside ("@{ }", "${}"): raw braces in the
#: prompt (9B, 2026-10-20, pack 14).
_EMPTY_REF = re.compile(r"[@$]\{\s*\}")
def _unbrace(text: str, warnings: list[str],
catalog: dict[str, Any] | None = None) -> str:
"""Braces around plain words, which the engine refuses as "not a pick",
become the words (re-test on d6ac847: one template draft came back 502
for a "{...}"). ``{character}`` around a variable's name is a read of
it, ``$character`` (bench T1 run 3, T3 run 1: the braces were dropped
and the template then read nothing)."""
def sub(m: re.Match[str]) -> str:
inner = m.group(1).strip()
if catalog and _IDENT.match(inner) and inner in catalog:
warnings.append(f"Read ${inner} for {{{inner}}}: a variable is written $name")
return f"${inner}"
if inner:
warnings.append(f"Dropped the braces around \"{inner[:40]}\": a {{ }} is a pick, "
"written {a|b}")
return inner
out = _STRAY_BRACES.sub(sub, text or "")
if _EMPTY_REF.search(out):
warnings.append("Dropped an empty @{ } / ${ }: it names nothing and prints as written")
out = _EMPTY_REF.sub("", out)
return re.sub(r"[ \t]{2,}", " ", out).strip() if out != text else out
def _first_pick(text: str, warnings: list[str]) -> str:
"""A ``{a|b}`` pick in a template becomes its first branch, with a
warning: the Assembler is seedless, so the pick would print as written
and the route used to answer 502 (base 9B, 2026-10-19: one template
draft in three wrote one). The choice belongs in a wildcard."""
out: list[str] = []
for tok in tokenize_text(text or ""):
if tok.kind in (TokenKind.DP_BRACE, TokenKind.DP_MULTI):
branches = [b.strip() for b in tok.meta.get("branches") or [] if b.strip()]
first = branches[0] if branches else ""
warnings.append(f"Kept \"{first[:40]}\" from {tok.raw[:40]}: the Assembler can't "
"pick; put the choice in a wildcard or a combine and read its "
"variable")
out.append(first)
else:
out.append(tok.raw)
return "".join(out)
def unasked(text: str, asked: str, reads_: list[str], had: list[str] | None = None) -> str:
"""A warning naming the ``$vars`` the draft reads that the request,
which spelled out two or more, didn't ask for, or "". Re-test on
d6ac847 (9B): two template drafts read ~20 library variables for a
three-variable ask. ``had`` are the variables the item read before an
edit: keeping them is no surprise (bench K3)."""
want = list(dict.fromkeys(_ASKED_VAR.findall(asked or "")))
if len(want) < 2:
return ""
extra = [v for v in reads_ if v not in want and v != "negatives" and v not in (had or [])]
if not extra:
return ""
return ("Also reads $" + ", $".join(extra[:12]) + (", …" if len(extra) > 12 else "")
+ ", which you didn't ask for; take out what you don't want")
def answer_tokens(kind: str) -> int:
"""The output limit for a text panel's answer, reasoning off. A template
or combine of `MAX_TEMPLATE` characters is about 1k tokens, a full value
list about 2k; a model looping on the same words (the 9B on a portrait
template, 2026-10-24) is stopped after these instead of after 8k."""
return 2048 if kind == "fixed_values" else 1536
def unset_asked(raw: Any, asked: str) -> list[str]:
"""The ``$vars`` a fixed values request spelled out that the answer
doesn't set. A fixed value can't read variables, so every one the
request names is one to set."""
values = raw.get("values") if isinstance(raw, dict) else None
have = {str(v.get("name") or "").lstrip("$") for v in values or [] if isinstance(v, dict)}
return [v for v in dict.fromkeys(_ASKED_VAR.findall(asked or "")) if v not in have]
def left_out(kind: str, raw: Any, asked: str) -> list[str]:
"""The ``$vars`` the request names that the answer leaves out: not set
(fixed values) or not read (a combine or template's text)."""
if kind == "fixed_values":
return unset_asked(raw, asked)
text = raw.get("template") if isinstance(raw, dict) else None
have = set(reads(text if isinstance(text, str) else ""))
return [v for v in dict.fromkeys(_ASKED_VAR.findall(asked or "")) if v not in have]
def missing_asked(text: str, asked: str) -> str:
"""A warning naming the ``$vars`` the request spelled out that the draft
doesn't read, or "". Browser test (9B): a portrait template asked to read
$quality_tags came back without it, quietly."""
have = set(reads(text))
gone = [v for v in dict.fromkeys(_ASKED_VAR.findall(asked or "")) if v not in have]
return ("Doesn't read $" + ", $".join(gone) + ", which you asked for") if gone else ""
def compile_combine(
raw: Any, *, out_var: str, catalog: dict[str, Any] | None = None,
existing: dict[str, Any] | None = None, request: str = "",
) -> dict[str, Any]:
"""A combine payload. Returns ``{payload, preview, warnings}``. With
``request`` (the panel's typed ask) the text is also held to it, see
`_tidy_text`."""
if not isinstance(raw, dict) or not isinstance(raw.get("template"), str):
raise ValueError("the answer had no template")
text = ai_refs.compile_text(_flat(raw["template"], MAX_TEMPLATE), surface="combine")
if not text:
raise ValueError("the template is empty")
negative = ai_refs.compile_text(_flat(raw.get("negative"), MAX_TEXT), surface="combine")
why = adult.blocked(text)
if why:
raise ValueError(why)
warnings: list[str] = []
text = _fix_axes(text, catalog, warnings)
if out_var in reads(text):
# Bench K3: 2 of 3 edits failed on this; the rest of the text is good.
text = drop_reads(text, {out_var})
warnings.append(f"Took out ${out_var}: the combine sets it, so it can't read it")
if not reads(text) and not text.strip(" ,"):
raise ValueError(f"the template only read ${out_var}, which it sets itself")
text, negative = _tidy_text(text, negative, request, catalog, warnings, kind="combine")
if not text:
raise ValueError("the template is empty")
unknown = _unknown(text, catalog, out_var)
if unknown:
warnings.append("Reads $" + ", $".join(unknown) + ", which nothing in the library sets")
payload = dict(existing or {})
payload.update({"template": text, "output_var": out_var,
"input_vars": [v for v in reads(text) if v != out_var]})
if negative:
payload["negative"] = negative
else:
payload.pop("negative", None)
CombineHandler.validate_payload(payload)
preview = [f"${out_var} = {text}"] + ([f"Negative: {negative}"] if negative else [])
return {"payload": payload, "preview": preview, "warnings": warnings}
def compile_template(raw: Any, *, catalog: dict[str, Any] | None = None,
request: str = "") -> dict[str, Any]:
"""``{payload: {template_string, negative_template}, preview, warnings}``.
With ``request`` the text is also held to it (see `_tidy_text`), and
a request that asks the negative to keep youth out ("a negative
against anything youthful") gets the youth words the model left out
(bench Z10: the saved negative had none)."""
if not isinstance(raw, dict) or not isinstance(raw.get("template"), str):
raise ValueError("the answer had no template")
warnings: list[str] = []
text = ai_refs.compile_text(
_first_pick(_unbrace(_flat(raw["template"], MAX_TEMPLATE), warnings, catalog),
warnings),
surface="template")
if not text:
raise ValueError("the template is empty")
negative = ai_refs.compile_text(
_first_pick(_unbrace(_flat(raw.get("negative"), MAX_TEMPLATE), warnings, catalog),
warnings),
surface="template")
why = adult.blocked(text)
if why:
raise ValueError(why)
text = _fix_axes(text, catalog, warnings)
text, negative = _tidy_text(text, negative, request, catalog, warnings, kind="template")
if not text:
raise ValueError("the template is empty")
unknown = _unknown(text, catalog)
if unknown:
warnings.append("Reads $" + ", $".join(unknown) + ", which nothing in the library sets")
if not reads(text):
warnings.append("It reads no variables, so it's the same every run")
if adult.asks_youth_negative(request) and not adult.youth_words(negative):
style = "tags" if text.count(",") >= 3 else "sentences"
negative, added = adult.with_youth_negative(negative, style)
if added:
warnings.append("Added " + ", ".join(added) + " to the negative: the request "
"asks it to keep youth out")
if re.search(r"\$\w+[^$]{0,40}\s(?:OR|or)\s[^$]{0,40}\$", text):
warnings.append("It joins picks with \"or\", but the Assembler prints every one; "
"read the one pick instead")
# Negatives are opt-in: a negative without the slot would drop every
# variable's negatives, which no draft means to do.
negative = with_slot(negative)
return {"payload": {"template_string": text, "negative_template": negative},
"preview": [text] + ([f"Negative: {negative}"] if negative else []),
"warnings": warnings}
# ── holding a combine or template to its request ─────────────────────────
def drop_reads(text: str, names: set[str]) -> str:
"""`text` without its reads of `names` (``$x``, ``$x.0``, ``$x.AXIS``),
with the commas and spaces around them tidied."""
out = re.sub(r"\$(" + "|".join(map(re.escape, sorted(names))) + r")(?:\.[A-Za-z0-9_]+)*\b",
"", text)
out = re.sub(r"\s*,\s*(?:,\s*)+", ", ", out)
out = re.sub(r"[ \t]{2,}", " ", out).strip().strip(",").strip()
# "$s and $look" without $s: no "and" left hanging at either end.
out = re.sub(r"^(?:and|with|or|of|in)\b\s*|\s*\b(?:and|with|or|of|in)$", "", out)
return out.strip().strip(",").strip()
#: Words that only belong in a negative prompt (bench T3 run 2 put "bad
#: anatomy, bad hands" in the positive).
NEGATIVE_ONLY = frozenset({
"bad anatomy", "bad hands", "bad proportions", "worst quality", "low quality",
"lowres", "blurry", "watermark", "jpeg artifacts", "extra fingers", "extra digits",
"fewer digits", "missing fingers", "deformed", "disfigured", "mutated hands",
"mutation", "signature", "username", "ugly", "poorly drawn hands", "poorly drawn face",
})
#: Quality tags a natural-sentence model (Flux and friends) doesn't use.
_TAG_QUALITY = frozenset({
"masterpiece", "best quality", "high quality", "8k", "4k", "uhd", "absurdres", "highres",
"ultra detailed", "highly detailed", "very aesthetic", "score_9", "score_8_up",
"score_7_up",
})
#: Families that read natural sentences (engine/ai/guide.py FORMATS).
_SENTENCE_FAMILIES = frozenset({"flux", "sd3", "chroma", "qwen_image", "hidream", "lumina2",
"wan"})
PONY_SCORE = "score_9, score_8_up, score_7_up"
_SCORE_WORD = re.compile(r"\bscore[ _]tags?\b", re.I)
_SHORT_NEG = re.compile(r"\bshort\b[^.;]{0,30}\bnegative|\bnegative\b[^.;]{0,20}\bshort\b",
re.I)
#: A "short" negative keeps this many pieces besides $negatives.
SHORT_NEGATIVE = 8
_SENTENCES = re.compile(r"\b(?:natural[- ]?(?:language|sentences?)|sentences?|prose)\b",
re.I)
def wants_sentences(request: str) -> bool:
"""Whether the request asks for natural sentences (by name or model)."""
from engine.ai import guide
return bool(_SENTENCES.search(request or "")) or bool(
set(guide.families_in(request or "")) & _SENTENCE_FAMILIES)
def _pieces(text: str) -> list[str]:
return [p.strip() for p in (text or "").split(",") if p.strip()]
def _vowel_warning(text: str, catalog: dict[str, Any] | None) -> str:
""""a $mood" prints "a angry" for an option that starts with a vowel
(bench K1, #44)."""
for m in re.finditer(r"(?<![\w])[Aa] \$([a-zA-Z_][a-zA-Z0-9_]*)\b", text):
entry = (catalog or {}).get(m.group(1)) or {}
hits = [v for v in entry.get("values") or [] if re.match(r"[aeiouAEIOU]", str(v))]
if hits:
return (f"\"a ${m.group(1)}\" reads \"a {hits[0]}\" for options that start with "
"a vowel; put the a / an in the options, or leave it out")
return ""
def _drop_echo(text: str, catalog: dict[str, Any] | None, warnings: list[str]) -> str:
""""with $hair_color hair" prints "brown hair hair" when the options
already end in the word: the word after the variable goes."""
def fix(m: re.Match[str]) -> str:
entry = (catalog or {}).get(m.group(1)) or {}
vals = [str(v).strip().lower() for v in entry.get("values") or [] if str(v).strip()]
word = m.group(2).lower()
if vals and sum(v.endswith(" " + word) or v == word for v in vals) * 2 > len(vals):
warnings.append(f"Took \"{m.group(2)}\" out after ${m.group(1)}: its options "
f"already end in it, so it printed \"{entry['values'][0]} "
f"{m.group(2)}\"")
return f"${m.group(1)}"
return m.group(0)
return re.sub(r"\$([a-zA-Z_][a-zA-Z0-9_]*) ([A-Za-z]+)\b(?!\s*[:(])", fix, text)
def _balance(text: str, warnings: list[str]) -> str:
"""Brackets that don't close (bench Z10: a 100-word template with an
unbalanced "(") are taken out; escaped ``\\(`` stays."""
out: list[str] = []
stack: list[int] = []
dropped = 0
for i, ch in enumerate(text):
escaped = i > 0 and text[i - 1] == "\\"
if ch == "(" and not escaped:
stack.append(len(out))
elif ch == ")" and not escaped:
if not stack:
dropped += 1
continue
stack.pop()
out.append(ch)
for at in reversed(stack):
out.pop(at)
dropped += 1
if dropped:
warnings.append(f"Took out {dropped} bracket{'s' if dropped != 1 else ''} that "
"didn't close")
return re.sub(r"[ \t]{2,}", " ", "".join(out)).strip()
def _tidy_text(text: str, negative: str, request: str, catalog: dict[str, Any] | None,
warnings: list[str], *, kind: str) -> tuple[str, str]:
"""A combine's or template's text and negative, cleaned in code:
- repeated comma pieces go (a looping model's "masterpiece, masterpiece…");
- brackets that don't close go;
- negative-only words in the positive move to the negative;
- a word after a variable whose options already end in it goes;
and with ``request`` (the panel's ask):
- loop and model variables the request doesn't name are taken out (they
are for rules, not prompt text: bench T3, #4);
- a Pony ask gets the real score tags first ("score_tags" becomes them);
- a natural-sentence ask (Flux…) loses its quality-tag soup;
- a "short" negative is cut to its first few pieces."""
text = _balance(dedupe_list(text), warnings)
negative = _balance(dedupe_list(negative), warnings) if negative else negative
moved = [p for p in _pieces(text) if p.lower() in NEGATIVE_ONLY] if "," in text else []
if moved:
text = ", ".join(p for p in _pieces(text) if p.lower() not in NEGATIVE_ONLY)
parts = _pieces(negative)
have = {p.lower() for p in parts}
# Up front (after the $negatives slot), so a "short" cut keeps them.
at = 1 if parts[:1] == ["$negatives"] else 0
negative = ", ".join([*parts[:at], *(p for p in moved if p.lower() not in have),
*parts[at:]])
warnings.append(f"Moved {', '.join(moved[:4])} to the negative: "
f"{'it only belongs' if len(moved) == 1 else 'they only belong'} there")
text = _drop_echo(text, catalog, warnings)
vowel = _vowel_warning(text, catalog)
if vowel:
warnings.append(vowel)
if not request:
return text, negative
from engine.ai import guide
from engine.ai.library_vars import RUNTIME_VARS
named = set(_ASKED_VAR.findall(request))
runtime = {v for v in reads(text) if v in RUNTIME_VARS and v not in named}
if runtime:
text = drop_reads(text, runtime)
warnings.append("Took out $" + ", $".join(sorted(runtime)) + ": loop and model "
"variables are for rules, not for the prompt text")
families = guide.families_in(request)
if kind == "template" and "pony" in families:
if _SCORE_WORD.search(text):
text = _SCORE_WORD.sub(PONY_SCORE, text, count=1)
warnings.append(f"Wrote Pony's score tags ({PONY_SCORE}) for \"score tags\"")
elif "score_9" not in text:
text = f"{PONY_SCORE}, {text}"
warnings.append(f"Put Pony's score tags ({PONY_SCORE}) first")
if wants_sentences(request) and "pony" not in families:
soup = [p for p in _pieces(text) if p.lower() in _TAG_QUALITY]
if soup and "," in text:
text = ", ".join(p for p in _pieces(text) if p.lower() not in _TAG_QUALITY)
warnings.append(f"Took out {', '.join(soup[:4])}: a natural-sentence model "
"doesn't use quality tags")
if negative and _SHORT_NEG.search(request):
parts = _pieces(negative)
slot = [p for p in parts if p == "$negatives"]
rest = [p for p in parts if p != "$negatives"]
if len(rest) > SHORT_NEGATIVE:
negative = ", ".join([*slot, *rest[:SHORT_NEGATIVE]])
warnings.append(f"Cut the negative to its first {SHORT_NEGATIVE} words: you "
"asked for a short one")
return text.strip(" ,"), negative
#: Request words that say nothing about the words around the variables.
_PLAIN_ASK = frozenset("""a an the and or of to in on at for with from by as using use uses
used my your this that these those it its make write create join prompt template combine
negative negatives standard short long plus also one into each every all them they their
variable variables only just keep it's natural sentence sentences prompts tag tags model
checkpoint against anything put first then last order reorder like""".split())
def recheck_notes(kind: str, raw: Any, request: str) -> list[str]:
"""What a combine or template answer misses of its request, as notes
for one re-ask (code checks, the model repairs):
- a natural-sentence ask (Flux, "natural sentences") answered with a
comma tag list (bench T1: every run);
- a template that is only variables when the request asks for words
around them (bench T4: "close-up portrait" came back as
"$mood $hair_color $accessory")."""
if kind == "fixed_values" or not isinstance(raw, dict):
return []
got = raw.get("template")
text = got if isinstance(got, str) else ""
notes: list[str] = []
if wants_sentences(request) and looks_like_tags(text):
notes.append("The request asks for natural sentences, but your last answer is a "
"comma list of tags. Write the prompt as one or two full sentences "
"that read the variables, without quality tags.")
bare = re.sub(r"\$[\w.]+|[\s,.;:()]+", "", text)
words = [w for w in re.findall(r"[a-z][a-z-]+", re.sub(r"\$[\w.]+", "", request.lower()))
if w not in _PLAIN_ASK and len(w) > 2]
if text.strip() and not bare and words:
notes.append("Your last answer is only variables. Write the words the request asks "
f"for ({', '.join(dict.fromkeys(words[:5]))}) around them.")
return notes
def looks_like_tags(text: str) -> bool:
"""Whether `text` is a comma tag list rather than sentences: four or
more pieces and almost none with a sentence word in it."""
parts = _pieces(re.sub(r"\$[\w.]+", "x", text or ""))
if len(parts) < 4:
return False
worded = sum(1 for p in parts if len(p.split()) >= 4
or re.search(r"\b(?:is|are|with|in|on|the|a|an|her|his|their)\b", p, re.I))
return worded * 3 < len(parts)
def sample_texts(modules: list[dict[str, Any]], skip: str | None = None) -> dict[str, str]:
"""What fixed values and combines print, by variable, so `samples`
can render them too (`skip` is the item being drafted)."""
out: dict[str, str] = {}
for m in modules:
if not isinstance(m, dict) or m.get("id") == skip:
continue
p = m.get("payload") if isinstance(m.get("payload"), dict) else {}
if m.get("type") == "fixed_values":
for v in p.get("values") or []:
if isinstance(v, dict) and isinstance(v.get("name"), str) \
and isinstance(v.get("value"), str):
out.setdefault(v["name"].strip(), v["value"])
elif m.get("type") == "combine" and isinstance(p.get("output_var"), str) \
and isinstance(p.get("template"), str):
out.setdefault(p["output_var"].strip(), p["template"])
return out
def samples(text: str, catalog: dict[str, Any] | None, *, seed: str, n: int = 3,
texts: dict[str, str] | None = None) -> list[str]:
"""``n`` example renders of a combine or template: each ``$var`` read
takes one of that variable's library options (seeded by `seed`, so
the examples don't jump between renders), or what the fixed value or
combine in `texts` prints (read in turn, a few levels deep); a
variable with neither stays as written. For the panels' "what it
prints" lines."""
import random
if not catalog or not reads(text):
return []
rng = random.Random(seed)
out: list[str] = []
var = r"\$([a-zA-Z_][a-zA-Z0-9_]*)(?:\.[A-Za-z0-9_]+)*"
for _ in range(n):
def sub(m: re.Match[str], depth: int = 0) -> str:
if texts and m.group(1) in texts and depth < 4:
return re.sub(var, lambda x: sub(x, depth + 1), texts[m.group(1)])
entry = (catalog or {}).get(m.group(1)) or {}
vals = [v for v in entry.get("values") or [] if isinstance(v, str)
and "@{" not in v and "{" not in v]
return rng.choice(vals) if vals else m.group(0)
line = re.sub(var, sub, text)
line = re.sub(r"\s*,\s*(?:,\s*)+", ", ", line).strip(" ,")
if line not in out:
out.append(line)
return out