업데이트
Some checks failed
Deploy to OCIWP / deploy (push) Has been cancelled

This commit is contained in:
jade
2026-09-04 17:44:21 +09:00
parent 072a31d883
commit 459895ed40
14 changed files with 1295 additions and 6 deletions

7
TestClientTransport.java Normal file
View File

@@ -0,0 +1,7 @@
import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
public class TestClientTransport {
public static void main(String[] args) {
HttpClientStreamableHttpTransport t = HttpClientStreamableHttpTransport.builder("http://localhost:8086/mcp").build();
System.out.println("Wait, I can't print private fields easily, but let's just see if it runs.");
}
}

22
TestMcpCall.java Normal file
View File

@@ -0,0 +1,22 @@
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class TestMcpCall {
public static void main(String[] args) throws Exception {
HttpClient client = HttpClient.newHttpClient();
String json = "{\"jsonrpc\":\"2.0\",\"method\":\"tools/call\",\"params\":{\"name\":\"iam_team_contact\",\"arguments\":{\"target\":\"전체\"}},\"id\":1}";
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:8086/mcp"))
.header("Content-Type", "application/json")
.header("Authorization", "Bearer dapms") // just in case
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println("STATUS: " + response.statusCode());
System.out.println("BODY: " + response.body());
}
}

39
TestMcpClient.java Normal file
View File

@@ -0,0 +1,39 @@
import io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.client.McpSyncClient;
import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
import io.modelcontextprotocol.spec.McpSchema;
import java.net.http.HttpRequest;
import java.time.Duration;
import java.util.Map;
import com.fasterxml.jackson.databind.ObjectMapper;
public class TestMcpClient {
public static void main(String[] args) throws Exception {
String endpoint = "http://localhost:8086/mcp";
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder();
requestBuilder.header("Authorization", "Bearer dapms");
HttpClientStreamableHttpTransport transport = HttpClientStreamableHttpTransport.builder(endpoint)
.requestBuilder(requestBuilder)
.connectTimeout(Duration.ofSeconds(5))
.build();
try (McpSyncClient client = McpClient.sync(transport)
.clientInfo(new McpSchema.Implementation("test-client", "1.0.0"))
.requestTimeout(Duration.ofSeconds(30))
.build()) {
System.out.println("Initializing client...");
client.initialize();
System.out.println("Client initialized.");
System.out.println("Calling tool...");
McpSchema.CallToolResult result = client.callTool(McpSchema.CallToolRequest.builder()
.name("iam_team_contact")
.arguments(Map.of("target", "전체"))
.build());
System.out.println("Result: " + result);
} catch (Exception e) {
e.printStackTrace();
}
}
}

11
TestReflection.java Normal file
View File

@@ -0,0 +1,11 @@
import java.lang.reflect.Method;
import io.modelcontextprotocol.server.transport.HttpServletStreamableServerTransportProvider;
public class TestReflection {
public static void main(String[] args) throws Exception {
Class<?> clazz = HttpServletStreamableServerTransportProvider.Builder.class;
for (Method m : clazz.getDeclaredMethods()) {
System.out.println(m.getName());
}
}
}

20
TestSseEndpoint.java Normal file
View File

@@ -0,0 +1,20 @@
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class TestSseEndpoint {
public static void main(String[] args) throws Exception {
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:8086/mcp"))
.header("Accept", "text/event-stream")
.header("X-Tool-Server-API-Key", "test")
.GET()
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println("STATUS: " + response.statusCode());
System.out.println("BODY: " + response.body());
}
}

View File

