# P&ID AX 코딩 플랜 v4 (MCP 기반 로컬 LLM 구현)

> **수정일**: 2026-04-30 (v4 완료 — 백엔드 완료, MCP 툴 추가)
> **수정일**: 2026-04-30 (v4.1 — 프론트엔드 완료)
> **기준**: `p&id_ax_coding_plan3.md` 반영
> **목적**: Anthropic Cloud Vision 제거 → MCP 경유 로컬 LLM(vLLM Qwen3-Coder-Next-FP8)으로 DXF/PDF 텍스트 추출 구현

**백엔드 완료 요약**:
- 단계 1-8 완료 (P&ID 도메인, 서비스, 컨트롤러, DB, Program.cs 등록)
- MCP 툴 추가: `extract_pid_tags`, `match_pid_tags` (mcp-server/server.py)
- 빌드 검증: `dotnet build` 성공 (0 에러)
- API 엔드포인트: `/api/pid/*` (13개)

**프론트엔드 완료 요약 (v4.1)**:
- 단계 9-12 완료 (index.html, app.js, style.css, appsettings.json)
- P&ID 추출 탭 추가 (11개 탭)
- 추출 결과 테이블 + 페이지네이션
- CSV/Excel 내보내기 기능
- 통계 정보 표시

---

## 📋 개요

DXF/PDF 형식의 P&ID 도면에서 장비 및 계기 정보를 AI로 자동 추출하여 ExperionCrawler 데이터베이스와 연동하는 기능입니다.

**주요 변경사항**:
- ❌ Anthropic Cloud Vision 제거
- ✅ MCP 경유 로컬 LLM(vLLM Qwen3-Coder-Next-FP8) 사용
- ✅ netDxf (DXF 파싱) + PdfPig (PDF 텍스트 추출) 사용

---

## 🎯 목표

1. P&ID 도면에서 장비 정보를 추출
2. 추출된 정보를 PostgreSQL 로 저장
3. 기존 Experion 데이터와 연동
4. 웹에서 시각화 및 관리

---

## ✅ 완료된 단계 (v4) — 백엔드 완료 (2026-04-30)

| 단계 | 내용 | 상태 |
|------|------|------|
| 1 | P&ID 도메인 엔티티 생성 (`PidEquipment`, `PidAuditLog`) | ✅ 완료 |
| 2 | DTOs 생성 (`PidEquipmentDto`, `PidExtractionResult`, `TagMappingDtos`) | ✅ 완료 |
| 3 | Interface 정의 (`IPidExtractorService`, `ITagMappingService`) | ✅ 완료 |
| 4 | TagMappingService 생성 | ✅ 완료 |
| 5 | PidExtractorService 생성 (Anthropic → MCP 로컬 LLM) | ✅ 완료 |
| 6 | Database Migration (`DbSet`, FK 설정) | ✅ 완료 |
| 7 | PidController 추가 (`ExperionPidController` - 13개 엔드포인트) | ✅ 완료 |
| 8 | Program.cs 등록 (`AddScoped<IPidExtractorService>`, `AddScoped<ITagMappingService>`) | ✅ 완료 |

---

## ✅ 완료된 단계 (v4) — MCP 툴 추가 (2026-04-30)

| 단계 | 내용 | 상태 |
|------|------|------|
| 9 | Python MCP 툴 `extract_pid_tags` 구현 | ✅ 완료 |
| 10 | Python MCP 툴 `match_pid_tags` 구현 | ✅ 완료 |

---

## ✅ 완료된 단계 (v4) — 상세

### 단계 1: P&ID 도메인 엔티티 생성 ✅

**파일**: [`src/Core/Domain/Entities/PidEquipment.cs`](../src/Core/Domain/Entities/PidEquipment.cs)  
**파일**: [`src/Core/Domain/Entities/PidAuditLog.cs`](../src/Core/Domain/Entities/PidAuditLog.cs)

