diff --git a/poc4_active_source_20260714/src/smilegate_demo/__init__.py b/ai-web-agent-console/ai_web_agent_console/smilegate_demo/__init__.py similarity index 100% rename from poc4_active_source_20260714/src/smilegate_demo/__init__.py rename to ai-web-agent-console/ai_web_agent_console/smilegate_demo/__init__.py diff --git a/poc4_active_source_20260714/src/smilegate_demo/ui/__init__.py b/ai-web-agent-console/ai_web_agent_console/smilegate_demo/ui/__init__.py similarity index 100% rename from poc4_active_source_20260714/src/smilegate_demo/ui/__init__.py rename to ai-web-agent-console/ai_web_agent_console/smilegate_demo/ui/__init__.py diff --git a/poc4_active_source_20260714/src/smilegate_demo/ui/shell.py b/ai-web-agent-console/ai_web_agent_console/smilegate_demo/ui/shell.py similarity index 100% rename from poc4_active_source_20260714/src/smilegate_demo/ui/shell.py rename to ai-web-agent-console/ai_web_agent_console/smilegate_demo/ui/shell.py diff --git a/poc4_active_source_20260714/apps/smilegate_demo/main.py b/ai-web-agent-console/smilegate_demo.py similarity index 65% rename from poc4_active_source_20260714/apps/smilegate_demo/main.py rename to ai-web-agent-console/smilegate_demo.py index a1eda10..1de7434 100644 --- a/poc4_active_source_20260714/apps/smilegate_demo/main.py +++ b/ai-web-agent-console/smilegate_demo.py @@ -7,16 +7,9 @@ after each review. 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 -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: diff --git a/deploy/caddy/smilegate/Caddyfile b/deploy/ai-web-agent-console/caddy/Caddyfile similarity index 100% rename from deploy/caddy/smilegate/Caddyfile rename to deploy/ai-web-agent-console/caddy/Caddyfile diff --git a/deploy/systemd/smilegate-poc4-auth.service b/deploy/ai-web-agent-console/systemd/smilegate-poc4-auth.service similarity index 100% rename from deploy/systemd/smilegate-poc4-auth.service rename to deploy/ai-web-agent-console/systemd/smilegate-poc4-auth.service diff --git a/deploy/systemd/smilegate-poc4-console-blank.override.conf b/deploy/ai-web-agent-console/systemd/smilegate-poc4-console-blank.override.conf similarity index 100% rename from deploy/systemd/smilegate-poc4-console-blank.override.conf rename to deploy/ai-web-agent-console/systemd/smilegate-poc4-console-blank.override.conf diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/McpProperties.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/McpProperties.java index 74a0b24..f80aa83 100644 --- a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/McpProperties.java +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/McpProperties.java @@ -2,13 +2,16 @@ package com.cloudhandson.vpdbackoffice.config; 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") public record McpProperties( + String publicUrl, + String serverName, String toolName, String toolLabel, String toolDescription, String promptDescription, + String tools, String showpromptToolName, String showpromptToolLabel, String showpromptToolDescription, @@ -26,45 +29,29 @@ public record McpProperties( 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_LABEL = "업무 데이터 Text2SQL"; private static final String DEFAULT_TOOL_DESCRIPTION = - "승인된 업무 데이터용 읽기 전용 SELECT/WITH SQL을 생성하고, 검증 후 읽기 전용 트랜잭션에서 실행합니다. " - + "생성 SQL과 최대 100건의 조회 결과를 함께 반환하며 DDL/DML/잠금/패키지 호출은 실행하지 않습니다."; + "승인된 업무 데이터용 읽기 전용 SELECT/WITH SQL을 생성하고, 검증 후 읽기 전용 " + + "트랜잭션에서 실행합니다."; 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_LABEL = - "업무 데이터 SHOWPROMPT"; - private static final String DEFAULT_SHOWPROMPT_TOOL_DESCRIPTION = - "Select AI가 SQL 생성에 사용한 prompt를 조회하는 읽기 전용 진단 도구입니다. " - + "생성 SQL이나 데이터 조회 SQL은 실행하지 않습니다."; - private static final String DEFAULT_QA_VECTOR_SEARCH_TOOL_NAME = - "oracle.select_ai.qa_vector_search"; - 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 실행 전에 조회합니다. " - + "반환값은 few-shot 컨텍스트 검토용이며 SQL을 실행하지 않습니다."; - 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을 호출하세요."; + + private static final String DEFAULT_SHOWPROMPT_TOOL_NAME = "oracle.select_ai.data_showprompt"; + private static final String DEFAULT_QA_VECTOR_SEARCH_TOOL_NAME = "oracle.select_ai.qa_vector_search"; + private static final String DEFAULT_QA_VECTOR_STORE_TOOL_NAME = "oracle.select_ai.qa_vector_store"; + private static final String DEFAULT_FEW_SHOT_NL2SQL_TOOL_NAME = "oracle.select_ai.smilegate_fewshot_nl2sql"; + private static final String DEFAULT_GAME_SCOPE_TOOL_NAME = "oracle.select_ai.game_scope_resolve"; + + public String resolvedPublicUrl() { + return requiredOrDefault(publicUrl, DEFAULT_PUBLIC_URL); + } + + public String resolvedServerName() { + return requiredOrDefault(serverName, DEFAULT_SERVER_NAME); + } public String resolvedToolName() { return requiredOrDefault(toolName, DEFAULT_TOOL_NAME); @@ -82,65 +69,21 @@ public record McpProperties( return requiredOrDefault(promptDescription, DEFAULT_PROMPT_DESCRIPTION); } - public String resolvedShowpromptToolName() { - return requiredOrDefault(showpromptToolName, DEFAULT_SHOWPROMPT_TOOL_NAME); - } - - public String resolvedShowpromptToolLabel() { - return requiredOrDefault(showpromptToolLabel, DEFAULT_SHOWPROMPT_TOOL_LABEL); - } - - public String resolvedShowpromptToolDescription() { - return requiredOrDefault(showpromptToolDescription, DEFAULT_SHOWPROMPT_TOOL_DESCRIPTION); - } - - public String resolvedQaVectorSearchToolName() { - return requiredOrDefault(qaVectorSearchToolName, DEFAULT_QA_VECTOR_SEARCH_TOOL_NAME); - } - - 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); - } + public String resolvedShowpromptToolName() { 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 resolvedQaVectorSearchToolLabel() { return requiredOrDefault(qaVectorSearchToolLabel, "Select AI 예제 SQL 조회"); } + 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 resolvedQaVectorStoreToolDescription() { return requiredOrDefault(qaVectorStoreToolDescription, "검토된 읽기 전용 답 SQL을 예제로 저장합니다."); } + 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 resolvedGameScopeToolName() { return requiredOrDefault(gameScopeToolName, DEFAULT_GAME_SCOPE_TOOL_NAME); } + public String resolvedGameScopeToolLabel() { return requiredOrDefault(gameScopeToolLabel, "게임 조회 범위 확인"); } + public String resolvedGameScopeToolDescription() { return requiredOrDefault(gameScopeToolDescription, "질문의 게임 별칭과 데이터 조회 범위를 확인합니다."); } private String requiredOrDefault(String value, String fallback) { return value == null || value.isBlank() ? fallback : value.trim(); diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/structured/StructuredDataTable.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/structured/StructuredDataTable.java index b1f19b3..446c88a 100644 --- a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/structured/StructuredDataTable.java +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/domain/structured/StructuredDataTable.java @@ -1,13 +1,21 @@ package com.cloudhandson.vpdbackoffice.domain.structured; +import java.util.List; + public record StructuredDataTable( String key, String tableName, String objectType, String businessName, - String description + String description, + List previewColumns ) { + 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); } } diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SchemaMetadataService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SchemaMetadataService.java index 92ce7eb..256bc8d 100644 --- a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SchemaMetadataService.java +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SchemaMetadataService.java @@ -1,25 +1,20 @@ package com.cloudhandson.vpdbackoffice.service; 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.SchemaMetadataColumnRow; import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataView; import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataTable; -import com.cloudhandson.vpdbackoffice.mapper.SchemaMetadataMapper; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; 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; -/** - * Screen-level orchestration for schema metadata. Database reads and DDL are - * intentionally delegated only to {@link SchemaMetadataMapper}. - */ @Service public class SchemaMetadataService { @@ -27,17 +22,12 @@ public class SchemaMetadataService { 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 final SchemaMetadataMapper mapper; + private final JdbcTemplate jdbcTemplate; private final StructuredDataService structuredDataService; - private final DataCatalog catalog; - public SchemaMetadataService( - SchemaMetadataMapper mapper, - StructuredDataService structuredDataService, DataCatalog catalog - ) { - this.mapper = mapper; + public SchemaMetadataService(JdbcTemplate jdbcTemplate, StructuredDataService structuredDataService) { + this.jdbcTemplate = jdbcTemplate; this.structuredDataService = structuredDataService; - this.catalog = catalog; } public List tables() { @@ -54,12 +44,14 @@ public class SchemaMetadataService { public SchemaMetadataView find(String tableKey) { StructuredDataTable table = structuredDataService.requireTable(tableKey); - String tableName = requireSimpleName(table.tableName(), "table name"); - Map> annotations = annotationsByTarget(tableName); + String tableName = table.tableName(); + String tableComment = tableComment(tableName); + Map> annotations = + table.isTable() ? annotationsByTarget(tableName) : Map.of(); List columns = columns(tableName, annotations); return new SchemaMetadataView( table, - nullToEmpty(mapper.findTableComment(catalog.owner(), tableName)), + nullToEmpty(tableComment), annotations.getOrDefault(tableTargetKey(), List.of()), columns ); @@ -68,24 +60,25 @@ public class SchemaMetadataService { @Transactional public void updateTableComment(String tableKey, String comment) { StructuredDataTable table = structuredDataService.requireTable(tableKey); - String tableName = requireSimpleName(table.tableName(), "table name"); 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 public void updateColumnComment(String tableKey, String columnName, String comment) { StructuredDataTable table = structuredDataService.requireTable(tableKey); - String tableName = requireSimpleName(table.tableName(), "table name"); - String column = requireColumn(tableName, columnName); + String column = requireColumn(table.tableName(), columnName); 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 public void updateTableAnnotation(String tableKey, String annotationName, String annotationValue) { StructuredDataTable table = structuredDataService.requireTable(tableKey); - updateAnnotation(requireSimpleName(table.tableName(), "table name"), null, annotationName, annotationValue); + requireAnnotationTable(table); + updateAnnotation(table.tableName(), null, annotationName, annotationValue); } @Transactional @@ -96,9 +89,9 @@ public class SchemaMetadataService { String annotationValue ) { StructuredDataTable table = structuredDataService.requireTable(tableKey); - String tableName = requireSimpleName(table.tableName(), "table name"); - String column = requireColumn(tableName, columnName); - updateAnnotation(tableName, column, annotationName, annotationValue); + requireAnnotationTable(table); + String column = requireColumn(table.tableName(), columnName); + updateAnnotation(table.tableName(), column, annotationName, annotationValue); } private void updateAnnotation( @@ -110,55 +103,75 @@ public class SchemaMetadataService { String key = requireSimpleName(annotationName, "annotation name"); String value = normalizeText(annotationValue, MAX_ANNOTATION_VALUE_LENGTH, "annotation value"); if (annotationExists(tableName, columnName, key)) { - if (columnName == null) { - mapper.dropTableAnnotation(catalog.owner(), tableName, key); - } else { - mapper.dropColumnAnnotation(catalog.owner(), tableName, columnName, key); - } + jdbcTemplate.execute(annotationSql(tableName, columnName, "DROP " + quoteName(key))); } if (!value.isBlank()) { - if (columnName == null) { - mapper.addTableAnnotation(catalog.owner(), tableName, key, quoteLiteral(value)); - } else { - mapper.addColumnAnnotation(catalog.owner(), tableName, columnName, key, quoteLiteral(value)); - } + jdbcTemplate.execute(annotationSql(tableName, columnName, + "ADD " + quoteName(key) + " " + quoteLiteral(value))); } } + private String tableComment(String tableName) { + List 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 columns( String tableName, Map> annotations ) { - return mapper.findColumns(catalog.owner(), tableName).stream() - .map(row -> toColumn(row, annotations)) - .toList(); - } - - private SchemaMetadataColumn toColumn( - SchemaMetadataColumnRow row, - Map> annotations - ) { - String columnName = requireSimpleName(row.columnName(), "column name"); - return new SchemaMetadataColumn( - columnName, - row.dataType(), - "Y".equalsIgnoreCase(row.nullable()), - nullToEmpty(row.comment()), - annotations.getOrDefault(columnTargetKey(columnName), List.of()) - ); + return jdbcTemplate.query(""" + SELECT c.column_name, + CASE + WHEN c.data_type IN ('VARCHAR2', 'CHAR', 'NVARCHAR2', 'NCHAR') + THEN c.data_type || '(' || c.char_length || ')' + WHEN c.data_type = 'NUMBER' AND c.data_precision IS NOT NULL AND c.data_scale IS NOT NULL + THEN c.data_type || '(' || c.data_precision || ',' || c.data_scale || ')' + WHEN c.data_type = 'NUMBER' AND c.data_precision IS NOT NULL + THEN c.data_type || '(' || c.data_precision || ')' + ELSE c.data_type + END AS display_type, + c.nullable, + cc.comments + FROM all_tab_columns c + LEFT JOIN all_col_comments cc + 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> annotationsByTarget(String tableName) { Map>> grouped = new LinkedHashMap<>(); - for (SchemaMetadataAnnotationRow row : mapper.findAnnotations(tableName)) { - String target = row.columnName() == null - ? tableTargetKey() - : columnTargetKey(row.columnName()); - grouped - .computeIfAbsent(target, ignored -> new LinkedHashMap<>()) - .computeIfAbsent(row.annotationName(), ignored -> new ArrayList<>()) - .add(nullToEmpty(row.annotationValue())); - } + jdbcTemplate.query(""" + 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() + : columnTargetKey(rs.getString("column_name")); + grouped + .computeIfAbsent(target, ignored -> new LinkedHashMap<>()) + .computeIfAbsent(rs.getString("annotation_name"), ignored -> new ArrayList<>()) + .add(nullToEmpty(rs.getString("annotation_value"))); + }, tableName); Map> result = new LinkedHashMap<>(); grouped.forEach((target, valuesByName) -> { @@ -173,14 +186,44 @@ public class SchemaMetadataService { } private boolean annotationExists(String tableName, String columnName, String annotationName) { - return columnName == null - ? mapper.countTableAnnotation(tableName, annotationName) > 0 - : mapper.countColumnAnnotation(tableName, columnName, annotationName) > 0; + Integer count = columnName == null + ? jdbcTemplate.queryForObject(""" + 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) { 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("선택한 테이블에 존재하지 않는 컬럼입니다."); } return column; @@ -211,6 +254,18 @@ public class SchemaMetadataService { 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) { return "'" + value.replace("'", "''") + "'"; } diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SmilegateSelectAiService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SmilegateSelectAiService.java deleted file mode 100644 index 2d166a0..0000000 --- a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/SmilegateSelectAiService.java +++ /dev/null @@ -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) {} -} diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/StructuredDataService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/StructuredDataService.java index bf36e5d..09f121d 100644 --- a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/StructuredDataService.java +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/StructuredDataService.java @@ -12,14 +12,26 @@ import org.springframework.stereotype.Service; public class StructuredDataService { private static final int ROW_LIMIT = 50; + private final JdbcTemplate jdbcTemplate; private final DataCatalog catalog; - public StructuredDataService(JdbcTemplate jdbcTemplate, DataCatalog catalog) { + public StructuredDataService( + JdbcTemplate jdbcTemplate, + DataCatalog catalog + ) { this.jdbcTemplate = jdbcTemplate; this.catalog = catalog; } + public DataCatalog catalog() { + return catalog; + } + + public String owner() { + return catalog.owner(); + } + public List tables() { return catalog.objects(); } @@ -42,21 +54,48 @@ public class StructuredDataService { WHERE owner = ? AND table_name = ? ORDER BY column_id - """, + """, (resultSet, rowNum) -> resultSet.getString(1), catalog.owner(), table.tableName()); if (columns.isEmpty()) { throw new AppException("정형 데이터 테이블의 컬럼 정보를 찾을 수 없습니다."); } - + List previewColumns = + table.previewColumns().isEmpty() ? columns : table.previewColumns(); + if (!columns.containsAll(previewColumns)) { + throw new AppException("환경 카탈로그의 미리보기 컬럼이 실제 객체와 일치하지 않습니다."); + } List> rows = jdbcTemplate.queryForList( - previewSql(table), ROW_LIMIT); - return new StructuredDataPreview(table, columns, rows, ROW_LIMIT); + previewSql(table, previewColumns), ROW_LIMIT); + return new StructuredDataPreview(table, previewColumns, rows, ROW_LIMIT); } 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 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 <= ?"; } } diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/mapper/SchemaMetadataMapperXmlTest.java b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/mapper/SchemaMetadataMapperXmlTest.java similarity index 100% rename from src/test/java/com/cloudhandson/vpdbackoffice/mapper/SchemaMetadataMapperXmlTest.java rename to vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/mapper/SchemaMetadataMapperXmlTest.java