mirror of
https://github.com/vegu-ai/talemate.git
synced 2026-09-02 12:09:31 +02:00
Major Features - API key encryption at rest using Fernet (OS keyring with file fallback) - Prompt Manager: unified UI with template groups, priority ordering, override tracking, response extractors - Scene context history review panel with token budgets and best-fit mode - Multiple concurrent director chats with auto-generated titles - Granular scene state reset dialog - Time passage insert/edit/delete in scene view - Image analysis via OpenAI-compatible and Talemate Client backends - Volatile context placement after scene history for improved prompt caching Improvements - Configurable narrator generation length per narration type - AI Aware conversation mode - Summarizer: custom instructions, writing style inclusion, short line filtering - Anthropic: adaptive thinking support, updated model list (opus-4-5/4-6, haiku-4-5) - Google: gemini-3.1 support - World editor: generate from topic, quick create state reinforcement, reorganized menus - Node editor: promote scene modules to global - Frontend: version mismatch detection, hideable bracket content, required scene name - TTS: improved pause handling, audio tag support for vocal markers (ElevenLabs v3) - Writing style template for AI-generated instructions - Added Kimi.jinja2 LLM prompt template - Option to disable character names in stopping strings - Client response length enforcement options - Graduated token count sliders - Increased summarizer token threshold max Bugfixes - Fix bracket/paren/brace terminators stripped from message ends - Fix colon in conversation causing content loss - Fix "Use as reference" navigating to blank page - Fix avatar regeneration and manual regenerate - Fix conversation agent ignoring generation length - Fix duplicate length instructions with reasoning enabled - Fix trailing newline on message edits - Fix summarize dialogue sending too much context with layered history - Fix layered history inspection and construction issues - Fix empty response handling in summarization - Fix context ID dot notation with dotted character names - Fix recursive retry in focal agent - Fix leading whitespace causing duplicate prepared responses - Fix summarization not stripping ANALYSIS OF lines - Fix template group selection/removal in prompt manager - Fix multiline text in parentheses/brackets parser - Fix determine_character_name resolution - Fix character activate/deactivate desyncing creative menu - Fix character image generation missing context - Fix LMStudio client not sending token limits - Fix Recent Scene images on newer Chromium - Fix sequential reinforcement messages cut off at first linebreak - Fix reinforcement removal not clearing state - Fixes #252, #256, #258 Deprecations - Removed context investigations (replaced by AI-assisted RAG mixin) - Removed deprecated prompt templates (fix-continuity-errors, fix-exposition, etc.) - Removed conversation/edit.jinja2, auto break repetition, CLI reset layered history --------- Co-authored-by: theDTV2 <47825738+theDTV2@users.noreply.github.com>
502 lines
18 KiB
Python
502 lines
18 KiB
Python
"""
|
|
Tests for Scene.fix_time / Scene._fix_time.
|
|
|
|
fix_time recalculates timestamps across archived_history (and layered_history)
|
|
based on TimePassageMessage entries in scene.history.
|
|
"""
|
|
|
|
import types
|
|
|
|
from talemate.scene_message import CharacterMessage, TimePassageMessage
|
|
from talemate.tale_mate import Scene
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _msg(text: str) -> CharacterMessage:
|
|
return CharacterMessage(message=text, source="ai")
|
|
|
|
|
|
def _time(ts: str) -> TimePassageMessage:
|
|
return TimePassageMessage(ts=ts, message=f"{ts} later")
|
|
|
|
|
|
def make_scene(
|
|
history: list,
|
|
archived_history: list,
|
|
layered_history: list | None = None,
|
|
ts: str = "PT0S",
|
|
):
|
|
"""Build a minimal Scene-like namespace with the fix_time methods bound."""
|
|
scene = types.SimpleNamespace(
|
|
ts=ts,
|
|
history=history,
|
|
archived_history=archived_history,
|
|
layered_history=layered_history or [],
|
|
)
|
|
return scene
|
|
|
|
|
|
def fix_time(scene):
|
|
"""Call Scene._fix_time on our namespace (skip the try/except wrapper)."""
|
|
Scene._fix_time(scene)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: fix_time with archived_history only (current behaviour)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestFixTimeArchivedHistory:
|
|
"""Verify fix_time correctly recalculates archived_history timestamps."""
|
|
|
|
def test_single_time_jump(self):
|
|
"""One time passage should set ts on subsequent archived entries."""
|
|
scene = make_scene(
|
|
history=[
|
|
_msg("Hello"), # 0
|
|
_msg("Hi"), # 1
|
|
_time("PT2H"), # 2 — 2 hours
|
|
_msg("After time"), # 3
|
|
_msg("More"), # 4
|
|
],
|
|
archived_history=[
|
|
# static entry (no start/end) — should be left alone
|
|
{"text": "Background", "ts": "PT0S"},
|
|
# summary of messages 0-1 (before time jump at idx 2)
|
|
{"text": "Summary A", "start": 0, "end": 1, "ts": "WRONG"},
|
|
# summary of messages 3-4 (after time jump at idx 2)
|
|
{"text": "Summary B", "start": 3, "end": 4, "ts": "WRONG"},
|
|
],
|
|
)
|
|
|
|
fix_time(scene)
|
|
|
|
# Static entry unchanged
|
|
assert scene.archived_history[0]["ts"] == "PT0S"
|
|
# Entry ending at idx 1: no time jump before idx 1, so inherits starting_time
|
|
assert scene.archived_history[1]["ts"] == "PT0S"
|
|
# Entry ending at idx 4: time jump at idx 2 (PT2H) is before idx 4
|
|
assert scene.archived_history[2]["ts"] == "PT2H"
|
|
# scene.ts should be the last cumulative time
|
|
assert scene.ts == "PT2H"
|
|
|
|
def test_multiple_time_jumps_cumulative(self):
|
|
"""Multiple time passages should accumulate correctly."""
|
|
scene = make_scene(
|
|
history=[
|
|
_msg("A"), # 0
|
|
_time("PT2H"), # 1 — 2 hours
|
|
_msg("B"), # 2
|
|
_time("PT1H"), # 3 — +1 hour = 3 hours total
|
|
_msg("C"), # 4
|
|
_time("P1D"), # 5 — +1 day = 1 day 3 hours total
|
|
_msg("D"), # 6
|
|
],
|
|
archived_history=[
|
|
{"text": "Before jumps", "start": 0, "end": 0, "ts": "WRONG"},
|
|
{"text": "After 2h", "start": 2, "end": 2, "ts": "WRONG"},
|
|
{"text": "After 3h", "start": 4, "end": 4, "ts": "WRONG"},
|
|
{"text": "After 1d3h", "start": 6, "end": 6, "ts": "WRONG"},
|
|
],
|
|
)
|
|
|
|
fix_time(scene)
|
|
|
|
# end=0: no jump before idx 0
|
|
assert scene.archived_history[0]["ts"] == "PT0S"
|
|
# end=2: jump at idx 1 (PT2H) is before idx 2
|
|
assert scene.archived_history[1]["ts"] == "PT2H"
|
|
# end=4: jumps at idx 1+3 cumulative = PT3H, both before idx 4
|
|
assert scene.archived_history[2]["ts"] == "PT3H"
|
|
# end=6: all jumps cumulative = P1DT3H
|
|
assert scene.archived_history[3]["ts"] == "P1DT3H"
|
|
assert scene.ts == "P1DT3H"
|
|
|
|
def test_static_entries_used_as_starting_time(self):
|
|
"""Static entries with ts should set the starting baseline."""
|
|
scene = make_scene(
|
|
history=[
|
|
_msg("A"), # 0
|
|
_time("PT1H"), # 1 — +1h from baseline
|
|
_msg("B"), # 2
|
|
],
|
|
archived_history=[
|
|
# static entry sets baseline to P1D
|
|
{"text": "Background", "ts": "P1D"},
|
|
{"text": "After 1h", "start": 0, "end": 2, "ts": "WRONG"},
|
|
],
|
|
)
|
|
|
|
fix_time(scene)
|
|
|
|
# Baseline is P1D from static entry, +1h = P1DT1H
|
|
assert scene.archived_history[1]["ts"] == "P1DT1H"
|
|
assert scene.ts == "P1DT1H"
|
|
|
|
def test_no_time_jumps(self):
|
|
"""No TimePassageMessages should leave ts at starting_time."""
|
|
scene = make_scene(
|
|
history=[_msg("A"), _msg("B")],
|
|
archived_history=[
|
|
{"text": "Summary", "start": 0, "end": 1, "ts": "PT5H"},
|
|
],
|
|
)
|
|
|
|
fix_time(scene)
|
|
|
|
# No time jumps, so ts stays at starting_time (PT0S default)
|
|
assert scene.ts == "PT0S"
|
|
|
|
def test_zero_duration_time_passage(self):
|
|
"""A zero-duration time passage should not change timestamps."""
|
|
scene = make_scene(
|
|
history=[
|
|
_msg("A"), # 0
|
|
_time("PT0S"), # 1 — zero
|
|
_msg("B"), # 2
|
|
_time("PT3H"), # 3 — 3 hours
|
|
_msg("C"), # 4
|
|
],
|
|
archived_history=[
|
|
{"text": "Before", "start": 0, "end": 0, "ts": "WRONG"},
|
|
{"text": "Middle", "start": 2, "end": 2, "ts": "WRONG"},
|
|
{"text": "After", "start": 4, "end": 4, "ts": "WRONG"},
|
|
],
|
|
)
|
|
|
|
fix_time(scene)
|
|
|
|
assert scene.archived_history[0]["ts"] == "PT0S"
|
|
# PT0S added = still PT0S (but as isodate normalises it may vary)
|
|
assert scene.archived_history[1]["ts"] in ("PT0S", "P0D")
|
|
assert scene.archived_history[2]["ts"] == "PT3H"
|
|
assert scene.ts == "PT3H"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: fix_time does NOT update layered_history (current gap)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestFixTimeLayeredHistoryBasic:
|
|
"""Verify that fix_time updates both archived_history and layered_history."""
|
|
|
|
def test_layered_history_updated(self):
|
|
"""
|
|
After fix_time, both archived_history and layered_history
|
|
timestamps should be updated from scene.history time passages.
|
|
"""
|
|
scene = make_scene(
|
|
history=[
|
|
_msg("A"), # 0
|
|
_msg("B"), # 1
|
|
_time("PT2H"), # 2
|
|
_msg("C"), # 3
|
|
_msg("D"), # 4
|
|
_time("P1D"), # 5
|
|
_msg("E"), # 6
|
|
_msg("F"), # 7
|
|
],
|
|
archived_history=[
|
|
{
|
|
"text": "Summary 0-1",
|
|
"start": 0,
|
|
"end": 1,
|
|
"ts": "STALE",
|
|
"id": "a1",
|
|
},
|
|
{
|
|
"text": "Summary 3-4",
|
|
"start": 3,
|
|
"end": 4,
|
|
"ts": "STALE",
|
|
"id": "a2",
|
|
},
|
|
{
|
|
"text": "Summary 6-7",
|
|
"start": 6,
|
|
"end": 7,
|
|
"ts": "STALE",
|
|
"id": "a3",
|
|
},
|
|
],
|
|
layered_history=[
|
|
# Layer 0: references archived_history indices
|
|
[
|
|
{
|
|
"text": "Layer0 entry A",
|
|
"start": 0,
|
|
"end": 1,
|
|
"ts": "STALE",
|
|
"ts_start": "STALE",
|
|
"ts_end": "STALE",
|
|
"id": "l0a",
|
|
},
|
|
{
|
|
"text": "Layer0 entry B",
|
|
"start": 2,
|
|
"end": 2,
|
|
"ts": "STALE",
|
|
"ts_start": "STALE",
|
|
"ts_end": "STALE",
|
|
"id": "l0b",
|
|
},
|
|
],
|
|
],
|
|
)
|
|
|
|
fix_time(scene)
|
|
|
|
# Archived history IS fixed
|
|
assert scene.archived_history[0]["ts"] == "PT0S"
|
|
assert scene.archived_history[1]["ts"] == "PT2H"
|
|
assert scene.archived_history[2]["ts"] == "P1DT2H"
|
|
|
|
# Layered history IS fixed — timestamps derived from archived_history
|
|
# Layer 0 entry [0]: covers archived[0..1]
|
|
# ts = archived[0]["ts"] = PT0S
|
|
# ts_end = archived[1]["ts"] = PT2H
|
|
assert scene.layered_history[0][0]["ts"] == "PT0S"
|
|
assert scene.layered_history[0][0]["ts_start"] == "PT0S"
|
|
assert scene.layered_history[0][0]["ts_end"] == "PT2H"
|
|
# Layer 0 entry [1]: covers archived[2..2]
|
|
assert scene.layered_history[0][1]["ts"] == "P1DT2H"
|
|
assert scene.layered_history[0][1]["ts_start"] == "P1DT2H"
|
|
assert scene.layered_history[0][1]["ts_end"] == "P1DT2H"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: fix_time SHOULD update layered_history (expected after fix)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestFixTimeLayeredHistory:
|
|
"""Verify fix_time correctly updates layered_history timestamps."""
|
|
|
|
def test_single_layer_timestamps_fixed(self):
|
|
"""
|
|
Layer 0 of layered_history references archived_history via start/end.
|
|
After fix_time, its ts/ts_start/ts_end should reflect the updated
|
|
archived_history timestamps.
|
|
"""
|
|
scene = make_scene(
|
|
history=[
|
|
_msg("A"), # 0
|
|
_msg("B"), # 1
|
|
_time("PT2H"), # 2
|
|
_msg("C"), # 3
|
|
_msg("D"), # 4
|
|
_time("P1D"), # 5
|
|
_msg("E"), # 6
|
|
_msg("F"), # 7
|
|
],
|
|
archived_history=[
|
|
{
|
|
"text": "Summary 0-1",
|
|
"start": 0,
|
|
"end": 1,
|
|
"ts": "STALE",
|
|
"id": "a1",
|
|
},
|
|
{
|
|
"text": "Summary 3-4",
|
|
"start": 3,
|
|
"end": 4,
|
|
"ts": "STALE",
|
|
"id": "a2",
|
|
},
|
|
{
|
|
"text": "Summary 6-7",
|
|
"start": 6,
|
|
"end": 7,
|
|
"ts": "STALE",
|
|
"id": "a3",
|
|
},
|
|
],
|
|
layered_history=[
|
|
# Layer 0: references archived_history[0..1] and archived_history[2]
|
|
[
|
|
{
|
|
"text": "Layer0 summary of archived 0-1",
|
|
"start": 0,
|
|
"end": 1,
|
|
"ts": "STALE",
|
|
"ts_start": "STALE",
|
|
"ts_end": "STALE",
|
|
"id": "l0a",
|
|
},
|
|
{
|
|
"text": "Layer0 summary of archived 2",
|
|
"start": 2,
|
|
"end": 2,
|
|
"ts": "STALE",
|
|
"ts_start": "STALE",
|
|
"ts_end": "STALE",
|
|
"id": "l0b",
|
|
},
|
|
],
|
|
],
|
|
)
|
|
|
|
fix_time(scene)
|
|
|
|
# Archived history is fixed:
|
|
# a1 end=1: no jump before idx 1 → PT0S
|
|
# a2 end=4: jump at idx 2 (PT2H) before idx 4 → PT2H
|
|
# a3 end=7: jumps cumulative P1DT2H before idx 7 → P1DT2H
|
|
assert scene.archived_history[0]["ts"] == "PT0S"
|
|
assert scene.archived_history[1]["ts"] == "PT2H"
|
|
assert scene.archived_history[2]["ts"] == "P1DT2H"
|
|
|
|
# Layer 0 entry [0]: covers archived[0..1]
|
|
# ts = archived[0]["ts"] = PT0S
|
|
# ts_start = archived[0]["ts"] = PT0S (archived has no ts_start)
|
|
# ts_end = archived[1]["ts"] = PT2H
|
|
assert scene.layered_history[0][0]["ts"] == "PT0S"
|
|
assert scene.layered_history[0][0]["ts_start"] == "PT0S"
|
|
assert scene.layered_history[0][0]["ts_end"] == "PT2H"
|
|
|
|
# Layer 0 entry [1]: covers archived[2..2]
|
|
# ts = archived[2]["ts"] = P1DT2H
|
|
# ts_start = P1DT2H
|
|
# ts_end = P1DT2H
|
|
assert scene.layered_history[0][1]["ts"] == "P1DT2H"
|
|
assert scene.layered_history[0][1]["ts_start"] == "P1DT2H"
|
|
assert scene.layered_history[0][1]["ts_end"] == "P1DT2H"
|
|
|
|
def test_multi_layer_timestamps_cascade(self):
|
|
"""
|
|
Layer 1 references layer 0, which references archived_history.
|
|
All should be updated after fix_time.
|
|
"""
|
|
scene = make_scene(
|
|
history=[
|
|
_msg("A"), # 0
|
|
_time("PT1H"), # 1
|
|
_msg("B"), # 2
|
|
_time("PT1H"), # 3
|
|
_msg("C"), # 4
|
|
_time("PT1H"), # 5
|
|
_msg("D"), # 6
|
|
],
|
|
archived_history=[
|
|
{"text": "Sum 0", "start": 0, "end": 0, "ts": "STALE", "id": "a1"},
|
|
{"text": "Sum 2", "start": 2, "end": 2, "ts": "STALE", "id": "a2"},
|
|
{"text": "Sum 4", "start": 4, "end": 4, "ts": "STALE", "id": "a3"},
|
|
{"text": "Sum 6", "start": 6, "end": 6, "ts": "STALE", "id": "a4"},
|
|
],
|
|
layered_history=[
|
|
# Layer 0: references archived_history
|
|
[
|
|
{
|
|
"text": "L0 A",
|
|
"start": 0,
|
|
"end": 1,
|
|
"ts": "STALE",
|
|
"ts_start": "STALE",
|
|
"ts_end": "STALE",
|
|
"id": "l0a",
|
|
},
|
|
{
|
|
"text": "L0 B",
|
|
"start": 2,
|
|
"end": 3,
|
|
"ts": "STALE",
|
|
"ts_start": "STALE",
|
|
"ts_end": "STALE",
|
|
"id": "l0b",
|
|
},
|
|
],
|
|
# Layer 1: references layered_history[0]
|
|
[
|
|
{
|
|
"text": "L1 A",
|
|
"start": 0,
|
|
"end": 1,
|
|
"ts": "STALE",
|
|
"ts_start": "STALE",
|
|
"ts_end": "STALE",
|
|
"id": "l1a",
|
|
},
|
|
],
|
|
],
|
|
)
|
|
|
|
fix_time(scene)
|
|
|
|
# Archived: jumps at idx 1,3,5 → cumulative PT1H, PT2H, PT3H
|
|
assert scene.archived_history[0]["ts"] == "PT0S" # end=0, no jump before
|
|
assert scene.archived_history[1]["ts"] == "PT1H" # end=2, jump at 1
|
|
assert scene.archived_history[2]["ts"] == "PT2H" # end=4, jumps at 1,3
|
|
assert scene.archived_history[3]["ts"] == "PT3H" # end=6, jumps at 1,3,5
|
|
|
|
# Layer 0 entry [0]: covers archived[0..1]
|
|
assert scene.layered_history[0][0]["ts"] == "PT0S"
|
|
assert scene.layered_history[0][0]["ts_start"] == "PT0S"
|
|
assert scene.layered_history[0][0]["ts_end"] == "PT1H"
|
|
|
|
# Layer 0 entry [1]: covers archived[2..3]
|
|
assert scene.layered_history[0][1]["ts"] == "PT2H"
|
|
assert scene.layered_history[0][1]["ts_start"] == "PT2H"
|
|
assert scene.layered_history[0][1]["ts_end"] == "PT3H"
|
|
|
|
# Layer 1 entry [0]: covers layered_history[0][0..1]
|
|
# ts_end = l0[1].ts_end = PT3H (l0[1] covers archived[2..3])
|
|
assert scene.layered_history[1][0]["ts"] == "PT0S"
|
|
assert scene.layered_history[1][0]["ts_start"] == "PT0S"
|
|
assert scene.layered_history[1][0]["ts_end"] == "PT3H"
|
|
|
|
def test_layered_entry_without_ts_start_ts_end(self):
|
|
"""
|
|
If a layered entry has no ts_start/ts_end fields, fix_time should
|
|
still set them based on the source layer.
|
|
"""
|
|
scene = make_scene(
|
|
history=[
|
|
_msg("A"), # 0
|
|
_time("PT5H"), # 1
|
|
_msg("B"), # 2
|
|
],
|
|
archived_history=[
|
|
{"text": "Sum 0", "start": 0, "end": 0, "ts": "STALE", "id": "a1"},
|
|
{"text": "Sum 2", "start": 2, "end": 2, "ts": "STALE", "id": "a2"},
|
|
],
|
|
layered_history=[
|
|
[
|
|
{
|
|
"text": "L0 covers both",
|
|
"start": 0,
|
|
"end": 1,
|
|
"ts": "STALE",
|
|
"id": "l0a",
|
|
# no ts_start or ts_end
|
|
},
|
|
],
|
|
],
|
|
)
|
|
|
|
fix_time(scene)
|
|
|
|
assert scene.layered_history[0][0]["ts"] == "PT0S"
|
|
assert scene.layered_history[0][0]["ts_start"] == "PT0S"
|
|
assert scene.layered_history[0][0]["ts_end"] == "PT5H"
|
|
|
|
def test_empty_layered_history(self):
|
|
"""fix_time should not crash when layered_history is empty."""
|
|
scene = make_scene(
|
|
history=[_msg("A"), _time("PT1H"), _msg("B")],
|
|
archived_history=[
|
|
{"text": "Sum", "start": 0, "end": 2, "ts": "STALE"},
|
|
],
|
|
layered_history=[],
|
|
)
|
|
|
|
fix_time(scene)
|
|
|
|
assert scene.archived_history[0]["ts"] == "PT1H"
|
|
assert scene.ts == "PT1H"
|