```csharp
// PidEquipment.cs
[Table("pid_equipment")]
public class PidEquipment
{
    public long Id { get; set; }
    
    [Required]
    [MaxLength(50)]
    public string TagNo { get; set; } = string.Empty;
    
    [MaxLength(200)]
    public string? EquipmentName { get; set; }
    
    [MaxLength(10)]
    public string? InstrumentType { get; set; }
    
    [MaxLength(100)]
    public string? LineNumber { get; set; }
    
    [MaxLength(50)]
    public string? PidDrawingNo { get; set; }
    
    public double Confidence { get; set; }
    
    public bool IsActive { get; set; } = true;
    
    public DateTime ExtractedAt { get; set; } = DateTime.UtcNow;
    
    public DateTime? UpdatedAt { get; set; }
    
    public int? ExperionTagId { get; set; }
    public RealtimePoint? ExperionTag { get; set; }
}

// PidAuditLog.cs
[Table("pid_audit_log")]
public class PidAuditLog
{
    public long Id { get; set; }
    [MaxLength(50)]
    public string Source { get; set; } = string.Empty;
    [MaxLength(50)]
    public string Action { get; set; } = string.Empty;
    [MaxLength(50)]
    public string TargetTagNo { get; set; } = string.Empty;
    public string? OldValue { get; set; }
    public string? NewValue { get; set; }
    public DateTime LoggedAt { get; set; } = DateTime.UtcNow;
}
```

> **현황**: 이미 코드베이스에 완료됨. `UpdatedAt`이 `DateTime?` (nullable)으로 정의되어 있음.

---

### 단계 2: DTOs 생성 ✅

**파일**: [`src/Core/Application/DTOs/PidEquipmentDto.cs`](../src/Core/Application/DTOs/PidEquipmentDto.cs)  
**파일**: [`src/Core/Application/DTOs/PidExtractionResult.cs`](../src/Core/Application/DTOs/PidExtractionResult.cs)  
**파일**: [`src/Core/Application/DTOs/TagMappingDtos.cs`](../src/Core/Application/DTOs/TagMappingDtos.cs)

```csharp
// PidEquipmentDto.cs
public record PidEquipmentDto(
    long Id,
    string TagNo,
    string? EquipmentName,
    string? InstrumentType,
    string? LineNumber,
    string? PidDrawingNo,
    double Confidence,
    bool IsActive,
    DateTime ExtractedAt,
    DateTime? UpdatedAt,
    int? ExperionTagId,
    string? ExperionTagName);

// PidExtractionResult.cs
public record PidExtractionResult(
    int TotalCount,
    int ConfidenceItems,
    int LowConfidenceItems);

// TagMappingDtos.cs
public record TagMappingResult
{
    public long PidEquipmentId { get; set; }
    public string TagNo { get; set; } = string.Empty;
    public string? EquipmentName { get; set; }
    public string? InstrumentType { get; set; }
    public string? LineNumber { get; set; }
    public string? PidDrawingNo { get; set; }
    public double Confidence { get; set; }
    public bool IsActive { get; set; }
    public int? ExperionTagId { get; set; }
    public string? ExperionTagName { get; set; }
    public string? ExperionNodeId { get; set; }
}

public record CreateMappingRequest(long PidEquipmentId, int ExperionTagId);
public record UpdateMappingRequest(int? ExperionTagId, bool? IsActive);
```

> **현황**: 이미 코드베이스에 완료됨. `ExtractedItem`/`MappingItem`은 `PidExtractorService.cs` 내부에 `public class`로 정의됨.

---

### 단계 3: Interface 정의 ✅

**파일**: [`src/Core/Application/Interfaces/IExperionServices.cs`](../src/Core/Application/Interfaces/IExperionServices.cs)

