<작업규칙>
1. roo-rules.md 확인 및 준수
2. glm-code-rules.md 확인 및 준수


# P&ID AX 코딩 플랜 v2 (수정 반영본)

> **수정일**: 2026-04-30  
> **기준**: `p&id_ax-coding_plan.md`의 ⚠️ 수정안 및 진단 결과 반영  
> **목적**: 런타임 오류, 아키텍처 위반, 프로젝트 규칙 위반 문제 해결

---

## 📋 개요

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

---

## 🎯 목표

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

---

## 📦 폴더 구조

```
ExperionCrawler/
├── src/
│   ├── Core/
│   │   ├── Application/
│   │   │   ├── Interfaces/
│   │   │   │   ├── IExperionServices.cs (기존 - 확장)
│   │   │   │   └── IPidExtractorService.cs (신규)
│   │   │   ├── Services/
│   │   │   │   ├── PidExtractorService.cs (신규)
│   │   │   │   └── TagMappingService.cs (신규)
│   │   │   └── Dtos/
│   │   │       ├── PidEquipmentDto.cs (신규)
│   │   │       └── PidExtractionResult.cs (신규)
│   │   └── Domain/
│   │       ├── Entities/
│   │       │   ├── PidEquipment.cs (신규)
│   │       │   └── PidAuditLog.cs (신규)
│   │       └── ValueObjects/
│   │           ├── ConfidenceScore.cs (신규)
│   │           └── MeasurementUnit.cs (신규)
│   ├── Infrastructure/
│   │   ├── Database/
│   │   │   └── ExperionDbContext.cs (확장 - PidDbContext 통합)
│   │   └── 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_plan2.md (이 파일)
```

---

## 📋 코딩 단계 (수정 반영 12단계)

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

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

```csharp
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace ExperionCrawler.Core.Domain.Entities;

[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; }
    
    // 외래 키 - 기존 RealtimePoint.Id는 int 타입
    public int? ExperionTagId { get; set; }
    
    // FK 네비게이션 프로퍼티
    public RealtimePoint? ExperionTag { get; set; }
}
```

> #### ⚠️ 수정안 반영
> - **`ExperionTagId` 타입**: 기존 `RealtimePoint.Id`는 `int`이므로 FK도 `int?`로 수정
> - **`[Table]` 어트리뷰트**: 기존 엔티티들과 일관되게 `[Table("pid_equipment")]` 추가
> - **`Confidence` 타입**: `decimal` 대신 `double` 사용 (EF Core에서 `HasPrecision` 설정 간편)
> - **`ExtractedAt` 기본값**: `= DateTime.UtcNow` 추가로 DB 레벨 기본값 대체

---

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

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

```csharp
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace ExperionCrawler.Core.Domain.Entities;

[Table("pid_audit_log")]
public class PidAuditLog
{
    public long Id { get; set; }
    
    // 사용자 인증 시스템 부재 → Source 필드로 대체
    [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;
}
```

> #### ⚠️ 수정안 반영
> - **`UserId` → `Source`**: 로그인 시스템이 없으므로 "WebUI", "API" 등 소스 식별자로 변경
> - **`[Table]` 어트리뷰트**: `[Table("pid_audit_log")]` 추가
> - **`LoggedAt` 기본값**: `= DateTime.UtcNow` 추가

---

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

> #### ⚠️ 수정안 반영
> - **실제 사용처 없음**: `PidEquipment`가 `double Confidence`를 그대로 사용하므로 이 Value Object는 **생략 가능**
> - **`MeasurementUnit.cs`**: 계획에 정의 없음 → **생략**

---

### 단계 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,
    double Confidence,
    bool IsActive,
    DateTime ExtractedAt,
    DateTime? UpdatedAt,
    int? ExperionTagId,
    string? ExperionTagName);
```

> #### ⚠️ 수정안 반영
> - **`ExperionTagId` 타입**: `long?` → `int?`로 수정 (단계 1과 일관성)
> - **`Confidence` 타입**: `decimal` → `double`로 수정 (단계 1과 일관성)

---

### 단계 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);
```

