# P&ID AX 코딩 플랜 (세분화된 단계)

## 📋 개요

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

---

## 🎯 목표

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

---

## 📦 폴더 구조

```
ExperionCrawler/
├── src/
│   ├── Core/
│   │   ├── Application/
│   │   │   ├── Interfaces/
│   │   │   │   ├── IPidExtractorService.cs (신규)
│   │   │   │   └── ITagMappingService.cs (신규)
│   │   │   ├── Services/
│   │   │   │   ├── PidExtractorService.cs (신규)
│   │   │   │   └── TagMappingService.cs (신규)
│   │   │   └── Dtos/
│   │   │       ├── PidEquipmentDto.cs (신규)
│   │   │       └── PidExtractionResult.cs (신규)
│   │   └── Domain/
│   │       ├── Entities/
│   │       │   ├── PidEquipment.cs (신규)
│   │       │   └── PidAuditLog.cs (신규)
│   │       └── ValueObjects/
│   │           ├── ConfidenceScore.cs (신규)
│   │           └── MeasurementUnit.cs (신규)
│   ├── Infrastructure/
│   │   ├── Database/
│   │   │   ├── PidDbContext.cs (신규)
│   │   │   └── ExperionDbContext.cs (확장)
│   │   └── OpcUa/
│   │       └── (기존)
│   └── Web/
│       ├── Controllers/
│       │   └── PidController.cs (신규)
│       └── wwwroot/
│           └── js/
│               └── app.js (확장)
└── futurePlan/
    ├── temp/
    │   ├── pid_extractor.py (AI 추출기)
    │   ├── schema.sql (추구용 DB 스키마)
    │   └── requirements.txt (Python 의존성)
    └── p&id_ax-coding_plan.md (이 파일)
```

---

## 📋 코딩 단계 (15단계)

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

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

```csharp
namespace ExperionCrawler.Core.Domain.Entities;

public class PidEquipment
{
    public long Id { 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 decimal Confidence { get; set; }
    public bool IsActive { get; set; } = true;
    public DateTime ExtractedAt { get; set; }
    public DateTime? UpdatedAt { get; set; }
    
    // 외래 키
    public long? ExperionTagId { get; set; }
    public RealtimePoint? ExperionTag { get; set; }
}
```

> #### ⚠️ 수정안
> **`ExperionTagId` 타입 오류**: 기존 `RealtimePoint.Id`는 `int` 타입(`ExperionEntities.cs:73`)이므로 FK도 `int?`여야 합니다. `long?`으로 선언하면 EF Core 모델 구성 및 JOIN 시 타입 불일치가 발생합니다.
> ```csharp
> // 변경 전
> public long? ExperionTagId { get; set; }
> // 변경 후
> public int? ExperionTagId { get; set; }
> ```
> **`[Table]` 어트리뷰트 누락**: 기존 엔티티들은 모두 `[Table("테이블명")]`을 명시합니다. `[Table("pid_equipment")]` 추가 필요.
> **`ConfidenceScore` Value Object 미사용**: 단계 3에서 생성하는 `ConfidenceScore` record를 이 엔티티에서 사용하지 않고 `decimal` 그대로 사용합니다. Value Object를 만드는 의미가 없으므로 단계 3을 생략하거나, 이 엔티티에 실제로 적용해야 합니다.

---

### 단계 2: P&ID 감사 로그 엔티티 생성

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

```csharp
namespace ExperionCrawler.Core.Domain.Entities;

public class PidAuditLog
{
    public long Id { get; set; }
    public string UserId { get; set; } = string.Empty;
    public string Action { get; set; } = string.Empty;
    public string TargetTagNo { get; set; } = string.Empty;
    public string? OldValue { get; set; }
    public string? NewValue { get; set; }
    public DateTime LoggedAt { get; set; }
}
```

> #### ⚠️ 수정안
> **인증 시스템 부재**: 이 프로젝트에는 로그인/사용자 관리 기능이 없습니다. `UserId` 필드가 항상 빈 문자열이 될 것입니다. 현실적인 대안은 `UserId`를 제거하고 `Source`(예: "WebUI", "API") 또는 `IpAddress` 정도로 대체하는 것입니다.
> **`[Table]` 어트리뷰트 누락**: `[Table("pid_audit_log")]` 추가 필요.
> **`LoggedAt` 자동 설정 누락**: `= DateTime.UtcNow` 기본값을 추가하거나 DB 레벨에서 `DEFAULT NOW()`를 설정해야 합니다.

---

### 단계 3: P&ID Value Object 생성

**파일**: [`src/Core/Domain/ValueObjects/ConfidenceScore.cs`](../src/Core/Domain/ValueObjects/ConfidenceScore.cs)

```csharp
namespace ExperionCrawler.Core.Domain.ValueObjects;

public record ConfidenceScore(decimal Value)
{
    public static ConfidenceScore From(decimal value)
    {
        if (value < 0 || value > 1)
            throw new ArgumentException("신뢰도는 0~1 사이여야 합니다.", nameof(value));
        return new ConfidenceScore(value);
    }
    
    public static implicit operator decimal(ConfidenceScore score) => score.Value;
    public override string ToString() => Value.ToString("0.00");
}
```

> #### ⚠️ 수정안
> **실제 사용처 없음**: `PidEquipment` 엔티티(단계 1)가 `decimal Confidence`를 그대로 사용하므로 이 Value Object는 아무 곳에서도 참조되지 않습니다. EF Core는 Value Object를 엔티티 컬럼에 매핑하려면 `[Owned]` 또는 별도 컨버터 설정이 필요한데, 이 부분이 빠져 있습니다.
> **`MeasurementUnit.cs` 미구현**: 폴더 구조에 `MeasurementUnit.cs`가 있지만 계획 어디에도 정의가 없습니다.
> **결론**: 이 단계는 **생략**하는 것을 권장합니다. `PidEquipment.Confidence`를 `decimal`로 유지하고, 유효성 검사는 컨트롤러 또는 서비스 레이어에서 `if (confidence < 0 || confidence > 1)` 조건문으로 처리하면 충분합니다.

---

### 단계 4: P&ID DTO 생성

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

```csharp
namespace ExperionCrawler.Core.Application.DTOs;

public record PidEquipmentDto(
    long Id,
    string TagNo,
    string? EquipmentName,
    string? InstrumentType,
    string? LineNumber,
    string? PidDrawingNo,
    decimal Confidence,
    bool IsActive,
    DateTime ExtractedAt,
    DateTime? UpdatedAt,
    long? ExperionTagId,
    string? ExperionTagName);
```

> #### ⚠️ 수정안
> **폴더명 불일치**: 계획은 `Dtos`(소문자)를 사용하지만, 기존 코드는 `src/Core/Application/DTOs`(대문자)를 사용합니다. 기존 폴더인 `DTOs`에 추가해야 합니다. 또는 기존 `ExperionDtos.cs` 파일 끝에 이 record를 추가하는 방식이 프로젝트 스타일과 일치합니다.
> **`ExperionTagId` 타입 오류**: 단계 1과 동일 — `long?`이 아닌 `int?`여야 합니다.

---

### 단계 5: P&ID 추출 결과 DTO 생성

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

```csharp
namespace ExperionCrawler.Core.Application.DTOs;

public record PidExtractionResult(
    int TotalCount,
    int ConfidenceItems,
    int LowConfidenceItems,
    string CsvPath,
    string ExcelPath);
```

> #### ⚠️ 수정안
> **서버 내부 파일 경로 노출**: `CsvPath`와 `ExcelPath`는 서버 파일시스템 경로(`output/pid_extracted_20260430_120000.csv`)입니다. 이 경로를 클라이언트로 반환하면 서버 디렉토리 구조가 노출됩니다. 클라이언트에서 직접 접근할 수 없는 경로이기도 합니다.
> **수정안**: 파일 경로 대신 다운로드 URL을 반환하거나, CSV는 직접 응답 스트림으로 내려주고 DTO에서 경로 필드를 제거합니다.
> ```csharp
> public record PidExtractionResult(
>     int TotalCount,
>     int ConfidenceItems,
>     int LowConfidenceItems);
> // CSV 다운로드는 별도 GET /api/pid/export?format=csv 엔드포인트로 처리
> ```