```csharp
// P&ID Extractor
public interface IPidExtractorService
{
    Task<PidExtractionResult> ExtractFromFileAsync(string filePath, bool useImageMode = false);
    Task<PidExtractionResult> ExtractFromStreamAsync(Stream stream, string fileName, bool useImageMode = false);
    
    Task<(int Total, IEnumerable<PidEquipment> Items)> GetEquipmentAsync(string? tagNo, int page, int pageSize);
    Task<PidEquipment?> GetByIdAsync(long id);
    
    Task UpdateConfidenceAsync(long id, double confidence);
    Task ActivateAsync(long id);
    Task DeactivateAsync(long id);
    
    Task<int> GetTotalCountAsync();
    Task<int> GetConfidenceItemsCountAsync();
    Task<int> GetLowConfidenceItemsCountAsync();
    Task<IDictionary<string, int>> GetConfidenceDistributionAsync();
    Task<int> GetDrawingCountAsync();
    
    Task<string> ExportToCsvAsync(IEnumerable<PidEquipment> items);
    Task<byte[]> ExportToExcelAsync(IEnumerable<PidEquipment> items);
}

// P&ID Tag Mapping
public interface ITagMappingService
{
    Task<(int Total, IEnumerable<TagMappingResult> Items)> GetMappingsAsync(int page, int pageSize);
    Task<TagMappingResult?> GetMappingByIdAsync(long id);
    Task<TagMappingResult> CreateMappingAsync(CreateMappingRequest request);
    Task UpdateMappingAsync(long id, UpdateMappingRequest request);
    Task ClearMappingAsync(long id);
    
    Task<int> GetUnmappedCountAsync();
    Task<int> GetMappedCountAsync();
    Task<IEnumerable<string>> GetAvailableTagsAsync();
}
```

> **현황**: 이미 코드베이스에 완료됨. `IExperionServices.cs`에 `IPidExtractorService`, `ITagMappingService` 모두 정의됨.

---

### 단계 4: TagMappingService 생성 ✅

**파일**: [`src/Core/Application/Services/TagMappingService.cs`](../src/Core/Application/Services/TagMappingService.cs)

```csharp
public class TagMappingService : ITagMappingService
{
    private readonly ExperionDbContext _dbContext;
    
    public TagMappingService(ExperionDbContext dbContext)
    {
        _dbContext = dbContext;
    }
    
    public async Task<(int Total, IEnumerable<TagMappingResult> Items)> GetMappingsAsync(int page, int pageSize)
    {
        var query = from pe in _dbContext.PidEquipment
                    join rt in _dbContext.RealtimePoints
                        on pe.ExperionTagId equals rt.Id into joined
                    from rt in joined.DefaultIfEmpty()
                    select new TagMappingResult { ... };
        
        var total = await query.CountAsync();
        var items = await query
            .OrderByDescending(e => e.Confidence)
            .Skip((page - 1) * pageSize)
            .Take(pageSize)
            .ToListAsync();
        
        return (total, items);
    }
    
    public async Task<TagMappingResult?> GetMappingByIdAsync(long id) { ... }
    public async Task<TagMappingResult> CreateMappingAsync(CreateMappingRequest request) { ... }
    public async Task UpdateMappingAsync(long id, UpdateMappingRequest request) { ... }
    public async Task ClearMappingAsync(long id) { ... }
    
    public async Task<int> GetUnmappedCountAsync()
        => await _dbContext.PidEquipment.CountAsync(e => e.ExperionTagId == null);
    
    public async Task<int> GetMappedCountAsync()
        => await _dbContext.PidEquipment.CountAsync(e => e.ExperionTagId != null);
    
    public async Task<IEnumerable<string>> GetAvailableTagsAsync()
    {
        var mappedTagIds = await _dbContext.PidEquipment
            .Where(e => e.ExperionTagId != null)
            .Select(e => e.ExperionTagId)
            .ToListAsync();
        
        return await _dbContext.RealtimePoints
            .Where(t => !mappedTagIds.Contains(t.Id))
            .Select(t => t.TagName)
            .OrderBy(t => t)
            .ToListAsync();
    }
}
```

> **현황**: 이미 코드베이스에 완료됨. `GetAvailableTagsAsync()`가 이미 매핑된 태그를 제외한 목록만 반환하는 올바른 구현이 있음.

---

### 단계 5: PidExtractorService 생성 ✅

**파일**: [`src/Core/Application/Services/PidExtractorService.cs`](../src/Core/Application/Services/PidExtractorService.cs)