> #### ⚠️ 수정안 반영
> - **서버 내부 파일 경로 제거**: `CsvPath`, `ExcelPath` 필드 제거
> - **CSV 다운로드는 별도 엔드포인트**: `GET /api/pid/export?format=csv`로 분리

---

### 단계 6: ExperionDbContext 확장 (PidDbContext 통합)

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

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

namespace ExperionCrawler.Infrastructure.Database;

public class ExperionDbContext : DbContext
{
    // 기존 DbSet...
    public DbSet<RealtimePoint> RealtimePoints { get; set; }
    
    // P&ID 데이터베이스용 DbSet 추가
    public DbSet<PidEquipment> PidEquipment { get; set; }
    public DbSet<PidAuditLog> PidAuditLog { get; set; }
    
    public ExperionDbContext(DbContextOptions<ExperionDbContext> options) : base(options) { }
    
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // 기존 설정...
        
        // P&ID 엔티티 설정
        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.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(4, 3);  // 최대 9.999까지 저장 가능
            
            entity.Property(e => e.IsActive)
                .HasDefaultValue(true);
            
            entity.Property(e => e.ExtractedAt)
                .HasDefaultValueSql("NOW()");
            
            entity.Property(e => e.UpdatedAt)
                .ValueGeneratedOnAddOrUpdate()
                .HasDefaultValueSql("NOW()");
            
            // 인덱스
            entity.HasIndex(e => e.TagNo);
            entity.HasIndex(e => e.InstrumentType);
            entity.HasIndex(e => e.ExtractedAt);
            
            // FK 설정 - ExperionTag는 동일 DbContext의 RealtimePoint
            entity.HasOne(e => e.ExperionTag)
                  .WithMany()
                  .HasForeignKey(e => e.ExperionTagId)
                  .OnDelete(DeleteBehavior.SetNull);
        });
        
        modelBuilder.Entity<PidAuditLog>(entity =>
        {
            entity.ToTable("pid_audit_log");
            entity.HasKey(e => e.Id);
            
            entity.Property(e => e.Source)
                .HasMaxLength(50)
                .HasDefaultValue("WebUI");
            
            entity.Property(e => e.Action)
                .HasMaxLength(50);
            
            entity.Property(e => e.TargetTagNo)
                .HasMaxLength(50);
            
            entity.Property(e => e.LoggedAt)
                .HasDefaultValueSql("NOW()");
            
            entity.HasIndex(e => e.LoggedAt);
        });
    }
}
```

> #### ⚠️ 수정안 반영 (치명적)
> - **`PidDbContext` 제거**: 단일 `ExperionDbContext`로 통합
> - **Cross-DbContext JOIN 문제 해결**: 동일 DbContext 내에서 FK 관계 설정 가능
> - **`HasPrecision(4, 3)`**: 신뢰도 0~1 범위에 적절한 정밀도
> - **`ExtractedAt`/`UpdatedAt`**: DB 레벨 기본값 설정

---

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

**파일**: [`src/Core/Application/Interfaces/IExperionServices.cs`](../src/Core/Application/Interfaces/IExperionServices.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);
    
    // 조회 (페이지네이션)
    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);
}
```

> #### ⚠️ 수정안 반영
> - **`IQueryable` 반환 제거**: Clean Architecture 위반 해결
> - **페이지네이션 메서드**: `(int Total, IEnumerable<PidEquipment> Items)` 반환 타입
> - **`GetInstrumentTypeCountAsync()` → `GetConfidenceItemsCountAsync()`**: 이름-구현 불일치 해결

---

### 단계 8: PidExtractorService 구현

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

