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 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 연결 테스트 진입점. * *

{@link #chat(String, String)} 과 달리 실패 시 규칙기반 폴백 문자열을 만들지 않고 {@code null} * 을 반환한다(라우터가 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 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 분석 결과: 현재 시스템 상태를 검토하고 운영 매뉴얼을 참조하세요."; } }