- 모바일 하단 네비게이션(홈/식당목록/내주변/찜/내정보) 추가 - 로그인 버튼을 모달 방식으로 변경 (소셜 로그인 확장 가능) - 내위치 기반 정렬 및 영역 필터, 지도 내위치 버튼 추가 - 채널 필터 시 해당 채널만 마커/범례 표시 - 캐치테이블 검색/연동 (단건/벌크), NONE 저장 패턴 - 벌크 트랜스크립트 SSE (Playwright 브라우저 재사용) - 테이블링/캐치테이블 버튼 UI 차별화 - Google Maps 링크 모바일 호환, 초기화 버튼, 검색 라벨 개선 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
183 lines
6.8 KiB
Java
183 lines
6.8 KiB
Java
package com.tasteby.service;
|
|
|
|
import com.fasterxml.jackson.core.type.TypeReference;
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
import com.oracle.bmc.ConfigFileReader;
|
|
import com.oracle.bmc.auth.ConfigFileAuthenticationDetailsProvider;
|
|
import com.oracle.bmc.generativeaiinference.GenerativeAiInferenceClient;
|
|
import com.oracle.bmc.generativeaiinference.model.*;
|
|
import com.oracle.bmc.generativeaiinference.requests.ChatRequest;
|
|
import com.oracle.bmc.generativeaiinference.requests.EmbedTextRequest;
|
|
import com.oracle.bmc.generativeaiinference.responses.ChatResponse;
|
|
import com.oracle.bmc.generativeaiinference.responses.EmbedTextResponse;
|
|
import org.slf4j.Logger;
|
|
import org.slf4j.LoggerFactory;
|
|
import org.springframework.beans.factory.annotation.Value;
|
|
import org.springframework.stereotype.Service;
|
|
|
|
import jakarta.annotation.PostConstruct;
|
|
import jakarta.annotation.PreDestroy;
|
|
import java.util.ArrayList;
|
|
import java.util.List;
|
|
import java.util.Map;
|
|
import java.util.regex.Matcher;
|
|
import java.util.regex.Pattern;
|
|
|
|
@Service
|
|
public class OciGenAiService {
|
|
|
|
private static final Logger log = LoggerFactory.getLogger(OciGenAiService.class);
|
|
private static final int EMBED_BATCH_SIZE = 96;
|
|
|
|
@Value("${app.oci.compartment-id}")
|
|
private String compartmentId;
|
|
|
|
@Value("${app.oci.chat-endpoint}")
|
|
private String chatEndpoint;
|
|
|
|
@Value("${app.oci.embed-endpoint}")
|
|
private String embedEndpoint;
|
|
|
|
@Value("${app.oci.chat-model-id}")
|
|
private String chatModelId;
|
|
|
|
@Value("${app.oci.embed-model-id}")
|
|
private String embedModelId;
|
|
|
|
private final ObjectMapper mapper;
|
|
private ConfigFileAuthenticationDetailsProvider authProvider;
|
|
private GenerativeAiInferenceClient chatClient;
|
|
private GenerativeAiInferenceClient embedClient;
|
|
|
|
public OciGenAiService(ObjectMapper mapper) {
|
|
this.mapper = mapper;
|
|
}
|
|
|
|
@PostConstruct
|
|
public void init() {
|
|
try {
|
|
ConfigFileReader.ConfigFile configFile = ConfigFileReader.parseDefault();
|
|
authProvider = new ConfigFileAuthenticationDetailsProvider(configFile);
|
|
chatClient = GenerativeAiInferenceClient.builder()
|
|
.endpoint(chatEndpoint).build(authProvider);
|
|
embedClient = GenerativeAiInferenceClient.builder()
|
|
.endpoint(embedEndpoint).build(authProvider);
|
|
log.info("OCI GenAI auth configured (clients initialized)");
|
|
} catch (Exception e) {
|
|
log.warn("OCI config not found, GenAI features disabled: {}", e.getMessage());
|
|
}
|
|
}
|
|
|
|
@PreDestroy
|
|
public void destroy() {
|
|
if (chatClient != null) chatClient.close();
|
|
if (embedClient != null) embedClient.close();
|
|
}
|
|
|
|
/**
|
|
* Call OCI GenAI LLM (Chat).
|
|
*/
|
|
public String chat(String prompt, int maxTokens) {
|
|
if (chatClient == null) throw new IllegalStateException("OCI GenAI not configured");
|
|
|
|
var textContent = TextContent.builder().text(prompt).build();
|
|
var userMessage = UserMessage.builder().content(List.of(textContent)).build();
|
|
|
|
var chatRequest = GenericChatRequest.builder()
|
|
.messages(List.of(userMessage))
|
|
.maxTokens(maxTokens)
|
|
.temperature(0.0)
|
|
.build();
|
|
|
|
var chatDetails = ChatDetails.builder()
|
|
.compartmentId(compartmentId)
|
|
.servingMode(OnDemandServingMode.builder().modelId(chatModelId).build())
|
|
.chatRequest(chatRequest)
|
|
.build();
|
|
|
|
ChatResponse response = chatClient.chat(
|
|
ChatRequest.builder().chatDetails(chatDetails).build());
|
|
|
|
var chatResult = (GenericChatResponse) response.getChatResult().getChatResponse();
|
|
var choice = chatResult.getChoices().get(0);
|
|
var content = ((TextContent) choice.getMessage().getContent().get(0)).getText();
|
|
return content.trim();
|
|
}
|
|
|
|
/**
|
|
* Generate embeddings for a list of texts.
|
|
*/
|
|
public List<List<Double>> embedTexts(List<String> texts) {
|
|
if (authProvider == null) throw new IllegalStateException("OCI GenAI not configured");
|
|
|
|
List<List<Double>> allEmbeddings = new ArrayList<>();
|
|
for (int i = 0; i < texts.size(); i += EMBED_BATCH_SIZE) {
|
|
List<String> batch = texts.subList(i, Math.min(i + EMBED_BATCH_SIZE, texts.size()));
|
|
allEmbeddings.addAll(embedBatch(batch));
|
|
}
|
|
return allEmbeddings;
|
|
}
|
|
|
|
private List<List<Double>> embedBatch(List<String> texts) {
|
|
if (embedClient == null) throw new IllegalStateException("OCI GenAI not configured");
|
|
|
|
var embedDetails = EmbedTextDetails.builder()
|
|
.inputs(texts)
|
|
.servingMode(OnDemandServingMode.builder().modelId(embedModelId).build())
|
|
.compartmentId(compartmentId)
|
|
.inputType(EmbedTextDetails.InputType.SearchDocument)
|
|
.build();
|
|
|
|
EmbedTextResponse response = embedClient.embedText(
|
|
EmbedTextRequest.builder().embedTextDetails(embedDetails).build());
|
|
|
|
return response.getEmbedTextResult().getEmbeddings()
|
|
.stream()
|
|
.map(emb -> emb.stream().map(Number::doubleValue).toList())
|
|
.toList();
|
|
}
|
|
|
|
/**
|
|
* Parse LLM response as JSON (handles markdown code blocks, truncated arrays, etc.)
|
|
*/
|
|
public Object parseJson(String raw) {
|
|
// Strip markdown code blocks
|
|
raw = raw.replaceAll("(?m)^```(?:json)?\\s*|\\s*```$", "").trim();
|
|
// Remove trailing commas
|
|
raw = raw.replaceAll(",\\s*([}\\]])", "$1");
|
|
|
|
try {
|
|
return mapper.readValue(raw, Object.class);
|
|
} catch (Exception ignored) {}
|
|
|
|
// Try to recover truncated array
|
|
if (raw.trim().startsWith("[")) {
|
|
List<Object> items = new ArrayList<>();
|
|
int idx = raw.indexOf('[') + 1;
|
|
while (idx < raw.length()) {
|
|
while (idx < raw.length() && " \t\n\r,".indexOf(raw.charAt(idx)) >= 0) idx++;
|
|
if (idx >= raw.length() || raw.charAt(idx) == ']') break;
|
|
|
|
// Try to parse next object
|
|
boolean found = false;
|
|
for (int end = idx + 1; end <= raw.length(); end++) {
|
|
try {
|
|
Object obj = mapper.readValue(raw.substring(idx, end), Object.class);
|
|
items.add(obj);
|
|
idx = end;
|
|
found = true;
|
|
break;
|
|
} catch (Exception ignored2) {}
|
|
}
|
|
if (!found) break;
|
|
}
|
|
if (!items.isEmpty()) {
|
|
log.info("Recovered {} items from truncated JSON", items.size());
|
|
return items;
|
|
}
|
|
}
|
|
|
|
throw new RuntimeException("JSON parse failed: " + raw.substring(0, Math.min(80, raw.length())));
|
|
}
|
|
}
|