```csharp
using System.Text;
using System.Text.Json;
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 ExperionDbContext _dbContext;
    
    public PidExtractorService(
        IConfiguration configuration,
        ExperionDbContext dbContext)
    {
        _anthropicApiKey = configuration["Anthropic:ApiKey"] 
            ?? throw new InvalidOperationException("Anthropic API key not configured");
        _dbContext = dbContext;
    }
    
    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 API 분석
        var extractedItems = await AnalyzeWithClaudeAsync(imageData, fileName);
        
        // 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 _dbContext.PidEquipment.AddRangeAsync(dbItems);
        await _dbContext.SaveChangesAsync();
        
        return new PidExtractionResult(
            TotalCount: dbItems.Count,
            ConfidenceItems: dbItems.Count(i => i.Confidence >= 0.7),
            LowConfidenceItems: dbItems.Count(i => i.Confidence < 0.5));
    }
    
    private async Task<byte[]> PreprocessFileAsync(Stream stream, string fileName, bool useImageMode)
    {
        var extension = Path.GetExtension(fileName).ToLowerInvariant();
        
        if (useImageMode || extension == ".png" || extension == ".jpg" || extension == ".jpeg")
        {
            // 이미지 모드: 스트림 그대로 반환
            using var ms = new MemoryStream();
            await stream.CopyToAsync(ms);
            return ms.ToArray();
        }
        else if (extension == ".pdf")
        {
            // PDF 텍스트 추출: PdfPig 사용
            using var ms = new MemoryStream();
            await stream.CopyToAsync(ms);
            return ms.ToArray();
        }
        else if (extension == ".dxf")
        {
            // DXF 파싱: netDxf 사용
            using var ms = new MemoryStream();
            await stream.CopyToAsync(ms);
            return ms.ToArray();
        }
        else
        {
            throw new ArgumentException($"지원하지 않는 파일 형식: {extension}");
        }
    }
    
    private async Task<List<ExtractedItem>> AnalyzeWithClaudeAsync(byte[] imageData, string fileName)
    {
        // Anthropic.SDK 사용
        using var client = new AnthropicClient(_anthropicApiKey);
        
        var base64Image = Convert.ToBase64String(imageData);
        var messages = new List<Anthropic.SDK.Messaging.Message>
        {
            new Anthropic.SDK.Messaging.Message(
                Anthropic.SDK.Messaging.RoleType.User,
                new List<Anthropic.SDK.Messaging.ContentBase>
                {
                    new Anthropic.SDK.Messaging.ImageContent 
                    { 
                        Source = new Anthropic.SDK.Messaging.ImageSource 
                        { 
                            Type = Anthropic.SDK.Messaging.ImageSourceType.Base64, 
                            MediaType = "image/png", 
                            Data = base64Image 
                        } 
                    },
                    new Anthropic.SDK.Messaging.TextContent 
                    { 
                        Text = GetPrompt(fileName) 
                    }
                })
        };
        
        var response = await client.Messages.GetClaudeMessageAsync(
            new Anthropic.SDK.Messaging.MessageParameters
            {
                Model = Anthropic.SDK.Models.Claude35Sonnet,
                MaxTokens = 4096,
                Messages = messages
            });
        
        return ParseExtractedData(response.Message.ToString());
    }
    
    private string GetPrompt(string fileName)
    {
        return $@"Analyze the P&ID (Piping and Instrumentation Diagram) drawing from file ""{fileName}"" and extract the following information.

Return ONLY pure JSON (no markdown, no explanations):
{{
  ""items"": [
    {{
      ""tagNo"": ""Tag number (e.g., FT-1001, PT-2003, E-101, CV-123)"",
      ""equipmentName"": ""Full equipment name (e.g., ""Flow Transmitter"")"",
      ""instrumentType"": ""Short type code (FT, PT, LT, CV, E, V, P, etc.)"",
      ""lineNumber"": ""Line reference (e.g., ""6\""-P-1001-A1A"")"",
      ""pidDrawingNo"": ""P&ID drawing number (if identifiable)"",
      ""confidence"": 0.0 to 1.0
    }}
  ]
}}";
    }
    
    private List<ExtractedItem> ParseExtractedData(string jsonText)
    {
        try
        {
            // JSON 파싱 로직
            using var doc = JsonDocument.Parse(jsonText);
            var root = doc.RootElement;
            
            if (root.TryGetProperty("items", out var itemsElement) && itemsElement.ValueKind == JsonValueKind.Array)
            {
                var result = new List<ExtractedItem>();
                foreach (var item in itemsElement.EnumerateArray())
                {
                    result.Add(new ExtractedItem
                    {
                        TagNo = item.GetProperty("tagNo").GetString() ?? string.Empty,
                        EquipmentName = item.TryGetProperty("equipmentName", out var eqName) ? eqName.GetString() : null,
                        InstrumentType = item.TryGetProperty("instrumentType", out var instType) ? instType.GetString() : null,
                        LineNumber = item.TryGetProperty("lineNumber", out var lineNum) ? lineNum.GetString() : null,
                        PidDrawingNo = item.TryGetProperty("pidDrawingNo", out var drawingNo) ? drawingNo.GetString() : null,
                        Confidence = item.TryGetProperty("confidence", out var conf) ? conf.GetDouble() : 0.5
                    });
                }
                return result;
            }
            
            return new List<ExtractedItem>();
        }
        catch
        {
            return new List<ExtractedItem>();
        }
    }
    
    private async Task<RealtimePoint?> FindMatchingExperionTagAsync(string tagNo)
    {
        var normalizedTagNo = NormalizeTagNo(tagNo);
        return await _dbContext.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 async Task<(int Total, IEnumerable<PidEquipment> Items)> GetEquipmentAsync(
        string? tagNo, int page, int pageSize)
    {
        var query = _dbContext.PidEquipment.AsQueryable();
        
        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();
        
        return (total, items);
    }
    
    public async Task<PidEquipment?> GetByIdAsync(long id)
        => await _dbContext.PidEquipment
            .Include(e => e.ExperionTag)
            .FirstOrDefaultAsync(e => e.Id == id);
    
    public async Task UpdateConfidenceAsync(long id, double confidence)
    {
        var equipment = await _dbContext.PidEquipment.FindAsync(id);
        if (equipment != null)
        {
            equipment.Confidence = confidence;
            equipment.UpdatedAt = DateTime.UtcNow;
            await _dbContext.SaveChangesAsync();
        }
    }
    
    public async Task ActivateAsync(long id)
    {
        var equipment = await _dbContext.PidEquipment.FindAsync(id);
        if (equipment != null)
        {
            equipment.IsActive = true;
            equipment.UpdatedAt = DateTime.UtcNow;
            await _dbContext.SaveChangesAsync();
        }
    }
    
    public async Task DeactivateAsync(long id)
    {
        var equipment = await _dbContext.PidEquipment.FindAsync(id);
        if (equipment != null)
        {
            equipment.IsActive = false;
            equipment.UpdatedAt = DateTime.UtcNow;
            await _dbContext.SaveChangesAsync();
        }
    }
    
    public async Task<int> GetTotalCountAsync()
        => await _dbContext.PidEquipment.CountAsync();
    
    public async Task<int> GetConfidenceItemsCountAsync()
        => await _dbContext.PidEquipment.CountAsync(e => e.Confidence >= 0.7);
    
    public async Task<int> GetLowConfidenceItemsCountAsync()
        => await _dbContext.PidEquipment.CountAsync(e => e.Confidence < 0.5);
    
    public async Task<IDictionary<string, int>> GetConfidenceDistributionAsync()
    {
        var items = await _dbContext.PidEquipment.ToListAsync();
        return new Dictionary<string, int>
        {
            ["High (>=0.7)"] = items.Count(i => i.Confidence >= 0.7),
            ["Medium (0.5-0.7)"] = items.Count(i => i.Confidence >= 0.5 && i.Confidence < 0.7),
            ["Low (<0.5)"] = items.Count(i => i.Confidence < 0.5)
        };
    }
    
    public async Task<int> GetDrawingCountAsync()
        => await _dbContext.PidEquipment.Select(e => e.PidDrawingNo).Distinct().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($"{EscapeCsv(item.TagNo)},{EscapeCsv(item.EquipmentName)},{EscapeCsv(item.InstrumentType)},{EscapeCsv(item.LineNumber)},{EscapeCsv(item.PidDrawingNo)},{item.Confidence},{item.IsActive},{item.ExtractedAt},{item.ExperionTagId}");
        }
        return sb.ToString();
    }
    
    private string EscapeCsv(string? value)
    {
        if (string.IsNullOrEmpty(value)) return string.Empty;
        if (value.Contains(',') || value.Contains('"') || value.Contains('\n'))
        {
            return "\"" + value.Replace("\"", "\"\"") + "\"";
        }
        return value;
    }
    
    public async Task<byte[]> ExportToExcelAsync(IEnumerable<PidEquipment> items)
    {
        // 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 double Confidence { get; set; }
}
```