```csharp
public class PidExtractorService : IPidExtractorService
{
    private readonly McpClient _mcp;
    private readonly ExperionDbContext _dbContext;
    private readonly ILogger<PidExtractorService> _logger;

    public PidExtractorService(McpClient mcp, ExperionDbContext dbContext, ILogger<PidExtractorService> logger)
    {
        _mcp = mcp;
        _dbContext = dbContext;
        _logger = logger;
    }

    public async Task<PidExtractionResult> ExtractFromFileAsync(string filePath, bool useImageMode = false)
    {
        await using var stream = File.OpenRead(filePath);
        return await ExtractFromStreamAsync(stream, Path.GetFileName(filePath), useImageMode);
    }

    public async Task<PidExtractionResult> ExtractFromStreamAsync(Stream stream, string fileName, bool useImageMode = false)
    {
        var ext = Path.GetExtension(fileName).ToLowerInvariant();

        string text = ext switch
        {
            ".dxf" => ExtractDxfText(stream),
            ".pdf" => ExtractPdfText(stream),
            _      => throw new NotSupportedException($"지원 형식: .dxf .pdf (스캔본 이미지는 Vision 모드 필요)")
        };

        if (string.IsNullOrWhiteSpace(text))
            return new PidExtractionResult(0, 0, 0);

        // MCP → vLLM 태그 추출
        var sourceType = ext.TrimStart('.');
        var json = await _mcp.ExtractPidTagsAsync(text, sourceType);
        var extractedItems = ParseJson(json);

        if (extractedItems.Count == 0)
        {
            _logger.LogWarning("P&ID 추출 결과 0건 — 파일: {FileName}", fileName);
            return new PidExtractionResult(0, 0, 0);
        }

        // MCP → vLLM 태그 매핑 제안
        var pidTagNos = extractedItems.Select(i => i.TagNo).Distinct().ToList();
        var experionTagNames = await _dbContext.RealtimePoints.Select(r => r.TagName).ToListAsync();
        var mappingJson = await _mcp.MatchPidTagsAsync(pidTagNos, experionTagNames);
        var mappings = ParseMappingJson(mappingJson);

        // DB 저장
        var dbItems = new List<PidEquipment>();
        foreach (var item in extractedItems)
        {
            mappings.TryGetValue(item.TagNo, out var matched);
            var experionTag = matched != null
                ? await _dbContext.RealtimePoints.FirstOrDefaultAsync(r => r.TagName == matched)
                : await FindFallbackTagAsync(item.TagNo);

            dbItems.Add(new PidEquipment
            {
                TagNo = item.TagNo,
                EquipmentName = item.EquipmentName,
                InstrumentType = item.InstrumentType,
                LineNumber = item.LineNumber,
                PidDrawingNo = item.PidDrawingNo,
                Confidence = item.Confidence,
                ExperionTagId = experionTag?.Id,
                ExtractedAt = DateTime.UtcNow,
                UpdatedAt = DateTime.UtcNow
            });
        }

        await _dbContext.PidEquipment.AddRangeAsync(dbItems);
        await _dbContext.SaveChangesAsync();

        _logger.LogInformation("P&ID 추출 완료: {Total}건 저장 (파일: {FileName})", dbItems.Count, fileName);

        return new PidExtractionResult(
            TotalCount: dbItems.Count,
            ConfidenceItems: dbItems.Count(i => i.Confidence >= 0.7),
            LowConfidenceItems: dbItems.Count(i => i.Confidence < 0.5));
    }

    private string ExtractDxfText(Stream stream) { ... }
    private string ExtractPdfText(Stream stream) { ... }
    private List<ExtractedItem> ParseJson(string json) { ... }
    private Dictionary<string, string> ParseMappingJson(string json) { ... }
    private async Task<RealtimePoint?> FindFallbackTagAsync(string tagNo) { ... }

    // CRUD / 통계 / 내보내기 메서드들...
}
```

**내부 파싱용 모델**:
```csharp
public class ExtractedItem
{
    public string TagNo { get; set; } = "";
    public string? EquipmentName { get; set; }
    public string? InstrumentType { get; set; }
    public string? LineNumber { get; set; }
    public string? PidDrawingNo { get; set; }
    public double Confidence { get; set; } = 0.5;
}

public class MappingItem
{
    public string PidTag { get; set; } = "";
    public string? ExperionTag { get; set; }
    public double Confidence { get; set; }
}
```

