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

27 KiB

P&ID AX 코딩 플랜 v4 (MCP 기반 로컬 LLM 구현)

수정일: 2026-04-30 (v4 완료 — 백엔드 완료, MCP 툴 추가) 수정일: 2026-04-30 (v4.1 — 프론트엔드 완료) 기준: p&id_ax_coding_plan3.md 반영 목적: Anthropic Cloud Vision 제거 → MCP 경유 로컬 LLM(vLLM Qwen3-Coder-Next-FP8)으로 DXF/PDF 텍스트 추출 구현

백엔드 완료 요약:

  • 단계 1-8 완료 (P&ID 도메인, 서비스, 컨트롤러, DB, Program.cs 등록)
  • MCP 툴 추가: extract_pid_tags, match_pid_tags (mcp-server/server.py)
  • 빌드 검증: dotnet build 성공 (0 에러)
  • API 엔드포인트: /api/pid/* (13개)

프론트엔드 완료 요약 (v4.1):

  • 단계 9-12 완료 (index.html, app.js, style.css, appsettings.json)
  • P&ID 추출 탭 추가 (11개 탭)
  • 추출 결과 테이블 + 페이지네이션
  • CSV/Excel 내보내기 기능
  • 통계 정보 표시

📋 개요

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. 웹에서 시각화 및 관리

완료된 단계 (v4) — 백엔드 완료 (2026-04-30)

단계 내용 상태
1 P&ID 도메인 엔티티 생성 (PidEquipment, PidAuditLog) 완료
2 DTOs 생성 (PidEquipmentDto, PidExtractionResult, TagMappingDtos) 완료
3 Interface 정의 (IPidExtractorService, ITagMappingService) 완료
4 TagMappingService 생성 완료
5 PidExtractorService 생성 (Anthropic → MCP 로컬 LLM) 완료
6 Database Migration (DbSet, FK 설정) 완료
7 PidController 추가 (ExperionPidController - 13개 엔드포인트) 완료
8 Program.cs 등록 (AddScoped<IPidExtractorService>, AddScoped<ITagMappingService>) 완료

완료된 단계 (v4) — MCP 툴 추가 (2026-04-30)

단계 내용 상태
9 Python MCP 툴 extract_pid_tags 구현 완료
10 Python MCP 툴 match_pid_tags 구현 완료

완료된 단계 (v4) — 상세

단계 1: P&ID 도메인 엔티티 생성

파일: src/Core/Domain/Entities/PidEquipment.cs
파일: src/Core/Domain/Entities/PidAuditLog.cs

// PidEquipment.cs
[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; }
    
    public int? ExperionTagId { get; set; }
    public RealtimePoint? ExperionTag { get; set; }
}

// PidAuditLog.cs
[Table("pid_audit_log")]
public class PidAuditLog
{
    public long Id { get; set; }
    [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;
}

현황: 이미 코드베이스에 완료됨. UpdatedAtDateTime? (nullable)으로 정의되어 있음.


단계 2: DTOs 생성

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

// PidEquipmentDto.cs
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);

// PidExtractionResult.cs
public record PidExtractionResult(
    int TotalCount,
    int ConfidenceItems,
    int LowConfidenceItems);

// TagMappingDtos.cs
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);

현황: 이미 코드베이스에 완료됨. ExtractedItem/MappingItemPidExtractorService.cs 내부에 public class로 정의됨.


단계 3: Interface 정의

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

// P&ID Extractor
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);
}

// P&ID Tag Mapping
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);
    
    Task<int> GetUnmappedCountAsync();
    Task<int> GetMappedCountAsync();
    Task<IEnumerable<string>> GetAvailableTagsAsync();
}

현황: 이미 코드베이스에 완료됨. IExperionServices.csIPidExtractorService, ITagMappingService 모두 정의됨.


단계 4: TagMappingService 생성

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

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 { ... };
        
        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) { ... }
    public async Task<TagMappingResult> CreateMappingAsync(CreateMappingRequest request) { ... }
    public async Task UpdateMappingAsync(long id, UpdateMappingRequest request) { ... }
    public async Task ClearMappingAsync(long id) { ... }
    
    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();
    }
}

현황: 이미 코드베이스에 완료됨. GetAvailableTagsAsync()가 이미 매핑된 태그를 제외한 목록만 반환하는 올바른 구현이 있음.


단계 5: PidExtractorService 생성

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

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

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

    public async Task<PidExtractionResult> ExtractFromFileAsync(string filePath, bool useImageMode = false)
    {
        await 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)
    {
        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 _dbContext.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 _dbContext.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 _dbContext.PidEquipment.AddRangeAsync(dbItems);
        await _dbContext.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));
    }

    private string ExtractDxfText(Stream stream) { ... }
    private string ExtractPdfText(Stream stream) { ... }
    private List<ExtractedItem> ParseJson(string json) { ... }
    private Dictionary<string, string> ParseMappingJson(string json) { ... }
    private async Task<RealtimePoint?> FindFallbackTagAsync(string tagNo) { ... }

    // CRUD / 통계 / 내보내기 메서드들...
}

내부 파싱용 모델:

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

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

현황: PidExtractorService.cs가 MCP 기반으로 완료됨. netDxf, PdfPig, EPPlus 패키지 추가 완료.


📦 추가된 패키지

패키지 버전 용도
netDxf 2022.11.2 DXF 파일 파싱
PdfPig 0.1.9 PDF 텍스트 추출
EPPlus 7.4.2 Excel 내보내기

완료된 단계 (v4) - 추가

단계 6: Database Migration

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

public class ExperionDbContext : DbContext
{
    public DbSet<PidEquipment> PidEquipment => Set<PidEquipment>();
    public DbSet<PidAuditLog> PidAuditLog => Set<PidAuditLog>();
    
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // PidEquipment → RealtimePoint FK
        modelBuilder.Entity<PidEquipment>(entity =>
        {
            entity.HasOne(e => e.ExperionTag)
                  .WithMany()
                  .HasForeignKey(e => e.ExperionTagId)
                  .OnDelete(DeleteBehavior.SetNull);
        });
    }
}

현황: 이미 코드베이스에 완료됨. DbSet과 FK 설정 모두 정상 구현됨.


단계 7: PidController 추가

파일: src/Web/Controllers/ExperionControllers.cs (810-985행)

[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) { ... }

    [HttpGet("equipment")]
    public async Task<IActionResult> GetEquipment(...) { ... }

    [HttpGet("statistics")]
    public async Task<IActionResult> GetStatistics() { ... }

    [HttpPut("{id:long}/confidence")]
    public async Task<IActionResult> UpdateConfidence(long id, [FromBody] double confidence) { ... }

    [HttpPost("{id:long}/activate")]
    public async Task<IActionResult> Activate(long id) { ... }

    [HttpPost("{id:long}/deactivate")]
    public async Task<IActionResult> Deactivate(long id) { ... }

    [HttpGet("mappings")]
    public async Task<IActionResult> GetMappings(...) { ... }

    [HttpPost("mappings")]
    public async Task<IActionResult> CreateMapping([FromBody] CreateMappingRequest req) { ... }

    [HttpPut("mappings/{id:long}")]
    public async Task<IActionResult> UpdateMapping(long id, [FromBody] UpdateMappingRequest req) { ... }

    [HttpDelete("mappings/{id:long}")]
    public async Task<IActionResult> ClearMapping(long id) { ... }

    [HttpGet("mappings/available-tags")]
    public async Task<IActionResult> GetAvailableTags() { ... }

    [HttpGet("export/csv")]
    public async Task<IActionResult> ExportCsv([FromQuery] string? tagNo) { ... }

    [HttpGet("export/excel")]
    public async Task<IActionResult> ExportExcel([FromQuery] string? tagNo) { ... }
}

현황: ExperionControllers.csExperionPidController가 완료됨. API 엔드포인트: /api/pid/* (13개 엔드포인트)


단계 8: Program.cs 등록

파일: src/Web/Program.cs

// Line 85-86
builder.Services.AddScoped<IPidExtractorService, PidExtractorService>();
builder.Services.AddScoped<ITagMappingService, TagMappingService>();

현황: 이미 코드베이스에 완료됨. AddScoped 등록 정상 구현됨.


단계 9~14: Frontend 및 기타 (2026-04-30)

상태: 단계 8까지 백엔드 완료. MCP 툴(extract_pid_tags, match_pid_tags) 구현 완료. 단계 9-12 프론트엔드 구현 완료. 다음 단계는 통합 테스트입니다.

단계 내용 상태
9 index.html에 P&ID 탭 + pane 추가 완료 (2026-04-30)
10 app.js에 P&ID 함수 추가 완료 (2026-04-30)
11 style.css에 P&ID 스타일 추가 완료 (2026-04-30)
12 appsettings.json Kestrel 설정 추가 완료 (2026-04-30)
13 실제 DXF/PDF 파일 업로드 테스트 대기
14 수동 매핑 UI 동작 확인 대기

단계 9: index.html에 P&ID 탭 + pane 추가

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

변경 내용:

  1. 탭 목록에 P&ID 탭 추가 (11번째 탭)
  2. pane-pid 섹션 추가 (fastRecord 다음)

구조:

<li class="nav-item" data-tab="pid">
  <span class="ni">11</span>
  <span class="nl">P&ID 추출</span>
</li>

<section class="pane" id="pane-pid">
  <!-- 파일 업로드 카드 -->
  <div class="card">
    <div class="card-cap">P&ID 파일 업로드</div>
    <input type="file" id="pid-file-input" accept=".dxf,.pdf"/>
    <button onclick="pidExtract()">🚀 추출 시작</button>
  </div>
  
  <!-- 추출 결과 테이블 -->
  <div class="card">
    <table class="table" id="pid-table">
      <thead>...</thead>
      <tbody id="pid-table-body"></tbody>
    </table>
  </div>
  
  <!-- 통계 카드 -->
  <div class="card">
    <div class="stat-box">
      <div class="stat-label">총 추출 건수</div>
      <div class="stat-value" id="pid-stat-total">0</div>
    </div>
  </div>
</section>

현황: index.html에 P&ID 탭 및 pane 완료. fastRecord 다음에 추가.


단계 10: app.js에 P&ID 함수 추가

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

추가된 함수:

  • pidExtract() — 파일 업로드 및 추출 시작
  • pidLoadTable(page) — 추출 결과 테이블 로드
  • pidRenderPagination(total, currentPage) — 페이지네이션 렌더링
  • pidUpdateStats() — 통계 정보 업데이트
  • pidClearLog() — 로그 지우기
  • pidOpenMapping(id) — 매핑 모달 열기

API 연동:

  • /api/pid/extract — POST (파일 업로드)
  • /api/pid/equipment — GET (목록 조회)
  • /api/pid/export/csv — GET (CSV 내보내기)
  • /api/pid/export/excel — GET (Excel 내보내기)

현황: app.js에 P&ID 함수 완료. 기존 패턴(인증서 관리, Text-to-SQL) 따름.


단계 11: style.css에 P&ID 스타일 추가

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

추가된 스타일:

  • #pane-pid .btn-sm — 버튼 스타일 (btn-a, btn-b)
  • #pane-pid .badge — 배지 스타일 (ok, warn, err)
  • #pane-pid .stat-box — 통계 박스 스타일
  • #pane-pid .pagination — 페이지네이션 스타일
  • #pane-pid .logbox — 로그 박스 스타일

현황: style.css에 P&ID 스타일 완료. 다크 테마 색상 사용.


단계 12: appsettings.json Kestrel 설정 추가

파일: src/Web/appsettings.json

추가된 설정:

{
  "Kestrel": {
    "Endpoints": {
      "Http": {
        "Url": "http://0.0.0.0:5000"
      }
    },
    "Limits": {
      "MaxRequestBodySize": 104857600
    }
  }
}

설명:

  • MaxRequestBodySize: 100MB (DXF/PDF 파일 업로드용)
  • Url: 모든 네트워크 인터페이스에서 수신

현황: appsettings.json에 Kestrel 설정 완료.


📋 구현 순서 및 체크리스트

Phase 1 — 백엔드 (완료)

  • 단계 1: ExperionCrawler.csprojnetDxf, PdfPig, EPPlus 패키지 추가 후 dotnet build 확인
  • 단계 2: McpClient.csExtractPidTagsAsync, MatchPidTagsAsync 메서드 추가
  • 단계 3: Python MCP 서버에 extract_pid_tags, match_pid_tags 툴 추가 및 테스트
  • 단계 4: PidExtractorService.cs 전체 교체
  • 단계 5: ExperionControllers.csExperionPidController 추가
  • 단계 6: dotnet build 에러 0건 확인 (2026-04-30 확인)
  • 단계 7: Swagger(/swagger)에서 /api/pid/* 엔드포인트 노출 확인 (대기)

Phase 2 — 프론트엔드 (완료)

  • 단계 9: index.html에 P&ID 탭 + pane 추가 (2026-04-30)
  • 단계 10: app.js에 P&ID 함수 추가 (2026-04-30)
  • 단계 11: style.css에 P&ID 스타일 추가 (2026-04-30)
  • 단계 12: appsettings.json Kestrel 설정 추가 (2026-04-30)

Phase 3 — 통합 테스트 (대기)

  • 실제 DXF 파일 업로드 → 태그 추출 확인
  • 실제 PDF(텍스트) 파일 업로드 → 태그 추출 확인
  • 추출 결과 → Experion 태그 자동 매핑 제안 확인
  • 수동 매핑 UI 동작 확인
  • CSV/Excel 내보내기 확인
  • 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/EPPlus 추가 후 새 경고 없어야 정상

🧐 감독자 진단 — 단계 8까지 완료 여부 (2026-04-30 기준)

항목 확인 내용 상태
엔티티 PidEquipment.cs, PidAuditLog.cs 존재
DbSet ExperionDbContext.PidEquipment, PidAuditLog 등록
FK 설정 ExperionTagIdRealtimePoint.Id FK 설정
DTOs PidEquipmentDto, PidExtractionResult, TagMappingDtos 존재
인터페이스 IPidExtractorService, ITagMappingService 정의
서비스 TagMappingService, PidExtractorService 구현
컨트롤러 ExperionPidController (13개 엔드포인트)
Program.cs AddScoped<IPidExtractorService>, AddScoped<ITagMappingService>
MCP 툴 extract_pid_tags, match_pid_tags 구현
빌드 dotnet build 에러 0건

진단 결과: 단계 1-8 완료. MCP 툴 추가 완료. 다음 단계는 프론트엔드 구현(단계 9-12) 및 통합 테스트(단계 13-14)입니다.

7. 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",...}]