Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -81,21 +81,12 @@ public FilterResponse allMatched(String text, Set<String> allowedWords) {

ElapsedStartAt start = ElapsedStartAt.now();

String cleanedText = ProfanityText.clean(text);
int currentPos = 0;
ProfanityText.Cleaned cleaned = ProfanityText.cleanWithOffsets(text);
Set<FilterWord> filterWords = new HashSet<>();

for (Emit emit : trie.parseText(cleanedText)) {
int startPos = text.indexOf(emit.getKeyword().charAt(0), currentPos);
if (startPos == -1) continue;
int endPos = startPos;
for (char c : emit.getKeyword().toCharArray()) {
endPos = text.indexOf(c, endPos) + 1;
}
// 허용 단어도 원문 위치는 그대로 지나가야 뒤따르는 검출의 위치가 어긋나지 않는다.
currentPos = endPos;
for (Emit emit : trie.parseText(cleaned.text())) {
if (isAllowed(emit, allowedWords)) continue;
filterWords.add(FilterWord.create(text.substring(startPos, endPos), startPos, endPos));
filterWords.add(toFilterWord(text, cleaned, emit));
}
Elapsed elapsed = Elapsed.end(start);
log.debug("[NormalProfanityFilter] 전체 비속어 필터링 완료 (지연 시간 : {})", elapsed);
Expand All @@ -111,22 +102,16 @@ public FilterWord firstMatched(String text) {

ElapsedStartAt start = ElapsedStartAt.now();

String cleanedText = ProfanityText.clean(text);
Emit emit = trie.firstMatch(cleanedText);
ProfanityText.Cleaned cleaned = ProfanityText.cleanWithOffsets(text);
Emit emit = trie.firstMatch(cleaned.text());

if (emit == null) {
return FilterWord.empty();
}

int startPos = text.indexOf(emit.getKeyword().charAt(0));
int endPos = startPos;
for (char c : emit.getKeyword().toCharArray()) {
endPos = text.indexOf(c, endPos) + 1;
}

Elapsed elapsed = Elapsed.end(start);
log.debug("[NormalProfanityFilter] 단일 비속어 필터링 완료 (지연 시간 : {})", elapsed);
return FilterWord.create(text.substring(startPos, endPos), startPos, endPos);
return toFilterWord(text, cleaned, emit);
}

/** 첫 검출이 허용 단어이면 그 뒤의 검출을 찾아야 하므로 전체를 훑습니다. 결과를 뒤에서 걸러 내면 허용 단어 뒤에 있는 실제 비속어를 놓칩니다. */
Expand All @@ -139,22 +124,21 @@ public FilterWord firstMatched(String text, Set<String> allowedWords) {
return FilterWord.empty();
}

String cleanedText = ProfanityText.clean(text);
int currentPos = 0;
for (Emit emit : trie.parseText(cleanedText)) {
int startPos = text.indexOf(emit.getKeyword().charAt(0), currentPos);
if (startPos == -1) continue;
int endPos = startPos;
for (char c : emit.getKeyword().toCharArray()) {
endPos = text.indexOf(c, endPos) + 1;
}
currentPos = endPos;
ProfanityText.Cleaned cleaned = ProfanityText.cleanWithOffsets(text);
for (Emit emit : trie.parseText(cleaned.text())) {
if (isAllowed(emit, allowedWords)) continue;
return FilterWord.create(text.substring(startPos, endPos), startPos, endPos);
return toFilterWord(text, cleaned, emit);
}
return FilterWord.empty();
}

/** 검출 구간을 원문 위치로 옮깁니다. 사이에 끼운 기호(`바-보`)는 원문 조각에 그대로 포함됩니다. */
private static FilterWord toFilterWord(String text, ProfanityText.Cleaned cleaned, Emit emit) {
int startPos = cleaned.originalStart(emit.getStart());
int endPos = cleaned.originalEnd(emit.getEnd());
return FilterWord.create(text.substring(startPos, endPos), startPos, endPos);
}

private static boolean isAllowed(Emit emit, Set<String> allowedWords) {
return allowedWords != null
&& !allowedWords.isEmpty()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package app.application.filter;

import java.util.Arrays;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
Expand All @@ -20,6 +22,46 @@ public static String clean(String text) {
return NOT_MATCHABLE.matcher(text).replaceAll("");
}

/**
* {@link #clean}과 같은 규칙으로 정리하면서 남은 글자마다 원문 위치를 함께 기록합니다.
*
* <p>매칭은 정리된 문자열에서 일어나지만 응답과 마스킹은 원문 위치를 써야 합니다. 원문에서 글자를 다시 찾으면 앞에 있는 같은 글자를 잡거나 대소문자가 달라 놓치므로,
* 정리하는 순간의 위치를 그대로 씁니다.
*/
static Cleaned cleanWithOffsets(String text) {
StringBuilder cleaned = new StringBuilder(text.length());
int[] offsets = new int[text.length()];
int size = 0;
int from = 0;
Matcher removed = NOT_MATCHABLE.matcher(text);
while (removed.find()) {
for (int i = from; i < removed.start(); i++) {
cleaned.append(text.charAt(i));
offsets[size++] = i;
}
from = removed.end();
}
for (int i = from; i < text.length(); i++) {
cleaned.append(text.charAt(i));
offsets[size++] = i;
}
return new Cleaned(cleaned.toString(), Arrays.copyOf(offsets, size));
}

/** 정리된 문자열과, 그 문자열의 각 글자가 원문에서 차지하던 위치입니다. */
record Cleaned(String text, int[] offsets) {

/** 정리된 문자열의 시작 위치를 원문 위치로 바꿉니다. */
int originalStart(int start) {
return offsets[start];
}

/** 정리된 문자열의 마지막 글자 위치(포함)를 원문의 끝 위치(배타)로 바꿉니다. Aho-Corasick의 Emit 끝 위치가 포함 구간입니다. */
int originalEnd(int endInclusive) {
return offsets[endInclusive] + 1;
}
}

/**
* 허용 단어와 사전 단어를 비교하는 키입니다. 필터가 대소문자를 구분하지 않으므로 소문자로 맞춥니다.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,15 @@ void allowsEnglishCaseInsensitively() {
assertThat(result.filterWords()).isEmpty();
}

@Test
@DisplayName("허용하지 않으면 대소문자가 달라도 원문 그대로 검출한다")
void detectsEnglishCaseInsensitivelyWhenNotAllowed() {
// 위 허용 테스트가 검출 실패 때문에 통과하지 않도록, 허용하지 않은 경우의 검출을 함께 확인한다.
FilterResponse result = filter.allMatched("you FOOL", Set.of());

assertThat(result.filterWords()).containsExactly(FilterWord.create("FOOL", 4, 8));
}

@Test
@DisplayName("사전에 없는 단어를 허용해도 결과가 바뀌지 않는다")
void ignoresAllowedWordsNotInDictionary() {
Expand Down Expand Up @@ -129,5 +138,13 @@ void returnsEmptyWhenEverythingIsAllowed() {

assertThat(result).isEqualTo(FilterWord.empty());
}

@Test
@DisplayName("허용 단어를 건너뛴 뒤의 영문 검출도 원문 대소문자와 위치를 지킨다")
void keepsCaseAndPositionAfterSkippingAllowedWord() {
FilterWord result = filter.firstMatched("죽여 you FOOL", allowed("죽여"));

assertThat(result).isEqualTo(FilterWord.create("FOOL", 7, 11));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,17 @@ void test_3() {
assertTrue(detected.filteredWord().equals("욕설") && detected.length() == 2);
assertEquals("**이 들어간 문장입니다.", filtered);
}

@Test
@DisplayName("filter 타입은 앞에 같은 글자로 시작하는 단어까지 마스킹하지 않는다")
void sanitizeProfanity_sameLeadingCharBefore_masksOnlyMatchedRange() {
// given
String word = "욕심 많은 욕설";

// when
FilterApiResponse response = profanityHandler.sanitizeProfanity(word, UUID.randomUUID());

// then
assertEquals("욕심 많은 **", response.filtered());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@
import app.dto.response.FilterWord;
import java.time.Instant;
import java.util.List;
import java.util.Set;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
Expand Down Expand Up @@ -176,4 +178,72 @@ void handleSpecialCharacters(String text) {
assertFalse(response.filterWords().isEmpty());
log.info("Found words: {}", response.filterWords());
}

@Nested
@DisplayName("검출 위치는 원문 기준으로 계산한다")
class OriginalOffsets {

@Test
@DisplayName("앞에 같은 글자로 시작하는 단어가 있어도 검출된 구간만 돌려준다")
void allMatched_sameLeadingCharBefore_returnsOnlyMatchedRange() {
// given
String text = "욕심 많은 욕설";

// when
FilterResponse result = normalProfanityFilter.allMatched(text);

// then
assertEquals(Set.of(FilterWord.create("욕설", 6, 8)), result.filterWords());
}

@Test
@DisplayName("첫 검출도 앞에 같은 글자로 시작하는 단어를 끌어오지 않는다")
void firstMatched_sameLeadingCharBefore_returnsOnlyMatchedRange() {
// when
FilterWord result = normalProfanityFilter.firstMatched("욕심 많은 욕설");

// then
assertEquals(FilterWord.create("욕설", 6, 8), result);
}

@Test
@DisplayName("사이에 끼운 기호는 원문 조각에 그대로 포함한다")
void allMatched_symbolsInside_keepsOriginalFragment() {
// when
FilterResponse result = normalProfanityFilter.allMatched("아 진짜 욕!@#설");

// then
assertEquals(Set.of(FilterWord.create("욕!@#설", 5, 10)), result.filterWords());
}

@Test
@DisplayName("같은 단어가 반복되면 각 위치를 따로 돌려준다")
void allMatched_repeatedWord_returnsEachRange() {
// when
FilterResponse result = normalProfanityFilter.allMatched("욕설 욕설");

// then
assertEquals(
Set.of(FilterWord.create("욕설", 0, 2), FilterWord.create("욕설", 3, 5)),
result.filterWords());
}

@ParameterizedTest
@DisplayName("영문은 대소문자가 달라도 원문 그대로 검출한다")
@ValueSource(strings = {"fool", "FOOL", "Fool", "fOoL"})
void matched_englishInAnyCase_returnsOriginalFragment(String word) {
// given
repository.save(ProfanityWord.create("fool"));
normalProfanityFilter.synchronizeProfanityTrie();
String text = "you " + word;

// when
FilterResponse all = normalProfanityFilter.allMatched(text);
FilterWord first = normalProfanityFilter.firstMatched(text);

// then
assertEquals(Set.of(FilterWord.create(word, 4, 8)), all.filterWords());
assertEquals(FilterWord.create(word, 4, 8), first);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package app.application.filter;

import static org.junit.jupiter.api.Assertions.assertEquals;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;

class ProfanityTextTest {

@Nested
@DisplayName("위치를 기록하는 정리는")
class CleanWithOffsets {

@ParameterizedTest
@DisplayName("clean과 같은 규칙으로 정리한다")
@ValueSource(strings = {"", " ", "바-보", "욕12#설", "😀욕설😀", "Fo!oL", "ㅅ ㅂ", "탭\t줄\n바꿈"})
void cleanWithOffsets_anyText_matchesClean(String text) {
// when
ProfanityText.Cleaned cleaned = ProfanityText.cleanWithOffsets(text);

// then
assertEquals(ProfanityText.clean(text), cleaned.text());
}

@ParameterizedTest
@DisplayName("정리된 글자마다 원문의 같은 글자 위치를 가리킨다")
@ValueSource(strings = {"바-보", "욕12#설", "😀욕설😀", "Fo!oL"})
void cleanWithOffsets_anyText_offsetsPointToSameChar(String text) {
// when
ProfanityText.Cleaned cleaned = ProfanityText.cleanWithOffsets(text);

// then
for (int i = 0; i < cleaned.text().length(); i++) {
assertEquals(cleaned.text().charAt(i), text.charAt(cleaned.offsets()[i]));
}
}

@Test
@DisplayName("정리된 구간을 사이에 끼운 기호까지 포함한 원문 구간으로 바꾼다")
void originalRange_symbolsInside_coversOriginalFragment() {
// given
String text = "이런 바-보야";
ProfanityText.Cleaned cleaned = ProfanityText.cleanWithOffsets(text);
int start = cleaned.text().indexOf("바보");

// when
int originalStart = cleaned.originalStart(start);
int originalEnd = cleaned.originalEnd(start + 1);

// then
assertEquals("바-보", text.substring(originalStart, originalEnd));
}
}
}
Loading