Ctrl K
ComunidadGuías…

backend/tests/test_llm_invented_hard_breaks.py

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
r"""A translation must not carry a hard line break the source did not have.

Measured on a real library on 2026-08-24: the written German ASS for one
episode contained 158 lines with a literal ``\N`` where its English source had
28. The extra breaks come from the model, they change how the subtitle renders
against the original, and — because the line *count* is still correct — nothing
in the pipeline notices. Sublarr's contract with the backends is a 1:1 line
mapping; where the source line has no hard break, neither may the translation.
"""

from translation.llm_utils import strip_invented_hard_breaks

HB = chr(92) + "N"  # literal ASS hard line break


def test_trailing_break_is_removed_when_the_source_had_none():
    source = ["By the way, Mariabelle..."]
    translated = [f"Apropos, Mariabelle…{HB}"]

    assert strip_invented_hard_breaks(source, translated) == ["Apropos, Mariabelle…"]


def test_inner_break_becomes_a_space_so_words_do_not_collide():
    source = ["That's the district mayor's message."]
    translated = [f"Das ist die Nachricht{HB}des Bezirksbürgermeisters."]

    assert strip_invented_hard_breaks(source, translated) == [
        "Das ist die Nachricht des Bezirksbürgermeisters."
    ]


def test_a_break_the_source_asked_for_is_kept():
    source = [f"The Founding Festival is{HB}going to be a huge success!"]
    translated = [f"Das Gründungsfest{HB}wird ein voller Erfolg!"]

    assert strip_invented_hard_breaks(source, translated) == translated


def test_lines_without_any_break_are_returned_untouched():
    source = ["What?!", "This is crazy..."]
    translated = ["Was?!", "Das ist verrückt..."]

    assert strip_invented_hard_breaks(source, translated) == translated


def test_a_length_mismatch_leaves_everything_alone():
    """Pairing is meaningless then — the caller handles the mismatch."""
    source = ["one", "two"]
    translated = [f"eins{HB}"]

    assert strip_invented_hard_breaks(source, translated) == translated


def test_multiple_invented_breaks_in_one_line_all_go():
    source = ["Short line."]
    translated = [f"Kurze{HB}Zeile{HB}hier.{HB}"]

    assert strip_invented_hard_breaks(source, translated) == ["Kurze Zeile hier."]


# ---------------------------------------------------------------------------
# Wiring — a helper nobody calls fixes nothing. The two defects this session
# found in the frontend both had this exact shape, so pin the call site too.
# ---------------------------------------------------------------------------

import os

import pytest

from config import reload_settings
from db import close_db, init_db
from translation.llm_base import LLMBackend, LLMResponse


@pytest.fixture()
def _app_context(tmp_path):
    from app import create_app

    os.environ["SUBLARR_DB_PATH"] = str(tmp_path / "hb.db")
    os.environ["SUBLARR_API_KEY"] = ""
    os.environ["SUBLARR_LOG_LEVEL"] = "ERROR"
    reload_settings()
    app = create_app(testing=True)
    with app.app_context():
        init_db()
        yield
    close_db()
    for key in ("SUBLARR_DB_PATH", "SUBLARR_API_KEY", "SUBLARR_LOG_LEVEL"):
        os.environ.pop(key, None)


class _BreakHappyBackend(LLMBackend):
    """A backend whose model appends a hard break to every line."""

    name = "break_happy"
    display_name = "Break Happy"
    config_fields = []
    default_model = "stub"
    cost_per_1m_tokens_in = 0
    cost_per_1m_tokens_out = 0

    def _build_request(self, messages, max_tokens):
        return {"messages": messages}

    def _call_api(self, payload, timeout_s):
        return {"n": len(payload["messages"])}

    def _parse_response(self, raw):
        return LLMResponse(
            translations=[f"Zeile eins{HB}", f"Zeile zwei{HB}"],
            tokens_in=10,
            tokens_out=10,
            model="stub",
            finish_reason="stop",
            raw_latency_ms=1,
        )

    def health_check(self):
        return (True, "stub")

    def get_config_fields(self):
        return self.config_fields


def test_translate_batch_removes_breaks_the_source_never_had(_app_context):
    from translation.concurrency import get_concurrency

    get_concurrency().register(_BreakHappyBackend.name, 2)
    backend = _BreakHappyBackend()

    result = backend.translate_batch(["Line one", "Line two"], "en", "de")

    assert result.success is True
    assert result.translated_lines == ["Zeile eins", "Zeile zwei"]


# --------------------------------------------------------------------------
# The soft break, found on 2026-09-03 by probing the deployed 1.14.0-rc.4.
#
# The guard only ever looked at "\N". Models also emit ASS's SOFT break
# "\n", it reached the translation memory unfiltered, and 459 of prod's
# 254 196 entries carry one their source never had — still growing daily
# through the long-term test. A break is a break: where the source line has
# none, the translation may not invent one in either spelling.
# --------------------------------------------------------------------------

SB = chr(92) + "n"  # literal ASS soft line break


def test_invented_soft_break_is_removed():
    source = ["By the way, Mariabelle..."]
    translated = [f"Apropos, Mariabelle…{SB}"]

    assert strip_invented_hard_breaks(source, translated) == ["Apropos, Mariabelle…"]


def test_inner_soft_break_becomes_a_space():
    source = ["That's the district mayor's message."]
    translated = [f"Das ist die Nachricht{SB}des Bezirksbürgermeisters."]

    assert strip_invented_hard_breaks(source, translated) == [
        "Das ist die Nachricht des Bezirksbürgermeisters."
    ]


def test_soft_break_is_kept_when_the_source_carried_a_soft_break():
    source = [f"The Founding Festival is{SB}going to be a huge success!"]
    translated = [f"Das Gründungsfest{SB}wird ein voller Erfolg!"]

    assert strip_invented_hard_breaks(source, translated) == translated


def test_soft_break_is_kept_when_the_source_carried_a_hard_break():
    # The subtitle asked for a break; the model merely spelled it the other
    # way. Removing it would delete a break the source genuinely wanted.
    source = [f"The Founding Festival is{HB}going to be a huge success!"]
    translated = [f"Das Gründungsfest{SB}wird ein voller Erfolg!"]

    assert strip_invented_hard_breaks(source, translated) == translated


def test_both_spellings_in_one_invented_line_go():
    source = ["One flat line without any break at all."]
    translated = [f"Eine{HB}flache{SB}Zeile ganz ohne Umbruch."]

    assert strip_invented_hard_breaks(source, translated) == [
        "Eine flache Zeile ganz ohne Umbruch."
    ]