> **현황**: `PidExtractorService.cs`가 MCP 기반으로 완료됨. `netDxf`, `PdfPig`, `EPPlus` 패키지 추가 완료.

---

## 📦 추가된 패키지

| 패키지 | 버전 | 용도 |
|--------|------|------|
| netDxf | 2022.11.2 | DXF 파일 파싱 |
| PdfPig | 0.1.9 | PDF 텍스트 추출 |
| EPPlus | 7.4.2 | Excel 내보내기 |

---

## ✅ 완료된 단계 (v4) - 추가

### 단계 6: Database Migration ✅

**파일**: [`src/Infrastructure/Database/ExperionDbContext.cs`](../src/Infrastructure/Database/ExperionDbContext.cs)

```csharp
public class ExperionDbContext : DbContext
{
    public DbSet<PidEquipment> PidEquipment => Set<PidEquipment>();
    public DbSet<PidAuditLog> PidAuditLog => Set<PidAuditLog>();
    
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // PidEquipment → RealtimePoint FK
        modelBuilder.Entity<PidEquipment>(entity =>
        {
            entity.HasOne(e => e.ExperionTag)
                  .WithMany()
                  .HasForeignKey(e => e.ExperionTagId)
                  .OnDelete(DeleteBehavior.SetNull);
        });
    }
}
```

> **현황**: 이미 코드베이스에 완료됨. `DbSet`과 FK 설정 모두 정상 구현됨.

---

### 단계 7: PidController 추가 ✅

**파일**: [`src/Web/Controllers/ExperionControllers.cs`](../src/Web/Controllers/ExperionControllers.cs) (810-985행)

```csharp
[ApiController]
[Route("api/pid")]
public class ExperionPidController : ControllerBase
{
    private readonly IPidExtractorService _extractor;
    private readonly ITagMappingService _mapping;

    public ExperionPidController(IPidExtractorService extractor, ITagMappingService mapping)
    {
        _extractor = extractor;
        _mapping = mapping;
    }

    [HttpPost("extract")]
    [RequestSizeLimit(100 * 1024 * 1024)]
    public async Task<IActionResult> Extract(IFormFile file) { ... }

    [HttpGet("equipment")]
    public async Task<IActionResult> GetEquipment(...) { ... }

    [HttpGet("statistics")]
    public async Task<IActionResult> GetStatistics() { ... }

    [HttpPut("{id:long}/confidence")]
    public async Task<IActionResult> UpdateConfidence(long id, [FromBody] double confidence) { ... }

    [HttpPost("{id:long}/activate")]
    public async Task<IActionResult> Activate(long id) { ... }

    [HttpPost("{id:long}/deactivate")]
    public async Task<IActionResult> Deactivate(long id) { ... }

    [HttpGet("mappings")]
    public async Task<IActionResult> GetMappings(...) { ... }

    [HttpPost("mappings")]
    public async Task<IActionResult> CreateMapping([FromBody] CreateMappingRequest req) { ... }

    [HttpPut("mappings/{id:long}")]
    public async Task<IActionResult> UpdateMapping(long id, [FromBody] UpdateMappingRequest req) { ... }

    [HttpDelete("mappings/{id:long}")]
    public async Task<IActionResult> ClearMapping(long id) { ... }

    [HttpGet("mappings/available-tags")]
    public async Task<IActionResult> GetAvailableTags() { ... }

    [HttpGet("export/csv")]
    public async Task<IActionResult> ExportCsv([FromQuery] string? tagNo) { ... }

    [HttpGet("export/excel")]
    public async Task<IActionResult> ExportExcel([FromQuery] string? tagNo) { ... }
}
```

> **현황**: `ExperionControllers.cs`에 `ExperionPidController`가 완료됨.
> **API 엔드포인트**: `/api/pid/*` (13개 엔드포인트)

---

### 단계 8: Program.cs 등록 ✅

**파일**: [`src/Web/Program.cs`](../src/Web/Program.cs)

```csharp
// Line 85-86
builder.Services.AddScoped<IPidExtractorService, PidExtractorService>();
builder.Services.AddScoped<ITagMappingService, TagMappingService>();
```

> **현황**: 이미 코드베이스에 완료됨. `AddScoped` 등록 정상 구현됨.