---

### 단계 6: P&ID DbContext 생성

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

```csharp
using Microsoft.EntityFrameworkCore;
using ExperionCrawler.Core.Domain.Entities;

namespace ExperionCrawler.Infrastructure.Database;

public class PidDbContext : DbContext
{
    public PidDbContext(DbContextOptions<PidDbContext> options) : base(options) { }
    
    public DbSet<PidEquipment> PidEquipment => Set<PidEquipment>();
    public DbSet<PidAuditLog> PidAuditLog => Set<PidAuditLog>();
    
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<PidEquipment>(entity =>
        {
            entity.HasKey(e => e.Id);
            entity.Property(e => e.TagNo).IsRequired().HasMaxLength(50);
            entity.Property(e => e.EquipmentName).HasMaxLength(200);
            entity.Property(e => e.InstrumentType).HasMaxLength(10);
            entity.Property(e => e.LineNumber).HasMaxLength(100);
            entity.Property(e => e.PidDrawingNo).HasMaxLength(50);
            entity.Property(e => e.Confidence).HasPrecision(3, 2);
            entity.Property(e => e.IsActive).HasDefaultValue(true);
            
            entity.HasIndex(e => e.TagNo);
            entity.HasIndex(e => e.InstrumentType);
            entity.HasIndex(e => e.ExtractedAt);
            
            entity.HasOne(e => e.ExperionTag)
                  .WithMany()
                  .HasForeignKey(e => e.ExperionTagId)
                  .OnDelete(DeleteBehavior.SetNull);
        });
        
        modelBuilder.Entity<PidAuditLog>(entity =>
        {
            entity.HasKey(e => e.Id);
            entity.Property(e => e.UserId).HasMaxLength(100);
            entity.Property(e => e.Action).HasMaxLength(50);
            entity.Property(e => e.TargetTagNo).HasMaxLength(50);
            
            entity.HasIndex(e => e.UserId);
            entity.HasIndex(e => e.LoggedAt);
        });
    }
}
```

> #### ⚠️ 수정안 (치명적)
> **별도 DbContext + FK 네비게이션 불가**: `HasOne(e => e.ExperionTag).WithMany().HasForeignKey(e => e.ExperionTagId)` 설정에서 `ExperionTag`(`RealtimePoint`)는 `ExperionDbContext`에 속하고, `PidEquipment`는 `PidDbContext`에 속합니다. **EF Core는 서로 다른 DbContext 간의 FK 관계를 지원하지 않습니다.** 이 설정을 그대로 두면 마이그레이션 생성 또는 런타임에 예외가 발생합니다.
>
> **권장 해결책 — 별도 DbContext 제거, `ExperionDbContext`에 통합**:
> 이 프로젝트는 이미 PostgreSQL 단일 DB를 사용합니다. `PidEquipment`, `PidAuditLog`를 `ExperionDbContext`에 추가하면:
> - FK 관계가 정상 동작
> - 단계 11의 cross-context JOIN 문제가 해결
> - 연결 풀 낭비 없음
>
> `PidDbContext.cs`는 **생성하지 않고**, 단계 7에서 `ExperionDbContext`에 직접 통합하는 방식으로 계획을 수정해야 합니다.
>
> **`HasPrecision(3, 2)` 오류**: Precision 3, Scale 2이면 최대 `9.99`까지만 저장 가능합니다. 신뢰도가 0~1 범위이므로 `HasPrecision(4, 3)`(최대 `9.999`) 또는 단순히 `double` 타입을 사용하는 것이 적절합니다.

---

### 단계 7: ExperionDbContext 확장

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

```csharp
// 기존 DbSet에 다음 추가
public DbSet<PidEquipment> PidEquipment => Set<PidEquipment>();
public DbSet<PidAuditLog> PidAuditLog => Set<PidAuditLog>();
```

> #### ⚠️ 수정안
> **단계 6과 중복 등록 충돌**: 단계 6에서 `PidDbContext`에 이미 `PidEquipment`와 `PidAuditLog`를 등록하고, 단계 7에서 `ExperionDbContext`에도 동일 엔티티를 등록합니다. 두 DbContext가 같은 테이블을 관리하면 `dotnet ef migrations add` 실행 시 어느 DbContext를 사용할지 결정해야 하고, 두 컨텍스트가 동시에 마이그레이션을 관리하면 충돌이 발생합니다.
>
> **수정안**: 단계 6의 `PidDbContext` 생성을 취소하고, 이 단계 7만 유지합니다. `ExperionDbContext` 단일 컨텍스트에서 모든 P&ID 엔티티를 관리하고, `OnModelCreating`에 P&ID 관련 설정을 추가합니다.
>
> 추가로 FK 관계 설정도 `OnModelCreating` 안에 포함해야 합니다:
> ```csharp
> modelBuilder.Entity<PidEquipment>(entity =>
> {
>     entity.ToTable("pid_equipment");
>     entity.HasKey(e => e.Id);
>     entity.Property(e => e.TagNo).IsRequired().HasMaxLength(50);
>     entity.Property(e => e.Confidence).HasPrecision(4, 3);
>     entity.HasOne(e => e.ExperionTag)
>           .WithMany()
>           .HasForeignKey(e => e.ExperionTagId)
>           .OnDelete(DeleteBehavior.SetNull);
> });
> modelBuilder.Entity<PidAuditLog>().ToTable("pid_audit_log");
> ```

---

### 단계 8: IPidExtractorService 인터페이스 정의

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

```csharp
using ExperionCrawler.Core.Application.DTOs;
using ExperionCrawler.Core.Domain.Entities;

namespace ExperionCrawler.Core.Application.Interfaces;

public interface IPidExtractorService
{
    Task<PidExtractionResult> ExtractFromFileAsync(string filePath, bool useImageMode = false);
    Task<PidExtractionResult> ExtractFromStreamAsync(Stream stream, string fileName, bool useImageMode = false);
    
    IQueryable<PidEquipment> GetQueryable();
    Task<IEnumerable<PidEquipment>> GetByTagNoAsync(string tagNo);
    Task<PidEquipment?> GetByIdAsync(long id);
    Task UpdateConfidenceAsync(long id, decimal confidence);
    Task ActivateAsync(long id);
    Task DeactivateAsync(long id);
    
    Task<int> GetInstrumentTypeCountAsync();
    Task<IDictionary<string, int>> GetConfidenceDistributionAsync();
    Task<int> GetDrawingCountAsync();
    Task<int> GetTotalCountAsync();
    
    Task<string> ExportToCsvAsync(IEnumerable<PidEquipment> items);
    Task<byte[]> ExportToExcelAsync(IEnumerable<PidEquipment> items);
}
```

> #### ⚠️ 수정안
> **`IQueryable<PidEquipment> GetQueryable()` 반환 금지**: 인터페이스에서 `IQueryable`을 반환하면 DbContext 생명주기가 컨트롤러 레이어까지 연장되어야 합니다. 컨트롤러에서 `.CountAsync()`, `.ToListAsync()`를 직접 호출하게 되어 Clean Architecture의 계층 분리가 깨집니다. 단계 12의 `GetEquipment` 컨트롤러가 이 패턴을 사용합니다. **`IQueryable` 반환 메서드는 제거**하고 페이지네이션 파라미터를 받는 메서드로 교체해야 합니다:
> ```csharp
> Task<(int Total, IEnumerable<PidEquipment> Items)> GetEquipmentAsync(string? tagNo, int page, int pageSize);
> ```
>
> **`GetInstrumentTypeCountAsync()` 이름-구현 불일치**: 이름은 "계기 타입 수"를 반환할 것처럼 보이지만, 단계 9 구현에서는 `PidEquipment.CountAsync()`(전체 장비 수)를 반환합니다. 이름을 `GetTotalCountAsync()`로 변경하거나 구현을 `SELECT COUNT(DISTINCT instrument_type)`로 수정해야 합니다.
>
> **`ExportToCsvAsync`, `ExportToExcelAsync` 책임 과다**: 추출 서비스에 내보내기 기능을 포함하면 단일책임원칙(SRP) 위반입니다. 별도 `IPidExportService` 인터페이스로 분리를 권장합니다. 현재 프로젝트에 이미 `IExperionCsvService`가 있으므로 패턴을 참고할 수 있습니다.
>
> **기존 프로젝트 패턴 불일치**: 기존 `IExperionServices.cs`를 보면 모든 인터페이스가 한 파일에 정의되어 있습니다. 신규 인터페이스도 `IExperionServices.cs`에 추가하거나 별도 파일로 만들되 기존 파일명 패턴(`IExperionServices.cs`)을 따르는 것이 좋습니다.

