# P&ID AX Plan 2 — 로컬 LLM 기반 재설계 > **작성일**: 2026-04-30 > **목적**: Anthropic Cloud Vision 제거 → MCP 경유 로컬 LLM(vLLM Qwen3-Coder-Next-FP8)으로 DXF/PDF 텍스트 추출 구현 > **기준**: 기존 구현 현황 반영 (이미 완료된 항목 제외) --- ## 현재 구현 현황 | 파일 | 상태 | 비고 | |------|------|------| | `PidEquipment.cs` | ✅ 완료 | `int? ExperionTagId`, `double Confidence`, `[Table("pid_equipment")]` | | `PidAuditLog.cs` | ✅ 완료 | `Source` 필드(UserId 대체) | | `ExperionDbContext.cs` | ✅ 완료 | `DbSet`, `DbSet` 등록 완료 | | `TagMappingService.cs` | ✅ 완료 | 단일 DbContext JOIN, `ClearMappingAsync` 명칭 정리 완료 | | `Program.cs` | ✅ 완료 | `IPidExtractorService`, `ITagMappingService` 등록 완료 | | DTOs | ✅ 완료 | `PidEquipmentDto`, `PidExtractionResult`, `TagMappingDtos` | | `PidExtractorService.cs` | ⚠️ 미완 | Anthropic 의존성 남음, DXF/PDF 파싱 미구현, `AnalyzeWithClaudeAsync` 빈 구현 | | `PidController` | ❌ 없음 | `ExperionControllers.cs`에 미추가 | | `McpClient` 신규 툴 | ❌ 없음 | `extract_pid_tags`, `match_pid_tags` 미추가 | | Python MCP 서버 | ❌ 없음 | 신규 툴 2개 미추가 | | Frontend (P&ID 탭) | ❌ 없음 | `index.html`, `app.js`, `style.css` 미추가 | --- ## 전체 아키텍처 ``` 파일 업로드 (DXF / PDF텍스트) │ ▼ PidExtractorService (C#) ├── .dxf ──► ExtractDxfText() [netDxf] ──► 텍스트 └── .pdf ──► ExtractPdfText() [PdfPig] ──► 텍스트 │ ▼ McpClient.ExtractPidTagsAsync(text) │ localhost:5001 │ Python MCP extract_pid_tags() │ vLLM localhost:8000 Qwen3-Coder-Next-FP8 │ JSON 태그 목록 반환 │ ▼ McpClient.MatchPidTagsAsync(pidTags, experionTags) │ Python MCP match_pid_tags() │ FT-101 → FT-101.PV 매핑 제안 │ ▼ ExperionDbContext.PidEquipment 저장 [ Vision (스캔본/이미지) ] ─── 🔮 미래 계획 (Vision LLM 도입 시 구현) ─────────────── ``` --- ## 구현 단계 --- ### 단계 1: NuGet 패키지 추가 **파일**: `src/Web/ExperionCrawler.csproj` ```xml ``` > `CsvHelper`(v33.0.1)는 이미 설치되어 있음 — CSV 내보내기 재사용. > `ClosedXML`은 Excel 내보내기 필요 시 추가. 현재 단계는 생략. --- ### 단계 2: McpClient 신규 메서드 추가 **파일**: `src/Infrastructure/Mcp/McpClient.cs` 기존 `QueryWithNlAsync` 아래에 추가: ```csharp public Task ExtractPidTagsAsync(string text, string sourceType) => CallToolAsync("extract_pid_tags", new Dictionary { ["text"] = text, ["source_type"] = sourceType // "dxf" | "pdf" }); public Task MatchPidTagsAsync(IEnumerable pidTags, IEnumerable experionTags) => CallToolAsync("match_pid_tags", new Dictionary { ["pid_tags"] = pidTags.ToList(), ["experion_tags"] = experionTags.ToList() }); ``` --- ### 단계 3: Python MCP 서버 툴 추가 **파일**: Python MCP 서버 (localhost:5001) 기존 툴 목록 끝에 두 툴 추가: ```python @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 "[]" ``` --- ### 단계 4: PidExtractorService 재작성 **파일**: `src/Core/Application/Services/PidExtractorService.cs` 전체 교체 — Anthropic 의존성 제거, McpClient 주입, 실제 DXF/PDF 파싱 구현: ```csharp 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 _logger; public PidExtractorService(McpClient mcp, ExperionDbContext db, ILogger logger) { _mcp = mcp; _db = db; _logger = logger; } // ── 파일 진입점 ────────────────────────────────────────────────────────── public async Task ExtractFromFileAsync(string filePath) { await using var stream = File.OpenRead(filePath); return await ExtractFromStreamAsync(stream, Path.GetFileName(filePath)); } public async Task 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(); 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 ParseJson(string json) { try { return JsonSerializer.Deserialize>(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 ParseMappingJson(string json) { try { var list = JsonSerializer.Deserialize>(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 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 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 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 GetTotalCountAsync() => _db.PidEquipment.CountAsync(); public Task GetConfidenceItemsCountAsync() => _db.PidEquipment.CountAsync(e => e.Confidence >= 0.7); public Task GetLowConfidenceItemsCountAsync() => _db.PidEquipment.CountAsync(e => e.Confidence < 0.5); public Task GetDrawingCountAsync() => _db.PidEquipment.Select(e => e.PidDrawingNo).Distinct().CountAsync(); public async Task> GetConfidenceDistributionAsync() { var items = await _db.PidEquipment.ToListAsync(); return new Dictionary { ["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 ExportToCsvAsync(IEnumerable 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: PidController 추가 **파일**: `src/Web/Controllers/ExperionControllers.cs` 끝에 추가 ```csharp // ── 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 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 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 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 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 Activate(long id) { await _extractor.ActivateAsync(id); return Ok(new { message = "활성화 완료" }); } [HttpPost("{id:long}/deactivate")] public async Task Deactivate(long id) { await _extractor.DeactivateAsync(id); return Ok(new { message = "비활성화 완료" }); } // ── 매핑 API ── [HttpGet("mappings")] public async Task 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 CreateMapping([FromBody] CreateMappingRequest req) { var result = await _mapping.CreateMappingAsync(req); return Ok(result); } [HttpPut("mappings/{id:long}")] public async Task UpdateMapping(long id, [FromBody] UpdateMappingRequest req) { await _mapping.UpdateMappingAsync(id, req); return Ok(new { message = "매핑 업데이트 완료" }); } [HttpDelete("mappings/{id:long}")] public async Task ClearMapping(long id) { await _mapping.ClearMappingAsync(id); return Ok(new { message = "매핑 해제 완료" }); } [HttpGet("mappings/available-tags")] public async Task GetAvailableTags() { var tags = await _mapping.GetAvailableTagsAsync(); return Ok(new { tags }); } [HttpGet("export/csv")] public async Task 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"); } } ``` --- ### 단계 6: Frontend — index.html (P&ID 탭 추가) **파일**: `src/Web/wwwroot/index.html` 사이드바 nav-item 추가 (기존 마지막 탭 `data-tab="fast"` 뒤에): ```html ``` pane 추가 (`#pane-fast` 섹션 뒤에): ```html
P&ID AX
DXF / PDF → LLM → TAG
태그번호장비명계기유형 라인번호도면번호신뢰도 상태Experion 태그작업
태그 매핑
태그번호장비명신뢰도 Experion 태그작업
``` --- ### 단계 7: Frontend — app.js (P&ID 함수 추가) **파일**: `src/Web/wwwroot/js/app.js` 기존 탭 핸들러(`app.js:15-17`)에 P&ID 탭 추가: ```javascript if (tab === 'pid') { /* 탭 진입 시 API 호출 없음 */ } ``` 파일 끝에 P&ID 함수 추가: ```javascript /* ───────────────────────────────────────────────────────────── 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 ` ${esc(i.tagNo)} ${esc(i.equipmentName||'')} ${esc(i.instrumentType||'')} ${esc(i.lineNumber||'')} ${esc(i.pidDrawingNo||'')} ${(i.confidence*100).toFixed(0)}% ${i.isActive ? '활성' : '비활성'} ${esc(i.experionTagName||'미매핑')} ${i.isActive ? `` : ``} `; }).join(''); document.getElementById('pid-eq-body').innerHTML = rows || '데이터 없음'; 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 ` ${esc(i.tagNo)} ${esc(i.equipmentName||'')} ${(i.confidence*100).toFixed(0)}% ${esc(i.experionTagName||'미매핑')} ${i.experionTagId ? `` : ``} `; }).join(''); document.getElementById('pid-map-body').innerHTML = rows || '데이터 없음'; 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 = '' + (d.tags||[]).map(t => ``).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; } // tagName → RealtimePoint.Id 조회는 서버에서 처리 // available-tags API가 tagName 문자열을 반환하므로, 서버 측에서 tagName으로 lookup // → CreateMappingRequest를 tagName 기반으로 변경하거나, 별도 lookup 엔드포인트 필요 // (단계 5 컨트롤러에 /api/pid/mappings/by-tagname 추가 권장) 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 += ``; for (let i = from; i <= to; i++) html += ``; if (to < pages) html += ``; document.getElementById(elId).innerHTML = html; } ``` --- ### 단계 8: Frontend — style.css (P&ID 스타일 추가) **파일**: `src/Web/wwwroot/css/style.css` 끝에 추가 ```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; } ``` --- ### 단계 9: appsettings.json — Kestrel 업로드 크기 설정 **파일**: `src/Web/appsettings.json` — 기존 설정에 병합 추가: ```json "Kestrel": { "Limits": { "MaxRequestBodySize": 104857600 } } ``` > Anthropic API 키 설정은 **불필요** — 로컬 LLM 사용. --- ## 🔮 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.csproj`에 `netDxf`, `PdfPig` 패키지 추가 후 `dotnet build` 확인 - [ ] **단계 2**: `McpClient.cs`에 `ExtractPidTagsAsync`, `MatchPidTagsAsync` 메서드 추가 - [ ] **단계 3**: Python MCP 서버에 `extract_pid_tags`, `match_pid_tags` 툴 추가 및 테스트 - `curl`로 직접 툴 호출하여 JSON 응답 확인 - [ ] **단계 4**: `PidExtractorService.cs` 전체 교체 - `_anthropicApiKey` 의존성 제거 확인 - DXF 파싱 단위 테스트: 실제 DXF 파일로 텍스트 추출 확인 - PDF 파싱 단위 테스트: 텍스트 기반 PDF로 추출 확인 - [ ] **단계 5**: `ExperionControllers.cs`에 `ExperionPidController` 추가 - `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() 형태 ✅ 컨트롤러: ExperionControllers.cs 단일 파일에 추가 (별도 파일 생성 금지) ✅ 탭 진입: API 자동 호출 금지 — 버튼 클릭으로만 동작 ✅ DOM 렌더링: innerHTML += 루프 금지 — rows 배열 .join('') 후 한번에 설정 ✅ 로깅: Console.WriteLine 금지 — ILogger 사용 ``` ### 2. MCP 서버 연동 패턴 ``` C# 서비스에서 MCP 호출 시 McpClient를 직접 주입. IMcpService를 통하지 않아도 됨 (McpClient는 Singleton 등록). ``` ```csharp // 올바른 패턴 public MyService(McpClient mcp, ExperionDbContext db, ILogger logger) // 금지 public MyService(IMcpService mcp) // 래핑 레이어가 필요할 때만 사용 ``` ### 3. Python MCP 툴 작성 규칙 ```python # 응답은 반드시 순수 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. 빌드 검증 체크포인트 ```bash # 각 단계 완료 후 실행 dotnet build src/Web/ExperionCrawler.csproj # 목표: 경고 N건, 에러 0건 # netDxf/PdfPig 추가 후 새 경고 없어야 정상 ``` ### 6. MCP 툴 독립 테스트 ```bash # 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",...}] ```