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

58 KiB

P&ID AX 코딩 플랜 v3 (로컬 LLM 기반 재설계)

수정일: 2026-04-30
기준: P&ID_AX_Plan2.md 반영
목적: Anthropic Cloud Vision 제거 → MCP 경유 로컬 LLM(vLLM Qwen3-Coder-Next-FP8)으로 DXF/PDF 텍스트 추출 구현


📋 개요

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

주요 변경사항:

  • Anthropic Cloud Vision 제거
  • MCP 경유 로컬 LLM(vLLM Qwen3-Coder-Next-FP8) 사용
  • netDxf (DXF 파싱) + PdfPig (PDF 텍스트 추출) 사용

🎯 목표

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

📦 폴더 구조

ExperionCrawler/
├── src/
│   ├── Core/
│   │   ├── Application/
│   │   │   ├── Interfaces/
│   │   │   │   ├── IExperionServices.cs (기존 - 확장)
│   │   │   │   └── IPidExtractorService.cs (신규)
│   │   │   ├── Services/
│   │   │   │   ├── PidExtractorService.cs (신규)
│   │   │   │   └── TagMappingService.cs (신규)
│   │   │   └── Dtos/
│   │   │       ├── PidEquipmentDto.cs (신규)
│   │   │       └── PidExtractionResult.cs (신규)
│   │   └── Domain/
│   │       ├── Entities/
│   │       │   ├── PidEquipment.cs (신규)
│   │       │   └── PidAuditLog.cs (신규)
│   │       └── ValueObjects/
│   │           ├── ConfidenceScore.cs (신규)
│   │           └── MeasurementUnit.cs (신규)
│   ├── Infrastructure/
│   │   ├── Database/
│   │   │   └── ExperionDbContext.cs (확장 - PidDbContext 통합)
│   │   └── OpcUa/
│   │       └── (기존)
│   └── Web/
│       ├── Controllers/
│       │   └── PidController.cs (신규)
│       └── wwwroot/
│           └── js/
│               └── app.js (확장)
└── futurePlan/
    ├── temp/
    │   ├── pid_extractor.py (AI 추출기)
    │   ├── schema.sql (추구용 DB 스키마)
    │   └── requirements.txt (Python 의존성)
    └── p&id_ax_coding_plan3.md (이 파일)

⚠️ 공통 사항

1. 단계 수 불일치

  • 제목이 "수정 반영 12단계" 로 되어 있으나 실제로는 단계 1~14, 총 14개 단계 존재

2. 폴더 구조 오류

  • ValueObjects/ConfidenceScore.cs, MeasurementUnit.cs — 코드 어디서도 사용하지 않음, 불필요하게 포함됨
  • Controllers/PidController.cs (신규) 로 표기되어 있으나 프로젝트 규칙상 ExperionControllers.cs 단일 파일에 추가 (단계 10에서 실제로도 그렇게 구현됨)

3. 이미 완료된 단계

다음 단계는 코드베이스에 이미 구현 완료된 상태. 재작성하지 말고 현황 확인만 할 것:

  • 단계 1: PidEquipment.cs, PidAuditLog.cs 엔티티 파일 존재, ExperionDbContext.csDbSetOnModelCreating FK 설정 완료
  • 단계 8: ExperionDbContext.csDbSet<PidEquipment>, DbSet<PidAuditLog> 이미 등록
  • 단계 9: Program.cs 85~86번째 줄에 IPidExtractorService, ITagMappingService 이미 등록

4. ExtractedItem / MappingItem 중복 정의 (컴파일 오류)

  • 단계 2 DTOs 파일에 public record ExtractedItem(...), public record MappingItem(...) 정의
  • 단계 5 파일 하단에 동일 이름의 file class ExtractedItem, file class MappingItem 정의
  • 같은 네임스페이스 내 동일 이름 중복 → 컴파일 오류 발생
  • 해결: 단계 2 DTOs에서 ExtractedItem, MappingItem 제거 — 두 타입은 PidExtractorService.cs 내부 파싱 전용 file class로만 유지

5. ITagMappingService 이중 정의

  • 단계 3 IPidExtractorService.cs에는 IPidExtractorService만 있고 ITagMappingService는 없음
  • 단계 4 TagMappingService.cs 파일 내부에 public interface ITagMappingService 중복 정의
  • 인터페이스를 서비스 구현 파일 내에 정의하는 것은 프로젝트 패턴(IExperionServices.cs 집중)에 위배
  • ITagMappingService도 단계 3의 인터페이스 파일에 함께 추가하거나, 기존 패턴대로 IExperionServices.cs에 추가할 것

📋 코딩 단계 (수정 반영 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(100)]
    public string? PidDrawingNo { get; set; }
    
    public double Confidence { get; set; } = 0.5;
    
    public bool IsActive { get; set; } = true;
    
    public DateTime ExtractedAt { get; set; } = DateTime.UtcNow;
    public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
    
    // 외래키
    public int? ExperionTagId { get; set; }
    [ForeignKey("ExperionTagId")]
    public RealtimePoint? ExperionTag { get; set; }
}

[Table("pid_audit_log")]
public class PidAuditLog
{
    public long Id { get; set; }
    
    public long PidEquipmentId { get; set; }
    [ForeignKey("PidEquipmentId")]
    public PidEquipment? PidEquipment { get; set; }
    
    [Required]
    [MaxLength(50)]
    public string Source { get; set; } = string.Empty;  // UserId 대체
    
    [MaxLength(500)]
    public string? Action { get; set; }
    
    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}

⚠️ 개선점 — 단계 1

  • 이미 완료: PidEquipment.cs, PidAuditLog.csExperionDbContext.cs DbSet/FK 설정이 코드베이스에 이미 존재. 재작성 불필요, 현황 확인만 할 것.
  • UpdatedAt DateTime (non-nullable) vs 기존 엔티티 DateTime? UpdatedAt (nullable) — 스키마 변경 시 기존 NULL 행 마이그레이션 필요. 실제 코드베이스 확인 후 nullable 여부를 맞출 것.
  • PidEquipmentPidAuditLog를 동일 파일에 정의하고 있으나 실제로는 이미 별도 파일로 분리됨.

단계 2: DTOs 생성

파일: 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
);

public record PidExtractionResult(
    int TotalCount,
    int ConfidenceItems,
    int LowConfidenceItems
);

public record ExtractedItem(
    string TagNo,
    string? EquipmentName,
    string? InstrumentType,
    string? LineNumber,
    string? PidDrawingNo,
    double Confidence
);

public record MappingItem(
    string PidTag,
    string? ExperionTag,
    double Confidence
);

⚠️ 개선점 — 단계 2

  • ExtractedItem, MappingItem을 여기에 public record로 정의하면 단계 5의 file class ExtractedItem/MappingItem과 이름 충돌 → 컴파일 오류 발생.
  • 해결: DTOs 파일에서 ExtractedItem, MappingItem 두 record 제거. 두 타입은 PidExtractorService.cs 내부 파싱 전용이므로 단계 5의 file class로만 유지.

단계 3: Interface 정의

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

namespace ExperionCrawler.Core.Application.Interfaces;

public interface IPidExtractorService
{
    Task<PidExtractionResult> ExtractFromFileAsync(string filePath);
    Task<PidExtractionResult> ExtractFromStreamAsync(Stream stream, string fileName);
    
    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<int> GetDrawingCountAsync();
    Task<IDictionary<string, int>> GetConfidenceDistributionAsync();
    Task<string> ExportToCsvAsync(IEnumerable<PidEquipment> items);
}