---

### 단계 9: PidExtractorService 구현

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

```csharp
using Azure.AI.Vision.ImageAnalysis;
using ExperionCrawler.Core.Application.Interfaces;
using ExperionCrawler.Core.Domain.Entities;
using ExperionCrawler.Infrastructure.Database;

namespace ExperionCrawler.Core.Application.Services;

public class PidExtractorService : IPidExtractorService
{
    private readonly string _anthropicApiKey;
    private readonly PidDbContext _pidDbContext;
    private readonly ExperionDbContext _experionDbContext;
    
    public PidExtractorService(
        IConfiguration configuration,
        PidDbContext pidDbContext,
        ExperionDbContext experionDbContext)
    {
        _anthropicApiKey = configuration["Anthropic:ApiKey"] ?? throw new InvalidOperationException("Anthropic API key not configured");
        _pidDbContext = pidDbContext;
        _experionDbContext = experionDbContext;
    }
    
    public async Task<PidExtractionResult> ExtractFromFileAsync(string filePath, bool useImageMode = false)
    {
        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)
    {
        // 1. 파일 텍스트/이미지 변환
        var imageData = await PreprocessFileAsync(stream, fileName, useImageMode);
        
        // 2. Claude Vision API 분석
        var extractedItems = await AnalyzeWithClaudeAsync(imageData);
        
        // 3. DB 저장
        var dbItems = new List<PidEquipment>();
        foreach (var item in extractedItems)
        {
            var existingTag = await FindMatchingExperionTagAsync(item.TagNo);
            var pidEquipment = new PidEquipment
            {
                TagNo = item.TagNo,
                EquipmentName = item.EquipmentName,
                InstrumentType = item.InstrumentType,
                LineNumber = item.LineNumber,
                PidDrawingNo = item.PidDrawingNo,
                Confidence = item.Confidence,
                ExperionTagId = existingTag?.Id,
                ExtractedAt = DateTime.UtcNow,
                UpdatedAt = DateTime.UtcNow
            };
            dbItems.Add(pidEquipment);
        }
        
        await _pidDbContext.PidEquipment.AddRangeAsync(dbItems);
        await _pidDbContext.SaveChangesAsync();
        
        // 4. CSV/Excel 생성
        var timestamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss");
        var csvPath = $"output/pid_extracted_{timestamp}.csv";
        var excelPath = $"output/pid_AX_import_{timestamp}.xlsx";
        
        Directory.CreateDirectory("output");
        await File.WriteAllTextAsync(csvPath, await ExportToCsvAsync(dbItems));
        
        return new PidExtractionResult(
            TotalCount: dbItems.Count,
            ConfidenceItems: dbItems.Count(i => i.Confidence >= 0.7m),
            LowConfidenceItems: dbItems.Count(i => i.Confidence < 0.5m),
            CsvPath: csvPath,
            ExcelPath: excelPath);
    }
    
    private async Task<byte[]> PreprocessFileAsync(Stream stream, string fileName, bool useImageMode)
    {
        if (useImageMode)
        {
            // 이미지 모드: PDF를 이미지로 변환
            using var ms = new MemoryStream();
            await stream.CopyToAsync(ms);
            return ms.ToArray();
        }
        else
        {
            // 텍스트 모드: PDF에서 텍스트 추출
            // 추후 PyPDF2 또는 pdfplumber 연동
            using var ms = new MemoryStream();
            await stream.CopyToAsync(ms);
            return ms.ToArray();
        }
    }
    
    private async Task<List<ExtractedItem>> AnalyzeWithClaudeAsync(byte[] imageData)
    {
        using var client = new ImageAnalysisClient(
            new Uri("https://vision.api.anthropic.com"),
            new System.ClientModel.ApiKeyCredential(_anthropicApiKey));
        
        var result = await client.AnalyzeAsync(ImageAnalyzerOptions.Create(
            BinaryData.FromBytes(imageData),
            ImageAnalysisFeature.RecognizedText | ImageAnalysisFeature.DenseCaption));
        
        return ParseExtractedData(result.Value.Text);
    }
    
    private List<ExtractedItem> ParseExtractedData(string jsonText)
    {
        // JSON 파싱 로직
        // 추출된 텍스트에서 JSON 파싱
        return new List<ExtractedItem>();
    }
    
    private async Task<RealtimePoint?> FindMatchingExperionTagAsync(string tagNo)
    {
        // 태그 번호 매칭 로직 (FT-1001 → FT-1001.PV)
        var normalizedTagNo = NormalizeTagNo(tagNo);
        return await _experionDbContext.RealtimePoints
            .FirstOrDefaultAsync(t => t.TagName == normalizedTagNo || t.TagName.StartsWith(normalizedTagNo));
    }
    
    private string NormalizeTagNo(string tagNo)
    {
        // FT-1001 → FT-1001
        // FT-1001.PV → FT-1001
        var parts = tagNo.Split('.');
        return parts.Length > 0 ? parts[0] : tagNo;
    }
    
    public IQueryable<PidEquipment> GetQueryable() => _pidDbContext.PidEquipment;
    
    public async Task<IEnumerable<PidEquipment>> GetByTagNoAsync(string tagNo)
    {
        return await _pidDbContext.PidEquipment
            .Where(e => e.TagNo.Contains(tagNo))
            .OrderByDescending(e => e.ExtractedAt)
            .ToListAsync();
    }
    
    public async Task<PidEquipment?> GetByIdAsync(long id)
        => await _pidDbContext.PidEquipment.FindAsync(id);
    
    public async Task UpdateConfidenceAsync(long id, decimal confidence)
    {
        var equipment = await _pidDbContext.PidEquipment.FindAsync(id);
        if (equipment != null)
        {
            equipment.Confidence = confidence;
            equipment.UpdatedAt = DateTime.UtcNow;
            await _pidDbContext.SaveChangesAsync();
        }
    }
    
    public async Task ActivateAsync(long id)
    {
        var equipment = await _pidDbContext.PidEquipment.FindAsync(id);
        if (equipment != null)
        {
            equipment.IsActive = true;
            equipment.UpdatedAt = DateTime.UtcNow;
            await _pidDbContext.SaveChangesAsync();
        }
    }
    
    public async Task DeactivateAsync(long id)
    {
        var equipment = await _pidDbContext.PidEquipment.FindAsync(id);
        if (equipment != null)
        {
            equipment.IsActive = false;
            equipment.UpdatedAt = DateTime.UtcNow;
            await _pidDbContext.SaveChangesAsync();
        }
    }
    
    public async Task<int> GetInstrumentTypeCountAsync()
        => await _pidDbContext.PidEquipment.CountAsync();
    
    public async Task<IDictionary<string, int>> GetConfidenceDistributionAsync()
    {
        var items = await _pidDbContext.PidEquipment.ToListAsync();
        return new Dictionary<string, int>
        {
            ["High (>=0.7)"] = items.Count(i => i.Confidence >= 0.7m),
            ["Medium (0.5-0.7)"] = items.Count(i => i.Confidence >= 0.5m && i.Confidence < 0.7m),
            ["Low (<0.5)"] = items.Count(i => i.Confidence < 0.5m)
        };
    }
    
    public async Task<int> GetDrawingCountAsync()
        => await _pidDbContext.PidEquipment.Select(e => e.PidDrawingNo).Distinct().CountAsync();
    
    public async Task<int> GetTotalCountAsync()
        => await _pidDbContext.PidEquipment.CountAsync();
    
    public async Task<string> ExportToCsvAsync(IEnumerable<PidEquipment> items)
    {
        var sb = new StringBuilder();
        sb.AppendLine("TagNo,EquipmentName,InstrumentType,LineNumber,PidDrawingNo,Confidence,IsActive,ExtractedAt,ExperionTagId");
        foreach (var item in items)
        {
            sb.AppendLine($"{item.TagNo},{item.EquipmentName},{item.InstrumentType},{item.LineNumber},{item.PidDrawingNo},{item.Confidence},{item.IsActive},{item.ExtractedAt},{item.ExperionTagId}");
        }
        return sb.ToString();
    }
    
    public async Task<byte[]> ExportToExcelAsync(IEnumerable<PidEquipment> items)
    {
        // Excel 생성 로직 (EPPlus 또는 ClosedXML 사용)
        return Array.Empty<byte>();
    }
}

public class ExtractedItem
{
    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 decimal Confidence { get; set; }
}
```