---

### 단계 9~14: Frontend 및 기타 (2026-04-30)

> **상태**: 단계 8까지 백엔드 완료. MCP 툴(`extract_pid_tags`, `match_pid_tags`) 구현 완료.
> 단계 9-12 프론트엔드 구현 완료. 다음 단계는 통합 테스트입니다.

| 단계 | 내용 | 상태 |
|------|------|------|
| 9 | `index.html`에 P&ID 탭 + pane 추가 | ✅ 완료 (2026-04-30) |
| 10 | `app.js`에 P&ID 함수 추가 | ✅ 완료 (2026-04-30) |
| 11 | `style.css`에 P&ID 스타일 추가 | ✅ 완료 (2026-04-30) |
| 12 | `appsettings.json` Kestrel 설정 추가 | ✅ 완료 (2026-04-30) |
| 13 | 실제 DXF/PDF 파일 업로드 테스트 | 대기 |
| 14 | 수동 매핑 UI 동작 확인 | 대기 |

---

### 단계 9: index.html에 P&ID 탭 + pane 추가 ✅

**파일**: [`src/Web/wwwroot/index.html`](../src/Web/wwwroot/index.html)

**변경 내용**:
1. 탭 목록에 P&ID 탭 추가 (11번째 탭)
2. `pane-pid` 섹션 추가 (fastRecord 다음)

**구조**:
```html
<li class="nav-item" data-tab="pid">
  <span class="ni">11</span>
  <span class="nl">P&ID 추출</span>
</li>

<section class="pane" id="pane-pid">
  <!-- 파일 업로드 카드 -->
  <div class="card">
    <div class="card-cap">P&ID 파일 업로드</div>
    <input type="file" id="pid-file-input" accept=".dxf,.pdf"/>
    <button onclick="pidExtract()">🚀 추출 시작</button>
  </div>
  
  <!-- 추출 결과 테이블 -->
  <div class="card">
    <table class="table" id="pid-table">
      <thead>...</thead>
      <tbody id="pid-table-body"></tbody>
    </table>
  </div>
  
  <!-- 통계 카드 -->
  <div class="card">
    <div class="stat-box">
      <div class="stat-label">총 추출 건수</div>
      <div class="stat-value" id="pid-stat-total">0</div>
    </div>
  </div>
</section>
```

> **현황**: index.html에 P&ID 탭 및 pane 완료. fastRecord 다음에 추가.

---

### 단계 10: app.js에 P&ID 함수 추가 ✅

**파일**: [`src/Web/wwwroot/js/app.js`](../src/Web/wwwroot/js/app.js)

**추가된 함수**:
- `pidExtract()` — 파일 업로드 및 추출 시작
- `pidLoadTable(page)` — 추출 결과 테이블 로드
- `pidRenderPagination(total, currentPage)` — 페이지네이션 렌더링
- `pidUpdateStats()` — 통계 정보 업데이트
- `pidClearLog()` — 로그 지우기
- `pidOpenMapping(id)` — 매핑 모달 열기

**API 연동**:
- `/api/pid/extract` — POST (파일 업로드)
- `/api/pid/equipment` — GET (목록 조회)
- `/api/pid/export/csv` — GET (CSV 내보내기)
- `/api/pid/export/excel` — GET (Excel 내보내기)

> **현황**: app.js에 P&ID 함수 완료. 기존 패턴(인증서 관리, Text-to-SQL) 따름.

---

### 단계 11: style.css에 P&ID 스타일 추가 ✅

**파일**: [`src/Web/wwwroot/css/style.css`](../src/Web/wwwroot/css/style.css)

**추가된 스타일**:
- `#pane-pid .btn-sm` — 버튼 스타일 (btn-a, btn-b)
- `#pane-pid .badge` — 배지 스타일 (ok, warn, err)
- `#pane-pid .stat-box` — 통계 박스 스타일
- `#pane-pid .pagination` — 페이지네이션 스타일
- `#pane-pid .logbox` — 로그 박스 스타일

> **현황**: style.css에 P&ID 스타일 완료. 다크 테마 색상 사용.

---