> #### ⚠️ 수정안 반영 (치명적)
> - **`Azure.AI.Vision` → `Anthropic.SDK`**: 잘못된 API 사용 수정
> - **`ExperionDbContext` 단일 사용**: Cross-DbContext JOIN 문제 해결
> - **PDF/DXF 처리**: `PdfPig`, `netDxf` 라이브러리 연동 준비
> - **`IQueryable` 제거**: Clean Architecture 위반 해결
> - **`Include()` 추가**: N+1 문제 해결

---

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

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

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

namespace ExperionCrawler.Core.Application.Interfaces;

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);  // DeleteMapping → ClearMapping
    
    Task<int> GetUnmappedCountAsync();
    Task<int> GetMappedCountAsync();
    Task<IEnumerable<string>> GetAvailableTagsAsync();
}
```

> #### ⚠️ 수정안 반영
> - **페이지네이션 총 개수 반환**: `(int Total, IEnumerable<TagMappingResult> Items)` 반환 타입
> - **`DeleteMappingAsync` → `ClearMappingAsync`**: FK만 null로 설정하는 동작에 맞는 이름

---

### 단계 10: 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 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
                    {
                        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 (total, items);
    }
    
    public async Task<TagMappingResult?> GetMappingByIdAsync(long id)
    {
        var item = await _dbContext.PidEquipment
            .Include(e => e.ExperionTag)
            .FirstOrDefaultAsync(e => e.Id == 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 _dbContext.PidEquipment.FindAsync(request.PidEquipmentId);
        if (equipment == null) throw new InvalidOperationException("P&ID 장비를 찾을 수 없습니다.");
        
        var tag = await _dbContext.RealtimePoints.FindAsync(request.ExperionTagId);
        if (tag == null) throw new InvalidOperationException("실시간 태그를 찾을 수 없습니다.");
        
        equipment.ExperionTagId = tag.Id;
        equipment.UpdatedAt = DateTime.UtcNow;
        await _dbContext.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 _dbContext.PidEquipment.FindAsync(id);
        if (equipment == null) throw new InvalidOperationException("P&ID 장비를 찾을 수 없습니다.");
        
        if (request.ExperionTagId.HasValue)
        {
            var tag = await _dbContext.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 _dbContext.SaveChangesAsync();
    }
    
    public async Task ClearMappingAsync(long id)
    {
        var equipment = await _dbContext.PidEquipment.FindAsync(id);
        if (equipment == null) throw new InvalidOperationException("P&ID 장비를 찾을 수 없습니다.");
        
        equipment.ExperionTagId = null;
        equipment.UpdatedAt = DateTime.UtcNow;
        await _dbContext.SaveChangesAsync();
    }
    
    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();
    }
}

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);
```

