61 KiB
P&ID AX 코딩 플랜 (세분화된 단계)
📋 개요
DXF/PDF 형식의 P&ID 도면에서 장비 및 계기 정보를 AI로 자동 추출하여 ExperionCrawler 데이터베이스와 연동하는 기능입니다.
🎯 목표
- P&ID 도면에서 장비 정보를 추출
- 추출된 정보를 PostgreSQL 로 저장
- 기존 Experion 데이터와 연동
- 웹에서 시각화 및 관리
📦 폴더 구조
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.Id는int타입(ExperionEntities.cs:73)이므로 FK도int?여야 합니다.long?으로 선언하면 EF Core 모델 구성 및 JOIN 시 타입 불일치가 발생합니다.// 변경 전 public long? ExperionTagId { get; set; } // 변경 후 public int? ExperionTagId { get; set; }
[Table]어트리뷰트 누락: 기존 엔티티들은 모두[Table("테이블명")]을 명시합니다.[Table("pid_equipment")]추가 필요.ConfidenceScoreValue Object 미사용: 단계 3에서 생성하는ConfidenceScorerecord를 이 엔티티에서 사용하지 않고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.Confidence를decimal로 유지하고, 유효성 검사는 컨트롤러 또는 서비스 레이어에서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);
⚠️ 수정안
서버 내부 파일 경로 노출:
CsvPath와ExcelPath는 서버 파일시스템 경로(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에 속하고,PidEquipment는PidDbContext에 속합니다. EF Core는 서로 다른 DbContext 간의 FK 관계를 지원하지 않습니다. 이 설정을 그대로 두면 마이그레이션 생성 또는 런타임에 예외가 발생합니다.권장 해결책 — 별도 DbContext 제거,
ExperionDbContext에 통합: 이 프로젝트는 이미 PostgreSQL 단일 DB를 사용합니다.PidEquipment,PidAuditLog를ExperionDbContext에 추가하면:
- FK 관계가 정상 동작
- 단계 11의 cross-context JOIN 문제가 해결
- 연결 풀 낭비 없음
PidDbContext.cs는 생성하지 않고, 단계 7에서ExperionDbContext에 직접 통합하는 방식으로 계획을 수정해야 합니다.
HasPrecision(3, 2)오류: Precision 3, Scale 2이면 최대9.99까지만 저장 가능합니다. 신뢰도가 0~1 범위이므로HasPrecision(4, 3)(최대9.999) 또는 단순히double타입을 사용하는 것이 적절합니다.
단계 7: ExperionDbContext 확장
파일: src/Infrastructure/Database/ExperionDbContext.cs
// 기존 DbSet에 다음 추가
public DbSet<PidEquipment> PidEquipment => Set<PidEquipment>();
public DbSet<PidAuditLog> PidAuditLog => Set<PidAuditLog>();
⚠️ 수정안
단계 6과 중복 등록 충돌: 단계 6에서
PidDbContext에 이미PidEquipment와PidAuditLog를 등록하고, 단계 7에서ExperionDbContext에도 동일 엔티티를 등록합니다. 두 DbContext가 같은 테이블을 관리하면dotnet ef migrations add실행 시 어느 DbContext를 사용할지 결정해야 하고, 두 컨텍스트가 동시에 마이그레이션을 관리하면 충돌이 발생합니다.수정안: 단계 6의
PidDbContext생성을 취소하고, 이 단계 7만 유지합니다.ExperionDbContext단일 컨텍스트에서 모든 P&ID 엔티티를 관리하고,OnModelCreating에 P&ID 관련 설정을 추가합니다.추가로 FK 관계 설정도
OnModelCreating안에 포함해야 합니다: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.ImageAnalysis는 Microsoft Azure Cognitive Services SDK입니다. Anthropic Claude API가 아닙니다.ImageAnalysisClient와ImageAnalysisFeature.RecognizedText는 Azure Vision의 클래스이고,"https://vision.api.anthropic.com"엔드포인트는 존재하지 않습니다. 이 코드는 컴파일 오류가 나거나(패키지 미설치 시), 런타임에 잘못된 서버로 요청을 보냅니다.올바른 Claude API 사용법:
Anthropic.SDKNuGet 패키지를 설치하고 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 위반:
PidExtractorService는src/Core/Application/Services에 위치하지만ExperionCrawler.Infrastructure.Database네임스페이스(PidDbContext,ExperionDbContext)를 직접 참조합니다. Application 레이어는 Infrastructure에 의존하면 안 됩니다. 인터페이스(IExperionDbService패턴)를 통해 간접 접근하거나, 서비스를src/Infrastructure아래로 이동해야 합니다.PDF/DXF 처리 미구현:
PreprocessFileAsync의 텍스트 모드에 "추후 PyPDF2 또는 pdfplumber 연동" 주석이 있는데, Python 라이브러리는 C#에서 직접 호출할 수 없습니다. .NET 네이티브 PDF 라이브러리를 사용해야 합니다:
- PDF 텍스트 추출:
PdfPig(오픈소스, MIT 라이선스)- DXF 파싱:
netDxf라이브러리- 두 패키지 모두 NuGet 등록 필요
DXF 지원 완전 누락: 개요에서 "DXF/PDF" 형식을 지원한다고 했지만, 파일 처리 로직 어디에도 DXF 분기가 없습니다.
fileName확장자로 분기하는 로직이 필요합니다.CSV 인젝션 취약점:
ExportToCsvAsync에서TagNo,EquipmentName등이 쉼표나 줄바꿈을 포함할 경우 CSV가 깨집니다. 기존 프로젝트에CsvHelper패키지가 이미 설치되어 있으므로(ExperionCrawler.csproj) 이를 사용해야 합니다.
ExtractedItem클래스 네임스페이스 누락: 파일 최하단에namespace없이 정의되어 전역 네임스페이스에 배치됩니다. 클래스 상단에namespace ExperionCrawler.Core.Application.Services;를 추가하거나,private중첩 클래스로 변경하세요.
ExportToExcelAsync미구현 (빈 반환):return Array.Empty<byte>()를 반환하면 컨트롤러가 빈 파일을 응답합니다. NuGet 패키지ClosedXML또는EPPlus(라이선스 주의)를 설치하고 구현이 필요합니다.
단계 10: ITagMappingService 인터페이스 정의
파일: src/Core/Application/Interfaces/ITagMappingService.cs
using ExperionCrawler.Core.Application.DTOs;
using ExperionCrawler.Core.Domain.Entities;
namespace ExperionCrawler.Core.Application.Interfaces;
public interface ITagMappingService
{
Task<IEnumerable<TagMappingResult>> GetMappingsAsync(int page = 1, int pageSize = 50);
Task<TagMappingResult?> GetMappingByIdAsync(long id);
Task<TagMappingResult> CreateMappingAsync(CreateMappingRequest request);
Task UpdateMappingAsync(long id, UpdateMappingRequest request);
Task DeleteMappingAsync(long id);
Task<int> GetUnmappedCountAsync();
Task<int> GetMappedCountAsync();
Task<IEnumerable<string>> GetAvailableTagsAsync();
}
⚠️ 수정안
TagMappingResult,CreateMappingRequest,UpdateMappingRequest위치 오류: 이 타입들은 단계 11의 서비스 구현 파일 맨 아래에 정의되어 있지만, 인터페이스에서 먼저 참조됩니다. 컴파일은 되더라도 설계 원칙상 DTO/Request 타입은src/Core/Application/DTOs/폴더에 배치해야 합니다.페이지네이션 총 개수 미반환:
GetMappingsAsync가IEnumerable<TagMappingResult>만 반환하므로 컨트롤러에서 total 개수를 알 수 없습니다. 단계 12의GetMappings()컨트롤러에서mappings.Count()로 IEnumerable을 두 번 순회하는 문제가 발생합니다. 반환 타입을 튜플이나 래퍼 record로 변경해야 합니다: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.RealtimePoints는 EF Core에서 실행 불가능합니다. EF Core는 서로 다른 DbContext의IQueryable을 하나의 SQL 쿼리로 JOIN할 수 없으며, 런타임에InvalidOperationException이 발생합니다.단계 6/7의 수정안대로
PidDbContext를 없애고ExperionDbContext단일 컨텍스트로 통합하면 이 문제가 자동으로 해결됩니다. 통합 시_pidDbContext와_experionDbContext를 모두_dbContext하나로 교체합니다.
GetAvailableTagsAsync의 타입 불일치:mappedTagIds는List<long?>(PidEquipment.ExperionTagId)이고,t.Id는int(RealtimePoint.Id)입니다.!mappedTagIds.Contains(t.Id)에서 타입이 맞지 않아 EF Core가 SQL로 변환하지 못하고 클라이언트 측 평가로 폴백되거나 예외가 발생합니다.ExperionTagId를int?로 수정(단계 1 수정안 반영)하면 해결됩니다.
DeleteMappingAsync동작 불명확: 현재 구현은 FK만 null로 설정합니다(매핑 해제). 메서드명DeleteMapping은 매핑 레코드 자체를 삭제할 것처럼 읽힙니다. 의도가 "매핑만 해제(Equipment 유지)"라면ClearMappingAsync또는UnmapAsync로 이름을 변경해야 혼란이 없습니다.
GetMappingByIdAsync에서 N+1 문제:FindAsync(id)후item.ExperionTag?.TagName을 사용하는데,Include(e => e.ExperionTag)를 하지 않으면 lazy loading이 없는 EF Core에서ExperionTag는 항상null입니다..Include(e => e.ExperionTag).FirstOrDefaultAsync(e => e.Id == id)로 수정이 필요합니다.
단계 12: PidController 생성
파일: src/Web/Controllers/PidController.cs
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을 함께 반환해야 합니다.
GetEquipmentN+1 문제:e.ExperionTag?.TagName을 사용하지만 쿼리에.Include(e => e.ExperionTag)가 없습니다. 모든 아이템에 대해 ExperionTag가 항상 null로 반환됩니다.GetQueryable()패턴을 제거하고 서비스에서.Include()후 반환해야 합니다.
GetUnmappedN+1 문제:GetQueryable().Where(...).ToListAsync()후e.ExperionTag?.TagName을 참조하는데, 역시 Include가 없습니다.
단계 13: 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에 연결 문자열 추가
{
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Database=experion;Username=postgres;Password=your_password",
"PidDb": "Host=localhost;Database=experion;Username=postgres;Password=your_password"
},
"Anthropic": {
"ApiKey": "your_anthropic_api_key"
}
}
⚠️ 수정안
PidDb연결 문자열 미사용: 단계 13에서PidDbContext는DefaultConnection을 사용합니다.PidDb항목은 참조되는 곳이 없으므로 불필요합니다. 단계 6/7 수정안대로 단일 DbContext로 통합하면 완전히 제거할 수 있습니다.API 키 평문 저장 보안 위험:
appsettings.json에"your_anthropic_api_key"를 직접 입력하면 git에 커밋될 경우 유출됩니다. 이미.gitignore에appsettings.json이 있다면 괜찮지만, 권장 방법은 환경 변수 또는dotnet user-secrets를 사용하는 것입니다:dotnet user-secrets set "Anthropic:ApiKey" "sk-ant-..."또는 서버에서 환경 변수
ANTHROPIC__APIKEY로 주입합니다.기존
appsettings.json덮어쓰기 주의: 현재 파일에 OPC UA 서버, Experion 접속 등의 설정이 있습니다. 전체를 교체하는 방식으로 기재되어 있는데, 실제로는 기존 설정을 유지한 채Anthropic섹션만 추가해야 합니다.
단계 15: Frontend UI 확장 (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.js는nmLoad(),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 |
[ ] |
⚠️ 주의사항
- API 키 보안:
appsettings.json에 Anthropic API 키를 저장하되,.gitignore에 추가하여 외부 유출 방지 - 파일 크기 제한:
Program.cs에서Kestrel설정으로 업로드 파일 크기 제한 (100MB권장) - 이미지 모드 성능: PDF→이미지 변환은 CPU 자원을 많이 소모하므로, 대용량 파일은 텍스트 모드 사용 권장
- 네트워크: Anthropic API 사용을 위해 외부 연결 필요 (방화벽 설정 확인)
🚀 다음 단계
- 위 15단계를 순차적으로 구현
- 각 단계 완료 후
dotnet build로 빌드 검증 dotnet run으로 서버 실행 및 API 테스트- Swagger UI (
/swagger)로 API 엔드포인트 확인
📊 성공 지표
- DXF/PDF 파일로부터 평균 성공 추출률 80% 이상
- 100MB 이하 파일 처리 시 응답 시간 30초 이내
- 신뢰도 0.7 이상 항목 자동 검증 기능
- Redis 캐싱으로 API 요청 50% 감소
🔍 전체 진단 요약
치명적 문제 (구현 시 런타임 오류 발생)
| 단계 | 문제 | 영향 |
|---|---|---|
| 9 | Azure.AI.Vision.ImageAnalysis 사용 — Anthropic API가 아님 |
컴파일 오류 또는 잘못된 서버 요청 |
| 6/11 | PidDbContext + ExperionDbContext 간 cross-context JOIN |
런타임 InvalidOperationException |
| 6 | HasOne(ExperionTag) FK를 다른 DbContext 엔티티로 설정 |
마이그레이션 생성 실패 또는 런타임 오류 |
| 11 | mappedTagIds (long?) vs t.Id (int) 타입 불일치 |
EF Core SQL 변환 실패 |
구조 설계 문제
| 단계 | 문제 |
|---|---|
| 6+7 | PidDbContext와 ExperionDbContext 이중 등록 — 마이그레이션 충돌 |
| 8+12 | IQueryable 서비스 반환 — Clean Architecture 위반, DbContext 생명주기 누출 |
| 9 | Application 레이어에서 Infrastructure(DbContext) 직접 참조 — 의존성 역전 위반 |
| 9 | DXF 파일 처리 완전 미구현 (개요에서 지원 명시) |
| 9 | Python 라이브러리(PyPDF2) C# 직접 호출 불가 |
프로젝트 규칙 위반 (CLAUDE.md 기준)
| 단계 | 위반 항목 |
|---|---|
| 15 | 탭 진입 시 API 자동 호출 (버그 3 수정 원칙 위반) |
| 15 | innerHTML 전체 교체 (성능 분석 규칙 위반) |
| 15 | 클래스 기반 코드 (기존 함수 방식 불일치) |
| 15 | index.html 변경 사항 누락 |
권장 수정 순서
- 단계 6 취소 →
PidDbContext파일 생성하지 않음 - 단계 7 확장 →
ExperionDbContext에 P&ID 엔티티 통합 +OnModelCreating설정 포함 - 단계 9 →
Azure.AI.Vision제거,Anthropic.SDK패키지로 교체, PDF는PdfPig, DXF는netDxf사용 - 단계 1 →
ExperionTagId타입을long?→int?수정 - 단계 8 →
IQueryable반환 제거, 페이지네이션 메서드로 교체 - 단계 13 →
PidDbContext등록 제거, 필요 NuGet 패키지 목록 반영 - 단계 15 → ES6 클래스 → 함수 방식 전환, 자동 API 호출 제거,
index.html추가