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 @@ -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;
Expand All @@ -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<Map<String, Object>> SCHEMA_TYPE = new TypeReference<>() {
};

Expand Down Expand Up @@ -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<LinePayload> 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);
Expand All @@ -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 -> "찬성";
Expand All @@ -107,7 +107,16 @@ private Map<Integer, String> 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<String, Object> readSchema() {
Expand All @@ -126,6 +135,9 @@ private String readResource(String path) {
}
}

record LinePayload(int index, String speechType, String team, String speaker, String text) {
}

record RefinementResponse(List<RefinedLine> lines) {
}

Expand Down
7 changes: 4 additions & 3 deletions src/main/resources/prompt/transcript-refinement-system.txt
Original file line number Diff line number Diff line change
@@ -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 앞에 붙이지 않는다. 예: "입론 | 찬성: 안녕하십니까"가 아니라 "안녕하십니까".
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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
Expand Down
Loading