mirror of
https://github.com/vegu-ai/talemate.git
synced 2026-09-01 19:48:52 +02:00
* Add node-graph event signals for 0.39.0 features: visual prompt finalization, creator dialogue examples, help chat, scene asset lifecycle (#76) * review: fire scene.backdrop_changed when backdrop asset is deleted; suppress no-op backdrop updates (#78)
429 lines
15 KiB
Python
429 lines
15 KiB
Python
import pytest
|
|
import yaml
|
|
|
|
import talemate.agents.visual.finalize as finalize
|
|
from talemate.agents.visual.agent import VisualAgent
|
|
from talemate.agents.visual.finalize import (
|
|
PromptFinalizeEmission,
|
|
apply_exact,
|
|
apply_fuzzy,
|
|
apply_regex,
|
|
cleanup_prompt,
|
|
validate_finalizers,
|
|
)
|
|
from talemate.agents.visual.schema import (
|
|
FINALIZER_MODE,
|
|
FINALIZER_TARGET,
|
|
VIS_TYPE,
|
|
GenerationRequest,
|
|
PromptFinalizer,
|
|
)
|
|
from talemate.character import Character
|
|
|
|
|
|
def test_cleanup_prompt():
|
|
assert cleanup_prompt("a, , b") == "a, b"
|
|
assert cleanup_prompt(", a, b, ") == "a, b"
|
|
assert cleanup_prompt("a, b") == "a, b"
|
|
assert cleanup_prompt("a ,b") == "a,b"
|
|
|
|
|
|
class TestApplyExact:
|
|
def test_case_insensitive_by_default(self):
|
|
assert (
|
|
apply_exact("Red hair, green eyes", "red hair", "crimson hair", False)
|
|
== "crimson hair, green eyes"
|
|
)
|
|
|
|
def test_case_sensitive(self):
|
|
text = "Red hair, red hair"
|
|
assert apply_exact(text, "red hair", "crimson hair", True) == (
|
|
"Red hair, crimson hair"
|
|
)
|
|
|
|
def test_no_match_returns_original(self):
|
|
text = "green eyes"
|
|
assert apply_exact(text, "red hair", "crimson hair", False) is text
|
|
|
|
def test_empty_replacement_removes_and_cleans_up(self):
|
|
assert (
|
|
apply_exact("a woman, red hair, green eyes", "red hair", "", False)
|
|
== "a woman, green eyes"
|
|
)
|
|
|
|
def test_empty_match_is_noop(self):
|
|
assert apply_exact("a woman", "", "x", False) == "a woman"
|
|
|
|
def test_replacement_with_backslash_is_literal(self):
|
|
assert apply_exact("a woman", "woman", r"wo\man", False) == r"a wo\man"
|
|
|
|
|
|
class TestApplyFuzzy:
|
|
def test_replaces_similar_segment(self):
|
|
assert (
|
|
apply_fuzzy(
|
|
"masterpiece, red haired, 1girl", "red hair", "crimson hair", 85
|
|
)
|
|
== "masterpiece, crimson hair, 1girl"
|
|
)
|
|
|
|
def test_case_insensitive(self):
|
|
assert (
|
|
apply_fuzzy("Red Hair, 1girl", "red hair", "crimson hair", 85)
|
|
== "crimson hair, 1girl"
|
|
)
|
|
|
|
def test_empty_replacement_removes_segment(self):
|
|
assert apply_fuzzy("a, red hair, b", "red hair", "", 85) == "a, b"
|
|
|
|
def test_below_threshold_untouched(self):
|
|
text = "masterpiece, blue dress"
|
|
assert apply_fuzzy(text, "red hair", "crimson hair", 85) is text
|
|
|
|
def test_empty_match_is_noop(self):
|
|
text = "a, b"
|
|
assert apply_fuzzy(text, " ", "x", 85) is text
|
|
|
|
|
|
class TestApplyRegex:
|
|
def test_group_passthrough(self):
|
|
assert (
|
|
apply_regex(
|
|
"bright blue eyes", r"bright (\w+) eyes", r"glowing \1 eyes", []
|
|
)
|
|
== "glowing blue eyes"
|
|
)
|
|
|
|
def test_case_sensitive_flag(self):
|
|
text = "Blue eyes"
|
|
assert apply_regex(text, r"blue", "green", ["case_sensitive"]) is text
|
|
assert apply_regex(text, r"blue", "green", []) == "green eyes"
|
|
|
|
def test_invalid_pattern_returns_original(self):
|
|
text = "a, b"
|
|
assert apply_regex(text, r"(unclosed", "x", []) is text
|
|
|
|
def test_empty_replacement_cleans_up(self):
|
|
assert apply_regex("a, red hair, b", r"red \w+", "", []) == "a, b"
|
|
|
|
|
|
def test_validate_finalizers_skips_invalid_rows():
|
|
rows = [
|
|
{"mode": "EXACT", "match": "a", "replace": "b"},
|
|
{"mode": "NOT_A_MODE"},
|
|
"not a dict",
|
|
PromptFinalizer(mode=FINALIZER_MODE.REGEX, match="x"),
|
|
]
|
|
finalizers = validate_finalizers(rows)
|
|
assert len(finalizers) == 2
|
|
assert finalizers[0].mode == FINALIZER_MODE.EXACT
|
|
assert finalizers[1].mode == FINALIZER_MODE.REGEX
|
|
|
|
|
|
def test_finalizer_is_yaml_safe():
|
|
# finalizers are stored inside world state template groups, which
|
|
# are persisted with yaml.dump and reloaded with yaml.safe_load
|
|
finalizer = PromptFinalizer(
|
|
mode=FINALIZER_MODE.AI,
|
|
target=FINALIZER_TARGET.BOTH,
|
|
vis_types=[VIS_TYPE.CHARACTER_PORTRAIT],
|
|
flags=["case_sensitive"],
|
|
)
|
|
dumped = yaml.dump(finalizer.model_dump())
|
|
assert "python/object" not in dumped
|
|
assert PromptFinalizer(**yaml.safe_load(dumped)) == finalizer
|
|
|
|
|
|
class TestAppliesTo:
|
|
def test_disabled(self):
|
|
finalizer = PromptFinalizer(enabled=False)
|
|
assert not finalizer.applies_to(VIS_TYPE.UNSPECIFIED, True)
|
|
|
|
def test_vis_type_filter(self):
|
|
finalizer = PromptFinalizer(vis_types=[VIS_TYPE.CHARACTER_PORTRAIT.value])
|
|
assert finalizer.applies_to(VIS_TYPE.CHARACTER_PORTRAIT, True)
|
|
assert not finalizer.applies_to(VIS_TYPE.SCENE_BACKGROUND, True)
|
|
|
|
def test_empty_vis_types_applies_to_all(self):
|
|
finalizer = PromptFinalizer()
|
|
assert finalizer.applies_to(VIS_TYPE.SCENE_BACKGROUND, True)
|
|
|
|
def test_target(self):
|
|
positive = PromptFinalizer(target=FINALIZER_TARGET.POSITIVE)
|
|
negative = PromptFinalizer(target=FINALIZER_TARGET.NEGATIVE)
|
|
both = PromptFinalizer(target=FINALIZER_TARGET.BOTH)
|
|
assert positive.applies_to(VIS_TYPE.UNSPECIFIED, True)
|
|
assert not positive.applies_to(VIS_TYPE.UNSPECIFIED, False)
|
|
assert not negative.applies_to(VIS_TYPE.UNSPECIFIED, True)
|
|
assert negative.applies_to(VIS_TYPE.UNSPECIFIED, False)
|
|
assert both.applies_to(VIS_TYPE.UNSPECIFIED, True)
|
|
assert both.applies_to(VIS_TYPE.UNSPECIFIED, False)
|
|
|
|
|
|
class FakeScene:
|
|
def __init__(self, characters=None):
|
|
self.characters = characters or {}
|
|
self.agent_overrides = None
|
|
|
|
def get_character(self, name):
|
|
return self.characters.get(name)
|
|
|
|
|
|
@pytest.fixture
|
|
def agent():
|
|
agent = VisualAgent()
|
|
return agent
|
|
|
|
|
|
def set_finalizers(agent, rows):
|
|
agent.actions["_prompt_finalization"].config["finalizers"].value = rows
|
|
|
|
|
|
class TestFinalizePromptRequest:
|
|
async def test_disabled_leaves_request_untouched(self, agent):
|
|
agent.actions["_prompt_finalization"].enabled = False
|
|
set_finalizers(agent, [{"mode": "EXACT", "match": "a", "replace": "b"}])
|
|
request = GenerationRequest(prompt="a")
|
|
await agent.finalize_prompt_request(request)
|
|
assert request.prompt == "a"
|
|
|
|
async def test_exact_and_regex_in_order(self, agent):
|
|
set_finalizers(
|
|
agent,
|
|
[
|
|
{"mode": "EXACT", "match": "red hair", "replace": "crimson hair"},
|
|
{
|
|
"mode": "REGEX",
|
|
"match": r"crimson (\w+)",
|
|
"replace": r"dark crimson \1",
|
|
},
|
|
],
|
|
)
|
|
request = GenerationRequest(prompt="a woman, red hair")
|
|
await agent.finalize_prompt_request(request)
|
|
assert request.prompt == "a woman, dark crimson hair"
|
|
|
|
async def test_negative_prompt_target(self, agent):
|
|
set_finalizers(
|
|
agent,
|
|
[
|
|
{
|
|
"mode": "EXACT",
|
|
"match": "blurry",
|
|
"replace": "blurry, low quality",
|
|
"target": "NEGATIVE",
|
|
}
|
|
],
|
|
)
|
|
request = GenerationRequest(prompt="blurry photo", negative_prompt="blurry")
|
|
await agent.finalize_prompt_request(request)
|
|
assert request.prompt == "blurry photo"
|
|
assert request.negative_prompt == "blurry, low quality"
|
|
|
|
async def test_vis_type_filter(self, agent):
|
|
set_finalizers(
|
|
agent,
|
|
[
|
|
{
|
|
"mode": "EXACT",
|
|
"match": "red",
|
|
"replace": "blue",
|
|
"vis_types": [VIS_TYPE.SCENE_BACKGROUND.value],
|
|
}
|
|
],
|
|
)
|
|
request = GenerationRequest(
|
|
prompt="red sky", vis_type=VIS_TYPE.CHARACTER_PORTRAIT
|
|
)
|
|
await agent.finalize_prompt_request(request)
|
|
assert request.prompt == "red sky"
|
|
|
|
request = GenerationRequest(
|
|
prompt="red sky", vis_type=VIS_TYPE.SCENE_BACKGROUND
|
|
)
|
|
await agent.finalize_prompt_request(request)
|
|
assert request.prompt == "blue sky"
|
|
|
|
async def test_character_finalizers_run_after_agent_finalizers(self, agent):
|
|
set_finalizers(
|
|
agent,
|
|
[{"mode": "EXACT", "match": "red hair", "replace": "crimson hair"}],
|
|
)
|
|
character = Character(
|
|
name="Elena",
|
|
visual_finalizers=[
|
|
PromptFinalizer(
|
|
mode=FINALIZER_MODE.EXACT,
|
|
match="crimson hair",
|
|
replace="crimson hair with silver streaks",
|
|
)
|
|
],
|
|
)
|
|
agent.scene = FakeScene(characters={"Elena": character})
|
|
|
|
request = GenerationRequest(prompt="Elena, red hair", character_name="Elena")
|
|
await agent.finalize_prompt_request(request)
|
|
assert request.prompt == "Elena, crimson hair with silver streaks"
|
|
|
|
# without the character the character finalizer does not run
|
|
request = GenerationRequest(prompt="Elena, red hair")
|
|
await agent.finalize_prompt_request(request)
|
|
assert request.prompt == "Elena, crimson hair"
|
|
|
|
async def test_fuzzy_threshold_config(self, agent):
|
|
set_finalizers(
|
|
agent, [{"mode": "FUZZY", "match": "red hair", "replace": "crimson hair"}]
|
|
)
|
|
agent.actions["_prompt_finalization"].config["fuzzy_threshold"].value = 100
|
|
request = GenerationRequest(prompt="a woman, red haired")
|
|
await agent.finalize_prompt_request(request)
|
|
assert request.prompt == "a woman, red haired"
|
|
|
|
agent.actions["_prompt_finalization"].config["fuzzy_threshold"].value = 85
|
|
request = GenerationRequest(prompt="a woman, red haired")
|
|
await agent.finalize_prompt_request(request)
|
|
assert request.prompt == "a woman, crimson hair"
|
|
|
|
async def test_ai_finalizer(self, agent, monkeypatch):
|
|
set_finalizers(
|
|
agent,
|
|
[{"mode": "AI", "replace": "Convert the prompt to JSON."}],
|
|
)
|
|
|
|
captured = {}
|
|
|
|
async def fake_request(uid, client, kind, vars=None, **kwargs):
|
|
captured.update(uid=uid, kind=kind, vars=vars)
|
|
# Prompt.request returns (response, extracted) - see Prompt.send
|
|
return ('<FINALIZED_PROMPT>{"prompt": "a woman"}</FINALIZED_PROMPT>', {})
|
|
|
|
monkeypatch.setattr(finalize.Prompt, "request", fake_request)
|
|
|
|
class FakeClient:
|
|
max_token_length = 8192
|
|
|
|
agent.client = FakeClient()
|
|
|
|
request = GenerationRequest(prompt="a woman")
|
|
await agent.finalize_prompt_request(request)
|
|
|
|
assert request.prompt == '{"prompt": "a woman"}'
|
|
assert captured["uid"] == "visual.finalize-prompt"
|
|
assert captured["vars"]["instruction"] == "Convert the prompt to JSON."
|
|
|
|
async def test_ai_finalizer_without_client_is_noop(self, agent):
|
|
set_finalizers(agent, [{"mode": "AI", "replace": "Convert."}])
|
|
request = GenerationRequest(prompt="a woman")
|
|
await agent.finalize_prompt_request(request)
|
|
assert request.prompt == "a woman"
|
|
|
|
|
|
@pytest.fixture
|
|
def finalize_signals(isolate_signals):
|
|
return isolate_signals(
|
|
"agent.visual.prompt_finalize.before",
|
|
"agent.visual.prompt_finalize.after",
|
|
)
|
|
|
|
|
|
class TestPromptFinalizeSignals:
|
|
async def test_before_and_after_fire_with_payload(self, agent, finalize_signals):
|
|
before, after = finalize_signals
|
|
received = {}
|
|
|
|
async def on_before(emission: PromptFinalizeEmission):
|
|
received["before"] = emission.model_dump(exclude={"agent"})
|
|
|
|
async def on_after(emission: PromptFinalizeEmission):
|
|
received["after"] = emission.model_dump(exclude={"agent"})
|
|
|
|
before.connect(on_before)
|
|
after.connect(on_after)
|
|
|
|
set_finalizers(
|
|
agent, [{"mode": "EXACT", "match": "red hair", "replace": "crimson hair"}]
|
|
)
|
|
await agent.finalize_prompts(
|
|
"a woman, red hair", "blurry", VIS_TYPE.CHARACTER_PORTRAIT, "Elena"
|
|
)
|
|
|
|
assert received["before"]["positive_prompt"] == "a woman, red hair"
|
|
assert received["before"]["negative_prompt"] == "blurry"
|
|
assert received["before"]["vis_type"] == VIS_TYPE.CHARACTER_PORTRAIT
|
|
assert received["before"]["character_name"] == "Elena"
|
|
assert len(received["before"]["finalizers"]) == 1
|
|
|
|
assert received["after"]["positive_prompt"] == "a woman, crimson hair"
|
|
assert received["after"]["negative_prompt"] == "blurry"
|
|
|
|
async def test_signals_fire_when_finalization_disabled(
|
|
self, agent, finalize_signals
|
|
):
|
|
before, after = finalize_signals
|
|
fired = []
|
|
|
|
async def on_signal(emission: PromptFinalizeEmission):
|
|
fired.append(emission)
|
|
|
|
before.connect(on_signal)
|
|
after.connect(on_signal)
|
|
|
|
agent.actions["_prompt_finalization"].enabled = False
|
|
set_finalizers(agent, [{"mode": "EXACT", "match": "a", "replace": "b"}])
|
|
positive, negative = await agent.finalize_prompts(
|
|
"a woman", None, VIS_TYPE.UNSPECIFIED
|
|
)
|
|
|
|
assert positive == "a woman"
|
|
assert negative is None
|
|
assert len(fired) == 2
|
|
assert fired[0].finalizers == []
|
|
|
|
async def test_before_handler_mutations_feed_finalizers(
|
|
self, agent, finalize_signals
|
|
):
|
|
before, _ = finalize_signals
|
|
|
|
async def on_before(emission: PromptFinalizeEmission):
|
|
emission.positive_prompt = "a woman, red hair"
|
|
|
|
before.connect(on_before)
|
|
|
|
set_finalizers(
|
|
agent, [{"mode": "EXACT", "match": "red hair", "replace": "crimson hair"}]
|
|
)
|
|
positive, _ = await agent.finalize_prompts(
|
|
"a woman", None, VIS_TYPE.UNSPECIFIED
|
|
)
|
|
assert positive == "a woman, crimson hair"
|
|
|
|
async def test_before_handler_can_inject_finalizers(self, agent, finalize_signals):
|
|
before, _ = finalize_signals
|
|
|
|
async def on_before(emission: PromptFinalizeEmission):
|
|
emission.finalizers.append(
|
|
PromptFinalizer(
|
|
mode=FINALIZER_MODE.EXACT, match="red hair", replace="crimson hair"
|
|
)
|
|
)
|
|
|
|
before.connect(on_before)
|
|
|
|
positive, _ = await agent.finalize_prompts(
|
|
"a woman, red hair", None, VIS_TYPE.UNSPECIFIED
|
|
)
|
|
assert positive == "a woman, crimson hair"
|
|
|
|
async def test_after_handler_mutations_are_returned(self, agent, finalize_signals):
|
|
_, after = finalize_signals
|
|
|
|
async def on_after(emission: PromptFinalizeEmission):
|
|
emission.positive_prompt = f"{emission.positive_prompt}, masterpiece"
|
|
|
|
after.connect(on_after)
|
|
|
|
positive, _ = await agent.finalize_prompts(
|
|
"a woman", None, VIS_TYPE.UNSPECIFIED
|
|
)
|
|
assert positive == "a woman, masterpiece"
|