feat #565: add vector knowledge ingestion demo
This commit is contained in:
@@ -20,6 +20,17 @@ public record BackofficeProperties(
|
||||
public record Ords(String baseUrl, Duration timeout) {
|
||||
}
|
||||
|
||||
public record Ai(boolean enabled, String baseUrl, String model, String apiKey, Duration timeout) {
|
||||
public record Ai(
|
||||
boolean enabled,
|
||||
String baseUrl,
|
||||
String model,
|
||||
String apiKey,
|
||||
Duration timeout,
|
||||
String embeddingModel
|
||||
) {
|
||||
|
||||
public Ai(boolean enabled, String baseUrl, String model, String apiKey, Duration timeout) {
|
||||
this(enabled, baseUrl, model, apiKey, timeout, "");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.cloudhandson.vpdbackoffice.domain.vector;
|
||||
|
||||
public record VectorChunk(int chunkNo, String text) {
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.cloudhandson.vpdbackoffice.domain.vector;
|
||||
|
||||
public record VectorIngestCommand(
|
||||
String documentId,
|
||||
String title,
|
||||
String sourceUri,
|
||||
String content,
|
||||
String techTags,
|
||||
int chunkSize,
|
||||
String embeddingMode
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.cloudhandson.vpdbackoffice.domain.vector;
|
||||
|
||||
public record VectorIngestResult(
|
||||
String documentId,
|
||||
int chunkCount,
|
||||
int tagCount,
|
||||
String embeddingMode,
|
||||
String embeddingModel
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.cloudhandson.vpdbackoffice.domain.vector;
|
||||
|
||||
public record VectorKnowledgeSummary(
|
||||
int documentCount,
|
||||
int chunkCount,
|
||||
int tagCount,
|
||||
boolean vectorObjectRegistered,
|
||||
String embeddingModel
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.cloudhandson.vpdbackoffice.domain.vector;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.domain.probe.ProbeResult;
|
||||
|
||||
public record VectorSearchResult(
|
||||
String query,
|
||||
String embeddingMode,
|
||||
String embeddingModel,
|
||||
ProbeResult probe
|
||||
) {
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import java.net.URI;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.springframework.boot.web.client.RestTemplateBuilder;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
@@ -46,6 +48,58 @@ public class OpenAiCompatibleClient {
|
||||
return ai == null ? "" : ai.model();
|
||||
}
|
||||
|
||||
public boolean embeddingConfigured() {
|
||||
BackofficeProperties.Ai ai = properties.ai();
|
||||
return ai != null
|
||||
&& ai.enabled()
|
||||
&& hasText(ai.baseUrl())
|
||||
&& hasText(ai.embeddingModel())
|
||||
&& hasText(ai.apiKey());
|
||||
}
|
||||
|
||||
public String embeddingModelName() {
|
||||
BackofficeProperties.Ai ai = properties.ai();
|
||||
return ai == null || ai.embeddingModel() == null ? "" : ai.embeddingModel();
|
||||
}
|
||||
|
||||
public List<Double> embedding(String input) {
|
||||
if (!embeddingConfigured()) {
|
||||
throw new AppException("AI 임베딩 설정이 없습니다. BACKOFFICE_AI_EMBEDDING_MODEL을 설정하거나 데모 임베딩을 선택하세요.");
|
||||
}
|
||||
|
||||
BackofficeProperties.Ai ai = properties.ai();
|
||||
ObjectNode request = objectMapper.createObjectNode();
|
||||
request.put("model", ai.embeddingModel());
|
||||
request.put("input", input == null ? "" : input);
|
||||
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
headers.setBearerAuth(ai.apiKey());
|
||||
Duration timeout = ai.timeout() == null ? Duration.ofSeconds(30) : ai.timeout();
|
||||
RestTemplate restTemplate = restTemplateBuilder
|
||||
.setConnectTimeout(timeout)
|
||||
.setReadTimeout(timeout)
|
||||
.build();
|
||||
ResponseEntity<String> response = restTemplate.postForEntity(
|
||||
embeddingEndpoint(ai.baseUrl()),
|
||||
new HttpEntity<>(request.toString(), headers),
|
||||
String.class
|
||||
);
|
||||
try {
|
||||
JsonNode values = objectMapper.readTree(response.getBody()).path("data").path(0).path("embedding");
|
||||
if (!values.isArray() || values.isEmpty()) {
|
||||
throw new AppException("AI 임베딩 응답에 embedding 배열이 없습니다.");
|
||||
}
|
||||
List<Double> result = new ArrayList<>();
|
||||
values.forEach(value -> result.add(value.asDouble()));
|
||||
return List.copyOf(result);
|
||||
} catch (AppException exception) {
|
||||
throw exception;
|
||||
} catch (Exception exception) {
|
||||
throw new AppException("AI 임베딩 응답을 해석할 수 없습니다: " + exception.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public String chat(String systemPrompt, String userPrompt) {
|
||||
if (!configured()) {
|
||||
throw new AppException("AI 호출 설정이 없습니다.");
|
||||
@@ -100,6 +154,18 @@ public class OpenAiCompatibleClient {
|
||||
return URI.create(withoutSlash + "/v1/chat/completions");
|
||||
}
|
||||
|
||||
private URI embeddingEndpoint(String baseUrl) {
|
||||
String trimmed = baseUrl.trim();
|
||||
if (trimmed.endsWith("/embeddings")) {
|
||||
return URI.create(trimmed);
|
||||
}
|
||||
String withoutSlash = trimmed.endsWith("/") ? trimmed.substring(0, trimmed.length() - 1) : trimmed;
|
||||
if (withoutSlash.endsWith("/v1")) {
|
||||
return URI.create(withoutSlash + "/embeddings");
|
||||
}
|
||||
return URI.create(withoutSlash + "/v1/embeddings");
|
||||
}
|
||||
|
||||
private boolean usesCompletionTokenLimit(String model) {
|
||||
return model != null && model.startsWith("openai.gpt-5.4");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.domain.vector.VectorChunk;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Small, deterministic paragraph/sentence chunker for the demonstration flow.
|
||||
* Production ingestion can replace this with a tokenizer-aware chunker without
|
||||
* changing the tag/VPD data contract.
|
||||
*/
|
||||
public final class VectorChunker {
|
||||
|
||||
private static final int MIN_CHUNK_SIZE = 80;
|
||||
private static final int MAX_CHUNK_SIZE = 2000;
|
||||
|
||||
private VectorChunker() {
|
||||
}
|
||||
|
||||
public static List<VectorChunk> chunk(String content, int requestedSize) {
|
||||
if (content == null || content.isBlank()) {
|
||||
throw new AppException("청킹할 문서 본문을 입력하세요.");
|
||||
}
|
||||
int chunkSize = Math.max(MIN_CHUNK_SIZE, Math.min(requestedSize <= 0 ? 600 : requestedSize, MAX_CHUNK_SIZE));
|
||||
List<VectorChunk> chunks = new ArrayList<>();
|
||||
String normalized = content.replace("\r\n", "\n").replace('\r', '\n').trim();
|
||||
String[] paragraphs = normalized.split("\\n\\s*\\n+");
|
||||
StringBuilder current = new StringBuilder();
|
||||
for (String paragraph : paragraphs) {
|
||||
String trimmed = paragraph.trim();
|
||||
if (trimmed.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
if (current.length() > 0 && current.length() + trimmed.length() + 1 > chunkSize) {
|
||||
appendChunks(chunks, current.toString(), chunkSize);
|
||||
current.setLength(0);
|
||||
}
|
||||
if (current.length() > 0) {
|
||||
current.append('\n');
|
||||
}
|
||||
current.append(trimmed);
|
||||
}
|
||||
if (current.length() > 0) {
|
||||
appendChunks(chunks, current.toString(), chunkSize);
|
||||
}
|
||||
if (chunks.isEmpty()) {
|
||||
throw new AppException("청킹할 문서 본문을 입력하세요.");
|
||||
}
|
||||
List<VectorChunk> numbered = new ArrayList<>();
|
||||
for (int index = 0; index < chunks.size(); index++) {
|
||||
numbered.add(new VectorChunk(index + 1, chunks.get(index).text()));
|
||||
}
|
||||
return List.copyOf(numbered);
|
||||
}
|
||||
|
||||
private static void appendChunks(List<VectorChunk> chunks, String text, int chunkSize) {
|
||||
String remaining = text.trim();
|
||||
while (remaining.length() > chunkSize) {
|
||||
int cut = lastBreak(remaining, chunkSize);
|
||||
chunks.add(new VectorChunk(0, remaining.substring(0, cut).trim()));
|
||||
remaining = remaining.substring(cut).trim();
|
||||
}
|
||||
if (!remaining.isEmpty()) {
|
||||
chunks.add(new VectorChunk(0, remaining));
|
||||
}
|
||||
}
|
||||
|
||||
private static int lastBreak(String text, int chunkSize) {
|
||||
int sentence = Math.max(text.lastIndexOf('.', chunkSize), text.lastIndexOf('。', chunkSize));
|
||||
if (sentence >= chunkSize / 2) {
|
||||
return sentence + 1;
|
||||
}
|
||||
int whitespace = text.lastIndexOf(' ', chunkSize);
|
||||
return whitespace >= chunkSize / 2 ? whitespace : chunkSize;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.domain.probe.ProbeCommand;
|
||||
import com.cloudhandson.vpdbackoffice.domain.probe.ProbeResult;
|
||||
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject;
|
||||
import com.cloudhandson.vpdbackoffice.domain.token.IssuedToken;
|
||||
import com.cloudhandson.vpdbackoffice.domain.vector.VectorChunk;
|
||||
import com.cloudhandson.vpdbackoffice.domain.vector.VectorIngestCommand;
|
||||
import com.cloudhandson.vpdbackoffice.domain.vector.VectorIngestResult;
|
||||
import com.cloudhandson.vpdbackoffice.domain.vector.VectorKnowledgeSummary;
|
||||
import com.cloudhandson.vpdbackoffice.domain.vector.VectorSearchResult;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
import org.springframework.dao.DataAccessException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
public class VectorKnowledgeService {
|
||||
|
||||
public static final String VECTOR_OBJECT = "CB_VECTOR_SEARCH_DOCUMENTS";
|
||||
public static final String DEMO_MODE = "DEMO";
|
||||
public static final String AI_MODE = "AI";
|
||||
|
||||
private static final Pattern TAG_PATTERN = Pattern.compile("[A-Z0-9_-]+");
|
||||
private static final int MAX_DOCUMENT_LENGTH = 500_000;
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final OpenAiCompatibleClient embeddingClient;
|
||||
private final ProtectedObjectService protectedObjectService;
|
||||
private final BearerTokenService tokenService;
|
||||
private final OrdsProbeService ordsProbeService;
|
||||
|
||||
public VectorKnowledgeService(
|
||||
JdbcTemplate jdbcTemplate,
|
||||
ObjectMapper objectMapper,
|
||||
OpenAiCompatibleClient embeddingClient,
|
||||
ProtectedObjectService protectedObjectService,
|
||||
BearerTokenService tokenService,
|
||||
OrdsProbeService ordsProbeService
|
||||
) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
this.objectMapper = objectMapper;
|
||||
this.embeddingClient = embeddingClient;
|
||||
this.protectedObjectService = protectedObjectService;
|
||||
this.tokenService = tokenService;
|
||||
this.ordsProbeService = ordsProbeService;
|
||||
}
|
||||
|
||||
public VectorKnowledgeSummary summary() {
|
||||
int documents = count("SELECT COUNT(DISTINCT document_id) FROM cb_vector_document_chunk");
|
||||
int chunks = count("SELECT COUNT(*) FROM cb_vector_document_chunk");
|
||||
int tags = count("SELECT COUNT(*) FROM cb_vector_document_tag");
|
||||
boolean registered = protectedObjectService.findEnabled().stream()
|
||||
.anyMatch(object -> VECTOR_OBJECT.equalsIgnoreCase(object.objectName()));
|
||||
return new VectorKnowledgeSummary(
|
||||
documents,
|
||||
chunks,
|
||||
tags,
|
||||
registered,
|
||||
embeddingClient.embeddingConfigured() ? embeddingClient.embeddingModelName() : "DEMO-4D"
|
||||
);
|
||||
}
|
||||
|
||||
public boolean aiEmbeddingConfigured() {
|
||||
return embeddingClient.embeddingConfigured();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public VectorIngestResult ingest(VectorIngestCommand command) {
|
||||
String documentId = requiredDocumentId(command.documentId());
|
||||
String title = required(command.title(), "문서 제목");
|
||||
String content = required(command.content(), "문서 본문");
|
||||
if (content.length() > MAX_DOCUMENT_LENGTH) {
|
||||
throw new AppException("문서 본문은 " + MAX_DOCUMENT_LENGTH + "자 이내로 입력하세요.");
|
||||
}
|
||||
String sourceUri = command.sourceUri() == null || command.sourceUri().isBlank()
|
||||
? "kb://backoffice/" + documentId
|
||||
: command.sourceUri().trim();
|
||||
Set<String> tags = normalizeTags(command.techTags());
|
||||
String mode = normalizeMode(command.embeddingMode());
|
||||
List<VectorChunk> chunks = VectorChunker.chunk(content, command.chunkSize());
|
||||
|
||||
replaceDocument(documentId);
|
||||
long nextChunkId = nextChunkId(chunks.size());
|
||||
int tagCount = 0;
|
||||
for (VectorChunk chunk : chunks) {
|
||||
List<Double> embedding = embed(chunk.text(), mode);
|
||||
String embeddingJson = json(embedding);
|
||||
long chunkId = nextChunkId++;
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO cb_vector_document_chunk
|
||||
(chunk_id, document_id, chunk_no, title, chunk_text, source_uri, embedding)
|
||||
VALUES (?, ?, ?, ?, ?, ?, TO_VECTOR(?))
|
||||
""", chunkId, documentId, chunk.chunkNo(), title, chunk.text(), sourceUri, embeddingJson);
|
||||
for (String tag : tags) {
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO cb_vector_document_tag (chunk_id, tech_tag)
|
||||
VALUES (?, ?)
|
||||
""", chunkId, tag);
|
||||
tagCount++;
|
||||
}
|
||||
}
|
||||
return new VectorIngestResult(documentId, chunks.size(), tagCount, mode, embeddingModel(mode));
|
||||
}
|
||||
|
||||
public VectorSearchResult search(long userId, String query, int limit, String embeddingMode) {
|
||||
String normalizedQuery = required(query, "검색 질문");
|
||||
ProtectedObject vectorObject = protectedObjectService.findEnabled().stream()
|
||||
.filter(object -> VECTOR_OBJECT.equalsIgnoreCase(object.objectName()))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new AppException(
|
||||
"CB_VECTOR_SEARCH_DOCUMENTS 보호 객체가 없습니다. 28_agent_ords_vector_tag_vpd_setup.sql을 먼저 실행하세요."));
|
||||
String mode = normalizeMode(embeddingMode);
|
||||
String requestBody = "{\"embedding\":" + json(embed(normalizedQuery, mode)) + "}";
|
||||
IssuedToken temporary = tokenService.issueTemporaryToken(userId, "벡터 지식자료 검색 임시 실행");
|
||||
try {
|
||||
ProbeResult probe = ordsProbeService.runProbe(new ProbeCommand(
|
||||
temporary.keyId(), vectorObject.objectId(), temporary.plainToken(), normalizeLimit(limit), requestBody));
|
||||
return new VectorSearchResult(normalizedQuery, mode, embeddingModel(mode), probe);
|
||||
} finally {
|
||||
tokenService.revokeToken(temporary.keyId(), "temporary vector search completed");
|
||||
}
|
||||
}
|
||||
|
||||
private void replaceDocument(String documentId) {
|
||||
jdbcTemplate.update("""
|
||||
DELETE FROM cb_vector_document_tag
|
||||
WHERE chunk_id IN (
|
||||
SELECT chunk_id FROM cb_vector_document_chunk WHERE document_id = ?
|
||||
)
|
||||
""", documentId);
|
||||
jdbcTemplate.update("DELETE FROM cb_vector_document_chunk WHERE document_id = ?", documentId);
|
||||
}
|
||||
|
||||
private long nextChunkId(int chunkCount) {
|
||||
try {
|
||||
Long sequenceValue = jdbcTemplate.queryForObject(
|
||||
"SELECT cb_vector_chunk_seq.NEXTVAL FROM dual", Long.class);
|
||||
if (sequenceValue != null) {
|
||||
return sequenceValue;
|
||||
}
|
||||
} catch (DataAccessException ignored) {
|
||||
// Older installations may not have the optional sequence yet. The
|
||||
// fallback keeps the demonstration usable; the setup SQL creates it.
|
||||
}
|
||||
Long max = jdbcTemplate.queryForObject(
|
||||
"SELECT NVL(MAX(chunk_id), 28000) FROM cb_vector_document_chunk", Long.class);
|
||||
return (max == null ? 28000 : max) + 1;
|
||||
}
|
||||
|
||||
private List<Double> embed(String input, String mode) {
|
||||
if (AI_MODE.equals(mode)) {
|
||||
return embeddingClient.embedding(input);
|
||||
}
|
||||
return demoEmbedding(input);
|
||||
}
|
||||
|
||||
private List<Double> demoEmbedding(String input) {
|
||||
double[] vector = new double[4];
|
||||
String normalized = input.toLowerCase(Locale.ROOT);
|
||||
addIfContains(vector, normalized, 0, "spring", "boot", "java", "security");
|
||||
addIfContains(vector, normalized, 1, "oracle", "vpd", "policy", "database", "db");
|
||||
addIfContains(vector, normalized, 2, "ords", "rest", "handler", "http", "api");
|
||||
addIfContains(vector, normalized, 3, "mcp", "tool", "권한", "tag", "태그");
|
||||
for (String token : normalized.split("[^a-z0-9가-힣]+")) {
|
||||
if (token.length() >= 3) {
|
||||
vector[Math.floorMod(token.hashCode(), vector.length)] += 0.03;
|
||||
}
|
||||
}
|
||||
double length = 0;
|
||||
for (double value : vector) {
|
||||
length += value * value;
|
||||
}
|
||||
if (length == 0) {
|
||||
vector[0] = 1;
|
||||
length = 1;
|
||||
}
|
||||
double scale = Math.sqrt(length);
|
||||
List<Double> result = new ArrayList<>(vector.length);
|
||||
for (double value : vector) {
|
||||
result.add(value / scale);
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
private void addIfContains(double[] vector, String input, int index, String... terms) {
|
||||
for (String term : terms) {
|
||||
if (input.contains(term)) {
|
||||
vector[index] += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Set<String> normalizeTags(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new AppException("기술 태그를 하나 이상 입력하세요.");
|
||||
}
|
||||
Set<String> tags = new LinkedHashSet<>();
|
||||
for (String raw : value.split("[,\\s]+")) {
|
||||
String tag = raw.trim().toUpperCase(Locale.ROOT);
|
||||
if (tag.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
if (!TAG_PATTERN.matcher(tag).matches()) {
|
||||
throw new AppException("기술 태그는 영문 대문자, 숫자, '_' 또는 '-'만 사용할 수 있습니다: " + tag);
|
||||
}
|
||||
tags.add(tag);
|
||||
}
|
||||
if (tags.isEmpty()) {
|
||||
throw new AppException("기술 태그를 하나 이상 입력하세요.");
|
||||
}
|
||||
return Set.copyOf(tags);
|
||||
}
|
||||
|
||||
private String normalizeMode(String mode) {
|
||||
String normalized = mode == null ? DEMO_MODE : mode.trim().toUpperCase(Locale.ROOT);
|
||||
if (!DEMO_MODE.equals(normalized) && !AI_MODE.equals(normalized)) {
|
||||
throw new AppException("임베딩 방식은 DEMO 또는 AI만 사용할 수 있습니다.");
|
||||
}
|
||||
if (AI_MODE.equals(normalized) && !embeddingClient.embeddingConfigured()) {
|
||||
throw new AppException("AI 임베딩이 설정되지 않았습니다. 설정에서 embedding model/API key를 추가하거나 DEMO 임베딩을 선택하세요.");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private String embeddingModel(String mode) {
|
||||
return AI_MODE.equals(mode) ? embeddingClient.embeddingModelName() : "DEMO-4D";
|
||||
}
|
||||
|
||||
private String json(List<Double> values) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(values);
|
||||
} catch (Exception exception) {
|
||||
throw new AppException("임베딩 JSON 생성에 실패했습니다: " + exception.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private int count(String sql) {
|
||||
Integer count = jdbcTemplate.queryForObject(sql, Integer.class);
|
||||
return count == null ? 0 : count;
|
||||
}
|
||||
|
||||
private int normalizeLimit(int limit) {
|
||||
return Math.min(Math.max(limit, 1), 100);
|
||||
}
|
||||
|
||||
private String required(String value, String label) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new AppException(label + "을 입력하세요.");
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
private String requiredDocumentId(String value) {
|
||||
String documentId = required(value, "문서 ID");
|
||||
if (documentId.length() > 200 || !documentId.matches("[A-Za-z0-9_.:-]+")) {
|
||||
throw new AppException("문서 ID는 영문·숫자와 '.', '_', ':', '-'만 사용할 수 있습니다.");
|
||||
}
|
||||
return documentId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.cloudhandson.vpdbackoffice.web;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.domain.vector.VectorIngestCommand;
|
||||
import com.cloudhandson.vpdbackoffice.domain.vector.VectorIngestResult;
|
||||
import com.cloudhandson.vpdbackoffice.service.VectorKnowledgeService;
|
||||
import com.cloudhandson.vpdbackoffice.mapper.UserMapper;
|
||||
import org.springframework.dao.DataAccessException;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
|
||||
@Controller
|
||||
public class VectorKnowledgeController {
|
||||
|
||||
private final VectorKnowledgeService vectorKnowledgeService;
|
||||
private final UserMapper userMapper;
|
||||
|
||||
public VectorKnowledgeController(VectorKnowledgeService vectorKnowledgeService, UserMapper userMapper) {
|
||||
this.vectorKnowledgeService = vectorKnowledgeService;
|
||||
this.userMapper = userMapper;
|
||||
}
|
||||
|
||||
@GetMapping("/vector-knowledge")
|
||||
public String page(Model model) {
|
||||
model.addAttribute("users", userMapper.findAll());
|
||||
model.addAttribute("aiEmbeddingConfigured", vectorKnowledgeService.aiEmbeddingConfigured());
|
||||
try {
|
||||
model.addAttribute("summary", vectorKnowledgeService.summary());
|
||||
} catch (DataAccessException exception) {
|
||||
RuntimeErrorMessage error = RuntimeErrorMessages.dataAccess(exception);
|
||||
model.addAttribute("summary", null);
|
||||
model.addAttribute("runtimeError", error);
|
||||
}
|
||||
return "vector-knowledge";
|
||||
}
|
||||
|
||||
@PostMapping("/vector-knowledge/ingest")
|
||||
public String ingest(
|
||||
@RequestParam String documentId,
|
||||
@RequestParam String title,
|
||||
@RequestParam(required = false, defaultValue = "") String sourceUri,
|
||||
@RequestParam String content,
|
||||
@RequestParam String techTags,
|
||||
@RequestParam(defaultValue = "600") int chunkSize,
|
||||
@RequestParam(defaultValue = "DEMO") String embeddingMode,
|
||||
RedirectAttributes redirectAttributes
|
||||
) {
|
||||
try {
|
||||
VectorIngestResult result = vectorKnowledgeService.ingest(new VectorIngestCommand(
|
||||
documentId, title, sourceUri, content, techTags, chunkSize, embeddingMode));
|
||||
redirectAttributes.addFlashAttribute("ingestResult", result);
|
||||
redirectAttributes.addFlashAttribute("message",
|
||||
result.chunkCount() + "개 청크를 저장했습니다. 태그 " + result.tagCount() + "개가 각 청크에 연결되었습니다.");
|
||||
} catch (Exception exception) {
|
||||
redirectAttributes.addFlashAttribute("errorMessage", exception.getMessage());
|
||||
}
|
||||
return "redirect:/vector-knowledge";
|
||||
}
|
||||
|
||||
@PostMapping("/vector-knowledge/search")
|
||||
public String search(
|
||||
@RequestParam long userId,
|
||||
@RequestParam String query,
|
||||
@RequestParam(defaultValue = "10") int limit,
|
||||
@RequestParam(defaultValue = "DEMO") String embeddingMode,
|
||||
Model model
|
||||
) {
|
||||
try {
|
||||
model.addAttribute("searchResult", vectorKnowledgeService.search(userId, query, limit, embeddingMode));
|
||||
} catch (Exception exception) {
|
||||
model.addAttribute("errorMessage", exception.getMessage());
|
||||
}
|
||||
return "fragments/vector-search-result :: result";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user