diff --git a/backend/app/chat.py b/backend/app/chat.py index 2c74628..9c05c16 100644 --- a/backend/app/chat.py +++ b/backend/app/chat.py @@ -42,8 +42,11 @@ _CHAPTER_VERSE_RE = re.compile( r"(? tuple[str, int, int] | None: return None -def _iter_references(text: str) -> Iterable[tuple[re.Match, str, str]]: - """Percorre as referências do texto, devolvendo livro canônico e faixa.""" +def _iter_references(text: str) -> Iterable[tuple[tuple[int, int], str, str]]: + """Percorre as referências do texto. + + Devolve `(span, livro canônico, referência)`, onde `span` cobre **do início do + nome do livro ao fim do capítulo:versículo**. Repassar só o trecho numérico + faria a remoção deixar o nome do livro pendurado — "(Gênesis 1:1)" viraria + "(Gênesis )" na resposta exibida ao usuário. + """ for canonical, start, match in _iter_reference_matches(text): chapter = int(match.group(1)) verse = int(match.group(2)) @@ -131,7 +140,7 @@ def _iter_references(text: str) -> Iterable[tuple[re.Match, str, str]]: reference = f"{canonical} {chapter}:{verse}" if end_verse: reference = f"{reference}-{end_verse}" - yield match, canonical, reference + yield (start, match.end()), canonical, reference def validate_citations( @@ -148,18 +157,19 @@ def validate_citations( removals: list[tuple[int, int]] = [] citations: list[str] = [] - for match, canonical, reference in _iter_references(text): + for span, canonical, reference in _iter_references(text): base = reference.split("-")[0] if reference in allowed or base in allowed: citations.append(reference) else: - removals.append(match.span()) + removals.append(span) cleaned = text for start, end in reversed(removals): cleaned = cleaned[:start] + cleaned[end:] - # Espaços duplos deixados pela remoção são normalizados. + # Resíduos da remoção: parênteses que ficaram vazios e espaços duplos. + cleaned = re.sub(r"\(\s*\)", "", cleaned) cleaned = re.sub(r"[ \t]{2,}", " ", cleaned) cleaned = re.sub(r"\s+([.,;:!?])", r"\1", cleaned) diff --git a/backend/tests/test_chat.py b/backend/tests/test_chat.py index 5208e09..cc02463 100644 --- a/backend/tests/test_chat.py +++ b/backend/tests/test_chat.py @@ -356,3 +356,110 @@ def test_source_shows_the_cited_verse_not_the_context_window(settings): assert source.reference == "Salmos 23:1" assert source.text == "O Senhor é o meu pastor." assert "Versículo anterior" in result.text, "o modelo recebe a janela completa" + + +def test_unsupported_citation_is_removed_whole_not_just_the_numbers(): + """Remover só o `1:1` deixa `(Gênesis )` pendurado na resposta do usuário. + + Defeito observado na UI: a validação cortava o trecho `Capítulo:Versículo` e + preservava o nome do livro, que é justamente o que não tem sustentação. + """ + from app.chat import validate_citations + + cleaned, citations = validate_citations( + "O Senhor é o meu pastor (Salmos 23:1) e no princípio criou Deus (Gênesis 1:1).", + {"Salmos 23:1"}, + ) + + assert citations == ["Salmos 23:1"] + assert "Gênesis" not in cleaned, "o nome do livro não sustentado tem de sair junto" + assert cleaned == "O Senhor é o meu pastor (Salmos 23:1) e no princípio criou Deus." + + +def test_removal_does_not_leave_empty_parentheses(): + from app.chat import validate_citations + + cleaned, citations = validate_citations( + "Deus é amor (1 João 4:8) e o texto continua (Apocalipse 9:9).", + {"1 João 4:8"}, + ) + + assert citations == ["1 João 4:8"] + assert "()" not in cleaned + assert "( )" not in cleaned + assert cleaned == "Deus é amor (1 João 4:8) e o texto continua." + + +def test_removal_of_a_range_citation_takes_the_whole_range(): + from app.chat import validate_citations + + cleaned, citations = validate_citations( + "Conforme Salmos 23:1-3 e também Jó 1:2-3, há esperança.", + {"Salmos 23:1-3"}, + ) + + assert citations == ["Salmos 23:1-3"] + assert "Jó" not in cleaned + + +# -------------------------------------------------------------------------- +# Livros numerados (18 dos 66: 1/2/3 Samuel, Reis, Crônicas, Coríntios, ...) +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "text,expected", + [ + ("1 João 4:8", ("1 João", 4, 8)), + ("2 Reis 5:14", ("2 Reis", 5, 14)), + ("1 Coríntios 13:4", ("1 Coríntios", 13, 4)), + ("3 João 1:4", ("3 João", 1, 4)), + ("I Samuel 17:4", ("1 Samuel", 17, 4)), + ("II Crônicas 7:14", ("2 Crônicas", 7, 14)), + ("1Sm 18:6", ("1 Samuel", 18, 6)), + ("João 3:16", ("João", 3, 16)), + ("Salmos 23:1", ("Salmos", 23, 1)), + ], +) +def test_extract_reference_keeps_the_book_number(text, expected): + """Perder o número devolve outro livro: '1 João 4:8' viraria João 4:8.""" + from app.chat import extract_reference + + assert extract_reference(text) == expected + + +def test_extract_reference_inside_a_sentence_keeps_the_book_number(): + from app.chat import extract_reference + + assert extract_reference("Leia 2 Reis 5:14 com atenção") == ("2 Reis", 5, 14) + + +def test_reference_after_another_reference_keeps_its_own_book(): + """O dígito do livro não pode ser confundido com o versículo anterior.""" + from app.chat import extract_reference + + assert extract_reference("Salmos 23:1 e também 1 João 4:8") == ("Salmos", 23, 1) + + +def test_supported_numbered_book_citation_is_kept(): + """Uma citação correta de livro numerado não pode ser apagada da resposta.""" + from app.chat import validate_citations + + cleaned, citations = validate_citations( + "Deus é amor (1 João 4:8).", {"1 João 4:8"} + ) + + assert citations == ["1 João 4:8"] + assert cleaned == "Deus é amor (1 João 4:8)." + + +def test_unsupported_numbered_book_citation_is_removed_whole(): + from app.chat import validate_citations + + cleaned, citations = validate_citations( + "Há esperança (2 Reis 5:14).", {"Salmos 23:1"} + ) + + assert citations == [] + assert "Reis" not in cleaned + assert cleaned == "Há esperança."