mirror of
https://github.com/vegu-ai/talemate.git
synced 2026-08-29 10:08:58 +02:00
Card import: keep the card's attributes when attribute extraction produces nothing (#159)
* Card import: keep the card's attributes when attribute extraction produces nothing (#158) * Simplify the empty-sheet guard and sync the scripting-wrapper contract (PR 159 review)
This commit is contained in:
@@ -31,6 +31,7 @@
|
||||
- "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."
|
||||
- "Character Card Import: A failed attribute extraction no longer overwrites the card's attributes. A generation that errored out or came back empty was still written to the character — as a sheet containing nothing but the character's own name — which dropped whatever attributes the card supplied (its gender, for example) and shadowed the character description in every prompt that renders the character sheet. An extraction that produces nothing now keeps the card's attributes, matching what the import already does when the attribute step is 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."
|
||||
|
||||
@@ -121,10 +121,10 @@ 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)
|
||||
!!! info "Failed generations keep the card's data"
|
||||
If the description rewrite, attribute extraction 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.
|
||||
|
||||
@@ -235,7 +235,7 @@ modify the character's existing sheet.
|
||||
|
||||
| Output | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `character_sheet` | `dict` | The extracted character sheet (dict) |
|
||||
| `character_sheet` | `dict` | The extracted character sheet (dict, empty if the model produced nothing) |
|
||||
|
||||
## Request World State
|
||||
|
||||
|
||||
@@ -359,6 +359,9 @@ class WorldStateAgent(
|
||||
"""
|
||||
Attempts to extract a character sheet from the given text.
|
||||
|
||||
Returns an empty dict when the generation produced no attribute
|
||||
content.
|
||||
|
||||
character: Explicit context character for the prompt - for callers
|
||||
whose character is not in the scene yet (pre-creation flows).
|
||||
Defaults to the scene lookup. When given, it renders as the
|
||||
@@ -389,10 +392,18 @@ class WorldStateAgent(
|
||||
#
|
||||
# break as soon as a non-empty line is found that doesn't contain a :
|
||||
|
||||
return self._parse_character_sheet(
|
||||
sheet = self._parse_character_sheet(
|
||||
extracted["response"], max_attributes=max_attributes
|
||||
)
|
||||
|
||||
# the template primes the response with "Name: <name>\nAge:", so a
|
||||
# generation that produced nothing is primed back up into a sheet
|
||||
# carrying just the character's own name - report that as empty
|
||||
if not any(value for key, value in sheet.items() if key.lower() != "name"):
|
||||
return {}
|
||||
|
||||
return sheet
|
||||
|
||||
@set_processing
|
||||
async def summarize_and_pin(self, message_id: int, num_messages: int = 3) -> str:
|
||||
"""
|
||||
|
||||
@@ -55,7 +55,7 @@ class ExtractCharacterSheet(AgentNode):
|
||||
|
||||
Outputs:
|
||||
|
||||
- character_sheet: The extracted character sheet (dict)
|
||||
- character_sheet: The extracted character sheet (dict, empty if the model produced nothing)
|
||||
"""
|
||||
|
||||
_agent_name: ClassVar[str] = "world_state"
|
||||
|
||||
@@ -166,7 +166,9 @@ def create(scene: "Scene") -> "ScopedAPI":
|
||||
|
||||
Returns:
|
||||
|
||||
- dict - The extracted character sheet where each key is an attribute name and the value is the attribute value
|
||||
- dict - The extracted character sheet where each key is an attribute
|
||||
name and the value is the attribute value, or an empty dict when the
|
||||
model produced nothing
|
||||
"""
|
||||
|
||||
class Arguments(pydantic.BaseModel):
|
||||
|
||||
@@ -687,7 +687,7 @@ async def _determine_character_attributes(
|
||||
|
||||
try:
|
||||
world_state = instance.get_agent("world_state")
|
||||
character.base_attributes = await world_state.extract_character_sheet(
|
||||
generated = await world_state.extract_character_sheet(
|
||||
name=character.name,
|
||||
# the imported character has no actor yet - without the explicit
|
||||
# context character the prompt's active-character loop misses it
|
||||
@@ -695,6 +695,11 @@ async def _determine_character_attributes(
|
||||
dynamic_instructions=relevant_info.to_dynamic_instructions(scenario=False),
|
||||
)
|
||||
|
||||
# an extraction that produced nothing must not replace the card's
|
||||
# own attributes
|
||||
if generated:
|
||||
character.base_attributes = generated
|
||||
|
||||
# any values that are lists should be converted to strings joined by ,
|
||||
for k, v in character.base_attributes.items():
|
||||
if isinstance(v, list):
|
||||
|
||||
@@ -428,6 +428,53 @@ class TestWorldStateAgentExtractMethods:
|
||||
# Verify response has at most 3 attributes
|
||||
assert len(response) <= 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_character_sheet_prime_only_reads_as_empty(
|
||||
self, active_context
|
||||
):
|
||||
"""A generation that produced nothing reads as an empty sheet."""
|
||||
agent = active_context
|
||||
|
||||
# the template primes the response with "Name: <name>\nAge:", so an
|
||||
# empty client response (e.g. the user ignoring a generation error)
|
||||
# is primed back up into {"Name": "Elena", "Age": ""} - callers must
|
||||
# see that as "nothing generated", not as a sheet worth keeping
|
||||
agent.client.send_prompt = AsyncMock(return_value="")
|
||||
|
||||
response = await agent.extract_character_sheet(
|
||||
name="Elena", text="A skilled healer with gentle manners."
|
||||
)
|
||||
|
||||
assert response == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_character_sheet_without_attribute_values_reads_as_empty(
|
||||
self, active_context
|
||||
):
|
||||
"""Attribute names without any values carry no content either."""
|
||||
agent = active_context
|
||||
|
||||
agent.client.send_prompt = AsyncMock(
|
||||
return_value="Name: Elena\nAge:\nOccupation:\n"
|
||||
)
|
||||
|
||||
response = await agent.extract_character_sheet(name="Elena", text="")
|
||||
|
||||
assert response == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_character_sheet_keeps_sheet_with_any_content(
|
||||
self, active_context
|
||||
):
|
||||
"""A single populated attribute is enough to keep the whole sheet."""
|
||||
agent = active_context
|
||||
|
||||
agent.client.send_prompt = AsyncMock(return_value="Name: Elena\nAge: 25\n")
|
||||
|
||||
response = await agent.extract_character_sheet(name="Elena", text="")
|
||||
|
||||
assert response == {"Name": "Elena", "Age": "25"}
|
||||
|
||||
|
||||
class TestWorldStateAgentRequestMethods:
|
||||
"""Tests for world_state agent request methods."""
|
||||
|
||||
@@ -457,6 +457,138 @@ async def test_process_characters_split_mode_examples_prompt_omits_card_examples
|
||||
assert character.example_dialogue == ["Hero: generated example"]
|
||||
|
||||
|
||||
async def test_process_characters_split_mode_attributes_error_keeps_card_attributes(
|
||||
agents,
|
||||
):
|
||||
# regression guard: the step must not grow a blank-before-generating
|
||||
# phase like its description/example-dialogue siblings without also
|
||||
# restoring the card's attributes when the generation dies
|
||||
character = _card_character()
|
||||
character.base_attributes["gender"] = "female"
|
||||
scene = _scene_stub()
|
||||
|
||||
with patch.object(
|
||||
agents.world_state,
|
||||
"extract_character_sheet",
|
||||
autospec=True,
|
||||
side_effect=RuntimeError("boom"),
|
||||
):
|
||||
await _process_characters_for_import(
|
||||
scene,
|
||||
[character],
|
||||
["hi"],
|
||||
"",
|
||||
LoadingStatus(None),
|
||||
_options_all_disabled(extract_attributes=True),
|
||||
)
|
||||
|
||||
assert character.base_attributes == {"gender": "female"}
|
||||
|
||||
|
||||
async def test_process_characters_split_mode_empty_attributes_keeps_card_attributes(
|
||||
agents,
|
||||
):
|
||||
# an extraction that produced nothing is not a reason to drop the card's
|
||||
# own attributes either
|
||||
character = _card_character()
|
||||
character.base_attributes["gender"] = "female"
|
||||
scene = _scene_stub()
|
||||
|
||||
with patch.object(
|
||||
agents.world_state,
|
||||
"extract_character_sheet",
|
||||
autospec=True,
|
||||
return_value={},
|
||||
):
|
||||
await _process_characters_for_import(
|
||||
scene,
|
||||
[character],
|
||||
["hi"],
|
||||
"",
|
||||
LoadingStatus(None),
|
||||
_options_all_disabled(extract_attributes=True),
|
||||
)
|
||||
|
||||
assert character.base_attributes == {"gender": "female"}
|
||||
|
||||
|
||||
async def test_process_characters_split_mode_dead_generation_keeps_card_attributes(
|
||||
agents,
|
||||
):
|
||||
# the same miss through the full stack: an empty client response is
|
||||
# primed back up to the bare "Name: Hero\nAge:" by the sheet template,
|
||||
# which used to be written over the card's attributes as a junk sheet
|
||||
character = _card_character()
|
||||
character.base_attributes["gender"] = "female"
|
||||
scene = _scene_stub()
|
||||
|
||||
async with MockClientContext():
|
||||
client_responses.get().append("")
|
||||
await _process_characters_for_import(
|
||||
scene,
|
||||
[character],
|
||||
["hi"],
|
||||
"",
|
||||
LoadingStatus(None),
|
||||
_options_all_disabled(extract_attributes=True),
|
||||
)
|
||||
|
||||
assert prompt_kinds(agents.client) == [KIND_SHEET]
|
||||
assert character.base_attributes == {"gender": "female"}
|
||||
|
||||
|
||||
async def test_process_characters_split_mode_dead_generation_leaves_sheet_unshadowed(
|
||||
agents,
|
||||
):
|
||||
# Character.sheet falls back to name + description only while
|
||||
# base_attributes is empty - a junk sheet from a dead generation used to
|
||||
# win over that fallback and hide the description from every later prompt
|
||||
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_attributes=True),
|
||||
)
|
||||
|
||||
assert character.base_attributes == {}
|
||||
assert "raw description" in character.sheet
|
||||
|
||||
|
||||
async def test_process_characters_split_mode_attributes_cancellation_keeps_card_attributes(
|
||||
agents,
|
||||
):
|
||||
# same guard for cancellation: an aborted import leaves the card's
|
||||
# attributes behind untouched
|
||||
character = _card_character()
|
||||
character.base_attributes["gender"] = "female"
|
||||
scene = _scene_stub()
|
||||
|
||||
with patch.object(
|
||||
agents.world_state,
|
||||
"extract_character_sheet",
|
||||
autospec=True,
|
||||
side_effect=GenerationCancelled(),
|
||||
):
|
||||
with pytest.raises(GenerationCancelled):
|
||||
await _process_characters_for_import(
|
||||
scene,
|
||||
[character],
|
||||
["hi"],
|
||||
"",
|
||||
LoadingStatus(None),
|
||||
_options_all_disabled(extract_attributes=True),
|
||||
)
|
||||
|
||||
assert character.base_attributes == {"gender": "female"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fast (consolidated) mode routing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user