### 단계 12: appsettings.json Kestrel 설정 추가 ✅

**파일**: [`src/Web/appsettings.json`](../src/Web/appsettings.json)

**추가된 설정**:
```json
{
  "Kestrel": {
    "Endpoints": {
      "Http": {
        "Url": "http://0.0.0.0:5000"
      }
    },
    "Limits": {
      "MaxRequestBodySize": 104857600
    }
  }
}
```

**설명**:
- `MaxRequestBodySize`: 100MB (DXF/PDF 파일 업로드용)
- `Url`: 모든 네트워크 인터페이스에서 수신

> **현황**: appsettings.json에 Kestrel 설정 완료.

---

## 📋 구현 순서 및 체크리스트

### Phase 1 — 백엔드 (완료)

- [x] **단계 1**: `ExperionCrawler.csproj`에 `netDxf`, `PdfPig`, `EPPlus` 패키지 추가 후 `dotnet build` 확인
- [x] **단계 2**: `McpClient.cs`에 `ExtractPidTagsAsync`, `MatchPidTagsAsync` 메서드 추가
- [x] **단계 3**: Python MCP 서버에 `extract_pid_tags`, `match_pid_tags` 툴 추가 및 테스트
- [x] **단계 4**: `PidExtractorService.cs` 전체 교체
- [x] **단계 5**: `ExperionControllers.cs`에 `ExperionPidController` 추가
- [x] **단계 6**: `dotnet build` 에러 0건 확인 (2026-04-30 확인)
- [ ] **단계 7**: Swagger(`/swagger`)에서 `/api/pid/*` 엔드포인트 노출 확인 (대기)

### Phase 2 — 프론트엔드 (완료)

- [x] **단계 9**: `index.html`에 P&ID 탭 + pane 추가 (2026-04-30)
- [x] **단계 10**: `app.js`에 P&ID 함수 추가 (2026-04-30)
- [x] **단계 11**: `style.css`에 P&ID 스타일 추가 (2026-04-30)
- [x] **단계 12**: `appsettings.json` Kestrel 설정 추가 (2026-04-30)

### Phase 3 — 통합 테스트 (대기)

- [ ] 실제 DXF 파일 업로드 → 태그 추출 확인
- [ ] 실제 PDF(텍스트) 파일 업로드 → 태그 추출 확인
- [ ] 추출 결과 → Experion 태그 자동 매핑 제안 확인
- [ ] 수동 매핑 UI 동작 확인
- [ ] CSV/Excel 내보내기 확인
- [ ] MCP 서버 다운 시 에러 처리 확인

---

## ⚠️ 주요 주의사항

| 항목 | 내용 |
|------|------|
| MCP 서버 의존성 | `localhost:5001` 응답 없으면 추출 실패 — UI에서 `ping` 사전 확인 권장 |
| netDxf 임시 파일 | DXF 파싱 시 `/tmp`에 임시 파일 생성/삭제 — 권한 및 디스크 여유 확인 |
| PDF 텍스트 추출 실패 | 스캔본 PDF는 `ExtractPdfText()`가 빈 문자열 반환 → Vision 미구현 안내 메시지 |
| 프롬프트 튜닝 | 도면 특성(한국어/영어, 태그 표기 방식)에 따라 MCP 서버 프롬프트 조정 필요 |
| 대용량 DXF | 1만 개 이상 엔티티 시 LLM 컨텍스트 초과 가능 → `text[:12000]` 슬라이싱으로 제한 중 |
| 태그 매핑 확신도 | `confidence < 0.7` 자동 매핑은 저장하지 않음 — 수동 매핑 유도 |

---

## 📝 코딩 가이드

### 1. 기존 패턴 엄수

```
새 기능 추가 시 반드시 기존 코드 패턴을 따를 것

✅ 엔티티:       [Table("테이블명")], [Column("컬럼명")] 어트리뷰트 필수
✅ DbContext:    ExperionDbContext 단일 컨텍스트 — 별도 DbContext 생성 금지
✅ 서비스 등록:  Program.cs에 AddScoped<Interface, Implementation>() 형태
✅ 컨트롤러:     ExperionControllers.cs 단일 파일에 추가 (별도 파일 생성 금지)
✅ 탭 진입:      API 자동 호출 금지 — 버튼 클릭으로만 동작
✅ DOM 렌더링:   innerHTML += 루프 금지 — rows 배열 .join('') 후 한번에 설정
✅ 로깅:         Console.WriteLine 금지 — ILogger<T> 사용
```

