Files
sundol/sundol-backend/src/main/java/com/sundol/service/OciGenAiService.java
joungmin 1088b23790 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>
2026-04-13 07:34:18 +00:00

203 lines
8.6 KiB
Java

package com.sundol.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@Service
public class OciGenAiService {
private static final Logger log = LoggerFactory.getLogger(OciGenAiService.class);
private final String apiKey;
private final String compartment;
private final String defaultModel;
private final String baseUrl;
private final ObjectMapper objectMapper;
private final HttpClient httpClient;
public static final List<Map<String, String>> AVAILABLE_MODELS = List.of(
Map.of("id", "google.gemini-2.5-pro", "name", "Gemini 2.5 Pro", "vendor", "Google"),
Map.of("id", "google.gemini-2.5-flash", "name", "Gemini 2.5 Flash", "vendor", "Google"),
Map.of("id", "google.gemini-2.5-flash-lite", "name", "Gemini 2.5 Flash Lite", "vendor", "Google"),
Map.of("id", "xai.grok-4.20-reasoning", "name", "Grok 4.20 Reasoning", "vendor", "xAI"),
Map.of("id", "xai.grok-4.20-non-reasoning", "name", "Grok 4.20 Non-Reasoning", "vendor", "xAI"),
Map.of("id", "xai.grok-4-1-fast-reasoning", "name", "Grok 4-1 Fast Reasoning", "vendor", "xAI"),
Map.of("id", "xai.grok-4-1-fast-non-reasoning", "name", "Grok 4-1 Fast Non-Reasoning", "vendor", "xAI"),
Map.of("id", "xai.grok-4", "name", "Grok 4", "vendor", "xAI"),
Map.of("id", "xai.grok-3", "name", "Grok 3", "vendor", "xAI"),
Map.of("id", "xai.grok-3-mini", "name", "Grok 3 Mini", "vendor", "xAI"),
Map.of("id", "openai.gpt-oss-120b", "name", "GPT-OSS 120B", "vendor", "OpenAI"),
Map.of("id", "openai.gpt-oss-20b", "name", "GPT-OSS 20B", "vendor", "OpenAI"),
Map.of("id", "cohere.command-a-03-2025", "name", "Command A", "vendor", "Cohere"),
Map.of("id", "cohere.command-a-reasoning", "name", "Command A Reasoning", "vendor", "Cohere"),
Map.of("id", "cohere.command-r-plus-08-2024", "name", "Command R+", "vendor", "Cohere"),
Map.of("id", "meta.llama-4-maverick-17b-128e-instruct-fp8", "name", "Llama 4 Maverick 17B", "vendor", "Meta"),
Map.of("id", "meta.llama-4-scout-17b-16e-instruct", "name", "Llama 4 Scout 17B", "vendor", "Meta")
);
public OciGenAiService(
@Value("${oci.genai.api-key:}") String apiKey,
@Value("${oci.genai.compartment:}") String compartment,
@Value("${oci.genai.model:google.gemini-2.5-flash}") String defaultModel,
@Value("${oci.genai.base-url:}") String baseUrl,
ObjectMapper objectMapper) {
this.apiKey = apiKey;
this.compartment = compartment;
this.defaultModel = defaultModel;
this.baseUrl = baseUrl;
this.objectMapper = objectMapper;
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(30))
.build();
}
public boolean isConfigured() {
return apiKey != null && !apiKey.isBlank()
&& compartment != null && !compartment.isBlank();
}
public String getDefaultModel() {
return defaultModel;
}
/**
* OCI GenAI Chat API 호출.
*/
public String chat(String systemMessage, String userMessage, String modelId) throws Exception {
if (!isConfigured()) {
throw new IllegalStateException("OCI GenAI is not configured");
}
if (modelId == null || modelId.isBlank()) {
modelId = defaultModel;
}
Map<String, Object> payload = Map.of(
"compartmentId", compartment,
"servingMode", Map.of(
"servingType", "ON_DEMAND",
"modelId", modelId
),
"chatRequest", Map.of(
"apiFormat", "GENERIC",
"messages", List.of(
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", 65536,
"temperature", 0.3
)
);
String body = objectMapper.writeValueAsString(payload);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/chat"))
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + apiKey)
.POST(HttpRequest.BodyPublishers.ofString(body))
.timeout(Duration.ofSeconds(120))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
log.error("OCI GenAI error {} (model={}): {}", response.statusCode(), modelId,
response.body().substring(0, Math.min(response.body().length(), 500)));
throw new RuntimeException("OCI GenAI returned " + response.statusCode());
}
JsonNode root = objectMapper.readTree(response.body());
JsonNode choices = root.path("chatResponse").path("choices");
if (choices.isArray() && !choices.isEmpty()) {
JsonNode content = choices.get(0).path("message").path("content");
if (content.isArray() && !content.isEmpty()) {
return content.get(0).path("text").asText("");
}
}
throw new RuntimeException("Unexpected OCI GenAI response structure");
}
private static final String EMBED_MODEL = "cohere.embed-v4.0";
private static final int EMBED_BATCH_SIZE = 96;
/**
* OCI GenAI Embed API 호출. 최대 96개씩 배치 처리.
* @param texts 임베딩할 텍스트 목록
* @param inputType SEARCH_DOCUMENT (저장 시) 또는 SEARCH_QUERY (검색 시)
* @return 각 텍스트에 대응하는 float[] 벡터 리스트
*/
public List<float[]> embedTexts(List<String> texts, String inputType) throws Exception {
if (!isConfigured()) {
throw new IllegalStateException("OCI GenAI is not configured");
}
List<float[]> allEmbeddings = new ArrayList<>();
for (int start = 0; start < texts.size(); start += EMBED_BATCH_SIZE) {
int end = Math.min(start + EMBED_BATCH_SIZE, texts.size());
List<String> batch = texts.subList(start, end);
Map<String, Object> payload = Map.of(
"compartmentId", compartment,
"servingMode", Map.of(
"servingType", "ON_DEMAND",
"modelId", EMBED_MODEL
),
"inputs", batch,
"inputType", inputType,
"truncate", "END"
);
String body = objectMapper.writeValueAsString(payload);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/embedText"))
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + apiKey)
.POST(HttpRequest.BodyPublishers.ofString(body))
.timeout(Duration.ofSeconds(120))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
log.error("OCI GenAI embed error {}: {}", response.statusCode(),
response.body().substring(0, Math.min(response.body().length(), 500)));
throw new RuntimeException("OCI GenAI embed returned " + response.statusCode());
}
JsonNode root = objectMapper.readTree(response.body());
JsonNode embeddings = root.path("embeddings");
if (!embeddings.isArray()) {
throw new RuntimeException("Unexpected embed response: no embeddings array");
}
for (JsonNode embNode : embeddings) {
float[] vec = new float[embNode.size()];
for (int i = 0; i < embNode.size(); i++) {
vec[i] = (float) embNode.get(i).asDouble();
}
allEmbeddings.add(vec);
}
log.debug("Embedded batch [{}-{}] of {} texts", start, end, texts.size());
}
return allEmbeddings;
}
}