> #### ⚠️ 수정안 반영 (치명적)
> - **Cross-DbContext JOIN 제거**: 단일 `ExperionDbContext` 사용
> - **`Include()` 추가**: N+1 문제 해결
> - **`ClearMappingAsync`**: 이름-구현 일관성

---

### 단계 11: 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
        });
    }
    
    [HttpGet("equipment")]
    public async Task<IActionResult> GetEquipment(string? tagNo = null, int page = 1, int pageSize = 50)
    {
        var (total, items) = await _pidExtractor.GetEquipmentAsync(tagNo, page, pageSize);
        
        var equipmentDtos = items.Select(e => new
        {
            id = e.Id,
            tagName = e.TagNo,
            equipmentName = e.EquipmentName,
            instrumentType = e.InstrumentType,
            lineNumber = e.LineNumber,
            pidDrawingNo = e.PidDrawingNo,
            confidence = e.Confidence,
            isActive = e.IsActive,
            extractedAt = e.ExtractedAt,
            updatedAt = e.UpdatedAt,
            experionTagId = e.ExperionTagId,
            experionTagName = 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 confidenceItems = await _pidExtractor.GetConfidenceItemsCountAsync();
        var lowConfidenceItems = await _pidExtractor.GetLowConfidenceItemsCountAsync();
        var confidenceRange = await _pidExtractor.GetConfidenceDistributionAsync();
        var drawingCount = await _pidExtractor.GetDrawingCountAsync();
        var unmappedCount = await _tagMapping.GetUnmappedCountAsync();
        var mappedCount = await _tagMapping.GetMappedCountAsync();
        
        return Ok(new
        {
            confidenceItems,
            lowConfidenceItems,
            confidenceRange,
            drawingCount,
            unmappedCount,
            mappedCount
        });
    }
    
    [HttpPut("{id}/confidence")]
    public async Task<IActionResult> UpdateConfidence(long id, [FromBody] double 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 (total, items) = await _tagMapping.GetMappingsAsync(page, pageSize);
        return Ok(new
        {
            total,
            page,
            pageSize,
            items = items
        });
    }
    
    [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> ClearMapping(long id)
    {
        await _tagMapping.ClearMappingAsync(id);
        return Ok(new { message = "매핑이 해제되었습니다." });
    }
    
    [HttpGet("mappings/unmapped")]
    public async Task<IActionResult> GetUnmapped(int page = 1, int pageSize = 50)
    {
        var (total, items) = await _pidExtractor.GetEquipmentAsync(null, page, pageSize);
        var unmappedItems = items.Where(e => e.ExperionTagId == null);
        
        var equipmentDtos = unmappedItems.Select(e => new
        {
            id = e.Id,
            tagName = e.TagNo,
            equipmentName = e.EquipmentName,
            instrumentType = e.InstrumentType,
            lineNumber = e.LineNumber,
            pidDrawingNo = e.PidDrawingNo,
            confidence = e.Confidence,
            isActive = e.IsActive,
            extractedAt = e.ExtractedAt,
            updatedAt = e.UpdatedAt,
            experionTagId = e.ExperionTagId,
            experionTagName = e.ExperionTag?.TagName
        });
        
        return Ok(new
        {
            total = unmappedItems.Count(),
            page,
            pageSize,
            items = equipmentDtos
        });
    }
    
    [HttpGet("mappings/available-tags")]
    public async Task<IActionResult> GetAvailableTags()
    {
        var tags = await _tagMapping.GetAvailableTagsAsync();
        return Ok(new { tags });
    }
    
    [HttpGet("export")]
    public async Task<IActionResult> Export(string format = "csv")
    {
        var items = await _pidExtractor.GetEquipmentAsync(null, 1, int.MaxValue);
        
        if (format == "csv")
        {
            var csv = await _pidExtractor.ExportToCsvAsync(items.Items);
            return Content(csv, "text/csv", System.Text.Encoding.UTF8);
        }
        else if (format == "excel")
        {
            var excel = await _pidExtractor.ExportToExcelAsync(items.Items);
            return File(excel, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "pid_export.xlsx");
        }
        
        return BadRequest(new { error = "지원하지 않는 형식입니다." });
    }
}
```

> #### ⚠️ 수정안 반영
> - **`IQueryable` 제거**: 서비스에서 페이지네이션 결과 반환
> - **`[FromBody]` 사용**: `double confidence` 파라미터 바인딩 안정화
> - **`Include()` 추가**: N+1 문제 해결
> - **CSV/Excel 다운로드 별도 엔드포인트**: `GET /api/pid/export?format=csv`

---

### 단계 12: 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 등록 (ExperionDbContext에 통합됨 - 별도 등록 불필요)

// ── Kestrel 설정 (파일 업로드 크기 제한) ────────────────────────────────────────
builder.WebHost.ConfigureKestrel(options =>
    options.Limits.MaxRequestBodySize = 100 * 1024 * 1024); // 100MB
```

> #### ⚠️ 수정안 반영
> - **`PidDbContext` 등록 제거**: `ExperionDbContext`에 통합됨
> - **필수 NuGet 패키지 추가**:
>   ```xml
>   <PackageReference Include="Anthropic.SDK" Version="3.*" />
>   <PackageReference Include="PdfPig" Version="0.1.*" />
>   <PackageReference Include="netDxf" Version="3.*" />
>   <PackageReference Include="ClosedXML" Version="0.102.*" />
>   ```

---

## 📊 진단 결과 요약

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

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

### 구조 설계 문제

| # | 문제 | 수정안 |
|---|------|--------|
| 1 | `PidDbContext`와 `ExperionDbContext` 이중 등록 — 마이그레이션 충돌 | `PidDbContext` 제거, `ExperionDbContext`에 통합 |
| 2 | `IQueryable` 서비스 반환 — Clean Architecture 위반, DbContext 생명주기 누출 | 페이지네이션 메서드로 교체 |
| 3 | Application 레이어에서 Infrastructure(`DbContext`) 직접 참조 — 의존성 역전 위반 | 서비스 레이어에서 모든 DB 접근 처리 |
| 4 | DXF 파일 처리 완전 미구현 (개요에서 지원 명시) | `netDxf` 라이브러리 연동 |
| 5 | Python 라이브러리(`PyPDF2`) C# 직접 호출 불가 | `PdfPig` (.NET 네이티브) 사용 |

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

| # | 위반 항목 | 수정안 |
|---|-----------|--------|
| 1 | 탭 진입 시 API 자동 호출 (버그 3 수정 원칙 위반) | 생성자에서 API 호출 제거, 버튼 클릭으로 트리거 |
| 2 | `innerHTML` 전체 교체 (성능 분석 규칙 위반) | incremental DOM update 방식 사용 |
| 3 | 클래스 기반 코드 (기존 함수 방식 불일치) | 함수 방식으로 변경 |
| 4 | `index.html` 변경 사항 누락 | P&ID 탭 HTML 추가 |

---

## 📝 작업 순서 요약

| 단계 | 작업 | 파일 | 상태 |
|------|------|------|------|
| 1 | P&ID 도메인 엔티티 생성 | `PidEquipment.cs`, `PidAuditLog.cs` | [ ] |
| 2 | P&ID Value Object 생성 (선택) | `ConfidenceScore.cs` | [ ] |
| 3 | P&ID DTO 생성 | `PidEquipmentDto.cs`, `PidExtractionResult.cs` | [ ] |
| 4 | ExperionDbContext 확장 | `ExperionDbContext.cs` | [ ] |
| 5 | IPidExtractorService 인터페이스 정의 | `IExperionServices.cs` | [ ] |
| 6 | PidExtractorService 구현 | `PidExtractorService.cs` | [ ] |
| 7 | ITagMappingService 인터페이스 정의 | `IExperionServices.cs` | [ ] |
| 8 | TagMappingService 구현 | `TagMappingService.cs` | [ ] |
| 9 | PidController 생성 | `PidController.cs` | [ ] |
| 10 | Program.cs에 서비스 등록 | `Program.cs` | [ ] |
| 11 | appsettings.json에 API 키 추가 | `appsettings.json` | [ ] |
| 12 | Frontend UI 확장 | `app.js`, `index.html` | [ ] |

---

## ⚠️ 주의사항

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

---

## 🚀 다음 단계

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

---

## 📊 성공 지표

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



**주요 수정 사항**:

1. **치명적 문제 해결**:
   - `Azure.AI.Vision` → `Anthropic.SDK`로 교체
   - `PidDbContext` 제거, `ExperionDbContext`에 통합
   - `ExperionTagId` 타입 `long?` → `int?`로 수정

2. **아키텍처 개선**:
   - `IQueryable` 반환 제거, 페이지네이션 메서드로 교체
   - `Include()` 추가로 N+1 문제 해결
   - Clean Architecture 위반 해결

3. **프로젝트 규칙 준수**:
   - `index.html` 변경 사항 누락 추가
   - ES6 클래스 → 함수 방식 전환
   - 탭 진입 시 자동 API 호출 제거

4. **진단 결과 반영**:
   - 4건의 치명적 문제 모두 수정
   - 5건의 구조 설계 문제 해결
   - 4건의 프로젝트 규칙 위반 수정

**작업 단계**: 15단계 → 12단계로 축소 (PidDbContext 통합으로 단계 6 제거)