### 2. MCP 서버 연동 패턴

```
C# 서비스에서 MCP 호출 시 McpClient를 직접 주입.
IMcpService를 통하지 않아도 됨 (McpClient는 Singleton 등록).
```

```csharp
// 올바른 패턴
public MyService(McpClient mcp, ExperionDbContext db, ILogger<MyService> logger)

// 금지
public MyService(IMcpService mcp)  // 래핑 레이어가 필요할 때만 사용
```

### 3. Python MCP 툴 작성 규칙

```python
# 응답은 반드시 순수 JSON 문자열 반환
# 코드펜스(```json) 제거 후 반환
# LLM 응답에서 JSON 배열 추출: re.search(r'\[.*\]', raw, re.DOTALL)
# temperature=0.1 고정 (결정론적 출력)
# 텍스트 슬라이싱: text[:12000] (컨텍스트 초과 방지)
```

### 4. 프론트엔드 규칙

```
✅ 함수 기반 작성 — class 사용 금지
✅ 기존 헬퍼 함수 재사용: esc(), log(), setGlobal(), api()
✅ 페이지네이션: pidPagination() 패턴 — 전체 페이지 버튼 생성 금지 (±3 범위)
✅ 다크 테마 색상 사용: #1e1e1e, #2d2d2d, #ccc, .ok/.err/.warn CSS 클래스
✅ Bootstrap 클래스 사용 금지
✅ fetch 에러 처리: if (!res.ok) throw new Error(...)
```

### 5. 빌드 검증 체크포인트

```bash
# 각 단계 완료 후 실행
dotnet build src/Web/ExperionCrawler.csproj

# 목표: 경고 N건, 에러 0건
# netDxf/PdfPig/EPPlus 추가 후 새 경고 없어야 정상
```

---

## 🧐 감독자 진단 — 단계 8까지 완료 여부 (2026-04-30 기준)

| 항목 | 확인 내용 | 상태 |
|------|-----------|------|
| **엔티티** | `PidEquipment.cs`, `PidAuditLog.cs` 존재 | ✅ |
| **DbSet** | `ExperionDbContext.PidEquipment`, `PidAuditLog` 등록 | ✅ |
| **FK 설정** | `ExperionTagId` → `RealtimePoint.Id` FK 설정 | ✅ |
| **DTOs** | `PidEquipmentDto`, `PidExtractionResult`, `TagMappingDtos` 존재 | ✅ |
| **인터페이스** | `IPidExtractorService`, `ITagMappingService` 정의 | ✅ |
| **서비스** | `TagMappingService`, `PidExtractorService` 구현 | ✅ |
| **컨트롤러** | `ExperionPidController` (13개 엔드포인트) | ✅ |
| **Program.cs** | `AddScoped<IPidExtractorService>`, `AddScoped<ITagMappingService>` | ✅ |
| **MCP 툴** | `extract_pid_tags`, `match_pid_tags` 구현 | ✅ |
| **빌드** | `dotnet build` 에러 0건 | ✅ |

> **진단 결과**: 단계 1-8 완료. MCP 툴 추가 완료. 다음 단계는 프론트엔드 구현(단계 9-12) 및 통합 테스트(단계 13-14)입니다.

### 7. MCP 툴 독립 테스트

```bash
# Python MCP 서버 직접 테스트 (C# 없이)
curl -X POST http://localhost:5001/mcp \
  -H "Content-Type: application/json" \
  -H "mcp-protocol-version: 2025-03-26" \
  -d '{"jsonrpc":"2.0","id":"1","method":"tools/call",
       "params":{"name":"extract_pid_tags",
                 "arguments":{"text":"FT-101 Flow Transmitter\nPT-201 Pressure","source_type":"dxf"}}}'
# 기대 응답: [{"tagNo":"FT-101",...},{"tagNo":"PT-201",...}]
```
