Files
ExperionCrawler/dxf-graph/p&id_ax_UI_API_plan.md
2026-05-08 17:22:10 +09:00

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 미설치: 통계 탭의 pidRenderConfidenceChartnew 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.jslog('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.tab Bootstrap 이벤트 사용 불가:

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.tagNamei.tagNo):

// CSV export
i.tagName  // ← 잘못됨
// 올바른 JSON 직렬화 결과는 tagNo (PidEquipmentDto.TagNo → camelCase)
i.tagNo    // ← 맞음

PidEquipmentDtoTagNo 필드는 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를 분리해야 합니다.

두 모달이 완전히 동일한 내용: pidOpenLinkModalpidOpenMappingModal, pidConfirmLinkpidConfirmMap이 각각 동일한 로직입니다. 중복 제거 후 단일 모달로 합치세요.


🤖 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.csAnalyzeWithClaudeAsync 메서드를 수정합니다.

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일)

  1. index.html에 P&ID 탭 추가
  2. style.css에 P&ID 스타일 추가
  3. app.js에 P&ID JavaScript 구현
  4. 모달 HTML 추가
  5. 브라우저에서 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일)

  1. Anthropic SDK 설치
  2. appsettings.json에 API 키 설정
  3. PidExtractorService 수정
  4. 실제 P&ID 파일로 테스트
  5. 오류 처리 개선

⚠️ 수정안

선행 작업 추가 필요: 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. 전체 흐름 테스트 (업로드 → 추출 → 매핑)
  2. 성능 최적화
  3. 사용자 피드백 반영

⚠️ 수정안

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 또는 폴링) 구현이 핵심입니다.


⚠️ 주의사항

  1. API 키 보안: appsettings.json.gitignore에 추가하여 커밋하지 않음
  2. 이미지 크기 제한: 큰 이미지는 사전에 리사이징
  3. API 호출 제한: Anthropic API 호출 횟수 제한 확인
  4. 오류 처리: API 오류 시 사용자에게 명확한 메시지 표시
  5. 로딩 상태: 긴 작업 시 로딩 인디케이터 표시

📊 성공 지표

  • P&ID 파일 업로드 및 추출 완료
  • 추출된 장비 목록 조회 가능
  • Experion 태그와 매핑 가능
  • 통계 정보 표시
  • CSV/Excel 내보내기 가능
  • 신뢰도 70% 이상 장비 추출률 80% 이상