업데이트
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

View File

@@ -16,6 +16,7 @@ package io.shinhanlife.dat.mcg.presentation;
* </pre>
*/
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.ToolSourceUpdater;
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")
public String updateTool(@RequestBody Map<String, String> req) {
try {
@@ -360,6 +428,39 @@ public class ScaffoldingController {
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) {
if (source == null || source.isBlank()) {
return List.of();
@@ -551,6 +652,27 @@ public class ScaffoldingController {
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,
String httpApiName, String functionDescription, String displayDescription,
String whenToUse, String whenNotToUse, String ioLimits,

View File

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

View File

@@ -787,6 +787,9 @@
<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>
</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">
<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>
@@ -1131,6 +1134,131 @@
</form>
</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 -->
<div class="tab-pane fade document-generator" id="document" role="tabpanel" aria-labelledby="document-tab">
<div class="docgen-workspace">
@@ -2483,6 +2611,288 @@
}
</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>
function openServerFolderPicker(inputId) {

View File

@@ -115,6 +115,85 @@ class ScaffoldingControllerToolDraftTest {
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
void podDraftUsesTheCurrentTargetModuleOptionsForConfusableServers() {
ChatClient.Builder builder = mock(ChatClient.Builder.class);