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

46 KiB

<작업규칙>

  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

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.Idint이므로 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

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;
}

⚠️ 수정안 반영

  • UserIdSource: 로그인 시스템이 없으므로 "WebUI", "API" 등 소스 식별자로 변경
  • [Table] 어트리뷰트: [Table("pid_audit_log")] 추가
  • LoggedAt 기본값: = DateTime.UtcNow 추가

단계 3: P&ID Value Object 생성 (선택 사항)

파일: src/Core/Domain/ValueObjects/ConfidenceScore.cs

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");
}

⚠️ 수정안 반영

  • 실제 사용처 없음: PidEquipmentdouble Confidence를 그대로 사용하므로 이 Value Object는 생략 가능
  • MeasurementUnit.cs: 계획에 정의 없음 → 생략

단계 4: P&ID DTO 생성

파일: src/Core/Application/DTOs/PidEquipmentDto.cs

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 타입: decimaldouble로 수정 (단계 1과 일관성)

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

파일: src/Core/Application/DTOs/PidExtractionResult.cs

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

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

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

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.VisionAnthropic.SDK: 잘못된 API 사용 수정
  • ExperionDbContext 단일 사용: Cross-DbContext JOIN 문제 해결
  • PDF/DXF 처리: PdfPig, netDxf 라이브러리 연동 준비
  • IQueryable 제거: Clean Architecture 위반 해결
  • Include() 추가: N+1 문제 해결

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

파일: src/Core/Application/Interfaces/IExperionServices.cs

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) 반환 타입
  • DeleteMappingAsyncClearMappingAsync: FK만 null로 설정하는 동작에 맞는 이름

단계 10: TagMappingService 구현

파일: src/Core/Application/Services/TagMappingService.cs

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

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

// ── 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 패키지 추가:
    <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 변환 실패 ExperionTagIdint?로 수정

구조 설계 문제

# 문제 수정안
1 PidDbContextExperionDbContext 이중 등록 — 마이그레이션 충돌 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.VisionAnthropic.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 제거)