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>
203 lines
8.6 KiB
Java
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;
|
|
}
|
|
}
|