# DXF 추출 로직 개선안 > 작성일: 2026-05-19 | 모델: Qwen3.6-27B-FP8 --- ## 1. 현재 파이프라인 구조 ``` [Frontend: app.js] | POST /api/pid/upload v [PidController.cs] -> uploads/pid/{filename}.dxf 저장 | POST /api/pid/extract v [PidExtractorService.cs] | +- Step 1: C# netDxf 텍스트 추출 | - Stream -> temp 파일 -> DxfDocument.Load() | - TEXT, MTEXT, Block Attribute 추출 | - Circle 중심 좌표 매핑 | - Balloon 재조합 (O(n^2)) | - FilterDxfText (regex 필터링) | -> (filteredText, coords) 반환 | +- Step 2: MCP -> Python extract_pid_tags | - HTTP localhost:5001 -> JSON-RPC | - _extract_pid_tags_from_text() (순수 regex, LLM 없음) | - kind: pipe | equipment | instrument | unknown 분류 | -> JSON: {success, count, tags: [...]} | +- Step 3: MCP -> Python match_pid_tags | - HTTP localhost:5001 -> JSON-RPC | - 결정론적 매칭 (exact 0.99, prefix 0.95) | -> JSON: {success, count, mappings: [...]} | +- Step 4: DB 저장 | - 중복 체크 (기존 TagNo 제외) | - Category 분류 (prefix rules) | - TagClass 분류 (field vs system) | - 좌표 할당 (coords) | -> pid_equipment 테이블 INSERT | v [Response: totalCount, confidenceItems, lowConfidenceItems, skippedDuplicates] 병렬 경로: MCP 직접 도구 (C# netDxf 우회) parse_pid_dxf(filepath) -> _extract_pid_dxf_fast() -> ezdxf layer-aware - LINENO 레이어 -> 라인 마스터 (service/line_no/size/material_spec) - 그 외 TEXT/MTEXT -> 태그 후보 (prefix로 장비/계기 분류) - 좌표 정보 없음 - 이 경로는 C# PidExtractorService 에서 사용되지 않음 ``` --- ## 2. 발견된 문제점 ### P1 - 심각한 문제 | # | 문제 | 영향 | 위치 | |---|------|------|------| | 1 | **C# <-> Python 중복 파싱** | C# netDxf 로 텍스트 추출 후 Python regex 로 재분류. 같은 DXF 2회 파싱 | `PidExtractorService.cs:136-234` + `server.py:373-432` | | 2 | **좌표 정보 손실** | Python `_extract_pid_dxf_fast`는 layer 활용하지만 좌표 반환 안 함 | `server.py:296-366` | | 3 | **Temp 파일 I/O** | Stream -> temp 파일 -> netDxf 로드 -> 삭제. 불필요한 디스크 I/O | `PidExtractorService.cs:138-142` | | 3b | **Block INSERT 처리 손실** | C#은 Block AttributeDefinitions 추출하나 Python `_extract_pid_dxf_fast`는 TEXT/MTEXT만 처리 | `PidExtractorService.cs:162-164` vs `server.py:310` | | 3c | **regex 불일치** | C# `FilterDxfText` `[A-Z]{1,6}-\d{2,6}` vs Python `_PID_TAG_RE` `[A-Z]{1,4}-\d{3,6}` → Python이 더 엄격하여 일부 태그 누락 가능 | `PidExtractorService.cs:253` vs `server.py:247` | | 4 | **Balloon 재조합 O(n^2)** | 모든 func/num 쌍 비교. 대용량 DXF (1000+ 텍스트)에서 성능 병목 | `PidExtractorService.cs:277-308` | | 5 | **MCP 서버 단일 장애점** | MCP 서버 다운 시 전체 파싱 실패. fallback 없음 | `PidExtractorService.cs:55-56` | ### P2 - 개선 필요 | # | 문제 | 영향 | 위치 | |---|------|------|------| | 6 | **FilterDxfText 과도한 필터링** | `[A-Z]{1,6}-\d{2,6}` 패턴만 통과. 일부 유효 태그 누락 가능 | `PidExtractorService.cs:240-260` | | 7 | **dxf-graph/ 고립** | `pid_geometric_extractor.py`, `pid_topology_builder.py` 등 production 미사용 | `dxf-graph/` | | 8 | **Backup 파일 축적** | `mcp-server/.rooBackup/`에 server.py 백업 3개 | `mcp-server/.rooBackup/` | | 9 | **Tag matching prefix 길이 제한** | `_MIN_PREFIX_LEN = 4` -> `P-10101` 같은 짧은 prefix prefix 매칭 불가 | `server.py:1643` | | 10 | **netDxf 의존성 불필요** | Python ezdxf 가 layer-aware 파싱 지원하므로 C# netDxf 중복 | `ExperionCrawler.csproj:32` | --- ## 3. 개선 방향 ### 핵심 전략: Python ezdxf 단일 파싱 경로로 통합 C# netDxf 제거하고 Python `parse_pid_dxf`를 확장하여 좌표 + layer 정보를 모두 반환. C#은 MCP 호출 + DB 저장만 담당. ``` [Frontend] | POST /api/pid/extract v [PidController.cs] | v [PidExtractorService.cs] | +- Step 1: MCP -> Python parse_pid_dxf (확장) | - ezdxf layer-aware 파싱 (기존 _extract_pid_dxf_fast 기반) | - 좌표 정보 포함 (TEXT/MTEXT 위치) | - Balloon 재조합 (Python 측으로 이동, R-tree 최적화) | - Block reference 가상 엔티티 확장 | -> JSON: {tags: [{tagNo, kind, coords: {x, y}, ...}]} | +- Step 2: MCP -> Python match_pid_tags (기존 유지) | -> JSON: {mappings: [...]} | +- Step 3: DB 저장 (기존 유지) | -> pid_equipment 테이블 INSERT | v [Response] Fallback: MCP 연결 실패 시 Python 프로세스 직접 호출 ``` --- ## 4. Todo List (작업 단위별) ### Phase 1: Python 파싱 확장 (좌표 + Balloon 포함) - [ ] **1.1** `_extract_pid_dxf_fast`에 좌표 정보 추가 - TEXT/MTEXT 엔티티의 `(x, y, height)`를 tag 결과에 포함 - Circle 중심 매핑 로직 추가 (C# `ExtractDxfText`의 circleCoords 로직 이식) - 파일: `mcp-server/server.py` - [ ] **1.2** Balloon 재조합을 Python 측으로 이동 - `_extract_pid_tags_from_text` 내에 Balloon 재조합 함수 추가 - C# `ReconstructBalloonTags` 로직을 Python 으로 포팅 - R-tree (spatial index) 도입으로 O(n^2) -> O(n log n) 최적화 - 파일: `mcp-server/server.py` - [ ] **1.3** Block reference 가상 엔티티 확장 - `INSERT` 엔티티의 `virtual_entities()`를 통해 블록 내부 TEXT 추출 - 파일: `mcp-server/server.py` - [ ] **1.4** `parse_pid_dxf` 반환 형식 확장 - 기존: `{success, fluid_dictionary, linenos, tags, stats}` - 변경: `{success, fluid_dictionary, linenos, tags: [{..., coords: {x, y, h}}], stats}` - 파일: `mcp-server/server.py` ### Phase 2: C# 측 정리 - [ ] **2.1** `ExtractDxfText` 메서드 제거 - netDxf 의존성 제거 - `using netDxf;` 제거 - 파일: `PidExtractorService.cs` - [ ] **2.2** `FilterDxfText` 메서드 제거 - Python 측에서 필터링하므로 C# 측 불필요 - 파일: `PidExtractorService.cs` - [ ] **2.3** `ReconstructBalloonTags` 메서드 제거 - Python 측으로 이동 - 파일: `PidExtractorService.cs` - [ ] **2.4** `ExtractFromStreamAsync` 재작성 - DXF: `McpClient.ParsePidDxfAsync(filepath)` 호출 - PDF: 기존 `ExtractPdfText` + `McpClient.ExtractPidTagsAsync` 유지 (변경 없음) - 파일: `PidExtractorService.cs` - [ ] **2.5** `netDxf` NuGet 패키지 제거 - `ExperionCrawler.csproj`에서 `` 제거 - 파일: `ExperionCrawler.csproj` - [ ] **2.6** `ExtractedItem` 모델에 좌표 필드 추가 - `PosX`, `PosY` 필드 추가 - 파일: `PidExtractorService.cs` (내부 클래스) ### Phase 3: MCP Fallback - [ ] **3.1** Python 프로세스 직접 호출 fallback 구현 - MCP 연결 실패 시 Python `_extract_pid_dxf_fast`를 subprocess 로 직접 호출 - 파일: `PidExtractorService.cs` - [ ] **3.2** `McpClient.CallToolAsync` 실패 감지 개선 - `"도구 호출 실패:"` 문자열이 포함된 응답을 예외로 처리 - 파일: `McpClient.cs` ### Phase 4: Tag matching 개선 - [ ] **4.1** `_MIN_PREFIX_LEN` 조정 - 4 -> 3으로 하향 (P-10101 같은 단축 prefix도 prefix 매칭 가능) - false positive 방지 위해 숫자 부분도 매칭하는 조건 추가 - 파일: `mcp-server/server.py` - [ ] **4.2** 숫자 기반 매칭 추가 - `P-10101` <-> `p-10101.pv` -> 숫자(10101)가 일치하면 매칭 - prefix도 일치해야 함 (PSV-10101 <-> p-10101.pv 거짓 매칭 방지) - 파일: `mcp-server/server.py` ### Phase 5: dxf-graph 통합 (선택적) - [ ] **5.1** `pid_geometric_extractor.py` -> `server.py` 통합 - bbox 계산 로직을 `_extract_pid_dxf_fast`에 통합 - 파일: `mcp-server/server.py` - [ ] **5.2** `pid_topology_builder.py` -> MCP tool 노출 - `build_pid_graph_parallel` tool 확장 - 파일: `mcp-server/server.py` ### Phase 6: 정리 - [ ] **6.1** `.rooBackup` 정리 - `mcp-server/.rooBackup/` 삭제 - 파일: `mcp-server/.rooBackup/` - [ ] **6.2** Build 검증 - `dotnet build src/Web/ExperionCrawler.csproj` - `dotnet test` - [ ] **6.3** 통합 테스트 - 샘플 DXF 파일로 추출 테스트 (`uploads/pid/P10-EQP-BLOCK.dxf`) - 좌표 정보가 DB에 저장되는지 확인 - MCP 서버 다운 시 fallback 동작 확인 --- ## 5. 코드 수정 사항 ### 5.1 `mcp-server/server.py` - `_extract_pid_dxf_fast` 확장 **현재 (296-366 라인):** ```python async def _extract_pid_dxf_fast(filepath: str) -> dict: """DXF에서 layer + regex만으로 구조 추출. 좌표 계산/LLM 호출 없음.""" import ezdxf from ezdxf.tools.text import plain_mtext from collections import Counter def _work(): doc = ezdxf.readfile(filepath) msp = doc.modelspace() linenos: list[dict] = [] tags: list[dict] = [] seen_tags: set[str] = set() for e in msp.query('TEXT MTEXT'): # ... 텍스트 추출 및 분류 ... ``` **수정 후:** ```python async def _extract_pid_dxf_fast(filepath: str) -> dict: """DXF에서 layer + regex + 좌표로 구조 추출. 좌표 포함, LLM 호출 없음.""" import ezdxf from ezdxf.tools.text import plain_mtext from collections import Counter import math def _work(): doc = ezdxf.readfile(filepath) msp = doc.modelspace() linenos: list[dict] = [] tags: list[dict] = [] seen_tags: set[str] = set() # Circle 중심 좌표 사전 수집 circles = [] for c in msp.query('CIRCLE'): circles.append((c.dxf.center.x, c.dxf.center.y, c.dxf.radius)) # 텍스트 엔티티 수집 (좌표 포함) positioned_texts = [] # list of (text, x, y, h) for e in msp.query('TEXT MTEXT'): if e.dxftype() == 'TEXT': txt = e.dxf.text or "" x, y = e.dxf.insert.x, e.dxf.insert.y h = e.dxf.height or 0.0 else: try: txt = plain_mtext(e.dxf.text or "") except Exception: txt = e.dxf.text or "" x, y = e.dxf.insert.x, e.dxf.insert.y h = e.dxf.height or 0.0 txt = txt.strip() if not txt: continue positioned_texts.append((txt, x, y, h)) layer = e.dxf.layer # 기존 분류 로직 (layer 기반 LINENO, 태그 분류) # tag 추가 시 coords 포함: # tags.append({ # "tagNo": txt, "kind": ..., "prefix": ..., "type": ..., # "coords": {"x": x, "y": y, "h": h}, # "layer": layer # }) # Circle 중심 매핑 circle_coords = {} for (txt, x, y, h) in positioned_texts: best_r = float('inf') cx, cy = x, y for (ccx, ccy, cr) in circles: d = math.sqrt((x - ccx) ** 2 + (y - ccy) ** 2) if d < cr and cr < best_r: best_r = cr cx, cy = ccx, ccy if best_r < float('inf'): circle_coords[txt] = (cx, cy) # Balloon 재조합 (R-tree 기반) balloon_tags = _reconstruct_balloons_rtree(positioned_texts, circle_coords) for (tag, x, y, h) in balloon_tags: if tag not in seen_tags: seen_tags.add(tag) cls = _classify_pid_tag(tag) final_x, final_y = x, y if tag in circle_coords: final_x, final_y = circle_coords[tag] tags.append({ "tagNo": tag, **cls, "instrumentType": cls["prefix"], "confidence": 0.90, "coords": {"x": final_x, "y": final_y, "h": h}, }) # stats 계산 ... ``` **추가할 Balloon 재조합 함수 (R-tree 기반):** ```python def _reconstruct_balloons_rtree( texts, circle_coords, ): """ISA 기능코드 + 루프번호를 근접 좌표로 짝지어 재조합. R-tree (spatial index) 로 O(n log n) 으로 최적화. """ import re try: import rtree HAS_RTREE = True except ImportError: HAS_RTREE = False _instr_func_re = re.compile(r'^[FPLTASHQWVXZBDCRK][ICTREVYSAQGZ]{1,3}$') _loop_num_re = re.compile(r'^\d{3,6}[A-Z]?$') funcs = [(t, x, y, h) for t, x, y, h in texts if _instr_func_re.match(t)] nums = [(t, x, y, h) for t, x, y, h in texts if _loop_num_re.match(t)] if not funcs or not nums: return [] result = [] seen = set() idx = None if HAS_RTREE and len(nums) > 50: idx = rtree.index.Index() for i, (t, x, y, h) in enumerate(nums): idx.insert(i, (x, y, x, y)) for (ft, fx, fy, fh) in funcs: threshold = fh * 5.0 if fh > 0 else 12.0 best_dist = float('inf') best_num = None nx, ny = fx, fy if idx is not None: candidates = list(idx.intersection( (fx - threshold, fy - threshold, fx + threshold, fy + threshold))) for i in candidates: t, x, y, h = nums[i] d = math.sqrt((fx - x) ** 2 + (fy - y) ** 2) if d < best_dist: best_dist = d best_num = t nx, ny = x, y else: for (t, x, y, h) in nums: d = math.sqrt((fx - x) ** 2 + (fy - y) ** 2) if d < best_dist: best_dist = d best_num = t nx, ny = x, y if best_num and best_dist <= threshold: tag = f"{ft}-{best_num}" if tag.upper() not in seen: seen.add(tag.upper()) result.append((tag, (fx + nx) / 2, (fy + ny) / 2, fh)) return result ``` ### 5.2 `mcp-server/server.py` - `_extract_pid_tags_from_text` 수정 **수정 사항:** `coords` 필드를 반환 형식에 포함 (PDF/OCR 텍스트는 좌표 없음) ```python def _extract_pid_tags_from_text(text: str, coords_map: dict | None = None) -> list[dict]: """plain text 에서 tag/LineNo를 regex로 추출. coords_map 이 제공되면 좌표 정보 포함. """ # ... 기존 로직 동일 ... # tag 추가 시: entry = { "tagNo": token, "kind": "equipment", "prefix": cls["prefix"], # ... } if coords_map and token in coords_map: entry["coords"] = coords_map[token] out.append(entry) return out ``` ### 5.3 `mcp-server/server.py` - `match_pid_tags` 개선 **현재:** ```python _MIN_PREFIX_LEN = 4 # prefix 매칭 최소 길이 ``` **수정 후:** ```python _MIN_PREFIX_LEN = 3 # P-10101 같은 단축 prefix도 매칭 가능 # 추가 매칭 전략: 숫자 기반 매칭 def _match_by_number(pid_norm, ex_index): """P-10101 <-> p-10101.pv -> 숫자(10101) + prefix(P)가 모두 일치하면 매칭.""" m = re.match(r'^([a-z]+)-(\d+)$', pid_norm) if not m: return None, 0.0 pid_prefix, pid_num = m.group(1), m.group(2) for ex_norm, ex_orig in ex_index.items(): em = re.match(r'^([a-z]+)-(\d+)', ex_norm) if em and em.group(1) == pid_prefix and em.group(2) == pid_num: return ex_orig, 0.95 return None, 0.0 ``` ### 5.4 `PidExtractorService.cs` - 재작성 **현재 `ExtractFromStreamAsync` (36-134 라인):** ```csharp public async Task ExtractFromStreamAsync(Stream stream, string fileName, bool useImageMode = false) { var ext = Path.GetExtension(fileName).ToLowerInvariant(); string text; Dictionary? coords = null; if (ext == ".dxf") (text, coords) = ExtractDxfText(stream); else if (ext == ".pdf") text = ExtractPdfText(stream); // ... ``` **수정 후:** ```csharp public async Task ExtractFromStreamAsync(Stream stream, string fileName, bool useImageMode = false) { var ext = Path.GetExtension(fileName).ToLowerInvariant(); List extractedItems; if (ext == ".dxf") { var tmp = Path.GetTempFileName() + ".dxf"; try { await using var fs = File.Create(tmp); await stream.CopyToAsync(fs); extractedItems = await ParseDxfViaMcpAsync(tmp); } finally { if (File.Exists(tmp)) File.Delete(tmp); } } else if (ext == ".pdf") { var text = ExtractPdfText(stream); if (string.IsNullOrWhiteSpace(text)) return new PidExtractionResult(0, 0, 0); var json = await _mcp.ExtractPidTagsAsync(text, "pdf"); extractedItems = ParseJson(json); } else { throw new NotSupportedException("지원 형식: .dxf .pdf"); } if (extractedItems.Count == 0) { _logger.LogWarning("P&ID 추출 결과 0건 - 파일: {FileName}", fileName); return new PidExtractionResult(0, 0, 0); } // 이후 로직 (매핑, 중복 체크, DB 저장) 동일 } ``` **새로 추가할 메서드:** ```csharp private async Task> ParseDxfViaMcpAsync(string filePath) { try { var json = await _mcp.ParsePidDxfAsync(filePath); using var doc = JsonDocument.Parse(json); var root = doc.RootElement; var items = new List(); if (root.TryGetProperty("tags", out var tagsEl) && tagsEl.ValueKind == JsonValueKind.Array) { foreach (var tag in tagsEl.EnumerateArray()) { var item = new ExtractedItem { TagNo = tag.TryGetProperty("tagNo", out var tn) ? tn.GetString() ?? "" : "", InstrumentType = tag.TryGetProperty("prefix", out var p) ? p.GetString() : null, LineNumber = tag.TryGetProperty("lineNumber", out var ln) ? ln.GetString() : null, Confidence = tag.TryGetProperty("confidence", out var c) ? c.GetDouble() : 0.5, }; if (tag.TryGetProperty("coords", out var coordsEl)) { item.PosX = coordsEl.TryGetProperty("x", out var cx) ? cx.GetDouble() : null; item.PosY = coordsEl.TryGetProperty("y", out var cy) ? cy.GetDouble() : null; } items.Add(item); } } _logger.LogInformation("[PID] MCP 파싱 완료: {Count}건 (파일: {File})", items.Count, Path.GetFileName(filePath)); return items; } catch (Exception ex) { _logger.LogWarning(ex, "[PID] MCP 파싱 실패 - fallback 사용 (파일: {File})", filePath); return await FallbackParseDxfAsync(filePath); } } private async Task> FallbackParseDxfAsync(string filePath) { var result = new List(); try { var escapedPath = filePath.Replace("'", "'\"'\"'"); var psi = new ProcessStartInfo { FileName = "python3", Arguments = $"-c \"import sys,json,asyncio; sys.path.insert(0,'mcp-server'); " + $"from server import _extract_pid_dxf_fast; " + $"d=asyncio.run(_extract_pid_dxf_fast('{escapedPath}')); " + $"print(json.dumps(d))\"", RedirectStandardOutput = true, UseShellExecute = false, CreateNoWindow = true, }; using var proc = Process.Start(psi); if (proc != null) { var output = await proc.StandardOutput.ReadToEndAsync(); await proc.WaitForExitAsync(); if (!string.IsNullOrWhiteSpace(output)) { using var doc = JsonDocument.Parse(output); if (doc.RootElement.TryGetProperty("tags", out var tagsEl) && tagsEl.ValueKind == JsonValueKind.Array) { foreach (var tag in tagsEl.EnumerateArray()) { result.Add(new ExtractedItem { TagNo = tag.TryGetProperty("tagNo", out var tn) ? tn.GetString() ?? "" : "", InstrumentType = tag.TryGetProperty("prefix", out var p) ? p.GetString() : null, Confidence = tag.TryGetProperty("confidence", out var c) ? c.GetDouble() : 0.5, }); } } } } } catch (Exception ex) { _logger.LogError(ex, "[PID] Fallback 파싱도 실패"); } return result; } ``` **제거할 메서드:** - `ExtractDxfText` (136-234 라인) - 전체 제거 - `FilterDxfText` (240-260 라인) - 전체 제거 - `ReconstructBalloonTags` (277-308 라인) - 전체 제거 - 관련 regex: `_instrFuncRe`, `_loopNumRe` - 제거 ### 5.5 `ExtractedItem` 모델 확장 **현재:** ```csharp 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; } ``` **수정 후:** ```csharp 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 double? PosX { get; set; } public double? PosY { get; set; } } ``` ### 5.6 `ExperionCrawler.csproj` - netDxf 제거 **현재:** ```xml ``` **수정 후:** ```xml ``` ### 5.7 DB 저장 시 좌표 할당 수정 **현재 (108-112 라인):** ```csharp if (coords != null && coords.TryGetValue(item.TagNo, out var c)) { newItem.PosX = c.X; newItem.PosY = c.Y; } ``` **수정 후:** ```csharp newItem.PosX = item.PosX; newItem.PosY = item.PosY; ``` --- ## 6. Python 의존성 추가 `mcp-server/pyproject.toml` 또는 `requirements.txt`에: ``` rtree>=1.0.0 # Balloon 재조합 공간 인덱싱 (optional, 없으면 선형 검색 fallback) ``` --- ## 7. 마이그레이션 계획 ### 단계별 롤아웃 | 단계 | 작업 | 리스크 | 롤백 | |------|------|--------|------| | 1 | Python `_extract_pid_dxf_fast` 확장 (좌표 포함) | 낮음 - 기존 API 호환 | git revert | | 2 | Python Balloon 재조합 + R-tree | 낮음 - optional dependency | rtree 없이 fallback | | 3 | C# `ExtractFromStreamAsync` 재작성 (MCP 호출) | 중간 - MCP 의존성 | 기존 코드 복원 | | 4 | netDxf 제거 | 낮음 - Phase 3 완료 후 | 패키지 재추가 | | 5 | Fallback 구현 | 낮음 - 안전망 | - | | 6 | Backup 정리, 테스트 | 없음 | - | ### 호환성 - `parse_pid_dxf` 반환 형식에 `coords` 필드 추가는 **backward compatible** (기존 호출자는 coords 무시) - `extract_pid_tags`는 변경 없음 (PDF 경로에서 사용) - C# `ExtractedItem`에 `PosX`/`PosY` 추가는 **nullable** - 기존 코드와 호환 --- ## 8. 성능 기대 효과 | 지표 | 현재 | 개선 후 | 개선율 | |------|------|---------|--------| | DXF 파싱 횟수 | 2회 (C# + Python) | 1회 (Python) | 50% 감소 | | Balloon 재조합 | O(n^2) | O(n log n) | 1000 텍스트 기준 ~100x | | 디스크 I/O | temp 파일 읽기/쓰기 | temp 파일 유지 (MCP filepath 인자 필요) | 변경 없음 ⚠️ | | NuGet 의존성 | netDxf + PdfPig | PdfPig only | 1개 감소 | | MCP 장애 시 | 전체 실패 | fallback 동작 | - | --- ## 9. 검증 체크리스트 - [ ] `dotnet build src/Web/ExperionCrawler.csproj` 성공 - [ ] `dotnet test` 성공 - [ ] 샘플 DXF (`P10-EQP-BLOCK.dxf`) 추출 시 좌표가 DB 에 저장됨 - [ ] MCP 서버 중지 상태에서 추출 시 fallback 동작 - [ ] PDF 추출 경로에 영향 없음 - [ ] 기존 DB 데이터에 영향 없음 (기존 좌표 유지) - [ ] `parse_pid_dxf` MCP tool 직접 호출 시 coords 반환 - [ ] Balloon 재조합 결과 검증 (TE-9101 같은 분리 태그가 재조합됨) --- ## 10. 실행용 프롬프트 (다른 LLM에게 전달) > 아래 프롬프트를 다른 LLM에게 그대로 전달하면 된다. 코드베이스의 현재 상태, 수정해야 할 정확한 위치, 주의사항이 모두 포함되어 있다. --- ### 프롬프트 시작 당신은 C# (.NET 8)과 Python에 능숙한 시니어 개발자다. 아래 DXF 추출 로직 개선안을 **정확히** 구현하라. --- ### 0. 코드베이스 구조 ``` src/ ├── Core/Application/Services/PidExtractorService.cs — 핵심 수정 대상 (1067줄) ├── Infrastructure/Mcp/McpClient.cs — MCP 호출 클라이언트 (256줄, 수정 X) └── Web/ExperionCrawler.csproj — netDxf 제거 대상 mcp-server/ └── server.py — Python 파싱 확장 대상 (2235줄) ``` 단일 프로젝트: `src/Web/ExperionCrawler.csproj`. Core/Infrastructure는 `` glob로 포함. --- ### 1. Phase 1: Python `server.py` 수정 (가장 중요) **파일:** `mcp-server/server.py` #### 1.1 `_extract_pid_dxf_fast` 함수 확장 (라인 296-366) **현재 동작:** DXF에서 TEXT/MTEXT를 추출하여 태그 분류. 좌표 정보 없음. **수정해야 할 점:** 1. 각 tag에 `coords: {"x": float, "y": float, "h": float}` 필드 추가 2. Circle 중심 좌표 매핑 로직 추가 (C# `PidExtractorService.cs:167-188`의 `ExtractDxfText` 내 circleCoords 로직을 Python으로 포팅) 3. Block INSERT 엔티티의 `virtual_entities()`도 처리 (C# `PidExtractorService.cs:162-164`의 Block AttributeDefinitions 처리에 해당) **Circle 매핑 알고리즘 (C# 원본 참고):** ``` foreach TEXT entity: bestR = infinity for each CIRCLE: d = distance(TEXT.pos, CIRCLE.center) if d < CIRCLE.radius AND CIRCLE.radius < bestR: bestR = CIRCLE.radius mapped_coords = CIRCLE.center if bestR < infinity: circle_coords[TEXT.value] = mapped_coords ``` **Balloon 재조합 함수 추가:** 새 함수 `_reconstruct_balloons_rtree(texts, circle_coords)`를 `_extract_pid_dxf_fast` 앞에 정의하라. - C# `PidExtractorService.cs:277-308`의 `ReconstructBalloonTags`를 Python으로 포팅 - regex 패턴: - `_instr_func_re = r'^[FPLTASHQWVXZBDCRK][ICTREVYSAQGZ]{1,3}$'` (ISA 기능코드) - `_loop_num_re = r'^\d{3,6}[A-Z]?$'` (루프번호) - R-tree (`rtree` 패키지)를 사용하되, **없으면 fallback으로 선형 검색**. `ImportError`를 catch하여 graceful degradation. - 임계값: `threshold = h * 5.0 if h > 0 else 12.0` - 반환: `list[tuple[str, float, float, float]]` — `(tag, x, y, h)` **반환 형식 변경:** ```python # 기존 tags.append({"tagNo": txt, **cls, "layer": layer}) # 변경 tags.append({ "tagNo": txt, **cls, "layer": layer, "coords": {"x": final_x, "y": final_y, "h": h}, }) ``` **주의:** 기존 `parse_pid_dxf` tool(라인 1702-1724)은 `_extract_pid_dxf_fast`의 결과를 `{"success": True, **data}`로 감싸서 반환한다. 반환 형식에 `coords`를 추가하는 것은 **backward compatible** — 기존 호출자는 coords를 무시한다. #### 1.2 `match_pid_tags` 개선 (라인 1622-1696) **수정 1:** `_MIN_PREFIX_LEN = 4` → `_MIN_PREFIX_LEN = 3` **수정 2:** prefix 매칭(전략 2)에 숫자 매칭 조건 추가. 현재 prefix 매칭은 `n.startswith(pid_norm + ".")`로 동작한다. `P-101` 같은 짧은 prefix가 `P-101`, `P-1010`, `P-10101` 모두와 매칭되는 문제가 있다. 숫자 부분도 일치하는지 확인하라: ```python # 전략 2 개선: prefix + 숫자 동시 매칭 import re m = re.match(r'^([a-z]+)-(\d+)$', pid_norm) if m: pid_prefix, pid_num = m.group(1), m.group(2) hit = next( (n for n in ex_norms if re.match(rf'^{re.escape(pid_prefix)}-{re.escape(pid_num)}(\.|-|$)', n)), None, ) if hit: mappings.append({"pidTag": pid, "experionTag": ex_index[hit], "confidence": 0.95}) continue ``` --- ### 2. Phase 2: C# `PidExtractorService.cs` 수정 **파일:** `src/Core/Application/Services/PidExtractorService.cs` #### 2.1 `ExtractedItem` 모델에 좌표 필드 추가 (라인 1052-1060) ```csharp 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 double? PosX { get; set; } // ← 추가 public double? PosY { get; set; } // ← 추가 } ``` #### 2.2 `ExtractFromStreamAsync` 재작성 (라인 36-134) **핵심 변경:** DXF 경로에서 C# netDxf 파싱을 제거하고 MCP `parse_pid_dxf` 호출로 대체. ``` 기존 흐름: DXF → ExtractDxfText(netDxf) → text + coords → MCP ExtractPidTagsAsync(text) → tags → 매핑 → DB 저장 변경 후 흐름: DXF → Stream → temp 파일 → MCP ParsePidDxfAsync(filepath) → tags (coords 포함) → 매핑 → DB 저장 ``` **구체적인 구현:** 1. `.dxf`인 경우: - Stream을 temp 파일로 저장 (`Path.GetTempFileName() + ".dxf"`) - `ParseDxfViaMcpAsync(tmp)` 호출 (새 메서드, 아래 참조) - finally에서 temp 파일 삭제 2. `.pdf`인 경우: **변경 없음**. 기존 `ExtractPdfText` + `McpClient.ExtractPidTagsAsync` 유지 3. `ParseDxfViaMcpAsync` 새 메서드: - `_mcp.ParsePidDxfAsync(filePath)` 호출 - JSON 응답에서 `tags` 배열 파싱 - 각 tag의 `coords.x`, `coords.y`를 `ExtractedItem.PosX`, `PosY`에 할당 - 실패 시 `FallbackParseDxfAsync` 호출 (Phase 3) 4. DB 저장 시 좌표 할당 (라인 108-112): ```csharp // 기존 if (coords != null && coords.TryGetValue(item.TagNo, out var c)) { newItem.PosX = c.X; newItem.PosY = c.Y; } // 변경 newItem.PosX = item.PosX; newItem.PosY = item.PosY; ``` #### 2.3 제거할 메서드 다음 메서드를 **전체 삭제**하라: - `ExtractDxfText` (라인 136-234) - `FilterDxfText` (라인 240-260) - `ReconstructBalloonTags` (라인 277-308) - 관련 regex: `_instrFuncRe`, `_loopNumRe` (라인 265-270) #### 2.4 제거할 using 라인 10: `using netDxf;` 삭제 #### 2.5 `ParseDxfViaMcpAsync` 구현 시 주의 `McpClient.ParsePidDxfAsync`는 `parse_pid_dxf` MCP tool을 호출한다. 반환 JSON: ```json { "success": true, "fluid_dictionary": {...}, "linenos": [...], "tags": [ { "tagNo": "P-10101", "kind": "equipment", "prefix": "P", "coords": {"x": 1234.5, "y": 5678.9, "h": 2.5}, "layer": "INSTRUMENT" } ], "stats": {...} } ``` `tags` 배열만 파싱하면 된다. `linenos`는 별도 처리 필요 없음 (기존 코드에서도 처리하지 않음). #### 2.6 `FallbackParseDxfAsync` 구현 (Phase 3) MCP 연결 실패 시 Python subprocess로 직접 호출: ```csharp private async Task> FallbackParseDxfAsync(string filePath) { // python3 -c "..." 로 _extract_pid_dxf_fast 직접 호출 // StandardOutput에서 JSON 읽어서 파싱 } ``` **주의:** subprocess 호출 시 파일 경로에 공백이 있을 수 있으므로 적절히 이스케이프하라. `sys.path.insert(0, 'mcp-server')`로 server.py를 import 가능하게 하라. --- ### 3. Phase 3: `ExperionCrawler.csproj` 수정 **파일:** `src/Web/ExperionCrawler.csproj` 라인 32: `` 삭제 --- ### 4. Phase 4: Python 의존성 `mcp-server/requirements.txt` 또는 `pyproject.toml`에 `rtree>=1.0.0` 추가 (optional dependency). --- ### 5. 검증 순서 1. `dotnet build src/Web/ExperionCrawler.csproj` — 컴파일 성공 2. `dotnet test` — 테스트 통과 3. 샘플 DXF 파일로 추출 테스트: - `uploads/pid/P10-EQP-BLOCK.dxf` 존재 여부 확인 - 추출 결과에 `PosX`, `PosY`가 저장되는지 DB 확인 4. MCP 서버 중지 상태에서 DXF 추출 시 fallback 동작 확인 5. PDF 추출 경로에 영향 없는지 확인 --- ### 6. 절대 해서는 안 되는 것 1. **`McpClient.cs` 수정 금지** — 기존 `ParsePidDxfAsync` 메서드(라인 168-169)가 이미 존재하므로 그대로 사용 2. **PDF 경로 변경 금지** — `ExtractPdfText` + `ExtractPidTagsAsync` 흐름은 그대로 유지 3. **DB 스키마 변경 금지** — `PidEquipment` 테이블에 이미 `pos_x`, `pos_y` 컬럼이 존재 4. **`ParseJson` 메서드 구조 변경 금지** — `ParseJson`은 `coords` 중첩 구조를 처리하지 못하므로 `ParseDxfViaMcpAsync`에서 수동 매핑(`coords.x` → `PosX`) 필요 5. **`using netDxf;` 제거 후 netDxf 타입 사용 금지** — `ExtractDxfText` 전체 삭제해야 함 6. **`_extract_pid_dxf_fast`의 기존 분류 로직 변경 금지** — 좌표 추가만 하고, `_PID_LINENO_FULL_RE`, `_PID_TAG_RE` 등 기존 regex 분류는 그대로 유지 ### 6b. 반드시 처리해야 할 것 (재진단에서 발견) 1. **Block INSERT 처리 필수** — C# `ExtractDxfText`(라인 162-164)는 Block AttributeDefinitions를 추출하나, Python `_extract_pid_dxf_fast`는 `msp.query('TEXT MTEXT')`만 처리. `INSERT` 엔티티의 `virtual_entities()`를 통해 블록 내부 TEXT도 추출해야 함. `pid_tracer.py:51`의 구현 참고. 2. **regex 불일치 검증** — C# `FilterDxfText`의 `[A-Z]{1,6}-\d{2,6}(-[A-Z0-9]+)*`는 Python `_PID_TAG_RE`의 `^([A-Z]{1,4})-(\d{3,6})([A-Z])?$`보다 관대함. 5~6글자 prefix(`FICQA-101`), 2자리 숫자(`P-10`), 복합 접미사(`PSV-10101-2A`)가 누락될 수 있음. Python 측 regex를 확장하거나 별도 fallback regex 추가 필요. 3. **Fallback 호출 안전성** — `FallbackParseDxfAsync`에서 `ProcessStartInfo.Arguments`에 파일 경로를 직접 삽입하는 것은 Python 코드 인젝션 위험이 있음. `UseShellExecute = false`이므로 shell injection은 아니지만, 파일 경로에 `'__import__("os").system("rm -rf /")#'` 같은 값이 들어갈 수 있음. 별도 Python 스크립트 파일을 실행하거나, `sys.argv`를 통해 경로를 전달하는 방식으로 변경. 4. **Temp 파일 I/O 불가피성 명시** — MCP `parse_pid_dxf`가 `filepath: str`을 인자로 받으므로 temp 파일 사용은 불가피. 개선 목표에서 "디스크 I/O 100% 감소"는 달성 불가. --- ### 7. 작업 순서 권장 1. 먼저 Python 측 (`server.py`)을 수정하고 테스트 2. 그 다음 C# 측 (`PidExtractorService.cs`)을 수정 3. 마지막으로 `csproj`에서 netDxf 제거 4. 빌드 → 테스트 → 통합 검증 Python 측을 먼저 수정하는 이유: C# 수정 후 MCP 호출 시 Python 측이 새로운 형식(coords 포함)으로 반환해야 하므로, Python이 먼저 준비되어 있어야 통합 테스트가 가능하다. ### 프롬프트 종료