diff --git a/src/main/java/com/debatetimer/service/transcript/TranscriptRefiner.java b/src/main/java/com/debatetimer/service/transcript/TranscriptRefiner.java index a0624b06..67f4b905 100644 --- a/src/main/java/com/debatetimer/service/transcript/TranscriptRefiner.java +++ b/src/main/java/com/debatetimer/service/transcript/TranscriptRefiner.java @@ -16,6 +16,7 @@ import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Map; +import java.util.regex.Pattern; import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; import org.springframework.core.io.ClassPathResource; @@ -30,6 +31,8 @@ public class TranscriptRefiner { private static final String SCHEMA_NAME = "transcript_refinement"; + private static final Pattern ECHOED_PREFIX = Pattern.compile( + "^\\s*(\\[\\d+]\\s*)?[^|\\n]{1,30}?\\s*\\|\\s*(찬성|반대|중립)[^:\\n]{0,30}:\\s*"); private static final TypeReference> SCHEMA_TYPE = new TypeReference<>() { }; @@ -65,17 +68,20 @@ public void refine(long transcriptId) { } } - private String buildUserPrompt(Transcript transcript) { + // 문장(text)과 맥락(발언 유형·진영·화자)을 필드로 나눠 보내, AI가 맥락을 문장에 붙여 돌려주지 않게 한다. + private String buildUserPrompt(Transcript transcript) throws JsonProcessingException { TranscriptTableSnapshot table = transcript.table(); - String lines = transcript.lines().stream() - .map(this::formatLine) - .collect(Collectors.joining("\n")); + List payload = transcript.lines().stream() + .map(line -> new LinePayload(line.index(), line.speechType(), stanceLabel(line.stance()), + line.speaker(), line.text())) + .toList(); + String lines = objectMapper.writeValueAsString(payload); return """ 토론 주제: %s 찬성 팀: %s 반대 팀: %s - 속기록: + 속기록(JSON 배열): %s """.formatted(orEmpty(table.agenda()), orEmpty(table.prosTeamName()), orEmpty(table.consTeamName()), lines); @@ -86,12 +92,6 @@ private String orEmpty(String value) { return value == null ? "" : value; } - private String formatLine(TranscriptLine line) { - String speaker = line.speaker() == null || line.speaker().isBlank() ? "" : " " + line.speaker(); - return "[%d] %s | %s%s: %s".formatted(line.index(), line.speechType(), stanceLabel(line.stance()), speaker, - line.text()); - } - private String stanceLabel(Stance stance) { return switch (stance) { case PROS -> "찬성"; @@ -107,7 +107,16 @@ private Map readRefinedTexts(String response) throws JsonProces return Map.of(); } return refinement.lines().stream() - .collect(Collectors.toMap(RefinedLine::index, RefinedLine::text, (first, second) -> first)); + .collect(Collectors.toMap(RefinedLine::index, line -> stripEchoedPrefix(line.text()), + (first, second) -> first)); + } + + // AI가 "[2] 입론 | 반대 비토: "처럼 번호·발언 유형·진영·화자 머리말을 문장 앞에 붙여 돌려주면 지운다. + private String stripEchoedPrefix(String text) { + if (text == null) { + return null; + } + return ECHOED_PREFIX.matcher(text).replaceFirst(""); } private Map readSchema() { @@ -126,6 +135,9 @@ private String readResource(String path) { } } + record LinePayload(int index, String speechType, String team, String speaker, String text) { + } + record RefinementResponse(List lines) { } diff --git a/src/main/resources/prompt/transcript-refinement-system.txt b/src/main/resources/prompt/transcript-refinement-system.txt index ea705d0e..28b505ba 100644 --- a/src/main/resources/prompt/transcript-refinement-system.txt +++ b/src/main/resources/prompt/transcript-refinement-system.txt @@ -1,11 +1,12 @@ -너는 토론 속기사다. 입력으로 토론 주제, 양 팀 이름, 그리고 음성 인식(STT)으로 받아 적은 속기록이 주어진다. 각 줄 앞에는 [번호]와 발언 팀·화자가 붙어 있다. +너는 토론 속기사다. 입력으로 토론 주제, 양 팀 이름, 그리고 음성 인식(STT)으로 받아 적은 속기록이 JSON 배열로 주어진다. 각 원소의 index는 줄 번호, speechType·team·speaker는 그 줄의 발언 유형·진영·화자(맥락 정보), text는 받아 적은 문장이다. -할 일: 각 줄의 문장을 읽기 좋은 속기록 문장으로 다듬는다. +할 일: 각 원소의 text만 읽기 좋은 속기록 문장으로 다듬는다. 규칙: 1. 음성 인식 오류를 고친다. 토론 주제, 팀 이름, 화자 이름, 앞뒤 문맥으로 볼 때 명백히 잘못 받아 적은 단어만 고친다. 예: "찬송추" → "찬성 측", "채찍 pt" → "ChatGPT", 소문자 "ai" → "AI". 2. 문장부호와 띄어쓰기를 바로잡는다. 3. "어", "음", "그" 같은 군말과 의미 없는 반복은 지운다. 줄 전체가 군말이면 text를 빈 문자열로 둔다. 4. 말하지 않은 내용을 덧붙이거나, 요약하거나, 주장·근거·어조(존댓말 등)를 바꾸지 않는다. -5. 줄을 합치거나 나누지 않는다. 입력의 모든 [번호]에 대해 같은 index로 한 번씩만 돌려준다. +5. 줄을 합치거나 나누지 않는다. 입력의 모든 원소에 대해 같은 index로 한 번씩만 돌려준다. 6. 고쳐야 할지 확실하지 않으면 원문을 그대로 둔다. +7. 돌려주는 text에는 다듬은 문장만 넣는다. 번호, 발언 유형, 진영, 화자 이름을 text 앞에 붙이지 않는다. 예: "입론 | 찬성: 안녕하십니까"가 아니라 "안녕하십니까". diff --git a/src/test/java/com/debatetimer/service/transcript/TranscriptRefinerTest.java b/src/test/java/com/debatetimer/service/transcript/TranscriptRefinerTest.java index aa84aa35..96d88fb2 100644 --- a/src/test/java/com/debatetimer/service/transcript/TranscriptRefinerTest.java +++ b/src/test/java/com/debatetimer/service/transcript/TranscriptRefinerTest.java @@ -5,7 +5,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyMap; import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -73,15 +73,36 @@ private Transcript savedTranscript() { } @Test - void 주제와_번호_화자를_붙여_원문을_전달한다() { + void 문장과_발언_맥락을_나눈_JSON으로_원문을_전달한다() { Transcript transcript = savedTranscript(); when(openAiClient.requestStructuredOutput(anyString(), anyString(), anyString(), anyMap())) .thenReturn("{\"lines\":[]}"); transcriptRefiner.refine(transcript.id()); - verify(openAiClient).requestStructuredOutput(anyString(), - contains("[2] 입론 | 반대 비토: 채찍 pt는 위험합니다"), anyString(), any()); + verify(openAiClient).requestStructuredOutput(anyString(), argThat(prompt -> prompt.contains( + "{\"index\":2,\"speechType\":\"입론\",\"team\":\"반대\",\"speaker\":\"비토\",\"text\":\"채찍 pt는 위험합니다\"}") + && !prompt.contains("입론 | 반대")), + anyString(), any()); + } + + @Test + void AI가_발언_유형과_진영_머리말을_붙여_돌려줘도_문장만_저장한다() { + Transcript transcript = savedTranscript(); + when(openAiClient.requestStructuredOutput(anyString(), anyString(), anyString(), anyMap())) + .thenReturn(""" + {"lines":[ + {"index":0,"text":"입론 | 찬성 콜리: 안녕하십니까, 찬성 측 콜리입니다."}, + {"index":1,"text":"입론 | 찬성: 어..."}, + {"index":2,"text":"[2] 입론 | 반대 비토: ChatGPT는 위험합니다."} + ]} + """); + + transcriptRefiner.refine(transcript.id()); + + Transcript refined = transcriptDomainRepository.getById(transcript.id()); + assertThat(refined.lines()).extracting(TranscriptLine::text) + .containsExactly("안녕하십니까, 찬성 측 콜리입니다.", "어...", "ChatGPT는 위험합니다."); } @Test