⚠️ 개선점 — 단계 3

  • 인터페이스를 별도 파일 IPidExtractorService.cs에 정의하나 프로젝트 패턴은 모든 인터페이스를 IExperionServices.cs에 집중. 별도 파일 생성 시 ITagMappingService도 동일 파일에 함께 두어야 함(단계 4에서 서비스 파일 내부에 중복 정의하는 것 방지).
  • ExportToCsvAsync(IEnumerable<PidEquipment>) 시그니처: 호출자가 entity 목록을 이미 로드한 상태에서 전달해야 함. 단계 10의 Controller에서 GetEquipmentAsync(tagNo, 1, int.MaxValue) 전체 메모리 로드와 연계됨 → 단계 10 개선점 참조.

단계 4: TagMappingService 생성

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

using ExperionCrawler.Core.Application.DTOs;
using ExperionCrawler.Core.Application.Interfaces;
using ExperionCrawler.Core.Domain.Entities;
using ExperionCrawler.Infrastructure.Database;
using Microsoft.EntityFrameworkCore;

namespace ExperionCrawler.Core.Application.Services;

public interface ITagMappingService
{
    Task<(int Total, IEnumerable<TagMappingDto> Items)> GetMappingsAsync(int page, int pageSize);
    Task<TagMappingResult> CreateMappingAsync(CreateMappingRequest request);
    Task UpdateMappingAsync(long id, UpdateMappingRequest request);
    Task ClearMappingAsync(long id);
    Task<IEnumerable<string>> GetAvailableTagsAsync();
    Task<int> GetMappedCountAsync();
    Task<int> GetUnmappedCountAsync();
}

public class TagMappingService : ITagMappingService
{
    private readonly ExperionDbContext _db;
    private readonly ILogger<TagMappingService> _logger;

    public TagMappingService(ExperionDbContext db, ILogger<TagMappingService> logger)
    {
        _db = db;
        _logger = logger;
    }

    public async Task<(int Total, IEnumerable<TagMappingDto> Items)> GetMappingsAsync(int page, int pageSize)
    {
        var q = from e in _db.PidEquipment
                join r in _db.RealtimePoints on e.ExperionTagId equals r.Id into gj
                from r in gj.DefaultIfEmpty()
                select new TagMappingDto
                {
                    Id = e.Id,
                    TagNo = e.TagNo,
                    EquipmentName = e.EquipmentName,
                    Confidence = e.Confidence,
                    ExperionTagId = e.ExperionTagId,
                    ExperionTagName = r?.TagName
                };

        var total = await q.CountAsync();
        var items = await q
            .OrderByDescending(e => e.Confidence)
            .Skip((page - 1) * pageSize)
            .Take(pageSize)
            .ToListAsync();

        return (total, items);
    }

    public async Task<TagMappingResult> CreateMappingAsync(CreateMappingRequest request)
    {
        var equipment = await _db.PidEquipment.FindAsync(request.PidEquipmentId);
        if (equipment == null)
            return TagMappingResult.Failure("P&ID 장비를 찾을 수 없습니다.");

        var tag = await _db.RealtimePoints.FirstOrDefaultAsync(t => t.TagName == request.ExperionTagName);
        if (tag == null)
            return TagMappingResult.Failure("Experion 태그를 찾을 수 없습니다.");

        equipment.ExperionTagId = tag.Id;
        equipment.UpdatedAt = DateTime.UtcNow;
        await _db.SaveChangesAsync();

        _logger.LogInformation("태그 매핑 생성: PidEquipmentId={Id}, ExperionTagId={TagId}", 
            request.PidEquipmentId, tag.Id);

        return TagMappingResult.Success();
    }

    public async Task UpdateMappingAsync(long id, UpdateMappingRequest request)
    {
        var equipment = await _db.PidEquipment.FindAsync(id);
        if (equipment == null) return;

        var tag = await _db.RealtimePoints.FirstOrDefaultAsync(t => t.TagName == request.ExperionTagName);
        if (tag != null)
        {
            equipment.ExperionTagId = tag.Id;
            equipment.UpdatedAt = DateTime.UtcNow;
            await _db.SaveChangesAsync();
        }
    }

    public async Task ClearMappingAsync(long id)
    {
        var equipment = await _db.PidEquipment.FindAsync(id);
        if (equipment == null) return;

        equipment.ExperionTagId = null;
        equipment.UpdatedAt = DateTime.UtcNow;
        await _db.SaveChangesAsync();
    }

    public async Task<IEnumerable<string>> GetAvailableTagsAsync()
    {
        return await _db.RealtimePoints.Select(t => t.TagName).OrderBy(t => t).ToListAsync();
    }

    public async Task<int> GetMappedCountAsync()
    {
        return await _db.PidEquipment.CountAsync(e => e.ExperionTagId.HasValue);
    }

    public async Task<int> GetUnmappedCountAsync()
    {
        return await _db.PidEquipment.CountAsync(e => !e.ExperionTagId.HasValue);
    }
}

public record TagMappingResult(bool Success, string? Message, long? Id = null)
{
    public static TagMappingResult Success(long? id = null) => new(true, null, id);
    public static TagMappingResult Failure(string message) => new(false, message);
}

public record CreateMappingRequest(long PidEquipmentId, string ExperionTagName);
public record UpdateMappingRequest(string ExperionTagName);
public record TagMappingDto(
    long Id,
    string TagNo,
    string? EquipmentName,
    double Confidence,
    int? ExperionTagId,
    string? ExperionTagName
);

⚠️ 개선점 — 단계 4

  • public interface ITagMappingService가 서비스 구현 파일 내부에 정의 — 인터페이스는 Interfaces/ 폴더 또는 IExperionServices.cs에 있어야 함.
  • TagMappingResult, CreateMappingRequest, UpdateMappingRequest, TagMappingDto record들이 서비스 파일 하단에 정의 — DTOs 파일로 이동 필요.
  • GetAvailableTagsAsync()_db.RealtimePoints 전체(2000+건)를 반환 → 매핑 모달(pidOpenModal)에서 <select>에 2000개 <option> 삽입 시 DOM 부하 발생. 기존 TagMappingService.cs는 이미 매핑된 태그를 제외한 목록만 반환하는 올바른 구현이 있음 — 기존 구현 재사용.
  • 기존 코드베이스의 TagMappingService.cs와 내용이 거의 동일 → 중복 구현 주의. 기존 파일을 교체하거나 확장하는 방식으로 작업할 것.

단계 5: PidExtractorService 생성

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

using System.Text;
using System.Text.Json;
using ExperionCrawler.Core.Application.DTOs;
using ExperionCrawler.Core.Application.Interfaces;
using ExperionCrawler.Core.Domain.Entities;
using ExperionCrawler.Infrastructure.Database;
using ExperionCrawler.Infrastructure.Mcp;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using netDxf;
using UglyToad.PdfPig;

namespace ExperionCrawler.Core.Application.Services;

public class PidExtractorService : IPidExtractorService
{
    private readonly McpClient _mcp;
    private readonly ExperionDbContext _db;
    private readonly ILogger<PidExtractorService> _logger;

    public PidExtractorService(McpClient mcp, ExperionDbContext db,
        ILogger<PidExtractorService> logger)
    {
        _mcp = mcp;
        _db = db;
        _logger = logger;
    }

