diff --git a/src/main/java/com/debatetimer/domain/transcript/Transcript.java b/src/main/java/com/debatetimer/domain/transcript/Transcript.java index b74f0b82..e91b5748 100644 --- a/src/main/java/com/debatetimer/domain/transcript/Transcript.java +++ b/src/main/java/com/debatetimer/domain/transcript/Transcript.java @@ -7,6 +7,7 @@ public record Transcript( Long id, long tableId, long memberId, + TranscriptTableSnapshot table, TranscriptStatus status, SttMode sttMode, // AI가 STT 원문을 다듬는 중이면 true. 다듬기가 끝나거나 실패하면 false가 된다. diff --git a/src/main/java/com/debatetimer/domain/transcript/TranscriptSummary.java b/src/main/java/com/debatetimer/domain/transcript/TranscriptSummary.java index c1fa9b3f..2b2ae854 100644 --- a/src/main/java/com/debatetimer/domain/transcript/TranscriptSummary.java +++ b/src/main/java/com/debatetimer/domain/transcript/TranscriptSummary.java @@ -1,6 +1,7 @@ package com.debatetimer.domain.transcript; import java.time.LocalDateTime; +import org.springframework.lang.Nullable; /** * 토론 기록 목록에 보여 줄 요약. 속기록·보고서 본문은 담지 않는다. @@ -8,6 +9,8 @@ public record TranscriptSummary( long id, long tableId, + @Nullable String tableName, + @Nullable String agenda, SttMode sttMode, TranscriptStatus status, boolean refining, diff --git a/src/main/java/com/debatetimer/domain/transcript/TranscriptTableSnapshot.java b/src/main/java/com/debatetimer/domain/transcript/TranscriptTableSnapshot.java new file mode 100644 index 00000000..dcdbee28 --- /dev/null +++ b/src/main/java/com/debatetimer/domain/transcript/TranscriptTableSnapshot.java @@ -0,0 +1,21 @@ +package com.debatetimer.domain.transcript; + +import com.debatetimer.domain.customize.CustomizeTable; +import org.springframework.lang.Nullable; + +/** + * 속기록을 만들 때의 시간표 이름·주제·팀 이름. 이후 시간표를 수정하거나 지워도 바뀌지 않는다. + * 스냅샷 도입 전에 지워진 시간표의 기록은 값이 비어 있을 수 있다. + */ +public record TranscriptTableSnapshot( + @Nullable String tableName, + @Nullable String agenda, + @Nullable String prosTeamName, + @Nullable String consTeamName +) { + + public static TranscriptTableSnapshot from(CustomizeTable table) { + return new TranscriptTableSnapshot(table.getName(), table.getAgenda(), table.getProsTeamName(), + table.getConsTeamName()); + } +} diff --git a/src/main/java/com/debatetimer/domainrepository/transcript/TranscriptDomainRepository.java b/src/main/java/com/debatetimer/domainrepository/transcript/TranscriptDomainRepository.java index 15f29ae1..31488350 100644 --- a/src/main/java/com/debatetimer/domainrepository/transcript/TranscriptDomainRepository.java +++ b/src/main/java/com/debatetimer/domainrepository/transcript/TranscriptDomainRepository.java @@ -4,6 +4,7 @@ import com.debatetimer.domain.transcript.Transcript; import com.debatetimer.domain.transcript.TranscriptLine; import com.debatetimer.domain.transcript.TranscriptSummary; +import com.debatetimer.domain.transcript.TranscriptTableSnapshot; import com.debatetimer.entity.transcript.TranscriptEntity; import com.debatetimer.repository.transcript.TranscriptRepository; import com.fasterxml.jackson.core.JsonProcessingException; @@ -25,8 +26,9 @@ public class TranscriptDomainRepository { private final ObjectMapper objectMapper; @Transactional - public Transcript create(long tableId, long memberId, SttMode sttMode, List lines) { - TranscriptEntity entity = new TranscriptEntity(tableId, memberId, sttMode, writeLines(lines)); + public Transcript create(long tableId, long memberId, TranscriptTableSnapshot table, SttMode sttMode, + List lines) { + TranscriptEntity entity = new TranscriptEntity(tableId, memberId, table, sttMode, writeLines(lines)); return toDomain(transcriptRepository.save(entity)); } @@ -66,7 +68,8 @@ public void fail(long id, String errorMessage) { } private Transcript toDomain(TranscriptEntity entity) { - return new Transcript(entity.getId(), entity.getTableId(), entity.getMemberId(), entity.getStatus(), + return new Transcript(entity.getId(), entity.getTableId(), entity.getMemberId(), + entity.getTableSnapshot(), entity.getStatus(), entity.getSttMode(), entity.isRefining(), readLines(entity.getLinesJson()), readRawLines(entity.getRawLinesJson()), entity.getReportJson(), entity.getErrorMessage()); diff --git a/src/main/java/com/debatetimer/dto/transcript/response/TranscriptResponse.java b/src/main/java/com/debatetimer/dto/transcript/response/TranscriptResponse.java index 95faf0bc..33cf35b2 100644 --- a/src/main/java/com/debatetimer/dto/transcript/response/TranscriptResponse.java +++ b/src/main/java/com/debatetimer/dto/transcript/response/TranscriptResponse.java @@ -1,6 +1,5 @@ package com.debatetimer.dto.transcript.response; -import com.debatetimer.domain.customize.CustomizeTable; import com.debatetimer.domain.transcript.SttMode; import com.debatetimer.domain.transcript.Transcript; import com.debatetimer.domain.transcript.TranscriptLine; @@ -24,9 +23,10 @@ public record TranscriptResponse( @Nullable String errorMessage ) { - public TranscriptResponse(Transcript transcript, CustomizeTable table, @Nullable JsonNode report) { - this(transcript.id(), transcript.tableId(), table.getName(), table.getAgenda(), table.getProsTeamName(), - table.getConsTeamName(), transcript.status(), transcript.sttMode(), transcript.refining(), + // 시간표 정보는 속기록을 만들 때 저장한 스냅샷을 쓴다. + public TranscriptResponse(Transcript transcript, @Nullable JsonNode report) { + this(transcript.id(), transcript.tableId(), transcript.table().tableName(), transcript.table().agenda(), + transcript.table().prosTeamName(), transcript.table().consTeamName(), transcript.status(), transcript.sttMode(), transcript.refining(), transcript.lines(), report, transcript.errorMessage()); } } diff --git a/src/main/java/com/debatetimer/dto/transcript/response/TranscriptSummaryResponse.java b/src/main/java/com/debatetimer/dto/transcript/response/TranscriptSummaryResponse.java index 96e47401..1e0413c2 100644 --- a/src/main/java/com/debatetimer/dto/transcript/response/TranscriptSummaryResponse.java +++ b/src/main/java/com/debatetimer/dto/transcript/response/TranscriptSummaryResponse.java @@ -1,6 +1,5 @@ package com.debatetimer.dto.transcript.response; -import com.debatetimer.domain.customize.CustomizeTable; import com.debatetimer.domain.transcript.SttMode; import com.debatetimer.domain.transcript.TranscriptStatus; import com.debatetimer.domain.transcript.TranscriptSummary; @@ -8,7 +7,7 @@ import org.springframework.lang.Nullable; /** - * 토론 기록 목록의 한 항목. 시간표가 삭제됐으면 시간표 이름과 주제는 null이다. + * 토론 기록 목록의 한 항목. 시간표 이름과 주제는 속기록을 만들 때의 값이다. 스냅샷 도입 전에 지워진 시간표의 기록은 null이다. */ public record TranscriptSummaryResponse( long id, @@ -21,9 +20,8 @@ public record TranscriptSummaryResponse( LocalDateTime createdAt ) { - public TranscriptSummaryResponse(TranscriptSummary summary, @Nullable CustomizeTable table) { - this(summary.id(), summary.tableId(), table == null ? null : table.getName(), - table == null ? null : table.getAgenda(), summary.sttMode(), summary.status(), summary.refining(), - summary.createdAt()); + public TranscriptSummaryResponse(TranscriptSummary summary) { + this(summary.id(), summary.tableId(), summary.tableName(), summary.agenda(), summary.sttMode(), + summary.status(), summary.refining(), summary.createdAt()); } } diff --git a/src/main/java/com/debatetimer/entity/transcript/TranscriptEntity.java b/src/main/java/com/debatetimer/entity/transcript/TranscriptEntity.java index 2c3c258e..b6a75dd5 100644 --- a/src/main/java/com/debatetimer/entity/transcript/TranscriptEntity.java +++ b/src/main/java/com/debatetimer/entity/transcript/TranscriptEntity.java @@ -2,6 +2,7 @@ import com.debatetimer.domain.transcript.SttMode; import com.debatetimer.domain.transcript.TranscriptStatus; +import com.debatetimer.domain.transcript.TranscriptTableSnapshot; import com.debatetimer.entity.BaseTimeEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -34,6 +35,19 @@ public class TranscriptEntity extends BaseTimeEntity { @Column(name = "member_id") private long memberId; + // 속기록을 만들 때의 시간표 정보. 이후 시간표를 수정·삭제해도 바꾸지 않는다. + @Column(name = "table_name") + private String tableName; + + @Column(name = "agenda") + private String agenda; + + @Column(name = "pros_team_name") + private String prosTeamName; + + @Column(name = "cons_team_name") + private String consTeamName; + @NotNull @Enumerated(EnumType.STRING) private TranscriptStatus status; @@ -59,9 +73,14 @@ public class TranscriptEntity extends BaseTimeEntity { @Column(name = "error_message", length = ERROR_MESSAGE_MAX_LENGTH) private String errorMessage; - public TranscriptEntity(long tableId, long memberId, SttMode sttMode, String linesJson) { + public TranscriptEntity(long tableId, long memberId, TranscriptTableSnapshot table, SttMode sttMode, + String linesJson) { this.tableId = tableId; this.memberId = memberId; + this.tableName = table.tableName(); + this.agenda = table.agenda(); + this.prosTeamName = table.prosTeamName(); + this.consTeamName = table.consTeamName(); this.sttMode = sttMode; this.linesJson = linesJson; this.rawLinesJson = linesJson; @@ -69,6 +88,10 @@ public TranscriptEntity(long tableId, long memberId, SttMode sttMode, String lin this.status = TranscriptStatus.PROCESSING; } + public TranscriptTableSnapshot getTableSnapshot() { + return new TranscriptTableSnapshot(tableName, agenda, prosTeamName, consTeamName); + } + public void refine(String refinedLinesJson) { this.linesJson = refinedLinesJson; this.refining = false; diff --git a/src/main/java/com/debatetimer/repository/transcript/TranscriptRepository.java b/src/main/java/com/debatetimer/repository/transcript/TranscriptRepository.java index 32ecac9b..20d99e94 100644 --- a/src/main/java/com/debatetimer/repository/transcript/TranscriptRepository.java +++ b/src/main/java/com/debatetimer/repository/transcript/TranscriptRepository.java @@ -20,7 +20,7 @@ public interface TranscriptRepository extends Repository // 속기록·보고서 본문(LONGTEXT)은 읽지 않고 목록에 필요한 값만 가져온다. @Query(""" SELECT new com.debatetimer.domain.transcript.TranscriptSummary( - t.id, t.tableId, t.sttMode, t.status, t.refining, t.createdAt) + t.id, t.tableId, t.tableName, t.agenda, t.sttMode, t.status, t.refining, t.createdAt) FROM TranscriptEntity t WHERE t.memberId = :memberId ORDER BY t.createdAt DESC, t.id DESC diff --git a/src/main/java/com/debatetimer/service/transcript/DebateAnalyzer.java b/src/main/java/com/debatetimer/service/transcript/DebateAnalyzer.java index e4a7a47f..fad6980e 100644 --- a/src/main/java/com/debatetimer/service/transcript/DebateAnalyzer.java +++ b/src/main/java/com/debatetimer/service/transcript/DebateAnalyzer.java @@ -1,11 +1,10 @@ package com.debatetimer.service.transcript; import com.debatetimer.client.llm.OpenAiClient; -import com.debatetimer.domain.customize.CustomizeTable; import com.debatetimer.domain.customize.Stance; import com.debatetimer.domain.transcript.Transcript; import com.debatetimer.domain.transcript.TranscriptLine; -import com.debatetimer.domainrepository.customize.CustomizeTableDomainRepository; +import com.debatetimer.domain.transcript.TranscriptTableSnapshot; import com.debatetimer.domainrepository.transcript.TranscriptDomainRepository; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; @@ -32,19 +31,16 @@ public class DebateAnalyzer { private final OpenAiClient openAiClient; private final TranscriptDomainRepository transcriptDomainRepository; - private final CustomizeTableDomainRepository customizeTableDomainRepository; private final String systemPrompt; private final Map schema; public DebateAnalyzer( OpenAiClient openAiClient, TranscriptDomainRepository transcriptDomainRepository, - CustomizeTableDomainRepository customizeTableDomainRepository, ObjectMapper objectMapper ) { this.openAiClient = openAiClient; this.transcriptDomainRepository = transcriptDomainRepository; - this.customizeTableDomainRepository = customizeTableDomainRepository; this.systemPrompt = readResource("prompt/debate-analysis-system.txt"); this.schema = readSchema(objectMapper); } @@ -52,8 +48,7 @@ public DebateAnalyzer( public void analyze(long transcriptId) { try { Transcript transcript = transcriptDomainRepository.getById(transcriptId); - CustomizeTable table = customizeTableDomainRepository.getById(transcript.tableId()); - String report = openAiClient.requestStructuredOutput(systemPrompt, buildUserPrompt(table, transcript), + String report = openAiClient.requestStructuredOutput(systemPrompt, buildUserPrompt(transcript), SCHEMA_NAME, schema); transcriptDomainRepository.complete(transcriptId, report); } catch (RuntimeException exception) { @@ -62,7 +57,8 @@ public void analyze(long transcriptId) { } } - private String buildUserPrompt(CustomizeTable table, Transcript transcript) { + private String buildUserPrompt(Transcript transcript) { + TranscriptTableSnapshot table = transcript.table(); String lines = transcript.lines().stream() .map(this::formatLine) .collect(Collectors.joining("\n")); @@ -73,7 +69,13 @@ private String buildUserPrompt(CustomizeTable table, Transcript transcript) { 속기록: %s - """.formatted(table.getAgenda(), table.getProsTeamName(), table.getConsTeamName(), lines); + """.formatted(orEmpty(table.agenda()), orEmpty(table.prosTeamName()), orEmpty(table.consTeamName()), + lines); + } + + // 속기록을 만들 때 저장한 시간표 정보를 쓰고, 비어 있으면 빈 값으로 둔다. + private String orEmpty(String value) { + return value == null ? "" : value; } private String formatLine(TranscriptLine line) { diff --git a/src/main/java/com/debatetimer/service/transcript/TranscriptRefiner.java b/src/main/java/com/debatetimer/service/transcript/TranscriptRefiner.java index 13ea0a86..67f4b905 100644 --- a/src/main/java/com/debatetimer/service/transcript/TranscriptRefiner.java +++ b/src/main/java/com/debatetimer/service/transcript/TranscriptRefiner.java @@ -1,12 +1,11 @@ package com.debatetimer.service.transcript; import com.debatetimer.client.llm.OpenAiClient; -import com.debatetimer.domain.customize.CustomizeTable; import com.debatetimer.domain.customize.Stance; import com.debatetimer.domain.transcript.RefinedTextMerger; import com.debatetimer.domain.transcript.Transcript; import com.debatetimer.domain.transcript.TranscriptLine; -import com.debatetimer.domainrepository.customize.CustomizeTableDomainRepository; +import com.debatetimer.domain.transcript.TranscriptTableSnapshot; import com.debatetimer.domainrepository.transcript.TranscriptDomainRepository; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; @@ -17,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; @@ -31,12 +31,13 @@ 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<>() { }; private final OpenAiClient openAiClient; private final TranscriptDomainRepository transcriptDomainRepository; - private final CustomizeTableDomainRepository customizeTableDomainRepository; private final ObjectMapper objectMapper; private final RefinedTextMerger refinedTextMerger = new RefinedTextMerger(); private final String systemPrompt; @@ -45,12 +46,10 @@ public class TranscriptRefiner { public TranscriptRefiner( OpenAiClient openAiClient, TranscriptDomainRepository transcriptDomainRepository, - CustomizeTableDomainRepository customizeTableDomainRepository, ObjectMapper objectMapper ) { this.openAiClient = openAiClient; this.transcriptDomainRepository = transcriptDomainRepository; - this.customizeTableDomainRepository = customizeTableDomainRepository; this.objectMapper = objectMapper; this.systemPrompt = readResource("prompt/transcript-refinement-system.txt"); this.schema = readSchema(); @@ -59,8 +58,7 @@ public TranscriptRefiner( public void refine(long transcriptId) { try { Transcript transcript = transcriptDomainRepository.getById(transcriptId); - CustomizeTable table = customizeTableDomainRepository.getById(transcript.tableId()); - String response = openAiClient.requestStructuredOutput(systemPrompt, buildUserPrompt(table, transcript), + String response = openAiClient.requestStructuredOutput(systemPrompt, buildUserPrompt(transcript), SCHEMA_NAME, schema); List refined = refinedTextMerger.merge(transcript.lines(), readRefinedTexts(response)); transcriptDomainRepository.refine(transcriptId, refined); @@ -70,24 +68,28 @@ public void refine(long transcriptId) { } } - private String buildUserPrompt(CustomizeTable table, Transcript transcript) { - String lines = transcript.lines().stream() - .map(this::formatLine) - .collect(Collectors.joining("\n")); + // 문장(text)과 맥락(발언 유형·진영·화자)을 필드로 나눠 보내, AI가 맥락을 문장에 붙여 돌려주지 않게 한다. + private String buildUserPrompt(Transcript transcript) throws JsonProcessingException { + TranscriptTableSnapshot table = transcript.table(); + 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(table.getAgenda(), table.getProsTeamName(), table.getConsTeamName(), lines); + """.formatted(orEmpty(table.agenda()), orEmpty(table.prosTeamName()), orEmpty(table.consTeamName()), + lines); } - 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 orEmpty(String value) { + return value == null ? "" : value; } private String stanceLabel(Stance stance) { @@ -105,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() { @@ -124,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/java/com/debatetimer/service/transcript/TranscriptService.java b/src/main/java/com/debatetimer/service/transcript/TranscriptService.java index ef396629..aea5acda 100644 --- a/src/main/java/com/debatetimer/service/transcript/TranscriptService.java +++ b/src/main/java/com/debatetimer/service/transcript/TranscriptService.java @@ -6,6 +6,7 @@ import com.debatetimer.domain.transcript.SpeakerSeparator; import com.debatetimer.domain.transcript.Transcript; import com.debatetimer.domain.transcript.TranscriptLine; +import com.debatetimer.domain.transcript.TranscriptTableSnapshot; import com.debatetimer.domainrepository.customize.CustomizeTableDomainRepository; import com.debatetimer.domainrepository.transcript.TranscriptDomainRepository; import com.debatetimer.dto.transcript.request.TranscriptCreateRequest; @@ -13,11 +14,6 @@ import com.debatetimer.dto.transcript.response.TranscriptResponse; import com.debatetimer.dto.transcript.response.TranscriptSummaryResponse; import com.debatetimer.dto.transcript.response.TranscriptSummaryResponses; -import com.debatetimer.entity.customize.CustomizeTableEntity; -import com.debatetimer.repository.customize.CustomizeTableRepository; -import java.util.Map; -import java.util.function.Function; -import java.util.stream.Collectors; import com.debatetimer.event.transcript.TranscriptCreatedEvent; import com.debatetimer.exception.custom.DTClientErrorException; import com.debatetimer.exception.errorcode.ClientErrorCode; @@ -38,7 +34,6 @@ public class TranscriptService { private final CustomizeTableDomainRepository customizeTableDomainRepository; private final TranscriptDomainRepository transcriptDomainRepository; - private final CustomizeTableRepository customizeTableRepository; private final ApplicationEventPublisher eventPublisher; private final ObjectMapper objectMapper; private final SpeakerSeparator speakerSeparator = new SpeakerSeparator(); @@ -54,7 +49,7 @@ public TranscriptCreateResponse create(long tableId, Member member, TranscriptCr } Transcript transcript = transcriptDomainRepository.create(table.getId(), member.getId(), - request.resolveSttMode(), lines); + TranscriptTableSnapshot.from(table), request.resolveSttMode(), lines); eventPublisher.publishEvent(new TranscriptCreatedEvent(transcript.id())); return new TranscriptCreateResponse(transcript.id()); } @@ -62,18 +57,14 @@ public TranscriptCreateResponse create(long tableId, Member member, TranscriptCr @Transactional(readOnly = true) public TranscriptResponse get(long transcriptId, Member member) { Transcript transcript = transcriptDomainRepository.getByIdAndMemberId(transcriptId, member.getId()); - CustomizeTable table = customizeTableDomainRepository.getById(transcript.tableId()); - return new TranscriptResponse(transcript, table, readReport(transcript)); + return new TranscriptResponse(transcript, readReport(transcript)); } @Transactional(readOnly = true) public TranscriptSummaryResponses findAll(Member member) { - Map tables = customizeTableRepository.findAllByMember(member).stream() - .map(CustomizeTableEntity::toDomain) - .collect(Collectors.toMap(CustomizeTable::getId, Function.identity())); List transcripts = transcriptDomainRepository.findSummariesByMemberId(member.getId()) .stream() - .map(summary -> new TranscriptSummaryResponse(summary, tables.get(summary.tableId()))) + .map(TranscriptSummaryResponse::new) .toList(); return new TranscriptSummaryResponses(transcripts); } diff --git a/src/main/resources/db/migration/V21__add_table_snapshot_to_transcript.sql b/src/main/resources/db/migration/V21__add_table_snapshot_to_transcript.sql new file mode 100644 index 00000000..02b86d46 --- /dev/null +++ b/src/main/resources/db/migration/V21__add_table_snapshot_to_transcript.sql @@ -0,0 +1,18 @@ +ALTER TABLE transcript + ADD COLUMN table_name VARCHAR(255) NULL; + +ALTER TABLE transcript + ADD COLUMN agenda VARCHAR(255) NULL; + +ALTER TABLE transcript + ADD COLUMN pros_team_name VARCHAR(255) NULL; + +ALTER TABLE transcript + ADD COLUMN cons_team_name VARCHAR(255) NULL; + +-- 기존 속기록은 지금 시간표 값으로 채운다. 이미 지워진 시간표의 기록은 비워 둔다. +UPDATE transcript +SET table_name = (SELECT c.name FROM customize_table c WHERE c.id = transcript.table_id), + agenda = (SELECT c.agenda FROM customize_table c WHERE c.id = transcript.table_id), + pros_team_name = (SELECT c.pros_team_name FROM customize_table c WHERE c.id = transcript.table_id), + cons_team_name = (SELECT c.cons_team_name FROM customize_table c WHERE c.id = transcript.table_id); 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/domainrepository/transcript/TranscriptDomainRepositoryTest.java b/src/test/java/com/debatetimer/domainrepository/transcript/TranscriptDomainRepositoryTest.java index 1ae40443..90e832a3 100644 --- a/src/test/java/com/debatetimer/domainrepository/transcript/TranscriptDomainRepositoryTest.java +++ b/src/test/java/com/debatetimer/domainrepository/transcript/TranscriptDomainRepositoryTest.java @@ -9,6 +9,7 @@ import com.debatetimer.domain.transcript.Transcript; import com.debatetimer.domain.transcript.TranscriptLine; import com.debatetimer.domain.transcript.TranscriptStatus; +import com.debatetimer.domain.transcript.TranscriptTableSnapshot; import com.debatetimer.domainrepository.BaseDomainRepositoryTest; import com.debatetimer.entity.transcript.TranscriptEntity; import com.debatetimer.exception.custom.DTClientErrorException; @@ -25,6 +26,8 @@ class TranscriptDomainRepositoryTest extends BaseDomainRepositoryTest { new TranscriptLine(1, 1, "자유토론", Stance.CONS, null, 65_000L, "반대 측 반박입니다.") ); + private static final TranscriptTableSnapshot SNAPSHOT = new TranscriptTableSnapshot("정기토론", "주제", "찬성", "반대"); + @Autowired private TranscriptDomainRepository transcriptDomainRepository; @@ -33,7 +36,7 @@ class Create { @Test void 속기록을_분석_중_상태로_저장한다() { - Transcript created = transcriptDomainRepository.create(1L, 2L, SttMode.RPC, LINES); + Transcript created = transcriptDomainRepository.create(1L, 2L, SNAPSHOT, SttMode.RPC, LINES); Transcript found = transcriptDomainRepository.getById(created.id()); assertAll( @@ -41,6 +44,7 @@ class Create { () -> assertThat(found.memberId()).isEqualTo(2L), () -> assertThat(found.status()).isEqualTo(TranscriptStatus.PROCESSING), () -> assertThat(found.sttMode()).isEqualTo(SttMode.RPC), + () -> assertThat(found.table()).isEqualTo(SNAPSHOT), () -> assertThat(found.refining()).isTrue(), () -> assertThat(found.rawLines()).isEqualTo(LINES), () -> assertThat(found.lines()).isEqualTo(LINES), @@ -54,7 +58,7 @@ class GetByIdAndMemberId { @Test void 다른_회원의_속기록이면_예외를_던진다() { - Transcript created = transcriptDomainRepository.create(1L, 2L, SttMode.RPC, LINES); + Transcript created = transcriptDomainRepository.create(1L, 2L, SNAPSHOT, SttMode.RPC, LINES); assertThatThrownBy(() -> transcriptDomainRepository.getByIdAndMemberId(created.id(), 3L)) .isInstanceOf(DTClientErrorException.class) @@ -67,7 +71,7 @@ class Refine { @Test void 다듬은_속기록을_저장하고_원문은_보관한다() { - Transcript created = transcriptDomainRepository.create(1L, 2L, SttMode.RPC, LINES); + Transcript created = transcriptDomainRepository.create(1L, 2L, SNAPSHOT, SttMode.RPC, LINES); List refined = List.of( new TranscriptLine(0, 0, "입론", Stance.PROS, "1번", 1_000L, "찬성 입론입니다.")); @@ -84,7 +88,7 @@ class Refine { @Test void 다듬기를_건너뛰면_원문을_속기록으로_쓴다() { - Transcript created = transcriptDomainRepository.create(1L, 2L, SttMode.RPC, LINES); + Transcript created = transcriptDomainRepository.create(1L, 2L, SNAPSHOT, SttMode.RPC, LINES); transcriptDomainRepository.skipRefinement(created.id()); @@ -101,7 +105,7 @@ class Complete { @Test void 분석_보고서를_저장하고_완료_상태로_바꾼다() { - Transcript created = transcriptDomainRepository.create(1L, 2L, SttMode.RPC, LINES); + Transcript created = transcriptDomainRepository.create(1L, 2L, SNAPSHOT, SttMode.RPC, LINES); transcriptDomainRepository.complete(created.id(), "{\"clashes\":[]}"); @@ -118,7 +122,7 @@ class Fail { @Test void 오류_메시지를_최대_길이로_잘라_실패_상태로_바꾼다() { - Transcript created = transcriptDomainRepository.create(1L, 2L, SttMode.RPC, LINES); + Transcript created = transcriptDomainRepository.create(1L, 2L, SNAPSHOT, SttMode.RPC, LINES); transcriptDomainRepository.fail(created.id(), "가".repeat(TranscriptEntity.ERROR_MESSAGE_MAX_LENGTH + 10)); diff --git a/src/test/java/com/debatetimer/service/transcript/DebateAnalyzerTest.java b/src/test/java/com/debatetimer/service/transcript/DebateAnalyzerTest.java index 6d266b2d..5a610952 100644 --- a/src/test/java/com/debatetimer/service/transcript/DebateAnalyzerTest.java +++ b/src/test/java/com/debatetimer/service/transcript/DebateAnalyzerTest.java @@ -15,6 +15,7 @@ import com.debatetimer.domain.transcript.SttMode; import com.debatetimer.domain.transcript.Transcript; import com.debatetimer.domain.transcript.TranscriptLine; +import com.debatetimer.domain.transcript.TranscriptTableSnapshot; import com.debatetimer.domain.transcript.TranscriptStatus; import com.debatetimer.domainrepository.transcript.TranscriptDomainRepository; import com.debatetimer.entity.customize.CustomizeTableEntity; @@ -42,7 +43,8 @@ private Transcript savedTranscript() { new TranscriptLine(0, 0, "입론", Stance.PROS, "콜리", 1_000, "정규직 과보호가 채용을 막습니다"), new TranscriptLine(1, 1, "입론", Stance.CONS, "비토", 65_000, "채용은 경기가 결정합니다") ); - return transcriptDomainRepository.create(table.getId(), member.getId(), SttMode.RPC, lines); + return transcriptDomainRepository.create(table.getId(), member.getId(), + TranscriptTableSnapshot.from(table.toDomain()), SttMode.RPC, lines); } @Test diff --git a/src/test/java/com/debatetimer/service/transcript/TranscriptRefinerTest.java b/src/test/java/com/debatetimer/service/transcript/TranscriptRefinerTest.java index 629f4419..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; @@ -15,6 +15,7 @@ import com.debatetimer.domain.transcript.SttMode; import com.debatetimer.domain.transcript.Transcript; import com.debatetimer.domain.transcript.TranscriptLine; +import com.debatetimer.domain.transcript.TranscriptTableSnapshot; import com.debatetimer.domainrepository.transcript.TranscriptDomainRepository; import com.debatetimer.entity.customize.CustomizeTableEntity; import com.debatetimer.service.BaseServiceTest; @@ -43,7 +44,8 @@ class TranscriptRefinerTest extends BaseServiceTest { private Transcript savedTranscript() { Member member = memberGenerator.generate("email@email.com"); CustomizeTableEntity table = customizeTableEntityGenerator.generate(member); - return transcriptDomainRepository.create(table.getId(), member.getId(), SttMode.RPC, RAW_LINES); + return transcriptDomainRepository.create(table.getId(), member.getId(), + TranscriptTableSnapshot.from(table.toDomain()), SttMode.RPC, RAW_LINES); } @Test @@ -71,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 diff --git a/src/test/java/com/debatetimer/service/transcript/TranscriptServiceTest.java b/src/test/java/com/debatetimer/service/transcript/TranscriptServiceTest.java index 746a979c..7bb57550 100644 --- a/src/test/java/com/debatetimer/service/transcript/TranscriptServiceTest.java +++ b/src/test/java/com/debatetimer/service/transcript/TranscriptServiceTest.java @@ -6,6 +6,12 @@ import com.debatetimer.client.llm.OpenAiClient; import com.debatetimer.domain.customize.CustomizeBoxType; +import com.debatetimer.domain.customize.BellType; +import com.debatetimer.dto.customize.request.BellRequest; +import com.debatetimer.dto.customize.request.CustomizeTableCreateRequest; +import com.debatetimer.dto.customize.request.CustomizeTableInfoCreateRequest; +import com.debatetimer.dto.customize.request.CustomizeTimeBoxCreateRequest; +import com.debatetimer.service.customize.CustomizeService; import com.debatetimer.domain.customize.Stance; import com.debatetimer.domain.member.Member; import com.debatetimer.domain.transcript.SttMode; @@ -31,6 +37,9 @@ class TranscriptServiceTest extends BaseServiceTest { @Autowired private TranscriptService transcriptService; + @Autowired + private CustomizeService customizeService; + @MockitoBean private OpenAiClient openAiClient; @@ -163,4 +172,54 @@ class FindAll { assertThat(transcriptService.findAll(member).transcripts()).isEmpty(); } } + + @Nested + class TableSnapshot { + + private CustomizeTableCreateRequest renewedTable() { + return new CustomizeTableCreateRequest( + new CustomizeTableInfoCreateRequest("바뀐 시간표", "바뀐 주제", "바뀐 찬성", "바뀐 반대", true, true), + List.of(new CustomizeTimeBoxCreateRequest(Stance.PROS, "입론", CustomizeBoxType.NORMAL, + 120, List.of(new BellRequest(BellType.AFTER_START, 90, 1)), null, null, "콜리")) + ); + } + + @Test + void 시간표를_수정해도_속기록은_생성_시점의_시간표_이름_주제_팀_이름을_유지한다() { + Member member = memberGenerator.generate("email@email.com"); + CustomizeTableEntity table = customizeTableEntityGenerator.generate(member); + customizeTimeBoxEntityGenerator.generate(table, CustomizeBoxType.NORMAL, 1); + long transcriptId = transcriptService.create(table.getId(), member, requestOf(0, "발언")).id(); + + customizeService.updateTable(renewedTable(), table.getId(), member); + + TranscriptResponse found = transcriptService.get(transcriptId, member); + TranscriptSummaryResponse summary = transcriptService.findAll(member).transcripts().get(0); + assertAll( + () -> assertThat(found.tableName()).isEqualTo(table.getName()), + () -> assertThat(found.agenda()).isEqualTo(table.getAgenda()), + () -> assertThat(found.prosTeamName()).isEqualTo(table.getProsTeamName()), + () -> assertThat(found.consTeamName()).isEqualTo(table.getConsTeamName()), + () -> assertThat(summary.tableName()).isEqualTo(table.getName()), + () -> assertThat(summary.agenda()).isEqualTo(table.getAgenda()) + ); + } + + @Test + void 시간표를_지워도_속기록과_목록에_생성_시점의_시간표_정보가_남는다() { + Member member = memberGenerator.generate("email@email.com"); + CustomizeTableEntity table = customizeTableEntityGenerator.generate(member); + customizeTimeBoxEntityGenerator.generate(table, CustomizeBoxType.NORMAL, 1); + long transcriptId = transcriptService.create(table.getId(), member, requestOf(0, "발언")).id(); + + customizeService.deleteTable(table.getId(), member); + + TranscriptResponse found = transcriptService.get(transcriptId, member); + TranscriptSummaryResponse summary = transcriptService.findAll(member).transcripts().get(0); + assertAll( + () -> assertThat(found.agenda()).isEqualTo(table.getAgenda()), + () -> assertThat(summary.tableName()).isEqualTo(table.getName()) + ); + } + } }