48 KiB
이 계획은 p&id_ax_coding_plan2.md의 작업 완료후 이에 맞는 UI 와 API 통합을 위한 플랜입니다.
P&ID AX UI 및 API 통합 계획
작성일: 2026-04-30
기준: 완료된 백엔드 구조 (p&id_ax_coding_plan2.md)
목적: 프론트엔드 UI 및 Anthropic API 통합 구현
📋 개요
백엔드 API가 완료되었으므로, 이제 프론트엔드 UI와 Anthropic API 통합을 구현합니다.
완료된 백엔드 구조 요약
| 계층 | 구성 요소 | 역할 |
|---|---|---|
| Domain | PidEquipment, PidAuditLog |
P&ID 장비 및 감사 로그 엔티티 |
| Application | IPidExtractorService, ITagMappingService |
비즈니스 로직 인터페이스 |
| Application | PidExtractorService, TagMappingService |
비즈니스 로직 구현 |
| Web API | PidController |
REST API 엔드포인트 |
| DTOs | PidEquipmentDto, TagMappingDtos |
데이터 전송 객체 |
API 엔드포인트 목록
| 메서드 | 경로 | 설명 |
|---|---|---|
POST |
/api/pid/extract |
파일에서 P&ID 추출 |
GET |
/api/pid/equipment |
장비 목록 조회 (페이징) |
GET |
/api/pid/statistics |
통계 정보 조회 |
PUT |
/api/pid/{id}/confidence |
신뢰도 점수 업데이트 |
POST |
/api/pid/{id}/activate |
장비 활성화 |
POST |
/api/pid/{id}/deactivate |
장비 비활성화 |
GET |
/api/pid/mappings |
매핑 목록 조회 |
POST |
/api/pid/mappings |
매핑 생성 |
PUT |
/api/pid/mappings/{id} |
매핑 업데이트 |
DELETE |
/api/pid/mappings/{id} |
매핑 삭제 |
GET |
/api/pid/mappings/unmapped |
미매핑 개수 |
GET |
/api/pid/mappings/mapped |
매핑된 개수 |
GET |
/api/pid/mappings/available-tags |
사용 가능한 태그 목록 |
🎨 프론트엔드 UI 구현
1. P&ID 탭 추가
src/Web/wwwroot/index.html에 P&ID 탭을 추가합니다.
<!-- Navigation -->
<li class="nav-item" data-tab="pid">
<a class="nav-link" href="#pane-pid">P&ID 추출</a>
</li>
<!-- P&ID Pane -->
<div id="pane-pid" class="pane">
<!-- 탭 내비게이션 -->
<ul class="nav nav-tabs mb-3">
<li class="nav-item"><a class="nav-link active" data-pidtab="extract">추출</a></li>
<li class="nav-item"><a class="nav-link" data-pidtab="equipment">장비 목록</a></li>
<li class="nav-item"><a class="nav-link" data-pidtab="mapping">태그 매핑</a></li>
<li class="nav-item"><a class="nav-link" data-pidtab="statistics">통계</a></li>
</ul>
<!-- 추출 탭 -->
<div id="pid-tab-extract" class="pid-tab active">
<div class="card mb-3">
<div class="card-header">P&ID 도면 업로드</div>
<div class="card-body">
<div class="form-group">
<label>도면 파일 (PDF/DXF)</label>
<input type="file" id="pid-file-input" class="form-control" accept=".pdf,.dxf,.png,.jpg,.jpeg">
</div>
<div class="form-check">
<input type="checkbox" id="pid-image-mode" class="form-check-input">
<label class="form-check-label" for="pid-image-mode">이미지 모드 (스캔본)</label>
</div>
<button id="pid-extract-btn" class="btn btn-primary mt-2">추출 시작</button>
</div>
</div>
<div id="pid-extract-result" class="card">
<div class="card-header">추출 결과</div>
<div class="card-body">
<div class="row">
<div class="col-md-4">
<div class="stat-box">
<div class="stat-value" id="pid-total-count">0</div>
<div class="stat-label">총 추출 장비</div>
</div>
</div>
<div class="col-md-4">
<div class="stat-box">
<div class="stat-value" id="pid-confidence-count">0</div>
<div class="stat-label">신뢰도 70% 이상</div>
</div>
</div>
<div class="col-md-4">
<div class="stat-box">
<div class="stat-value" id="pid-low-confidence-count">0</div>
<div class="stat-label">신뢰도 70% 미만</div>
</div>
</div>
</div>
<div class="mt-3">
<button id="pid-export-csv-btn" class="btn btn-outline-secondary btn-sm">CSV 내보내기</button>
<button id="pid-export-excel-btn" class="btn btn-outline-secondary btn-sm">Excel 내보내기</button>
</div>
</div>
</div>
</div>
<!-- 장비 목록 탭 -->
<div id="pid-tab-equipment" class="pid-tab">
<div class="card">
<div class="card-header">
<div class="row">
<div class="col-md-6">
<input type="text" id="pid-equipment-search" class="form-control form-control-sm" placeholder="태그 번호 검색...">
</div>
<div class="col-md-6 text-end">
<select id="pid-page-size" class="form-control form-control-sm d-inline-block w-auto">
<option value="10">10개</option>
<option value="25">25개</option>
<option value="50" selected>50개</option>
<option value="100">100개</option>
</select>
</div>
</div>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm table-hover" id="pid-equipment-table">
<thead>
<tr>
<th>태그 번호</th>
<th>장비명</th>
<th>계기 유형</th>
<th>라인 번호</th>
<th>도면 번호</th>
<th>신뢰도</th>
<th>상태</th>
<th>작업</th>
</tr>
</thead>
<tbody id="pid-equipment-body"></tbody>
</table>
</div>
</div>
<div class="card-footer">
<nav>
<ul class="pagination" id="pid-equipment-pagination"></ul>
</nav>
</div>
</div>
</div>
<!-- 태그 매핑 탭 -->
<div id="pid-tab-mapping" class="pid-tab">
<div class="card">
<div class="card-header">태그 매핑 관리</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm table-hover" id="pid-mapping-table">
<thead>
<tr>
<th>PID 장비 ID</th>
<th>태그 번호</th>
<th>장비명</th>
<th>계기 유형</th>
<th>라인 번호</th>
<th>도면 번호</th>
<th>신뢰도</th>
<th>매핑된 태그</th>
<th>작업</th>
</tr>
</thead>
<tbody id="pid-mapping-body"></tbody>
</table>
</div>
</div>
<div class="card-footer">
<nav>
<ul class="pagination" id="pid-mapping-pagination"></ul>
</nav>
</div>
</div>
</div>
<!-- 통계 탭 -->
<div id="pid-tab-statistics" class="pid-tab">
<div class="row">
<div class="col-md-6">
<div class="card mb-3">
<div class="card-header">신뢰도 분포</div>
<div class="card-body">
<canvas id="pid-confidence-chart"></canvas>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card mb-3">
<div class="card-header">매핑 상태</div>
<div class="card-body">
<div class="row">
<div class="col-6">
<div class="stat-box">
<div class="stat-value" id="pid-mapped-count">0</div>
<div class="stat-label">매핑됨</div>
</div>
</div>
<div class="col-6">
<div class="stat-box">
<div class="stat-value" id="pid-unmapped-count">0</div>
<div class="stat-label">미매핑</div>
</div>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">요약</div>
<div class="card-body">
<div class="kv"><span class="kk">총 추출 장비</span><span class="kv2" id="pid-summary-total">0</span></div>
<div class="kv"><span class="kk">신뢰도 70% 이상</span><span class="kv2" id="pid-summary-confidence">0</span></div>
<div class="kv"><span class="kk">신뢰도 70% 미만</span><span class="kv2" id="pid-summary-low-confidence">0</span></div>
<div class="kv"><span class="kk">도면 수</span><span class="kv2" id="pid-summary-drawings">0</span></div>
</div>
</div>
</div>
</div>
</div>
</div>
2. CSS 스타일 추가
src/Web/wwwroot/css/style.css에 P&ID 관련 스타일을 추가합니다.
/* ── P&ID Styles ─────────────────────────────────────────────────────────────── */
.pid-tab {
display: none;
}
.pid-tab.active {
display: block;
}
.pid-tab .stat-box {
text-align: center;
padding: 15px;
background: #f8f9fa;
border-radius: 8px;
margin-bottom: 10px;
}
.pid-tab .stat-value {
font-size: 24px;
font-weight: bold;
color: #0d6efd;
}
.pid-tab .stat-label {
font-size: 12px;
color: #6c757d;
}
.pid-tab .kv {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid #eee;
}
.pid-tab .kv:last-child {
border-bottom: none;
}
.pid-tab .kk {
font-weight: 500;
color: #495057;
}
.pid-tab .kv2 {
font-family: monospace;
color: #212529;
}
.pid-tab .kv2.ok {
color: #198754;
}
.pid-tab .kv2.err {
color: #dc3545;
}
.pid-tab .table th {
font-size: 12px;
background: #f8f9fa;
}
.pid-tab .table td {
font-size: 13px;
}
.pid-tab .confidence-badge {
padding: 2px 6px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
}
.pid-tab .confidence-high {
background: #d1e7dd;
color: #0f5132;
}
.pid-tab .confidence-medium {
background: #fff3cd;
color: #856404;
}
.pid-tab .confidence-low {
background: #f8d7da;
color: #842029;
}
.pid-tab .status-badge {
padding: 2px 6px;
border-radius: 4px;
font-size: 11px;
}
.pid-tab .status-active {
background: #d1e7dd;
color: #0f5132;
}
.pid-tab .status-inactive {
background: #f8d7da;
color: #842029;
}
.pid-tab .action-btn {
padding: 4px 8px;
font-size: 11px;
margin-right: 4px;
}
.pid-tab .action-btn:hover {
transform: scale(1.05);
}
.pid-tab .action-btn.activate {
background: #198754;
color: white;
border: none;
}
.pid-tab .action-btn.deactivate {
background: #dc3545;
color: white;
border: none;
}
.pid-tab .action-btn.unlink {
background: #6c757d;
color: white;
border: none;
}
.pid-tab .action-btn.link {
background: #0d6efd;
color: white;
border: none;
}
3. JavaScript 구현
src/Web/wwwroot/js/app.js에 P&ID 관련 함수를 추가합니다.
/* ── P&ID Variables ───────────────────────────────────────────────────────────── */
let pidCurrentPage = 1;
let pidCurrentTab = 'extract';
let pidEquipmentPageSize = 50;
let pidMappingPageSize = 50;
/* ── P&ID Tab Navigation ─────────────────────────────────────────────────────── */
document.querySelectorAll('[data-pidtab]').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('[data-pidtab]').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.pid-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
pidCurrentTab = tab.dataset.pidtab;
document.getElementById(`pid-tab-${pidCurrentTab}`).classList.add('active');
if (pidCurrentTab === 'equipment') pidLoadEquipment();
if (pidCurrentTab === 'mapping') pidLoadMappings();
if (pidCurrentTab === 'statistics') pidLoadStatistics();
});
});
/* ── P&ID API Helper ─────────────────────────────────────────────────────────── */
async function pidApi(method, path, body) {
const opt = { method, headers: { 'Content-Type': 'application/json' } };
if (body) opt.body = JSON.stringify(body);
const res = await fetch('/api/pid/' + path, opt);
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
return res.json();
}
/* ── P&ID Extract ────────────────────────────────────────────────────────────── */
async function pidExtract() {
const fileInput = document.getElementById('pid-file-input');
const file = fileInput.files[0];
const useImageMode = document.getElementById('pid-image-mode').checked;
if (!file) {
alert('파일을 선택하세요.');
return;
}
setGlobal('busy', 'P&ID 추출 중...');
try {
const formData = new FormData();
formData.append('file', file);
formData.append('useImageMode', useImageMode);
const res = await fetch('/api/pid/extract', {
method: 'POST',
body: formData
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
const data = await res.json();
// 결과 표시
document.getElementById('pid-total-count').textContent = data.totalCount || 0;
document.getElementById('pid-confidence-count').textContent = data.confidenceItems || 0;
document.getElementById('pid-low-confidence-count').textContent = data.lowConfidenceItems || 0;
log('pid-log', [
{ c: 'ok', t: '✅ 추출 완료' },
{ c: 'inf', t: ` 총 ${data.totalCount || 0}개 장비 추출` },
{ c: 'inf', t: ` 신뢰도 70% 이상: ${data.confidenceItems || 0}개` },
{ c: 'inf', t: ` 신뢰도 70% 미만: ${data.lowConfidenceItems || 0}개` }
]);
setGlobal(data.totalCount > 0 ? 'ok' : 'warn', data.totalCount > 0 ? '추출 완료' : '추출 완료 (0개)');
// 탭 전환
document.querySelectorAll('[data-pidtab]').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.pid-tab').forEach(t => t.classList.remove('active'));
document.querySelector('[data-pidtab="equipment"]').classList.add('active');
pidCurrentTab = 'equipment';
document.getElementById('pid-tab-equipment').classList.add('active');
pidLoadEquipment();
} catch (e) {
log('pid-log', [{ c: 'err', t: '❌ ' + e.message }]);
setGlobal('err', '오류');
}
}
/* ── P&ID Equipment List ─────────────────────────────────────────────────────── */
async function pidLoadEquipment(page = 1) {
const search = document.getElementById('pid-equipment-search').value.trim();
const pageSize = parseInt(document.getElementById('pid-page-size').value);
try {
const url = `/api/pid/equipment?page=${page}&pageSize=${pageSize}`;
const res = await fetch(search ? `${url}&tagNo=${encodeURIComponent(search)}` : url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
// 테이블 렌더링
const tbody = document.getElementById('pid-equipment-body');
tbody.innerHTML = data.items.length ? '' : '<tr><td colspan="8" class="text-center">데이터가 없습니다</td></tr>';
data.items.forEach(item => {
const confidenceClass = item.confidence >= 0.7 ? 'confidence-high' :
item.confidence >= 0.5 ? 'confidence-medium' : 'confidence-low';
const statusClass = item.isActive ? 'status-active' : 'status-inactive';
tbody.innerHTML += `
<tr>
<td>${esc(item.tagName)}</td>
<td>${esc(item.equipmentName || '')}</td>
<td>${esc(item.instrumentType || '')}</td>
<td>${esc(item.lineNumber || '')}</td>
<td>${esc(item.pidDrawingNo || '')}</td>
<td><span class="confidence-badge ${confidenceClass}">${(item.confidence * 100).toFixed(1)}%</span></td>
<td><span class="status-badge ${statusClass}">${item.isActive ? '활성' : '비활성'}</span></td>
<td>
${item.isActive
? `<button class="action-btn deactivate" onclick="pidDeactivate(${item.id})">비활성화</button>`
: `<button class="action-btn activate" onclick="pidActivate(${item.id})">활성화</button>`
}
<button class="action-btn link" onclick="pidOpenLinkModal(${item.id})">태그 매핑</button>
</td>
</tr>
`;
});
// 페이징
pidRenderPagination('pid-equipment-pagination', data.total, data.page, data.pageSize, pidLoadEquipment);
} catch (e) {
console.error('P&ID 장비 로드 실패:', e);
}
}
/* ── P&ID Mapping List ───────────────────────────────────────────────────────── */
async function pidLoadMappings(page = 1) {
try {
const res = await fetch(`/api/pid/mappings?page=${page}&pageSize=${pidMappingPageSize}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const tbody = document.getElementById('pid-mapping-body');
tbody.innerHTML = data.items.length ? '' : '<tr><td colspan="9" class="text-center">데이터가 없습니다</td></tr>';
data.items.forEach(item => {
const confidenceClass = item.confidence >= 0.7 ? 'confidence-high' :
item.confidence >= 0.5 ? 'confidence-medium' : 'confidence-low';
tbody.innerHTML += `
<tr>
<td>${item.pidEquipmentId}</td>
<td>${esc(item.tagNo)}</td>
<td>${esc(item.equipmentName || '')}</td>
<td>${esc(item.instrumentType || '')}</td>
<td>${esc(item.lineNumber || '')}</td>
<td>${esc(item.pidDrawingNo || '')}</td>
<td><span class="confidence-badge ${confidenceClass}">${(item.confidence * 100).toFixed(1)}%</span></td>
<td>${esc(item.experionTagName || '미매핑')}</td>
<td>
${item.experionTagId
? `<button class="action-btn unlink" onclick="pidUnlinkMapping(${item.pidEquipmentId})">unlink</button>`
: `<button class="action-btn link" onclick="pidOpenMappingModal(${item.pidEquipmentId})">link</button>`
}
</td>
</tr>
`;
});
pidRenderPagination('pid-mapping-pagination', data.total, data.page, data.pageSize, pidLoadMappings);
} catch (e) {
console.error('P&ID 매핑 로드 실패:', e);
}
}
/* ── P&ID Statistics ─────────────────────────────────────────────────────────── */
async function pidLoadStatistics() {
try {
const res = await fetch('/api/pid/statistics');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
// 요약 정보
document.getElementById('pid-summary-total').textContent = data.confidenceItems + data.lowConfidenceItems;
document.getElementById('pid-summary-confidence').textContent = data.confidenceItems;
document.getElementById('pid-summary-low-confidence').textContent = data.lowConfidenceItems;
document.getElementById('pid-summary-drawings').textContent = data.drawingCount;
// 매핑 상태
document.getElementById('pid-mapped-count').textContent = data.mappedCount;
document.getElementById('pid-unmapped-count').textContent = data.unmappedCount;
// 신뢰도 분포 차트
pidRenderConfidenceChart(data.confidenceRange);
} catch (e) {
console.error('P&ID 통계 로드 실패:', e);
}
}
/* ── P&ID Actions ────────────────────────────────────────────────────────────── */
async function pidActivate(id) {
try {
await pidApi('POST', `${id}/activate`);
pidLoadEquipment(pidCurrentPage);
log('pid-log', [{ c: 'ok', t: `✅ 장비 ${id} 활성화` }]);
} catch (e) {
log('pid-log', [{ c: 'err', t: `❌ 활성화 실패: ${e.message}` }]);
}
}
async function pidDeactivate(id) {
try {
await pidApi('POST', `${id}/deactivate`);
pidLoadEquipment(pidCurrentPage);
log('pid-log', [{ c: 'ok', t: `✅ 장비 ${id} 비활성화` }]);
} catch (e) {
log('pid-log', [{ c: 'err', t: `❌ 비활성화 실패: ${e.message}` }]);
}
}
async function pidUpdateConfidence(id, confidence) {
try {
await pidApi('PUT', `${id}/confidence`, confidence);
pidLoadEquipment(pidCurrentPage);
log('pid-log', [{ c: 'ok', t: `✅ 장비 ${id} 신뢰도 ${confidence}` }]);
} catch (e) {
log('pid-log', [{ c: 'err', t: `❌ 신뢰도 업데이트 실패: ${e.message}` }]);
}
}
async function pidCreateMapping(pidEquipmentId, experionTagId) {
try {
await pidApi('POST', 'mappings', { pidEquipmentId, experionTagId });
pidLoadMappings();
pidLoadEquipment(pidCurrentPage);
log('pid-log', [{ c: 'ok', t: `✅ 매핑 생성: PID ${pidEquipmentId} → Experion ${experionTagId}` }]);
} catch (e) {
log('pid-log', [{ c: 'err', t: `❌ 매핑 생성 실패: ${e.message}` }]);
}
}
async function pidUpdateMapping(id, request) {
try {
await pidApi('PUT', `mappings/${id}`, request);
pidLoadMappings();
pidLoadEquipment(pidCurrentPage);
log('pid-log', [{ c: 'ok', t: `✅ 매핑 업데이트: ${id}` }]);
} catch (e) {
log('pid-log', [{ c: 'err', t: `❌ 매핑 업데이트 실패: ${e.message}` }]);
}
}
async function pidClearMapping(id) {
try {
await pidApi('DELETE', `mappings/${id}`);
pidLoadMappings();
pidLoadEquipment(pidCurrentPage);
log('pid-log', [{ c: 'ok', t: `✅ 매핑 삭제: ${id}` }]);
} catch (e) {
log('pid-log', [{ c: 'err', t: `❌ 매핑 삭제 실패: ${e.message}` }]);
}
}
/* ── P&ID Modals ─────────────────────────────────────────────────────────────── */
function pidOpenLinkModal(id) {
const modal = document.getElementById('modal-pid-link');
document.getElementById('pid-link-equipment-id').value = id;
modal.style.display = 'flex';
pidLoadAvailableTags();
}
function pidOpenMappingModal(id) {
const modal = document.getElementById('modal-pid-map');
document.getElementById('pid-map-equipment-id').value = id;
modal.style.display = 'flex';
pidLoadAvailableTags();
}
function pidCloseModals() {
document.getElementById('modal-pid-link').style.display = 'none';
document.getElementById('modal-pid-map').style.display = 'none';
}
async function pidLoadAvailableTags() {
try {
const res = await fetch('/api/pid/mappings/available-tags');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const select = document.getElementById('pid-available-tags');
select.innerHTML = '<option value="">선택하세요</option>';
(data.tags || []).forEach(tag => {
select.innerHTML += `<option value="${tag.id}">${esc(tag.tagName)}</option>`;
});
} catch (e) {
console.error('사용 가능한 태그 로드 실패:', e);
}
}
async function pidConfirmLink() {
const equipmentId = parseInt(document.getElementById('pid-link-equipment-id').value);
const tagId = parseInt(document.getElementById('pid-available-tags').value);
if (!tagId) {
alert('태그를 선택하세요.');
return;
}
await pidCreateMapping(equipmentId, tagId);
pidCloseModals();
}
async function pidConfirmMap() {
const equipmentId = parseInt(document.getElementById('pid-map-equipment-id').value);
const tagId = parseInt(document.getElementById('pid-available-tags').value);
if (!tagId) {
alert('태그를 선택하세요.');
return;
}
await pidCreateMapping(equipmentId, tagId);
pidCloseModals();
}
/* ── P&ID UI Helpers ─────────────────────────────────────────────────────────── */
function pidRenderPagination(elementId, total, page, pageSize, loadFn) {
const totalPages = Math.ceil(total / pageSize);
const html = [];
for (let i = 1; i <= totalPages; i++) {
html.push(`<li class="page-item ${i === page ? 'active' : ''}">
<a class="page-link" href="#" onclick="event.preventDefault(); ${loadFn.name}(${i})">${i}</a>
</li>`);
}
document.getElementById(elementId).innerHTML = html.join('');
}
function pidRenderConfidenceChart(data) {
const ctx = document.getElementById('pid-confidence-chart').getContext('2d');
const labels = ['0-50%', '50-70%', '70-85%', '85-100%'];
const values = [
data['0-50'] || 0,
data['50-70'] || 0,
data['70-85'] || 0,
data['85-100'] || 0
];
new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [{
label: '장비 수',
data: values,
backgroundColor: ['#f8d7da', '#fff3cd', '#d1e7dd', '#cfe2ff']
}]
},
options: {
responsive: true,
scales: { y: { beginAtZero: true } }
}
});
}
/* ── P&ID Event Listeners ────────────────────────────────────────────────────── */
document.getElementById('pid-extract-btn')?.addEventListener('click', pidExtract);
document.getElementById('pid-export-csv-btn')?.addEventListener('click', async () => {
const res = await fetch('/api/pid/equipment');
if (!res.ok) return;
const data = await res.json();
const csv = '태그번호,장비명,계기유형,라인번호,도면번호,신뢰도,상태\n' +
data.items.map(i => [
i.tagName, i.equipmentName, i.instrumentType, i.lineNumber,
i.pidDrawingNo, i.confidence, i.isActive ? '활성' : '비활성'
].join(',')).join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `pid-equipment-${new Date().toISOString().slice(0,10)}.csv`;
a.click();
URL.revokeObjectURL(url);
});
document.getElementById('pid-export-excel-btn')?.addEventListener('click', async () => {
const res = await fetch('/api/pid/equipment');
if (!res.ok) return;
const data = await res.json();
const ws = XLSX.utils.json_to_sheet(data.items.map(i => ({
'태그번호': i.tagName,
'장비명': i.equipmentName,
'계기유형': i.instrumentType,
'라인번호': i.lineNumber,
'도면번호': i.pidDrawingNo,
'신뢰도': i.confidence,
'상태': i.isActive ? '활성' : '비활성'
})));
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'P&ID 장비');
XLSX.writeFile(wb, `pid-equipment-${new Date().toISOString().slice(0,10)}.xlsx`);
});
document.getElementById('pid-equipment-search')?.addEventListener('input', () => {
pidLoadEquipment(1);
});
document.getElementById('pid-page-size')?.addEventListener('change', (e) => {
pidEquipmentPageSize = parseInt(e.target.value);
pidLoadEquipment(1);
});
// 모달 닫기 이벤트
document.querySelectorAll('.modal-pid-close').forEach(btn => {
btn.addEventListener('click', pidCloseModals);
});
document.getElementById('pid-confirm-link')?.addEventListener('click', pidConfirmLink);
document.getElementById('pid-confirm-map')?.addEventListener('click', pidConfirmMap);
/* ── P&ID Initial Load ───────────────────────────────────────────────────────── */
// P&ID 탭 진입 시 자동 로드
document.querySelectorAll('[href="#pane-pid"]').forEach(a => {
a.addEventListener('show.bs.tab', () => {
pidLoadStatistics();
});
});
4. 모달 HTML 추가
src/Web/wwwroot/index.html의 모달 섹션에 다음을 추가합니다.
<!-- P&ID Link Modal -->
<div id="modal-pid-link" class="modal" style="display:none;">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">태그 매핑</h5>
<button type="button" class="btn-close modal-pid-close"></button>
</div>
<div class="modal-body">
<input type="hidden" id="pid-link-equipment-id">
<div class="form-group">
<label>매핑할 태그</label>
<select id="pid-available-tags" class="form-control"></select>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary modal-pid-close">취소</button>
<button id="pid-confirm-link" class="btn btn-primary">매핑</button>
</div>
</div>
</div>
</div>
<!-- P&ID Map Modal -->
<div id="modal-pid-map" class="modal" style="display:none;">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">태그 매핑</h5>
<button type="button" class="btn-close modal-pid-close"></button>
</div>
<div class="modal-body">
<input type="hidden" id="pid-map-equipment-id">
<div class="form-group">
<label>매핑할 태그</label>
<select id="pid-available-tags" class="form-control"></select>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary modal-pid-close">취소</button>
<button id="pid-confirm-map" class="btn btn-primary">매핑</button>
</div>
</div>
</div>
</div>
⚠️ Phase 1 수정안 — 프론트엔드 UI
[1. P&ID 탭 추가 — HTML 구조]
Bootstrap 클래스 사용 불가: 계획 전체에
nav nav-tabs,card,card-header,card-body,form-group,form-check,col-md-4,btn btn-primary,table-sm table-hover,text-end,d-inline-block,pagination등 Bootstrap 클래스가 광범위하게 사용됩니다. 기존index.html은 Bootstrap을 사용하지 않으며 커스텀 CSS만 사용합니다. 이 클래스들을 그대로 사용하면 스타일이 전혀 적용되지 않습니다. → 기존 프로젝트 패턴(pane,pane-hdr,pane-tag,.kv/.kk, 커스텀 버튼 스타일)을 따라 재작성해야 합니다.탭 내비게이션 구조: P&ID pane 안에
data-pidtab중첩 탭을 추가하는 것은 기존 프로젝트에 유사 사례가 없습니다. 탭 안에 탭을 넣는 대신, 기존#pane-fast처럼 단일 pane에 섹션을 수직으로 나열하는 방식이 더 일관적입니다.
<canvas id="pid-confidence-chart">— Chart.js 미설치: 통계 탭의pidRenderConfidenceChart가new Chart(ctx, ...)(Chart.js API)를 호출합니다. 기존 프로젝트는 Chart.js가 아닌 uPlot(/lib/uPlot.iife.min.js)을 사용합니다. Chart.js를 별도로 설치하지 않으면Chart is not defined오류가 발생합니다. uPlot으로 구현하거나 Chart.js 파일을/js/에 추가해야 합니다.
<div id="pid-log">누락:app.js의log('pid-log', [...])호출이 참조하는<div id="pid-log">요소가 HTML에 없습니다. 추출 결과 섹션 하단에 추가해야 합니다.[2. CSS 스타일]
다크 테마 불일치: 기존
style.css는 다크 테마(background: #1e1e1e,color: #ccc등)를 사용합니다. 계획의 CSS는 라이트 테마 색상(background: #f8f9fa,color: #0d6efd,color: #495057)을 사용하여 기존 UI와 이질감이 발생합니다. 기존 CSS 변수/색상 값에 맞춰 조정 필요합니다.
.stat-box,.stat-value,.stat-label클래스 중복: 기존style.css에.srv-status-card등 유사한 통계 카드 스타일이 이미 있습니다. 새 클래스를 만들기 전에 재사용 가능한지 확인 후 필요한 경우만 추가하세요.[3. JavaScript]
서브탭 진입 시 자동 API 호출 — 프로젝트 규칙 위반:
if (pidCurrentTab === 'equipment') pidLoadEquipment(); if (pidCurrentTab === 'mapping') pidLoadMappings(); if (pidCurrentTab === 'statistics') pidLoadStatistics();기존 CLAUDE.md 원칙("탭 진입 시 API 호출 0건")에 위배됩니다. 각 서브탭에 "▼ 목록 불러오기" 버튼을 추가하고 자동 호출을 제거해야 합니다.
document.querySelectorAll('[data-pidtab]')실행 시점 문제: 이 코드가 최상위 레벨에서 즉시 실행됩니다. 기존app.js구조를 보면 탭 핸들러가 파일 상단 5번째 줄에서DOMContentLoaded없이 실행되는데, HTML이<script>태그 앞에 완전히 로드되어 있으므로 동작합니다. 신규 코드도 동일 조건이므로 문제는 없지만,data-pidtab요소가 HTML에 실제로 추가되어야 합니다.
show.bs.tabBootstrap 이벤트 사용 불가:a.addEventListener('show.bs.tab', () => pidLoadStatistics());프로젝트에 Bootstrap JS가 없으므로 이 이벤트는 절대 발생하지 않습니다. P&ID 탭 진입 처리는 기존 탭 핸들러(
app.js:5-18)에if (tab === 'pid') ...형태로 추가해야 합니다.
tbody.innerHTML +=루프 — 프로젝트 규칙 위반:data.items.forEach(item => { tbody.innerHTML += `<tr>...</tr>`; // ← 반복할 때마다 전체 DOM 재파싱 });기존 CLAUDE.md 원칙("innerHTML 전체 교체 금지, incremental DOM update 방식 사용")에 위배됩니다. 문자열 배열에 누적 후
.join('')으로 한 번에 설정하거나, 기존 행만.textContent갱신 방식을 사용해야 합니다.CSV 내보내기 인젝션 취약점: 필드에 쉼표, 따옴표, 줄바꿈이 포함될 경우 CSV가 깨집니다. 기존 프로젝트에
CsvHelper라이브러리가 설치되어 있으므로 서버 측에서 내보내기 엔드포인트를 구현하는 것을 권장합니다.DTO 필드명 오류 (
i.tagName→i.tagNo):// CSV export i.tagName // ← 잘못됨 // 올바른 JSON 직렬화 결과는 tagNo (PidEquipmentDto.TagNo → camelCase) i.tagNo // ← 맞음
PidEquipmentDto의TagNo필드는 JSON으로 직렬화될 때tagNo로 내려옵니다. 동일하게i.tagName을 참조하는 모든 곳을i.tagNo로 수정해야 합니다.
/api/pid/mappings/available-tags응답 형식 불일치:select.innerHTML += `<option value="${tag.id}">${esc(tag.tagName)}</option>`;
GetAvailableTagsAsync()는IEnumerable<string>(태그 이름 문자열 목록)을 반환합니다.tag.id,tag.tagName이 아닌 문자열 자체가 value이고 표시 텍스트입니다:(data.tags || []).forEach(tagName => { select.innerHTML += `<option value="${esc(tagName)}">${esc(tagName)}</option>`; });
pidUnlinkMapping미정의:pidLoadMappings에서onclick="pidUnlinkMapping(${item.pidEquipmentId})"버튼을 생성하지만,pidUnlinkMapping함수 정의가 없습니다.pidClearMapping이 해당 역할을 하므로 이름을 통일해야 합니다.
pidRenderPagination전체 페이지 버튼 생성: 총 1000건, pageSize 10이면 100개의 버튼을 DOM에 생성합니다. 앞/뒤 5페이지만 표시하는 방식으로 제한이 필요합니다.[4. 모달 HTML]
중복 ID
pid-available-tags: 두 모달(modal-pid-link,modal-pid-map) 모두<select id="pid-available-tags">를 포함합니다. HTML ID는 문서 내 유일해야 하며, 중복 시getElementById가 첫 번째 요소만 반환합니다. 두 번째 모달의 드롭다운은 절대 채워지지 않습니다. → 두 모달의 기능이 동일하므로 하나의 공유 모달로 통합하거나,pid-available-tags-link/pid-available-tags-map으로 ID를 분리해야 합니다.두 모달이 완전히 동일한 내용:
pidOpenLinkModal과pidOpenMappingModal,pidConfirmLink와pidConfirmMap이 각각 동일한 로직입니다. 중복 제거 후 단일 모달로 합치세요.
🤖 Anthropic API 통합
1. Anthropic SDK 설치
dotnet add package Anthropic.SDK --version 2.0.0
2. Configuration 추가
src/Web/appsettings.json에 다음을 추가합니다.
{
"Anthropic": {
"ApiKey": "your-anthropic-api-key-here",
"Model": "claude-3-5-sonnet-20241022",
"MaxTokens": 4096
}
}
3. PidExtractorService 수정
src/Core/Application/Services/PidExtractorService.cs의 AnalyzeWithClaudeAsync 메서드를 수정합니다.
private async Task<List<ExtractedItem>> AnalyzeWithClaudeAsync(byte[] imageData, string fileName)
{
try
{
var client = new AnthropicClient(_anthropicApiKey);
var message = await client.Messages.CreateAsync(new MessageCreateRequest
{
Model = "claude-3-5-sonnet-20241022",
MaxTokens = 4096,
Messages = new List<Message>
{
new Message(Role.User, new List<ContentBlock>
{
new ImageContentBlock
{
Source = new ImageSource
{
Type = ImageSourceType.Base64,
MediaType = "image/png",
Data = Convert.ToBase64String(imageData)
}
},
new TextContentBlock
{
Text = GetPrompt(fileName)
}
})
}
});
return ParseExtractedData(message.Content.FirstOrDefault()?.Text ?? "[]");
}
catch (Exception ex)
{
// Fallback: 빈 목록 반환
Console.WriteLine($"Anthropic API 오류: {ex.Message}");
return new List<ExtractedItem>();
}
}
4. 오류 처리 개선
PidExtractorService에 오류 로깅을 추가합니다.
private async Task<List<ExtractedItem>> AnalyzeWithClaudeAsync(byte[] imageData, string fileName)
{
try
{
var client = new AnthropicClient(_anthropicApiKey);
var message = await client.Messages.CreateAsync(new MessageCreateRequest
{
Model = "claude-3-5-sonnet-20241022",
MaxTokens = 4096,
Messages = new List<Message>
{
new Message(Role.User, new List<ContentBlock>
{
new ImageContentBlock
{
Source = new ImageSource
{
Type = ImageSourceType.Base64,
MediaType = "image/png",
Data = Convert.ToBase64String(imageData)
}
},
new TextContentBlock
{
Text = GetPrompt(fileName)
}
})
}
});
return ParseExtractedData(message.Content.FirstOrDefault()?.Text ?? "[]");
}
catch (AnthropicApiException ex)
{
Console.WriteLine($"Anthropic API 오류: {ex.StatusCode} - {ex.Message}");
return new List<ExtractedItem>();
}
catch (Exception ex)
{
Console.WriteLine($"예상치 못한 오류: {ex.Message}");
return new List<ExtractedItem>();
}
}
⚠️ Phase 2 수정안 — Anthropic API 통합
[1. SDK 버전 오류]
버전 2.0.0은 구버전:
dotnet add package Anthropic.SDK --version 2.0.0— 현재 최신 버전은 3.x입니다. 버전 2.x와 3.x는 API 클래스명이 다릅니다. 최신 버전을 사용하세요:dotnet add package Anthropic.SDK[2. API 키 설정]
appsettings.json전체 교체 방식 위험: 현재appsettings.json에는 OPC UA 서버, Experion 접속, Kestrel 등 기존 설정이 있습니다. 계획의 JSON 블록은 기존 내용을 덮어쓰는 것처럼 표기되어 있습니다. 실제로는 기존 설정을 유지한 채"Anthropic"섹션만 병합 추가해야 합니다.[3, 4. PidExtractorService 코드]
섹션 3과 4가 거의 동일한 코드: 섹션 3("수정")과 섹션 4("오류 처리 개선")의 코드 내용이 사실상 동일합니다. 유일한 차이는 catch 블록에
AnthropicApiException이 추가된 것입니다. 중복 섹션으로 혼란을 줍니다. 하나로 합치세요.클래스명 검증 필요:
MessageCreateRequest,Role,ContentBlock,ImageContentBlock,TextContentBlock,ImageSource,ImageSourceType,AnthropicApiException— 이 클래스들의 정확한 이름은 설치된 SDK 버전에 따라 다릅니다. 최신 Anthropic.SDK(3.x) 기준으로는 일부 클래스명이 다를 수 있습니다. 설치 후 IDE 자동완성으로 확인이 필요합니다.
GetPrompt(fileName)미정의: 코드에서GetPrompt(fileName)을 호출하지만 이 메서드 정의가 계획 어디에도 없습니다. P&ID 추출의 핵심인 프롬프트 설계가 빠진 것으로, 반드시 구현 전 프롬프트를 작성해야 합니다. 예시:private string GetPrompt(string fileName) => """ 이 P&ID 도면 이미지에서 모든 계기/장비 태그 정보를 추출하여 JSON 배열로만 응답하세요. 각 항목의 형식: {"tagNo":"FT-101","equipmentName":"Flow Transmitter","instrumentType":"FT","lineNumber":"6\"-P-1001","pidDrawingNo":"DWG-001","confidence":0.95} 태그 번호가 없거나 불확실한 경우 confidence를 낮게 설정하세요. JSON 외 다른 텍스트는 포함하지 마세요. """;
message.Content.FirstOrDefault()?.Text— 타입 캐스팅 누락: SDK에서Content의 각 항목은ContentBlock기본 타입입니다..Text속성에 직접 접근하려면TextContentBlock으로 캐스팅이 필요합니다:var textBlock = message.Content.FirstOrDefault(c => c is TextContentBlock) as TextContentBlock; return ParseExtractedData(textBlock?.Text ?? "[]");
MediaType = "image/png"하드코딩: PDF나 DXF 파일을 이미지로 변환하지 않고 그대로 바이트로 전달할 경우,image/png로 보내면 API 오류가 발생합니다. 실제 파일 형식에 따라mediaType을 동적으로 설정해야 하며, DXF는 이미지 형식이 아니므로 반드시 사전에 이미지로 변환(PdfPig/래스터라이즈)해야 합니다. Claude Vision API가 지원하는 미디어 타입:image/jpeg,image/png,image/gif,image/webp.
Console.WriteLine대신ILogger사용: 기존 코드베이스 전체가ILogger<T>를 사용합니다.Console.WriteLine으로 로깅하면 기존 로그 시스템과 분리됩니다.// 변경 전 Console.WriteLine($"Anthropic API 오류: {ex.StatusCode} - {ex.Message}"); // 변경 후 _logger.LogError(ex, "Anthropic API 오류: {StatusCode}", ex.StatusCode);
PidExtractorService생성자에ILogger<PidExtractorService> logger주입이 필요합니다.모델명
appsettings.json에 있지만 코드에 하드코딩:appsettings.json에"Model": "claude-3-5-sonnet-20241022"를 설정했지만,AnalyzeWithClaudeAsync코드 안에Model = "claude-3-5-sonnet-20241022"가 하드코딩되어 있습니다. 설정 파일 값을 읽어야 합니다:Model = _configuration["Anthropic:Model"] ?? "claude-sonnet-4-6", MaxTokens = int.TryParse(_configuration["Anthropic:MaxTokens"], out var mt) ? mt : 4096또한 현재 기준(2026-04) 최신 모델은
claude-sonnet-4-6입니다.claude-3-5-sonnet-20241022는 구버전입니다.
📋 구현 순서
Phase 1: 프론트엔드 UI (1-2일)
index.html에 P&ID 탭 추가style.css에 P&ID 스타일 추가app.js에 P&ID JavaScript 구현- 모달 HTML 추가
- 브라우저에서 UI 테스트
⚠️ 수정안
선행 조건 누락:
p&id_ax_coding_plan.md의 수정안들(단일 DbContext 통합,ExperionTagId타입 수정,IQueryable제거 등)이 먼저 완료되어야 이 Phase의 API 연동이 정상 동작합니다. Bootstrap 제거: HTML 작성 시 Bootstrap 클래스를 모두 제거하고 기존 커스텀 스타일로 재작성해야 합니다. 1-2일 일정 재검토: Bootstrap 제거 + 다크 테마 적용 + 중복 모달 통합 + innerHTML 패턴 수정까지 포함하면 실질적으로 3-4일 소요가 예상됩니다.
Phase 2: Anthropic API 통합 (1-2일)
- Anthropic SDK 설치
appsettings.json에 API 키 설정PidExtractorService수정- 실제 P&ID 파일로 테스트
- 오류 처리 개선
⚠️ 수정안
선행 작업 추가 필요: SDK 설치 전에
GetPrompt()메서드의 프롬프트 내용을 먼저 설계해야 합니다. 프롬프트 품질이 추출 정확도의 90%를 결정합니다. PDF 전처리 단계 추가: PDF 파일은 Claude Vision에 직접 전달할 수 없습니다.PdfPig를 이용해 페이지를 이미지로 변환하는 단계가 Phase 2에 포함되어야 합니다. 현재 계획에 이 단계가 없습니다. DXF 처리 전략 부재: DXF는 벡터 텍스트 파일이므로 이미지 변환 없이 텍스트를 직접 파싱(netDxf)한 후 Claude에 텍스트로 전달하는 것이 더 정확합니다. 이미지 변환 경로와 별도로 DXF 텍스트 경로를 설계해야 합니다. 테스트용 실제 파일 확보: P&ID 도면 파일을 실제로 Claude API에 테스트하기 전에 소규모 샘플(A4 1장, 태그 10개 이하)로 프롬프트를 먼저 검증하는 것을 권장합니다. 대용량 파일로 바로 테스트하면 API 비용과 디버깅 시간이 낭비됩니다.
Phase 3: 통합 테스트 (1일)
- 전체 흐름 테스트 (업로드 → 추출 → 매핑)
- 성능 최적화
- 사용자 피드백 반영
⚠️ 수정안
1일은 비현실적: 위 Phase 1, 2의 수정 사항들이 모두 반영된 후 전체 흐름 테스트 + Claude API 응답 파싱 검증 + 엣지 케이스(태그 없는 도면, 빈 응답, 네트워크 오류) 처리까지 포함하면 최소 2-3일이 필요합니다.
ParseExtractedData구현이 현재 빈 상태:return new List<ExtractedItem>();를 반환하는 미구현 메서드입니다. Claude가 반환하는 JSON 파싱 로직을 구현해야 테스트가 가능합니다. Claude 응답에는 JSON 앞뒤로 마크다운 코드펜스(```json)가 붙을 수 있으므로 이를 제거하는 전처리가 필요합니다:private List<ExtractedItem> ParseExtractedData(string text) { // ```json ... ``` 제거 var json = System.Text.RegularExpressions.Regex.Match(text, @"\[.*\]", System.Text.RegularExpressions.RegexOptions.Singleline).Value; if (string.IsNullOrEmpty(json)) return []; return System.Text.Json.JsonSerializer.Deserialize<List<ExtractedItem>>(json, new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? []; }성능 최적화 항목 구체화 필요: "성능 최적화"가 무엇인지 모호합니다. 실제 병목은 Claude API 응답 시간(일반적으로 5-30초)이므로 업로드 후 스트리밍 진행상태 표시(
SSE또는 폴링) 구현이 핵심입니다.
⚠️ 주의사항
- API 키 보안:
appsettings.json은.gitignore에 추가하여 커밋하지 않음 - 이미지 크기 제한: 큰 이미지는 사전에 리사이징
- API 호출 제한: Anthropic API 호출 횟수 제한 확인
- 오류 처리: API 오류 시 사용자에게 명확한 메시지 표시
- 로딩 상태: 긴 작업 시 로딩 인디케이터 표시
📊 성공 지표
- P&ID 파일 업로드 및 추출 완료
- 추출된 장비 목록 조회 가능
- Experion 태그와 매핑 가능
- 통계 정보 표시
- CSV/Excel 내보내기 가능
- 신뢰도 70% 이상 장비 추출률 80% 이상