    // ── 파일 진입점 ──────────────────────────────────────────────────────────

    public async Task<PidExtractionResult> ExtractFromFileAsync(string filePath)
    {
        await using var stream = File.OpenRead(filePath);
        return await ExtractFromStreamAsync(stream, Path.GetFileName(filePath));
    }

    public async Task<PidExtractionResult> ExtractFromStreamAsync(Stream stream, string fileName)
    {
        var ext = Path.GetExtension(fileName).ToLowerInvariant();

        string text = ext switch
        {
            ".dxf" => ExtractDxfText(stream),
            ".pdf" => ExtractPdfText(stream),
            _      => throw new NotSupportedException($"지원 형식: .dxf .pdf (스캔본 이미지는 Vision 모드 필요)")
        };

        if (string.IsNullOrWhiteSpace(text))
            return new PidExtractionResult(0, 0, 0);

        // MCP → vLLM 태그 추출
        var sourceType = ext.TrimStart('.');
        var json = await _mcp.ExtractPidTagsAsync(text, sourceType);
        var extractedItems = ParseJson(json);

        if (extractedItems.Count == 0)
        {
            _logger.LogWarning("P&ID 추출 결과 0건 — 파일: {FileName}", fileName);
            return new PidExtractionResult(0, 0, 0);
        }

        // MCP → vLLM 태그 매핑 제안
        var pidTagNos = extractedItems.Select(i => i.TagNo).Distinct().ToList();
        var experionTagNames = await _db.RealtimePoints.Select(r => r.TagName).ToListAsync();
        var mappingJson = await _mcp.MatchPidTagsAsync(pidTagNos, experionTagNames);
        var mappings = ParseMappingJson(mappingJson);

        // DB 저장
        var dbItems = new List<PidEquipment>();
        foreach (var item in extractedItems)
        {
            mappings.TryGetValue(item.TagNo, out var matched);
            var experionTag = matched != null
                ? await _db.RealtimePoints.FirstOrDefaultAsync(r => r.TagName == matched)
                : await FindFallbackTagAsync(item.TagNo);

            dbItems.Add(new PidEquipment
            {
                TagNo = item.TagNo,
                EquipmentName = item.EquipmentName,
                InstrumentType = item.InstrumentType,
                LineNumber = item.LineNumber,
                PidDrawingNo = item.PidDrawingNo,
                Confidence = item.Confidence,
                ExperionTagId = experionTag?.Id,
                ExtractedAt = DateTime.UtcNow,
                UpdatedAt = DateTime.UtcNow
            });
        }

        await _db.PidEquipment.AddRangeAsync(dbItems);
        await _db.SaveChangesAsync();

        _logger.LogInformation("P&ID 추출 완료: {Total}건 저장 (파일: {FileName})", dbItems.Count, fileName);

        return new PidExtractionResult(
            TotalCount: dbItems.Count,
            ConfidenceItems: dbItems.Count(i => i.Confidence >= 0.7),
            LowConfidenceItems: dbItems.Count(i => i.Confidence < 0.5));
    }

    // ── DXF 텍스트 추출 ──────────────────────────────────────────────────────

    private string ExtractDxfText(Stream stream)
    {
        // netDxf는 파일 경로를 요구하므로 임시 파일 사용
        var tmp = Path.GetTempFileName() + ".dxf";
        try
        {
            using (var fs = File.Create(tmp))
                stream.CopyTo(fs);

            var doc = DxfDocument.Load(tmp);
            var sb = new StringBuilder();

            foreach (var txt in doc.Entities.Texts)
                sb.AppendLine(txt.Value);
            foreach (var mtxt in doc.Entities.MTexts)
                sb.AppendLine(mtxt.PlainText());
            foreach (var blk in doc.Blocks)
                foreach (var attr in blk.AttributeDefinitions.Values)
                    sb.AppendLine(attr.Value);

            return sb.ToString();
        }
        finally
        {
            if (File.Exists(tmp)) File.Delete(tmp);
        }
    }

    // ── PDF 텍스트 추출 ──────────────────────────────────────────────────────

    private string ExtractPdfText(Stream stream)
    {
        using var pdf = PdfDocument.Open(stream);
        var sb = new StringBuilder();
        foreach (var page in pdf.GetPages())
            sb.AppendLine(page.Text);
        return sb.ToString();
    }

    // ── JSON 파싱 ─────────────────────────────────────────────────────────────

    private List<ExtractedItem> ParseJson(string json)
    {
        try
        {
            return JsonSerializer.Deserialize<List<ExtractedItem>>(json,
                new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? [];
        }
        catch (Exception ex)
        {
            _logger.LogWarning("P&ID JSON 파싱 실패: {Msg} / raw: {Raw}", ex.Message, json[..Math.Min(200, json.Length)]);
            return [];
        }
    }

    private Dictionary<string, string> ParseMappingJson(string json)
    {
        try
        {
            var list = JsonSerializer.Deserialize<List<MappingItem>>(json,
                new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? [];
            return list
                .Where(m => m.Confidence >= 0.7 && !string.IsNullOrEmpty(m.ExperionTag))
                .ToDictionary(m => m.PidTag, m => m.ExperionTag!);
        }
        catch { return []; }
    }

    // ── Fallback 매칭 (LLM 불확실 시) ───────────────────────────────────────

    private async Task<RealtimePoint?> FindFallbackTagAsync(string tagNo)
    {
        var normalized = tagNo.Split('.')[0];
        return await _db.RealtimePoints
            .FirstOrDefaultAsync(t => t.TagName == normalized
                                   || t.TagName.StartsWith(normalized + "."));
    }

    // ── CRUD / 통계 (기존 유지) ───────────────────────────────────────────────

    public async Task<(int Total, IEnumerable<PidEquipment> Items)> GetEquipmentAsync(
        string? tagNo, int page, int pageSize)
    {
        var q = _db.PidEquipment.AsQueryable();
        if (!string.IsNullOrEmpty(tagNo))
            q = q.Where(e => e.TagNo.Contains(tagNo));
        var total = await q.CountAsync();
        var items = await q.OrderByDescending(e => e.ExtractedAt)
                           .Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
        return (total, items);
    }

    public async Task<PidEquipment?> GetByIdAsync(long id)
        => await _db.PidEquipment.Include(e => e.ExperionTag).FirstOrDefaultAsync(e => e.Id == id);

    public async Task UpdateConfidenceAsync(long id, double confidence)
    {
        var e = await _db.PidEquipment.FindAsync(id);
        if (e == null) return;
        e.Confidence = confidence; e.UpdatedAt = DateTime.UtcNow;
        await _db.SaveChangesAsync();
    }

    public async Task ActivateAsync(long id)
    {
        var e = await _db.PidEquipment.FindAsync(id);
        if (e == null) return;
        e.IsActive = true; e.UpdatedAt = DateTime.UtcNow;
        await _db.SaveChangesAsync();
    }

    public async Task DeactivateAsync(long id)
    {
        var e = await _db.PidEquipment.FindAsync(id);
        if (e == null) return;
        e.IsActive = false; e.UpdatedAt = DateTime.UtcNow;
        await _db.SaveChangesAsync();
    }

    public Task<int> GetTotalCountAsync() => _db.PidEquipment.CountAsync();
    public Task<int> GetConfidenceItemsCountAsync() => _db.PidEquipment.CountAsync(e => e.Confidence >= 0.7);
    public Task<int> GetLowConfidenceItemsCountAsync() => _db.PidEquipment.CountAsync(e => e.Confidence < 0.5);
    public Task<int> GetDrawingCountAsync() => _db.PidEquipment.Select(e => e.PidDrawingNo).Distinct().CountAsync();

    public async Task<IDictionary<string, int>> GetConfidenceDistributionAsync()
    {
        var items = await _db.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<string> ExportToCsvAsync(IEnumerable<PidEquipment> items)
    {
        var sb = new StringBuilder();
        sb.AppendLine("TagNo,EquipmentName,InstrumentType,LineNumber,PidDrawingNo,Confidence,IsActive,ExtractedAt,ExperionTagId");
        foreach (var i in items)
            sb.AppendLine($"{Csv(i.TagNo)},{Csv(i.EquipmentName)},{Csv(i.InstrumentType)},{Csv(i.LineNumber)},{Csv(i.PidDrawingNo)},{i.Confidence},{i.IsActive},{i.ExtractedAt:O},{i.ExperionTagId}");
        return sb.ToString();
    }

    private static string Csv(string? v)
    {
        if (string.IsNullOrEmpty(v)) return "";
        return (v.Contains(',') || v.Contains('"') || v.Contains('\n'))
            ? $"\"{v.Replace("\"", "\"\"")}\"" : v;
    }
}

// ── 내부 파싱용 모델 ──────────────────────────────────────────────────────────
file class ExtractedItem
{
    public string TagNo { get; set; } = "";
    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; } = 0.5;
}

