From da464bb5b07c21bb20cd73525409d1edf24f88de Mon Sep 17 00:00:00 2001 From: veguAI <152010387+vegu-ai-tools@users.noreply.github.com> Date: Sun, 26 Jul 2026 11:24:30 +0300 Subject: [PATCH] Card import: keep the card's description and example dialogue when generation fails (#156) * Card import: keep the card's description and example dialogue when generation fails (#154) * Document the empty-description contract and scope the import fallback note (PR 156 review) * Scope the import fallback admonition title and link Fast Character Generation (PR 156 review round 2) --- CHANGELOG.yaml | 1 + docs/user-guide/character-card-import.md | 6 + .../reference/nodes/agents-creator.md | 2 +- src/talemate/agents/creator/character.py | 15 +- src/talemate/agents/creator/nodes.py | 2 +- .../game/engine/api/agents/creator.py | 3 +- src/talemate/load/character_card.py | 21 ++- .../test_character_card_extraction_options.py | 153 ++++++++++++++++++ tests/test_creator_character.py | 27 ++++ 9 files changed, 224 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.yaml b/CHANGELOG.yaml index 92bad0a7..418285e9 100644 --- a/CHANGELOG.yaml +++ b/CHANGELOG.yaml @@ -29,6 +29,7 @@ - "Scene Browser Landing Page: The Quick Load recent-scene cards are smaller and scale with the viewport width, so a full row of recents no longer pushes the Scene Library far down the page." - "Scene Browser Landing Page: The Quick Load card menu trigger moved below each card, so it no longer crowds the card title. Scene save files in the Scene Library gained the same context menu — Timeline, Remove from Quick Load (shown when the save is in Quick Load), and Delete — replacing the bare delete button." fixes: + - "Character Card Import: A failed description or example dialogue generation no longer wipes the card's own text. Both steps clear the field before regenerating it so the card's version doesn't bias the rewrite, but a generation that errored out or came back empty left the character with nothing at all — an empty description then also blinded the attribute extraction that runs right after it. The card's original description and example dialogue are now restored whenever generation fails, returns nothing, or is cancelled, matching what the import already does when those generation steps are switched off." - "Pocket TTS: Pasting a Hugging Face token into the agent settings now enables the gated voice-cloning model download without restarting Talemate — previously a generation attempted before the token was set cached the fallback model without voice cloning, so following the in-app 'paste token and try again' instructions kept failing until a restart. While voice cloning is unavailable the agent card now also shows a warning." - "Prompt Templates: The `system_time` template function crashed with 'Invalid format string' on Windows — it used strftime codes only available on Linux/macOS. The time is now formatted platform-independently with identical output." - "Director Chat: Images created through the director chat's image generation action never appeared in the chat — the generation completed and the image was saved to the scene's assets, but the 'Image Generated' message was inserted into a newly created orphan chat instead of the conversation that requested it. The message is now inserted into the initiating chat." diff --git a/docs/user-guide/character-card-import.md b/docs/user-guide/character-card-import.md index 142f4e68..c04a2e9d 100644 --- a/docs/user-guide/character-card-import.md +++ b/docs/user-guide/character-card-import.md @@ -121,6 +121,12 @@ By default, importing a character card runs several AI generation steps that enr - **Generate Story Intent**: AI generates the overall story intent for the scene. When the Director's auto-direct is enabled, this also covers scene type generation and scene intent setup. +!!! info "Failed description or example dialogue generations keep the card's data" + If the description or example dialogue generation fails or comes back empty, the + card's own version is kept and the import carries on with the remaining steps. With + the Creator agent's [**Fast Character Generation**](/talemate/user-guide/agents/creator/settings) + enabled, a consolidated response that cannot be parsed at all still aborts the import. + Use the **Full** / **Minimal** preset buttons to toggle all AI generation steps at once. **Minimal** also disables episode title generation, reducing the import to the bare card data (name, description, greetings, example dialogue) — the fastest possible import. !!! tip "Simplified Import" diff --git a/docs/user-guide/node-editor/reference/nodes/agents-creator.md b/docs/user-guide/node-editor/reference/nodes/agents-creator.md index c002e1b8..0a7ba393 100644 --- a/docs/user-guide/node-editor/reference/nodes/agents-creator.md +++ b/docs/user-guide/node-editor/reference/nodes/agents-creator.md @@ -112,7 +112,7 @@ Determines the description for a character. | Output | Type | Description | | --- | --- | --- | -| `description` | `str` | The determined description | +| `description` | `str` | The determined description (empty if the model produced nothing) | ## Determine Character Dialogue Instructions diff --git a/src/talemate/agents/creator/character.py b/src/talemate/agents/creator/character.py index b234e046..957d1994 100644 --- a/src/talemate/agents/creator/character.py +++ b/src/talemate/agents/creator/character.py @@ -477,7 +477,9 @@ class CharacterCreatorMixin: instructions: str = "", information: str = "", dynamic_instructions: list = None, - ): + ) -> str: + """The generated description, or an empty string when the model + produced nothing.""" vars_dict = { "character": character, "scene": self.scene, @@ -496,7 +498,16 @@ class CharacterCreatorMixin: "create", vars=vars_dict, ) - return extracted["response"].strip() + + description = extracted["response"].strip() + + # the template primes the response with the character's name, so a + # generation that produced nothing comes back as the bare name - + # report it as empty rather than as a one-word description + if description == character.name.strip(): + return "" + + return description @set_processing async def determine_character_goals( diff --git a/src/talemate/agents/creator/nodes.py b/src/talemate/agents/creator/nodes.py index 7304a5cc..20aa13a0 100644 --- a/src/talemate/agents/creator/nodes.py +++ b/src/talemate/agents/creator/nodes.py @@ -84,7 +84,7 @@ class DetermineCharacterDescription(AgentNode): Outputs: - - description: The determined description + - description: The determined description (empty if the model produced nothing) """ _agent_name: ClassVar[str] = "creator" diff --git a/src/talemate/game/engine/api/agents/creator.py b/src/talemate/game/engine/api/agents/creator.py index 22d22076..7508bd63 100644 --- a/src/talemate/game/engine/api/agents/creator.py +++ b/src/talemate/game/engine/api/agents/creator.py @@ -144,7 +144,8 @@ def create(scene: "Scene") -> "ScopedAPI": Returns: - - str - The generated description + - str - The generated description, or an empty string when the model + produced nothing Raises: diff --git a/src/talemate/load/character_card.py b/src/talemate/load/character_card.py index f5de39b4..b718581b 100644 --- a/src/talemate/load/character_card.py +++ b/src/talemate/load/character_card.py @@ -645,6 +645,9 @@ async def _determine_character_description( """Determine and set character description.""" loading_status(f"Determine description for {character.name}...") + # keep the card's description so a generation miss can restore it + original_description = character.description + try: creator = instance.get_agent("creator") dynamic_instructions = relevant_info.to_dynamic_instructions(scenario=False) @@ -657,14 +660,20 @@ async def _determine_character_description( text=relevant_info.scenario.content if relevant_info.scenario else "", dynamic_instructions=dynamic_instructions, ) + + if not character.description: + character.description = original_description + log.debug( "character_description", character=character.name, description=character.description, ) except GenerationCancelled: + character.description = original_description raise except Exception as e: + character.description = original_description log.warning("determine_character_description", error=e) @@ -734,6 +743,9 @@ async def _determine_character_dialogue_examples( """ loading_status(f"Determine dialogue examples for {character.name}...") + # keep the card's examples so a generation miss can restore them + original_example_dialogue = list(character.example_dialogue) + try: creator = instance.get_agent("creator") @@ -743,6 +755,9 @@ async def _determine_character_dialogue_examples( original_dialogue_examples_text if original_dialogue_examples_text else "" ) + # needs to be empty here, so the card's examples don't bias the rewrite + character.example_dialogue = [] + character.example_dialogue = ( await creator.determine_character_dialogue_examples( character, @@ -754,6 +769,9 @@ async def _determine_character_dialogue_examples( ) ) + if not character.example_dialogue: + character.example_dialogue = original_example_dialogue + log.debug( "determine_character_dialogue_examples", character=character.name, @@ -761,8 +779,10 @@ async def _determine_character_dialogue_examples( examples=character.example_dialogue, ) except GenerationCancelled: + character.example_dialogue = original_example_dialogue raise except Exception as e: + character.example_dialogue = original_example_dialogue log.warning("determine_character_dialogue_examples", error=e) @@ -1445,7 +1465,6 @@ async def _process_characters_for_import( ) if import_options.extract_dialogue_examples: - character.example_dialogue = [] await _determine_character_dialogue_examples( character, loading_status, diff --git a/tests/test_character_card_extraction_options.py b/tests/test_character_card_extraction_options.py index a16db5d8..06af6c11 100644 --- a/tests/test_character_card_extraction_options.py +++ b/tests/test_character_card_extraction_options.py @@ -22,6 +22,7 @@ import pytest from conftest import MockClientContext, MockScene, bootstrap_scene, client_responses from _character_test_helpers import ( + KIND_DESCRIPTION, KIND_FOCAL, KIND_SHEET, KIND_UNIFIED, @@ -308,6 +309,154 @@ async def test_process_characters_sheet_prompt_carries_character_context(agents) assert prompt.count("A veteran smith.") == 1 +# --------------------------------------------------------------------------- +# Split-mode fallback to the card's own data +# --------------------------------------------------------------------------- + + +async def test_process_characters_split_mode_description_error_keeps_card_text(agents): + # the description step blanks the character before regenerating (a stale + # description would bias the rewrite) - a failed generation must put the + # card's text back instead of leaving the character description-less + character = _card_character() + scene = _scene_stub() + + with patch.object( + agents.creator, + "determine_character_description", + autospec=True, + side_effect=RuntimeError("boom"), + ): + await _process_characters_for_import( + scene, + [character], + ["hi"], + "", + LoadingStatus(None), + _options_all_disabled(extract_description=True), + ) + + assert character.description == "raw description" + + +async def test_process_characters_split_mode_empty_description_keeps_card_text(agents): + # an empty generation (e.g. a reasoning model that emits no answer) is the + # same data loss without an exception + character = _card_character() + scene = _scene_stub() + + with patch.object( + agents.creator, + "determine_character_description", + autospec=True, + return_value="", + ): + await _process_characters_for_import( + scene, + [character], + ["hi"], + "", + LoadingStatus(None), + _options_all_disabled(extract_description=True), + ) + + assert character.description == "raw description" + + +async def test_process_characters_split_mode_dead_generation_keeps_card_text(agents): + # the same miss through the full stack: an empty client response (the + # user ignoring a generation error) is primed back up to the bare + # character name by the description template, and must still read as + # "nothing generated" + character = _card_character() + scene = _scene_stub() + + async with MockClientContext(): + client_responses.get().append("") + await _process_characters_for_import( + scene, + [character], + ["hi"], + "", + LoadingStatus(None), + _options_all_disabled(extract_description=True), + ) + + assert prompt_kinds(agents.client) == [KIND_DESCRIPTION] + assert character.description == "raw description" + + +async def test_process_characters_split_mode_examples_error_keeps_card_examples(agents): + # same contract for example dialogue, which is blanked before generating + # so the card's examples don't ride along as "existing examples" + character = _card_character() + scene = _scene_stub() + + with patch.object( + agents.creator, + "determine_character_dialogue_examples", + autospec=True, + side_effect=RuntimeError("boom"), + ): + await _process_characters_for_import( + scene, + [character], + ["hi"], + "raw mes example", + LoadingStatus(None), + _options_all_disabled(extract_dialogue_examples=True), + ) + + assert character.example_dialogue == ["Hero: raw example"] + + +async def test_process_characters_split_mode_empty_examples_keeps_card_examples(agents): + # a response that makes no add_dialogue_example calls yields no examples - + # the card's own examples must survive (parity with fast mode's miss + # handling) + character = _card_character() + scene = _scene_stub() + + async with MockClientContext(): + client_responses.get().append("no calls in this response") + await _process_characters_for_import( + scene, + [character], + ["hi"], + "raw mes example", + LoadingStatus(None), + _options_all_disabled(extract_dialogue_examples=True), + ) + + assert prompt_kinds(agents.client) == [KIND_FOCAL] + assert character.example_dialogue == ["Hero: raw example"] + + +async def test_process_characters_split_mode_examples_prompt_omits_card_examples( + agents, +): + # the blanking that makes the fallback necessary still has to happen: the + # focal request renders the character's existing examples, and the card's + # raw examples must not be fed back as such + character = _card_character() + scene = _scene_stub() + + async with MockClientContext(): + client_responses.get().append(example_dialogue_response("generated example")) + await _process_characters_for_import( + scene, + [character], + ["hi"], + "raw mes example", + LoadingStatus(None), + _options_all_disabled(extract_dialogue_examples=True), + ) + + prompt = str(agents.client.prompt_history[0]["prompt"]) + assert "Hero: raw example" not in prompt + assert character.example_dialogue == ["Hero: generated example"] + + # --------------------------------------------------------------------------- # Fast (consolidated) mode routing # --------------------------------------------------------------------------- @@ -561,6 +710,10 @@ async def test_process_characters_split_mode_cancellation_propagates( _options_all_disabled(**{aspect: True}), ) + # an aborted import leaves no half-blanked character behind + assert character.description == "raw description" + assert character.example_dialogue == ["Hero: raw example"] + async def test_determine_character_context_cancellation_propagates(agents): # the content-context step runs before the fast/split branch - a diff --git a/tests/test_creator_character.py b/tests/test_creator_character.py index 56297d92..2b7bbfa7 100644 --- a/tests/test_creator_character.py +++ b/tests/test_creator_character.py @@ -82,6 +82,33 @@ def _titles_section(prompt_text: str, title: str) -> str: return prompt_text[start:] if end == -1 else prompt_text[start:end] +@pytest.mark.asyncio +async def test_determine_character_description(creator): + character = Character(name="Alice", description="A curious girl.") + + async with MockClientContext(): + client_responses.get().append(description_response("Alice", "is a tinkerer.")) + description = await creator.determine_character_description(character) + + assert description == "Alice is a tinkerer." + + +@pytest.mark.asyncio +async def test_determine_character_description_prime_only_reads_as_empty(creator): + # the template primes the response with the character's name, so a + # generation that produced nothing at all (empty client response, e.g. + # after the user ignores a generation error) comes back as the bare + # name - callers must see that as "nothing generated", not as a + # one-word description + character = Character(name="Alice", description="A curious girl.") + + async with MockClientContext(): + client_responses.get().append("") + description = await creator.determine_character_description(character) + + assert description == "" + + @pytest.mark.asyncio async def test_determine_character_dialogue_examples(creator): character = Character(name="Alice", description="A curious girl.")