이 계획은 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 탭을 추가합니다.

```html
<!-- 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 관련 스타일을 추가합니다.

```css
/* ── 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 관련 함수를 추가합니다.

```javascript
/* ── 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`의 모달 섹션에 다음을 추가합니다.

```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 호출 — 프로젝트 규칙 위반**:
> ```javascript
> 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 이벤트 사용 불가**:
> ```javascript
> a.addEventListener('show.bs.tab', () => pidLoadStatistics());
> ```
> 프로젝트에 Bootstrap JS가 없으므로 이 이벤트는 절대 발생하지 않습니다. P&ID 탭 진입 처리는 기존 탭 핸들러(`app.js:5-18`)에 `if (tab === 'pid') ...` 형태로 추가해야 합니다.
>
> **`tbody.innerHTML +=` 루프 — 프로젝트 규칙 위반**:
> ```javascript
> 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`)**:
> ```javascript
> // CSV export
> i.tagName  // ← 잘못됨
> // 올바른 JSON 직렬화 결과는 tagNo (PidEquipmentDto.TagNo → camelCase)
> i.tagNo    // ← 맞음
> ```
> `PidEquipmentDto`의 `TagNo` 필드는 JSON으로 직렬화될 때 `tagNo`로 내려옵니다. 동일하게 `i.tagName`을 참조하는 모든 곳을 `i.tagNo`로 수정해야 합니다.
>
> **`/api/pid/mappings/available-tags` 응답 형식 불일치**:
> ```javascript
> select.innerHTML += `<option value="${tag.id}">${esc(tag.tagName)}</option>`;
> ```
> `GetAvailableTagsAsync()`는 `IEnumerable<string>`(태그 이름 문자열 목록)을 반환합니다. `tag.id`, `tag.tagName`이 아닌 문자열 자체가 value이고 표시 텍스트입니다:
> ```javascript
> (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 설치

```bash
dotnet add package Anthropic.SDK --version 2.0.0
```

### 2. Configuration 추가

`src/Web/appsettings.json`에 다음을 추가합니다.

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

```csharp
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`에 오류 로깅을 추가합니다.

```csharp
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 클래스명이 다릅니다. 최신 버전을 사용하세요:
> ```bash
> 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 추출의 핵심인 프롬프트 설계가 빠진 것으로, 반드시 구현 전 프롬프트를 작성해야 합니다. 예시:
> ```csharp
> 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`으로 캐스팅이 필요합니다:
> ```csharp
> 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`으로 로깅하면 기존 로그 시스템과 분리됩니다.
> ```csharp
> // 변경 전
> 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"`가 하드코딩되어 있습니다. 설정 파일 값을 읽어야 합니다:
> ```csharp
> 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 `)가 붙을 수 있으므로 이를 제거하는 전처리가 필요합니다:
> ```csharp
> 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% 이상