file class MappingItem
{
    public string PidTag { get; set; } = "";
    public string? ExperionTag { get; set; }
    public double Confidence { get; set; }
}

⚠️ 개선점 — 단계 5

  • N+1 문제: foreach 내부에서 _db.RealtimePoints.FirstOrDefaultAsync() 호출 → 추출된 태그 수만큼 DB 쿼리 발생.
    // 개선: foreach 전에 Experion 태그를 Dictionary로 메모리 캐싱
    var tagMap = await _db.RealtimePoints.ToDictionaryAsync(r => r.TagName, r => r);
    // foreach 내: tagMap.TryGetValue(matched, out var experionTag)
    
  • 중복 삽입 방지 없음: 동일 도면 파일 재업로드 시 pid_equipment에 중복 TagNo 행이 계속 추가됨. PidDrawingNo 기준으로 기존 데이터 삭제 후 재삽입(upsert) 또는 중복 체크 로직 필요.
  • 단계 2 DTOs의 public record ExtractedItem/MappingItem과 이 파일 하단 file class ExtractedItem/MappingItem이 중복 정의 → 단계 2에서 제거해야 컴파일 통과.

단계 6: McpClient 확장

파일: src/Infrastructure/Mcp/McpClient.cs

기존 QueryWithNlAsync 아래에 추가:

public Task<string> ExtractPidTagsAsync(string text, string sourceType) =>
    CallToolAsync("extract_pid_tags", new Dictionary<string, object>
    {
        ["text"] = text,
        ["source_type"] = sourceType   // "dxf" | "pdf"
    });

public Task<string> MatchPidTagsAsync(IEnumerable<string> pidTags, IEnumerable<string> experionTags) =>
    CallToolAsync("match_pid_tags", new Dictionary<string, object>
    {
        ["pid_tags"] = pidTags.ToList(),
        ["experion_tags"] = experionTags.ToList()
    });

⚠️ 개선점 — 단계 6

  • 코드 자체는 문제 없음. 단, experionTags.ToList() 시 2000+건 전체가 JSON 페이로드에 포함됨 — 단계 7에서 experion_tags[:300] 슬라이싱으로 제한하므로 C# 쪽에서도 상위 N건만 전달하도록 고려.

단계 7: Python MCP 서버 확장

파일: Python MCP 서버 (localhost:5001)

기존 툴 목록 끝에 두 툴 추가:

@mcp.tool()
def extract_pid_tags(text: str, source_type: str = "dxf") -> str:
    """DXF/PDF 텍스트에서 P&ID 계기/장비 태그 추출. JSON 배열 반환."""
    prompt = f"""다음 {source_type.upper()} 텍스트에서 P&ID 계기/장비 태그 정보를 추출하세요.
반드시 순수 JSON 배열만 응답하세요 (마크다운, 설명 금지):
[{"{"}"tagNo":"FT-101","equipmentName":"Flow Transmitter","instrumentType":"FT","lineNumber":"6P-1001","confidence":0.95{"}"}}]

추출 규칙:
- tagNo: 계기 태그 번호 (예: FT-101, PT-2003, LT-100A)
- instrumentType: 태그 번호 앞 영문자 부분 (FT, PT, LT, CV, E, V, P 등)
- confidence: 텍스트에서 명확히 식별된 경우 0.9↑, 불확실한 경우 0.5↓
- 태그가 없는 텍스트(범례, 제목, 주석)는 무시

텍스트:
{text[:12000]}"""

    response = llm_client.chat.completions.create(
        model="Qwen/Qwen3-Coder-Next-FP8",
        messages=[{"role": "user", "content": prompt}],
        temperature=0.1,
        max_tokens=4096
    )
    raw = response.choices[0].message.content.strip()
    # 코드펜스 제거
    import re
    match = re.search(r'\[.*\]', raw, re.DOTALL)
    return match.group(0) if match else "[]"


@mcp.tool()
def match_pid_tags(pid_tags: list[str], experion_tags: list[str]) -> str:
    """P&ID 태그명 → Experion 태그명 자동 매핑 제안. JSON 배열 반환."""
    prompt = f"""P&ID 태그 목록을 Experion 태그 목록과 매핑하세요.
반드시 순수 JSON 배열만 응답하세요:
[{"{"}"pidTag":"FT-101","experionTag":"FT-101.PV","confidence":0.92{"}"}}]

규칙:
- 동일하거나 유사한 태그번호를 매핑 (대소문자 무시, 하이픈/언더스코어 무시)
- FT-101 → FT-101.PV 처럼 접미사(.PV .SV 등)가 붙는 패턴 우선
- 매핑 불가능하면 해당 항목 제외
- confidence: 완전 일치 1.0, 유사 매칭 0.7~0.9

P&ID 태그: {pid_tags}
Experion 태그 (샘플): {experion_tags[:300]}"""

    response = llm_client.chat.completions.create(
        model="Qwen/Qwen3-Coder-Next-FP8",
        messages=[{"role": "user", "content": prompt}],
        temperature=0.1,
        max_tokens=2048
    )
    raw = response.choices[0].message.content.strip()
    import re
    match = re.search(r'\[.*\]', raw, re.DOTALL)
    return match.group(0) if match else "[]"

⚠️ 개선점 — 단계 7

  • import re가 두 함수 내부에서 각각 선언 → 파일 상단으로 이동.
  • experion_tags[:300] — 리스트의 300번째 아이템까지 슬라이스 (문자 수가 아님). 2000개 태그 중 1700개 누락 가능. 태그번호 형식 기반 필터링 등 다른 샘플링 전략 검토 필요.
  • match 변수명이 Python 3.10+ match 문(패턴 매칭 키워드)과 혼동 가능 → m = re.search(...)m.group(0) 패턴으로 변경 권장.

단계 8: ExperionDbContext 확장

파일: src/Infrastructure/Database/ExperionDbContext.cs

기존 DbSet에 추가:

public DbSet<PidEquipment> PidEquipment => Set<PidEquipment>();
public DbSet<PidAuditLog> PidAuditLog => Set<PidAuditLog>();

⚠️ 개선점 — 단계 8

  • 이미 완료: DbSet<PidEquipment>, DbSet<PidAuditLog>OnModelCreating FK/인덱스 설정이 ExperionDbContext.cs에 이미 존재. 재추가 불필요.
  • 플랜에 OnModelCreating FK 관계 설정이 언급되지 않았으나 실제 DB 제약 생성에 필요 — 코드베이스 현황 확인 후 빠진 설정이 있으면 추가할 것.

단계 9: Program.cs 확장

파일: src/Web/Program.cs

기존 서비스 등록에 추가:

// P&ID Services
builder.Services.AddScoped<IPidExtractorService, PidExtractorService>();
builder.Services.AddScoped<ITagMappingService, TagMappingService>();

⚠️ 개선점 — 단계 9

  • 이미 완료: Program.cs 85~86번째 줄에 IPidExtractorService, ITagMappingService 이미 등록됨. 재추가 불필요.
  • 단계 4에서 TagMappingService를 새 버전으로 교체할 경우, 등록된 구현체가 올바른 파일을 참조하는지 확인 필요.

단계 10: PidController 추가

파일: src/Web/Controllers/ExperionControllers.cs

파일 끝에 추가:

// ── P&ID Controller ──────────────────────────────────────────────────────────
[ApiController]
[Route("api/pid")]
public class ExperionPidController : ControllerBase
{
    private readonly IPidExtractorService _extractor;
    private readonly ITagMappingService _mapping;

    public ExperionPidController(IPidExtractorService extractor, ITagMappingService mapping)
    {
        _extractor = extractor;
        _mapping = mapping;
    }

    [HttpPost("extract")]
    [RequestSizeLimit(100 * 1024 * 1024)]
    public async Task<IActionResult> Extract(IFormFile file)
    {
        if (file == null || file.Length == 0)
            return BadRequest(new { error = "파일 없음" });

        var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
        if (ext != ".dxf" && ext != ".pdf")
            return BadRequest(new { error = "지원 형식: .dxf .pdf" });

        using var stream = file.OpenReadStream();
        var result = await _extractor.ExtractFromStreamAsync(stream, file.FileName);
        return Ok(result);
    }

    [HttpGet("equipment")]
    public async Task<IActionResult> GetEquipment(
        [FromQuery] string? tagNo, [FromQuery] int page = 1, [FromQuery] int pageSize = 50)
    {
        var (total, items) = await _extractor.GetEquipmentAsync(tagNo, page, pageSize);
        return Ok(new { total, page, pageSize, items });
    }

    [HttpGet("statistics")]
    public async Task<IActionResult> GetStatistics()
    {
        return Ok(new
        {
            total = await _extractor.GetTotalCountAsync(),
            highConfidence = await _extractor.GetConfidenceItemsCountAsync(),
            lowConfidence = await _extractor.GetLowConfidenceItemsCountAsync(),
            drawingCount = await _extractor.GetDrawingCountAsync(),
            mapped = await _mapping.GetMappedCountAsync(),
            unmapped = await _mapping.GetUnmappedCountAsync(),
            distribution = await _extractor.GetConfidenceDistributionAsync()
        });
    }

    [HttpPut("{id:long}/confidence")]
    public async Task<IActionResult> UpdateConfidence(long id, [FromBody] double confidence)
    {
        if (confidence < 0 || confidence > 1)
            return BadRequest(new { error = "신뢰도는 0~1 범위" });
        await _extractor.UpdateConfidenceAsync(id, confidence);
        return Ok(new { message = "업데이트 완료" });
    }

    [HttpPost("{id:long}/activate")]
    public async Task<IActionResult> Activate(long id)
    {
        await _extractor.ActivateAsync(id);
        return Ok(new { message = "활성화 완료" });
    }

    [HttpPost("{id:long}/deactivate")]
    public async Task<IActionResult> Deactivate(long id)
    {
        await _extractor.DeactivateAsync(id);
        return Ok(new { message = "비활성화 완료" });
    }

    // ── 매핑 API ──
    [HttpGet("mappings")]
    public async Task<IActionResult> GetMappings([FromQuery] int page = 1, [FromQuery] int pageSize = 50)
    {
        var (total, items) = await _mapping.GetMappingsAsync(page, pageSize);
        return Ok(new { total, page, pageSize, items });
    }

    [HttpPost("mappings")]
    public async Task<IActionResult> CreateMapping([FromBody] CreateMappingRequest req)
    {
        var result = await _mapping.CreateMappingAsync(req);
        return Ok(result);
    }

    [HttpPut("mappings/{id:long}")]
    public async Task<IActionResult> UpdateMapping(long id, [FromBody] UpdateMappingRequest req)
    {
        await _mapping.UpdateMappingAsync(id, req);
        return Ok(new { message = "매핑 업데이트 완료" });
    }

    [HttpDelete("mappings/{id:long}")]
    public async Task<IActionResult> ClearMapping(long id)
    {
        await _mapping.ClearMappingAsync(id);
        return Ok(new { message = "매핑 해제 완료" });
    }

    [HttpGet("mappings/available-tags")]
    public async Task<IActionResult> GetAvailableTags()
    {
        var tags = await _mapping.GetAvailableTagsAsync();
        return Ok(new { tags });
    }

    [HttpGet("export/csv")]
    public async Task<IActionResult> ExportCsv([FromQuery] string? tagNo)
    {
        var (_, items) = await _extractor.GetEquipmentAsync(tagNo, 1, int.MaxValue);
        var csv = await _extractor.ExportToCsvAsync(items);
        var bytes = System.Text.Encoding.UTF8.GetBytes(csv);
        return File(bytes, "text/csv", $"pid-equipment-{DateTime.Now:yyyyMMdd}.csv");
    }
}

⚠️ 개선점 — 단계 10

  • ExportCsv 액션에서 GetEquipmentAsync(tagNo, 1, int.MaxValue) — 전체 레코드를 메모리에 한 번에 로드. 수만 건 이상 시 OOM 가능. _db.PidEquipment.Where(...).AsAsyncEnumerable()로 스트리밍하거나 Response.Body에 직접 쓰는 방식 권장.
  • [RequestSizeLimit(100 * 1024 * 1024)] 어트리뷰트 단독으로는 부족 — Kestrel 레벨 설정(단계 14)도 함께 적용해야 실제 100MB 업로드가 허용됨.

단계 11: Frontend — index.html (P&ID 탭 추가)

파일: src/Web/wwwroot/index.html

사이드바 nav-item 추가 (기존 마지막 탭 data-tab="fast" 뒤에):

<li class="nav-item" data-tab="pid">
  <span class="nav-num">11</span>
  <span class="nav-lbl">P&ID AX</span>
</li>

