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

61 KiB

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

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.Idint 타입(ExperionEntities.cs:73)이므로 FK도 int?여야 합니다. long?으로 선언하면 EF Core 모델 구성 및 JOIN 시 타입 불일치가 발생합니다.

// 변경 전
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

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

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.Confidencedecimal로 유지하고, 유효성 검사는 컨트롤러 또는 서비스 레이어에서 if (confidence < 0 || confidence > 1) 조건문으로 처리하면 충분합니다.


단계 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,
    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

namespace ExperionCrawler.Core.Application.DTOs;

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

⚠️ 수정안

서버 내부 파일 경로 노출: CsvPathExcelPath는 서버 파일시스템 경로(output/pid_extracted_20260430_120000.csv)입니다. 이 경로를 클라이언트로 반환하면 서버 디렉토리 구조가 노출됩니다. 클라이언트에서 직접 접근할 수 없는 경로이기도 합니다. 수정안: 파일 경로 대신 다운로드 URL을 반환하거나, CSV는 직접 응답 스트림으로 내려주고 DTO에서 경로 필드를 제거합니다.

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

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에 속하고, PidEquipmentPidDbContext에 속합니다. EF Core는 서로 다른 DbContext 간의 FK 관계를 지원하지 않습니다. 이 설정을 그대로 두면 마이그레이션 생성 또는 런타임에 예외가 발생합니다.

권장 해결책 — 별도 DbContext 제거, ExperionDbContext에 통합: 이 프로젝트는 이미 PostgreSQL 단일 DB를 사용합니다. PidEquipment, PidAuditLogExperionDbContext에 추가하면:

  • 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

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

⚠️ 수정안

단계 6과 중복 등록 충돌: 단계 6에서 PidDbContext에 이미 PidEquipmentPidAuditLog를 등록하고, 단계 7에서 ExperionDbContext에도 동일 엔티티를 등록합니다. 두 DbContext가 같은 테이블을 관리하면 dotnet ef migrations add 실행 시 어느 DbContext를 사용할지 결정해야 하고, 두 컨텍스트가 동시에 마이그레이션을 관리하면 충돌이 발생합니다.

수정안: 단계 6의 PidDbContext 생성을 취소하고, 이 단계 7만 유지합니다. ExperionDbContext 단일 컨텍스트에서 모든 P&ID 엔티티를 관리하고, OnModelCreating에 P&ID 관련 설정을 추가합니다.

추가로 FK 관계 설정도 OnModelCreating 안에 포함해야 합니다:

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

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 반환 메서드는 제거하고 페이지네이션 파라미터를 받는 메서드로 교체해야 합니다:

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

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.ImageAnalysisMicrosoft Azure Cognitive Services SDK입니다. Anthropic Claude API가 아닙니다. ImageAnalysisClientImageAnalysisFeature.RecognizedText는 Azure Vision의 클래스이고, "https://vision.api.anthropic.com" 엔드포인트는 존재하지 않습니다. 이 코드는 컴파일 오류가 나거나(패키지 미설치 시), 런타임에 잘못된 서버로 요청을 보냅니다.

올바른 Claude API 사용법: Anthropic.SDK NuGet 패키지를 설치하고 Messages API를 사용해야 합니다:

// 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 위반: PidExtractorServicesrc/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

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/ 폴더에 배치해야 합니다.

페이지네이션 총 개수 미반환: GetMappingsAsyncIEnumerable<TagMappingResult>만 반환하므로 컨트롤러에서 total 개수를 알 수 없습니다. 단계 12의 GetMappings() 컨트롤러에서 mappings.Count()로 IEnumerable을 두 번 순회하는 문제가 발생합니다. 반환 타입을 튜플이나 래퍼 record로 변경해야 합니다:

Task<(int Total, IEnumerable<TagMappingResult> Items)> GetMappingsAsync(int page, int pageSize);

단계 11: 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 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.RealtimePointsEF Core에서 실행 불가능합니다. EF Core는 서로 다른 DbContext의 IQueryable을 하나의 SQL 쿼리로 JOIN할 수 없으며, 런타임에 InvalidOperationException이 발생합니다.

단계 6/7의 수정안대로 PidDbContext를 없애고 ExperionDbContext 단일 컨텍스트로 통합하면 이 문제가 자동으로 해결됩니다. 통합 시 _pidDbContext_experionDbContext를 모두 _dbContext 하나로 교체합니다.

GetAvailableTagsAsync의 타입 불일치: mappedTagIdsList<long?>(PidEquipment.ExperionTagId)이고, t.Idint(RealtimePoint.Id)입니다. !mappedTagIds.Contains(t.Id)에서 타입이 맞지 않아 EF Core가 SQL로 변환하지 못하고 클라이언트 측 평가로 폴백되거나 예외가 발생합니다. ExperionTagIdint?로 수정(단계 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

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으로 받는 것이 안전합니다:

[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

// ── 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에 없습니다. 추가가 필요합니다:

<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에 추가 필요:

builder.WebHost.ConfigureKestrel(options =>
    options.Limits.MaxRequestBodySize = 100 * 1024 * 1024); // 100MB

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

파일: src/Web/appsettings.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에서 PidDbContextDefaultConnection을 사용합니다. PidDb 항목은 참조되는 곳이 없으므로 불필요합니다. 단계 6/7 수정안대로 단일 DbContext로 통합하면 완전히 제거할 수 있습니다.

API 키 평문 저장 보안 위험: appsettings.json"your_anthropic_api_key"를 직접 입력하면 git에 커밋될 경우 유출됩니다. 이미 .gitignoreappsettings.json이 있다면 괜찮지만, 권장 방법은 환경 변수 또는 dotnet user-secrets를 사용하는 것입니다:

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

// ── 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.jsnmLoad(), pbLoad(), histLoad()평범한 함수 방식으로 작성되어 있습니다. 클래스 기반으로 추가하면 두 가지 패턴이 혼재합니다. 기존 스타일에 맞춰 함수 방식으로 작성하는 것을 권장합니다:

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 PidDbContextExperionDbContext 이중 등록 — 마이그레이션 충돌
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. 단계 9Azure.AI.Vision 제거, Anthropic.SDK 패키지로 교체, PDF는 PdfPig, DXF는 netDxf 사용
  4. 단계 1ExperionTagId 타입을 long?int? 수정
  5. 단계 8IQueryable 반환 제거, 페이지네이션 메서드로 교체
  6. 단계 13PidDbContext 등록 제거, 필요 NuGet 패키지 목록 반영
  7. 단계 15 → ES6 클래스 → 함수 방식 전환, 자동 API 호출 제거, index.html 추가