mirror of https://github.com/razor-ai/soup.git
161 lines
4.8 KiB
Python
161 lines
4.8 KiB
Python
"""Tests for v0.37.0 Part D — JinjaTemplateAnalyzer.
|
|
|
|
Walks chat-template ASTs to discover which ``message[...]`` fields the
|
|
template touches. Used to make ``train_on_responses_only`` masking aware of
|
|
non-standard fields (e.g. ``tool_calls``, ``name``, ``weight``).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from soup_cli.utils.jinja_analyzer import (
|
|
DEFAULT_MESSAGE_FIELDS,
|
|
JinjaTemplateAnalyzer,
|
|
extract_message_fields,
|
|
)
|
|
|
|
# ---- extract_message_fields ----------------------------------------------
|
|
|
|
|
|
def test_extracts_role_and_content():
|
|
template = "{% for m in messages %}{{ m.role }}: {{ m.content }}{% endfor %}"
|
|
fields = extract_message_fields(template)
|
|
assert "role" in fields
|
|
assert "content" in fields
|
|
|
|
|
|
def test_extracts_tool_calls_field():
|
|
template = (
|
|
"{% for m in messages %}"
|
|
"{{ m.role }}: {{ m.content }}"
|
|
"{% if m.tool_calls %}{{ m.tool_calls }}{% endif %}"
|
|
"{% endfor %}"
|
|
)
|
|
fields = extract_message_fields(template)
|
|
assert "tool_calls" in fields
|
|
|
|
|
|
def test_extracts_subscript_access():
|
|
# message["content"] form — used by some HF templates
|
|
template = (
|
|
"{% for m in messages %}"
|
|
'{{ m["role"] }}: {{ m["content"] }}'
|
|
"{% endfor %}"
|
|
)
|
|
fields = extract_message_fields(template)
|
|
assert "role" in fields
|
|
assert "content" in fields
|
|
|
|
|
|
def test_extracts_weight_field():
|
|
# message.weight — used by Axolotl per-message training masks
|
|
template = (
|
|
"{% for m in messages %}"
|
|
"{% if m.weight > 0 %}{{ m.content }}{% endif %}"
|
|
"{% endfor %}"
|
|
)
|
|
fields = extract_message_fields(template)
|
|
assert "weight" in fields
|
|
|
|
|
|
def test_returns_empty_set_for_no_message_loop():
|
|
template = "static text with no message loop"
|
|
fields = extract_message_fields(template)
|
|
assert fields == set()
|
|
|
|
|
|
def test_handles_train_field_axolotl_style():
|
|
template = (
|
|
"{% for m in messages %}"
|
|
"{% if m.train %}{{ m.content }}{% endif %}"
|
|
"{% endfor %}"
|
|
)
|
|
fields = extract_message_fields(template)
|
|
assert "train" in fields
|
|
|
|
|
|
def test_rejects_empty_template():
|
|
with pytest.raises(ValueError, match="empty"):
|
|
extract_message_fields("")
|
|
|
|
|
|
def test_rejects_non_string():
|
|
with pytest.raises(TypeError, match="must be str"):
|
|
extract_message_fields(123) # type: ignore[arg-type]
|
|
|
|
|
|
def test_rejects_null_byte():
|
|
with pytest.raises(ValueError, match="null"):
|
|
extract_message_fields("{{ m.content\x00 }}")
|
|
|
|
|
|
def test_oversize_template_rejected():
|
|
huge = "x" * 200_000
|
|
with pytest.raises(ValueError, match="too large"):
|
|
extract_message_fields(huge)
|
|
|
|
|
|
def test_invalid_jinja_raises():
|
|
template = "{% for m in messages %}{{ m.role" # unterminated
|
|
with pytest.raises(ValueError, match="parse"):
|
|
extract_message_fields(template)
|
|
|
|
|
|
# ---- JinjaTemplateAnalyzer class -----------------------------------------
|
|
|
|
|
|
def test_analyzer_construct_and_query():
|
|
template = "{% for m in messages %}{{ m.role }}: {{ m.content }}{% endfor %}"
|
|
analyzer = JinjaTemplateAnalyzer(template)
|
|
assert analyzer.has_field("role")
|
|
assert analyzer.has_field("content")
|
|
assert not analyzer.has_field("tool_calls")
|
|
|
|
|
|
def test_analyzer_unknown_field_returns_false():
|
|
template = "{% for m in messages %}{{ m.content }}{% endfor %}"
|
|
analyzer = JinjaTemplateAnalyzer(template)
|
|
assert analyzer.has_field("nonexistent_xyz") is False
|
|
|
|
|
|
def test_analyzer_message_fields_property():
|
|
template = (
|
|
"{% for m in messages %}"
|
|
"{{ m.role }}: {{ m.content }}"
|
|
"{% if m.tool_calls %}T{% endif %}"
|
|
"{% endfor %}"
|
|
)
|
|
analyzer = JinjaTemplateAnalyzer(template)
|
|
fields = analyzer.message_fields
|
|
assert "role" in fields
|
|
assert "content" in fields
|
|
assert "tool_calls" in fields
|
|
# Returned set should be a copy (defence against mutation)
|
|
fields.add("tampered")
|
|
assert "tampered" not in analyzer.message_fields
|
|
|
|
|
|
def test_analyzer_uses_non_standard_fields_helper():
|
|
# Standard fields = role / content. Anything else is "non-standard".
|
|
template = (
|
|
"{% for m in messages %}"
|
|
"{{ m.role }}: {{ m.content }}"
|
|
"{% if m.weight > 0 %}W{% endif %}"
|
|
"{% endfor %}"
|
|
)
|
|
analyzer = JinjaTemplateAnalyzer(template)
|
|
non_standard = analyzer.non_standard_fields()
|
|
assert "weight" in non_standard
|
|
assert "role" not in non_standard
|
|
assert "content" not in non_standard
|
|
|
|
|
|
def test_analyzer_default_fields_constant():
|
|
assert "role" in DEFAULT_MESSAGE_FIELDS
|
|
assert "content" in DEFAULT_MESSAGE_FIELDS
|
|
# Must be frozen — prevent runtime mutation
|
|
assert isinstance(DEFAULT_MESSAGE_FIELDS, frozenset)
|
|
with pytest.raises(AttributeError):
|
|
DEFAULT_MESSAGE_FIELDS.add("x") # type: ignore[attr-defined]
|