mirror of
https://github.com/vegu-ai/talemate.git
synced 2026-05-18 05:05:39 +02:00
442 lines
17 KiB
Python
442 lines
17 KiB
Python
|
|
"""Unit tests for talemate.agents.director.guide.GuideSceneMixin.
|
||
|
|
|
||
|
|
Covers:
|
||
|
|
- Config property helpers (guide_scene, guide_actors, guide_narrator,
|
||
|
|
guide_scene_guidance_length, guide_scene_cache_guidance).
|
||
|
|
- get_cached_guidance / set_cached_guidance / get_cached_character_guidance:
|
||
|
|
fingerprint-based caching with `guide_scene_cache_guidance` flag gate.
|
||
|
|
- on_summarization_scene_analysis_after: dispatches narration vs conversation,
|
||
|
|
early-returns when guide_scene disabled, no-ops when guidance is empty.
|
||
|
|
- on_editor_revision_analysis_before: copies cached guidance into
|
||
|
|
emission.dynamic_instructions when present.
|
||
|
|
- guide_actor_off_of_scene_analysis / guide_narrator_off_of_scene_analysis:
|
||
|
|
exercised via stubbed Prompt.request.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from conftest import MockScene, bootstrap_scene
|
||
|
|
|
||
|
|
import talemate.instance as instance
|
||
|
|
from talemate.agents.base import AgentTemplateEmission, DynamicInstruction
|
||
|
|
from talemate.agents.context import ActiveAgent
|
||
|
|
from talemate.agents.summarize.analyze_scene import SceneAnalysisEmission
|
||
|
|
|
||
|
|
from _director_test_helpers import (
|
||
|
|
add_character_to_scene as _add_character,
|
||
|
|
patch_prompt_request_in,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Helpers
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def scene():
|
||
|
|
s = MockScene()
|
||
|
|
bootstrap_scene(s)
|
||
|
|
return s
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def director(scene):
|
||
|
|
return instance.get_agent("director")
|
||
|
|
|
||
|
|
|
||
|
|
def _guide_fn():
|
||
|
|
"""Stand-in callable required by ActiveAgent context."""
|
||
|
|
pass
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def stub_prompt(monkeypatch):
|
||
|
|
"""Replace the real ``Prompt.request`` classmethod with a queued response source.
|
||
|
|
|
||
|
|
Patches the canonical ``talemate.prompts.base.Prompt`` class with
|
||
|
|
``raising=True`` so a rename of ``Prompt.request`` immediately fails.
|
||
|
|
"""
|
||
|
|
from talemate.agents.director import guide as guide_mod
|
||
|
|
|
||
|
|
return patch_prompt_request_in(monkeypatch, guide_mod)
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Config properties
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
|
||
|
|
class TestGuideConfigProperties:
|
||
|
|
def test_guide_scene_disabled_by_default(self, director):
|
||
|
|
assert director.guide_scene is False
|
||
|
|
|
||
|
|
def test_guide_actors_default_true(self, director):
|
||
|
|
assert director.guide_actors is True
|
||
|
|
|
||
|
|
def test_guide_narrator_default_true(self, director):
|
||
|
|
assert director.guide_narrator is True
|
||
|
|
|
||
|
|
def test_guide_scene_guidance_length_returns_int(self, director):
|
||
|
|
assert isinstance(director.guide_scene_guidance_length, int)
|
||
|
|
assert director.guide_scene_guidance_length == 384
|
||
|
|
|
||
|
|
def test_guide_scene_guidance_length_int_coercion(self, director):
|
||
|
|
director.actions["guide_scene"].config["guidance_length"].value = "512"
|
||
|
|
assert director.guide_scene_guidance_length == 512
|
||
|
|
|
||
|
|
def test_guide_scene_cache_guidance_default_false(self, director):
|
||
|
|
assert director.guide_scene_cache_guidance is False
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# get_cached_guidance / set_cached_guidance
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
|
||
|
|
class TestCachedGuidance:
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_returns_none_when_caching_disabled(self, director):
|
||
|
|
# Default: cache_guidance=False
|
||
|
|
result = await director.get_cached_guidance("any analysis")
|
||
|
|
assert result is None
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_returns_none_when_no_cache_state(self, director):
|
||
|
|
director.actions["guide_scene"].config["cache_guidance"].value = True
|
||
|
|
result = await director.get_cached_guidance("any analysis")
|
||
|
|
assert result is None
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_returns_guidance_without_analysis_arg(self, director):
|
||
|
|
director.actions["guide_scene"].config["cache_guidance"].value = True
|
||
|
|
director.set_scene_states(
|
||
|
|
cached_guidance={
|
||
|
|
"fp": "some-fp",
|
||
|
|
"guidance": "cached text",
|
||
|
|
"analysis_type": "narration",
|
||
|
|
"character": None,
|
||
|
|
}
|
||
|
|
)
|
||
|
|
# Without an analysis parameter, returns the cached guidance
|
||
|
|
result = await director.get_cached_guidance(None)
|
||
|
|
assert result == "cached text"
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_returns_none_when_fingerprint_mismatches(self, scene, director):
|
||
|
|
director.actions["guide_scene"].config["cache_guidance"].value = True
|
||
|
|
director.set_scene_states(
|
||
|
|
cached_guidance={
|
||
|
|
"fp": "different-fp",
|
||
|
|
"guidance": "cached text",
|
||
|
|
"analysis_type": "narration",
|
||
|
|
"character": None,
|
||
|
|
}
|
||
|
|
)
|
||
|
|
# Without ActiveAgent context, context_fingerprint returns None
|
||
|
|
result = await director.get_cached_guidance("any analysis")
|
||
|
|
assert result is None
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_returns_when_fingerprint_matches(self, scene, director):
|
||
|
|
director.actions["guide_scene"].config["cache_guidance"].value = True
|
||
|
|
# Compute the fingerprint inside an ActiveAgent context, store it,
|
||
|
|
# then retrieve it inside the SAME context (so context_fingerprint
|
||
|
|
# matches).
|
||
|
|
with ActiveAgent(director, _guide_fn):
|
||
|
|
await director.set_cached_guidance(
|
||
|
|
analysis="my analysis",
|
||
|
|
guidance="cached guidance",
|
||
|
|
analysis_type="narration",
|
||
|
|
)
|
||
|
|
result = await director.get_cached_guidance("my analysis")
|
||
|
|
assert result == "cached guidance"
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_set_cached_guidance_stores_character_name(self, scene, director):
|
||
|
|
char = await _add_character(scene, "Alice")
|
||
|
|
with ActiveAgent(director, _guide_fn):
|
||
|
|
await director.set_cached_guidance(
|
||
|
|
analysis="x",
|
||
|
|
guidance="hint",
|
||
|
|
analysis_type="conversation",
|
||
|
|
character=char,
|
||
|
|
)
|
||
|
|
cached = director.get_scene_state("cached_guidance")
|
||
|
|
assert cached["character"] == "Alice"
|
||
|
|
assert cached["analysis_type"] == "conversation"
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_set_cached_guidance_with_no_character(self, scene, director):
|
||
|
|
with ActiveAgent(director, _guide_fn):
|
||
|
|
await director.set_cached_guidance(
|
||
|
|
analysis="x", guidance="g", analysis_type="narration"
|
||
|
|
)
|
||
|
|
cached = director.get_scene_state("cached_guidance")
|
||
|
|
assert cached["character"] is None
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# get_cached_character_guidance
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
|
||
|
|
class TestGetCachedCharacterGuidance:
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_returns_none_when_no_cache(self, director):
|
||
|
|
result = await director.get_cached_character_guidance("Alice")
|
||
|
|
assert result is None
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_returns_guidance_when_character_and_type_match(self, director):
|
||
|
|
director.set_scene_states(
|
||
|
|
cached_guidance={
|
||
|
|
"fp": "x",
|
||
|
|
"guidance": "alice's hint",
|
||
|
|
"analysis_type": "conversation",
|
||
|
|
"character": "Alice",
|
||
|
|
}
|
||
|
|
)
|
||
|
|
result = await director.get_cached_character_guidance("Alice")
|
||
|
|
assert result == "alice's hint"
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_returns_none_when_character_mismatch(self, director):
|
||
|
|
director.set_scene_states(
|
||
|
|
cached_guidance={
|
||
|
|
"fp": "x",
|
||
|
|
"guidance": "g",
|
||
|
|
"analysis_type": "conversation",
|
||
|
|
"character": "Alice",
|
||
|
|
}
|
||
|
|
)
|
||
|
|
result = await director.get_cached_character_guidance("Bob")
|
||
|
|
assert result is None
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_returns_none_when_analysis_type_is_not_conversation(self, director):
|
||
|
|
director.set_scene_states(
|
||
|
|
cached_guidance={
|
||
|
|
"fp": "x",
|
||
|
|
"guidance": "g",
|
||
|
|
"analysis_type": "narration",
|
||
|
|
"character": "Alice",
|
||
|
|
}
|
||
|
|
)
|
||
|
|
result = await director.get_cached_character_guidance("Alice")
|
||
|
|
assert result is None
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# on_summarization_scene_analysis_after
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
|
||
|
|
class TestOnSummarizationSceneAnalysisAfter:
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_no_op_when_guide_scene_disabled(self, scene, director):
|
||
|
|
# guide_scene defaults to False
|
||
|
|
emission = SceneAnalysisEmission(
|
||
|
|
agent=director,
|
||
|
|
analysis_type="narration",
|
||
|
|
response="some analysis",
|
||
|
|
)
|
||
|
|
await director.on_summarization_scene_analysis_after(emission)
|
||
|
|
# No context state set
|
||
|
|
assert director.get_context_state("narrator_guidance") is None
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_narration_analysis_calls_guide_narrator(
|
||
|
|
self, scene, director, stub_prompt
|
||
|
|
):
|
||
|
|
director.actions["guide_scene"].enabled = True
|
||
|
|
try:
|
||
|
|
stub_prompt(
|
||
|
|
{
|
||
|
|
"director.guide-narration": [
|
||
|
|
("guidance text", {"guidance": "guidance text"})
|
||
|
|
]
|
||
|
|
}
|
||
|
|
)
|
||
|
|
emission = SceneAnalysisEmission(
|
||
|
|
agent=director,
|
||
|
|
analysis_type="narration",
|
||
|
|
response="some analysis",
|
||
|
|
)
|
||
|
|
# context_state lives inside the active agent context — assert
|
||
|
|
# within the `with` block.
|
||
|
|
with ActiveAgent(director, _guide_fn):
|
||
|
|
await director.on_summarization_scene_analysis_after(emission)
|
||
|
|
assert (
|
||
|
|
director.get_context_state("narrator_guidance") == "guidance text"
|
||
|
|
)
|
||
|
|
finally:
|
||
|
|
director.actions["guide_scene"].enabled = False
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_conversation_analysis_calls_guide_actor(
|
||
|
|
self, scene, director, stub_prompt
|
||
|
|
):
|
||
|
|
director.actions["guide_scene"].enabled = True
|
||
|
|
char = await _add_character(scene, "Alice")
|
||
|
|
try:
|
||
|
|
stub_prompt(
|
||
|
|
{
|
||
|
|
"director.guide-conversation": [
|
||
|
|
("actor hint", {"guidance": "actor hint"})
|
||
|
|
]
|
||
|
|
}
|
||
|
|
)
|
||
|
|
emission = SceneAnalysisEmission(
|
||
|
|
agent=director,
|
||
|
|
analysis_type="conversation",
|
||
|
|
response="analysis",
|
||
|
|
template_vars={"character": char},
|
||
|
|
)
|
||
|
|
with ActiveAgent(director, _guide_fn):
|
||
|
|
await director.on_summarization_scene_analysis_after(emission)
|
||
|
|
assert director.get_context_state("actor_guidance") == "actor hint"
|
||
|
|
finally:
|
||
|
|
director.actions["guide_scene"].enabled = False
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_skips_narration_when_guide_narrator_disabled(
|
||
|
|
self, scene, director, stub_prompt
|
||
|
|
):
|
||
|
|
director.actions["guide_scene"].enabled = True
|
||
|
|
director.actions["guide_scene"].config["guide_narrator"].value = False
|
||
|
|
try:
|
||
|
|
emission = SceneAnalysisEmission(
|
||
|
|
agent=director, analysis_type="narration", response="x"
|
||
|
|
)
|
||
|
|
with ActiveAgent(director, _guide_fn):
|
||
|
|
await director.on_summarization_scene_analysis_after(emission)
|
||
|
|
# No call made because guide_narrator is False
|
||
|
|
assert director.get_context_state("narrator_guidance") is None
|
||
|
|
finally:
|
||
|
|
director.actions["guide_scene"].enabled = False
|
||
|
|
director.actions["guide_scene"].config["guide_narrator"].value = True
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_warns_and_returns_when_response_empty(
|
||
|
|
self, scene, director, stub_prompt
|
||
|
|
):
|
||
|
|
director.actions["guide_scene"].enabled = True
|
||
|
|
try:
|
||
|
|
# Empty response from the LLM stub
|
||
|
|
stub_prompt({"director.guide-narration": [("", {"guidance": ""})]})
|
||
|
|
emission = SceneAnalysisEmission(
|
||
|
|
agent=director, analysis_type="narration", response="x"
|
||
|
|
)
|
||
|
|
with ActiveAgent(director, _guide_fn):
|
||
|
|
await director.on_summarization_scene_analysis_after(emission)
|
||
|
|
# Empty guidance → no context state set
|
||
|
|
assert director.get_context_state("narrator_guidance") is None
|
||
|
|
finally:
|
||
|
|
director.actions["guide_scene"].enabled = False
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# on_editor_revision_analysis_before
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
|
||
|
|
class TestOnEditorRevisionAnalysisBefore:
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_no_dynamic_instruction_added_without_cache(self, director):
|
||
|
|
emission = AgentTemplateEmission(agent=director)
|
||
|
|
emission.response = "analysis text"
|
||
|
|
await director.on_editor_revision_analysis_before(emission)
|
||
|
|
assert emission.dynamic_instructions == []
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_appends_cached_guidance_as_dynamic_instruction(
|
||
|
|
self, scene, director
|
||
|
|
):
|
||
|
|
director.actions["guide_scene"].config["cache_guidance"].value = True
|
||
|
|
try:
|
||
|
|
with ActiveAgent(director, _guide_fn):
|
||
|
|
await director.set_cached_guidance(
|
||
|
|
analysis="my analysis",
|
||
|
|
guidance="cached guidance text",
|
||
|
|
analysis_type="narration",
|
||
|
|
)
|
||
|
|
emission = AgentTemplateEmission(agent=director)
|
||
|
|
emission.response = "my analysis"
|
||
|
|
await director.on_editor_revision_analysis_before(emission)
|
||
|
|
assert len(emission.dynamic_instructions) == 1
|
||
|
|
instr = emission.dynamic_instructions[0]
|
||
|
|
assert isinstance(instr, DynamicInstruction)
|
||
|
|
assert instr.title == "Guidance"
|
||
|
|
assert instr.content == "cached guidance text"
|
||
|
|
finally:
|
||
|
|
director.actions["guide_scene"].config["cache_guidance"].value = False
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# guide_actor_off_of_scene_analysis & guide_narrator_off_of_scene_analysis
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
|
||
|
|
class TestGuideActorOffOfSceneAnalysis:
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_uses_extracted_guidance_when_present(
|
||
|
|
self, scene, director, stub_prompt
|
||
|
|
):
|
||
|
|
char = await _add_character(scene, "Alice")
|
||
|
|
stub_prompt(
|
||
|
|
{
|
||
|
|
"director.guide-conversation": [
|
||
|
|
("raw response", {"guidance": "extracted guidance"})
|
||
|
|
]
|
||
|
|
}
|
||
|
|
)
|
||
|
|
with ActiveAgent(director, _guide_fn):
|
||
|
|
result = await director.guide_actor_off_of_scene_analysis(
|
||
|
|
"scene analysis", char
|
||
|
|
)
|
||
|
|
assert result == "extracted guidance"
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_falls_back_to_response_when_no_extracted(
|
||
|
|
self, scene, director, stub_prompt
|
||
|
|
):
|
||
|
|
char = await _add_character(scene, "Alice")
|
||
|
|
# Empty extracted; use raw response.
|
||
|
|
stub_prompt(
|
||
|
|
{
|
||
|
|
"director.guide-conversation": [
|
||
|
|
("raw fallback content.", {"guidance": ""})
|
||
|
|
]
|
||
|
|
}
|
||
|
|
)
|
||
|
|
with ActiveAgent(director, _guide_fn):
|
||
|
|
result = await director.guide_actor_off_of_scene_analysis(
|
||
|
|
"scene analysis", char
|
||
|
|
)
|
||
|
|
# Strip + strip_partial_sentences may keep complete sentences only
|
||
|
|
assert "raw fallback" in result or result == ""
|
||
|
|
|
||
|
|
|
||
|
|
class TestGuideNarratorOffOfSceneAnalysis:
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_uses_extracted_guidance(self, scene, director, stub_prompt):
|
||
|
|
stub_prompt(
|
||
|
|
{"director.guide-narration": [("raw", {"guidance": "narrator guidance"})]}
|
||
|
|
)
|
||
|
|
with ActiveAgent(director, _guide_fn):
|
||
|
|
result = await director.guide_narrator_off_of_scene_analysis("analysis")
|
||
|
|
assert result == "narrator guidance"
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_calls_correct_template(self, scene, director, stub_prompt):
|
||
|
|
stub = stub_prompt({"director.guide-narration": [("raw", {"guidance": "x"})]})
|
||
|
|
with ActiveAgent(director, _guide_fn):
|
||
|
|
await director.guide_narrator_off_of_scene_analysis(
|
||
|
|
"analysis", response_length=512
|
||
|
|
)
|
||
|
|
assert stub.calls[0]["template"] == "director.guide-narration"
|
||
|
|
assert stub.calls[0]["vars"]["response_length"] == 512
|