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 텍스트 추출) 사용
🎯 목표
- P&ID 도면에서 장비 정보를 추출
- 추출된 정보를 PostgreSQL 로 저장
- 기존 Experion 데이터와 연동
- 웹에서 시각화 및 관리
✅ 완료된 단계 (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;
}
현황: 이미 코드베이스에 완료됨.
UpdatedAt이DateTime?(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/MappingItem은PidExtractorService.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.cs에IPidExtractorService,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.cs에ExperionPidController가 완료됨. API 엔드포인트:/api/pid/*(13개 엔드포인트)
단계 8: 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
변경 내용:
- 탭 목록에 P&ID 탭 추가 (11번째 탭)
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 함수 추가 ✅
추가된 함수:
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 설정 추가 ✅
추가된 설정:
{
"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.csproj에netDxf,PdfPig,EPPlus패키지 추가 후dotnet build확인 - 단계 2:
McpClient.cs에ExtractPidTagsAsync,MatchPidTagsAsync메서드 추가 - 단계 3: Python MCP 서버에
extract_pid_tags,match_pid_tags툴 추가 및 테스트 - 단계 4:
PidExtractorService.cs전체 교체 - 단계 5:
ExperionControllers.cs에ExperionPidController추가 - 단계 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.jsonKestrel 설정 추가 (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 설정 | ExperionTagId → RealtimePoint.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",...}]