refs #739: align Smilegate with application repository layout

This commit is contained in:
devmrko
2026-08-03 12:37:38 +09:00
parent 4d2964b5c6
commit 03bf0e096c
13 changed files with 217 additions and 390 deletions

View File

@@ -7,16 +7,9 @@ after each review.
from __future__ import annotations from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
import streamlit as st import streamlit as st
from src.smilegate_demo.ui.shell import render_blank_shell from ai_web_agent_console.smilegate_demo.ui.shell import render_blank_shell
def main() -> None: def main() -> None:

View File

@@ -2,13 +2,16 @@ package com.cloudhandson.vpdbackoffice.config;
import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.ConfigurationProperties;
/** Product-neutral labels and endpoint details for the MCP Select AI tool. */ /** Product-neutral MCP endpoint labels and tool catalogue configuration. */
@ConfigurationProperties(prefix = "backoffice.mcp") @ConfigurationProperties(prefix = "backoffice.mcp")
public record McpProperties( public record McpProperties(
String publicUrl,
String serverName,
String toolName, String toolName,
String toolLabel, String toolLabel,
String toolDescription, String toolDescription,
String promptDescription, String promptDescription,
String tools,
String showpromptToolName, String showpromptToolName,
String showpromptToolLabel, String showpromptToolLabel,
String showpromptToolDescription, String showpromptToolDescription,
@@ -26,45 +29,29 @@ public record McpProperties(
String gameScopeToolDescription String gameScopeToolDescription
) { ) {
private static final String DEFAULT_PUBLIC_URL = "/mcp";
private static final String DEFAULT_SERVER_NAME = "data-ai-backoffice";
private static final String DEFAULT_TOOL_NAME = "oracle.select_ai.data_text2sql"; private static final String DEFAULT_TOOL_NAME = "oracle.select_ai.data_text2sql";
private static final String DEFAULT_TOOL_LABEL = "업무 데이터 Text2SQL"; private static final String DEFAULT_TOOL_LABEL = "업무 데이터 Text2SQL";
private static final String DEFAULT_TOOL_DESCRIPTION = private static final String DEFAULT_TOOL_DESCRIPTION =
"승인된 업무 데이터용 읽기 전용 SELECT/WITH SQL을 생성하고, 검증 후 읽기 전용 트랜잭션에서 실행합니다. " "승인된 업무 데이터용 읽기 전용 SELECT/WITH SQL을 생성하고, 검증 후 읽기 전용 "
+ "생성 SQL과 최대 100건의 조회 결과를 함께 반환하며 DDL/DML/잠금/패키지 호출은 실행하지 않습니다."; + "트랜잭션에서 실행합니다.";
private static final String DEFAULT_PROMPT_DESCRIPTION = private static final String DEFAULT_PROMPT_DESCRIPTION =
"업무 데이터에서 조회할 내용을 자연어로 입력합니다."; "업무 데이터에서 조회할 내용을 자연어로 입력합니다.";
private static final String DEFAULT_SHOWPROMPT_TOOL_NAME =
"oracle.select_ai.data_showprompt"; private static final String DEFAULT_SHOWPROMPT_TOOL_NAME = "oracle.select_ai.data_showprompt";
private static final String DEFAULT_SHOWPROMPT_TOOL_LABEL = private static final String DEFAULT_QA_VECTOR_SEARCH_TOOL_NAME = "oracle.select_ai.qa_vector_search";
"업무 데이터 SHOWPROMPT"; private static final String DEFAULT_QA_VECTOR_STORE_TOOL_NAME = "oracle.select_ai.qa_vector_store";
private static final String DEFAULT_SHOWPROMPT_TOOL_DESCRIPTION = private static final String DEFAULT_FEW_SHOT_NL2SQL_TOOL_NAME = "oracle.select_ai.smilegate_fewshot_nl2sql";
"Select AI가 SQL 생성에 사용한 prompt를 조회하는 읽기 전용 진단 도구입니다. " private static final String DEFAULT_GAME_SCOPE_TOOL_NAME = "oracle.select_ai.game_scope_resolve";
+ "생성 SQL이나 데이터 조회 SQL은 실행하지 않습니다.";
private static final String DEFAULT_QA_VECTOR_SEARCH_TOOL_NAME = public String resolvedPublicUrl() {
"oracle.select_ai.qa_vector_search"; return requiredOrDefault(publicUrl, DEFAULT_PUBLIC_URL);
private static final String DEFAULT_QA_VECTOR_SEARCH_TOOL_LABEL = "Select AI 예제 SQL 조회"; }
private static final String DEFAULT_QA_VECTOR_SEARCH_TOOL_DESCRIPTION =
"현재 질문에 사용할 유사 예제 SQL을 Select AI 실행 전에 조회합니다. " public String resolvedServerName() {
+ "반환값은 few-shot 컨텍스트 검토용이며 SQL을 실행하지 않습니다."; return requiredOrDefault(serverName, DEFAULT_SERVER_NAME);
private static final String DEFAULT_QA_VECTOR_STORE_TOOL_NAME = }
"oracle.select_ai.qa_vector_store";
private static final String DEFAULT_QA_VECTOR_STORE_TOOL_LABEL = "Select AI 예제 SQL 저장";
private static final String DEFAULT_QA_VECTOR_STORE_TOOL_DESCRIPTION =
"검토된 Select AI 결과를 후속 Text2SQL 품질 향상용 예제 SQL로 저장합니다. "
+ "질문과 읽기 전용 답 SQL이 필요합니다.";
private static final String DEFAULT_FEW_SHOT_NL2SQL_TOOL_NAME =
"oracle.select_ai.smilegate_fewshot_nl2sql";
private static final String DEFAULT_FEW_SHOT_NL2SQL_TOOL_LABEL =
"Few-shot NL2SQL 실행";
private static final String DEFAULT_FEW_SHOT_NL2SQL_TOOL_DESCRIPTION =
"벡터 Few-shot 예제를 찾아 prompt에 반영하고, SHOWSQL로 생성한 읽기 전용 SQL을 실행합니다. "
+ "Few-shot 근거, 생성 SQL, 실행 결과를 함께 반환합니다.";
private static final String DEFAULT_GAME_SCOPE_TOOL_NAME =
"oracle.select_ai.game_scope_resolve";
private static final String DEFAULT_GAME_SCOPE_TOOL_LABEL = "게임 조회 범위 확인";
private static final String DEFAULT_GAME_SCOPE_TOOL_DESCRIPTION =
"질문에서 언급된 게임 별칭을 DB 게임 범위 view로 확인합니다. 데이터 SQL은 실행하지 않으며, "
+ "반환된 SUPPORTED scope에만 Few-shot NL2SQL을 호출하세요.";
public String resolvedToolName() { public String resolvedToolName() {
return requiredOrDefault(toolName, DEFAULT_TOOL_NAME); return requiredOrDefault(toolName, DEFAULT_TOOL_NAME);
@@ -82,65 +69,21 @@ public record McpProperties(
return requiredOrDefault(promptDescription, DEFAULT_PROMPT_DESCRIPTION); return requiredOrDefault(promptDescription, DEFAULT_PROMPT_DESCRIPTION);
} }
public String resolvedShowpromptToolName() { public String resolvedShowpromptToolName() { return requiredOrDefault(showpromptToolName, DEFAULT_SHOWPROMPT_TOOL_NAME); }
return requiredOrDefault(showpromptToolName, DEFAULT_SHOWPROMPT_TOOL_NAME); public String resolvedShowpromptToolLabel() { return requiredOrDefault(showpromptToolLabel, "업무 데이터 SHOWPROMPT"); }
} public String resolvedShowpromptToolDescription() { return requiredOrDefault(showpromptToolDescription, "Select AI SQL 생성 prompt를 조회하는 읽기 전용 진단 도구입니다."); }
public String resolvedQaVectorSearchToolName() { return requiredOrDefault(qaVectorSearchToolName, DEFAULT_QA_VECTOR_SEARCH_TOOL_NAME); }
public String resolvedShowpromptToolLabel() { public String resolvedQaVectorSearchToolLabel() { return requiredOrDefault(qaVectorSearchToolLabel, "Select AI 예제 SQL 조회"); }
return requiredOrDefault(showpromptToolLabel, DEFAULT_SHOWPROMPT_TOOL_LABEL); public String resolvedQaVectorSearchToolDescription() { return requiredOrDefault(qaVectorSearchToolDescription, "현재 질문에 사용할 유사 예제 SQL을 조회합니다."); }
} public String resolvedQaVectorStoreToolName() { return requiredOrDefault(qaVectorStoreToolName, DEFAULT_QA_VECTOR_STORE_TOOL_NAME); }
public String resolvedQaVectorStoreToolLabel() { return requiredOrDefault(qaVectorStoreToolLabel, "Select AI 예제 SQL 저장"); }
public String resolvedShowpromptToolDescription() { public String resolvedQaVectorStoreToolDescription() { return requiredOrDefault(qaVectorStoreToolDescription, "검토된 읽기 전용 답 SQL을 예제로 저장합니다."); }
return requiredOrDefault(showpromptToolDescription, DEFAULT_SHOWPROMPT_TOOL_DESCRIPTION); public String resolvedFewShotNl2SqlToolName() { return requiredOrDefault(fewShotNl2SqlToolName, DEFAULT_FEW_SHOT_NL2SQL_TOOL_NAME); }
} public String resolvedFewShotNl2SqlToolLabel() { return requiredOrDefault(fewShotNl2SqlToolLabel, "Few-shot NL2SQL 실행"); }
public String resolvedFewShotNl2SqlToolDescription() { return requiredOrDefault(fewShotNl2SqlToolDescription, "검증된 Few-shot 예제를 참고해 읽기 전용 SQL을 생성·실행합니다."); }
public String resolvedQaVectorSearchToolName() { public String resolvedGameScopeToolName() { return requiredOrDefault(gameScopeToolName, DEFAULT_GAME_SCOPE_TOOL_NAME); }
return requiredOrDefault(qaVectorSearchToolName, DEFAULT_QA_VECTOR_SEARCH_TOOL_NAME); public String resolvedGameScopeToolLabel() { return requiredOrDefault(gameScopeToolLabel, "게임 조회 범위 확인"); }
} public String resolvedGameScopeToolDescription() { return requiredOrDefault(gameScopeToolDescription, "질문의 게임 별칭과 데이터 조회 범위를 확인합니다."); }
public String resolvedQaVectorSearchToolLabel() {
return requiredOrDefault(qaVectorSearchToolLabel, DEFAULT_QA_VECTOR_SEARCH_TOOL_LABEL);
}
public String resolvedQaVectorSearchToolDescription() {
return requiredOrDefault(qaVectorSearchToolDescription, DEFAULT_QA_VECTOR_SEARCH_TOOL_DESCRIPTION);
}
public String resolvedQaVectorStoreToolName() {
return requiredOrDefault(qaVectorStoreToolName, DEFAULT_QA_VECTOR_STORE_TOOL_NAME);
}
public String resolvedQaVectorStoreToolLabel() {
return requiredOrDefault(qaVectorStoreToolLabel, DEFAULT_QA_VECTOR_STORE_TOOL_LABEL);
}
public String resolvedQaVectorStoreToolDescription() {
return requiredOrDefault(qaVectorStoreToolDescription, DEFAULT_QA_VECTOR_STORE_TOOL_DESCRIPTION);
}
public String resolvedFewShotNl2SqlToolName() {
return requiredOrDefault(fewShotNl2SqlToolName, DEFAULT_FEW_SHOT_NL2SQL_TOOL_NAME);
}
public String resolvedFewShotNl2SqlToolLabel() {
return requiredOrDefault(fewShotNl2SqlToolLabel, DEFAULT_FEW_SHOT_NL2SQL_TOOL_LABEL);
}
public String resolvedFewShotNl2SqlToolDescription() {
return requiredOrDefault(fewShotNl2SqlToolDescription, DEFAULT_FEW_SHOT_NL2SQL_TOOL_DESCRIPTION);
}
public String resolvedGameScopeToolName() {
return requiredOrDefault(gameScopeToolName, DEFAULT_GAME_SCOPE_TOOL_NAME);
}
public String resolvedGameScopeToolLabel() {
return requiredOrDefault(gameScopeToolLabel, DEFAULT_GAME_SCOPE_TOOL_LABEL);
}
public String resolvedGameScopeToolDescription() {
return requiredOrDefault(gameScopeToolDescription, DEFAULT_GAME_SCOPE_TOOL_DESCRIPTION);
}
private String requiredOrDefault(String value, String fallback) { private String requiredOrDefault(String value, String fallback) {
return value == null || value.isBlank() ? fallback : value.trim(); return value == null || value.isBlank() ? fallback : value.trim();

View File

@@ -1,13 +1,21 @@
package com.cloudhandson.vpdbackoffice.domain.structured; package com.cloudhandson.vpdbackoffice.domain.structured;
import java.util.List;
public record StructuredDataTable( public record StructuredDataTable(
String key, String key,
String tableName, String tableName,
String objectType, String objectType,
String businessName, String businessName,
String description String description,
List<String> previewColumns
) { ) {
public StructuredDataTable(String key, String tableName, String businessName, String description) { public StructuredDataTable(String key, String tableName, String businessName, String description) {
this(key, tableName, "TABLE", businessName, description); this(key, tableName, "TABLE", businessName, description, List.of());
}
public boolean isTable() {
return "TABLE".equals(objectType);
} }
} }

View File

@@ -1,25 +1,20 @@
package com.cloudhandson.vpdbackoffice.service; package com.cloudhandson.vpdbackoffice.service;
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaAnnotation; import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaAnnotation;
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataAnnotationRow;
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataColumn; import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataColumn;
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataColumnRow;
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataView; import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataView;
import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataTable; import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataTable;
import com.cloudhandson.vpdbackoffice.mapper.SchemaMetadataMapper;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
/**
* Screen-level orchestration for schema metadata. Database reads and DDL are
* intentionally delegated only to {@link SchemaMetadataMapper}.
*/
@Service @Service
public class SchemaMetadataService { public class SchemaMetadataService {
@@ -27,17 +22,12 @@ public class SchemaMetadataService {
private static final int MAX_ANNOTATION_VALUE_LENGTH = 4000; private static final int MAX_ANNOTATION_VALUE_LENGTH = 4000;
private static final Pattern ORACLE_SIMPLE_NAME = Pattern.compile("[A-Z][A-Z0-9_$#]{0,127}"); private static final Pattern ORACLE_SIMPLE_NAME = Pattern.compile("[A-Z][A-Z0-9_$#]{0,127}");
private final SchemaMetadataMapper mapper; private final JdbcTemplate jdbcTemplate;
private final StructuredDataService structuredDataService; private final StructuredDataService structuredDataService;
private final DataCatalog catalog;
public SchemaMetadataService( public SchemaMetadataService(JdbcTemplate jdbcTemplate, StructuredDataService structuredDataService) {
SchemaMetadataMapper mapper, this.jdbcTemplate = jdbcTemplate;
StructuredDataService structuredDataService, DataCatalog catalog
) {
this.mapper = mapper;
this.structuredDataService = structuredDataService; this.structuredDataService = structuredDataService;
this.catalog = catalog;
} }
public List<StructuredDataTable> tables() { public List<StructuredDataTable> tables() {
@@ -54,12 +44,14 @@ public class SchemaMetadataService {
public SchemaMetadataView find(String tableKey) { public SchemaMetadataView find(String tableKey) {
StructuredDataTable table = structuredDataService.requireTable(tableKey); StructuredDataTable table = structuredDataService.requireTable(tableKey);
String tableName = requireSimpleName(table.tableName(), "table name"); String tableName = table.tableName();
Map<String, List<SchemaAnnotation>> annotations = annotationsByTarget(tableName); String tableComment = tableComment(tableName);
Map<String, List<SchemaAnnotation>> annotations =
table.isTable() ? annotationsByTarget(tableName) : Map.of();
List<SchemaMetadataColumn> columns = columns(tableName, annotations); List<SchemaMetadataColumn> columns = columns(tableName, annotations);
return new SchemaMetadataView( return new SchemaMetadataView(
table, table,
nullToEmpty(mapper.findTableComment(catalog.owner(), tableName)), nullToEmpty(tableComment),
annotations.getOrDefault(tableTargetKey(), List.of()), annotations.getOrDefault(tableTargetKey(), List.of()),
columns columns
); );
@@ -68,24 +60,25 @@ public class SchemaMetadataService {
@Transactional @Transactional
public void updateTableComment(String tableKey, String comment) { public void updateTableComment(String tableKey, String comment) {
StructuredDataTable table = structuredDataService.requireTable(tableKey); StructuredDataTable table = structuredDataService.requireTable(tableKey);
String tableName = requireSimpleName(table.tableName(), "table name");
String normalizedComment = normalizeText(comment, MAX_COMMENT_LENGTH, "테이블 comment"); String normalizedComment = normalizeText(comment, MAX_COMMENT_LENGTH, "테이블 comment");
mapper.updateTableComment(catalog.owner(), tableName, quoteLiteral(normalizedComment)); jdbcTemplate.execute("COMMENT ON TABLE " + qualifiedTable(table.tableName())
+ " IS " + quoteLiteral(normalizedComment));
} }
@Transactional @Transactional
public void updateColumnComment(String tableKey, String columnName, String comment) { public void updateColumnComment(String tableKey, String columnName, String comment) {
StructuredDataTable table = structuredDataService.requireTable(tableKey); StructuredDataTable table = structuredDataService.requireTable(tableKey);
String tableName = requireSimpleName(table.tableName(), "table name"); String column = requireColumn(table.tableName(), columnName);
String column = requireColumn(tableName, columnName);
String normalizedComment = normalizeText(comment, MAX_COMMENT_LENGTH, "컬럼 comment"); String normalizedComment = normalizeText(comment, MAX_COMMENT_LENGTH, "컬럼 comment");
mapper.updateColumnComment(catalog.owner(), tableName, column, quoteLiteral(normalizedComment)); jdbcTemplate.execute("COMMENT ON COLUMN " + qualifiedTable(table.tableName()) + "."
+ quoteName(column) + " IS " + quoteLiteral(normalizedComment));
} }
@Transactional @Transactional
public void updateTableAnnotation(String tableKey, String annotationName, String annotationValue) { public void updateTableAnnotation(String tableKey, String annotationName, String annotationValue) {
StructuredDataTable table = structuredDataService.requireTable(tableKey); StructuredDataTable table = structuredDataService.requireTable(tableKey);
updateAnnotation(requireSimpleName(table.tableName(), "table name"), null, annotationName, annotationValue); requireAnnotationTable(table);
updateAnnotation(table.tableName(), null, annotationName, annotationValue);
} }
@Transactional @Transactional
@@ -96,9 +89,9 @@ public class SchemaMetadataService {
String annotationValue String annotationValue
) { ) {
StructuredDataTable table = structuredDataService.requireTable(tableKey); StructuredDataTable table = structuredDataService.requireTable(tableKey);
String tableName = requireSimpleName(table.tableName(), "table name"); requireAnnotationTable(table);
String column = requireColumn(tableName, columnName); String column = requireColumn(table.tableName(), columnName);
updateAnnotation(tableName, column, annotationName, annotationValue); updateAnnotation(table.tableName(), column, annotationName, annotationValue);
} }
private void updateAnnotation( private void updateAnnotation(
@@ -110,55 +103,75 @@ public class SchemaMetadataService {
String key = requireSimpleName(annotationName, "annotation name"); String key = requireSimpleName(annotationName, "annotation name");
String value = normalizeText(annotationValue, MAX_ANNOTATION_VALUE_LENGTH, "annotation value"); String value = normalizeText(annotationValue, MAX_ANNOTATION_VALUE_LENGTH, "annotation value");
if (annotationExists(tableName, columnName, key)) { if (annotationExists(tableName, columnName, key)) {
if (columnName == null) { jdbcTemplate.execute(annotationSql(tableName, columnName, "DROP " + quoteName(key)));
mapper.dropTableAnnotation(catalog.owner(), tableName, key);
} else {
mapper.dropColumnAnnotation(catalog.owner(), tableName, columnName, key);
}
} }
if (!value.isBlank()) { if (!value.isBlank()) {
if (columnName == null) { jdbcTemplate.execute(annotationSql(tableName, columnName,
mapper.addTableAnnotation(catalog.owner(), tableName, key, quoteLiteral(value)); "ADD " + quoteName(key) + " " + quoteLiteral(value)));
} else {
mapper.addColumnAnnotation(catalog.owner(), tableName, columnName, key, quoteLiteral(value));
} }
} }
private String tableComment(String tableName) {
List<String> values = jdbcTemplate.query("""
SELECT comments
FROM all_tab_comments
WHERE owner = ?
AND table_name = ?
""", (rs, rowNum) -> rs.getString(1), owner(), tableName);
return values.isEmpty() ? "" : values.getFirst();
} }
private List<SchemaMetadataColumn> columns( private List<SchemaMetadataColumn> columns(
String tableName, String tableName,
Map<String, List<SchemaAnnotation>> annotations Map<String, List<SchemaAnnotation>> annotations
) { ) {
return mapper.findColumns(catalog.owner(), tableName).stream() return jdbcTemplate.query("""
.map(row -> toColumn(row, annotations)) SELECT c.column_name,
.toList(); CASE
} WHEN c.data_type IN ('VARCHAR2', 'CHAR', 'NVARCHAR2', 'NCHAR')
THEN c.data_type || '(' || c.char_length || ')'
private SchemaMetadataColumn toColumn( WHEN c.data_type = 'NUMBER' AND c.data_precision IS NOT NULL AND c.data_scale IS NOT NULL
SchemaMetadataColumnRow row, THEN c.data_type || '(' || c.data_precision || ',' || c.data_scale || ')'
Map<String, List<SchemaAnnotation>> annotations WHEN c.data_type = 'NUMBER' AND c.data_precision IS NOT NULL
) { THEN c.data_type || '(' || c.data_precision || ')'
String columnName = requireSimpleName(row.columnName(), "column name"); ELSE c.data_type
return new SchemaMetadataColumn( END AS display_type,
columnName, c.nullable,
row.dataType(), cc.comments
"Y".equalsIgnoreCase(row.nullable()), FROM all_tab_columns c
nullToEmpty(row.comment()), LEFT JOIN all_col_comments cc
annotations.getOrDefault(columnTargetKey(columnName), List.of()) ON cc.owner = c.owner
); AND cc.table_name = c.table_name
AND cc.column_name = c.column_name
WHERE c.owner = ?
AND c.table_name = ?
ORDER BY c.column_id
""", (rs, rowNum) -> new SchemaMetadataColumn(
rs.getString("column_name"),
rs.getString("display_type"),
"Y".equalsIgnoreCase(rs.getString("nullable")),
nullToEmpty(rs.getString("comments")),
annotations.getOrDefault(columnTargetKey(rs.getString("column_name")), List.of())
), owner(), tableName);
} }
private Map<String, List<SchemaAnnotation>> annotationsByTarget(String tableName) { private Map<String, List<SchemaAnnotation>> annotationsByTarget(String tableName) {
Map<String, LinkedHashMap<String, List<String>>> grouped = new LinkedHashMap<>(); Map<String, LinkedHashMap<String, List<String>>> grouped = new LinkedHashMap<>();
for (SchemaMetadataAnnotationRow row : mapper.findAnnotations(tableName)) { jdbcTemplate.query("""
String target = row.columnName() == null SELECT column_name, annotation_name, annotation_value
FROM all_annotations_usage
WHERE object_name = ?
AND object_type = 'TABLE'
ORDER BY column_name NULLS FIRST, annotation_name, annotation_value
""", rs -> {
String target = rs.getString("column_name") == null
? tableTargetKey() ? tableTargetKey()
: columnTargetKey(row.columnName()); : columnTargetKey(rs.getString("column_name"));
grouped grouped
.computeIfAbsent(target, ignored -> new LinkedHashMap<>()) .computeIfAbsent(target, ignored -> new LinkedHashMap<>())
.computeIfAbsent(row.annotationName(), ignored -> new ArrayList<>()) .computeIfAbsent(rs.getString("annotation_name"), ignored -> new ArrayList<>())
.add(nullToEmpty(row.annotationValue())); .add(nullToEmpty(rs.getString("annotation_value")));
} }, tableName);
Map<String, List<SchemaAnnotation>> result = new LinkedHashMap<>(); Map<String, List<SchemaAnnotation>> result = new LinkedHashMap<>();
grouped.forEach((target, valuesByName) -> { grouped.forEach((target, valuesByName) -> {
@@ -173,14 +186,44 @@ public class SchemaMetadataService {
} }
private boolean annotationExists(String tableName, String columnName, String annotationName) { private boolean annotationExists(String tableName, String columnName, String annotationName) {
return columnName == null Integer count = columnName == null
? mapper.countTableAnnotation(tableName, annotationName) > 0 ? jdbcTemplate.queryForObject("""
: mapper.countColumnAnnotation(tableName, columnName, annotationName) > 0; SELECT COUNT(*)
FROM all_annotations_usage
WHERE object_name = ?
AND object_type = 'TABLE'
AND annotation_name = ?
AND column_name IS NULL
""", Integer.class, tableName, annotationName)
: jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM all_annotations_usage
WHERE object_name = ?
AND object_type = 'TABLE'
AND annotation_name = ?
AND column_name = ?
""", Integer.class, tableName, annotationName, columnName);
return count != null && count > 0;
}
private String annotationSql(String tableName, String columnName, String operation) {
if (columnName == null) {
return "ALTER TABLE " + qualifiedTable(tableName) + " ANNOTATIONS (" + operation + ")";
}
return "ALTER TABLE " + qualifiedTable(tableName) + " MODIFY " + quoteName(columnName)
+ " ANNOTATIONS (" + operation + ")";
} }
private String requireColumn(String tableName, String columnName) { private String requireColumn(String tableName, String columnName) {
String column = requireSimpleName(columnName, "column name"); String column = requireSimpleName(columnName, "column name");
if (mapper.countColumn(catalog.owner(), tableName, column) == 0) { Integer count = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM all_tab_columns
WHERE owner = ?
AND table_name = ?
AND column_name = ?
""", Integer.class, owner(), tableName, column);
if (count == null || count == 0) {
throw new AppException("선택한 테이블에 존재하지 않는 컬럼입니다."); throw new AppException("선택한 테이블에 존재하지 않는 컬럼입니다.");
} }
return column; return column;
@@ -211,6 +254,18 @@ public class SchemaMetadataService {
return normalized; return normalized;
} }
private String qualifiedTable(String tableName) {
return quoteName(owner()) + "." + quoteName(requireSimpleName(tableName, "table name"));
}
private String owner() {
return structuredDataService.owner();
}
private String quoteName(String value) {
return "\"" + value.replace("\"", "\"\"") + "\"";
}
private String quoteLiteral(String value) { private String quoteLiteral(String value) {
return "'" + value.replace("'", "''") + "'"; return "'" + value.replace("'", "''") + "'";
} }

View File

@@ -1,211 +0,0 @@
package com.cloudhandson.vpdbackoffice.service;
import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
import com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.ResultSetMetaData;
import java.sql.Statement;
import java.time.Clock;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.regex.Pattern;
import org.springframework.stereotype.Service;
/**
* Generates and executes bounded read-only SQL through the configured schema-owned Select AI profile.
*/
@Service
public class SelectAiService {
private static final int MAX_PROMPT_LENGTH = 4_000;
private static final int MAX_RESULT_ROWS = 100;
private static final int QUERY_TIMEOUT_SECONDS = 30;
private static final Pattern UNSAFE_SQL = Pattern.compile(
"(?is)\\b(?:insert|update|delete|merge|alter|drop|create|truncate|grant|revoke|"
+ "commit|rollback|savepoint|lock|call|exec(?:ute)?|begin|declare|for\\s+update|"
+ "dbms_[a-z0-9_]*|utl_[a-z0-9_]*|sys\\s*\\.)\\b"
);
private final BackofficeProperties properties;
private final BearerTokenService bearerTokenService;
private final Clock clock;
private final ObjectMapper objectMapper;
public SelectAiService(
BackofficeProperties properties,
BearerTokenService bearerTokenService,
Clock clock,
ObjectMapper objectMapper
) {
this.properties = properties;
this.bearerTokenService = bearerTokenService;
this.clock = clock;
this.objectMapper = objectMapper;
}
public JsonNode generateAndExecute(String bearerToken, String prompt) {
requireActiveToken(bearerToken);
String normalizedPrompt = requiredPrompt(prompt);
BackofficeProperties.SelectAi selectAi = properties == null ? null : properties.selectAi();
if (selectAi == null || !selectAi.configured()) {
throw new AppException("Select AI 연결 설정이 필요합니다. "
+ "BACKOFFICE_SELECT_AI_DB_URL, BACKOFFICE_SELECT_AI_DB_USERNAME, "
+ "BACKOFFICE_SELECT_AI_DB_PASSWORD를 확인하세요.");
}
String generatedSql = generate(selectAi, normalizedPrompt);
String normalizedSql = validateReadOnlySql(generatedSql);
QueryExecution execution = executeReadOnly(selectAi, normalizedSql);
ObjectNode response = objectMapper.createObjectNode();
response.put("status", "SHOWSQL_AND_EXECUTED");
response.put("profile", selectAi.profile());
response.put("generatedSql", normalizedSql);
response.put("execution", "READ_ONLY_EXECUTED");
response.put("rowCount", execution.items().size());
response.put("truncated", execution.truncated());
response.set("items", execution.items());
response.put("nextStep", execution.truncated()
? "최초 " + MAX_RESULT_ROWS + "건만 반환했습니다. 생성 SQL로 전체 결과를 확인할 수 있습니다."
: "생성 SQL을 읽기 전용으로 실행한 결과입니다.");
return response;
}
private void requireActiveToken(String bearerToken) {
if (bearerToken == null || bearerToken.isBlank()) {
throw new VpdTokenAccessDeniedException();
}
BearerTokenRecord token = bearerTokenService.findByPlainToken(bearerToken.trim());
LocalDateTime now = LocalDateTime.now(clock.withZone(ZoneId.systemDefault()));
if (token == null || !token.active(now)) {
throw new VpdTokenAccessDeniedException();
}
}
private String requiredPrompt(String prompt) {
String normalized = prompt == null ? "" : prompt.trim();
if (normalized.isEmpty()) {
throw new AppException("prompt는 필수입니다.");
}
if (normalized.length() > MAX_PROMPT_LENGTH) {
throw new AppException("prompt는 " + MAX_PROMPT_LENGTH + "자 이하여야 합니다.");
}
return normalized;
}
private String generate(BackofficeProperties.SelectAi selectAi, String prompt) {
String sql = "SELECT DBMS_CLOUD_AI.GENERATE(?, ?, 'showsql') FROM dual";
try (Connection connection = DriverManager.getConnection(
selectAi.dbUrl(), selectAi.dbUsername(), selectAi.dbPassword());
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, prompt);
statement.setString(2, selectAi.profile());
try (ResultSet resultSet = statement.executeQuery()) {
if (!resultSet.next() || resultSet.getString(1) == null) {
throw new AppException("Select AI가 생성 SQL을 반환하지 않았습니다.");
}
return resultSet.getString(1);
}
} catch (AppException exception) {
throw exception;
} catch (Exception exception) {
throw new AppException("Select AI SHOWSQL 생성 실패: " + exception.getMessage());
}
}
private QueryExecution executeReadOnly(BackofficeProperties.SelectAi selectAi, String generatedSql) {
ArrayNode items = objectMapper.createArrayNode();
boolean truncated = false;
try (Connection connection = DriverManager.getConnection(
selectAi.dbUrl(), selectAi.dbUsername(), selectAi.dbPassword());
Statement transaction = connection.createStatement()) {
connection.setAutoCommit(false);
connection.setReadOnly(true);
transaction.execute("SET TRANSACTION READ ONLY");
try (PreparedStatement statement = connection.prepareStatement(generatedSql)) {
statement.setQueryTimeout(QUERY_TIMEOUT_SECONDS);
statement.setFetchSize(MAX_RESULT_ROWS + 1);
statement.setMaxRows(MAX_RESULT_ROWS + 1);
try (ResultSet resultSet = statement.executeQuery()) {
ResultSetMetaData metadata = resultSet.getMetaData();
while (resultSet.next()) {
if (items.size() >= MAX_RESULT_ROWS) {
truncated = true;
break;
}
ObjectNode row = items.addObject();
for (int columnIndex = 1; columnIndex <= metadata.getColumnCount(); columnIndex++) {
String column = metadata.getColumnLabel(columnIndex);
if (column == null || column.isBlank()) {
column = metadata.getColumnName(columnIndex);
}
putResultValue(row, column, resultSet.getObject(columnIndex));
}
}
}
} finally {
connection.rollback();
}
} catch (Exception exception) {
throw new AppException("Select AI 생성 SQL 실행 실패: " + exception.getMessage());
}
return new QueryExecution(items, truncated);
}
private void putResultValue(ObjectNode row, String column, Object value) {
if (value == null) {
row.putNull(column);
} else if (value instanceof BigDecimal number) {
row.put(column, number);
} else if (value instanceof BigInteger number) {
row.put(column, number);
} else if (value instanceof Integer number) {
row.put(column, number);
} else if (value instanceof Long number) {
row.put(column, number);
} else if (value instanceof Short number) {
row.put(column, number);
} else if (value instanceof Float number) {
row.put(column, number);
} else if (value instanceof Double number) {
row.put(column, number);
} else if (value instanceof Boolean bool) {
row.put(column, bool);
} else {
row.put(column, String.valueOf(value));
}
}
private String validateReadOnlySql(String generatedSql) {
String normalized = generatedSql == null ? "" : generatedSql.trim();
if (normalized.startsWith("```")) {
int firstLineEnd = normalized.indexOf('\n');
int closingFence = normalized.lastIndexOf("```");
if (firstLineEnd >= 0 && closingFence > firstLineEnd) {
normalized = normalized.substring(firstLineEnd + 1, closingFence).trim();
}
}
normalized = normalized.replaceFirst(";\\s*$", "").trim();
if (!normalized.matches("(?is)^(select|with)\\b.*")) {
throw new AppException("Select AI가 읽기 전용 SELECT/WITH SQL을 반환하지 않았습니다.");
}
if (normalized.contains(";")) {
throw new AppException("Select AI 결과에 여러 SQL 문장이 포함되어 있어 반환하지 않습니다.");
}
if (normalized.contains("--") || normalized.contains("/*") || normalized.contains("*/")
|| UNSAFE_SQL.matcher(normalized).find()) {
throw new AppException("Select AI 결과에 실행이 허용되지 않는 SQL 구문이 포함되어 있습니다.");
}
return normalized;
}
private record QueryExecution(ArrayNode items, boolean truncated) {}
}

View File

@@ -12,14 +12,26 @@ import org.springframework.stereotype.Service;
public class StructuredDataService { public class StructuredDataService {
private static final int ROW_LIMIT = 50; private static final int ROW_LIMIT = 50;
private final JdbcTemplate jdbcTemplate; private final JdbcTemplate jdbcTemplate;
private final DataCatalog catalog; private final DataCatalog catalog;
public StructuredDataService(JdbcTemplate jdbcTemplate, DataCatalog catalog) { public StructuredDataService(
JdbcTemplate jdbcTemplate,
DataCatalog catalog
) {
this.jdbcTemplate = jdbcTemplate; this.jdbcTemplate = jdbcTemplate;
this.catalog = catalog; this.catalog = catalog;
} }
public DataCatalog catalog() {
return catalog;
}
public String owner() {
return catalog.owner();
}
public List<StructuredDataTable> tables() { public List<StructuredDataTable> tables() {
return catalog.objects(); return catalog.objects();
} }
@@ -47,16 +59,43 @@ public class StructuredDataService {
if (columns.isEmpty()) { if (columns.isEmpty()) {
throw new AppException("정형 데이터 테이블의 컬럼 정보를 찾을 수 없습니다."); throw new AppException("정형 데이터 테이블의 컬럼 정보를 찾을 수 없습니다.");
} }
List<String> previewColumns =
table.previewColumns().isEmpty() ? columns : table.previewColumns();
if (!columns.containsAll(previewColumns)) {
throw new AppException("환경 카탈로그의 미리보기 컬럼이 실제 객체와 일치하지 않습니다.");
}
List<Map<String, Object>> rows = jdbcTemplate.queryForList( List<Map<String, Object>> rows = jdbcTemplate.queryForList(
previewSql(table), ROW_LIMIT); previewSql(table, previewColumns), ROW_LIMIT);
return new StructuredDataPreview(table, columns, rows, ROW_LIMIT); return new StructuredDataPreview(table, previewColumns, rows, ROW_LIMIT);
} catch (DataAccessException exception) { } catch (DataAccessException exception) {
throw new AppException("카탈로그 데이터를 조회할 수 없습니다. DB 권한과 대상 객체 상태를 확인하세요."); throw new AppException("정형 데이터를 조회할 수 없습니다. " + catalog.owner()
+ " 조회 권한과 대상 객체 상태를 확인하세요.");
} }
} }
private String previewSql(StructuredDataTable table) { /**
return "SELECT * FROM \"" + catalog.owner() + "\".\"" + table.tableName() + "\" WHERE ROWNUM <= ?"; * The table is selected from a closed application whitelist, so the query
* text remains fixed and no request value can become a SQL identifier.
*/
String previewSql(StructuredDataTable table) {
return previewSql(table, table.previewColumns());
}
private String previewSql(
StructuredDataTable table,
List<String> previewColumns
) {
StructuredDataTable approved = requireTable(table.key());
if (!approved.tableName().equals(table.tableName())) {
throw new AppException("선택할 수 없는 카탈로그 객체입니다.");
}
String projection = previewColumns == null || previewColumns.isEmpty()
? "*"
: previewColumns.stream()
.map(column -> "\"" + column + "\"")
.reduce((left, right) -> left + ", " + right)
.orElseThrow();
return "SELECT " + projection + " FROM \"" + catalog.owner() + "\".\"" + approved.tableName()
+ "\" WHERE ROWNUM <= ?";
} }
} }