@@ -16,6 +16,7 @@ package io.shinhanlife.dat.mcg.presentation;
* </pre> * </pre>
*/ */
import io.shinhanlife.dat.lib.util.PodScaffolder; import io.shinhanlife.dat.lib.util.PodScaffolder;
import io.shinhanlife.dat.lib.util.MciResponseScaffolder;
import io.shinhanlife.dat.lib.util.ToolScaffolder; import io.shinhanlife.dat.lib.util.ToolScaffolder;
import io.shinhanlife.dat.lib.util.ToolSourceUpdater; import io.shinhanlife.dat.lib.util.ToolSourceUpdater;
import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.core.type.TypeReference;
@@ -317,6 +318,73 @@ public class ScaffoldingController {
} }
} }
@PostMapping("/mci-response/analyze")
public ResponseEntity<?> analyzeMciResponse(@RequestBody MciResponseAnalyzeRequest request) {
if (request == null || request.source() == null || request.source().isBlank()) {
return ResponseEntity.badRequest().body(Map.of("error", "XXXX_O.java 소스를 입력해주세요."));
}
try {
MciResponseScaffolder.ParsedSource parsed = MciResponseScaffolder.parse(request.source());
String fieldsJson = objectMapper.writeValueAsString(parsed.types().stream()
.flatMap(type -> type.fields().stream().map(field -> Map.of(
"ownerType", type.name(),
"sourceName", field.name(),
"description", field.description(),
"javaType", field.type(),
"sensitive", field.sensitive())))
.toList());
String prompt = """
You rename legacy Korean financial-system response fields for an MCP Tool response DTO.
Return JSON only with this exact shape:
{"mappings":[{"ownerType":"SourceOwnerClass","sourceName":"legacyField","targetName":"businessMeaningInEnglish","include":true}]}
Rules:
- Return exactly one mapping for every input field, preserving ownerType and sourceName verbatim.
- targetName must be a concise, descriptive English Java camelCase identifier.
- Derive the business meaning primarily from description; use sourceName only as supporting metadata.
- Expand abbreviations: No -> Number, Cd -> Code, Nm -> Name, Ymd/Dt -> Date when the description supports it.
- Do not invent fields, examples, descriptions, values, or business rules.
- Keep include=true. Sensitive fields must still be named accurately; the UI will show a warning for human review.
- targetName values must be unique within each ownerType.
Source fields:
%s
""".formatted(fieldsJson);
String aiResponse = generateAiContent(prompt, request.model());
AiMciMappingDraft draft = objectMapper.readValue(stripCodeFence(aiResponse), AiMciMappingDraft.class);
List<MciResponseScaffolder.FieldMapping> mappings = normalizeAiMappings(parsed, draft);
return ResponseEntity.ok(new MciResponseAnalyzeResponse(parsed, mappings));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("error", safeMessage(e)));
} catch (Exception e) {
return ResponseEntity.internalServerError()
.body(Map.of("error", "MCI Response AI 분석 실패: " + safeMessage(e)));
}
}
@PostMapping("/mci-response/generate")
public ResponseEntity<?> generateMciResponse(@RequestBody MciResponseGenerateRequest request) {
if (request == null || request.source() == null || request.source().isBlank()) {
return ResponseEntity.badRequest().body(Map.of("error", "XXXX_O.java 소스를 입력해주세요."));
}
try {
MciResponseScaffolder.ParsedSource parsed = MciResponseScaffolder.parse(request.source());
MciResponseScaffolder.GeneratedSources generated = MciResponseScaffolder.generate(
parsed,
request.responsePackage(),
request.responseClassName(),
request.converterPackage(),
request.converterClassName(),
request.mappings());
return ResponseEntity.ok(generated);
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("error", safeMessage(e)));
} catch (Exception e) {
return ResponseEntity.internalServerError()
.body(Map.of("error", "MCI Response 소스 생성 실패: " + safeMessage(e)));
}
}
@PostMapping("/tool/update") @PostMapping("/tool/update")
public String updateTool(@RequestBody Map<String, String> req) { public String updateTool(@RequestBody Map<String, String> req) {
try { try {
@@ -360,6 +428,39 @@ public class ScaffoldingController {
return objectMapper.readValue(source, new TypeReference<List<ToolScaffolder.FieldDefinition>>() { }); return objectMapper.readValue(source, new TypeReference<List<ToolScaffolder.FieldDefinition>>() { });
} }
private List<MciResponseScaffolder.FieldMapping> normalizeAiMappings(
MciResponseScaffolder.ParsedSource parsed, AiMciMappingDraft draft) {
Map<String, AiMciFieldMapping> suggestions = new java.util.LinkedHashMap<>();
if (draft != null && draft.mappings() != null) {
for (AiMciFieldMapping mapping : draft.mappings()) {
if (mapping == null || mapping.ownerType() == null || mapping.sourceName() == null) continue;
suggestions.put(mapping.ownerType() + "#" + mapping.sourceName(), mapping);
}
}
List<MciResponseScaffolder.FieldMapping> result = new java.util.ArrayList<>();
Map<String, Set<String>> usedTargets = new java.util.HashMap<>();
for (MciResponseScaffolder.ParsedType type : parsed.types()) {
Set<String> used = usedTargets.computeIfAbsent(type.name(), ignored -> new LinkedHashSet<>());
for (MciResponseScaffolder.ParsedField field : type.fields()) {
AiMciFieldMapping suggestion = suggestions.get(type.name() + "#" + field.name());
String targetName = suggestion == null ? field.name() : suggestion.targetName();
targetName = targetName == null ? "" : targetName.trim();
if (!targetName.matches("^[a-z][A-Za-z0-9]*$") || used.contains(targetName)) {
targetName = field.name();
}
if (!targetName.matches("^[a-z][A-Za-z0-9]*$") || !used.add(targetName)) {
throw new IllegalArgumentException(
"AI가 중복되거나 올바르지 않은 필드명을 생성했습니다: " + type.name() + "." + field.name());
}
boolean include = suggestion == null || suggestion.include() == null || suggestion.include();
result.add(new MciResponseScaffolder.FieldMapping(
type.name(), field.name(), targetName, include));
}
}
return List.copyOf(result);
}
private List<String> parseDelimited(String source) { private List<String> parseDelimited(String source) {
if (source == null || source.isBlank()) { if (source == null || source.isBlank()) {
return List.of(); return List.of();
@@ -551,6 +652,27 @@ public class ScaffoldingController {
private record FieldDraft(List<ToolScaffolder.FieldDefinition> fields) { private record FieldDraft(List<ToolScaffolder.FieldDefinition> fields) {
} }
public record MciResponseAnalyzeRequest(String source, String model) {
}
public record MciResponseAnalyzeResponse(MciResponseScaffolder.ParsedSource parsed,
List<MciResponseScaffolder.FieldMapping> mappings) {
}
public record MciResponseGenerateRequest(String source,
String responsePackage,
String responseClassName,
String converterPackage,
String converterClassName,
List<MciResponseScaffolder.FieldMapping> mappings) {
}
private record AiMciMappingDraft(List<AiMciFieldMapping> mappings) {
}
private record AiMciFieldMapping(String ownerType, String sourceName, String targetName, Boolean include) {
}
private record ToolDraft(String baseName, String title, String description, String categoryKey, String routingType, private record ToolDraft(String baseName, String title, String description, String categoryKey, String routingType,
String httpApiName, String functionDescription, String displayDescription, String httpApiName, String functionDescription, String displayDescription,
String whenToUse, String whenNotToUse, String ioLimits, String whenToUse, String whenNotToUse, String ioLimits,

View File

@@ -50,6 +50,8 @@ mcp:
hr: http://was-cus:8084 hr: http://was-cus:8084
pro: http://was-pro:8085 pro: http://was-pro:8085
sys: http://was-sys:8086 sys: http://was-sys:8086
iam: http://was-sys:8086
agent-claims-required: false agent-claims-required: false
trusted-claims-required: false trusted-claims-required: false
write-approval-required: false write-approval-required: false

View File

@@ -787,6 +787,9 @@
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link" id="tool-tab" data-bs-toggle="tab" data-bs-target="#tool" type="button" role="tab">Tool Function</button> <button class="nav-link" id="tool-tab" data-bs-toggle="tab" data-bs-target="#tool" type="button" role="tab">Tool Function</button>
</li> </li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="mci-response-tab" data-bs-toggle="tab" data-bs-target="#mci-response" type="button" role="tab">MCI Response 변환</button>
</li>
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link" id="document-tab" data-bs-toggle="tab" data-bs-target="#document" type="button" role="tab" onclick="loadDocumentGenerator()">Document Generator</button> <button class="nav-link" id="document-tab" data-bs-toggle="tab" data-bs-target="#document" type="button" role="tab" onclick="loadDocumentGenerator()">Document Generator</button>
</li> </li>
@@ -1131,6 +1134,131 @@
</form> </form>
</div> </div>
<!-- Glow MCI Response Converter -->
<div class="tab-pane fade" id="mci-response" role="tabpanel" aria-labelledby="mci-response-tab">
<div class="mb-4 p-3 rounded" style="background:#18181b; border:1px solid #3f3f46;">
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap">
<div>
<h2 style="font-size:1.05rem; color:#f4f4f5; margin:0 0 0.45rem;">Glow MCI 응답을 LLM Response로 변환</h2>
<div class="input-hint mt-0">XXXX_O.java의 전문 구조와 <code>@GlowTrgmField.description</code>은 원문 그대로 읽고, AI는 축약 필드명을 업무 의미가 드러나는 영문 camelCase로만 제안합니다.</div>
</div>
<span class="badge-mono">구조: Parser · 이름: AI · 출력: MapStruct</span>
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-md-8">
<label class="form-label" for="mciResponseFile">XXXX_O.java 파일</label>
<input class="form-control" type="file" id="mciResponseFile" accept=".java,text/x-java-source">
<div class="input-hint">파일을 선택하면 아래 소스 입력란에 UTF-8 텍스트로 불러옵니다. 소스를 직접 붙여넣어도 됩니다.</div>
</div>
<div class="col-md-4">
<label class="form-label" for="mciResponseAiModel">필드명 추천 AI</label>
<select id="mciResponseAiModel" class="form-select">
<optgroup label="[신한라이프 내부망]">
<option value="Qwen3-Coder" selected>Qwen3-Coder</option>
<option value="Gemma-4-31B">Gemma-4-31B</option>
</optgroup>
<optgroup label="[외부 OpenRouter 무료]">
<option value="cohere/north-mini-code:free">Cohere North Mini Code</option>
<option value="inclusionai/ling-3.0-flash:free">InclusionAI Ling 3 Flash</option>
<option value="openai/gpt-oss-20b:free">OpenAI GPT-OSS 20B</option>
<option value="google/gemma-4-31b-it:free">Google Gemma 4 31B</option>
<option value="nvidia/nemotron-3-nano-30b-a3b:free">NVIDIA Nemotron 3 Nano</option>
</optgroup>
</select>
</div>
</div>
<div class="mb-4">
<label class="form-label" for="mciResponseSource">MCI 응답 소스</label>
<textarea id="mciResponseSource" class="form-control" rows="13" spellcheck="false" placeholder="package ...;&#10;&#10;public class ONBSZ0460_O {&#10; @GlowTrgmField(order = 1, length = 8, description = &quot;인사번호&quot;)&#10; private String prafNo;&#10;}"></textarea>
</div>
<div class="d-flex justify-content-end mb-4">
<button id="mciResponseAnalyzeButton" type="button" class="btn-action" onclick="analyzeMciResponse()">AI로 필드명 분석</button>
</div>
<div id="mciResponseResultArea" style="display:none;">
<div class="row g-3 mb-4">
<div class="col-md-6">
<label class="form-label" for="mciResponsePackage">Response Package</label>
<input id="mciResponsePackage" type="text" class="form-control" value="io.shinhanlife.dat.mcc.biz.pro.dto">
</div>
<div class="col-md-6">
<label class="form-label" for="mciResponseClassName">Response Class</label>
<input id="mciResponseClassName" type="text" class="form-control" placeholder="IndividualCustomerDetailInquiryResponse">
<div class="input-hint">업무 의미가 드러나는 PascalCase 이름으로 최종 확인하세요.</div>
</div>
<div class="col-md-6">
<label class="form-label" for="mciConverterPackage">Converter Package</label>
<input id="mciConverterPackage" type="text" class="form-control" value="io.shinhanlife.dat.mcc.biz.pro.converter">
</div>
<div class="col-md-6">
<label class="form-label" for="mciConverterClassName">Converter Class</label>
<input id="mciConverterClassName" type="text" class="form-control" placeholder="IndividualCustomerDetailInquiryConverter">
</div>
</div>
<div class="mb-4">
<div class="d-flex justify-content-between align-items-center mb-2 flex-wrap gap-2">
<div>
<label class="form-label mb-0">필드 매핑 검토</label>
<div class="input-hint mt-1">AI 제안은 직접 수정할 수 있습니다. description은 Glow 원문 그대로 Response의 <code>@Schema</code>에 반영됩니다.</div>
</div>
<span id="mciResponseFieldCount" class="badge-mono"></span>
</div>
<div class="table-responsive rounded" style="border:1px solid #3f3f46;">
<table class="table mb-0" style="color:#d4d4d8; font-size:0.82rem;">
<thead style="background:#18181b; color:#a1a1aa;">
<tr>
<th style="width:7%;">사용</th>
<th style="width:17%;">소유 타입</th>
<th style="width:15%;">전문 필드</th>
<th style="width:22%;">Glow 설명</th>
<th style="width:23%;">LLM 필드명</th>
<th style="width:10%;">Java 타입</th>
<th style="width:6%;">확인</th>
</tr>
</thead>
<tbody id="mciResponseMappingBody"></tbody>
</table>
</div>
</div>
<div class="d-flex justify-content-end mb-4">
<button id="mciResponseGenerateButton" type="button" class="btn-action" onclick="generateMciResponseSources()">Response / Converter 미리보기 생성</button>
</div>
<div id="mciResponsePreviewArea" style="display:none;">
<div class="row g-3">
<div class="col-lg-6">
<div class="d-flex justify-content-between align-items-center mb-2">
<label class="form-label mb-0">Response.java</label>
<div class="d-flex gap-2">
<button type="button" class="btn-secondary-action" onclick="copyMciResponseSource('mciResponsePreview')">복사</button>
<button type="button" class="btn-secondary-action" onclick="downloadMciResponseSource('response')">다운로드</button>
</div>
</div>
<textarea id="mciResponsePreview" class="form-control" rows="22" spellcheck="false" readonly></textarea>
</div>
<div class="col-lg-6">
<div class="d-flex justify-content-between align-items-center mb-2">
<label class="form-label mb-0">Converter.java</label>
<div class="d-flex gap-2">
<button type="button" class="btn-secondary-action" onclick="copyMciResponseSource('mciConverterPreview')">복사</button>
<button type="button" class="btn-secondary-action" onclick="downloadMciResponseSource('converter')">다운로드</button>
</div>
</div>
<textarea id="mciConverterPreview" class="form-control" rows="22" spellcheck="false" readonly></textarea>
</div>
</div>
</div>
</div>
<div id="mciResponseStatus" class="input-hint mt-3" role="status" aria-live="polite"></div>
</div>
<!-- Document Generator --> <!-- Document Generator -->
<div class="tab-pane fade document-generator" id="document" role="tabpanel" aria-labelledby="document-tab"> <div class="tab-pane fade document-generator" id="document" role="tabpanel" aria-labelledby="document-tab">
<div class="docgen-workspace"> <div class="docgen-workspace">
@@ -2483,6 +2611,288 @@
} }
</script> </script>
<script>
const mciResponseState = {
parsed: null,
responseSource: '',
converterSource: '',
autoConverterName: ''
};
document.getElementById('mciResponseFile').addEventListener('change', async function() {
const file = this.files && this.files[0];
if (!file) return;
try {
document.getElementById('mciResponseSource').value = (await file.text()).replace(/^\uFEFF/, '');
setMciResponseStatus(`${file.name} 파일을 불러왔습니다.`, false);
} catch (error) {
setMciResponseStatus(`파일을 읽지 못했습니다: ${error.message}`, true);
}
});
document.getElementById('mciResponseClassName').addEventListener('input', function() {
const converterInput = document.getElementById('mciConverterClassName');
if (!converterInput.value || converterInput.value === mciResponseState.autoConverterName) {
mciResponseState.autoConverterName = converterNameFor(this.value);
converterInput.value = mciResponseState.autoConverterName;
}
});
document.getElementById('mciResponsePackage').addEventListener('input', function() {
const converterPackage = document.getElementById('mciConverterPackage');
if (this.value.endsWith('.dto')) converterPackage.value = this.value.slice(0, -4) + '.converter';
});
async function analyzeMciResponse() {
const source = document.getElementById('mciResponseSource').value.trim();
if (!source) {
setMciResponseStatus('XXXX_O.java 파일을 선택하거나 소스를 붙여넣어 주세요.', true);
return;
}
const button = document.getElementById('mciResponseAnalyzeButton');
setMciResponseButtonBusy(button, true, 'AI 분석 중...');
setMciResponseStatus('Glow 전문 구조를 분석하고 AI가 LLM 필드명을 제안하고 있습니다.', false);
try {
const response = await fetch('/api/v1/scaffold/mci-response/analyze', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
source,
model: document.getElementById('mciResponseAiModel').value
})
});
const result = await response.json();
if (!response.ok) throw new Error(result.error || `AI 분석 실패 (HTTP ${response.status})`);
mciResponseState.parsed = result.parsed;
mciResponseState.responseSource = '';
mciResponseState.converterSource = '';
initializeMciResponseNames(result.parsed);
renderMciResponseMappings(result.parsed, result.mappings || []);
document.getElementById('mciResponseResultArea').style.display = 'block';
document.getElementById('mciResponsePreviewArea').style.display = 'none';
setMciResponseStatus('AI 제안을 불러왔습니다. 필드명과 개인정보 표시를 검토한 뒤 미리보기를 생성하세요.', false);
} catch (error) {
setMciResponseStatus(error.message, true);
} finally {
setMciResponseButtonBusy(button, false, 'AI로 필드명 분석');
}
}
function initializeMciResponseNames(parsed) {
const rootName = parsed?.rootClassName || 'MciOutput';
const baseName = rootName.replace(/_O$/, '') || 'MciOutput';
const responseName = `${baseName}Response`;
const converterName = `${baseName}Converter`;
document.getElementById('mciResponseClassName').value = responseName;
document.getElementById('mciConverterClassName').value = converterName;
mciResponseState.autoConverterName = converterName;
const sourcePackage = parsed?.packageName || '';
const applicationRoot = sourcePackage.includes('.infra.')
? sourcePackage.substring(0, sourcePackage.indexOf('.infra.')) : '';
if (applicationRoot) {
document.getElementById('mciResponsePackage').value = `${applicationRoot}.biz.pro.dto`;
document.getElementById('mciConverterPackage').value = `${applicationRoot}.biz.pro.converter`;
}
}
function renderMciResponseMappings(parsed, mappings) {
const tbody = document.getElementById('mciResponseMappingBody');
tbody.replaceChildren();
const mappingByKey = new Map(mappings.map(mapping => [
`${mapping.ownerType}#${mapping.sourceName}`, mapping
]));
let fieldCount = 0;
let sensitiveCount = 0;
(parsed.types || []).forEach(type => {
(type.fields || []).forEach(field => {
fieldCount++;
if (field.sensitive) sensitiveCount++;
const mapping = mappingByKey.get(`${type.name}#${field.name}`) || {
ownerType: type.name,
sourceName: field.name,
targetName: field.name,
include: true
};
tbody.appendChild(createMciResponseMappingRow(type, field, mapping));
});
});
document.getElementById('mciResponseFieldCount').textContent = sensitiveCount
? `${fieldCount}개 필드 · 개인정보 후보 ${sensitiveCount}`
: `${fieldCount}개 필드`;
}
function createMciResponseMappingRow(type, field, mapping) {
const row = document.createElement('tr');
row.dataset.ownerType = type.name;
row.dataset.sourceName = field.name;
const includeCell = document.createElement('td');
const include = document.createElement('input');
include.type = 'checkbox';
include.className = 'form-check-input mci-response-include';
include.checked = mapping.include !== false;
includeCell.appendChild(include);
const ownerCell = document.createElement('td');
ownerCell.textContent = type.name;
const sourceCell = document.createElement('td');
const sourceCode = document.createElement('code');
sourceCode.textContent = field.name;
sourceCell.appendChild(sourceCode);
const descriptionCell = document.createElement('td');
descriptionCell.textContent = field.description || '(설명 없음)';
const targetCell = document.createElement('td');
const targetInput = document.createElement('input');
targetInput.type = 'text';
targetInput.className = 'form-control form-control-sm mci-response-target';
targetInput.value = mapping.targetName || field.name;
targetInput.pattern = '^[a-z][A-Za-z0-9]*$';
targetInput.title = '영문 소문자로 시작하는 camelCase Java 필드명을 입력하세요.';
targetInput.addEventListener('input', validateMciResponseMappingRows);
targetCell.appendChild(targetInput);
const typeCell = document.createElement('td');
typeCell.textContent = field.type;
const warningCell = document.createElement('td');
if (field.sensitive) {
const badge = document.createElement('span');
badge.className = 'badge-mono';
badge.style.color = '#fca5a5';
badge.style.borderColor = '#7f1d1d';
badge.style.background = 'rgba(127,29,29,0.22)';
badge.textContent = 'PII';
badge.title = '개인정보 가능성이 있는 필드입니다. Tool 응답 노출 여부를 반드시 확인하세요.';
warningCell.appendChild(badge);
} else {
warningCell.textContent = '-';
}
[includeCell, ownerCell, sourceCell, descriptionCell, targetCell, typeCell, warningCell]
.forEach(cell => row.appendChild(cell));
return row;
}
function validateMciResponseMappingRows() {
const usedByOwner = new Map();
let valid = true;
document.querySelectorAll('#mciResponseMappingBody tr').forEach(row => {
const input = row.querySelector('.mci-response-target');
const include = row.querySelector('.mci-response-include').checked;
input.classList.remove('is-invalid');
if (!include) return;
const name = input.value.trim();
const owner = row.dataset.ownerType;
const used = usedByOwner.get(owner) || new Set();
if (!/^[a-z][A-Za-z0-9]*$/.test(name) || used.has(name)) {
input.classList.add('is-invalid');
valid = false;
}
used.add(name);
usedByOwner.set(owner, used);
});
return valid;
}
function collectMciResponseMappings() {
if (!validateMciResponseMappingRows()) {
throw new Error('빨간색으로 표시된 LLM 필드명을 수정하세요. camelCase 형식이며 같은 타입 안에서 중복될 수 없습니다.');
}
return Array.from(document.querySelectorAll('#mciResponseMappingBody tr')).map(row => ({
ownerType: row.dataset.ownerType,
sourceName: row.dataset.sourceName,
targetName: row.querySelector('.mci-response-target').value.trim(),
include: row.querySelector('.mci-response-include').checked
}));
}
async function generateMciResponseSources() {
if (!mciResponseState.parsed) {
setMciResponseStatus('먼저 AI 필드명 분석을 실행하세요.', true);
return;
}
const button = document.getElementById('mciResponseGenerateButton');
setMciResponseButtonBusy(button, true, '소스 생성 중...');
try {
const payload = {
source: document.getElementById('mciResponseSource').value,
responsePackage: document.getElementById('mciResponsePackage').value.trim(),
responseClassName: document.getElementById('mciResponseClassName').value.trim(),
converterPackage: document.getElementById('mciConverterPackage').value.trim(),
converterClassName: document.getElementById('mciConverterClassName').value.trim(),
mappings: collectMciResponseMappings()
};
const response = await fetch('/api/v1/scaffold/mci-response/generate', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
});
const result = await response.json();
if (!response.ok) throw new Error(result.error || `소스 생성 실패 (HTTP ${response.status})`);
mciResponseState.responseSource = result.responseSource || '';
mciResponseState.converterSource = result.converterSource || '';
document.getElementById('mciResponsePreview').value = mciResponseState.responseSource;
document.getElementById('mciConverterPreview').value = mciResponseState.converterSource;
document.getElementById('mciResponsePreviewArea').style.display = 'block';
setMciResponseStatus('Response와 MapStruct Converter를 생성했습니다. 미리보기 검토 후 다운로드하세요.', false);
} catch (error) {
setMciResponseStatus(error.message, true);
} finally {
setMciResponseButtonBusy(button, false, 'Response / Converter 미리보기 생성');
}
}
async function copyMciResponseSource(elementId) {
const textarea = document.getElementById(elementId);
if (!textarea.value) return;
try {
await navigator.clipboard.writeText(textarea.value);
} catch (_) {
textarea.select();
document.execCommand('copy');
textarea.setSelectionRange(0, 0);
}
setMciResponseStatus('소스를 클립보드에 복사했습니다.', false);
}
function downloadMciResponseSource(kind) {
const response = kind === 'response';
const source = response ? mciResponseState.responseSource : mciResponseState.converterSource;
const className = document.getElementById(response ? 'mciResponseClassName' : 'mciConverterClassName').value.trim();
if (!source || !className) {
setMciResponseStatus('먼저 소스 미리보기를 생성하세요.', true);
return;
}
const blob = new Blob([source], {type: 'text/x-java-source;charset=utf-8'});
const href = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = href;
anchor.download = `${className}.java`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(href);
}
function converterNameFor(responseName) {
return responseName && responseName.endsWith('Response')
? responseName.slice(0, -8) + 'Converter'
: (responseName ? responseName + 'Converter' : '');
}
function setMciResponseButtonBusy(button, busy, text) {
button.disabled = busy;
button.textContent = text;
button.style.opacity = busy ? '0.7' : '1';
}
function setMciResponseStatus(message, error) {
const status = document.getElementById('mciResponseStatus');
status.textContent = message || '';
status.style.color = error ? '#f87171' : '#a1a1aa';
}
</script>
<script> <script>
function openServerFolderPicker(inputId) { function openServerFolderPicker(inputId) {

View File

@@ -115,6 +115,85 @@ class ScaffoldingControllerToolDraftTest {
assertTrue(promptCaptor.getValue().contains("Default to MCI")); assertTrue(promptCaptor.getValue().contains("Default to MCI"));
} }
@Test
void mciResponseAnalyzeKeepsGlowDescriptionAndUsesAiForTheLlmFieldName() throws Exception {
ChatClient.Builder builder = mock(ChatClient.Builder.class);
ChatClient chatClient = mock(ChatClient.class);
ChatClient.ChatClientRequestSpec requestSpec = mock(ChatClient.ChatClientRequestSpec.class);
ChatClient.CallResponseSpec responseSpec = mock(ChatClient.CallResponseSpec.class);
when(builder.build()).thenReturn(chatClient);
when(chatClient.prompt()).thenReturn(requestSpec);
when(requestSpec.user(anyString())).thenReturn(requestSpec);
when(requestSpec.options(any(ChatOptions.class))).thenReturn(requestSpec);
when(requestSpec.call()).thenReturn(responseSpec);
when(responseSpec.content()).thenReturn("""
{"mappings":[{"ownerType":"ONBSZ0460_O","sourceName":"prafNo","targetName":"employeeNumber","include":true}]}
""");
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(
new ScaffoldingController(builder, new ObjectMapper()))
.setMessageConverters(new MappingJackson2HttpMessageConverter())
.build();
String source = """
package io.shinhanlife.dat.mcc.infra.itrf.mci.onbsz.io;
public class ONBSZ0460_O {
@GlowTrgmField(order = 1, length = 8, description = "인사번호")
private String prafNo;
}
""";
String request = new ObjectMapper().writeValueAsString(java.util.Map.of(
"source", source,
"model", "cohere/north-mini-code:free"));
mockMvc.perform(post("/api/v1/scaffold/mci-response/analyze")
.contentType(MediaType.APPLICATION_JSON).content(request))
.andExpect(status().isOk())
.andExpect(jsonPath("$.parsed.rootClassName").value("ONBSZ0460_O"))
.andExpect(jsonPath("$.parsed.types[0].fields[0].description").value("인사번호"))
.andExpect(jsonPath("$.mappings[0].sourceName").value("prafNo"))
.andExpect(jsonPath("$.mappings[0].targetName").value("employeeNumber"));
org.mockito.ArgumentCaptor<String> promptCaptor = org.mockito.ArgumentCaptor.forClass(String.class);
verify(requestSpec).user(promptCaptor.capture());
assertTrue(promptCaptor.getValue().contains("인사번호"));
assertTrue(promptCaptor.getValue().contains("prafNo"));
}
@Test
void mciResponseGenerateReturnsResponseAndConverterPreviews() throws Exception {
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(
new ScaffoldingController(mock(ChatClient.Builder.class), new ObjectMapper()))
.setMessageConverters(new MappingJackson2HttpMessageConverter())
.build();
String source = """
package io.shinhanlife.dat.mcc.infra.itrf.mci.onbsz.io;
public class ONBSZ0460_O {
@GlowTrgmField(order = 1, length = 8, description = "인사번호")
private String prafNo;
}
""";
String request = new ObjectMapper().writeValueAsString(java.util.Map.of(
"source", source,
"responsePackage", "io.shinhanlife.dat.mcc.biz.pro.dto",
"responseClassName", "IndividualCustomerDetailInquiryResponse",
"converterPackage", "io.shinhanlife.dat.mcc.biz.pro.converter",
"converterClassName", "IndividualCustomerDetailInquiryConverter",
"mappings", java.util.List.of(java.util.Map.of(
"ownerType", "ONBSZ0460_O",
"sourceName", "prafNo",
"targetName", "employeeNumber",
"include", true))));
mockMvc.perform(post("/api/v1/scaffold/mci-response/generate")
.contentType(MediaType.APPLICATION_JSON).content(request))
.andExpect(status().isOk())
.andExpect(jsonPath("$.responseSource", org.hamcrest.Matchers.containsString(
"@Schema(description = \"인사번호\")")))
.andExpect(jsonPath("$.responseSource", org.hamcrest.Matchers.containsString(
"private String employeeNumber;")))
.andExpect(jsonPath("$.converterSource", org.hamcrest.Matchers.containsString(
"@Mapping(source = \"prafNo\", target = \"employeeNumber\")")));
}
@Test @Test
void podDraftUsesTheCurrentTargetModuleOptionsForConfusableServers() { void podDraftUsesTheCurrentTargetModuleOptionsForConfusableServers() {
ChatClient.Builder builder = mock(ChatClient.Builder.class); ChatClient.Builder builder = mock(ChatClient.Builder.class);

View File

@@ -63,6 +63,7 @@ public class WebConfig implements WebMvcConfigurer {
registration.setFilter(new MdcLoggingFilter()); registration.setFilter(new MdcLoggingFilter());
registration.addUrlPatterns("/*"); registration.addUrlPatterns("/*");
registration.setOrder(1); registration.setOrder(1);
registration.setAsyncSupported(true);
return registration; return registration;
} }
@@ -73,6 +74,7 @@ public class WebConfig implements WebMvcConfigurer {
registration.setFilter(new McpRequestHeaderFilter(sessionService)); registration.setFilter(new McpRequestHeaderFilter(sessionService));
registration.addUrlPatterns("/*"); registration.addUrlPatterns("/*");
registration.setOrder(2); registration.setOrder(2);
registration.setAsyncSupported(true);
return registration; return registration;
} }
} }

View File

@@ -0,0 +1,410 @@
package io.shinhanlife.dat.lib.util;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Glow MCI 응답 전문({@code *_O.java})을 분석하여 LLM 노출용 Response DTO와
* MapStruct Converter 소스를 생성합니다. AI는 필드명 제안에만 사용하고 전문 구조와
* {@code GlowTrgmField.description}은 이 클래스가 원문에서 직접 추출합니다.
*/
public final class MciResponseScaffolder {
private static final Pattern PACKAGE_PATTERN = Pattern.compile(
"(?m)^\\s*package\\s+([A-Za-z_$][\\w$]*(?:\\.[A-Za-z_$][\\w$]*)*)\\s*;");
private static final Pattern CLASS_PATTERN = Pattern.compile(
"\\bclass\\s+([A-Za-z_$][\\w$]*)\\b");
private static final Pattern FIELD_PATTERN = Pattern.compile(
"(?s)((?:@[A-Za-z_$][\\w.$]*(?:\\s*\\([^;{}]*?\\))?\\s*)*)"
+ "(?:private|protected|public)\\s+"
+ "(?:(?:static|final|transient|volatile)\\s+)*"
+ "([A-Za-z_$][\\w.$]*(?:\\s*<[^;{}=]+>)?(?:\\s*\\[\\])?)\\s+"
+ "([A-Za-z_$][\\w$]*)\\s*(?:=[^;{}]*)?;");
private static final Pattern ORDER_PATTERN = Pattern.compile("\\border\\s*=\\s*(\\d+)");
private static final Pattern LENGTH_PATTERN = Pattern.compile("\\blength\\s*=\\s*(\\d+)");
private static final Pattern DESCRIPTION_PATTERN = Pattern.compile(
"\\bdescription\\s*=\\s*\"((?:\\\\.|[^\"\\\\])*)\"");
private static final Pattern JAVA_NAME_PATTERN = Pattern.compile("^[a-z][A-Za-z0-9]*$");
private static final Pattern PACKAGE_NAME_PATTERN = Pattern.compile(
"^[a-z_][a-z0-9_]*(?:\\.[a-z_][a-z0-9_]*)*$");
private static final Pattern CLASS_NAME_PATTERN = Pattern.compile("^[A-Z][A-Za-z0-9]*$");
private static final List<String> SENSITIVE_KEYWORDS = List.of(
"주민등록", "주민번호", "외국인등록", "여권번호", "계좌번호", "카드번호",
"비밀번호", "암호", "resident registration", "social security", "passport", "password");
private MciResponseScaffolder() {
}
public record ParsedSource(String packageName, String rootClassName, List<ParsedType> types) {
}
public record ParsedType(String name, String parentName, List<ParsedField> fields) {
}
public record ParsedField(String ownerType, int order, int length, String type, String name,
String description, boolean sensitive) {
}
public record FieldMapping(String ownerType, String sourceName, String targetName, boolean include) {
}
public record GeneratedSources(String responseSource, String converterSource) {
}
public static ParsedSource parse(String source) {
if (source == null || source.isBlank()) {
throw new IllegalArgumentException("MCI 응답 Java 소스를 입력해주세요.");
}
String normalized = source.replace("\uFEFF", "");
Matcher packageMatcher = PACKAGE_PATTERN.matcher(normalized);
if (!packageMatcher.find()) {
throw new IllegalArgumentException("package 선언을 찾을 수 없습니다.");
}
List<ClassRange> ranges = findClassRanges(normalized);
if (ranges.isEmpty()) {
throw new IllegalArgumentException("Java class 선언을 찾을 수 없습니다.");
}
ClassRange root = ranges.stream()
.filter(range -> range.parentName() == null && range.name().endsWith("_O"))
.findFirst()
.orElseGet(() -> ranges.stream().filter(range -> range.parentName() == null)
.findFirst().orElse(ranges.getFirst()));
List<ParsedType> types = new ArrayList<>();
types.add(new ParsedType(root.name(), null, parseFields(normalized, root)));
ranges.stream()
.filter(range -> range != root && isDescendantOf(range, root, ranges))
.sorted(Comparator.comparingInt(ClassRange::openBrace))
.forEach(range -> types.add(new ParsedType(
range.name(), range.parentName(), parseFields(normalized, range))));
if (types.stream().allMatch(type -> type.fields().isEmpty())) {
throw new IllegalArgumentException("변환할 응답 필드를 찾을 수 없습니다.");
}
return new ParsedSource(packageMatcher.group(1), root.name(), List.copyOf(types));
}
public static GeneratedSources generate(ParsedSource parsed, String responsePackage,
String responseClassName, String converterPackage,
String converterClassName, List<FieldMapping> mappings) {
if (parsed == null || parsed.types() == null || parsed.types().isEmpty()) {
throw new IllegalArgumentException("분석된 MCI 응답 정보가 없습니다.");
}
validatePackage(responsePackage, "Response package");
validatePackage(converterPackage, "Converter package");
validateClassName(responseClassName, "Response class");
validateClassName(converterClassName, "Converter class");
Map<String, FieldMapping> mappingBySource = normalizeMappings(parsed, mappings);
validateTargetNames(parsed, mappingBySource);
return new GeneratedSources(
responseSource(parsed, responsePackage, responseClassName, mappingBySource),
converterSource(parsed, responsePackage, responseClassName, converterPackage,
converterClassName, mappingBySource));
}
private static List<ClassRange> findClassRanges(String source) {
List<ClassRangeDraft> drafts = new ArrayList<>();
Matcher matcher = CLASS_PATTERN.matcher(source);
while (matcher.find()) {
int openBrace = source.indexOf('{', matcher.end());
if (openBrace < 0) {
continue;
}
int closeBrace = matchingBrace(source, openBrace);
if (closeBrace > openBrace) {
drafts.add(new ClassRangeDraft(matcher.group(1), openBrace, closeBrace));
}
}
drafts.sort(Comparator.comparingInt(ClassRangeDraft::openBrace));
List<ClassRange> ranges = new ArrayList<>();
for (ClassRangeDraft draft : drafts) {
String parent = drafts.stream()
.filter(candidate -> candidate != draft
&& candidate.openBrace() < draft.openBrace()
&& candidate.closeBrace() > draft.closeBrace())
.min(Comparator.comparingInt(candidate -> candidate.closeBrace() - candidate.openBrace()))
.map(ClassRangeDraft::name)
.orElse(null);
ranges.add(new ClassRange(draft.name(), parent, draft.openBrace(), draft.closeBrace()));
}
return ranges;
}
private static boolean isDescendantOf(ClassRange range, ClassRange root, List<ClassRange> ranges) {
if (range.openBrace() <= root.openBrace() || range.closeBrace() >= root.closeBrace()) {
return false;
}
String parent = range.parentName();
while (parent != null) {
if (parent.equals(root.name())) {
return true;
}
String current = parent;
parent = ranges.stream().filter(candidate -> candidate.name().equals(current))
.map(ClassRange::parentName).findFirst().orElse(null);
}
return false;
}
private static List<ParsedField> parseFields(String source, ClassRange range) {
String body = source.substring(range.openBrace() + 1, range.closeBrace());
Matcher matcher = FIELD_PATTERN.matcher(body);
List<FieldWithPosition> fields = new ArrayList<>();
while (matcher.find()) {
int absoluteStart = range.openBrace() + 1 + matcher.start();
if (braceDepth(source, range.openBrace() + 1, absoluteStart) != 0) {
continue;
}
String annotations = matcher.group(1) == null ? "" : matcher.group(1);
String type = matcher.group(2).replaceAll("\\s+", "");
String name = matcher.group(3);
int order = intAttribute(annotations, ORDER_PATTERN, Integer.MAX_VALUE);
int length = intAttribute(annotations, LENGTH_PATTERN, 0);
String description = stringAttribute(annotations, DESCRIPTION_PATTERN);
fields.add(new FieldWithPosition(absoluteStart, new ParsedField(
range.name(), order, length, type, name, description, isSensitive(name, description))));
}
fields.sort(Comparator
.comparingInt((FieldWithPosition value) -> value.field().order())
.thenComparingInt(FieldWithPosition::position));
return fields.stream().map(FieldWithPosition::field).toList();
}
private static int intAttribute(String source, Pattern pattern, int defaultValue) {
Matcher matcher = pattern.matcher(source);
return matcher.find() ? Integer.parseInt(matcher.group(1)) : defaultValue;
}
private static String stringAttribute(String source, Pattern pattern) {
Matcher matcher = pattern.matcher(source);
return matcher.find() ? unescapeJavaString(matcher.group(1)) : "";
}
private static String unescapeJavaString(String value) {
return value.replace("\\\"", "\"")
.replace("\\n", "\n")
.replace("\\r", "\r")
.replace("\\t", "\t")
.replace("\\\\", "\\");
}
private static boolean isSensitive(String name, String description) {
String text = (name + " " + description).toLowerCase(Locale.ROOT);
return SENSITIVE_KEYWORDS.stream().anyMatch(text::contains);
}
private static int matchingBrace(String source, int openBrace) {
int depth = 0;
ScanState state = ScanState.CODE;
for (int i = openBrace; i < source.length(); i++) {
char current = source.charAt(i);
char next = i + 1 < source.length() ? source.charAt(i + 1) : '\0';
if (state == ScanState.CODE) {
if (current == '/' && next == '/') { state = ScanState.LINE_COMMENT; i++; continue; }
if (current == '/' && next == '*') { state = ScanState.BLOCK_COMMENT; i++; continue; }
if (current == '"') { state = ScanState.STRING; continue; }
if (current == '\'') { state = ScanState.CHAR; continue; }
if (current == '{') depth++;
if (current == '}' && --depth == 0) return i;
} else if (state == ScanState.LINE_COMMENT && (current == '\n' || current == '\r')) {
state = ScanState.CODE;
} else if (state == ScanState.BLOCK_COMMENT && current == '*' && next == '/') {
state = ScanState.CODE; i++;
} else if ((state == ScanState.STRING || state == ScanState.CHAR) && current == '\\') {
i++;
} else if (state == ScanState.STRING && current == '"') {
state = ScanState.CODE;
} else if (state == ScanState.CHAR && current == '\'') {
state = ScanState.CODE;
}
}
return -1;
}
private static int braceDepth(String source, int from, int to) {
int depth = 0;
ScanState state = ScanState.CODE;
for (int i = from; i < to; i++) {
char current = source.charAt(i);
char next = i + 1 < to ? source.charAt(i + 1) : '\0';
if (state == ScanState.CODE) {
if (current == '/' && next == '/') { state = ScanState.LINE_COMMENT; i++; continue; }
if (current == '/' && next == '*') { state = ScanState.BLOCK_COMMENT; i++; continue; }
if (current == '"') { state = ScanState.STRING; continue; }
if (current == '\'') { state = ScanState.CHAR; continue; }
if (current == '{') depth++;
if (current == '}') depth--;
} else if (state == ScanState.LINE_COMMENT && (current == '\n' || current == '\r')) {
state = ScanState.CODE;
} else if (state == ScanState.BLOCK_COMMENT && current == '*' && next == '/') {
state = ScanState.CODE; i++;
} else if ((state == ScanState.STRING || state == ScanState.CHAR) && current == '\\') {
i++;
} else if (state == ScanState.STRING && current == '"') {
state = ScanState.CODE;
} else if (state == ScanState.CHAR && current == '\'') {
state = ScanState.CODE;
}
}
return depth;
}
private static Map<String, FieldMapping> normalizeMappings(ParsedSource parsed, List<FieldMapping> mappings) {
Map<String, FieldMapping> normalized = new LinkedHashMap<>();
if (mappings != null) {
for (FieldMapping mapping : mappings) {
if (mapping == null || mapping.ownerType() == null || mapping.sourceName() == null) continue;
normalized.put(key(mapping.ownerType(), mapping.sourceName()), mapping);
}
}
for (ParsedType type : parsed.types()) {
for (ParsedField field : type.fields()) {
normalized.putIfAbsent(key(type.name(), field.name()),
new FieldMapping(type.name(), field.name(), field.name(), true));
}
}
return normalized;
}
private static void validateTargetNames(ParsedSource parsed, Map<String, FieldMapping> mappings) {
for (ParsedType type : parsed.types()) {
Set<String> targets = new HashSet<>();
for (ParsedField field : type.fields()) {
FieldMapping mapping = mappings.get(key(type.name(), field.name()));
if (!mapping.include()) continue;
String target = mapping.targetName() == null ? "" : mapping.targetName().trim();
if (!JAVA_NAME_PATTERN.matcher(target).matches()) {
throw new IllegalArgumentException("올바르지 않은 LLM 필드명: " + target);
}
if (!targets.add(target)) {
throw new IllegalArgumentException(type.name() + " 안에 중복 LLM 필드명이 있습니다: " + target);
}
}
}
}
private static String responseSource(ParsedSource parsed, String responsePackage, String responseClassName,
Map<String, FieldMapping> mappings) {
boolean usesList = parsed.types().stream().flatMap(type -> type.fields().stream())
.anyMatch(field -> field.type().contains("List<"));
boolean usesBigDecimal = parsed.types().stream().flatMap(type -> type.fields().stream())
.anyMatch(field -> field.type().contains("BigDecimal"));
StringBuilder source = new StringBuilder("package ").append(responsePackage).append(";\n\n")
.append("import com.fasterxml.jackson.annotation.JsonInclude;\n")
.append("import io.swagger.v3.oas.annotations.media.Schema;\n")
.append("import lombok.Data;\n");
if (usesBigDecimal) source.append("import java.math.BigDecimal;\n");
if (usesList) source.append("import java.util.List;\n");
source.append("\n@Data\n@JsonInclude(JsonInclude.Include.NON_NULL)\n")
.append("public class ").append(responseClassName).append(" {\n\n")
.append(responseFields(parsed.types().getFirst(), mappings, " "));
for (ParsedType type : parsed.types().stream().skip(1).toList()) {
source.append(" @Data\n")
.append(" @JsonInclude(JsonInclude.Include.NON_NULL)\n")
.append(" public static class ").append(type.name()).append(" {\n\n")
.append(responseFields(type, mappings, " "))
.append(" }\n\n");
}
return source.append("}\n").toString();
}
private static String responseFields(ParsedType type, Map<String, FieldMapping> mappings, String indent) {
StringBuilder source = new StringBuilder();
for (ParsedField field : type.fields()) {
FieldMapping mapping = mappings.get(key(type.name(), field.name()));
if (!mapping.include()) continue;
source.append(indent).append("@Schema(description = \"")
.append(escapeJava(field.description())).append("\")\n")
.append(indent).append("private ").append(field.type()).append(' ')
.append(mapping.targetName().trim()).append(";\n\n");
}
return source.toString();
}
private static String converterSource(ParsedSource parsed, String responsePackage, String responseClassName,
String converterPackage, String converterClassName,
Map<String, FieldMapping> mappings) {
StringBuilder source = new StringBuilder("package ").append(converterPackage).append(";\n\n")
.append("import ").append(responsePackage).append('.').append(responseClassName).append(";\n")
.append("import ").append(parsed.packageName()).append('.').append(parsed.rootClassName()).append(";\n")
.append("import org.mapstruct.Mapper;\n")
.append("import org.mapstruct.Mapping;\n")
.append("import org.mapstruct.ReportingPolicy;\n\n")
.append("@Mapper(componentModel = \"spring\", unmappedTargetPolicy = ReportingPolicy.IGNORE)\n")
.append("public interface ").append(converterClassName).append(" {\n\n");
appendMappingMethod(source, parsed.types().getFirst(), responseClassName,
parsed.rootClassName(), "toResponse", mappings);
Map<String, ParsedType> typeByName = new HashMap<>();
parsed.types().forEach(type -> typeByName.put(type.name(), type));
for (ParsedType type : parsed.types().stream().skip(1).toList()) {
appendMappingMethod(source, type, responseClassName + "." + type.name(),
sourceTypePath(parsed.rootClassName(), type, typeByName), "to" + type.name(), mappings);
}
return source.append("}\n").toString();
}
private static void appendMappingMethod(StringBuilder source, ParsedType type, String targetType,
String sourceType, String methodName,
Map<String, FieldMapping> mappings) {
for (ParsedField field : type.fields()) {
FieldMapping mapping = mappings.get(key(type.name(), field.name()));
if (mapping.include() && !field.name().equals(mapping.targetName())) {
source.append(" @Mapping(source = \"").append(field.name())
.append("\", target = \"").append(mapping.targetName()).append("\")\n");
}
}
source.append(" ").append(targetType).append(' ').append(methodName)
.append('(').append(sourceType).append(" source);\n\n");
}
private static String sourceTypePath(String rootClassName, ParsedType type,
Map<String, ParsedType> typeByName) {
List<String> names = new ArrayList<>();
ParsedType current = type;
while (current != null && !current.name().equals(rootClassName)) {
names.addFirst(current.name());
current = current.parentName() == null ? null : typeByName.get(current.parentName());
}
return rootClassName + "." + String.join(".", names);
}
private static void validatePackage(String value, String label) {
if (value == null || !PACKAGE_NAME_PATTERN.matcher(value).matches()) {
throw new IllegalArgumentException(label + " 이름이 올바르지 않습니다: " + value);
}
}
private static void validateClassName(String value, String label) {
if (value == null || !CLASS_NAME_PATTERN.matcher(value).matches()) {
throw new IllegalArgumentException(label + " 이름이 올바르지 않습니다: " + value);
}
}
private static String key(String ownerType, String sourceName) {
return ownerType + "#" + sourceName;
}
private static String escapeJava(String value) {
return value == null ? "" : value.replace("\\", "\\\\")
.replace("\"", "\\\"").replace("\r", "\\r").replace("\n", "\\n");
}
private enum ScanState { CODE, STRING, CHAR, LINE_COMMENT, BLOCK_COMMENT }
private record ClassRangeDraft(String name, int openBrace, int closeBrace) { }
private record ClassRange(String name, String parentName, int openBrace, int closeBrace) { }
private record FieldWithPosition(int position, ParsedField field) { }
}

View File

@@ -451,6 +451,17 @@ public class PodScaffolder {
} }
private static String applicationProfileYml(String profile, String port) { private static String applicationProfileYml(String profile, String port) {
if ("local".equals(profile)) {
return """
spring:
config:
activate:
on-profile: local
import:
- classpath:glow/application-glow.yml
- classpath:glow/application-glow-local.yml
""";
}
return """ return """
server: server:
port: ${PORT:%s} port: ${PORT:%s}
@@ -477,8 +488,8 @@ public class PodScaffolder {
List<String> targetModules) List<String> targetModules)
throws IOException { throws IOException {
Map<String, Object> root = parseManifestYaml(source); Map<String, Object> root = parseManifestYaml(source);
if (!(root.get("mcp") instanceof Map<?, ?> mcp) Map<String, Object> manifest = manifestNode(root);
|| !(mcp.get("manifest") instanceof Map<?, ?> manifest)) { if (manifest == null) {
throw new IOException("tool-service-manifest.yml의 mcp.manifest.routing-functions 형식이 올바르지 않습니다."); throw new IOException("tool-service-manifest.yml의 mcp.manifest.routing-functions 형식이 올바르지 않습니다.");
} }
@@ -491,10 +502,29 @@ public class PodScaffolder {
routingFunction.put("server-id", moduleName); routingFunction.put("server-id", moduleName);
routingFunction.put("category-key", categoryKey); routingFunction.put("category-key", categoryKey);
routingFunction.put("confusable-servers", allowedTargetModules(rootDir, moduleName, targetModules)); routingFunction.put("confusable-servers", allowedTargetModules(rootDir, moduleName, targetModules));
((Map<String, Object>) manifest).put("routing-functions", List.of(routingFunction)); manifest.put("routing-functions", List.of(routingFunction));
return YAML_MAPPER.writeValueAsString(root); return YAML_MAPPER.writeValueAsString(root);
} }
@SuppressWarnings("unchecked")
private static Map<String, Object> manifestNode(Map<String, Object> root) {
if (root.get("mcp") instanceof Map<?, ?> mcp
&& mcp.get("manifest") instanceof Map<?, ?> manifest) {
return (Map<String, Object>) manifest;
}
Object routingFunctions = root.remove("mcp.manifest.routing-functions");
if (routingFunctions == null) {
return null;
}
Map<String, Object> manifest = new java.util.LinkedHashMap<>();
manifest.put("routing-functions", routingFunctions);
Map<String, Object> mcp = new java.util.LinkedHashMap<>();
mcp.put("manifest", manifest);
root.put("mcp", mcp);
return manifest;
}
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
private static Map<String, Object> routingFunction(Object routingFunctions) { private static Map<String, Object> routingFunction(Object routingFunctions) {
if (routingFunctions instanceof List<?> functions if (routingFunctions instanceof List<?> functions

View File

@@ -0,0 +1,99 @@
package io.shinhanlife.dat.lib.util;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import org.junit.jupiter.api.Test;
class MciResponseScaffolderTest {
private static final String MCI_SOURCE = """
package io.shinhanlife.dat.mcc.infra.itrf.mci.onbsz.io;
import io.shinhanlife.glow.communication.annotation.GlowTrgmField;
import java.util.List;
import lombok.Data;
@Data
public class ONBSZ0460_O {
@GlowTrgmField(order = 1, length = 20, description = "인사정보 목록")
private List<CmnnPrafIfinOutDto> employeeItems;
@Data
public static class CmnnPrafIfinOutDto {
@GlowTrgmField(order = 1, length = 8, description = "인사번호")
private String prafNo;
@GlowTrgmField(order = 2, length = 2, description = "인사유형코드")
private String prafTypeCd;
@GlowTrgmField(order = 3, length = 200, description = "인사명")
private String prafNm;
@GlowTrgmField(order = 4, length = 50, description = "주민등록번호")
private String rdreNo;
}
}
""";
@Test
void parsesGlowDescriptionsAndNestedResponseFields() {
MciResponseScaffolder.ParsedSource parsed = MciResponseScaffolder.parse(MCI_SOURCE);
assertEquals("io.shinhanlife.dat.mcc.infra.itrf.mci.onbsz.io", parsed.packageName());
assertEquals("ONBSZ0460_O", parsed.rootClassName());
assertEquals(List.of("ONBSZ0460_O", "CmnnPrafIfinOutDto"),
parsed.types().stream().map(MciResponseScaffolder.ParsedType::name).toList());
assertEquals("인사번호", parsed.types().get(1).fields().getFirst().description());
assertEquals(8, parsed.types().get(1).fields().getFirst().length());
assertTrue(parsed.types().get(1).fields().get(3).sensitive());
}
@Test
void generatesLlmResponseAndExplicitMapStructMappings() {
MciResponseScaffolder.ParsedSource parsed = MciResponseScaffolder.parse(MCI_SOURCE);
List<MciResponseScaffolder.FieldMapping> mappings = List.of(
new MciResponseScaffolder.FieldMapping("ONBSZ0460_O", "employeeItems", "employees", true),
new MciResponseScaffolder.FieldMapping("CmnnPrafIfinOutDto", "prafNo", "employeeNumber", true),
new MciResponseScaffolder.FieldMapping("CmnnPrafIfinOutDto", "prafTypeCd", "employeeTypeCode", true),
new MciResponseScaffolder.FieldMapping("CmnnPrafIfinOutDto", "prafNm", "employeeName", true),
new MciResponseScaffolder.FieldMapping("CmnnPrafIfinOutDto", "rdreNo", "residentRegistrationNumber", true));
MciResponseScaffolder.GeneratedSources generated = MciResponseScaffolder.generate(
parsed,
"io.shinhanlife.dat.mcc.biz.pro.dto",
"IndividualCustomerDetailInquiryResponse",
"io.shinhanlife.dat.mcc.biz.pro.converter",
"IndividualCustomerDetailInquiryConverter",
mappings);
assertTrue(generated.responseSource().contains("@Schema(description = \"인사번호\")"));
assertTrue(generated.responseSource().contains("private String employeeNumber;"));
assertTrue(generated.responseSource().contains("private List<CmnnPrafIfinOutDto> employees;"));
assertTrue(generated.responseSource().contains("public static class CmnnPrafIfinOutDto"));
assertTrue(generated.converterSource().contains(
"@Mapping(source = \"prafNo\", target = \"employeeNumber\")"));
assertTrue(generated.converterSource().contains(
"IndividualCustomerDetailInquiryResponse toResponse(ONBSZ0460_O source);"));
assertTrue(generated.converterSource().contains(
"IndividualCustomerDetailInquiryResponse.CmnnPrafIfinOutDto toCmnnPrafIfinOutDto(ONBSZ0460_O.CmnnPrafIfinOutDto source);"));
}
@Test
void rejectsDuplicateAiTargetNamesWithinTheSameType() {
MciResponseScaffolder.ParsedSource parsed = MciResponseScaffolder.parse(MCI_SOURCE);
List<MciResponseScaffolder.FieldMapping> mappings = List.of(
new MciResponseScaffolder.FieldMapping("CmnnPrafIfinOutDto", "prafNo", "employeeNumber", true),
new MciResponseScaffolder.FieldMapping("CmnnPrafIfinOutDto", "prafTypeCd", "employeeNumber", true));
try {
MciResponseScaffolder.generate(parsed, "io.example.dto", "EmployeeResponse",
"io.example.converter", "EmployeeConverter", mappings);
} catch (IllegalArgumentException exception) {
assertTrue(exception.getMessage().contains("중복"));
return;
}
throw new AssertionError("Duplicate target names must be rejected");
}
}

View File

@@ -44,8 +44,8 @@ class PodScaffolderTest {
assertTrue(Files.exists(resources.resolve("application-prod.yml"))); assertTrue(Files.exists(resources.resolve("application-prod.yml")));
assertTrue(Files.readString(resources.resolve("application-local.yml")) assertTrue(Files.readString(resources.resolve("application-local.yml"))
.contains("classpath:glow/application-glow-local.yml")); .contains("classpath:glow/application-glow-local.yml"));
assertTrue(Files.readString(resources.resolve("application-local.yml")) assertFalse(Files.readString(resources.resolve("application-local.yml"))
.contains("classpath:application-core-local.yml")); .contains("application-core-local.yml"));
assertTrue(Files.readString(resources.resolve("application-dev.yml")) assertTrue(Files.readString(resources.resolve("application-dev.yml"))
.contains("classpath:glow/application-glow-dev.yml")); .contains("classpath:glow/application-glow-dev.yml"));
assertTrue(Files.readString(resources.resolve("application-dev.yml")) assertTrue(Files.readString(resources.resolve("application-dev.yml"))
@@ -65,7 +65,7 @@ class PodScaffolderTest {
PodScaffolder.scaffoldPod(root, "dat-was-cla", "8087", "cla", "tester", "2026.09.02"); PodScaffolder.scaffoldPod(root, "dat-was-cla", "8087", "cla", "tester", "2026.09.02");
Path resources = root.resolve("dat-was-cla/src/main/resources"); Path resources = root.resolve("dat-was-cla/src/main/resources");
for (String profile : List.of("local", "dev", "test", "prod")) { for (String profile : List.of("dev", "test", "prod")) {
String content = Files.readString(resources.resolve("application-" + profile + ".yml")); String content = Files.readString(resources.resolve("application-" + profile + ".yml"));
assertTrue(content.contains("port: ${PORT:8087}")); assertTrue(content.contains("port: ${PORT:8087}"));
assertTrue(content.contains("on-profile: " + profile)); assertTrue(content.contains("on-profile: " + profile));
@@ -78,6 +78,23 @@ class PodScaffolderTest {
} }
} }
@Test
void generatesLocalProfileWithOnlyGlowImports() throws Exception {
PodScaffolder.scaffoldPod(root, "dat-was-cla", "8087", "cla", "tester", "2026.09.03");
String localProfile = Files.readString(root.resolve("dat-was-cla/src/main/resources/application-local.yml"));
assertEquals("""
spring:
config:
activate:
on-profile: local
import:
- classpath:glow/application-glow.yml
- classpath:glow/application-glow-local.yml
""", localProfile);
}
@Test @Test
void canonicalizesEhrManifestAndUsesAllExistingPodsAsConfusableServers() throws Exception { void canonicalizesEhrManifestAndUsesAllExistingPodsAsConfusableServers() throws Exception {
Files.writeString(root.resolve("docker-compose.yml"), "services:\n"); Files.writeString(root.resolve("docker-compose.yml"), "services:\n");
@@ -137,6 +154,25 @@ class PodScaffolderTest {
assertEquals(List.of("dat-was-cus", "dat-was-sal"), routingFunction.get("confusable-servers")); assertEquals(List.of("dat-was-cus", "dat-was-sal"), routingFunction.get("confusable-servers"));
} }
@Test
void normalizesAiManifestWithDottedRoutingFunctionsRoot() throws Exception {
String aiManifest = """
mcp.manifest.routing-functions:
- name: route_to_dat-was-ehr
server-id: dat-was-ehr
category-key: ehr
business-outcome: "인사 정보와 휴가 정보를 제공합니다."
confusable-servers: [dat-was-hrd, dat-was-pay]
""";
String normalized = PodScaffolder.normalizeToolServiceManifest(aiManifest, "dat-was-ehr",
List.of("dat-was-cus", "dat-was-ehr", "dat-was-sal"));
Map<String, Object> routingFunction = routingFunction(normalized);
assertEquals("route_to_dat-was-ehr", routingFunction.get("name"));
assertEquals(List.of("dat-was-cus", "dat-was-sal"), routingFunction.get("confusable-servers"));
}
@Test @Test
void generatesRedisOptionalPodComposeConfiguration() throws Exception { void generatesRedisOptionalPodComposeConfiguration() throws Exception {
Files.writeString(root.resolve("settings.gradle"), "rootProject.name = 'test'\n"); Files.writeString(root.resolve("settings.gradle"), "rootProject.name = 'test'\n");