> #### ⚠️ 수정안 (치명적)
> **잘못된 AI API 사용**: `using Azure.AI.Vision.ImageAnalysis`는 **Microsoft Azure Cognitive Services SDK**입니다. Anthropic Claude API가 아닙니다. `ImageAnalysisClient`와 `ImageAnalysisFeature.RecognizedText`는 Azure Vision의 클래스이고, `"https://vision.api.anthropic.com"` 엔드포인트는 존재하지 않습니다. 이 코드는 컴파일 오류가 나거나(패키지 미설치 시), 런타임에 잘못된 서버로 요청을 보냅니다.
>
> **올바른 Claude API 사용법**: `Anthropic.SDK` NuGet 패키지를 설치하고 Messages API를 사용해야 합니다:
> ```csharp
> // csproj에 추가 필요
> // <PackageReference Include="Anthropic.SDK" Version="3.*" />
>
> using Anthropic.SDK;
> using Anthropic.SDK.Messaging;
>
> private async Task<List<ExtractedItem>> AnalyzeWithClaudeAsync(byte[] imageData)
> {
>     var client = new AnthropicClient(_anthropicApiKey);
>     var base64Image = Convert.ToBase64String(imageData);
>     var messages = new List<Message>
>     {
>         new Message(RoleType.User, new List<ContentBase>
>         {
>             new ImageContent { Source = new ImageSource { Type = "base64", MediaType = "image/png", Data = base64Image } },
>             new TextContent { Text = "이 P&ID 도면에서 계기 태그번호(TagNo), 장비명, 계기 타입, 라인번호, 도면번호를 JSON 배열로 추출해주세요." }
>         })
>     };
>     var response = await client.Messages.GetClaudeMessageAsync(new MessageParameters
>     {
>         Model = AnthropicModels.Claude3Sonnet,
>         MaxTokens = 4096,
>         Messages = messages
>     });
>     return ParseExtractedData(response.Message.ToString());
> }
> ```
>
> **Clean Architecture 위반**: `PidExtractorService`는 `src/Core/Application/Services`에 위치하지만 `ExperionCrawler.Infrastructure.Database` 네임스페이스(`PidDbContext`, `ExperionDbContext`)를 직접 참조합니다. Application 레이어는 Infrastructure에 의존하면 안 됩니다. 인터페이스(`IExperionDbService` 패턴)를 통해 간접 접근하거나, 서비스를 `src/Infrastructure` 아래로 이동해야 합니다.
>
> **PDF/DXF 처리 미구현**: `PreprocessFileAsync`의 텍스트 모드에 "추후 PyPDF2 또는 pdfplumber 연동" 주석이 있는데, Python 라이브러리는 C#에서 직접 호출할 수 없습니다. .NET 네이티브 PDF 라이브러리를 사용해야 합니다:
> - PDF 텍스트 추출: `PdfPig` (오픈소스, MIT 라이선스)
> - DXF 파싱: `netDxf` 라이브러리
> - 두 패키지 모두 NuGet 등록 필요
>
> **DXF 지원 완전 누락**: 개요에서 "DXF/PDF" 형식을 지원한다고 했지만, 파일 처리 로직 어디에도 DXF 분기가 없습니다. `fileName` 확장자로 분기하는 로직이 필요합니다.
>
> **CSV 인젝션 취약점**: `ExportToCsvAsync`에서 `TagNo`, `EquipmentName` 등이 쉼표나 줄바꿈을 포함할 경우 CSV가 깨집니다. 기존 프로젝트에 `CsvHelper` 패키지가 이미 설치되어 있으므로(`ExperionCrawler.csproj`) 이를 사용해야 합니다.
>
> **`ExtractedItem` 클래스 네임스페이스 누락**: 파일 최하단에 `namespace` 없이 정의되어 전역 네임스페이스에 배치됩니다. 클래스 상단에 `namespace ExperionCrawler.Core.Application.Services;`를 추가하거나, `private` 중첩 클래스로 변경하세요.
>
> **`ExportToExcelAsync` 미구현 (빈 반환)**: `return Array.Empty<byte>()`를 반환하면 컨트롤러가 빈 파일을 응답합니다. NuGet 패키지 `ClosedXML` 또는 `EPPlus`(라이선스 주의)를 설치하고 구현이 필요합니다.

---

### 단계 10: ITagMappingService 인터페이스 정의

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

```csharp
using ExperionCrawler.Core.Application.DTOs;
using ExperionCrawler.Core.Domain.Entities;

namespace ExperionCrawler.Core.Application.Interfaces;

public interface ITagMappingService
{
    Task<IEnumerable<TagMappingResult>> GetMappingsAsync(int page = 1, int pageSize = 50);
    Task<TagMappingResult?> GetMappingByIdAsync(long id);
    Task<TagMappingResult> CreateMappingAsync(CreateMappingRequest request);
    Task UpdateMappingAsync(long id, UpdateMappingRequest request);
    Task DeleteMappingAsync(long id);
    
    Task<int> GetUnmappedCountAsync();
    Task<int> GetMappedCountAsync();
    Task<IEnumerable<string>> GetAvailableTagsAsync();
}
```

> #### ⚠️ 수정안
> **`TagMappingResult`, `CreateMappingRequest`, `UpdateMappingRequest` 위치 오류**: 이 타입들은 단계 11의 서비스 구현 파일 맨 아래에 정의되어 있지만, 인터페이스에서 먼저 참조됩니다. 컴파일은 되더라도 설계 원칙상 DTO/Request 타입은 `src/Core/Application/DTOs/` 폴더에 배치해야 합니다.
>
> **페이지네이션 총 개수 미반환**: `GetMappingsAsync`가 `IEnumerable<TagMappingResult>`만 반환하므로 컨트롤러에서 total 개수를 알 수 없습니다. 단계 12의 `GetMappings()` 컨트롤러에서 `mappings.Count()`로 IEnumerable을 두 번 순회하는 문제가 발생합니다. 반환 타입을 튜플이나 래퍼 record로 변경해야 합니다:
> ```csharp
> Task<(int Total, IEnumerable<TagMappingResult> Items)> GetMappingsAsync(int page, int pageSize);
> ```

---

### 단계 11: TagMappingService 구현

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

