mirror of
https://github.com/vegu-ai/talemate.git
synced 2026-05-18 05:05:39 +02:00
0.37.0 - **Director Planning** — Multi-step todo lists in director chat plus a Generate long progress action for multi-beat scene arcs. - **Auto Narration** — Unified auto-narration replacing the old Narrate after Dialogue toggle, with a chance slider and weighted action mix. - **LLM Prompt Templates Manager** — Dedicated UI tab for viewing, creating, editing, and deleting prompt templates. - **Character Folders** — Collapsible folders in the World Editor character list, synced across linked scenes. - **OpenAI Compatible TTS** — Connect any number of OpenAI-compatible TTS servers in parallel. - **KoboldCpp TTS Auto-Setup** — KoboldCpp clients with a TTS model loaded register themselves as a TTS backend. - **Model Testing Harness** — Bundled scene that runs basic capability tests against any connected LLM. Plus 27 improvements and 28 bug fixes
208 lines
6.7 KiB
Python
208 lines
6.7 KiB
Python
"""
|
|
Validates that all prompt templates have properly closed sections.
|
|
|
|
Every <|SECTION:NAME|> must have a corresponding <|CLOSE_SECTION|> before
|
|
the next <|SECTION:...|> or end of file. This is required for XML-style
|
|
sectioning to work correctly, since XML needs explicit closing tags.
|
|
"""
|
|
|
|
import re
|
|
import pytest
|
|
from pathlib import Path
|
|
|
|
TEMPLATES_DIR = (
|
|
Path(__file__).resolve().parent.parent.parent
|
|
/ "src"
|
|
/ "talemate"
|
|
/ "prompts"
|
|
/ "templates"
|
|
)
|
|
|
|
SECTION_OPEN_RE = re.compile(r"<\|SECTION:([^|]+)\|>")
|
|
SECTION_CLOSE_RE = re.compile(r"<\|CLOSE_SECTION\|>")
|
|
|
|
# Jinja calls that emit <|BOT|> into the rendered output
|
|
BOT_TOKEN_RE = re.compile(
|
|
r"\{\{.*?("
|
|
r"set_prepared_response|set_data_response|set_json_response"
|
|
r"|set_prepared_response_random|bot_token"
|
|
r").*?\}\}"
|
|
)
|
|
|
|
# Templates rendered without sectioning applied (raw markers pass through
|
|
# to the including template which handles closing). Paths relative to TEMPLATES_DIR.
|
|
SKIP_SECTION_VALIDATION = {
|
|
"focal/instructions.jinja2",
|
|
}
|
|
|
|
|
|
def find_unclosed_sections(filepath: Path) -> list[dict]:
|
|
"""
|
|
Parse a template file and return a list of unclosed sections.
|
|
|
|
A section is unclosed if a <|SECTION:X|> is followed by another
|
|
<|SECTION:Y|> or EOF without an intervening <|CLOSE_SECTION|>.
|
|
"""
|
|
text = filepath.read_text()
|
|
lines = text.split("\n")
|
|
|
|
unclosed = []
|
|
current_section = None
|
|
current_section_line = None
|
|
|
|
for line_num, line in enumerate(lines, start=1):
|
|
open_match = SECTION_OPEN_RE.search(line)
|
|
close_match = SECTION_CLOSE_RE.search(line)
|
|
|
|
if open_match and close_match:
|
|
# Both on same line - check order
|
|
if line.index("<|SECTION:") < line.index("<|CLOSE_SECTION|>"):
|
|
# Open then close on same line - section is self-closed
|
|
# But first close previous if any
|
|
if current_section:
|
|
unclosed.append(
|
|
{
|
|
"section": current_section,
|
|
"opened_at": current_section_line,
|
|
"reason": f"implicitly closed by new section at line {line_num}",
|
|
}
|
|
)
|
|
current_section = None
|
|
current_section_line = None
|
|
else:
|
|
# Close then open on same line
|
|
current_section = open_match.group(1)
|
|
current_section_line = line_num
|
|
continue
|
|
|
|
if open_match:
|
|
if current_section:
|
|
unclosed.append(
|
|
{
|
|
"section": current_section,
|
|
"opened_at": current_section_line,
|
|
"reason": f"implicitly closed by new section '{open_match.group(1)}' at line {line_num}",
|
|
}
|
|
)
|
|
current_section = open_match.group(1)
|
|
current_section_line = line_num
|
|
continue
|
|
|
|
if close_match:
|
|
current_section = None
|
|
current_section_line = None
|
|
|
|
# Check for section still open at EOF
|
|
if current_section:
|
|
unclosed.append(
|
|
{
|
|
"section": current_section,
|
|
"opened_at": current_section_line,
|
|
"reason": "still open at end of file",
|
|
}
|
|
)
|
|
|
|
return unclosed
|
|
|
|
|
|
def collect_template_files() -> list[Path]:
|
|
"""Collect all jinja2 template files."""
|
|
return sorted(TEMPLATES_DIR.rglob("*.jinja2"))
|
|
|
|
|
|
def test_all_sections_explicitly_closed():
|
|
"""Every <|SECTION:X|> must have an explicit <|CLOSE_SECTION|>."""
|
|
template_files = collect_template_files()
|
|
assert template_files, f"No template files found in {TEMPLATES_DIR}"
|
|
|
|
failures = []
|
|
|
|
for filepath in template_files:
|
|
rel_path = filepath.relative_to(TEMPLATES_DIR)
|
|
if str(rel_path) in SKIP_SECTION_VALIDATION:
|
|
continue
|
|
unclosed = find_unclosed_sections(filepath)
|
|
if unclosed:
|
|
for issue in unclosed:
|
|
failures.append(
|
|
f" {rel_path}:{issue['opened_at']} - "
|
|
f"section '{issue['section']}' {issue['reason']}"
|
|
)
|
|
|
|
if failures:
|
|
msg = f"Found {len(failures)} unclosed section(s):\n" + "\n".join(failures)
|
|
pytest.fail(msg)
|
|
|
|
|
|
def find_bot_token_inside_sections(filepath: Path) -> list[dict]:
|
|
"""
|
|
Find cases where bot_token / set_prepared_response / set_data_response
|
|
appears inside a section (between SECTION open and CLOSE_SECTION).
|
|
|
|
When using XML sectioning, the closing tag would end up in the assistant
|
|
prefill, corrupting the LLM's response start.
|
|
"""
|
|
text = filepath.read_text()
|
|
lines = text.split("\n")
|
|
|
|
issues = []
|
|
current_section = None
|
|
current_section_line = None
|
|
|
|
for line_num, line in enumerate(lines, start=1):
|
|
open_match = SECTION_OPEN_RE.search(line)
|
|
close_match = SECTION_CLOSE_RE.search(line)
|
|
|
|
if open_match:
|
|
current_section = open_match.group(1)
|
|
current_section_line = line_num
|
|
elif close_match:
|
|
current_section = None
|
|
current_section_line = None
|
|
|
|
if current_section and BOT_TOKEN_RE.search(line):
|
|
issues.append(
|
|
{
|
|
"section": current_section,
|
|
"section_line": current_section_line,
|
|
"bot_line": line_num,
|
|
"call": BOT_TOKEN_RE.search(line).group(1),
|
|
}
|
|
)
|
|
|
|
return issues
|
|
|
|
|
|
def test_no_bot_token_inside_sections():
|
|
"""Bot token / prepared response calls must not appear inside sections.
|
|
|
|
When using XML sectioning, the closing tag (e.g. </TASK>) would end up
|
|
in the assistant prefill after the <|BOT|> marker, corrupting the
|
|
LLM's response start.
|
|
"""
|
|
template_files = collect_template_files()
|
|
assert template_files, f"No template files found in {TEMPLATES_DIR}"
|
|
|
|
failures = []
|
|
|
|
for filepath in template_files:
|
|
rel_path = filepath.relative_to(TEMPLATES_DIR)
|
|
if str(rel_path) in SKIP_SECTION_VALIDATION:
|
|
continue
|
|
issues = find_bot_token_inside_sections(filepath)
|
|
if issues:
|
|
for issue in issues:
|
|
failures.append(
|
|
f" {rel_path}:{issue['bot_line']} - "
|
|
f"{issue['call']}() inside section '{issue['section']}' "
|
|
f"(opened at line {issue['section_line']})"
|
|
)
|
|
|
|
if failures:
|
|
msg = (
|
|
f"Found {len(failures)} bot token/prepared response call(s) inside sections.\n"
|
|
f"Move the call after <|CLOSE_SECTION|> or remove the enclosing section.\n"
|
|
+ "\n".join(failures)
|
|
)
|
|
pytest.fail(msg)
|