pane 추가 (#pane-fast 섹션 뒤에):

<section class="pane" id="pane-pid">
  <header class="pane-hdr">
    <div class="pane-title">P&ID AX</div>
    <div class="pane-tag">DXF / PDF → LLM → TAG</div>
  </header>

  <!-- 추출 섹션 -->
  <div class="pid-section">
    <div class="pid-row">
      <label class="pid-lbl">도면 파일 (.dxf / .pdf 텍스트)</label>
      <input type="file" id="pid-file" accept=".dxf,.pdf" class="pid-file-input">
      <button class="btn" onclick="pidExtract()">▶ 추출 시작</button>
    </div>
    <div id="pid-log" class="log hidden"></div>
  </div>

  <!-- 결과 카드 -->
  <div class="pid-stat-row" id="pid-stat-row" style="display:none">
    <div class="srv-status-card">
      <div class="kv"><span class="kk">총 추출</span><span class="kv2" id="pid-s-total">0</span></div>
      <div class="kv"><span class="kk">신뢰도 ≥70%</span><span class="kv2 ok" id="pid-s-high">0</span></div>
      <div class="kv"><span class="kk">신뢰도 <50%</span><span class="kv2 err" id="pid-s-low">0</span></div>
    </div>
  </div>

  <!-- 장비 목록 -->
  <div class="pid-section">
    <div class="pid-row">
      <input type="text" id="pid-search" class="ipt" placeholder="태그 번호 검색..." style="width:200px">
      <button class="btn" onclick="pidLoadEquipment(1)">▼ 조회</button>
      <button class="btn btn-sub" onclick="pidExportCsv()">CSV 내보내기</button>
      <span id="pid-eq-total" class="pid-count"></span>
    </div>
    <div class="tbl-wrap">
      <table class="tbl">
        <thead>
          <tr>
            <th>태그번호</th><th>장비명</th><th>계기유형</th>
            <th>라인번호</th><th>도면번호</th><th>신뢰도</th>
            <th>상태</th><th>Experion 태그</th><th>작업</th>
          </tr>
        </thead>
        <tbody id="pid-eq-body"></tbody>
      </table>
    </div>
    <div id="pid-eq-pg" class="pg"></div>
  </div>

  <!-- 매핑 관리 -->
  <div class="pid-section">
    <div class="pid-row">
      <span class="pid-lbl">태그 매핑</span>
      <button class="btn" onclick="pidLoadMappings(1)">▼ 매핑 목록</button>
    </div>
    <div class="tbl-wrap">
      <table class="tbl">
        <thead>
          <tr>
            <th>태그번호</th><th>장비명</th><th>신뢰도</th>
            <th>Experion 태그</th><th>작업</th>
          </tr>
        </thead>
        <tbody id="pid-map-body"></tbody>
      </table>
    </div>
    <div id="pid-map-pg" class="pg"></div>
  </div>

  <!-- 매핑 모달 -->
  <div id="pid-modal" class="modal-overlay" style="display:none" onclick="pidCloseModal()">
    <div class="modal-box" onclick="event.stopPropagation()">
      <div class="modal-hdr">태그 매핑</div>
      <input type="hidden" id="pid-modal-id">
      <div class="kv">
        <span class="kk">P&ID 태그</span>
        <span class="kv2" id="pid-modal-tag"></span>
      </div>
      <div class="kv">
        <span class="kk">Experion 태그</span>
        <select id="pid-modal-select" class="ipt" style="width:260px"></select>
      </div>
      <div class="modal-ftr">
        <button class="btn" onclick="pidConfirmMapping()">매핑</button>
        <button class="btn btn-sub" onclick="pidCloseModal()">취소</button>
      </div>
    </div>
  </div>
</section>

⚠️ 개선점 — 단계 11

  • 신뢰도 <50% 텍스트에서 < → HTML에서 &lt;로 이스케이프 필요 (<span class="kk">신뢰도 &lt;50%</span>).
  • P&ID AX, P&ID 태그& 문자 → &amp;로 이스케이프 (일부 위치에서 누락, 일관성 확보 필요).
  • srv-status-card CSS 클래스는 OPC UA 서버 탭 전용으로 추가된 클래스 — P&ID 탭에서 재사용하면 의미 혼동 가능. pid-stat-card 같은 전용 클래스 신설 권장.

단계 12: Frontend — app.js (P&ID 함수 추가)

파일: src/Web/wwwroot/js/app.js

기존 탭 핸들러(app.js:15-17)에 P&ID 탭 추가:

if (tab === 'pid') { /* 탭 진입 시 API 호출 없음 */ }

파일 끝에 P&ID 함수 추가:

/* ─────────────────────────────────────────────────────────────
   11  P&ID AX
───────────────────────────────────────────────────────────── */
let _pidEqPage = 1;
let _pidMapPage = 1;
const PID_PAGE = 50;

async function pidExtract() {
  const fileInput = document.getElementById('pid-file');
  const file = fileInput.files[0];
  if (!file) { log('pid-log', [{ c: 'err', t: '파일을 선택하세요.' }]); return; }

  const ext = file.name.split('.').pop().toLowerCase();
  if (ext !== 'dxf' && ext !== 'pdf') {
    log('pid-log', [{ c: 'err', t: '지원 형식: .dxf .pdf (스캔본 이미지는 미지원)' }]);
    return;
  }

  log('pid-log', [{ c: 'inf', t: `▶ 추출 중... (${file.name})` }]);
  setGlobal('busy', '추출 중');

  try {
    const fd = new FormData();
    fd.append('file', file);
    const res = await fetch('/api/pid/extract', { method: 'POST', body: fd });
    if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
    const d = await res.json();

    document.getElementById('pid-s-total').textContent = d.totalCount;
    document.getElementById('pid-s-high').textContent = d.confidenceItems;
    document.getElementById('pid-s-low').textContent = d.lowConfidenceItems;
    document.getElementById('pid-stat-row').style.display = '';

    log('pid-log', [
      { c: 'ok', t: `✅ 추출 완료: ${d.totalCount}건` },
      { c: 'inf', t: `   신뢰도 ≥70%: ${d.confidenceItems}건` },
      { c: 'inf', t: `   신뢰도 <50%: ${d.lowConfidenceItems}건` }
    ]);
    setGlobal('ok', '추출 완료');
    pidLoadEquipment(1);
  } catch (e) {
    log('pid-log', [{ c: 'err', t: '❌ ' + e.message }]);
    setGlobal('err', '오류');
  }
}

async function pidLoadEquipment(page = 1) {
  _pidEqPage = page;
  const tagNo = document.getElementById('pid-search').value.trim();
  let url = `/api/pid/equipment?page=${page}&pageSize=${PID_PAGE}`;
  if (tagNo) url += `&tagNo=${encodeURIComponent(tagNo)}`;

  try {
    const res = await fetch(url);
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    const d = await res.json();

    document.getElementById('pid-eq-total').textContent = `총 ${d.total}건`;

    const rows = d.items.map(i => {
      const cls = i.confidence >= 0.7 ? 'ok' : i.confidence < 0.5 ? 'err' : 'warn';
      return `<tr>
        <td>${esc(i.tagNo)}</td>
        <td>${esc(i.equipmentName||'')}</td>
        <td>${esc(i.instrumentType||'')}</td>
        <td>${esc(i.lineNumber||'')}</td>
        <td>${esc(i.pidDrawingNo||'')}</td>
        <td class="${cls}">${(i.confidence*100).toFixed(0)}%</td>
        <td>${i.isActive ? '활성' : '<span class="err">비활성</span>'}</td>
        <td>${esc(i.experionTagName||'미매핑')}</td>
        <td>
          ${i.isActive
            ? `<button class="btn-sm" onclick="pidDeactivate(${i.id})">비활성화</button>`
            : `<button class="btn-sm" onclick="pidActivate(${i.id})">활성화</button>`}
          <button class="btn-sm" onclick="pidOpenModal(${i.id},'${esc(i.tagNo)}')">매핑</button>
        </td>
      </tr>`;
    }).join('');

    document.getElementById('pid-eq-body').innerHTML = rows || '<tr><td colspan="9">데이터 없음</td></tr>';
    pidPagination('pid-eq-pg', d.total, page, PID_PAGE, pidLoadEquipment);
  } catch (e) {
    console.error('장비 목록 로드 실패:', e);
  }
}

async function pidLoadMappings(page = 1) {
  _pidMapPage = page;
  try {
    const res = await fetch(`/api/pid/mappings?page=${page}&pageSize=${PID_PAGE}`);
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    const d = await res.json();

    const rows = d.items.map(i => {
      const cls = i.confidence >= 0.7 ? 'ok' : i.confidence < 0.5 ? 'err' : 'warn';
      return `<tr>
        <td>${esc(i.tagNo)}</td>
        <td>${esc(i.equipmentName||'')}</td>
        <td class="${cls}">${(i.confidence*100).toFixed(0)}%</td>
        <td>${esc(i.experionTagName||'미매핑')}</td>
        <td>
          ${i.experionTagId
            ? `<button class="btn-sm" onclick="pidClearMapping(${i.pidEquipmentId})">해제</button>`
            : `<button class="btn-sm" onclick="pidOpenModal(${i.pidEquipmentId},'${esc(i.tagNo)}')">매핑</button>`}
        </td>
      </tr>`;
    }).join('');

    document.getElementById('pid-map-body').innerHTML = rows || '<tr><td colspan="5">데이터 없음</td></tr>';
    pidPagination('pid-map-pg', d.total, page, PID_PAGE, pidLoadMappings);
  } catch (e) {
    console.error('매핑 목록 로드 실패:', e);
  }
}

async function pidActivate(id) { await fetch(`/api/pid/${id}/activate`, { method: 'POST' }); pidLoadEquipment(_pidEqPage); }
async function pidDeactivate(id) { await fetch(`/api/pid/${id}/deactivate`, { method: 'POST' }); pidLoadEquipment(_pidEqPage); }

async function pidClearMapping(id) {
  await fetch(`/api/pid/mappings/${id}`, { method: 'DELETE' });
  pidLoadMappings(_pidMapPage);
  pidLoadEquipment(_pidEqPage);
}

async function pidOpenModal(id, tagNo) {
  document.getElementById('pid-modal-id').value = id;
  document.getElementById('pid-modal-tag').textContent = tagNo;

  const res = await fetch('/api/pid/mappings/available-tags');
  const d = await res.json();
  const sel = document.getElementById('pid-modal-select');
  sel.innerHTML = '<option value="">-- 선택 --</option>' +
    (d.tags||[]).map(t => `<option value="${esc(t)}">${esc(t)}</option>`).join('');

  document.getElementById('pid-modal').style.display = 'flex';
}

function pidCloseModal() { document.getElementById('pid-modal').style.display = 'none'; }

async function pidConfirmMapping() {
  const id = parseInt(document.getElementById('pid-modal-id').value);
  const tagName = document.getElementById('pid-modal-select').value;
  if (!tagName) { alert('태그를 선택하세요.'); return; }

  await fetch('/api/pid/mappings', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ pidEquipmentId: id, experionTagName: tagName })
  });
  pidCloseModal();
  pidLoadMappings(_pidMapPage);
  pidLoadEquipment(_pidEqPage);
}