```csharp
using ExperionCrawler.Core.Application.Interfaces;
using ExperionCrawler.Core.Domain.Entities;
using ExperionCrawler.Infrastructure.Database;

namespace ExperionCrawler.Core.Application.Services;

public class TagMappingService : ITagMappingService
{
    private readonly PidDbContext _pidDbContext;
    private readonly ExperionDbContext _experionDbContext;
    
    public TagMappingService(PidDbContext pidDbContext, ExperionDbContext experionDbContext)
    {
        _pidDbContext = pidDbContext;
        _experionDbContext = experionDbContext;
    }
    
    public async Task<IEnumerable<TagMappingResult>> GetMappingsAsync(int page = 1, int pageSize = 50)
    {
        var query = from pe in _pidDbContext.PidEquipment
                    join rt in _experionDbContext.RealtimePoints
                        on pe.ExperionTagId equals rt.Id into joined
                    from rt in joined.DefaultIfEmpty()
                    select new TagMappingResult
                    {
                        PidEquipmentId = pe.Id,
                        TagNo = pe.TagNo,
                        EquipmentName = pe.EquipmentName,
                        InstrumentType = pe.InstrumentType,
                        LineNumber = pe.LineNumber,
                        PidDrawingNo = pe.PidDrawingNo,
                        Confidence = pe.Confidence,
                        IsActive = pe.IsActive,
                        ExperionTagId = pe.ExperionTagId,
                        ExperionTagName = rt?.TagName,
                        ExperionNodeId = rt?.NodeId
                    };
        
        var total = await query.CountAsync();
        var items = await query
            .OrderByDescending(e => e.Confidence)
            .Skip((page - 1) * pageSize)
            .Take(pageSize)
            .ToListAsync();
        
        return items;
    }
    
    public async Task<TagMappingResult?> GetMappingByIdAsync(long id)
    {
        var item = await _pidDbContext.PidEquipment.FindAsync(id);
        if (item == null) return null;
        
        return new TagMappingResult
        {
            PidEquipmentId = item.Id,
            TagNo = item.TagNo,
            EquipmentName = item.EquipmentName,
            InstrumentType = item.InstrumentType,
            LineNumber = item.LineNumber,
            PidDrawingNo = item.PidDrawingNo,
            Confidence = item.Confidence,
            IsActive = item.IsActive,
            ExperionTagId = item.ExperionTagId,
            ExperionTagName = item.ExperionTag?.TagName,
            ExperionNodeId = item.ExperionTag?.NodeId
        };
    }
    
    public async Task<TagMappingResult> CreateMappingAsync(CreateMappingRequest request)
    {
        var equipment = await _pidDbContext.PidEquipment.FindAsync(request.PidEquipmentId);
        if (equipment == null) throw new InvalidOperationException("P&ID 장비를 찾을 수 없습니다.");
        
        var tag = await _experionDbContext.RealtimePoints.FindAsync(request.ExperionTagId);
        if (tag == null) throw new InvalidOperationException("실시간 태그를 찾을 수 없습니다.");
        
        equipment.ExperionTagId = tag.Id;
        equipment.UpdatedAt = DateTime.UtcNow;
        await _pidDbContext.SaveChangesAsync();
        
        return new TagMappingResult
        {
            PidEquipmentId = equipment.Id,
            TagNo = equipment.TagNo,
            EquipmentName = equipment.EquipmentName,
            InstrumentType = equipment.InstrumentType,
            LineNumber = equipment.LineNumber,
            PidDrawingNo = equipment.PidDrawingNo,
            Confidence = equipment.Confidence,
            IsActive = equipment.IsActive,
            ExperionTagId = equipment.ExperionTagId,
            ExperionTagName = tag.TagName,
            ExperionNodeId = tag.NodeId
        };
    }
    
    public async Task UpdateMappingAsync(long id, UpdateMappingRequest request)
    {
        var equipment = await _pidDbContext.PidEquipment.FindAsync(id);
        if (equipment == null) throw new InvalidOperationException("P&ID 장비를 찾을 수 없습니다.");
        
        if (request.ExperionTagId.HasValue)
        {
            var tag = await _experionDbContext.RealtimePoints.FindAsync(request.ExperionTagId.Value);
            if (tag == null) throw new InvalidOperationException("실시간 태그를 찾을 수 없습니다.");
            
            equipment.ExperionTagId = tag.Id;
        }
        
        if (request.IsActive.HasValue)
            equipment.IsActive = request.IsActive.Value;
        
        equipment.UpdatedAt = DateTime.UtcNow;
        await _pidDbContext.SaveChangesAsync();
    }
    
    public async Task DeleteMappingAsync(long id)
    {
        var equipment = await _pidDbContext.PidEquipment.FindAsync(id);
        if (equipment == null) throw new InvalidOperationException("P&ID 장비를 찾을 수 없습니다.");
        
        equipment.ExperionTagId = null;
        equipment.UpdatedAt = DateTime.UtcNow;
        await _pidDbContext.SaveChangesAsync();
    }
    
    public async Task<int> GetUnmappedCountAsync()
        => await _pidDbContext.PidEquipment.CountAsync(e => e.ExperionTagId == null);
    
    public async Task<int> GetMappedCountAsync()
        => await _pidDbContext.PidEquipment.CountAsync(e => e.ExperionTagId != null);
    
    public async Task<IEnumerable<string>> GetAvailableTagsAsync()
    {
        var mappedTagIds = await _pidDbContext.PidEquipment
            .Where(e => e.ExperionTagId != null)
            .Select(e => e.ExperionTagId)
            .ToListAsync();
        
        return await _experionDbContext.RealtimePoints
            .Where(t => !mappedTagIds.Contains(t.Id))
            .Select(t => t.TagName)
            .OrderBy(t => t)
            .ToListAsync();
    }
}

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 decimal Confidence { get; set; }
    public bool IsActive { get; set; }
    public long? ExperionTagId { get; set; }
    public string? ExperionTagName { get; set; }
    public string? ExperionNodeId { get; set; }
}

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

> #### ⚠️ 수정안 (치명적)
> **Cross-DbContext LINQ JOIN 불가**: `from pe in _pidDbContext.PidEquipment join rt in _experionDbContext.RealtimePoints`는 **EF Core에서 실행 불가능합니다.** EF Core는 서로 다른 DbContext의 `IQueryable`을 하나의 SQL 쿼리로 JOIN할 수 없으며, 런타임에 `InvalidOperationException`이 발생합니다.
>
> 단계 6/7의 수정안대로 `PidDbContext`를 없애고 `ExperionDbContext` 단일 컨텍스트로 통합하면 이 문제가 자동으로 해결됩니다. 통합 시 `_pidDbContext`와 `_experionDbContext`를 모두 `_dbContext` 하나로 교체합니다.
>
> **`GetAvailableTagsAsync`의 타입 불일치**: `mappedTagIds`는 `List<long?>`(PidEquipment.ExperionTagId)이고, `t.Id`는 `int`(RealtimePoint.Id)입니다. `!mappedTagIds.Contains(t.Id)`에서 타입이 맞지 않아 EF Core가 SQL로 변환하지 못하고 클라이언트 측 평가로 폴백되거나 예외가 발생합니다. `ExperionTagId`를 `int?`로 수정(단계 1 수정안 반영)하면 해결됩니다.
>
> **`DeleteMappingAsync` 동작 불명확**: 현재 구현은 FK만 null로 설정합니다(매핑 해제). 메서드명 `DeleteMapping`은 매핑 레코드 자체를 삭제할 것처럼 읽힙니다. 의도가 "매핑만 해제(Equipment 유지)"라면 `ClearMappingAsync` 또는 `UnmapAsync`로 이름을 변경해야 혼란이 없습니다.
>
> **`GetMappingByIdAsync`에서 N+1 문제**: `FindAsync(id)` 후 `item.ExperionTag?.TagName`을 사용하는데, `Include(e => e.ExperionTag)`를 하지 않으면 lazy loading이 없는 EF Core에서 `ExperionTag`는 항상 `null`입니다. `.Include(e => e.ExperionTag).FirstOrDefaultAsync(e => e.Id == id)`로 수정이 필요합니다.

---

### 단계 12: PidController 생성

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

```csharp
using Microsoft.AspNetCore.Mvc;
using ExperionCrawler.Core.Application.Interfaces;
using ExperionCrawler.Core.Application.DTOs;

namespace ExperionCrawler.Web.Controllers;

[ApiController]
[Route("api/[controller]")]
public class PidController : ControllerBase
{
    private readonly IPidExtractorService _pidExtractor;
    private readonly ITagMappingService _tagMapping;
    
