Add Notes, Voice Clone TTS, fix auth persistence and maxTokens
Notes: - notes table with TEXT/AUDIO types, category support - Audio upload → OpenRouter Gemini STT → OCI GenAI polish/summary - Raw STT saved separately in raw_content column - Polish/summary button for manual re-processing - Async processing with real-time polling Voice Clone TTS: - Qwen3-TTS 1.7B model on A10 GPU via FastAPI server - Voice profile registration (record/upload → save embedding) - Profile-based TTS generation API - TTS web page with recording, profile management, generation Auth fixes: - Store both access + refresh tokens in localStorage - Initialize state from localStorage synchronously (no flash) - Request interceptor reads token from localStorage every request - Refresh via body (not just cookie) Other fixes: - maxTokens 4096 → 65536 (OCI GenAI Gemini supports up to 65536) - Fix broken Korean chars in source files - OpenRouter config for STT - ffmpeg installed for audio conversion - Ollama + Gemma 4 E4B installed (STT fallback) - nginx proxy for TTS server (/api/tts/) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -45,11 +45,18 @@ public class AuthController {
|
||||
}
|
||||
|
||||
@PostMapping("/refresh")
|
||||
public Mono<ResponseEntity<LoginResponse>> refresh(ServerHttpRequest request, ServerHttpResponse response) {
|
||||
HttpCookie cookie = request.getCookies().getFirst("refreshToken");
|
||||
String refreshToken = cookie != null ? cookie.getValue() : null;
|
||||
public Mono<ResponseEntity<LoginResponse>> refresh(
|
||||
ServerHttpRequest request, ServerHttpResponse response,
|
||||
@RequestBody(required = false) Map<String, String> body) {
|
||||
// 1차: body에서 refreshToken
|
||||
String refreshToken = (body != null) ? body.get("refreshToken") : null;
|
||||
// 2차: cookie에서 refreshToken
|
||||
if (refreshToken == null || refreshToken.isBlank()) {
|
||||
HttpCookie cookie = request.getCookies().getFirst("refreshToken");
|
||||
refreshToken = cookie != null ? cookie.getValue() : null;
|
||||
}
|
||||
|
||||
if (refreshToken == null) {
|
||||
if (refreshToken == null || refreshToken.isBlank()) {
|
||||
return Mono.just(ResponseEntity.status(401).build());
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,561 @@
|
||||
package com.sundol.controller;
|
||||
|
||||
import com.sundol.repository.CategoryRepository;
|
||||
import com.sundol.repository.NoteRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.codec.multipart.FilePart;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/notes")
|
||||
public class NoteController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(NoteController.class);
|
||||
private static final Path AUDIO_DIR = Path.of(System.getProperty("user.dir"), "audio-uploads");
|
||||
private static final HttpClient httpClient = HttpClient.newHttpClient();
|
||||
|
||||
@Value("${openrouter.api-key:}")
|
||||
private String openRouterApiKey;
|
||||
|
||||
@Value("${openrouter.model:google/gemini-2.5-flash}")
|
||||
private String openRouterModel;
|
||||
|
||||
private final NoteRepository noteRepository;
|
||||
private final CategoryRepository categoryRepository;
|
||||
private final com.sundol.service.OciGenAiService genAiService;
|
||||
|
||||
public NoteController(NoteRepository noteRepository, CategoryRepository categoryRepository,
|
||||
com.sundol.service.OciGenAiService genAiService) {
|
||||
this.noteRepository = noteRepository;
|
||||
this.categoryRepository = categoryRepository;
|
||||
this.genAiService = genAiService;
|
||||
try { Files.createDirectories(AUDIO_DIR); } catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public Mono<ResponseEntity<List<Map<String, Object>>>> list(
|
||||
@AuthenticationPrincipal String userId,
|
||||
@RequestParam(required = false) String categoryId) {
|
||||
return Mono.fromCallable(() -> noteRepository.list(userId, categoryId))
|
||||
.subscribeOn(Schedulers.boundedElastic())
|
||||
.map(ResponseEntity::ok);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public Mono<ResponseEntity<Map<String, Object>>> getById(
|
||||
@AuthenticationPrincipal String userId,
|
||||
@PathVariable String id) {
|
||||
return Mono.fromCallable(() -> {
|
||||
Map<String, Object> note = noteRepository.findById(userId, id);
|
||||
if (note == null) return ResponseEntity.notFound().<Map<String, Object>>build();
|
||||
return ResponseEntity.ok(note);
|
||||
}).subscribeOn(Schedulers.boundedElastic());
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public Mono<ResponseEntity<Map<String, Object>>> create(
|
||||
@AuthenticationPrincipal String userId,
|
||||
@RequestBody Map<String, String> body) {
|
||||
return Mono.fromCallable(() -> {
|
||||
String title = body.getOrDefault("title", "");
|
||||
String content = body.getOrDefault("content", "");
|
||||
String categoryId = body.get("categoryId");
|
||||
String id = noteRepository.insert(userId, title, content, "TEXT", null, categoryId);
|
||||
return ResponseEntity.ok(Map.<String, Object>of("id", id));
|
||||
}).subscribeOn(Schedulers.boundedElastic());
|
||||
}
|
||||
|
||||
@PatchMapping("/{id}")
|
||||
public Mono<ResponseEntity<Map<String, Object>>> update(
|
||||
@AuthenticationPrincipal String userId,
|
||||
@PathVariable String id,
|
||||
@RequestBody Map<String, String> body) {
|
||||
return Mono.fromCallable(() -> {
|
||||
String title = body.get("title");
|
||||
String content = body.get("content");
|
||||
String categoryId = body.get("categoryId");
|
||||
noteRepository.update(id, userId, title, content, categoryId);
|
||||
return ResponseEntity.ok(noteRepository.findById(userId, id));
|
||||
}).subscribeOn(Schedulers.boundedElastic());
|
||||
}
|
||||
|
||||
/**
|
||||
* 기존 노트의 내용을 LLM으로 교정 + 요약 재실행
|
||||
*/
|
||||
@PostMapping("/{id}/polish")
|
||||
public Mono<ResponseEntity<Map<String, Object>>> polishNote(
|
||||
@AuthenticationPrincipal String userId,
|
||||
@PathVariable String id) {
|
||||
return Mono.fromCallable(() -> {
|
||||
Map<String, Object> note = noteRepository.findById(userId, id);
|
||||
if (note == null) return ResponseEntity.notFound().<Map<String, Object>>build();
|
||||
|
||||
String content = note.get("CONTENT") != null ? note.get("CONTENT").toString() : "";
|
||||
if (content.isBlank()) return ResponseEntity.badRequest().<Map<String, Object>>build();
|
||||
|
||||
// raw_content가 있으면 그걸 사용, 없으면 content에서 전문 추출
|
||||
Object rawObj = note.get("RAW_CONTENT");
|
||||
String rawText = (rawObj != null && !rawObj.toString().isBlank()) ? rawObj.toString() : content;
|
||||
if (rawText.contains("# 전문")) {
|
||||
int idx = rawText.indexOf("# 전문");
|
||||
rawText = rawText.substring(idx + "# 전문".length()).strip();
|
||||
}
|
||||
|
||||
String noteType = note.get("NOTE_TYPE") != null ? note.get("NOTE_TYPE").toString() : "TEXT";
|
||||
boolean isAudio = "AUDIO".equals(noteType) || note.get("AUDIO_PATH") != null;
|
||||
|
||||
noteRepository.updateNoteType(id, "TRANSCRIBING");
|
||||
|
||||
final String finalRawText = rawText;
|
||||
Schedulers.boundedElastic().schedule(() -> {
|
||||
try {
|
||||
noteRepository.updateContent(id, finalRawText + "\n\n--- 텍스트 교정 중... ---");
|
||||
String polished = polishTranscription(finalRawText);
|
||||
|
||||
noteRepository.updateContent(id, polished + "\n\n--- 요약 생성 중... ---");
|
||||
String summary = summarizeTranscription(polished);
|
||||
|
||||
String result = "# 요약\n\n" + summary + "\n\n---\n\n# 전문\n\n" + polished;
|
||||
String newTitle = generateAudioTitle(summary, java.time.LocalDateTime.now());
|
||||
noteRepository.update(id, null, newTitle, result, null);
|
||||
noteRepository.updateNoteType(id, isAudio ? "AUDIO" : "TEXT");
|
||||
log.info("Polish + summary complete for note {}", id);
|
||||
} catch (Exception e) {
|
||||
log.error("Polish failed for note {}", id, e);
|
||||
noteRepository.updateNoteType(id, isAudio ? "AUDIO" : "TEXT");
|
||||
}
|
||||
});
|
||||
|
||||
return ResponseEntity.ok(Map.<String, Object>of("status", "processing"));
|
||||
}).subscribeOn(Schedulers.boundedElastic());
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public Mono<ResponseEntity<Void>> delete(
|
||||
@AuthenticationPrincipal String userId,
|
||||
@PathVariable String id) {
|
||||
return Mono.fromRunnable(() -> noteRepository.delete(id, userId))
|
||||
.subscribeOn(Schedulers.boundedElastic())
|
||||
.then(Mono.just(ResponseEntity.ok().<Void>build()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 오디오 파일 업로드 → Gemma 4 STT → 텍스트 노트 생성
|
||||
*/
|
||||
@PostMapping("/audio")
|
||||
public Mono<ResponseEntity<Map<String, Object>>> uploadAudio(
|
||||
@AuthenticationPrincipal String userId,
|
||||
@RequestPart("file") FilePart filePart,
|
||||
@RequestPart(value = "title", required = false) String title,
|
||||
@RequestPart(value = "categoryId", required = false) String categoryId) {
|
||||
final String inputTitle = (title != null && !title.isBlank()) ? title : "음성 변환 중...";
|
||||
final String inputCategoryId = categoryId;
|
||||
return Mono.fromCallable(() -> {
|
||||
// 1. 파일 저장
|
||||
String fileName = System.currentTimeMillis() + "_" + filePart.filename();
|
||||
Path audioFile = AUDIO_DIR.resolve(fileName);
|
||||
filePart.transferTo(audioFile).block();
|
||||
log.info("Audio file saved: {} ({} bytes)", audioFile, Files.size(audioFile));
|
||||
|
||||
// 2. 노트 즉시 생성 (TRANSCRIBING 상태)
|
||||
String id = noteRepository.insert(userId, inputTitle, "음성 변환을 시작합니다...", "TRANSCRIBING", fileName, inputCategoryId);
|
||||
|
||||
// 3. 백그라운드에서 STT 실행
|
||||
Schedulers.boundedElastic().schedule(() -> {
|
||||
try {
|
||||
transcribeAsync(id, audioFile, inputTitle);
|
||||
} catch (Exception e) {
|
||||
log.error("Async transcription failed for note {}", id, e);
|
||||
noteRepository.updateContent(id, "음성 변환에 실패했습니다: " + e.getMessage());
|
||||
noteRepository.updateNoteType(id, "AUDIO_FAILED");
|
||||
}
|
||||
});
|
||||
|
||||
return ResponseEntity.ok(Map.<String, Object>of("id", id));
|
||||
}).subscribeOn(Schedulers.boundedElastic());
|
||||
}
|
||||
|
||||
/**
|
||||
* 비동기 STT 처리.
|
||||
* Step 1: OpenRouter (Gemini) STT → raw_content에 저장
|
||||
* Step 2: OCI GenAI로 교정 → content에 저장
|
||||
* Step 3: OCI GenAI로 요약 → content 앞에 추가
|
||||
*/
|
||||
private void transcribeAsync(String noteId, Path audioFile, String inputTitle) throws IOException, InterruptedException {
|
||||
// === Step 1: STT (OpenRouter Gemini) ===
|
||||
String rawResult = null;
|
||||
|
||||
if (openRouterApiKey != null && !openRouterApiKey.isBlank()) {
|
||||
try {
|
||||
noteRepository.updateContent(noteId, "Gemini로 음성 변환 중...");
|
||||
rawResult = transcribeWithOpenRouter(audioFile);
|
||||
log.info("OpenRouter STT: {} chars", rawResult != null ? rawResult.length() : 0);
|
||||
} catch (Exception e) {
|
||||
log.warn("OpenRouter STT failed: {}", e.getMessage());
|
||||
noteRepository.updateContent(noteId, "Gemini STT 실패: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Gemma fallback (OpenRouter 실패 시)
|
||||
if (rawResult == null || rawResult.isBlank()) {
|
||||
try {
|
||||
noteRepository.updateContent(noteId, "Gemma로 음성 변환 중...");
|
||||
Path wavFile = convertToWav(audioFile);
|
||||
rawResult = transcribeChunk(wavFile);
|
||||
cleanup(wavFile, audioFile);
|
||||
} catch (Exception e) {
|
||||
log.error("All STT failed for note {}", noteId, e);
|
||||
noteRepository.updateContent(noteId, "모든 음성 변환 실패: " + e.getMessage());
|
||||
noteRepository.updateNoteType(noteId, "AUDIO_FAILED");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (rawResult == null || rawResult.isBlank()) {
|
||||
noteRepository.updateContent(noteId, "음성 변환 결과가 비어있습니다.");
|
||||
noteRepository.updateNoteType(noteId, "AUDIO_FAILED");
|
||||
return;
|
||||
}
|
||||
|
||||
// raw 텍스트를 별도 컬럼에 저장 + content에도 일단 저장
|
||||
noteRepository.updateRawContent(noteId, rawResult);
|
||||
noteRepository.updateContent(noteId, rawResult);
|
||||
String sttTitle = java.time.LocalDateTime.now().format(
|
||||
java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")) + " 음성 메모";
|
||||
noteRepository.update(noteId, null,
|
||||
inputTitle.equals("음성 변환 중...") ? sttTitle : inputTitle,
|
||||
rawResult, null);
|
||||
noteRepository.updateNoteType(noteId, "AUDIO");
|
||||
log.info("STT raw saved: {} chars", rawResult.length());
|
||||
|
||||
// === Step 2: 교정 (OCI GenAI) ===
|
||||
try {
|
||||
noteRepository.updateNoteType(noteId, "TRANSCRIBING");
|
||||
noteRepository.updateContent(noteId, rawResult + "\n\n--- 텍스트 교정 중 (OCI GenAI)... ---");
|
||||
String polished = polishTranscription(rawResult);
|
||||
log.info("Polish complete: {} chars", polished.length());
|
||||
|
||||
// === Step 3: 요약 (OCI GenAI) ===
|
||||
noteRepository.updateContent(noteId, polished + "\n\n--- 요약 생성 중... ---");
|
||||
String summary = summarizeTranscription(polished);
|
||||
log.info("Summary complete: {} chars", summary.length());
|
||||
|
||||
// 최종 결과 저장
|
||||
String result = "# 요약\n\n" + summary + "\n\n---\n\n# 전문\n\n" + polished;
|
||||
String finalTitle = inputTitle.equals("음성 변환 중...")
|
||||
? generateAudioTitle(summary, java.time.LocalDateTime.now())
|
||||
: inputTitle;
|
||||
noteRepository.update(noteId, null, finalTitle, result, null);
|
||||
log.info("Final note saved: {} chars", result.length());
|
||||
} catch (Exception e) {
|
||||
log.warn("Polish/summary failed, keeping raw STT text: {}", e.getMessage());
|
||||
}
|
||||
|
||||
noteRepository.updateNoteType(noteId, "AUDIO");
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemma 4 E4B를 사용하여 오디오 파일을 텍스트로 변환
|
||||
*/
|
||||
/**
|
||||
* 오디오 파일을 wav로 변환한다 (Ollama 호환성).
|
||||
*/
|
||||
private Path convertToWav(Path audioFile) throws IOException, InterruptedException {
|
||||
String name = audioFile.getFileName().toString();
|
||||
if (name.toLowerCase().endsWith(".wav")) return audioFile;
|
||||
|
||||
Path wavFile = audioFile.getParent().resolve(name.replaceAll("\\.[^.]+$", "") + ".wav");
|
||||
ProcessBuilder pb = new ProcessBuilder(
|
||||
"ffmpeg", "-i", audioFile.toString(),
|
||||
"-ar", "16000", "-ac", "1", "-y",
|
||||
wavFile.toString()
|
||||
);
|
||||
pb.redirectErrorStream(true);
|
||||
Process proc = pb.start();
|
||||
String output = new String(proc.getInputStream().readAllBytes());
|
||||
int exitCode = proc.waitFor();
|
||||
if (exitCode != 0) {
|
||||
log.error("ffmpeg conversion failed (exit {}): {}", exitCode, output.substring(0, Math.min(500, output.length())));
|
||||
throw new IOException("오디오 변환 실패 (ffmpeg exit " + exitCode + ")");
|
||||
}
|
||||
log.info("Converted {} to wav: {} bytes", name, Files.size(wavFile));
|
||||
return wavFile;
|
||||
}
|
||||
|
||||
private static final int CHUNK_SECONDS = 180; // 3분 단위 분할
|
||||
|
||||
private String transcribeWithGemma(Path audioFile) throws IOException, InterruptedException {
|
||||
Path wavFile = convertToWav(audioFile);
|
||||
double duration = getAudioDuration(wavFile);
|
||||
log.info("Audio duration: {}s", duration);
|
||||
|
||||
if (duration <= CHUNK_SECONDS) {
|
||||
String result = transcribeChunk(wavFile);
|
||||
cleanup(wavFile, audioFile);
|
||||
return result;
|
||||
}
|
||||
|
||||
// 긴 오디오: 3분 단위로 분할
|
||||
int chunks = (int) Math.ceil(duration / CHUNK_SECONDS);
|
||||
log.info("Splitting audio into {} chunks of {}s", chunks, CHUNK_SECONDS);
|
||||
|
||||
StringBuilder fullText = new StringBuilder();
|
||||
for (int i = 0; i < chunks; i++) {
|
||||
int start = i * CHUNK_SECONDS;
|
||||
Path chunkFile = wavFile.getParent().resolve("chunk_" + i + "_" + System.currentTimeMillis() + ".wav");
|
||||
|
||||
ProcessBuilder pb = new ProcessBuilder(
|
||||
"ffmpeg", "-i", wavFile.toString(),
|
||||
"-ss", String.valueOf(start), "-t", String.valueOf(CHUNK_SECONDS),
|
||||
"-ar", "16000", "-ac", "1", "-y", chunkFile.toString()
|
||||
);
|
||||
pb.redirectErrorStream(true);
|
||||
Process proc = pb.start();
|
||||
proc.getInputStream().readAllBytes();
|
||||
proc.waitFor();
|
||||
|
||||
log.info("Transcribing chunk {}/{} ({}s-{}s)", i + 1, chunks, start, Math.min(start + CHUNK_SECONDS, (int) duration));
|
||||
|
||||
try {
|
||||
String chunkText = transcribeChunk(chunkFile);
|
||||
if (!chunkText.isBlank()) {
|
||||
if (fullText.length() > 0) fullText.append("\n\n");
|
||||
fullText.append(chunkText);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Chunk {} failed: {}", i + 1, e.getMessage());
|
||||
fullText.append("\n\n[chunk ").append(i + 1).append(" 변환 실패]");
|
||||
} finally {
|
||||
try { Files.deleteIfExists(chunkFile); } catch (Exception ignored) {}
|
||||
}
|
||||
}
|
||||
|
||||
cleanup(wavFile, audioFile);
|
||||
String result = fullText.toString().strip();
|
||||
if (result.isBlank()) throw new IOException("Gemma STT returned empty for all chunks");
|
||||
return result;
|
||||
}
|
||||
|
||||
private String transcribeChunk(Path wavFile) throws IOException, InterruptedException {
|
||||
byte[] audioBytes = Files.readAllBytes(wavFile);
|
||||
String base64Audio = Base64.getEncoder().encodeToString(audioBytes);
|
||||
log.info("Chunk base64: {} chars ({} MB)", base64Audio.length(), audioBytes.length / 1024 / 1024);
|
||||
|
||||
String payload = """
|
||||
{
|
||||
"model": "gemma4:e4b",
|
||||
"messages": [{"role": "user", "content": "Transcribe the following audio to text accurately. Output only the spoken content in its original language. Do not add any description or translation.", "images": ["%s"]}],
|
||||
"stream": false,
|
||||
"options": {"num_ctx": 8000}
|
||||
}
|
||||
""".formatted(base64Audio);
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create("http://localhost:11434/api/chat"))
|
||||
.header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(payload))
|
||||
.timeout(java.time.Duration.ofMinutes(10))
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
if (response.statusCode() != 200) {
|
||||
log.error("Gemma STT error {}: {}", response.statusCode(), response.body().substring(0, Math.min(500, response.body().length())));
|
||||
throw new IOException("Gemma STT failed: HTTP " + response.statusCode());
|
||||
}
|
||||
|
||||
var root = new com.fasterxml.jackson.databind.ObjectMapper().readTree(response.body());
|
||||
return root.path("message").path("content").asText("").strip();
|
||||
}
|
||||
|
||||
/**
|
||||
* STT 결과를 LLM으로 교정한다.
|
||||
* 발음 오인식 보정, 문장 구분, 불필요한 추임새 제거, 가독성 향상.
|
||||
*/
|
||||
private String polishTranscription(String rawText) {
|
||||
if (!genAiService.isConfigured()) {
|
||||
log.info("GenAI not configured, skipping polish");
|
||||
return rawText;
|
||||
}
|
||||
|
||||
try {
|
||||
String systemMsg =
|
||||
"당신은 전문 속기사입니다. 음성 인식(STT) 텍스트를 교정해주세요.\n\n" +
|
||||
"## 규칙\n" +
|
||||
"1. 발음 오인식 단어를 문맥에 맞게 보정하세요.\n" +
|
||||
"2. 추임새(어, 음, 그, 아, 뭐, 이제, 근데)를 제거하세요.\n" +
|
||||
"3. 문장 부호를 넣고 단락을 나누세요.\n" +
|
||||
"4. 절대 요약하지 마세요. 원문의 모든 내용을 빠짐없이 유지하세요.\n" +
|
||||
"5. 내용을 추가하거나 삭제하지 마세요. 교정만 하세요.\n" +
|
||||
"6. 전문 용어와 고유 명사는 올바르게 표기하세요.\n" +
|
||||
"7. 입력 텍스트와 비슷한 분량으로 출력하세요. 줄이지 마세요.\n" +
|
||||
"8. Markdown 형식으로 출력하세요.";
|
||||
|
||||
// maxTokens 65536이므로 대부분 한 번에 처리 가능
|
||||
if (rawText.length() <= 30000) {
|
||||
log.info("Polishing in single call: {} chars", rawText.length());
|
||||
return genAiService.chat(systemMsg,
|
||||
"아래 STT 텍스트를 교정해주세요. 전체 내용을 빠짐없이 유지하세요:\n\n" + rawText, null).strip();
|
||||
}
|
||||
|
||||
// 30000자 이상만 분할
|
||||
StringBuilder polished = new StringBuilder();
|
||||
int chunkSize = 20000;
|
||||
int totalChunks = (int) Math.ceil((double) rawText.length() / chunkSize);
|
||||
for (int i = 0; i < rawText.length(); i += chunkSize) {
|
||||
int chunkNum = (i / chunkSize) + 1;
|
||||
String chunk = rawText.substring(i, Math.min(i + chunkSize, rawText.length()));
|
||||
log.info("Polishing chunk {}/{}: {} chars", chunkNum, totalChunks, chunk.length());
|
||||
String result = genAiService.chat(systemMsg,
|
||||
"아래 STT 텍스트를 교정해주세요. 전체 내용을 빠짐없이 유지하세요:\n\n" + chunk, null).strip();
|
||||
if (polished.length() > 0) polished.append("\n\n");
|
||||
polished.append(result);
|
||||
}
|
||||
return polished.toString();
|
||||
} catch (Exception e) {
|
||||
log.warn("Polish transcription failed, returning raw text: {}", e.getMessage());
|
||||
return rawText;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 교정된 텍스트를 요약한다.
|
||||
*/
|
||||
private String summarizeTranscription(String polishedText) {
|
||||
if (!genAiService.isConfigured()) return "";
|
||||
|
||||
try {
|
||||
String systemMsg =
|
||||
"당신은 회의록/녹음 요약 전문가입니다. 아래 텍스트를 요약해주세요.\n\n" +
|
||||
"## 규칙\n" +
|
||||
"1. 주요 논의 주제별로 소제목(##)을 나누어 요약하세요.\n" +
|
||||
"2. 각 주제 아래 핵심 내용을 불릿 포인트로 정리하세요.\n" +
|
||||
"3. 주요 결정 사항, 액션 아이템이 있다면 별도로 표시하세요.\n" +
|
||||
"4. 원문과 같은 언어로 작성하세요.\n" +
|
||||
"5. Markdown 형식으로 작성하세요.\n" +
|
||||
"6. 원본 길이에 비례하여 요약하세요. 긴 내용은 상세하게, 짧은 내용은 간결하게.\n" +
|
||||
"7. 중요한 수치, 이름, 기술명은 빠뜨리지 마세요.";
|
||||
|
||||
String content = polishedText.length() > 15000
|
||||
? polishedText.substring(0, 15000) : polishedText;
|
||||
return genAiService.chat(systemMsg, "아래 내용을 요약해주세요:\n\n" + content, null).strip();
|
||||
} catch (Exception e) {
|
||||
log.warn("Summarization failed: {}", e.getMessage());
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* LLM으로 음성 메모 제목을 생성한다. "일시 - 핵심 주제" 형태.
|
||||
*/
|
||||
private String generateAudioTitle(String summary, java.time.LocalDateTime dateTime) {
|
||||
if (!genAiService.isConfigured() || summary.isBlank()) {
|
||||
return dateTime.format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")) + " 음성 메모";
|
||||
}
|
||||
|
||||
try {
|
||||
String systemMsg = "음성 메모의 요약을 보고 10자 이내의 짧은 제목을 생성해주세요. " +
|
||||
"제목만 출력하세요. 따옴표, 설명, 접두사 없이 제목만.";
|
||||
String title = genAiService.chat(systemMsg, summary, null).strip()
|
||||
.replaceAll("^\"|\"$", "").replaceAll("^'|'$", "");
|
||||
if (title.length() > 40) title = title.substring(0, 40);
|
||||
return dateTime.format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")) + " " + title;
|
||||
} catch (Exception e) {
|
||||
log.warn("Title generation failed: {}", e.getMessage());
|
||||
return dateTime.format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")) + " 음성 메모";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenRouter API (Gemini 2.5 Flash)를 사용하여 오디오 STT. 한 번에 전체 파일 처리 가능.
|
||||
*/
|
||||
private String transcribeWithOpenRouter(Path audioFile) throws IOException, InterruptedException {
|
||||
byte[] audioBytes = Files.readAllBytes(audioFile);
|
||||
String base64Audio = Base64.getEncoder().encodeToString(audioBytes);
|
||||
|
||||
String mimeType = "audio/wav";
|
||||
String name = audioFile.getFileName().toString().toLowerCase();
|
||||
if (name.endsWith(".mp3")) mimeType = "audio/mpeg";
|
||||
else if (name.endsWith(".m4a")) mimeType = "audio/mp4";
|
||||
else if (name.endsWith(".ogg")) mimeType = "audio/ogg";
|
||||
else if (name.endsWith(".webm")) mimeType = "audio/webm";
|
||||
else if (name.endsWith(".flac")) mimeType = "audio/flac";
|
||||
|
||||
log.info("OpenRouter STT: {} ({} MB, {})", name, audioBytes.length / 1024 / 1024, mimeType);
|
||||
|
||||
// OpenRouter chat/completions API with audio input
|
||||
String payload = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(Map.of(
|
||||
"model", openRouterModel,
|
||||
"messages", List.of(Map.of(
|
||||
"role", "user",
|
||||
"content", List.of(
|
||||
Map.of("type", "input_audio", "input_audio", Map.of(
|
||||
"data", base64Audio,
|
||||
"format", mimeType.substring(mimeType.indexOf('/') + 1)
|
||||
)),
|
||||
Map.of("type", "text", "text",
|
||||
"Transcribe the audio accurately. Output only the spoken content in its original language. " +
|
||||
"Do not add description, annotation, timestamps, or translation. " +
|
||||
"If the audio contains Korean, output in Korean.")
|
||||
)
|
||||
))
|
||||
));
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create("https://openrouter.ai/api/v1/chat/completions"))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Authorization", "Bearer " + openRouterApiKey)
|
||||
.POST(HttpRequest.BodyPublishers.ofString(payload))
|
||||
.timeout(java.time.Duration.ofMinutes(10))
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
if (response.statusCode() != 200) {
|
||||
log.error("OpenRouter STT error {}: {}", response.statusCode(),
|
||||
response.body().substring(0, Math.min(500, response.body().length())));
|
||||
throw new IOException("OpenRouter STT failed: HTTP " + response.statusCode());
|
||||
}
|
||||
|
||||
var root = new com.fasterxml.jackson.databind.ObjectMapper().readTree(response.body());
|
||||
String text = root.path("choices").path(0).path("message").path("content").asText("").strip();
|
||||
|
||||
if (text.isBlank()) {
|
||||
throw new IOException("OpenRouter STT returned empty result");
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
private double getAudioDuration(Path audioFile) throws IOException, InterruptedException {
|
||||
ProcessBuilder pb = new ProcessBuilder("ffprobe", "-i", audioFile.toString(),
|
||||
"-show_entries", "format=duration", "-v", "quiet", "-of", "csv=p=0");
|
||||
pb.redirectErrorStream(true);
|
||||
Process proc = pb.start();
|
||||
String output = new String(proc.getInputStream().readAllBytes()).strip();
|
||||
proc.waitFor();
|
||||
try { return Double.parseDouble(output); } catch (NumberFormatException e) { return 0; }
|
||||
}
|
||||
|
||||
private void cleanup(Path wavFile, Path originalFile) {
|
||||
if (!wavFile.equals(originalFile)) {
|
||||
try { Files.deleteIfExists(wavFile); } catch (Exception ignored) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package com.sundol.repository;
|
||||
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.sql.Clob;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Repository
|
||||
public class NoteRepository {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
public NoteRepository(JdbcTemplate jdbcTemplate) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
}
|
||||
|
||||
public String insert(String userId, String title, String content, String noteType, String audioPath, String categoryId) {
|
||||
if (categoryId != null) {
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO notes (id, user_id, title, content, note_type, audio_path, category_id, created_at, updated_at) " +
|
||||
"VALUES (SYS_GUID(), HEXTORAW(?), ?, ?, ?, ?, HEXTORAW(?), SYSTIMESTAMP, SYSTIMESTAMP)",
|
||||
new Object[]{userId, title, content, noteType, audioPath, categoryId},
|
||||
new int[]{java.sql.Types.VARCHAR, java.sql.Types.VARCHAR, java.sql.Types.CLOB, java.sql.Types.VARCHAR, java.sql.Types.VARCHAR, java.sql.Types.VARCHAR}
|
||||
);
|
||||
} else {
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO notes (id, user_id, title, content, note_type, audio_path, created_at, updated_at) " +
|
||||
"VALUES (SYS_GUID(), HEXTORAW(?), ?, ?, ?, ?, SYSTIMESTAMP, SYSTIMESTAMP)",
|
||||
new Object[]{userId, title, content, noteType, audioPath},
|
||||
new int[]{java.sql.Types.VARCHAR, java.sql.Types.VARCHAR, java.sql.Types.CLOB, java.sql.Types.VARCHAR, java.sql.Types.VARCHAR}
|
||||
);
|
||||
}
|
||||
var result = jdbcTemplate.queryForList(
|
||||
"SELECT RAWTOHEX(id) AS id FROM notes WHERE user_id = HEXTORAW(?) ORDER BY created_at DESC FETCH FIRST 1 ROW ONLY",
|
||||
userId
|
||||
);
|
||||
return (String) result.get(0).get("ID");
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> list(String userId, String categoryId) {
|
||||
if (categoryId != null && !categoryId.isBlank()) {
|
||||
return jdbcTemplate.queryForList(
|
||||
"SELECT RAWTOHEX(n.id) AS id, n.title, n.note_type, n.audio_path, " +
|
||||
" RAWTOHEX(n.category_id) AS category_id, c.full_path AS category_path, " +
|
||||
" n.created_at, n.updated_at " +
|
||||
"FROM notes n LEFT JOIN categories c ON c.id = n.category_id " +
|
||||
"WHERE n.user_id = HEXTORAW(?) AND n.category_id = HEXTORAW(?) " +
|
||||
"ORDER BY n.created_at DESC",
|
||||
userId, categoryId
|
||||
);
|
||||
}
|
||||
return jdbcTemplate.queryForList(
|
||||
"SELECT RAWTOHEX(n.id) AS id, n.title, n.note_type, n.audio_path, " +
|
||||
" RAWTOHEX(n.category_id) AS category_id, c.full_path AS category_path, " +
|
||||
" n.created_at, n.updated_at " +
|
||||
"FROM notes n LEFT JOIN categories c ON c.id = n.category_id " +
|
||||
"WHERE n.user_id = HEXTORAW(?) " +
|
||||
"ORDER BY n.created_at DESC",
|
||||
userId
|
||||
);
|
||||
}
|
||||
|
||||
public Map<String, Object> findById(String userId, String id) {
|
||||
var results = jdbcTemplate.queryForList(
|
||||
"SELECT RAWTOHEX(n.id) AS id, n.title, n.content, n.raw_content, n.note_type, n.audio_path, " +
|
||||
" RAWTOHEX(n.category_id) AS category_id, c.full_path AS category_path, " +
|
||||
" n.created_at, n.updated_at " +
|
||||
"FROM notes n LEFT JOIN categories c ON c.id = n.category_id " +
|
||||
"WHERE RAWTOHEX(n.id) = ? AND n.user_id = HEXTORAW(?)",
|
||||
id, userId
|
||||
);
|
||||
if (results.isEmpty()) return null;
|
||||
return convertClobFields(results.get(0));
|
||||
}
|
||||
|
||||
public void update(String id, String userId, String title, String content, String categoryId) {
|
||||
if (userId != null) {
|
||||
if (categoryId != null) {
|
||||
jdbcTemplate.update(
|
||||
"UPDATE notes SET title = ?, content = ?, category_id = HEXTORAW(?), updated_at = SYSTIMESTAMP " +
|
||||
"WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?)",
|
||||
new Object[]{title, content, categoryId, id, userId},
|
||||
new int[]{java.sql.Types.VARCHAR, java.sql.Types.CLOB, java.sql.Types.VARCHAR, java.sql.Types.VARCHAR, java.sql.Types.VARCHAR}
|
||||
);
|
||||
} else {
|
||||
jdbcTemplate.update(
|
||||
"UPDATE notes SET title = ?, content = ?, updated_at = SYSTIMESTAMP " +
|
||||
"WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?)",
|
||||
new Object[]{title, content, id, userId},
|
||||
new int[]{java.sql.Types.VARCHAR, java.sql.Types.CLOB, java.sql.Types.VARCHAR, java.sql.Types.VARCHAR}
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// 내부 호출 (userId 없음)
|
||||
jdbcTemplate.update(
|
||||
"UPDATE notes SET title = ?, content = ?, updated_at = SYSTIMESTAMP WHERE RAWTOHEX(id) = ?",
|
||||
new Object[]{title, content, id},
|
||||
new int[]{java.sql.Types.VARCHAR, java.sql.Types.CLOB, java.sql.Types.VARCHAR}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public void updateRawContent(String id, String rawContent) {
|
||||
jdbcTemplate.update(
|
||||
"UPDATE notes SET raw_content = ?, updated_at = SYSTIMESTAMP WHERE RAWTOHEX(id) = ?",
|
||||
new Object[]{rawContent, id},
|
||||
new int[]{java.sql.Types.CLOB, java.sql.Types.VARCHAR}
|
||||
);
|
||||
}
|
||||
|
||||
public void updateContent(String id, String content) {
|
||||
jdbcTemplate.update(
|
||||
"UPDATE notes SET content = ?, updated_at = SYSTIMESTAMP WHERE RAWTOHEX(id) = ?",
|
||||
new Object[]{content, id},
|
||||
new int[]{java.sql.Types.CLOB, java.sql.Types.VARCHAR}
|
||||
);
|
||||
}
|
||||
|
||||
public void updateNoteType(String id, String noteType) {
|
||||
jdbcTemplate.update(
|
||||
"UPDATE notes SET note_type = ?, updated_at = SYSTIMESTAMP WHERE RAWTOHEX(id) = ?",
|
||||
noteType, id
|
||||
);
|
||||
}
|
||||
|
||||
public void delete(String id, String userId) {
|
||||
jdbcTemplate.update(
|
||||
"DELETE FROM notes WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?)",
|
||||
id, userId
|
||||
);
|
||||
}
|
||||
|
||||
private Map<String, Object> convertClobFields(Map<String, Object> row) {
|
||||
for (var entry : row.entrySet()) {
|
||||
Object val = entry.getValue();
|
||||
if (val instanceof Clob clob) {
|
||||
try {
|
||||
entry.setValue(clob.getSubString(1, (int) clob.length()));
|
||||
} catch (Exception e) {
|
||||
entry.setValue(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
return row;
|
||||
}
|
||||
}
|
||||
@@ -96,7 +96,7 @@ public class OciGenAiService {
|
||||
Map.of("role", "SYSTEM", "content", List.of(Map.of("type", "TEXT", "text", systemMessage))),
|
||||
Map.of("role", "USER", "content", List.of(Map.of("type", "TEXT", "text", userMessage)))
|
||||
),
|
||||
"maxTokens", 4096,
|
||||
"maxTokens", 65536,
|
||||
"temperature", 0.3
|
||||
)
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user