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)
This commit is contained in:
veguAI
2026-07-26 11:24:30 +03:00
committed by GitHub
parent fb20dfffe4
commit da464bb5b0
9 changed files with 224 additions and 6 deletions

View File

@@ -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."

View File

@@ -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"

View File

@@ -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

View File

@@ -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(

View File

@@ -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"

View File

@@ -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:

View File

@@ -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,

View File

@@ -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

View File

@@ -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.")