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 @@ -7,6 +7,7 @@ public record Transcript(
Long id,
long tableId,
long memberId,
TranscriptTableSnapshot table,
TranscriptStatus status,
SttMode sttMode,
// AI가 STT 원문을 다듬는 중이면 true. 다듬기가 끝나거나 실패하면 false가 된다.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
package com.debatetimer.domain.transcript;

import java.time.LocalDateTime;
import org.springframework.lang.Nullable;

/**
* 토론 기록 목록에 보여 줄 요약. 속기록·보고서 본문은 담지 않는다.
*/
public record TranscriptSummary(
long id,
long tableId,
@Nullable String tableName,
@Nullable String agenda,
SttMode sttMode,
TranscriptStatus status,
boolean refining,
Expand Down
Original file line number Diff line number Diff line change
@@ -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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -25,8 +26,9 @@ public class TranscriptDomainRepository {
private final ObjectMapper objectMapper;

@Transactional
public Transcript create(long tableId, long memberId, SttMode sttMode, List<TranscriptLine> lines) {
TranscriptEntity entity = new TranscriptEntity(tableId, memberId, sttMode, writeLines(lines));
public Transcript create(long tableId, long memberId, TranscriptTableSnapshot table, SttMode sttMode,
List<TranscriptLine> lines) {
TranscriptEntity entity = new TranscriptEntity(tableId, memberId, table, sttMode, writeLines(lines));
return toDomain(transcriptRepository.save(entity));
}

Expand Down Expand Up @@ -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());
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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());
}
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
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;
import java.time.LocalDateTime;
import org.springframework.lang.Nullable;

/**
* 토론 기록 목록의 한 항목. 시간표가 삭제됐으면 시간표 이름과 주제는 null이다.
* 토론 기록 목록의 한 항목. 시간표 이름과 주제는 속기록을 만들 때의 값이다. 스냅샷 도입 전에 지워진 시간표의 기록은 null이다.
*/
public record TranscriptSummaryResponse(
long id,
Expand All @@ -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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -59,16 +73,25 @@ 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;
this.refining = true;
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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public interface TranscriptRepository extends Repository<TranscriptEntity, Long>
// 속기록·보고서 본문(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
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -32,28 +31,24 @@ public class DebateAnalyzer {

private final OpenAiClient openAiClient;
private final TranscriptDomainRepository transcriptDomainRepository;
private final CustomizeTableDomainRepository customizeTableDomainRepository;
private final String systemPrompt;
private final Map<String, Object> 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);
}

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) {
Expand All @@ -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"));
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand All @@ -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<Map<String, Object>> 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;
Expand All @@ -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();
Expand All @@ -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<TranscriptLine> refined = refinedTextMerger.merge(transcript.lines(), readRefinedTexts(response));
transcriptDomainRepository.refine(transcriptId, refined);
Expand All @@ -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<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(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) {
Expand All @@ -105,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 @@ -124,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
Loading
Loading