46 KiB
<작업규칙>
- roo-rules.md 확인 및 준수
- glm-code-rules.md 확인 및 준수
P&ID AX 코딩 플랜 v2 (수정 반영본)
수정일: 2026-04-30
기준:p&id_ax-coding_plan.md의 ⚠️ 수정안 및 진단 결과 반영
목적: 런타임 오류, 아키텍처 위반, 프로젝트 규칙 위반 문제 해결
📋 개요
DXF/PDF 형식의 P&ID 도면에서 장비 및 계기 정보를 AI로 자동 추출하여 ExperionCrawler 데이터베이스와 연동하는 기능입니다.
🎯 목표
- P&ID 도면에서 장비 정보를 추출
- 추출된 정보를 PostgreSQL 로 저장
- 기존 Experion 데이터와 연동
- 웹에서 시각화 및 관리
📦 폴더 구조
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.Id는int이므로 FK도int?로 수정[Table]어트리뷰트: 기존 엔티티들과 일관되게[Table("pid_equipment")]추가Confidence타입:decimal대신double사용 (EF Core에서HasPrecision설정 간편)ExtractedAt기본값:= DateTime.UtcNow추가로 DB 레벨 기본값 대체
단계 2: P&ID 감사 로그 엔티티 생성
파일: src/Core/Domain/Entities/PidAuditLog.cs
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace ExperionCrawler.Core.Domain.Entities;
[Table("pid_audit_log")]
public class PidAuditLog
{
public long Id { get; set; }
// 사용자 인증 시스템 부재 → Source 필드로 대체
[MaxLength(50)]
public string Source { get; set; } = string.Empty;
[MaxLength(50)]
public string Action { get; set; } = string.Empty;
[MaxLength(50)]
public string TargetTagNo { get; set; } = string.Empty;
public string? OldValue { get; set; }
public string? NewValue { get; set; }
public DateTime LoggedAt { get; set; } = DateTime.UtcNow;
}
⚠️ 수정안 반영
UserId→Source: 로그인 시스템이 없으므로 "WebUI", "API" 등 소스 식별자로 변경[Table]어트리뷰트:[Table("pid_audit_log")]추가LoggedAt기본값:= DateTime.UtcNow추가
단계 3: P&ID Value Object 생성 (선택 사항)
파일: src/Core/Domain/ValueObjects/ConfidenceScore.cs
namespace ExperionCrawler.Core.Domain.ValueObjects;
public record ConfidenceScore(double Value)
{
public static ConfidenceScore From(double value)
{
if (value < 0 || value > 1)
throw new ArgumentException("신뢰도는 0~1 사이여야 합니다.", nameof(value));
return new ConfidenceScore(value);
}
public static implicit operator double(ConfidenceScore score) => score.Value;
public override string ToString() => Value.ToString("0.00");
}
⚠️ 수정안 반영
- 실제 사용처 없음:
PidEquipment가double Confidence를 그대로 사용하므로 이 Value Object는 생략 가능MeasurementUnit.cs: 계획에 정의 없음 → 생략
단계 4: P&ID DTO 생성
파일: src/Core/Application/DTOs/PidEquipmentDto.cs
namespace ExperionCrawler.Core.Application.DTOs;
public record PidEquipmentDto(
long Id,
string TagNo,
string? EquipmentName,
string? InstrumentType,
string? LineNumber,
string? PidDrawingNo,
double Confidence,
bool IsActive,
DateTime ExtractedAt,
DateTime? UpdatedAt,
int? ExperionTagId,
string? ExperionTagName);
⚠️ 수정안 반영
ExperionTagId타입:long?→int?로 수정 (단계 1과 일관성)Confidence타입:decimal→double로 수정 (단계 1과 일관성)
단계 5: P&ID 추출 결과 DTO 생성
파일: src/Core/Application/DTOs/PidExtractionResult.cs
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.Vision→Anthropic.SDK: 잘못된 API 사용 수정ExperionDbContext단일 사용: Cross-DbContext JOIN 문제 해결- PDF/DXF 처리:
PdfPig,netDxf라이브러리 연동 준비IQueryable제거: Clean Architecture 위반 해결Include()추가: N+1 문제 해결
단계 9: ITagMappingService 인터페이스 정의
파일: src/Core/Application/Interfaces/IExperionServices.cs
using ExperionCrawler.Core.Application.DTOs;
using ExperionCrawler.Core.Domain.Entities;
namespace ExperionCrawler.Core.Application.Interfaces;
public interface ITagMappingService
{
Task<(int Total, IEnumerable<TagMappingResult> Items)> GetMappingsAsync(int page, int pageSize);
Task<TagMappingResult?> GetMappingByIdAsync(long id);
Task<TagMappingResult> CreateMappingAsync(CreateMappingRequest request);
Task UpdateMappingAsync(long id, UpdateMappingRequest request);
Task ClearMappingAsync(long id); // DeleteMapping → ClearMapping
Task<int> GetUnmappedCountAsync();
Task<int> GetMappedCountAsync();
Task<IEnumerable<string>> GetAvailableTagsAsync();
}
⚠️ 수정안 반영
- 페이지네이션 총 개수 반환:
(int Total, IEnumerable<TagMappingResult> Items)반환 타입DeleteMappingAsync→ClearMappingAsync: FK만 null로 설정하는 동작에 맞는 이름
단계 10: TagMappingService 구현
파일: src/Core/Application/Services/TagMappingService.cs
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에 서비스 등록
// ── 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 변환 실패 | ExperionTagId를 int?로 수정 |
구조 설계 문제
| # | 문제 | 수정안 |
|---|---|---|
| 1 | PidDbContext와 ExperionDbContext 이중 등록 — 마이그레이션 충돌 |
PidDbContext 제거, ExperionDbContext에 통합 |
| 2 | IQueryable 서비스 반환 — Clean Architecture 위반, DbContext 생명주기 누출 |
페이지네이션 메서드로 교체 |
| 3 | Application 레이어에서 Infrastructure(DbContext) 직접 참조 — 의존성 역전 위반 |
서비스 레이어에서 모든 DB 접근 처리 |
| 4 | DXF 파일 처리 완전 미구현 (개요에서 지원 명시) | netDxf 라이브러리 연동 |
| 5 | Python 라이브러리(PyPDF2) C# 직접 호출 불가 |
PdfPig (.NET 네이티브) 사용 |
프로젝트 규칙 위반 (CLAUDE.md 기준)
| # | 위반 항목 | 수정안 |
|---|---|---|
| 1 | 탭 진입 시 API 자동 호출 (버그 3 수정 원칙 위반) | 생성자에서 API 호출 제거, 버튼 클릭으로 트리거 |
| 2 | innerHTML 전체 교체 (성능 분석 규칙 위반) |
incremental DOM update 방식 사용 |
| 3 | 클래스 기반 코드 (기존 함수 방식 불일치) | 함수 방식으로 변경 |
| 4 | index.html 변경 사항 누락 |
P&ID 탭 HTML 추가 |
📝 작업 순서 요약
| 단계 | 작업 | 파일 | 상태 |
|---|---|---|---|
| 1 | P&ID 도메인 엔티티 생성 | PidEquipment.cs, PidAuditLog.cs |
[ ] |
| 2 | P&ID Value Object 생성 (선택) | ConfidenceScore.cs |
[ ] |
| 3 | P&ID DTO 생성 | PidEquipmentDto.cs, PidExtractionResult.cs |
[ ] |
| 4 | ExperionDbContext 확장 | ExperionDbContext.cs |
[ ] |
| 5 | IPidExtractorService 인터페이스 정의 | IExperionServices.cs |
[ ] |
| 6 | PidExtractorService 구현 | PidExtractorService.cs |
[ ] |
| 7 | ITagMappingService 인터페이스 정의 | IExperionServices.cs |
[ ] |
| 8 | TagMappingService 구현 | TagMappingService.cs |
[ ] |
| 9 | PidController 생성 | PidController.cs |
[ ] |
| 10 | Program.cs에 서비스 등록 | Program.cs |
[ ] |
| 11 | appsettings.json에 API 키 추가 | appsettings.json |
[ ] |
| 12 | Frontend UI 확장 | app.js, index.html |
[ ] |
⚠️ 주의사항
- API 키 보안:
appsettings.json에 Anthropic API 키를 저장하되,.gitignore에 추가하여 외부 유출 방지 - 파일 크기 제한:
Program.cs에서Kestrel설정으로 업로드 파일 크기 제한 (100MB권장) - 이미지 모드 성능: PDF→이미지 변환은 CPU 자원을 많이 소모하므로, 대용량 파일은 텍스트 모드 사용 권장
- 네트워크: Anthropic API 사용을 위해 외부 연결 필요 (방화벽 설정 확인)
🚀 다음 단계
- 위 12단계를 순차적으로 구현
- 각 단계 완료 후
dotnet build src/Web/ExperionCrawler.csproj --no-restore -v q로 빌드 검증 dotnet run으로 서버 실행 및 API 테스트- Swagger UI (
/swagger)로 API 엔드포인트 확인
📊 성공 지표
- DXF/PDF 파일로부터 평균 성공 추출률 80% 이상
- 100MB 이하 파일 처리 시 응답 시간 30초 이내
- 신뢰도 0.7 이상 항목 자동 검증 기능
- Redis 캐싱으로 API 요청 50% 감소
주요 수정 사항:
-
치명적 문제 해결:
Azure.AI.Vision→Anthropic.SDK로 교체PidDbContext제거,ExperionDbContext에 통합ExperionTagId타입long?→int?로 수정
-
아키텍처 개선:
IQueryable반환 제거, 페이지네이션 메서드로 교체Include()추가로 N+1 문제 해결- Clean Architecture 위반 해결
-
프로젝트 규칙 준수:
index.html변경 사항 누락 추가- ES6 클래스 → 함수 방식 전환
- 탭 진입 시 자동 API 호출 제거
-
진단 결과 반영:
- 4건의 치명적 문제 모두 수정
- 5건의 구조 설계 문제 해결
- 4건의 프로젝트 규칙 위반 수정
작업 단계: 15단계 → 12단계로 축소 (PidDbContext 통합으로 단계 6 제거)