Add compiler/builder/prompt_builder.py: build_prompts(category_map, config, logger=None) -> CompilerResult (data = tuple[PromptOutput]), per MASTER_SPEC §19. - Emits one output per canonical category (in order) plus reserved `negative` and `scene`; V1 always emits all 21 outputs for stable node sockets. - Tags joined with config.prompt_builder.separator (default comma, no surrounding whitespace, no trailing separator). Empty categories yield an empty string, never null/placeholder (§19.4). - Formatting only: no tag generation, removal, reordering, dedup, or escaping (§19.1, §19.5). Output names are the lowercase canonical category ids and `negative`/`scene`, mapping to the §19.2/§19.3 display names. Tests: output set (19 + 2 reserved), comma join, order preservation, empty strings, reserved-empty, custom separator, no-trailing-separator, no escaping, determinism. Co-authored-by: kunail0804 <kunail0804@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
kunail0804
Claude Opus 4.8
parent
499a2490ca
commit
30312136f9
@@ -0,0 +1,50 @@
|
||||
"""The Prompt Builder (MASTER_SPEC §19).
|
||||
|
||||
Formats the Category Map into one plain UTF-8 Prompt Output per category, plus
|
||||
the reserved ``negative`` and ``scene`` outputs. This stage is formatting only:
|
||||
it never generates, removes, reorders, deduplicates, escapes, or optimizes tags
|
||||
(§19.1, §19.5).
|
||||
|
||||
Version 1 always emits all 21 outputs (19 categories + 2 reserved) so the ComfyUI
|
||||
node exposes a stable set of sockets. Empty categories yield an empty string —
|
||||
never null or placeholder text (§19.4). Output names are the lowercase canonical
|
||||
category identifiers (and ``negative`` / ``scene``), corresponding one-to-one to
|
||||
the §19.2/§19.3 display names.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from compiler.common.categories import CANONICAL_CATEGORIES
|
||||
from compiler.common.config import Config
|
||||
from compiler.common.log import StructuredLogger
|
||||
from compiler.common.result import CompilerResult
|
||||
from schemas.models import CategoryMap, PromptOutput
|
||||
|
||||
# Reserved outputs, always emitted empty in V1 (nothing populates them).
|
||||
RESERVED_OUTPUTS: tuple[str, ...] = ("negative", "scene")
|
||||
|
||||
|
||||
def build_prompts(
|
||||
category_map: CategoryMap,
|
||||
config: Config,
|
||||
logger: StructuredLogger | None = None,
|
||||
) -> CompilerResult:
|
||||
"""Format a Category Map into the final Prompt Outputs.
|
||||
|
||||
Returns:
|
||||
A CompilerResult whose ``data`` is the tuple of :class:`PromptOutput`:
|
||||
one per canonical category (in order) followed by the reserved outputs.
|
||||
"""
|
||||
separator = config.prompt_builder.separator
|
||||
|
||||
outputs: list[PromptOutput] = []
|
||||
for category in CANONICAL_CATEGORIES:
|
||||
value = separator.join(tag.tag for tag in category_map.tags_for(category))
|
||||
outputs.append(PromptOutput(name=category, value=value))
|
||||
|
||||
for reserved in RESERVED_OUTPUTS:
|
||||
outputs.append(PromptOutput(name=reserved, value=""))
|
||||
|
||||
if logger is not None:
|
||||
logger.basic("prompts_built", outputs=len(outputs))
|
||||
return CompilerResult(data=tuple(outputs))
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Tests for the Prompt Builder (issue #14, MASTER_SPEC §19)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import MappingProxyType
|
||||
|
||||
from compiler.builder.prompt_builder import RESERVED_OUTPUTS, build_prompts
|
||||
from compiler.common.categories import CANONICAL_CATEGORIES
|
||||
from compiler.common.config import Config
|
||||
from schemas.models import CategoryMap, ResolvedTag
|
||||
|
||||
|
||||
def rtag(name: str, category: str) -> ResolvedTag:
|
||||
return ResolvedTag(tag=name, category=category, source_concept=name, knowledge_base_entry=name)
|
||||
|
||||
|
||||
def category_map(**by_category: list[ResolvedTag]) -> CategoryMap:
|
||||
return CategoryMap(categories=MappingProxyType({k: tuple(v) for k, v in by_category.items()}))
|
||||
|
||||
|
||||
def outputs_by_name(result) -> dict[str, str]:
|
||||
return {o.name: o.value for o in result.data}
|
||||
|
||||
|
||||
def default_config() -> Config:
|
||||
return Config()
|
||||
|
||||
|
||||
def test_emits_one_output_per_category_plus_reserved() -> None:
|
||||
result = build_prompts(category_map(), default_config())
|
||||
names = [o.name for o in result.data]
|
||||
assert names == [*CANONICAL_CATEGORIES, *RESERVED_OUTPUTS]
|
||||
assert len(result.data) == 19 + 2
|
||||
|
||||
|
||||
def test_tags_joined_with_default_comma_separator() -> None:
|
||||
cm = category_map(hair=[rtag("blonde hair", "hair"), rtag("long hair", "hair")])
|
||||
result = build_prompts(cm, default_config())
|
||||
assert outputs_by_name(result)["hair"] == "blonde hair,long hair"
|
||||
|
||||
|
||||
def test_order_is_preserved() -> None:
|
||||
cm = category_map(hair=[rtag("c", "hair"), rtag("a", "hair"), rtag("b", "hair")])
|
||||
result = build_prompts(cm, default_config())
|
||||
assert outputs_by_name(result)["hair"] == "c,a,b"
|
||||
|
||||
|
||||
def test_empty_category_is_empty_string() -> None:
|
||||
result = build_prompts(category_map(), default_config())
|
||||
values = outputs_by_name(result)
|
||||
assert values["hair"] == ""
|
||||
assert all(isinstance(o.value, str) for o in result.data)
|
||||
|
||||
|
||||
def test_reserved_outputs_present_and_empty() -> None:
|
||||
result = build_prompts(category_map(character=[rtag("1girl", "character")]), default_config())
|
||||
values = outputs_by_name(result)
|
||||
assert values["negative"] == ""
|
||||
assert values["scene"] == ""
|
||||
|
||||
|
||||
def test_custom_separator() -> None:
|
||||
cm = category_map(hair=[rtag("a", "hair"), rtag("b", "hair")])
|
||||
config = Config.from_json({"prompt_builder": {"separator": " | "}})
|
||||
result = build_prompts(cm, config)
|
||||
assert outputs_by_name(result)["hair"] == "a | b"
|
||||
|
||||
|
||||
def test_no_trailing_separator_or_whitespace() -> None:
|
||||
cm = category_map(hair=[rtag("only", "hair")])
|
||||
result = build_prompts(cm, default_config())
|
||||
value = outputs_by_name(result)["hair"]
|
||||
assert value == "only"
|
||||
assert not value.endswith(",")
|
||||
assert value == value.strip()
|
||||
|
||||
|
||||
def test_no_escaping_tags_emitted_verbatim() -> None:
|
||||
cm = category_map(objects=[rtag("cat (animal)", "objects")])
|
||||
result = build_prompts(cm, default_config())
|
||||
assert outputs_by_name(result)["objects"] == "cat (animal)"
|
||||
|
||||
|
||||
def test_output_is_deterministic() -> None:
|
||||
cm = category_map(hair=[rtag("a", "hair"), rtag("b", "hair")])
|
||||
a = [(o.name, o.value) for o in build_prompts(cm, default_config()).data]
|
||||
b = [(o.name, o.value) for o in build_prompts(cm, default_config()).data]
|
||||
assert a == b
|
||||
Reference in New Issue
Block a user