    public PidController(IPidExtractorService pidExtractor, ITagMappingService tagMapping)
    {
        _pidExtractor = pidExtractor;
        _tagMapping = tagMapping;
    }
    
    [HttpPost("extract")]
    public async Task<IActionResult> ExtractFromFile(IFormFile file, bool useImageMode = false)
    {
        if (file == null || file.Length == 0)
            return BadRequest(new { error = "파일이 없습니다." });
        
        using var stream = file.OpenReadStream();
        var result = await _pidExtractor.ExtractFromStreamAsync(stream, file.FileName, useImageMode);
        
        return Ok(new
        {
            totalCount = result.TotalCount,
            confidenceItems = result.ConfidenceItems,
            lowConfidenceItems = result.LowConfidenceItems,
            csvPath = result.CsvPath,
            excelPath = result.ExcelPath
        });
    }
    
    [HttpGet("equipment")]
    public async Task<IActionResult> GetEquipment(string? tagNo = null, int page = 1, int pageSize = 50)
    {
        var query = _pidExtractor.GetQueryable();
        if (!string.IsNullOrEmpty(tagNo))
            query = query.Where(e => e.TagNo.Contains(tagNo));
        
        var total = await query.CountAsync();
        var items = await query
            .OrderByDescending(e => e.ExtractedAt)
            .Skip((page - 1) * pageSize)
            .Take(pageSize)
            .ToListAsync();
        
        var equipmentDtos = items.Select(e => new PidEquipmentDto(
            e.Id,
            e.TagNo,
            e.EquipmentName,
            e.InstrumentType,
            e.LineNumber,
            e.PidDrawingNo,
            e.Confidence,
            e.IsActive,
            e.ExtractedAt,
            e.UpdatedAt,
            e.ExperionTagId,
            e.ExperionTag?.TagName));
        
        return Ok(new
        {
            total,
            page,
            pageSize,
            confidenceRate = items.Count > 0 ? items.Average(e => e.Confidence) : 0,
            items = equipmentDtos
        });
    }
    
    [HttpGet("statistics")]
    public async Task<IActionResult> GetStatistics()
    {
        var typeCount = await _pidExtractor.GetInstrumentTypeCountAsync();
        var confidenceRange = await _pidExtractor.GetConfidenceDistributionAsync();
        var drawingCount = await _pidExtractor.GetDrawingCountAsync();
        var unmappedCount = await _tagMapping.GetUnmappedCountAsync();
        var mappedCount = await _tagMapping.GetMappedCountAsync();
        
        return Ok(new
        {
            typeCount,
            confidenceRange,
            drawingCount,
            unmappedCount,
            mappedCount
        });
    }
    
    [HttpPut("{id}/confidence")]
    public async Task<IActionResult> UpdateConfidence(long id, decimal confidence)
    {
        if (confidence < 0 || confidence > 1)
            return BadRequest(new { error = "신뢰도는 0~1 사이여야 합니다." });
        
        await _pidExtractor.UpdateConfidenceAsync(id, confidence);
        return Ok(new { message = "신뢰도가 업데이트되었습니다." });
    }
    
    [HttpPut("{id}/activate")]
    public async Task<IActionResult> Activate(long id)
    {
        await _pidExtractor.ActivateAsync(id);
        return Ok(new { message = "장비가 활성화되었습니다." });
    }
    
    [HttpPut("{id}/deactivate")]
    public async Task<IActionResult> Deactivate(long id)
    {
        await _pidExtractor.DeactivateAsync(id);
        return Ok(new { message = "장비가 비활성화되었습니다." });
    }
    
    [HttpGet("mappings")]
    public async Task<IActionResult> GetMappings(int page = 1, int pageSize = 50)
    {
        var mappings = await _tagMapping.GetMappingsAsync(page, pageSize);
        return Ok(new
        {
            total = mappings.Count(),
            page,
            pageSize,
            items = mappings
        });
    }
    
    [HttpPost("mappings")]
    public async Task<IActionResult> CreateMapping([FromBody] CreateMappingRequest request)
    {
        var mapping = await _tagMapping.CreateMappingAsync(request);
        return Ok(mapping);
    }
    
    [HttpPut("mappings/{id}")]
    public async Task<IActionResult> UpdateMapping(long id, [FromBody] UpdateMappingRequest request)
    {
        await _tagMapping.UpdateMappingAsync(id, request);
        return Ok(new { message = "매핑이 업데이트되었습니다." });
    }
    
    [HttpDelete("mappings/{id}")]
    public async Task<IActionResult> DeleteMapping(long id)
    {
        await _tagMapping.DeleteMappingAsync(id);
        return Ok(new { message = "매핑이 삭제되었습니다." });
    }
    
    [HttpGet("mappings/unmapped")]
    public async Task<IActionResult> GetUnmapped(int page = 1, int pageSize = 50)
    {
        var unmapped = await _pidExtractor.GetQueryable()
            .Where(e => e.ExperionTagId == null)
            .OrderByDescending(e => e.Confidence)
            .Skip((page - 1) * pageSize)
            .Take(pageSize)
            .ToListAsync();
        
        var equipmentDtos = unmapped.Select(e => new PidEquipmentDto(
            e.Id,
            e.TagNo,
            e.EquipmentName,
            e.InstrumentType,
            e.LineNumber,
            e.PidDrawingNo,
            e.Confidence,
            e.IsActive,
            e.ExtractedAt,
            e.UpdatedAt,
            e.ExperionTagId,
            e.ExperionTag?.TagName));
        
        return Ok(new
        {
            total = unmapped.Count,
            page,
            pageSize,
            items = equipmentDtos
        });
    }
    
    [HttpGet("mappings/available-tags")]
    public async Task<IActionResult> GetAvailableTags()
    {
        var tags = await _tagMapping.GetAvailableTagsAsync();
        return Ok(new { tags });
    }
}
```

> #### ⚠️ 수정안
> **컨트롤러에서 EF Core 직접 사용**: `GetEquipment` 액션에서 `_pidExtractor.GetQueryable()` 후 `.CountAsync()`, `.ToListAsync()`를 직접 호출합니다. 이는 `using Microsoft.EntityFrameworkCore;`를 Web 레이어에 import해야 하는 것을 의미하며, 기존 컨트롤러(`ExperionControllers.cs`)에서도 이 패턴이 없습니다. 단계 8 수정안대로 서비스에서 페이지네이션 결과를 반환하도록 변경해야 합니다.
>
> **`[HttpPut("{id}/confidence")]`의 파라미터 바인딩 문제**: `decimal confidence`는 ASP.NET Core에서 쿼리스트링이나 라우트 파라미터로 받을 때 로케일에 따라 소수점 구분자(`.` vs `,`)가 달라질 수 있습니다. `[FromBody]`를 사용하거나 요청 본문에 JSON으로 받는 것이 안전합니다:
> ```csharp
> [HttpPut("{id}/confidence")]
> public async Task<IActionResult> UpdateConfidence(long id, [FromBody] decimal confidence)
> ```
>
> **`GetMappings` 이중 순회**: `mappings.Count()`를 호출한 뒤 `items = mappings`로 다시 응답에 사용하면 `IEnumerable`이 두 번 평가됩니다. `ToList()`로 한 번만 구체화하거나 단계 10 수정안대로 서비스에서 total을 함께 반환해야 합니다.
>
> **`GetEquipment` N+1 문제**: `e.ExperionTag?.TagName`을 사용하지만 쿼리에 `.Include(e => e.ExperionTag)`가 없습니다. 모든 아이템에 대해 ExperionTag가 항상 null로 반환됩니다. `GetQueryable()` 패턴을 제거하고 서비스에서 `.Include()` 후 반환해야 합니다.
>
> **`GetUnmapped` N+1 문제**: `GetQueryable().Where(...).ToListAsync()` 후 `e.ExperionTag?.TagName`을 참조하는데, 역시 Include가 없습니다.

---

### 단계 13: Program.cs에 서비스 등록

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

```csharp
// ── P&ID Services ───────────────────────────────────────────────────────────────
builder.Services.AddScoped<IPidExtractorService, PidExtractorService>();
builder.Services.AddScoped<ITagMappingService, TagMappingService>();