async function pidExportCsv() {
  const tagNo = document.getElementById('pid-search').value.trim();
  let url = '/api/pid/export/csv';
  if (tagNo) url += `?tagNo=${encodeURIComponent(tagNo)}`;
  window.location.href = url;
}

function pidPagination(elId, total, current, pageSize, fn) {
  const pages = Math.ceil(total / pageSize);
  if (pages <= 1) { document.getElementById(elId).innerHTML = ''; return; }
  const from = Math.max(1, current - 3);
  const to = Math.min(pages, current + 3);
  let html = '';
  if (from > 1) html += `<button class="pg-btn" onclick="${fn.name}(1)">«</button>`;
  for (let i = from; i <= to; i++)
    html += `<button class="pg-btn${i===current?' active':''}" onclick="${fn.name}(${i})">${i}</button>`;
  if (to < pages) html += `<button class="pg-btn" onclick="${fn.name}(${pages})">»</button>`;
  document.getElementById(elId).innerHTML = html;
}

⚠️ 개선점 — 단계 12

  • pidLoadMappings 함수 내 i.pidEquipmentId 사용 — TagMappingDto 의 필드명은 Id이므로 i.id 를 사용해야 함. pidEquipmentId는 존재하지 않는 필드 → undefined 전달로 매핑 해제/모달 오동작.
  • onclick="pidOpenModal(${i.id},'${esc(i.tagNo)}')"esc()는 HTML 이스케이프만 수행. tagNo에 작은따옴표(')가 포함될 경우 인라인 JS 문자열 파싱 오류 발생. data-id, data-tagno 속성으로 값을 전달하고 이벤트 위임 방식 사용 권장.

단계 13: Frontend — style.css (P&ID 스타일 추가)

파일: src/Web/wwwroot/css/style.css

파일 끝에 추가:

/* ── P&ID AX ─────────────────────────────────────────────────────────────── */
.pid-section   { margin-bottom: 20px; }
.pid-row       { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; flex-wrap: wrap; }
.pid-lbl       { font-size: 13px; color: #aaa; }
.pid-count     { font-size: 12px; color: #888; }
.pid-stat-row  { display: flex; gap: 12px; margin-bottom: 16px; }
.pid-file-input { color: #ccc; background: #2d2d2d; border: 1px solid #444;
                  padding: 4px 8px; border-radius: 4px; font-size: 12px; }

.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.6);
                 display: flex; align-items: center; justify-content: center; z-index: 1000; }
.modal-box     { background: #2d2d2d; border: 1px solid #555; border-radius: 6px;
                 padding: 20px; min-width: 340px; }
.modal-hdr     { font-size: 14px; font-weight: bold; color: #ddd; margin-bottom: 14px; }
.modal-ftr     { display: flex; gap: 8px; margin-top: 14px; justify-content: flex-end; }

⚠️ 개선점 — 단계 13

  • .modal-overlay, .modal-box, .modal-hdr, .modal-ftr — 기존 style.css에 없는 신규 클래스, 추가 필요 (정상).
  • .btn-sm, .tbl-wrap, .pg, .ipt, .btn, .ok, .err, .warn — 이미 style.css에 존재. 중복 정의 금지, 기존 스타일 그대로 재사용할 것.
  • pid-stat-rowdisplay: flex로 정의되어 있으나 HTML에서 style="display:none" 초기 숨김 처리 — JS에서 style.display = ''로 복원 시 flex 레이아웃으로 표시됨, 정상.

단계 14: appsettings.json — Kestrel 업로드 크기 설정

파일: src/Web/appsettings.json

기존 설정에 병합 추가:

{
  "Kestrel": {
    "Limits": {
      "MaxRequestBodySize": 104857600
    }
  }
}

Anthropic API 키 설정은 불필요 — 로컬 LLM 사용.

⚠️ 개선점 — 단계 14

  • Kestrel 설정이 현재 appsettings.json에 없음 (확인됨). 이 설정 없이 단계 10의 [RequestSizeLimit(100MB)] 어트리뷰트만 있으면 기본 Kestrel 최대 요청 크기(30MB)에서 막힘.
  • 기존 JSON에 병합 시 최상위에 "Kestrel" 키가 중복되지 않도록 주의. 기존 설정("Logging", "ConnectionStrings" 등)과 같은 레벨에 추가할 것.

🔮 Vision 모드 (미래 계획 — 현재 미구현)

조건: Vision 기능을 가진 로컬 모델(Qwen2-VL, InternVL 등) 또는 별도 Vision API 도입 시 구현

추가될 파일 처리 경로

입력 처리 방법 상태
.dxf netDxf 텍스트 추출 → MCP 현재 구현
.pdf (텍스트) PdfPig 텍스트 추출 → MCP 현재 구현
.pdf (스캔본) 페이지 이미지 변환 → Vision LLM 🔮 미래
.png .jpg 이미지 그대로 → Vision LLM 🔮 미래

미래 구현 시 추가 작업

1. MCP 서버에 analyze_pid_image(image_base64) 툴 추가
   → Vision 모델 엔드포인트 호출 (별도 포트 또는 vLLM vision 모드)

2. PidExtractorService.ExtractFromStreamAsync에 분기 추가:
   ".pdf" → PdfPig 텍스트 추출 시도 → 텍스트 없으면 이미지 모드 fallback
   ".png"/".jpg" → 이미지 모드 직행

3. 업로드 UI에 "이미지 모드" 체크박스 활성화

📋 구현 순서 및 체크리스트

Phase 1 — 백엔드 (2-3일)

  • 단계 1: ExperionCrawler.csprojnetDxf, PdfPig 패키지 추가 후 dotnet build 확인
  • 단계 2: McpClient.csExtractPidTagsAsync, MatchPidTagsAsync 메서드 추가
  • 단계 3: Python MCP 서버에 extract_pid_tags, match_pid_tags 툴 추가 및 테스트
    • curl로 직접 툴 호출하여 JSON 응답 확인
  • 단계 4: PidExtractorService.cs 전체 교체
    • _anthropicApiKey 의존성 제거 확인
    • DXF 파싱 단위 테스트: 실제 DXF 파일로 텍스트 추출 확인
    • PDF 파싱 단위 테스트: 텍스트 기반 PDF로 추출 확인
  • 단계 5: ExperionControllers.csExperionPidController 추가
    • dotnet build 에러 0건 확인
    • Swagger(/swagger)에서 /api/pid/* 엔드포인트 노출 확인

Phase 2 — 프론트엔드 (1-2일)

  • 단계 6: index.html에 P&ID 탭 + pane 추가
  • 단계 7: app.js에 P&ID 함수 추가
    • 탭 핸들러(app.js:6-18)에 if (tab === 'pid') 줄 추가
  • 단계 8: style.css에 P&ID 스타일 추가
  • 단계 9: appsettings.json Kestrel 설정 추가

Phase 3 — 통합 테스트 (1-2일)

  • 실제 DXF 파일 업로드 → 태그 추출 확인
  • 실제 PDF(텍스트) 파일 업로드 → 태그 추출 확인
  • 스캔본 PDF 업로드 시 명확한 에러 메시지 확인("지원 형식: .dxf .pdf")
  • 추출 결과 → Experion 태그 자동 매핑 제안 확인
  • 수동 매핑 UI 동작 확인
  • CSV 내보내기 확인
  • MCP 서버 다운 시 에러 처리 확인

⚠️ 주요 주의사항

항목 내용
MCP 서버 의존성 localhost:5001 응답 없으면 추출 실패 — UI에서 ping 사전 확인 권장
netDxf 임시 파일 DXF 파싱 시 /tmp에 임시 파일 생성/삭제 — 권한 및 디스크 여유 확인
PDF 텍스트 추출 실패 스캔본 PDF는 ExtractPdfText()가 빈 문자열 반환 → Vision 미구현 안내 메시지
프롬프트 튜닝 도면 특성(한국어/영어, 태그 표기 방식)에 따라 MCP 서버 프롬프트 조정 필요
대용량 DXF 1만 개 이상 엔티티 시 LLM 컨텍스트 초과 가능 → text[:12000] 슬라이싱으로 제한 중
태그 매핑 확신도 confidence < 0.7 자동 매핑은 저장하지 않음 — 수동 매핑 유도

📝 코딩 가이드

1. 기존 패턴 엄수

새 기능 추가 시 반드시 기존 코드 패턴을 따를 것

✅ 엔티티:       [Table("테이블명")], [Column("컬럼명")] 어트리뷰트 필수
✅ DbContext:    ExperionDbContext 단일 컨텍스트 — 별도 DbContext 생성 금지
✅ 서비스 등록:  Program.cs에 AddScoped<Interface, Implementation>() 형태
✅ 컨트롤러:     ExperionControllers.cs 단일 파일에 추가 (별도 파일 생성 금지)
✅ 탭 진입:      API 자동 호출 금지 — 버튼 클릭으로만 동작
✅ DOM 렌더링:   innerHTML += 루프 금지 — rows 배열 .join('') 후 한번에 설정
✅ 로깅:         Console.WriteLine 금지 — ILogger<T> 사용

2. MCP 서버 연동 패턴

C# 서비스에서 MCP 호출 시 McpClient를 직접 주입.
IMcpService를 통하지 않아도 됨 (McpClient는 Singleton 등록).
// 올바른 패턴
public MyService(McpClient mcp, ExperionDbContext db, ILogger<MyService> logger)

// 금지
public MyService(IMcpService mcp)  // 래핑 레이어가 필요할 때만 사용

3. Python MCP 툴 작성 규칙

# 응답은 반드시 순수 JSON 문자열 반환
# 코드펜스(```json) 제거 후 반환
# LLM 응답에서 JSON 배열 추출: re.search(r'\[.*\]', raw, re.DOTALL)
# temperature=0.1 고정 (결정론적 출력)
# 텍스트 슬라이싱: text[:12000] (컨텍스트 초과 방지)

4. 프론트엔드 규칙

✅ 함수 기반 작성 — class 사용 금지
✅ 기존 헬퍼 함수 재사용: esc(), log(), setGlobal(), api()
✅ 페이지네이션: pidPagination() 패턴 — 전체 페이지 버튼 생성 금지 (±3 범위)
✅ 다크 테마 색상 사용: #1e1e1e, #2d2d2d, #ccc, .ok/.err/.warn CSS 클래스
✅ Bootstrap 클래스 사용 금지
✅ fetch 에러 처리: if (!res.ok) throw new Error(...)

5. 빌드 검증 체크포인트

# 각 단계 완료 후 실행
dotnet build src/Web/ExperionCrawler.csproj

# 목표: 경고 N건, 에러 0건
# netDxf/PdfPig 추가 후 새 경고 없어야 정상

6. MCP 툴 독립 테스트

# Python MCP 서버 직접 테스트 (C# 없이)
curl -X POST http://localhost:5001/mcp \
  -H "Content-Type: application/json" \
  -H "mcp-protocol-version: 2025-03-26" \
  -d '{"jsonrpc":"2.0","id":"1","method":"tools/call",
       "params":{"name":"extract_pid_tags",
                 "arguments":{"text":"FT-101 Flow Transmitter\nPT-201 Pressure","source_type":"dxf"}}}'
# 기대 응답: [{"tagNo":"FT-101",...},{"tagNo":"PT-201",...}]