guardia-signage/backend/src/main/java/com/zioinfo/signage/config/OllamaClient.java

120 lines
5.2 KiB
Java

package com.zioinfo.signage.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
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.Map;
/**
* Ollama 온프레미스 AI 클라이언트.
* 보안 불변 규칙: 외부 AI API 절대 금지 — Ollama localhost:11434만 허용.
* 연결 실패 시 폴백 응답 반환(예외 미전파).
*/
@Slf4j
@Component
public class OllamaClient {
@Value("${guardia.ollama-url:http://localhost:11434}")
private String baseUrl;
@Value("${guardia.ollama-text-model:llama3}")
private String model;
private final HttpClient http = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build();
private final ObjectMapper mapper = new ObjectMapper();
public String chat(String prompt) {
return chat(prompt, "ESN AI 이상 분석 전문가 역할입니다.");
}
public String chat(String prompt, String systemPrompt) {
try {
var body = Map.of(
"model", model,
"messages", new Object[]{
Map.of("role", "system", "content", systemPrompt),
Map.of("role", "user", "content", prompt)
},
"stream", false
);
String json = mapper.writeValueAsString(body);
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/api/chat"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json))
.timeout(Duration.ofSeconds(120))
.build();
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() == 200) {
var res = mapper.readTree(resp.body());
return res.path("message").path("content").asText("분석 결과를 생성했습니다.");
}
return fallback(prompt);
} catch (Exception e) {
log.warn("Ollama 연결 실패 — 폴백 응답 반환: {}", e.getMessage());
return fallback(prompt);
}
}
/**
* 평문 텍스트 생성(모델 지정). AiTextRouter/AiConfigService 연결 테스트 진입점.
*
* <p>{@link #chat(String, String)} 과 달리 <b>실패 시 규칙기반 폴백 문자열을 만들지 않고 {@code null}</b>
* 을 반환한다(라우터가 degraded 로 감지해 다음 단계 폴백을 수행하게 하기 위함).
*
* @param prompt 사용자 프롬프트(시스템 지시 포함 가능)
* @param modelOverride 사용할 Ollama 모델(null/blank 이면 서버 기본 {@code guardia.ollama-text-model})
* @return 생성 텍스트(성공) 또는 {@code null}(비200/타임아웃/예외/빈응답)
*/
public String generateText(String prompt, String modelOverride) {
if (prompt == null || prompt.isBlank()) {
return null;
}
String useModel = (modelOverride == null || modelOverride.isBlank()) ? model : modelOverride;
try {
var body = Map.of(
"model", useModel,
"messages", new Object[]{ Map.of("role", "user", "content", prompt) },
"stream", false
);
String json = mapper.writeValueAsString(body);
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/api/chat"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json))
.timeout(Duration.ofSeconds(120))
.build();
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() == 200) {
var res = mapper.readTree(resp.body());
String text = res.path("message").path("content").asText("");
return (text == null || text.isBlank()) ? null : text.trim();
}
log.warn("Ollama generateText status {} — null 반환(라우터 폴백)", resp.statusCode());
return null;
} catch (Exception e) {
log.warn("Ollama generateText 실패 — null 반환(라우터 폴백): {}", e.getClass().getSimpleName());
return null;
}
}
private String fallback(String prompt) {
if (prompt.toLowerCase().contains("alarm") || prompt.contains("알람")) {
return "알람 분석: 장치 연결 상태를 확인하고 네트워크 환경을 점검하세요. 지속 발생 시 현장 엔지니어 파견이 필요합니다.";
}
if (prompt.toLowerCase().contains("pos") || prompt.contains("가격")) {
return "POS 데이터 분류: 정상 가격 변환 데이터입니다. 이상 감지된 항목은 수동 검토가 필요합니다.";
}
return "AI 분석 결과: 현재 시스템 상태를 검토하고 운영 매뉴얼을 참조하세요.";
}
}