// P&ID DbContext 등록
builder.Services.AddDbContext<PidDbContext>(opt =>
    opt.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
```

> #### ⚠️ 수정안
> **`PidDbContext` 등록 불필요**: 단계 6/7 수정안대로 `ExperionDbContext`에 통합하면 이 `AddDbContext<PidDbContext>` 줄은 필요 없습니다. `ExperionDbContext`는 이미 `Program.cs:33`에서 등록되어 있습니다.
>
> **필수 NuGet 패키지 미기재**: 실제 구현에 필요한 패키지들이 `ExperionCrawler.csproj`에 없습니다. 추가가 필요합니다:
> ```xml
> <PackageReference Include="Anthropic.SDK" Version="3.*" />
> <PackageReference Include="PdfPig" Version="0.1.*" />         <!-- PDF 텍스트 추출 -->
> <PackageReference Include="netDxf" Version="3.*" />           <!-- DXF 파싱 -->
> <PackageReference Include="ClosedXML" Version="0.102.*" />    <!-- Excel 내보내기 -->
> ```
> `CsvHelper`는 이미 설치되어 있음(`v33.0.1`).
>
> **파일 업로드 크기 제한 설정 누락**: 대용량 P&ID 파일 업로드를 위한 Kestrel 설정이 없습니다. `Program.cs`에 추가 필요:
> ```csharp
> builder.WebHost.ConfigureKestrel(options =>
>     options.Limits.MaxRequestBodySize = 100 * 1024 * 1024); // 100MB
> ```

---

### 단계 14: appsettings.json에 연결 문자열 추가

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

```json
{
  "ConnectionStrings": {
    "DefaultConnection": "Host=localhost;Database=experion;Username=postgres;Password=your_password",
    "PidDb": "Host=localhost;Database=experion;Username=postgres;Password=your_password"
  },
  "Anthropic": {
    "ApiKey": "your_anthropic_api_key"
  }
}
```

> #### ⚠️ 수정안
> **`PidDb` 연결 문자열 미사용**: 단계 13에서 `PidDbContext`는 `DefaultConnection`을 사용합니다. `PidDb` 항목은 참조되는 곳이 없으므로 불필요합니다. 단계 6/7 수정안대로 단일 DbContext로 통합하면 완전히 제거할 수 있습니다.
>
> **API 키 평문 저장 보안 위험**: `appsettings.json`에 `"your_anthropic_api_key"`를 직접 입력하면 git에 커밋될 경우 유출됩니다. 이미 `.gitignore`에 `appsettings.json`이 있다면 괜찮지만, **권장 방법은 환경 변수 또는 `dotnet user-secrets`를 사용**하는 것입니다:
> ```bash
> dotnet user-secrets set "Anthropic:ApiKey" "sk-ant-..."
> ```
> 또는 서버에서 환경 변수 `ANTHROPIC__APIKEY`로 주입합니다.
>
> **기존 `appsettings.json` 덮어쓰기 주의**: 현재 파일에 OPC UA 서버, Experion 접속 등의 설정이 있습니다. 전체를 교체하는 방식으로 기재되어 있는데, 실제로는 기존 설정을 유지한 채 `Anthropic` 섹션만 **추가**해야 합니다.

---

### 단계 15: Frontend UI 확장 (app.js)

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

```javascript
// ── P&ID Manager ────────────────────────────────────────────────────────────────

class PidManager {
    constructor() {
        this.extractorFileInput = document.getElementById('pid-file-input');
        this.extractActionBtn = document.getElementById('extract-pid-btn');
        this.useImageMode = document.getElementById('use-image-mode');
        
        this.equipmentTable = document.getElementById('pid-equipment-table');
        this.statisticsDiv = document.getElementById('pid-statistics');
        
        this.bindEvents();
        this.loadEquipmentList();
        this.loadStatistics();
    }
    
    bindEvents() {
        this.extractActionBtn.addEventListener('click', () => this.handleExtract());
        
        this.useImageMode.addEventListener('change', (e) => {
            this.extractActionBtn.textContent = 
                e.target.checked ? '이미지 모드로 추출' : '텍스트 모드로 추출';
        });
    }
    
    async handleExtract() {
        const file = this.extractorFileInput.files[0];
        if (!file) {
            alert('선택된 파일이 없습니다.');
            return;
        }
        
        const formData = new FormData();
        formData.append('file', file);
        formData.append('useImageMode', this.useImageMode.checked);
        
        this.extractActionBtn.disabled = true;
        this.extractActionBtn.textContent = '추출 중...';
        
        try {
            const response = await fetch('/api/pid/extract', {
                method: 'POST',
                body: formData
            });
            
            const result = await response.json();
            
            if (response.ok) {
                alert(`추출 완료! 총 ${result.totalCount}건 처리됨\n신뢰도 높음: ${result.confidenceItems}건\n신뢰도 낮음: ${result.lowConfidenceItems}건`);
                this.loadEquipmentList();
                this.loadStatistics();
            } else {
                alert(`오류: ${result.error || '알 수 없는 오류'}`);
            }
        } catch (error) {
            console.error('추출 실패:', error);
            alert('추출 중 오류가 발생했습니다.');
        } finally {
            this.extractActionBtn.disabled = false;
        }
    }
    
    async loadEquipmentList() {
        try {
            const response = await fetch('/api/pid/equipment?page=1&pageSize=50');
            const data = await response.json();
            
            if (this.equipmentTable) {
                this.renderEquipmentTable(data.items);
            }
        } catch (error) {
            console.error('장비 목록 로드 실패:', error);
        }
    }
    
    async loadStatistics() {
        try {
            const response = await fetch('/api/pid/statistics');
            const data = await response.json();
            
            if (this.statisticsDiv) {
                this.renderStatistics(data);
            }
        } catch (error) {
            console.error('통계 로드 실패:', error);
        }
    }
    
    renderEquipmentTable(items) {
        if (!items || items.length === 0) {
            this.equipmentTable.innerHTML = '<tr><td colspan="8">추출된 장비가 없습니다.</td></tr>';
            return;
        }
        
        let html = '<tr><th>태그번호</th><th>장비명</th><th>계기타입</th><th>라인번호</th><th>도면번호</th><th>신뢰도</th><th>활성화</th><th>추출일시</th></tr>';
        
        for (const item of items) {
            html += `
                <tr>
                    <td>${item.tagNo}</td>
                    <td>${item.equipmentName || '-'}</td>
                    <td>${item.instrumentType || '-'}</td>
                    <td>${item.lineNumber || '-'}</td>
                    <td>${item.pidDrawingNo || '-'}</td>
                    <td>${(item.confidence * 100).toFixed(1)}%</td>
                    <td>${item.isActive ? '✓' : '✗'}</td>
                    <td>${new Date(item.extractedAt).toLocaleString()}</td>
                </tr>
            `;
        }
        
        this.equipmentTable.innerHTML = html;
    }
    
    renderStatistics(data) {
        let html = `
            <div class="stat-card">
                <div class="stat-value">${data.typeCount}</div>
                <div class="stat-label">총 장비 수</div>
            </div>
            <div class="stat-card">
                <div class="stat-value">${data.mappedCount}</div>
                <div class="stat-label">매핑 완료</div>
            </div>
            <div class="stat-card">
                <div class="stat-value">${data.unmappedCount}</div>
                <div class="stat-label">매핑 대기</div>
            </div>
            <div class="stat-card">
                <div class="stat-value">${data.drawingCount}</div>
                <div class="stat-label">도면 수</div>
            </div>
        `;
        
        if (data.confidenceRange) {
            html += `
                <div class="stat-card">
                    <div class="stat-value">${data.confidenceRange['High (>=0.7)'] || 0}</div>
                    <div class="stat-label">신뢰도 높음 (≥70%)</div>
                </div>
                <div class="stat-card">
                    <div class="stat-value">${data.confidenceRange['Medium (0.5-0.7)'] || 0}</div>
                    <div class="stat-label">신뢰도 중간 (50-70%)</div>
                </div>
                <div class="stat-card">
                    <div class="stat-value">${data.confidenceRange['Low (<0.5)'] || 0}</div>
                    <div class="stat-label">신뢰도 낮음 (<50%)</div>
                </div>
            `;
        }
        
        this.statisticsDiv.innerHTML = html;
    }
}

// ── 애플리케이션 초기화 ────────────────────────────────────────────────────────

document.addEventListener('DOMContentLoaded', () => {
    new PidManager();
});
```

> #### ⚠️ 수정안
> **탭 진입 시 자동 API 호출 — 프로젝트 규칙 위반**: `PidManager` 생성자에서 `loadEquipmentList()`와 `loadStatistics()`를 즉시 호출합니다. 이 프로젝트의 기존 원칙(CLAUDE.md 버그 3 수정 이력)은 **"탭 진입 시 API 호출 0건"** 입니다. 생성자에서 API 호출을 제거하고, 탭 내부에 "▼ 장비 목록 불러오기" 버튼을 추가해야 합니다.
>
> **ES6 클래스 사용 — 기존 코드 스타일 불일치**: 기존 `app.js`는 `nmLoad()`, `pbLoad()`, `histLoad()` 등 **평범한 함수** 방식으로 작성되어 있습니다. 클래스 기반으로 추가하면 두 가지 패턴이 혼재합니다. 기존 스타일에 맞춰 함수 방식으로 작성하는 것을 권장합니다:
> ```javascript
> function pidLoad() { /* 장비 목록 로드 */ }
> function pidExtract() { /* 추출 실행 */ }
> function pidRenderTable(items) { /* 테이블 렌더링 */ }
> ```
>
> **`innerHTML` 전체 교체 — 프로젝트 규칙 위반**: `renderEquipmentTable`에서 `this.equipmentTable.innerHTML = html`로 전체를 교체합니다. 기존 CLAUDE.md 성능 분석에 명시된 규칙: **"innerHTML 전체 교체 금지 — incremental DOM update 방식 사용"**. 이미 그려진 `<td>` 셀의 `.textContent`만 갱신하는 방식으로 구현해야 합니다.
>
> **`index.html` 변경 누락**: 단계 15는 `app.js`만 다루고 있지만, P&ID 탭 HTML(`#pane-pid` 섹션, 사이드바 탭 버튼, 파일 업로드 UI)은 `index.html`에도 추가해야 합니다. 이 부분이 계획에서 완전히 빠져 있습니다.

---

## 📝 작업 순서 요약

| 단계 | 작업 | 파일 | 상태 |
|------|------|------|------|
| 1 | P&ID 도메인 엔티티 생성 | `PidEquipment.cs` | [ ] |
| 2 | P&ID 감사 로그 엔티티 생성 | `PidAuditLog.cs` | [ ] |
| 3 | P&ID Value Object 생성 | `ConfidenceScore.cs` | [ ] |
| 4 | P&ID DTO 생성 | `PidEquipmentDto.cs` | [ ] |
| 5 | P&ID 추출 결과 DTO 생성 | `PidExtractionResult.cs` | [ ] |
| 6 | P&ID DbContext 생성 | `PidDbContext.cs` | [ ] |
| 7 | ExperionDbContext 확장 | `ExperionDbContext.cs` | [ ] |
| 8 | IPidExtractorService 인터페이스 정의 | `IPidExtractorService.cs` | [ ] |
| 9 | PidExtractorService 구현 | `PidExtractorService.cs` | [ ] |
| 10 | ITagMappingService 인터페이스 정의 | `ITagMappingService.cs` | [ ] |
| 11 | TagMappingService 구현 | `TagMappingService.cs` | [ ] |
| 12 | PidController 생성 | `PidController.cs` | [ ] |
| 13 | Program.cs에 서비스 등록 | `Program.cs` | [ ] |
| 14 | appsettings.json에 연결 문자열 추가 | `appsettings.json` | [ ] |
| 15 | Frontend UI 확장 | `app.js` | [ ] |

---

## ⚠️ 주의사항

1. **API 키 보안**: `appsettings.json`에 Anthropic API 키를 저장하되, `.gitignore`에 추가하여 외부 유출 방지
2. **파일 크기 제한**: `Program.cs`에서 `Kestrel` 설정으로 업로드 파일 크기 제한 (`100MB` 권장)
3. **이미지 모드 성능**: PDF→이미지 변환은 CPU 자원을 많이 소모하므로, 대용량 파일은 텍스트 모드 사용 권장
4. **네트워크**: Anthropic API 사용을 위해 외부 연결 필요 (방화벽 설정 확인)

---

## 🚀 다음 단계

1. 위 15단계를 순차적으로 구현
2. 각 단계 완료 후 `dotnet build`로 빌드 검증
3. `dotnet run`으로 서버 실행 및 API 테스트
4. Swagger UI (`/swagger`)로 API 엔드포인트 확인

---

## 📊 성공 지표

- DXF/PDF 파일로부터 평균 성공 추출률 80% 이상
- 100MB 이하 파일 처리 시 응답 시간 30초 이내
- 신뢰도 0.7 이상 항목 자동 검증 기능
- Redis 캐싱으로 API 요청 50% 감소

---

## 🔍 전체 진단 요약

### 치명적 문제 (구현 시 런타임 오류 발생)

| 단계 | 문제 | 영향 |
|------|------|------|
| 9 | `Azure.AI.Vision.ImageAnalysis` 사용 — Anthropic API가 아님 | 컴파일 오류 또는 잘못된 서버 요청 |
| 6/11 | `PidDbContext` + `ExperionDbContext` 간 cross-context JOIN | 런타임 `InvalidOperationException` |
| 6 | `HasOne(ExperionTag)` FK를 다른 DbContext 엔티티로 설정 | 마이그레이션 생성 실패 또는 런타임 오류 |
| 11 | `mappedTagIds (long?)` vs `t.Id (int)` 타입 불일치 | EF Core SQL 변환 실패 |

### 구조 설계 문제

| 단계 | 문제 |
|------|------|
| 6+7 | `PidDbContext`와 `ExperionDbContext` 이중 등록 — 마이그레이션 충돌 |
| 8+12 | `IQueryable` 서비스 반환 — Clean Architecture 위반, DbContext 생명주기 누출 |
| 9 | Application 레이어에서 Infrastructure(`DbContext`) 직접 참조 — 의존성 역전 위반 |
| 9 | DXF 파일 처리 완전 미구현 (개요에서 지원 명시) |
| 9 | Python 라이브러리(`PyPDF2`) C# 직접 호출 불가 |

### 프로젝트 규칙 위반 (CLAUDE.md 기준)

| 단계 | 위반 항목 |
|------|-----------|
| 15 | 탭 진입 시 API 자동 호출 (버그 3 수정 원칙 위반) |
| 15 | `innerHTML` 전체 교체 (성능 분석 규칙 위반) |
| 15 | 클래스 기반 코드 (기존 함수 방식 불일치) |
| 15 | `index.html` 변경 사항 누락 |

### 권장 수정 순서

1. **단계 6 취소** → `PidDbContext` 파일 생성하지 않음
2. **단계 7 확장** → `ExperionDbContext`에 P&ID 엔티티 통합 + `OnModelCreating` 설정 포함
3. **단계 9** → `Azure.AI.Vision` 제거, `Anthropic.SDK` 패키지로 교체, PDF는 `PdfPig`, DXF는 `netDxf` 사용
4. **단계 1** → `ExperionTagId` 타입을 `long?` → `int?` 수정
5. **단계 8** → `IQueryable` 반환 제거, 페이지네이션 메서드로 교체
6. **단계 13** → `PidDbContext` 등록 제거, 필요 NuGet 패키지 목록 반영
7. **단계 15** → ES6 클래스 → 함수 방식 전환, 자동 API 호출 제거, `index.html` 추가
