mirror of
https://github.com/vegu-ai/talemate.git
synced 2026-08-29 10:08:58 +02:00
* fix: exclude the character's own name from the max_attributes budget (#162) * fix: stop the unified character prompt spending an attribute slot on the name (#162) * fix: keep an unbudgeted Name wherever it sits in the sheet; pin the cap instruction (#162 review) * docs: describe the Fast-mode prompt change rather than the sheet it produces (#162 review)
This commit is contained in:
@@ -58,6 +58,8 @@
|
||||
- "Agent Settings Dialog: Scene mode now honors each setting's visibility conditions. Conditional settings - such as the Creator agent's Fast Character Creation fields, which only apply while Fast mode is on - no longer show up as scene overrides while their gate is off, and a section (along with its tab) whose settings are all currently hidden is dropped from Scene mode instead of rendering empty. Conditions are evaluated against the scene's effective values, so overriding the gating setting for a scene reveals the settings it gates. A setting that already carries an override stays visible even while its gate is off, so it can still be seen in the override count and cleared."
|
||||
- "Character Card Import: Cancelling an import now actually aborts it — the split-mode per-aspect extractions, content-context determination, and story-intent generation each swallowed the cancellation as a warning, so the import completed anyway (and a cancel during story-intent generation leaked into the next unrelated generation)."
|
||||
- "Character Card Import: Attribute extraction no longer runs blind in the default (non-Fast) import mode — the character being profiled was missing from its own extraction prompt, so attributes were generated from the greeting and character book alone, without the character's description. Imported attributes now reflect the description."
|
||||
- "Character Creation: The director's 'Limit character attributes' setting now delivers the number of attributes it promises. The character's own name is written into the generated character sheet as a `Name` line, and it was counted against the limit — so every value delivered one attribute fewer than configured, and a limit of 1 produced no attributes at all. The name no longer costs a slot, in generated sheets as well as in sheets supplied to the Persist Character node."
|
||||
- "Fast Character Creation: The one-shot generation prompt no longer repeats the character's name as an attribute — the name is already generated as its own aspect, so a `Name` entry in the character sheet was duplication, and under a configured attribute limit it consumed one of the allowed lines. This applies at every value of the director's 'Limit character attributes' setting, including the default of 0: Fast mode no longer asks for a `Name` attribute, and one written anyway does not cost a slot."
|
||||
|
||||
0.38.0:
|
||||
features:
|
||||
|
||||
@@ -162,10 +162,12 @@ The Character Management settings control how the director handles character cre
|
||||
Controls the maximum number of attributes that will be generated when creating or updating character sheets. This applies when the director creates new characters or when character sheets are generated through templates.
|
||||
|
||||
- **0** (default): No limit - attributes are generated without restriction
|
||||
- **1-40**: Limits the character sheet to this many attributes
|
||||
- **1-40**: Generates at most this many attributes (the character's own name is not counted)
|
||||
|
||||
When a limit is set, the AI is instructed to generate no more than the specified number of attributes, and any excess attributes are trimmed during processing.
|
||||
|
||||
If the sheet carries the character's own name as a `Name` attribute, it does not count towards the limit - it is scaffolding rather than a generated trait.
|
||||
|
||||
This setting is useful when you want to keep character sheets concise, or when working with characters that might otherwise generate an excessive number of attributes.
|
||||
|
||||
### Persisting Characters
|
||||
|
||||
@@ -6,6 +6,7 @@ import talemate.instance as instance
|
||||
import talemate.agents.tts.voice_library as voice_library
|
||||
from talemate.agents.tts.schema import Voice
|
||||
from talemate.util import random_color, chunk_items_by_tokens, remove_substring_names
|
||||
from talemate.util.data import trim_attributes
|
||||
from talemate.character import Character, set_voice, activate_character
|
||||
from talemate.status import LoadingStatus
|
||||
from talemate.exceptions import GenerationCancelled
|
||||
@@ -205,7 +206,7 @@ class CharacterManagementMixin:
|
||||
"max_attributes": AgentActionConfig(
|
||||
type="number",
|
||||
label="Limit character attributes",
|
||||
description="Maximum number of attributes to generate for character sheets. Set to 0 for unlimited (default).",
|
||||
description="Maximum number of attributes to generate for character sheets, not counting the character's name. Set to 0 for unlimited (default).",
|
||||
value=0,
|
||||
min=0,
|
||||
max=40,
|
||||
@@ -591,19 +592,20 @@ class CharacterManagementMixin:
|
||||
|
||||
# Enforce max_attributes limit on base_attributes if configured -
|
||||
# before aspect generation, so the downstream prompts render the
|
||||
# truncated sheet
|
||||
if max_attrs and len(character.base_attributes) > max_attrs:
|
||||
# Keep only the first N attributes (preserving insertion order)
|
||||
limited_attrs = dict(
|
||||
list(character.base_attributes.items())[:max_attrs]
|
||||
# truncated sheet. Same budget rule as the generated sheet: the
|
||||
# character's own name does not cost a slot.
|
||||
if max_attrs:
|
||||
limited_attrs = trim_attributes(
|
||||
character.base_attributes, max_attributes=max_attrs
|
||||
)
|
||||
log.debug(
|
||||
"persist_character",
|
||||
limiting_attributes=True,
|
||||
original_count=len(character.base_attributes),
|
||||
limited_count=len(limited_attrs),
|
||||
)
|
||||
character.base_attributes = limited_attrs
|
||||
if len(limited_attrs) < len(character.base_attributes):
|
||||
log.debug(
|
||||
"persist_character",
|
||||
limiting_attributes=True,
|
||||
original_count=len(character.base_attributes),
|
||||
limited_count=len(limited_attrs),
|
||||
)
|
||||
character.base_attributes = limited_attrs
|
||||
|
||||
if not fast and request.generate:
|
||||
split = await self._prepare_split_generation(
|
||||
|
||||
@@ -56,7 +56,7 @@ NAME: The character's name. If `{{ character_name }}` is already a distinct name
|
||||
DESCRIPTION: The character description: an overview of the character in broad strokes — who they are and what defines them — not a continuation of any current narrative.
|
||||
{% endif -%}
|
||||
{% if "attributes" in aspects -%}
|
||||
ATTRIBUTES: A character sheet of attributes for {{ character_name }}. You are omniscient and can describe the character in detail; you are a creative writer and may fill any gaps in the profile with your own ideas. Expand on interesting details. Format MUST be one attribute per line, with a colon after the attribute name.{% if max_attributes and max_attributes > 0 %} At most {{ max_attributes }} attributes (lines).{% endif %}
|
||||
ATTRIBUTES: A character sheet of attributes for {{ character_name }}. You are omniscient and can describe the character in detail; you are a creative writer and may fill any gaps in the profile with your own ideas. Expand on interesting details. Format MUST be one attribute per line, with a colon after the attribute name. Do not include the character's name as an attribute.{% if max_attributes and max_attributes > 0 %} At most {{ max_attributes }} attributes (lines).{% endif %}
|
||||
|
||||
Example:
|
||||
Age: early 30s
|
||||
|
||||
@@ -45,7 +45,7 @@ Appearance: <description of appearance>
|
||||
Your response MUST be a character sheet with multiple attributes.
|
||||
Format MUST be one attribute per line, with a colon after the attribute name.
|
||||
{% if max_attributes and max_attributes > 0 %}
|
||||
You MUST output at most {{ max_attributes }} attributes (lines) total.
|
||||
You MUST output at most {{ max_attributes }} attributes (lines) in addition to the Name line.
|
||||
{% endif %}
|
||||
{% endset %}
|
||||
|
||||
|
||||
@@ -18,10 +18,14 @@ __all__ = [
|
||||
"fix_yaml_colon_in_strings",
|
||||
"fix_faulty_yaml",
|
||||
"parse_attribute_lines",
|
||||
"trim_attributes",
|
||||
]
|
||||
|
||||
log = structlog.get_logger("talemate.util.dedupe")
|
||||
|
||||
# the character's own name is prompt scaffold, not a generated attribute
|
||||
UNBUDGETED_ATTRIBUTES = frozenset({"name"})
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from talemate.client.base import ClientBase
|
||||
@@ -53,6 +57,34 @@ class DataParsingError(Exception):
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
def trim_attributes(
|
||||
attributes: dict[str, str], max_attributes: int | None = None
|
||||
) -> dict[str, str]:
|
||||
"""Trim an attributes dict to `max_attributes` attributes, preserving
|
||||
insertion order. A falsy or negative limit means no limit.
|
||||
|
||||
Attributes in `UNBUDGETED_ATTRIBUTES` are kept without costing a slot,
|
||||
wherever they sit in the sheet.
|
||||
"""
|
||||
if not max_attributes or max_attributes <= 0:
|
||||
return dict(attributes)
|
||||
|
||||
trimmed = {}
|
||||
budgeted = 0
|
||||
for name, value in attributes.items():
|
||||
if name.strip().lower() in UNBUDGETED_ATTRIBUTES:
|
||||
trimmed[name] = value
|
||||
continue
|
||||
|
||||
if budgeted >= max_attributes:
|
||||
continue
|
||||
|
||||
trimmed[name] = value
|
||||
budgeted += 1
|
||||
|
||||
return trimmed
|
||||
|
||||
|
||||
def parse_attribute_lines(
|
||||
text: str, max_attributes: int | None = None
|
||||
) -> dict[str, str]:
|
||||
@@ -60,7 +92,8 @@ def parse_attribute_lines(
|
||||
|
||||
The shared character-sheet attribute format: one attribute per line,
|
||||
with a colon after the attribute name. Parsing stops at the first
|
||||
non-empty line without a colon.
|
||||
non-empty line without a colon. The character's own name does not cost
|
||||
a slot of `max_attributes`.
|
||||
"""
|
||||
data = {}
|
||||
for line in text.split("\n"):
|
||||
@@ -71,10 +104,7 @@ def parse_attribute_lines(
|
||||
name, value = line.split(":", 1)
|
||||
data[name.strip()] = value.strip()
|
||||
|
||||
if max_attributes and max_attributes > 0 and len(data) >= max_attributes:
|
||||
break
|
||||
|
||||
return data
|
||||
return trim_attributes(data, max_attributes)
|
||||
|
||||
|
||||
def fix_faulty_json(data: str) -> str:
|
||||
|
||||
@@ -8,7 +8,7 @@ Generate each of the following aspects for the tall woman with dark hair.
|
||||
|
||||
NAME: The character's name. If `the tall woman with dark hair` is already a distinct name, repeat it. If it is currently a description, give the character a distinct name. If we don't know the character's actual name, you must decide one. The name MUST fit the context of the scenario and the scene. The name only, nothing else.
|
||||
DESCRIPTION: The character description: an overview of the character in broad strokes — who they are and what defines them — not a continuation of any current narrative.
|
||||
ATTRIBUTES: A character sheet of attributes for the tall woman with dark hair. You are omniscient and can describe the character in detail; you are a creative writer and may fill any gaps in the profile with your own ideas. Expand on interesting details. Format MUST be one attribute per line, with a colon after the attribute name.
|
||||
ATTRIBUTES: A character sheet of attributes for the tall woman with dark hair. You are omniscient and can describe the character in detail; you are a creative writer and may fill any gaps in the profile with your own ideas. Expand on interesting details. Format MUST be one attribute per line, with a colon after the attribute name. Do not include the character's name as an attribute.
|
||||
|
||||
Example:
|
||||
Age: early 30s
|
||||
|
||||
@@ -15,7 +15,7 @@ You are creating the character `Elena` for Fantasy adventure story.
|
||||
Generate each of the following aspects for Elena.
|
||||
|
||||
DESCRIPTION: The character description: an overview of the character in broad strokes — who they are and what defines them — not a continuation of any current narrative.
|
||||
ATTRIBUTES: A character sheet of attributes for Elena. You are omniscient and can describe the character in detail; you are a creative writer and may fill any gaps in the profile with your own ideas. Expand on interesting details. Format MUST be one attribute per line, with a colon after the attribute name.
|
||||
ATTRIBUTES: A character sheet of attributes for Elena. You are omniscient and can describe the character in detail; you are a creative writer and may fill any gaps in the profile with your own ideas. Expand on interesting details. Format MUST be one attribute per line, with a colon after the attribute name. Do not include the character's name as an attribute.
|
||||
|
||||
Example:
|
||||
Age: early 30s
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
## Characters
|
||||
|
||||
### Hero
|
||||
|
||||
### Elena
|
||||
|
||||
A wandering healer with knowledge of ancient herbs.
|
||||
|
||||
## Content
|
||||
A peaceful clearing in the heart of an ancient forest.Elena: Hello there, traveler.
|
||||
The sun filters through the leaves above.
|
||||
Marcus: What brings you to these woods?
|
||||
|
||||
## Task
|
||||
|
||||
Generate a real world character profile for Elena, one attribute per line. You are a creative writer and are allowed to fill in any gaps in the profile with your own ideas.
|
||||
Expand on interesting details.
|
||||
|
||||
Instructions for the character: A skilled healer with gentle manners.
|
||||
|
||||
You must only generate attributes for Elena. You are omniscient and can describe the character in detail.
|
||||
|
||||
Example:
|
||||
|
||||
Name: <character name>
|
||||
Age: <age written out in text>
|
||||
Appearance: <description of appearance>
|
||||
<...>
|
||||
|
||||
Your response MUST be a character sheet with multiple attributes.
|
||||
Format MUST be one attribute per line, with a colon after the attribute name.
|
||||
|
||||
You MUST output at most 3 attributes (lines) in addition to the Name line.
|
||||
|
||||
|
||||
The length of your response must fit within 4 paragraphs.
|
||||
<|BOT|>Name: Elena
|
||||
Age:
|
||||
@@ -0,0 +1,38 @@
|
||||
## Characters
|
||||
|
||||
### Hero
|
||||
|
||||
### Elena
|
||||
|
||||
A wandering healer with knowledge of ancient herbs.
|
||||
|
||||
## Content
|
||||
A peaceful clearing in the heart of an ancient forest.Elena: Hello there, traveler.
|
||||
The sun filters through the leaves above.
|
||||
Marcus: What brings you to these woods?
|
||||
|
||||
## Task
|
||||
|
||||
Generate a real world character profile for Elena, one attribute per line. You are a creative writer and are allowed to fill in any gaps in the profile with your own ideas.
|
||||
Expand on interesting details.
|
||||
|
||||
Instructions for the character: A skilled healer with gentle manners.
|
||||
|
||||
You must only generate attributes for Elena. You are omniscient and can describe the character in detail.
|
||||
|
||||
Example:
|
||||
|
||||
Name: <character name>
|
||||
Age: <age written out in text>
|
||||
Appearance: <description of appearance>
|
||||
<...>
|
||||
|
||||
Your response MUST be a character sheet with multiple attributes.
|
||||
Format MUST be one attribute per line, with a colon after the attribute name.
|
||||
|
||||
You MUST output at most 3 attributes (lines) in addition to the Name line.
|
||||
|
||||
|
||||
The length of your response must fit within 4 paragraphs.
|
||||
<|BOT|>Name: Elena
|
||||
Age:
|
||||
@@ -0,0 +1,36 @@
|
||||
## Characters
|
||||
|
||||
### Hero
|
||||
|
||||
### Elena
|
||||
|
||||
A wandering healer with knowledge of ancient herbs.
|
||||
|
||||
## Content
|
||||
A peaceful clearing in the heart of an ancient forest.Elena: Hello there, traveler.
|
||||
The sun filters through the leaves above.
|
||||
Marcus: What brings you to these woods?
|
||||
|
||||
## Task
|
||||
|
||||
Generate a real world character profile for Elena, one attribute per line. You are a creative writer and are allowed to fill in any gaps in the profile with your own ideas.
|
||||
Expand on interesting details.
|
||||
|
||||
Instructions for the character: A skilled healer with gentle manners.
|
||||
|
||||
You must only generate attributes for Elena. You are omniscient and can describe the character in detail.
|
||||
|
||||
Example:
|
||||
|
||||
Name: <character name>
|
||||
Age: <age written out in text>
|
||||
Appearance: <description of appearance>
|
||||
<...>
|
||||
|
||||
Your response MUST be a character sheet with multiple attributes.
|
||||
Format MUST be one attribute per line, with a colon after the attribute name.
|
||||
|
||||
You MUST output at most 3 attributes (lines) in addition to the Name line.
|
||||
|
||||
<|BOT|>Name: Elena
|
||||
Age:
|
||||
@@ -0,0 +1,39 @@
|
||||
## Characters
|
||||
|
||||
### Hero
|
||||
|
||||
### Elena
|
||||
|
||||
A wandering healer with knowledge of ancient herbs.
|
||||
|
||||
## Content
|
||||
A peaceful clearing in the heart of an ancient forest.Elena: Hello there, traveler.
|
||||
The sun filters through the leaves above.
|
||||
Marcus: What brings you to these woods?
|
||||
|
||||
## Task
|
||||
|
||||
Generate a real world character profile for Elena, one attribute per line. You are a creative writer and are allowed to fill in any gaps in the profile with your own ideas.
|
||||
Expand on interesting details.
|
||||
|
||||
Instructions for the character: A skilled healer with gentle manners.
|
||||
|
||||
You must only generate attributes for Elena. You are omniscient and can describe the character in detail.
|
||||
|
||||
Example:
|
||||
|
||||
Name: <character name>
|
||||
Age: <age written out in text>
|
||||
Appearance: <description of appearance>
|
||||
<...>
|
||||
|
||||
Your response MUST be a character sheet with multiple attributes.
|
||||
Format MUST be one attribute per line, with a colon after the attribute name.
|
||||
|
||||
You MUST output at most 3 attributes (lines) in addition to the Name line.
|
||||
|
||||
|
||||
The length of your response must fit within 4 paragraphs.
|
||||
|
||||
Start your response with: Name: Elena
|
||||
Age:
|
||||
@@ -0,0 +1,39 @@
|
||||
## Characters
|
||||
|
||||
### Hero
|
||||
|
||||
### Elena
|
||||
|
||||
A wandering healer with knowledge of ancient herbs.
|
||||
|
||||
## Content
|
||||
A peaceful clearing in the heart of an ancient forest.Elena: Hello there, traveler.
|
||||
The sun filters through the leaves above.
|
||||
Marcus: What brings you to these woods?
|
||||
|
||||
## Task
|
||||
|
||||
Generate a real world character profile for Elena, one attribute per line. You are a creative writer and are allowed to fill in any gaps in the profile with your own ideas.
|
||||
Expand on interesting details.
|
||||
|
||||
Instructions for the character: A skilled healer with gentle manners.
|
||||
|
||||
You must only generate attributes for Elena. You are omniscient and can describe the character in detail.
|
||||
|
||||
Example:
|
||||
|
||||
Name: <character name>
|
||||
Age: <age written out in text>
|
||||
Appearance: <description of appearance>
|
||||
<...>
|
||||
|
||||
Your response MUST be a character sheet with multiple attributes.
|
||||
Format MUST be one attribute per line, with a colon after the attribute name.
|
||||
|
||||
You MUST output at most 3 attributes (lines) in addition to the Name line.
|
||||
|
||||
|
||||
The length of your final answer must fit within 4 paragraphs.
|
||||
|
||||
After thinking about it, start your answer with: Name: Elena
|
||||
Age:
|
||||
@@ -0,0 +1,39 @@
|
||||
<CHARACTERS>
|
||||
### Hero
|
||||
|
||||
### Elena
|
||||
|
||||
A wandering healer with knowledge of ancient herbs.
|
||||
</CHARACTERS>
|
||||
|
||||
<CONTENT>
|
||||
A peaceful clearing in the heart of an ancient forest.Elena: Hello there, traveler.
|
||||
The sun filters through the leaves above.
|
||||
Marcus: What brings you to these woods?
|
||||
</CONTENT>
|
||||
|
||||
<TASK>
|
||||
Generate a real world character profile for Elena, one attribute per line. You are a creative writer and are allowed to fill in any gaps in the profile with your own ideas.
|
||||
Expand on interesting details.
|
||||
|
||||
Instructions for the character: A skilled healer with gentle manners.
|
||||
</TASK>
|
||||
|
||||
You must only generate attributes for Elena. You are omniscient and can describe the character in detail.
|
||||
|
||||
Example:
|
||||
|
||||
Name: <character name>
|
||||
Age: <age written out in text>
|
||||
Appearance: <description of appearance>
|
||||
<...>
|
||||
|
||||
Your response MUST be a character sheet with multiple attributes.
|
||||
Format MUST be one attribute per line, with a colon after the attribute name.
|
||||
|
||||
You MUST output at most 3 attributes (lines) in addition to the Name line.
|
||||
|
||||
|
||||
The length of your response must fit within 4 paragraphs.
|
||||
<|BOT|>Name: Elena
|
||||
Age:
|
||||
@@ -159,6 +159,25 @@ class TestWorldStateExtractBaselines:
|
||||
)
|
||||
baseline_checker(capture_prompt(agent), AGENT, "extract_character_sheet")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_character_sheet__with_max_attributes(
|
||||
self, active_context, baseline_checker
|
||||
):
|
||||
# the cap instruction only renders when a limit is set, so without
|
||||
# this the wording is unpinned by any test
|
||||
agent = active_context
|
||||
agent.client.send_prompt = AsyncMock(
|
||||
return_value="name: Elena\nage: 25\noccupation: Healer"
|
||||
)
|
||||
await agent.extract_character_sheet(
|
||||
name="Elena",
|
||||
text="A skilled healer with gentle manners.",
|
||||
max_attributes=3,
|
||||
)
|
||||
baseline_checker(
|
||||
capture_prompt(agent), AGENT, "extract_character_sheet__with_max_attributes"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_character_sheet__with_alteration(
|
||||
self, active_context, baseline_checker
|
||||
|
||||
@@ -425,8 +425,47 @@ class TestWorldStateAgentExtractMethods:
|
||||
name="Elena", text="Elena is a healer.", max_attributes=3
|
||||
)
|
||||
|
||||
# Verify response has at most 3 attributes
|
||||
assert len(response) <= 3
|
||||
# the primed name line is scaffold, so the limit buys 3 attributes
|
||||
# beside it
|
||||
assert response == {
|
||||
"name": "Elena",
|
||||
"age": "25",
|
||||
"occupation": "Healer",
|
||||
"status": "healthy",
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_character_sheet_max_attributes_of_one(self, active_context):
|
||||
"""A limit of 1 buys one real attribute, not a name-only sheet."""
|
||||
agent = active_context
|
||||
|
||||
agent.client.send_prompt = AsyncMock(
|
||||
return_value="Name: Elena\nAge: 25\nOccupation: Healer"
|
||||
)
|
||||
|
||||
response = await agent.extract_character_sheet(
|
||||
name="Elena", text="Elena is a healer.", max_attributes=1
|
||||
)
|
||||
|
||||
assert response == {"Name": "Elena", "Age": "25"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_character_sheet_max_attributes_of_two(self, active_context):
|
||||
"""The limit counts generated attributes, not the primed name line."""
|
||||
agent = active_context
|
||||
|
||||
# nothing name-like in the generation - Prompt.send prepends the
|
||||
# template's "Name: Elena\nAge:" prime, which is exactly the case that
|
||||
# used to cost a slot
|
||||
agent.client.send_prompt = AsyncMock(
|
||||
return_value=" 25\nOccupation: Healer\nWeapon: staff"
|
||||
)
|
||||
|
||||
response = await agent.extract_character_sheet(
|
||||
name="Elena", text="Elena is a healer.", max_attributes=2
|
||||
)
|
||||
|
||||
assert response == {"Name": "Elena", "Age": "25", "Occupation": "Healer"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_character_sheet_prime_only_reads_as_empty(
|
||||
@@ -1103,11 +1142,15 @@ class TestWorldStateAgentHelperMethods:
|
||||
assert result == {"name": "Elena", "age": "25", "occupation": "Healer"}
|
||||
|
||||
def test_parse_character_sheet_with_max_attributes(self, world_state_agent):
|
||||
"""Test _parse_character_sheet respects max_attributes."""
|
||||
"""Test _parse_character_sheet respects max_attributes.
|
||||
|
||||
The name line is the template's prime, not a generated attribute, so
|
||||
the limit applies to what follows it.
|
||||
"""
|
||||
response = "name: Elena\nage: 25\noccupation: Healer\nstatus: healthy"
|
||||
result = world_state_agent._parse_character_sheet(response, max_attributes=2)
|
||||
|
||||
assert len(result) == 2
|
||||
assert result == {"name": "Elena", "age": "25", "occupation": "Healer"}
|
||||
|
||||
def test_parse_character_sheet_stops_at_non_attribute_line(self, world_state_agent):
|
||||
"""Test _parse_character_sheet stops at line without colon."""
|
||||
|
||||
@@ -434,6 +434,26 @@ async def test_generate_character_unified_max_attributes(creator):
|
||||
assert result.attributes == {"Age": "30", "Occupation": "healer"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_character_unified_max_attributes_ignores_name_line(creator):
|
||||
# the one-shot prompt asks the model not to repeat the name as an
|
||||
# attribute, but a model that does anyway must not spend the budget on it
|
||||
async with MockClientContext():
|
||||
client_responses.get().append(
|
||||
unified_response(attributes="Name: Elena\nAge: 30\nOccupation: healer")
|
||||
)
|
||||
result = await creator.generate_character_unified(
|
||||
CharacterGenerationRequest(
|
||||
aspects=["attributes"],
|
||||
name="Elena",
|
||||
content="A healer.",
|
||||
max_attributes=1,
|
||||
)
|
||||
)
|
||||
|
||||
assert result.attributes == {"Name": "Elena", "Age": "30"}
|
||||
|
||||
|
||||
def test_unknown_aspect_rejected_at_construction():
|
||||
# the aspects Literal is the contract - pydantic rejects unknown aspects
|
||||
# at construction, before any generation method runs
|
||||
|
||||
@@ -1164,7 +1164,9 @@ class TestPersistCharacterGenerationModes:
|
||||
self, scene, director
|
||||
):
|
||||
# the trailing enforcement block was removed - for generated sheets
|
||||
# the cap relies on max_attributes threading through extraction
|
||||
# the cap relies on max_attributes threading through extraction. The
|
||||
# primed Name line is scaffold, so a limit of 2 buys 2 attributes
|
||||
# beside it
|
||||
director.actions["character_management"].config["max_attributes"].value = 2
|
||||
try:
|
||||
async with MockClientContext():
|
||||
@@ -1189,7 +1191,11 @@ class TestPersistCharacterGenerationModes:
|
||||
finally:
|
||||
director.actions["character_management"].config["max_attributes"].value = 0
|
||||
|
||||
assert character.base_attributes == {"Name": "Elena", "Age": "30"}
|
||||
assert character.base_attributes == {
|
||||
"Name": "Elena",
|
||||
"Age": "30",
|
||||
"Occupation": "healer",
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_split_mode_template_overflow_truncated_before_prompts(
|
||||
@@ -1240,9 +1246,14 @@ class TestPersistCharacterGenerationModes:
|
||||
finally:
|
||||
director.actions["character_management"].config["max_attributes"].value = 0
|
||||
|
||||
# capped to the first two (insertion order) before the description
|
||||
# prompt - the prompt must not render the discarded attributes
|
||||
assert character.base_attributes == {"Name": "Bram", "Age": "40"}
|
||||
# capped to the first two budgeted attributes (insertion order, the
|
||||
# character's own name is free) before the description prompt - the
|
||||
# prompt must not render the discarded attributes
|
||||
assert character.base_attributes == {
|
||||
"Name": "Bram",
|
||||
"Age": "40",
|
||||
"Occupation": "smith",
|
||||
}
|
||||
description_prompt = str(scene.mock_client.prompt_history[0]["prompt"])
|
||||
assert "Age: 40" in description_prompt
|
||||
assert "Height" not in description_prompt
|
||||
|
||||
@@ -19,6 +19,8 @@ from talemate.util.data import (
|
||||
DataParsingError,
|
||||
fix_yaml_colon_in_strings,
|
||||
fix_faulty_yaml,
|
||||
parse_attribute_lines,
|
||||
trim_attributes,
|
||||
)
|
||||
|
||||
|
||||
@@ -837,3 +839,123 @@ active: false
|
||||
assert result[0]["name"] == "Fixed YAML"
|
||||
assert result[0]["id"] == 999
|
||||
assert result[0]["active"] is False
|
||||
|
||||
|
||||
class TestParseAttributeLines:
|
||||
"""The character sheet's `Name` line is prompt scaffold, not an attribute -
|
||||
extract-character-sheet.jinja2 primes it into every generation, so it must
|
||||
not consume a slot of the attribute budget."""
|
||||
|
||||
def test_name_line_does_not_consume_a_slot(self):
|
||||
text = "Name: Hero\nAge: 40\nScars: three"
|
||||
|
||||
assert parse_attribute_lines(text, max_attributes=1) == {
|
||||
"Name": "Hero",
|
||||
"Age": "40",
|
||||
}
|
||||
|
||||
def test_two_attributes_beside_the_name(self):
|
||||
text = "Name: Hero\nAge: 40\nScars: three\nCreed: none"
|
||||
|
||||
assert parse_attribute_lines(text, max_attributes=2) == {
|
||||
"Name": "Hero",
|
||||
"Age": "40",
|
||||
"Scars": "three",
|
||||
}
|
||||
|
||||
def test_name_exemption_is_case_insensitive(self):
|
||||
text = "name: Hero\nAge: 40\nScars: three"
|
||||
|
||||
assert parse_attribute_lines(text, max_attributes=1) == {
|
||||
"name": "Hero",
|
||||
"Age": "40",
|
||||
}
|
||||
|
||||
def test_sheet_without_a_name_line_is_unaffected(self):
|
||||
text = "Age: 40\nScars: three"
|
||||
|
||||
assert parse_attribute_lines(text, max_attributes=1) == {"Age": "40"}
|
||||
|
||||
def test_name_line_after_the_budget_is_still_kept(self):
|
||||
text = "Age: 40\nName: Hero\nScars: three"
|
||||
|
||||
assert parse_attribute_lines(text, max_attributes=1) == {
|
||||
"Age": "40",
|
||||
"Name": "Hero",
|
||||
}
|
||||
|
||||
def test_no_limit_keeps_everything(self):
|
||||
text = "Name: Hero\nAge: 40\nScars: three"
|
||||
|
||||
assert parse_attribute_lines(text) == {
|
||||
"Name": "Hero",
|
||||
"Age": "40",
|
||||
"Scars": "three",
|
||||
}
|
||||
|
||||
def test_zero_is_no_limit(self):
|
||||
text = "Name: Hero\nAge: 40\nScars: three"
|
||||
|
||||
assert len(parse_attribute_lines(text, max_attributes=0)) == 3
|
||||
|
||||
def test_stops_at_the_first_line_without_a_colon(self):
|
||||
text = "Name: Hero\nAge: 40\nprose sneaks in here\nScars: three"
|
||||
|
||||
assert parse_attribute_lines(text, max_attributes=5) == {
|
||||
"Name": "Hero",
|
||||
"Age": "40",
|
||||
}
|
||||
|
||||
def test_repeated_attribute_costs_a_single_slot(self):
|
||||
text = "Name: Hero\nAge: 40\nAge: 41\nScars: three"
|
||||
|
||||
assert parse_attribute_lines(text, max_attributes=1) == {
|
||||
"Name": "Hero",
|
||||
"Age": "41",
|
||||
}
|
||||
|
||||
|
||||
class TestTrimAttributes:
|
||||
def test_trims_to_budget_excluding_the_name(self):
|
||||
attributes = {"Name": "Hero", "Age": "40", "Scars": "three"}
|
||||
|
||||
assert trim_attributes(attributes, max_attributes=1) == {
|
||||
"Name": "Hero",
|
||||
"Age": "40",
|
||||
}
|
||||
|
||||
def test_name_after_the_budget_is_filled_is_still_kept(self):
|
||||
# the trim must scan past a filled budget rather than stop at it -
|
||||
# the augment path appends the primed Name after the template
|
||||
# attributes, so a late Name is reachable
|
||||
attributes = {"Age": "40", "Scars": "three", "Name": "Hero"}
|
||||
|
||||
assert trim_attributes(attributes, max_attributes=1) == {
|
||||
"Age": "40",
|
||||
"Name": "Hero",
|
||||
}
|
||||
|
||||
def test_name_in_the_middle_of_the_sheet_is_free(self):
|
||||
attributes = {"Age": "40", "Name": "Hero", "Scars": "three"}
|
||||
|
||||
assert trim_attributes(attributes, max_attributes=2) == attributes
|
||||
|
||||
def test_name_last_does_not_rescue_trimmed_attributes(self):
|
||||
attributes = {"Age": "40", "Scars": "three", "Name": "Hero", "Creed": "none"}
|
||||
|
||||
assert trim_attributes(attributes, max_attributes=1) == {
|
||||
"Age": "40",
|
||||
"Name": "Hero",
|
||||
}
|
||||
|
||||
def test_no_limit_returns_a_copy(self):
|
||||
attributes = {"Name": "Hero", "Age": "40"}
|
||||
result = trim_attributes(attributes)
|
||||
|
||||
assert result == attributes
|
||||
assert result is not attributes
|
||||
|
||||
def test_sheet_within_budget_is_untouched(self):
|
||||
attributes = {"Age": "40", "Scars": "three"}
|
||||
|
||||
assert trim_attributes(attributes, max_attributes=5) == attributes
|
||||
|
||||
Reference in New Issue
Block a user